1use std::collections::BTreeMap;
26use std::path::PathBuf;
27use std::time::Duration;
28
29use rpi_ai::ThinkingLevel;
30
31pub(crate) const RPI_OFFLINE_ENV: &str = "RPI_OFFLINE";
35
36pub(crate) fn is_truthy_env_flag(value: Option<&str>) -> bool {
40 value.is_some_and(|value| {
41 value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes")
42 })
43}
44
45pub(crate) fn offline_env_enabled() -> bool {
46 is_truthy_env_flag(std::env::var(RPI_OFFLINE_ENV).ok().as_deref())
47}
48
49pub(crate) fn offline_mode_enabled(cli_offline: bool) -> bool {
50 cli_offline || offline_env_enabled()
51}
52
53pub(crate) fn normalize_offline_mode(args: &[String]) -> bool {
57 let enabled = offline_mode_enabled(args.iter().any(|arg| arg == "--offline"));
58 if enabled {
59 std::env::set_var(RPI_OFFLINE_ENV, "1");
60 }
61 enabled
62}
63
64pub(crate) fn without_offline_flag(args: &[String]) -> Vec<String> {
67 args.iter()
68 .filter(|arg| arg.as_str() != "--offline")
69 .cloned()
70 .collect()
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
75pub enum Mode {
76 #[default]
77 Text,
78 Json,
79 Rpc,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
85pub enum RpcMessageUpdates {
86 #[default]
87 CompactDeltas,
88 Snapshot,
89}
90
91impl RpcMessageUpdates {
92 pub const fn as_str(self) -> &'static str {
93 match self {
94 Self::CompactDeltas => "compact-deltas",
95 Self::Snapshot => "snapshot",
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum TuiMode {
104 Fullscreen,
105 Regular,
106}
107
108const fn default_tui_mode_for(is_macos: bool) -> TuiMode {
109 if is_macos {
110 TuiMode::Regular
111 } else {
112 TuiMode::Fullscreen
113 }
114}
115
116impl Default for TuiMode {
117 fn default() -> Self {
118 default_tui_mode_for(cfg!(target_os = "macos"))
119 }
120}
121
122#[derive(Debug, Clone, Default)]
125pub struct Args {
126 pub provider: Option<String>,
127 pub model: Option<String>,
128 pub api_key: Option<String>,
129 pub base_url: Option<String>,
132 pub timeout: Option<Duration>,
134 pub stream_idle_timeout: Option<Duration>,
136 pub resource_config: Option<PathBuf>,
137 pub resource_transition: Option<PathBuf>,
138 pub system_prompt: Option<String>,
139 pub append_system_prompt: Vec<String>,
140 pub theme: Option<String>,
142 pub thinking: Option<ThinkingLevel>,
143
144 pub print: bool,
145 pub mode: Mode,
146 pub rpc_message_updates: RpcMessageUpdates,
147 pub tui_mode: TuiMode,
151
152 pub list_models: Option<String>,
155 pub offline: bool,
157 pub export: Option<PathBuf>,
160 pub trust_override: Option<bool>,
163
164 pub continue_session: bool,
165 pub resume: bool,
166 pub session: Option<String>,
167 pub session_id: Option<String>,
171 pub fork: Option<String>,
174 pub models: Option<Vec<String>>,
178 pub session_dir: Option<PathBuf>,
179 pub experimental_native_store: Option<PathBuf>,
181 pub no_session: bool,
182 pub name: Option<String>,
183
184 pub tools: Option<Vec<String>>,
185 pub exclude_tools: Option<Vec<String>>,
186 pub no_tools: bool,
187 pub no_builtin_tools: bool,
188
189 pub no_skills: bool,
192 pub no_prompt_templates: bool,
195 pub no_context_files: bool,
198 pub no_extensions: bool,
201 pub no_themes: bool,
204 pub extensions_dir: Vec<PathBuf>,
212 pub extension: Vec<PathBuf>,
215 pub skill: Vec<PathBuf>,
217 pub prompt_template: Vec<PathBuf>,
220
221 pub dev_local_only: bool,
223 pub project_only: bool,
226
227 pub verbose: bool,
228 pub help: bool,
229 pub version: bool,
230
231 pub debug_system_prompt: bool,
238
239 pub messages: Vec<String>,
241 pub file_args: Vec<PathBuf>,
244
245 pub unknown_flags: BTreeMap<String, serde_json::Value>,
249 pub ignored: Vec<String>,
252 pub errors: Vec<String>,
255}
256
257pub use rpi_runtime::thinking::VALID_THINKING_LEVELS;
260
261pub use rpi_runtime::thinking::parse_thinking_level;
263
264fn file_arg(arg: &str) -> Option<PathBuf> {
267 if let Some(rest) = arg.strip_prefix('@') {
268 if rest.is_empty() {
270 None
271 } else {
272 Some(PathBuf::from(rest))
273 }
274 } else {
275 None
276 }
277}
278
279pub fn parse_args(args: &[String]) -> Args {
285 let mut result = Args::default();
286 result.offline = offline_env_enabled();
287 let mut env_extension_dirs = Vec::new();
291 if let Ok(raw) = std::env::var("RPI_EXTENSIONS_DIR") {
292 if !raw.is_empty() {
293 let sep = if cfg!(windows) { ';' } else { ':' };
294 for part in raw.split(sep) {
295 let trimmed = part.trim();
296 if !trimmed.is_empty() {
297 env_extension_dirs.push(PathBuf::from(trimmed));
298 }
299 }
300 }
301 }
302 let mut i = 0;
303 while i < args.len() {
304 let arg = args[i].clone();
305 let (flag_key, inline) = if arg.starts_with("--") {
309 match arg.find('=') {
310 Some(eq) => (arg[..eq].to_string(), Some(arg[eq + 1..].to_string())),
311 None => (arg.clone(), None),
312 }
313 } else {
314 (arg.clone(), None)
315 };
316
317 let mut take_value = |result: &mut Args, _flag: &str| -> Option<String> {
321 if let Some(v) = inline.clone() {
322 return Some(v);
323 }
324 if i + 1 < args.len() {
325 let next = &args[i + 1];
326 if !next.starts_with('-') || next == "-" {
327 i += 1;
328 return Some(args[i].clone());
329 }
330 }
331 result.errors.push(format!("{flag_key} requires a value"));
332 None
333 };
334
335 match flag_key.as_str() {
336 "--help" | "-h" => result.help = true,
337 "--version" | "-v" => result.version = true,
338 "--print" | "-p" => {
339 result.print = true;
340 if i + 1 < args.len() {
345 let next = &args[i + 1];
346 if !next.starts_with('@') && !next.starts_with('-') {
347 i += 1;
348 result.messages.push(args[i].clone());
349 }
350 }
351 }
352 "--mode" => {
353 if let Some(v) = take_value(&mut result, "--mode") {
354 result.mode = match v.as_str() {
355 "text" => Mode::Text,
356 "json" => Mode::Json,
357 "rpc" => Mode::Rpc,
358 other => {
359 result.errors.push(format!(
360 "Invalid --mode \"{other}\". Valid: text, json, rpc"
361 ));
362 Mode::Text
363 }
364 };
365 }
366 }
367 "--rpc-message-updates" => {
368 if let Some(value) = take_value(&mut result, "--rpc-message-updates") {
369 match value.as_str() {
370 "snapshot" => result.rpc_message_updates = RpcMessageUpdates::Snapshot,
371 "compact-deltas" => result.rpc_message_updates = RpcMessageUpdates::CompactDeltas,
372 other => result.errors.push(format!(
373 "Invalid --rpc-message-updates \"{other}\". Valid: snapshot, compact-deltas"
374 )),
375 }
376 }
377 }
378 "--tui-mode" => {
379 if let Some(v) = take_value(&mut result, "--tui-mode") {
380 result.tui_mode = match v.to_ascii_lowercase().as_str() {
381 "regular" => TuiMode::Regular,
382 "fullscreen" => TuiMode::Fullscreen,
383 other => {
384 result.errors.push(format!(
385 "Invalid --tui-mode \"{other}\". Valid: regular, fullscreen"
386 ));
387 TuiMode::Fullscreen
388 }
389 };
390 }
391 }
392 "--continue" | "-c" => result.continue_session = true,
393 "--resume" | "-r" => result.resume = true,
394 "--no-session" => result.no_session = true,
395 "--no-tools" | "-nt" => result.no_tools = true,
396 "--no-builtin-tools" | "-nbt" => result.no_builtin_tools = true,
397 "--no-skills" | "-ns" => result.no_skills = true,
398 "--no-prompt-templates" | "-np" => result.no_prompt_templates = true,
399 "--no-context-files" | "-nc" => result.no_context_files = true,
400 "--no-extensions" | "-ne" => result.no_extensions = true,
401 "--local-only" => result.dev_local_only = true,
402 "--package-only" | "--project-only" => result.project_only = true,
403 "--extensions-dir" | "-ed" => {
404 if let Some(v) = take_value(&mut result, &flag_key) {
405 result.extensions_dir.push(PathBuf::from(v));
406 }
407 }
408 "--verbose" => result.verbose = true,
409 "--debug-system-prompt" => result.debug_system_prompt = true,
410 "--provider" => result.provider = take_value(&mut result, "--provider"),
411 "--model" => result.model = take_value(&mut result, "--model"),
412 "--api-key" => result.api_key = take_value(&mut result, "--api-key"),
413 "--base-url" => result.base_url = take_value(&mut result, "--base-url"),
414 "--timeout" => {
415 if let Some(v) = take_value(&mut result, "--timeout") {
416 match v.parse::<u64>() {
417 Ok(seconds) if seconds > 0 => {
418 result.timeout = Some(Duration::from_secs(seconds));
419 }
420 _ => result.errors.push(format!(
421 "Invalid --timeout \"{v}\". Expected a positive integer number of seconds"
422 )),
423 }
424 }
425 }
426 "--stream-idle-timeout" => {
427 if let Some(v) = take_value(&mut result, "--stream-idle-timeout") {
428 match v.parse::<u64>() {
429 Ok(seconds) => result.stream_idle_timeout = Some(Duration::from_secs(seconds)),
430 Err(_) => result.errors.push(format!(
431 "Invalid --stream-idle-timeout \"{v}\". Expected a non-negative integer number of seconds (0 disables it)"
432 )),
433 }
434 }
435 }
436 "--resource-config" => {
437 result.resource_config =
438 take_value(&mut result, "--resource-config").map(PathBuf::from)
439 }
440 "--resource-transition" => {
441 result.resource_transition =
442 take_value(&mut result, "--resource-transition").map(PathBuf::from)
443 }
444 "--system-prompt" => result.system_prompt = take_value(&mut result, "--system-prompt"),
445 "--append-system-prompt" => {
446 if let Some(v) = take_value(&mut result, "--append-system-prompt") {
447 result.append_system_prompt.push(v);
448 }
449 }
450 "--name" | "-n" => result.name = take_value(&mut result, "--name"),
451 "--session" => result.session = take_value(&mut result, "--session"),
452 "--session-id" => result.session_id = take_value(&mut result, "--session-id"),
453 "--fork" => result.fork = take_value(&mut result, "--fork"),
454 "--models" => {
455 if let Some(v) = take_value(&mut result, &flag_key) {
456 result.models = Some(split_csv(&v));
457 }
458 }
459 "--extension" | "-e" => {
460 if let Some(v) = take_value(&mut result, &flag_key) {
461 result.extension.push(PathBuf::from(v));
462 }
463 }
464 "--skill" => {
465 if let Some(v) = take_value(&mut result, &flag_key) {
466 result.skill.push(PathBuf::from(v));
467 }
468 }
469 "--prompt-template" => {
470 if let Some(v) = take_value(&mut result, &flag_key) {
471 result.prompt_template.push(PathBuf::from(v));
472 }
473 }
474 "--experimental-native-store" => {
475 if let Some(value) = take_value(&mut result, &flag_key) {
476 if result.experimental_native_store.is_some() {
477 result
478 .errors
479 .push("--experimental-native-store may be supplied only once".into());
480 } else {
481 result.experimental_native_store = Some(PathBuf::from(value));
482 }
483 }
484 }
485 "--session-dir" => {
486 if let Some(v) = take_value(&mut result, "--session-dir") {
487 result.session_dir = Some(PathBuf::from(v));
488 }
489 }
490 "--thinking" => {
491 if let Some(v) = take_value(&mut result, "--thinking") {
492 match parse_thinking_level(&v) {
493 Some(lvl) => result.thinking = Some(lvl),
494 None => result.ignored.push(format!(
495 "Invalid --thinking \"{v}\". Valid: {}",
496 VALID_THINKING_LEVELS.join(", ")
497 )),
498 }
499 }
500 }
501 "--tools" | "-t" => {
502 if let Some(v) = take_value(&mut result, &flag_key) {
503 result.tools = Some(split_csv(&v));
504 }
505 }
506 "--exclude-tools" | "-xt" => {
507 if let Some(v) = take_value(&mut result, &flag_key) {
508 result.exclude_tools = Some(split_csv(&v));
509 }
510 }
511 "--list-models" => {
512 let mut search = inline.clone().unwrap_or_default();
515 if inline.is_none()
516 && i + 1 < args.len()
517 && !args[i + 1].starts_with('-')
518 && !args[i + 1].starts_with('@')
519 {
520 i += 1;
521 search = args[i].clone();
522 }
523 result.list_models = Some(search);
524 }
525 "--offline" => result.offline = true,
526 "--export" => {
527 if let Some(value) = take_value(&mut result, &flag_key) {
528 result.export = Some(PathBuf::from(value));
529 }
530 }
531 "--approve" | "-a" => result.trust_override = Some(true),
532 "--no-approve" | "-na" => result.trust_override = Some(false),
533 other if matches!(other, "--models") => {
544 if inline.is_none()
547 && i + 1 < args.len()
548 && !args[i + 1].starts_with('-')
549 && !args[i + 1].starts_with('@')
550 {
551 i += 1;
552 }
553 result
554 .ignored
555 .push(format!("{other} is not supported in v1 (ignored)"));
556 }
557 "--theme" => {
558 result.theme = take_value(&mut result, "--theme");
559 }
560 "--no-themes" => result.no_themes = true,
561 other if other.starts_with("--") => {
566 let name = &flag_key;
567 let value = if let Some(value) = inline {
568 serde_json::Value::String(value)
569 } else if i + 1 < args.len()
570 && !args[i + 1].starts_with('-')
571 && !args[i + 1].starts_with('@')
572 {
573 i += 1;
574 serde_json::Value::String(args[i].clone())
575 } else {
576 serde_json::Value::Bool(true)
577 };
578 result.unknown_flags.insert(name[2..].to_string(), value);
579 }
580 other if other.starts_with('-') && other.len() > 1 => {
582 result.errors.push(format!("Unknown option: {other}"));
583 }
584 other => {
585 if let Some(path) = file_arg(other) {
586 result.file_args.push(path);
587 } else {
588 result.messages.push(other.to_string());
589 }
590 }
591 }
592 i += 1;
593 }
594
595 if result.resource_config.is_none() && result.resource_transition.is_none() {
597 env_extension_dirs.append(&mut result.extensions_dir);
598 result.extensions_dir = env_extension_dirs;
599 }
600 if let Err(error) = crate::native_store::validate(&result) {
604 result.errors.push(error);
605 }
606 result
607}
608
609fn split_csv(v: &str) -> Vec<String> {
611 v.split(',')
612 .map(|s| s.trim().to_string())
613 .filter(|s| !s.is_empty())
614 .collect()
615}
616
617pub fn resolve_mode(parsed: &Args, stdin_is_tty: bool, stdout_is_tty: bool) -> RunMode {
622 if parsed.unknown_flags.contains_key("server") {
624 return RunMode::Rpc;
625 }
626 if parsed.mode == Mode::Rpc {
627 return RunMode::Rpc;
628 }
629 if parsed.mode == Mode::Json {
630 return RunMode::Json;
631 }
632 if parsed.print || !stdin_is_tty || !stdout_is_tty {
633 RunMode::Print
634 } else {
635 RunMode::Interactive
636 }
637}
638
639#[derive(Debug, Clone, Copy, PartialEq, Eq)]
644pub enum RunMode {
645 Interactive,
646 Print,
647 Json,
648 Rpc,
649}
650
651pub fn print_help() {
653 let builtin = "read, bash, edit, write, docs";
654 println!(
655 "{name} - AI coding assistant with read, bash, edit, write, docs tools
656
657{u}Usage:{r}
658 {name} [options] [@files...] [messages...]
659
660{u}Options:{r}
661 --provider <name> Provider name (built-in, installed package, or models.json id)
662 --model <pattern> Model pattern or ID (supports \"provider/id\" and optional \":<thinking>\")
663 --api-key <key> API key override for the selected provider
664 --base-url <url> Override the selected model endpoint
665 --timeout <seconds> LLM API request timeout (default: 600)
666 --stream-idle-timeout <seconds> Max silence between response bytes (default: 300; 0 disables)
667 --resource-capabilities Pure bounded JSON resource protocol profile (use alone; no startup)
668 --resource-session-read <file> Strict bounded nonexecuting saved-session read (standalone)
669 --resource-transition <file> Explicit same-session resource CAS (RPC only; see docs/resource-transition-rpc.md)
670 --resource-config <file> Exact versioned startup resources (RPC only; see docs/resource-selection-rpc.md)
671 --system-prompt <text> Replace the default system prompt
672 --append-system-prompt <text> Append text to the system prompt (repeatable)
673 --thinking <level> off, minimal, low, medium, high, xhigh, max
674 --mode <mode> Output mode: text (default), json, or rpc
675 --rpc-message-updates <mode> RPC updates: compact-deltas (default) or snapshot (legacy)
676 --server [--port <n>] [--bind <ip>]
677 Headless server: serve a remote-connectable agent (no TUI)
678 --connect <host:port> Remote client: connect to a `--server` instance and run the TUI
679 (no local provider/tools/session; all agent work is remote)
680 --token <token> Auth token for `--connect` (printed by `--server`; may also
681 be provided via the RPI_SERVER_TOKEN env var)
682 --tui-mode <mode> TUI buffer: regular or fullscreen (macOS default: regular)
683 --list-models [search] List available models (with optional fuzzy search)
684 --offline Disable startup network operations (same as RPI_OFFLINE=1)
685 --export <file> Export a JSONL session to HTML and exit
686 --approve, -a Force-enable current-project resources
687 --no-approve, -na Disable current-project resources
688 --print, -p Non-interactive: process prompt(s) and exit
689 --continue, -c Continue the most recent session
690 --resume, -r Browse and select a session to resume
691 --session <id|path> Use a specific session (partial UUID or file)
692 --session-dir <dir> Directory for session storage
693 --experimental-native-store <dir>
694 Fresh --print text/json only; requires experimental-native-store build feature
695 --no-session Ephemeral mode (do not persist the session)
696 --name, -n <name> Set the session display name
697 --tools, -t <list> Comma-separated allowlist of tool names to enable
698 --exclude-tools, -xt <list> Comma-separated denylist of tool names to disable
699 --no-tools, -nt Disable all tools
700 --no-builtin-tools, -nbt Disable the built-in tools (read, bash, edit, write, docs)
701 --no-skills, -ns Skip skill discovery (no <available_skills> block)
702 --no-prompt-templates, -np Skip prompt-template discovery (/expand templates)
703 --no-context-files, -nc Skip AGENTS.md/CLAUDE.md discovery (no <project_context>)
704 --no-extensions, -ne Skip Rust cdylib extension loading
705 --extensions-dir, -ed <dir> Extra dir to scan for plugins (.dll/.so/.dylib); repeatable
706 (also via RPI_EXTENSIONS_DIR env: ';' on Windows, ':' on Unix)
707 --local-only Skip automatic extension discovery: load only
708 --extensions-dir / --extension paths (no project
709 .rpi/extensions, no global extensions)
710 --package-only, --project-only Load project skills/prompts/extensions/packages
711 from the active project; exclude global resources
712 --debug-system-prompt Print the resolved system-prompt sections to stderr (verification)
713 --verbose Show startup warnings (e.g. ignored flags)
714 --help, -h Show this help
715 --version, -v Show version
716
717{u}Subcommands:{r}
718 update Update the rpi CLI from crates.io
719 auth login|check|logout Manage persisted credentials in ~/.rpi/auth.json
720 (see `rpi auth --help`)
721 events path|tail Inspect the extension event journal (needs
722 RPI_EVENT_LOG=1; see `rpi events --help`)
723 install <crate> Install a prebuilt Rust extension (--build for source)
724 (see `rpi install --help`)
725 uninstall <crate> Remove an installed Rust cdylib extension
726 extension update [name] Update project and global installed Rust extensions
727 (see `rpi extension update --help`)
728 package link|list|update|unlink Manage project-local Rust extension packages
729 (update is a legacy alias; see `rpi package --help`)
730 dev [options] Build, watch, and hot-reload a Rust extension
731 (see `rpi dev --help`)
732 dev-local [options] Debug only the current Rust extension
733 (shortcut for `rpi dev --local-only`)
734
735{u}Built-in Tools:{r}
736 {builtin} (enabled by default)
737
738{u}Examples:{r}
739 # Interactive with an initial prompt
740 {name} \"List all .rs files in src/\"
741
742 # Single-shot print mode
743 {name} -p \"Summarize this project\"
744
745 # Include a file in the initial message
746 {name} @README.md \"What does this project do?\"
747
748 # Continue the previous session
749 {name} -c \"What did we discuss?\"
750
751 # Use a specific model + thinking level
752 {name} --model claude-sonnet-5 --thinking high \"Refactor this\"
753
754 # JSON event stream (one JSON object per line on stdout)
755 {name} --mode json -p \"Inspect the code\"
756
757 # Read-only: no file-modifying tools
758 {name} --tools read,bash -p \"Review the code in src/\"
759
760{u}Environment:{r}
761 ANTHROPIC_API_KEY Anthropic API key (x-api-key) — fallback when no stored credential
762 ANTHROPIC_AUTH_TOKEN Bearer token (Authorization: Bearer) for third-party gateways
763 ANTHROPIC_BASE_URL Override the Anthropic endpoint (e.g. a compatible proxy)
764 OPENAI_API_KEY Bearer token for openai-completions/responses
765 RPI_OFFLINE Disable startup network operations when set to 1/true/yes
766 RPI_CODING_AGENT_DIR Override the ~/.rpi config directory (auth.json + models.json)
767 RPI_SESSION_ID Session id served by a plugin (set by the host; also honored as an override)
768 RPI_NO_EMOJI Render the brand lockup without the crab (1/true/yes)
769
770{u}Notes:{r}
771 Supported HTTP protocols are Anthropic Messages and OpenAI Chat Completions.
772 Define custom model catalogs and provider apiKey values in
773 ~/.rpi/agent/models.json. The interactive TUI, Rust extensions, skills,
774 prompt templates, themes, model cycling, session fork/export, and trust
775 commands are available in the current build. OAuth, RPC, and full model
776 cycling remain outside the current implementation.
777",
778 name = crate::APP_NAME,
779 builtin = builtin,
780 u = "\x1b[1m",
781 r = "\x1b[0m",
782 );
783}
784
785pub fn print_version() {
787 println!("{} {}", crate::APP_NAME, crate::VERSION);
788}
789
790#[cfg(test)]
791mod tests {
792 #[test]
793 fn rpc_message_updates_selection_and_errors() {
794 assert_eq!(
795 super::Args::default().rpc_message_updates,
796 super::RpcMessageUpdates::CompactDeltas
797 );
798 for (value, expected) in [
799 ("snapshot", super::RpcMessageUpdates::Snapshot),
800 ("compact-deltas", super::RpcMessageUpdates::CompactDeltas),
801 ] {
802 for args in [
803 vec![
804 "--mode".into(),
805 "rpc".into(),
806 format!("--rpc-message-updates={value}"),
807 ],
808 vec!["--rpc-message-updates".into(), value.into()],
809 ] {
810 let parsed = super::parse_args(&args);
811 assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
812 assert_eq!(parsed.rpc_message_updates, expected);
813 assert!(parsed.unknown_flags.is_empty());
814 }
815 }
816 for args in [
817 vec!["--rpc-message-updates".into()],
818 vec!["--rpc-message-updates".into(), "--mode=json".into()],
819 vec!["--rpc-message-updates=".into()],
820 vec!["--rpc-message-updates=invalid".into()],
821 vec!["--rpc-message-updates".into(), "invalid".into()],
822 ] {
823 let parsed = super::parse_args(&args);
824 assert_eq!(parsed.errors.len(), 1);
825 assert!(parsed.errors[0].contains("--rpc-message-updates"));
826 }
827 let parsed = super::parse_args(&[
828 "--mode=json".into(),
829 "--rpc-message-updates=snapshot".into(),
830 ]);
831 assert_eq!(parsed.mode, super::Mode::Json);
832 }
833
834 use super::*;
835
836 struct RestoreOfflineEnv(Option<std::ffi::OsString>);
837
838 impl Drop for RestoreOfflineEnv {
839 fn drop(&mut self) {
840 match self.0.take() {
841 Some(value) => std::env::set_var(RPI_OFFLINE_ENV, value),
842 None => std::env::remove_var(RPI_OFFLINE_ENV),
843 }
844 }
845 }
846
847 fn s(args: &[&str]) -> Vec<String> {
848 args.iter().map(|a| a.to_string()).collect()
849 }
850
851 #[test]
852 fn parses_basic_prompt() {
853 let a = parse_args(&s(&["hello", "world"]));
854 assert_eq!(a.messages, vec!["hello".to_string(), "world".to_string()]);
855 assert!(!a.help);
856 }
857
858 #[test]
859 fn parses_help_and_version() {
860 let a = parse_args(&s(&["--help"]));
861 assert!(a.help);
862 let a = parse_args(&s(&["-v"]));
863 assert!(a.version);
864 }
865
866 #[test]
867 fn print_consumes_following_positional() {
868 let a = parse_args(&s(&["-p", "summarize"]));
869 assert!(a.print);
870 assert_eq!(a.messages, vec!["summarize".to_string()]);
871 }
872
873 #[test]
874 fn print_does_not_consume_file_or_flag() {
875 let a = parse_args(&s(&["-p", "@file.md"]));
876 assert!(a.print);
877 assert!(a.messages.is_empty());
878 assert_eq!(a.file_args, vec![PathBuf::from("file.md")]);
879 }
880
881 #[test]
882 fn model_and_thinking() {
883 let a = parse_args(&s(&["--model", "claude-sonnet-5", "--thinking", "high"]));
884 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
885 assert_eq!(a.thinking, Some(ThinkingLevel::High));
886 }
887
888 #[test]
889 fn model_with_thinking_shorthand() {
890 let a = parse_args(&s(&["--model", "claude-sonnet-5:high"]));
891 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5:high"));
893 }
894
895 #[test]
896 fn tools_split_csv() {
897 let a = parse_args(&s(&["--tools", "read, bash ,write"]));
898 assert_eq!(
899 a.tools.as_deref(),
900 Some(&["read".to_string(), "bash".to_string(), "write".to_string()][..])
901 );
902 }
903
904 #[test]
905 fn unknown_short_flag_errors() {
906 let a = parse_args(&s(&["-Z"]));
907 assert!(!a.errors.is_empty());
908 }
909
910 #[test]
911 fn unknown_long_flag_is_retained_for_extensions() {
912 let a = parse_args(&s(&["--frobnicate", "value"]));
913 assert!(a.errors.is_empty());
914 assert_eq!(
915 a.unknown_flags.get("frobnicate"),
916 Some(&serde_json::Value::String("value".into()))
917 );
918 assert!(a.ignored.is_empty());
919 }
920
921 #[test]
922 fn unknown_long_boolean_flag_is_retained() {
923 let a = parse_args(&s(&["--server"]));
924 assert_eq!(
925 a.unknown_flags.get("server"),
926 Some(&serde_json::Value::Bool(true))
927 );
928 }
929
930 #[test]
931 fn unknown_long_flags_keep_string_and_equals_values() {
932 let a = parse_args(&s(&["--port", "8080", "--bind=127.0.0.1"]));
933 assert_eq!(
934 a.unknown_flags.get("port"),
935 Some(&serde_json::Value::String("8080".into()))
936 );
937 assert_eq!(
938 a.unknown_flags.get("bind"),
939 Some(&serde_json::Value::String("127.0.0.1".into()))
940 );
941 }
942
943 #[test]
944 fn models_flag_parses_csv() {
945 let a = parse_args(&s(&["--models", "a,b,c"]));
946 assert!(a.errors.is_empty());
947 assert!(a.ignored.is_empty(), "--models is implemented");
948 assert_eq!(
949 a.models.as_deref(),
950 Some(&["a".to_string(), "b".to_string(), "c".to_string()][..])
951 );
952 assert!(a.messages.is_empty());
954 }
955
956 #[test]
957 fn list_models_accepts_bare_and_search_forms() {
958 let bare = parse_args(&s(&["--list-models"]));
959 assert_eq!(bare.list_models.as_deref(), Some(""));
960 assert!(bare.ignored.is_empty());
961 assert!(bare.messages.is_empty());
962
963 let search = parse_args(&s(&["--list-models", "claude"]));
964 assert_eq!(search.list_models.as_deref(), Some("claude"));
965 assert!(search.messages.is_empty());
966
967 let inline = parse_args(&s(&["--list-models=gpt"]));
968 assert_eq!(inline.list_models.as_deref(), Some("gpt"));
969 }
970
971 #[test]
972 fn offline_flag_is_honored_without_warning() {
973 let args = parse_args(&s(&["--offline"]));
974 assert!(args.offline);
975 assert!(args.ignored.is_empty());
976 }
977
978 #[test]
979 fn timeout_parses_seconds_in_separate_and_equals_forms() {
980 let separate = parse_args(&s(&["--timeout", "45"]));
981 assert!(separate.errors.is_empty());
982 assert_eq!(separate.timeout, Some(Duration::from_secs(45)));
983
984 let inline = parse_args(&s(&["--timeout=90"]));
985 assert!(inline.errors.is_empty());
986 assert_eq!(inline.timeout, Some(Duration::from_secs(90)));
987 }
988
989 #[test]
990 fn timeout_rejects_zero_and_invalid_values() {
991 for value in ["0", "1.5", "forever", "18446744073709551616"] {
992 let args = parse_args(&s(&[&format!("--timeout={value}")]));
993 assert_eq!(args.errors.len(), 1, "value: {value}");
994 assert!(args.timeout.is_none(), "value: {value}");
995 }
996 }
997
998 #[test]
999 fn stream_idle_timeout_accepts_both_forms_and_zero_but_rejects_invalid_values() {
1000 for (argv, seconds) in [
1001 (s(&["--stream-idle-timeout", "45"]), 45),
1002 (s(&["--stream-idle-timeout=90"]), 90),
1003 (s(&["--stream-idle-timeout=0"]), 0),
1004 ] {
1005 let args = parse_args(&argv);
1006 assert!(args.errors.is_empty(), "{:?}", args.errors);
1007 assert_eq!(args.stream_idle_timeout, Some(Duration::from_secs(seconds)));
1008 assert!(args.timeout.is_none());
1009 }
1010 for value in ["-1", "1.5", "no", "18446744073709551616"] {
1011 let args = parse_args(&s(&[&format!("--stream-idle-timeout={value}")]));
1012 assert!(!args.errors.is_empty(), "{value}");
1013 assert!(args.stream_idle_timeout.is_none());
1014 }
1015 assert!(!parse_args(&s(&["--stream-idle-timeout"])).errors.is_empty());
1016 }
1017
1018 #[test]
1019 fn native_pi_offline_truthy_values_are_case_insensitive() {
1020 for value in [
1021 Some("1"),
1022 Some("true"),
1023 Some("TRUE"),
1024 Some("Yes"),
1025 Some("yEs"),
1026 ] {
1027 assert!(is_truthy_env_flag(value), "value={value:?}");
1028 }
1029 for value in [
1030 None,
1031 Some(""),
1032 Some("0"),
1033 Some("false"),
1034 Some("no"),
1035 Some(" true "),
1036 ] {
1037 assert!(!is_truthy_env_flag(value), "value={value:?}");
1038 }
1039 }
1040
1041 #[test]
1042 fn pi_offline_env_and_cli_flag_share_one_normalized_gate() {
1043 let _guard = crate::config::test_support::env_lock().lock().unwrap();
1044 let _restore = RestoreOfflineEnv(std::env::var_os(RPI_OFFLINE_ENV));
1045
1046 std::env::set_var(RPI_OFFLINE_ENV, "YeS");
1047 assert!(parse_args(&[]).offline);
1048 assert!(normalize_offline_mode(&[]));
1049 assert_eq!(std::env::var(RPI_OFFLINE_ENV).as_deref(), Ok("1"));
1050
1051 std::env::set_var(RPI_OFFLINE_ENV, "0");
1052 assert!(!parse_args(&[]).offline);
1053 let argv = s(&["--offline"]);
1054 assert!(normalize_offline_mode(&argv));
1055 assert_eq!(std::env::var(RPI_OFFLINE_ENV).as_deref(), Ok("1"));
1056 }
1057
1058 #[test]
1059 fn project_trust_flags_are_honored_without_warning() {
1060 let approved = parse_args(&s(&["--approve"]));
1061 assert_eq!(approved.trust_override, Some(true));
1062 assert!(approved.ignored.is_empty());
1063 let denied = parse_args(&s(&["--no-approve"]));
1064 assert_eq!(denied.trust_override, Some(false));
1065 assert!(denied.ignored.is_empty());
1066 }
1067
1068 #[test]
1069 fn export_flag_captures_input_and_output_position() {
1070 let args = parse_args(&s(&["--export", "session.jsonl", "transcript.html"]));
1071 assert_eq!(args.export, Some(PathBuf::from("session.jsonl")));
1072 assert_eq!(args.messages, vec!["transcript.html".to_string()]);
1073 assert!(args.ignored.is_empty());
1074 }
1075
1076 #[test]
1077 fn session_id_and_fork_flags_parse() {
1078 let a = parse_args(&s(&["--session-id", "01abc", "--fork", "xyz"]));
1079 assert!(a.errors.is_empty());
1080 assert_eq!(a.session_id.as_deref(), Some("01abc"));
1081 assert_eq!(a.fork.as_deref(), Some("xyz"));
1082 let a = parse_args(&s(&[
1083 "-e",
1084 "plugin.dll",
1085 "--skill",
1086 "s",
1087 "--prompt-template",
1088 "t.md",
1089 ]));
1090 assert_eq!(a.extension.len(), 1);
1091 assert_eq!(a.skill.len(), 1);
1092 assert_eq!(a.prompt_template.len(), 1);
1093 }
1094
1095 #[test]
1096 fn no_skills_flag_honored() {
1097 let a = parse_args(&s(&["-ns"]));
1098 assert!(a.errors.is_empty());
1099 assert!(a.no_skills);
1100 assert!(a.ignored.is_empty());
1102 }
1103
1104 #[test]
1105 fn no_prompt_templates_flag_honored() {
1106 let a = parse_args(&s(&["--no-prompt-templates"]));
1107 assert!(a.no_prompt_templates);
1108 assert!(a.ignored.is_empty());
1109 }
1110
1111 #[test]
1112 fn no_context_files_flag_honored() {
1113 let a = parse_args(&s(&["-nc"]));
1114 assert!(a.no_context_files);
1115 assert!(a.ignored.is_empty());
1116 }
1117
1118 #[test]
1119 fn no_extensions_flag_honored() {
1120 let a = parse_args(&s(&["--no-extensions"]));
1122 assert!(a.no_extensions);
1123 assert!(a.ignored.is_empty());
1124 }
1125
1126 #[test]
1127 fn local_only_flag_is_honored_on_the_regular_parser() {
1128 let a = parse_args(&s(&["--local-only"]));
1131 assert!(a.errors.is_empty());
1132 assert!(a.dev_local_only);
1133 assert!(a.ignored.is_empty());
1134 }
1135
1136 #[test]
1137 fn project_only_aliases_are_honored() {
1138 for flag in ["--package-only", "--project-only"] {
1139 let a = parse_args(&s(&[flag]));
1140 assert!(a.errors.is_empty());
1141 assert!(a.project_only);
1142 assert!(a.ignored.is_empty());
1143 }
1144 }
1145
1146 #[test]
1147 fn extensions_dir_flag_collects_dirs() {
1148 let a = parse_args(&s(&["--extensions-dir", "/a/b", "-ed", "/c/d"]));
1149 assert_eq!(
1150 a.extensions_dir,
1151 vec![PathBuf::from("/a/b"), PathBuf::from("/c/d")]
1152 );
1153 assert!(a.ignored.is_empty());
1154 }
1155
1156 #[test]
1157 fn extensions_dir_inline_equals_form() {
1158 let a = parse_args(&s(&["--extensions-dir=/x/y"]));
1159 assert_eq!(a.extensions_dir, vec![PathBuf::from("/x/y")]);
1160 }
1161
1162 #[test]
1163 fn extensions_dir_env_is_merged() {
1164 let a = parse_args(&s(&["--extensions-dir", "/flag/only"]));
1170 assert!(a
1171 .extensions_dir
1172 .iter()
1173 .any(|p| p == &PathBuf::from("/flag/only")));
1174 }
1175
1176 #[test]
1177 fn file_args_stripped() {
1178 let a = parse_args(&s(&["@a.txt", "@b.md", "hi"]));
1179 assert_eq!(
1180 a.file_args,
1181 vec![PathBuf::from("a.txt"), PathBuf::from("b.md")]
1182 );
1183 assert_eq!(a.messages, vec!["hi".to_string()]);
1184 }
1185
1186 #[test]
1187 fn equals_form_supported() {
1188 let a = parse_args(&s(&["--model=claude-sonnet-5", "--thinking=low"]));
1189 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
1190 assert_eq!(a.thinking, Some(ThinkingLevel::Low));
1191 }
1192
1193 #[test]
1194 fn theme_flag_is_honored() {
1195 let a = parse_args(&s(&["--theme", "ocean.json"]));
1196 assert_eq!(a.theme.as_deref(), Some("ocean.json"));
1197 assert!(a.ignored.is_empty());
1198 }
1199
1200 #[test]
1201 fn no_themes_is_honored() {
1202 let a = parse_args(&s(&["--no-themes"]));
1203 assert!(a.no_themes);
1204 assert!(a.ignored.is_empty());
1205 }
1206
1207 #[test]
1208 fn tui_mode_parses_and_validates() {
1209 assert_eq!(
1210 parse_args(&s(&["--tui-mode", "regular"])).tui_mode,
1211 TuiMode::Regular
1212 );
1213 assert_eq!(
1214 parse_args(&s(&["--tui-mode=fullscreen"])).tui_mode,
1215 TuiMode::Fullscreen
1216 );
1217 let invalid = parse_args(&s(&["--tui-mode", "split"]));
1218 assert!(!invalid.errors.is_empty());
1219 }
1220
1221 #[test]
1222 fn tui_mode_default_preserves_native_macos_scrollback() {
1223 assert_eq!(default_tui_mode_for(true), TuiMode::Regular);
1224 assert_eq!(default_tui_mode_for(false), TuiMode::Fullscreen);
1225 }
1226
1227 #[test]
1228 fn resolve_mode_interactive_when_tty() {
1229 let a = Args {
1230 print: true,
1231 ..Args::default()
1232 };
1233 assert_eq!(resolve_mode(&a, true, true), RunMode::Print);
1234 let a = Args::default();
1235 assert_eq!(resolve_mode(&a, true, true), RunMode::Interactive);
1236 let a = Args {
1237 mode: Mode::Json,
1238 ..Args::default()
1239 };
1240 assert_eq!(resolve_mode(&a, true, true), RunMode::Json);
1241 let a = Args {
1242 mode: Mode::Rpc,
1243 ..Args::default()
1244 };
1245 assert_eq!(resolve_mode(&a, true, true), RunMode::Rpc);
1246 }
1247
1248 #[test]
1249 fn piped_stdout_forces_print() {
1250 let a = Args::default();
1251 assert_eq!(resolve_mode(&a, true, false), RunMode::Print);
1253 }
1254}