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)]
85pub enum TuiMode {
86 Fullscreen,
87 Regular,
88}
89
90const fn default_tui_mode_for(is_macos: bool) -> TuiMode {
91 if is_macos {
92 TuiMode::Regular
93 } else {
94 TuiMode::Fullscreen
95 }
96}
97
98impl Default for TuiMode {
99 fn default() -> Self {
100 default_tui_mode_for(cfg!(target_os = "macos"))
101 }
102}
103
104#[derive(Debug, Clone, Default)]
107pub struct Args {
108 pub provider: Option<String>,
109 pub model: Option<String>,
110 pub api_key: Option<String>,
111 pub base_url: Option<String>,
114 pub timeout: Option<Duration>,
116 pub stream_idle_timeout: Option<Duration>,
118 pub system_prompt: Option<String>,
119 pub append_system_prompt: Vec<String>,
120 pub theme: Option<String>,
122 pub thinking: Option<ThinkingLevel>,
123
124 pub print: bool,
125 pub mode: Mode,
126 pub tui_mode: TuiMode,
130
131 pub list_models: Option<String>,
134 pub offline: bool,
136 pub export: Option<PathBuf>,
139 pub trust_override: Option<bool>,
142
143 pub continue_session: bool,
144 pub resume: bool,
145 pub session: Option<String>,
146 pub session_id: Option<String>,
150 pub fork: Option<String>,
153 pub models: Option<Vec<String>>,
157 pub session_dir: Option<PathBuf>,
158 pub no_session: bool,
159 pub name: Option<String>,
160
161 pub tools: Option<Vec<String>>,
162 pub exclude_tools: Option<Vec<String>>,
163 pub no_tools: bool,
164 pub no_builtin_tools: bool,
165
166 pub no_skills: bool,
169 pub no_prompt_templates: bool,
172 pub no_context_files: bool,
175 pub no_extensions: bool,
178 pub no_themes: bool,
181 pub extensions_dir: Vec<PathBuf>,
189 pub extension: Vec<PathBuf>,
192 pub skill: Vec<PathBuf>,
194 pub prompt_template: Vec<PathBuf>,
197
198 pub dev_local_only: bool,
200 pub project_only: bool,
203
204 pub verbose: bool,
205 pub help: bool,
206 pub version: bool,
207
208 pub debug_system_prompt: bool,
215
216 pub messages: Vec<String>,
218 pub file_args: Vec<PathBuf>,
221
222 pub unknown_flags: BTreeMap<String, serde_json::Value>,
226 pub ignored: Vec<String>,
229 pub errors: Vec<String>,
232}
233
234pub const VALID_THINKING_LEVELS: &[&str] =
237 &["off", "minimal", "low", "medium", "high", "xhigh", "max"];
238
239pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
241 Some(match s {
242 "off" => ThinkingLevel::Off,
243 "minimal" => ThinkingLevel::Minimal,
244 "low" => ThinkingLevel::Low,
245 "medium" => ThinkingLevel::Medium,
246 "high" => ThinkingLevel::High,
247 "xhigh" => ThinkingLevel::Xhigh,
248 "max" => ThinkingLevel::Max,
249 _ => return None,
250 })
251}
252
253fn file_arg(arg: &str) -> Option<PathBuf> {
256 if let Some(rest) = arg.strip_prefix('@') {
257 if rest.is_empty() {
259 None
260 } else {
261 Some(PathBuf::from(rest))
262 }
263 } else {
264 None
265 }
266}
267
268pub fn parse_args(args: &[String]) -> Args {
274 let mut result = Args::default();
275 result.offline = offline_env_enabled();
276 if let Ok(raw) = std::env::var("RPI_EXTENSIONS_DIR") {
280 if !raw.is_empty() {
281 let sep = if cfg!(windows) { ';' } else { ':' };
282 for part in raw.split(sep) {
283 let trimmed = part.trim();
284 if !trimmed.is_empty() {
285 result.extensions_dir.push(PathBuf::from(trimmed));
286 }
287 }
288 }
289 }
290 let mut i = 0;
291 while i < args.len() {
292 let arg = args[i].clone();
293 let (flag_key, inline) = if arg.starts_with("--") {
297 match arg.find('=') {
298 Some(eq) => (arg[..eq].to_string(), Some(arg[eq + 1..].to_string())),
299 None => (arg.clone(), None),
300 }
301 } else {
302 (arg.clone(), None)
303 };
304
305 let mut take_value = |result: &mut Args, _flag: &str| -> Option<String> {
309 if let Some(v) = inline.clone() {
310 return Some(v);
311 }
312 if i + 1 < args.len() {
313 let next = &args[i + 1];
314 if !next.starts_with('-') || next == "-" {
315 i += 1;
316 return Some(args[i].clone());
317 }
318 }
319 result.errors.push(format!("{flag_key} requires a value"));
320 None
321 };
322
323 match flag_key.as_str() {
324 "--help" | "-h" => result.help = true,
325 "--version" | "-v" => result.version = true,
326 "--print" | "-p" => {
327 result.print = true;
328 if i + 1 < args.len() {
333 let next = &args[i + 1];
334 if !next.starts_with('@') && !next.starts_with('-') {
335 i += 1;
336 result.messages.push(args[i].clone());
337 }
338 }
339 }
340 "--mode" => {
341 if let Some(v) = take_value(&mut result, "--mode") {
342 result.mode = match v.as_str() {
343 "text" => Mode::Text,
344 "json" => Mode::Json,
345 "rpc" => Mode::Rpc,
346 other => {
347 result.errors.push(format!(
348 "Invalid --mode \"{other}\". Valid: text, json, rpc"
349 ));
350 Mode::Text
351 }
352 };
353 }
354 }
355 "--tui-mode" => {
356 if let Some(v) = take_value(&mut result, "--tui-mode") {
357 result.tui_mode = match v.to_ascii_lowercase().as_str() {
358 "regular" => TuiMode::Regular,
359 "fullscreen" => TuiMode::Fullscreen,
360 other => {
361 result.errors.push(format!(
362 "Invalid --tui-mode \"{other}\". Valid: regular, fullscreen"
363 ));
364 TuiMode::Fullscreen
365 }
366 };
367 }
368 }
369 "--continue" | "-c" => result.continue_session = true,
370 "--resume" | "-r" => result.resume = true,
371 "--no-session" => result.no_session = true,
372 "--no-tools" | "-nt" => result.no_tools = true,
373 "--no-builtin-tools" | "-nbt" => result.no_builtin_tools = true,
374 "--no-skills" | "-ns" => result.no_skills = true,
375 "--no-prompt-templates" | "-np" => result.no_prompt_templates = true,
376 "--no-context-files" | "-nc" => result.no_context_files = true,
377 "--no-extensions" | "-ne" => result.no_extensions = true,
378 "--local-only" => result.dev_local_only = true,
379 "--package-only" | "--project-only" => result.project_only = true,
380 "--extensions-dir" | "-ed" => {
381 if let Some(v) = take_value(&mut result, &flag_key) {
382 result.extensions_dir.push(PathBuf::from(v));
383 }
384 }
385 "--verbose" => result.verbose = true,
386 "--debug-system-prompt" => result.debug_system_prompt = true,
387 "--provider" => result.provider = take_value(&mut result, "--provider"),
388 "--model" => result.model = take_value(&mut result, "--model"),
389 "--api-key" => result.api_key = take_value(&mut result, "--api-key"),
390 "--base-url" => result.base_url = take_value(&mut result, "--base-url"),
391 "--timeout" => {
392 if let Some(v) = take_value(&mut result, "--timeout") {
393 match v.parse::<u64>() {
394 Ok(seconds) if seconds > 0 => {
395 result.timeout = Some(Duration::from_secs(seconds));
396 }
397 _ => result.errors.push(format!(
398 "Invalid --timeout \"{v}\". Expected a positive integer number of seconds"
399 )),
400 }
401 }
402 }
403 "--stream-idle-timeout" => {
404 if let Some(v) = take_value(&mut result, "--stream-idle-timeout") {
405 match v.parse::<u64>() {
406 Ok(seconds) => result.stream_idle_timeout = Some(Duration::from_secs(seconds)),
407 Err(_) => result.errors.push(format!(
408 "Invalid --stream-idle-timeout \"{v}\". Expected a non-negative integer number of seconds (0 disables it)"
409 )),
410 }
411 }
412 }
413 "--system-prompt" => result.system_prompt = take_value(&mut result, "--system-prompt"),
414 "--append-system-prompt" => {
415 if let Some(v) = take_value(&mut result, "--append-system-prompt") {
416 result.append_system_prompt.push(v);
417 }
418 }
419 "--name" | "-n" => result.name = take_value(&mut result, "--name"),
420 "--session" => result.session = take_value(&mut result, "--session"),
421 "--session-id" => result.session_id = take_value(&mut result, "--session-id"),
422 "--fork" => result.fork = take_value(&mut result, "--fork"),
423 "--models" => {
424 if let Some(v) = take_value(&mut result, &flag_key) {
425 result.models = Some(split_csv(&v));
426 }
427 }
428 "--extension" | "-e" => {
429 if let Some(v) = take_value(&mut result, &flag_key) {
430 result.extension.push(PathBuf::from(v));
431 }
432 }
433 "--skill" => {
434 if let Some(v) = take_value(&mut result, &flag_key) {
435 result.skill.push(PathBuf::from(v));
436 }
437 }
438 "--prompt-template" => {
439 if let Some(v) = take_value(&mut result, &flag_key) {
440 result.prompt_template.push(PathBuf::from(v));
441 }
442 }
443 "--session-dir" => {
444 if let Some(v) = take_value(&mut result, "--session-dir") {
445 result.session_dir = Some(PathBuf::from(v));
446 }
447 }
448 "--thinking" => {
449 if let Some(v) = take_value(&mut result, "--thinking") {
450 match parse_thinking_level(&v) {
451 Some(lvl) => result.thinking = Some(lvl),
452 None => result.ignored.push(format!(
453 "Invalid --thinking \"{v}\". Valid: {}",
454 VALID_THINKING_LEVELS.join(", ")
455 )),
456 }
457 }
458 }
459 "--tools" | "-t" => {
460 if let Some(v) = take_value(&mut result, &flag_key) {
461 result.tools = Some(split_csv(&v));
462 }
463 }
464 "--exclude-tools" | "-xt" => {
465 if let Some(v) = take_value(&mut result, &flag_key) {
466 result.exclude_tools = Some(split_csv(&v));
467 }
468 }
469 "--list-models" => {
470 let mut search = inline.clone().unwrap_or_default();
473 if inline.is_none()
474 && i + 1 < args.len()
475 && !args[i + 1].starts_with('-')
476 && !args[i + 1].starts_with('@')
477 {
478 i += 1;
479 search = args[i].clone();
480 }
481 result.list_models = Some(search);
482 }
483 "--offline" => result.offline = true,
484 "--export" => {
485 if let Some(value) = take_value(&mut result, &flag_key) {
486 result.export = Some(PathBuf::from(value));
487 }
488 }
489 "--approve" | "-a" => result.trust_override = Some(true),
490 "--no-approve" | "-na" => result.trust_override = Some(false),
491 other if matches!(other, "--models") => {
502 if inline.is_none()
505 && i + 1 < args.len()
506 && !args[i + 1].starts_with('-')
507 && !args[i + 1].starts_with('@')
508 {
509 i += 1;
510 }
511 result
512 .ignored
513 .push(format!("{other} is not supported in v1 (ignored)"));
514 }
515 "--theme" => {
516 result.theme = take_value(&mut result, "--theme");
517 }
518 "--no-themes" => result.no_themes = true,
519 other if other.starts_with("--") => {
524 let name = &flag_key;
525 let value = if let Some(value) = inline {
526 serde_json::Value::String(value)
527 } else if i + 1 < args.len()
528 && !args[i + 1].starts_with('-')
529 && !args[i + 1].starts_with('@')
530 {
531 i += 1;
532 serde_json::Value::String(args[i].clone())
533 } else {
534 serde_json::Value::Bool(true)
535 };
536 result.unknown_flags.insert(name[2..].to_string(), value);
537 }
538 other if other.starts_with('-') && other.len() > 1 => {
540 result.errors.push(format!("Unknown option: {other}"));
541 }
542 other => {
543 if let Some(path) = file_arg(other) {
544 result.file_args.push(path);
545 } else {
546 result.messages.push(other.to_string());
547 }
548 }
549 }
550 i += 1;
551 }
552
553 result
557}
558
559fn split_csv(v: &str) -> Vec<String> {
561 v.split(',')
562 .map(|s| s.trim().to_string())
563 .filter(|s| !s.is_empty())
564 .collect()
565}
566
567pub fn resolve_mode(parsed: &Args, stdin_is_tty: bool, stdout_is_tty: bool) -> RunMode {
572 if parsed.unknown_flags.contains_key("server") {
574 return RunMode::Rpc;
575 }
576 if parsed.mode == Mode::Rpc {
577 return RunMode::Rpc;
578 }
579 if parsed.mode == Mode::Json {
580 return RunMode::Json;
581 }
582 if parsed.print || !stdin_is_tty || !stdout_is_tty {
583 RunMode::Print
584 } else {
585 RunMode::Interactive
586 }
587}
588
589#[derive(Debug, Clone, Copy, PartialEq, Eq)]
594pub enum RunMode {
595 Interactive,
596 Print,
597 Json,
598 Rpc,
599}
600
601pub fn print_help() {
603 let builtin = "read, bash, edit, write, docs";
604 println!(
605 "{name} - AI coding assistant with read, bash, edit, write, docs tools
606
607{u}Usage:{r}
608 {name} [options] [@files...] [messages...]
609
610{u}Options:{r}
611 --provider <name> Provider name (built-in, installed package, or models.json id)
612 --model <pattern> Model pattern or ID (supports \"provider/id\" and optional \":<thinking>\")
613 --api-key <key> API key override for the selected provider
614 --base-url <url> Override the selected model endpoint
615 --timeout <seconds> LLM API request timeout (default: 600)
616 --stream-idle-timeout <seconds> Max silence between response bytes (default: 300; 0 disables)
617 --system-prompt <text> Replace the default system prompt
618 --append-system-prompt <text> Append text to the system prompt (repeatable)
619 --thinking <level> off, minimal, low, medium, high, xhigh, max
620 --mode <mode> Output mode: text (default), json, or rpc
621 --server [--port <n>] [--bind <ip>]
622 Headless server: serve a remote-connectable agent (no TUI)
623 --connect <host:port> Remote client: connect to a `--server` instance and run the TUI
624 (no local provider/tools/session; all agent work is remote)
625 --token <token> Auth token for `--connect` (printed by `--server`; may also
626 be provided via the RPI_SERVER_TOKEN env var)
627 --tui-mode <mode> TUI buffer: regular or fullscreen (macOS default: regular)
628 --list-models [search] List available models (with optional fuzzy search)
629 --offline Disable startup network operations (same as RPI_OFFLINE=1)
630 --export <file> Export a JSONL session to HTML and exit
631 --approve, -a Force-enable current-project resources
632 --no-approve, -na Disable current-project resources
633 --print, -p Non-interactive: process prompt(s) and exit
634 --continue, -c Continue the most recent session
635 --resume, -r Browse and select a session to resume
636 --session <id|path> Use a specific session (partial UUID or file)
637 --session-dir <dir> Directory for session storage
638 --no-session Ephemeral mode (do not persist the session)
639 --name, -n <name> Set the session display name
640 --tools, -t <list> Comma-separated allowlist of tool names to enable
641 --exclude-tools, -xt <list> Comma-separated denylist of tool names to disable
642 --no-tools, -nt Disable all tools
643 --no-builtin-tools, -nbt Disable the built-in tools (read, bash, edit, write, docs)
644 --no-skills, -ns Skip skill discovery (no <available_skills> block)
645 --no-prompt-templates, -np Skip prompt-template discovery (/expand templates)
646 --no-context-files, -nc Skip AGENTS.md/CLAUDE.md discovery (no <project_context>)
647 --no-extensions, -ne Skip Rust cdylib extension loading
648 --extensions-dir, -ed <dir> Extra dir to scan for plugins (.dll/.so/.dylib); repeatable
649 (also via RPI_EXTENSIONS_DIR env: ';' on Windows, ':' on Unix)
650 --local-only Skip automatic extension discovery: load only
651 --extensions-dir / --extension paths (no project
652 .rpi/extensions, no global extensions)
653 --package-only, --project-only Load project skills/prompts/extensions/packages
654 from the active project; exclude global resources
655 --debug-system-prompt Print the resolved system-prompt sections to stderr (verification)
656 --verbose Show startup warnings (e.g. ignored flags)
657 --help, -h Show this help
658 --version, -v Show version
659
660{u}Subcommands:{r}
661 update Update the rpi CLI from crates.io
662 auth login|check|logout Manage persisted credentials in ~/.rpi/auth.json
663 (see `rpi auth --help`)
664 events path|tail Inspect the extension event journal (needs
665 RPI_EVENT_LOG=1; see `rpi events --help`)
666 install <crate> Install a prebuilt Rust extension (--build for source)
667 (see `rpi install --help`)
668 uninstall <crate> Remove an installed Rust cdylib extension
669 package link|list|update|unlink Manage project-local Rust extension packages
670 (see `rpi package --help`)
671 dev [options] Build, watch, and hot-reload a Rust extension
672 (see `rpi dev --help`)
673 dev-local [options] Debug only the current Rust extension
674 (shortcut for `rpi dev --local-only`)
675
676{u}Built-in Tools:{r}
677 {builtin} (enabled by default)
678
679{u}Examples:{r}
680 # Interactive with an initial prompt
681 {name} \"List all .rs files in src/\"
682
683 # Single-shot print mode
684 {name} -p \"Summarize this project\"
685
686 # Include a file in the initial message
687 {name} @README.md \"What does this project do?\"
688
689 # Continue the previous session
690 {name} -c \"What did we discuss?\"
691
692 # Use a specific model + thinking level
693 {name} --model claude-sonnet-5 --thinking high \"Refactor this\"
694
695 # JSON event stream (one JSON object per line on stdout)
696 {name} --mode json -p \"Inspect the code\"
697
698 # Read-only: no file-modifying tools
699 {name} --tools read,bash -p \"Review the code in src/\"
700
701{u}Environment:{r}
702 ANTHROPIC_API_KEY Anthropic API key (x-api-key) — fallback when no stored credential
703 ANTHROPIC_AUTH_TOKEN Bearer token (Authorization: Bearer) for third-party gateways
704 ANTHROPIC_BASE_URL Override the Anthropic endpoint (e.g. a compatible proxy)
705 OPENAI_API_KEY Bearer token for openai-completions/responses
706 RPI_OFFLINE Disable startup network operations when set to 1/true/yes
707 RPI_CODING_AGENT_DIR Override the ~/.rpi config directory (auth.json + models.json)
708 RPI_SESSION_ID Session id served by a plugin (set by the host; also honored as an override)
709 RPI_NO_EMOJI Render the brand lockup without the crab (1/true/yes)
710
711{u}Notes:{r}
712 Supported HTTP protocols are Anthropic Messages and OpenAI Chat Completions.
713 Define custom model catalogs and provider apiKey values in
714 ~/.rpi/agent/models.json. The interactive TUI, Rust extensions, skills,
715 prompt templates, themes, model cycling, session fork/export, and trust
716 commands are available in the current build. OAuth, RPC, and full model
717 cycling remain outside the current implementation.
718",
719 name = crate::APP_NAME,
720 builtin = builtin,
721 u = "\x1b[1m",
722 r = "\x1b[0m",
723 );
724}
725
726pub fn print_version() {
728 println!("{} {}", crate::APP_NAME, crate::VERSION);
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734
735 struct RestoreOfflineEnv(Option<std::ffi::OsString>);
736
737 impl Drop for RestoreOfflineEnv {
738 fn drop(&mut self) {
739 match self.0.take() {
740 Some(value) => std::env::set_var(RPI_OFFLINE_ENV, value),
741 None => std::env::remove_var(RPI_OFFLINE_ENV),
742 }
743 }
744 }
745
746 fn s(args: &[&str]) -> Vec<String> {
747 args.iter().map(|a| a.to_string()).collect()
748 }
749
750 #[test]
751 fn parses_basic_prompt() {
752 let a = parse_args(&s(&["hello", "world"]));
753 assert_eq!(a.messages, vec!["hello".to_string(), "world".to_string()]);
754 assert!(!a.help);
755 }
756
757 #[test]
758 fn parses_help_and_version() {
759 let a = parse_args(&s(&["--help"]));
760 assert!(a.help);
761 let a = parse_args(&s(&["-v"]));
762 assert!(a.version);
763 }
764
765 #[test]
766 fn print_consumes_following_positional() {
767 let a = parse_args(&s(&["-p", "summarize"]));
768 assert!(a.print);
769 assert_eq!(a.messages, vec!["summarize".to_string()]);
770 }
771
772 #[test]
773 fn print_does_not_consume_file_or_flag() {
774 let a = parse_args(&s(&["-p", "@file.md"]));
775 assert!(a.print);
776 assert!(a.messages.is_empty());
777 assert_eq!(a.file_args, vec![PathBuf::from("file.md")]);
778 }
779
780 #[test]
781 fn model_and_thinking() {
782 let a = parse_args(&s(&["--model", "claude-sonnet-5", "--thinking", "high"]));
783 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
784 assert_eq!(a.thinking, Some(ThinkingLevel::High));
785 }
786
787 #[test]
788 fn model_with_thinking_shorthand() {
789 let a = parse_args(&s(&["--model", "claude-sonnet-5:high"]));
790 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5:high"));
792 }
793
794 #[test]
795 fn tools_split_csv() {
796 let a = parse_args(&s(&["--tools", "read, bash ,write"]));
797 assert_eq!(
798 a.tools.as_deref(),
799 Some(&["read".to_string(), "bash".to_string(), "write".to_string()][..])
800 );
801 }
802
803 #[test]
804 fn unknown_short_flag_errors() {
805 let a = parse_args(&s(&["-Z"]));
806 assert!(!a.errors.is_empty());
807 }
808
809 #[test]
810 fn unknown_long_flag_is_retained_for_extensions() {
811 let a = parse_args(&s(&["--frobnicate", "value"]));
812 assert!(a.errors.is_empty());
813 assert_eq!(
814 a.unknown_flags.get("frobnicate"),
815 Some(&serde_json::Value::String("value".into()))
816 );
817 assert!(a.ignored.is_empty());
818 }
819
820 #[test]
821 fn unknown_long_boolean_flag_is_retained() {
822 let a = parse_args(&s(&["--server"]));
823 assert_eq!(
824 a.unknown_flags.get("server"),
825 Some(&serde_json::Value::Bool(true))
826 );
827 }
828
829 #[test]
830 fn unknown_long_flags_keep_string_and_equals_values() {
831 let a = parse_args(&s(&["--port", "8080", "--bind=127.0.0.1"]));
832 assert_eq!(
833 a.unknown_flags.get("port"),
834 Some(&serde_json::Value::String("8080".into()))
835 );
836 assert_eq!(
837 a.unknown_flags.get("bind"),
838 Some(&serde_json::Value::String("127.0.0.1".into()))
839 );
840 }
841
842 #[test]
843 fn models_flag_parses_csv() {
844 let a = parse_args(&s(&["--models", "a,b,c"]));
845 assert!(a.errors.is_empty());
846 assert!(a.ignored.is_empty(), "--models is implemented");
847 assert_eq!(
848 a.models.as_deref(),
849 Some(&["a".to_string(), "b".to_string(), "c".to_string()][..])
850 );
851 assert!(a.messages.is_empty());
853 }
854
855 #[test]
856 fn list_models_accepts_bare_and_search_forms() {
857 let bare = parse_args(&s(&["--list-models"]));
858 assert_eq!(bare.list_models.as_deref(), Some(""));
859 assert!(bare.ignored.is_empty());
860 assert!(bare.messages.is_empty());
861
862 let search = parse_args(&s(&["--list-models", "claude"]));
863 assert_eq!(search.list_models.as_deref(), Some("claude"));
864 assert!(search.messages.is_empty());
865
866 let inline = parse_args(&s(&["--list-models=gpt"]));
867 assert_eq!(inline.list_models.as_deref(), Some("gpt"));
868 }
869
870 #[test]
871 fn offline_flag_is_honored_without_warning() {
872 let args = parse_args(&s(&["--offline"]));
873 assert!(args.offline);
874 assert!(args.ignored.is_empty());
875 }
876
877 #[test]
878 fn timeout_parses_seconds_in_separate_and_equals_forms() {
879 let separate = parse_args(&s(&["--timeout", "45"]));
880 assert!(separate.errors.is_empty());
881 assert_eq!(separate.timeout, Some(Duration::from_secs(45)));
882
883 let inline = parse_args(&s(&["--timeout=90"]));
884 assert!(inline.errors.is_empty());
885 assert_eq!(inline.timeout, Some(Duration::from_secs(90)));
886 }
887
888 #[test]
889 fn timeout_rejects_zero_and_invalid_values() {
890 for value in ["0", "1.5", "forever", "18446744073709551616"] {
891 let args = parse_args(&s(&[&format!("--timeout={value}")]));
892 assert_eq!(args.errors.len(), 1, "value: {value}");
893 assert!(args.timeout.is_none(), "value: {value}");
894 }
895 }
896
897 #[test]
898 fn stream_idle_timeout_accepts_both_forms_and_zero_but_rejects_invalid_values() {
899 for (argv, seconds) in [
900 (s(&["--stream-idle-timeout", "45"]), 45),
901 (s(&["--stream-idle-timeout=90"]), 90),
902 (s(&["--stream-idle-timeout=0"]), 0),
903 ] {
904 let args = parse_args(&argv);
905 assert!(args.errors.is_empty(), "{:?}", args.errors);
906 assert_eq!(args.stream_idle_timeout, Some(Duration::from_secs(seconds)));
907 assert!(args.timeout.is_none());
908 }
909 for value in ["-1", "1.5", "no", "18446744073709551616"] {
910 let args = parse_args(&s(&[&format!("--stream-idle-timeout={value}")]));
911 assert!(!args.errors.is_empty(), "{value}");
912 assert!(args.stream_idle_timeout.is_none());
913 }
914 assert!(!parse_args(&s(&["--stream-idle-timeout"])).errors.is_empty());
915 }
916
917 #[test]
918 fn native_pi_offline_truthy_values_are_case_insensitive() {
919 for value in [
920 Some("1"),
921 Some("true"),
922 Some("TRUE"),
923 Some("Yes"),
924 Some("yEs"),
925 ] {
926 assert!(is_truthy_env_flag(value), "value={value:?}");
927 }
928 for value in [
929 None,
930 Some(""),
931 Some("0"),
932 Some("false"),
933 Some("no"),
934 Some(" true "),
935 ] {
936 assert!(!is_truthy_env_flag(value), "value={value:?}");
937 }
938 }
939
940 #[test]
941 fn pi_offline_env_and_cli_flag_share_one_normalized_gate() {
942 let _guard = crate::config::test_support::env_lock().lock().unwrap();
943 let _restore = RestoreOfflineEnv(std::env::var_os(RPI_OFFLINE_ENV));
944
945 std::env::set_var(RPI_OFFLINE_ENV, "YeS");
946 assert!(parse_args(&[]).offline);
947 assert!(normalize_offline_mode(&[]));
948 assert_eq!(std::env::var(RPI_OFFLINE_ENV).as_deref(), Ok("1"));
949
950 std::env::set_var(RPI_OFFLINE_ENV, "0");
951 assert!(!parse_args(&[]).offline);
952 let argv = s(&["--offline"]);
953 assert!(normalize_offline_mode(&argv));
954 assert_eq!(std::env::var(RPI_OFFLINE_ENV).as_deref(), Ok("1"));
955 }
956
957 #[test]
958 fn project_trust_flags_are_honored_without_warning() {
959 let approved = parse_args(&s(&["--approve"]));
960 assert_eq!(approved.trust_override, Some(true));
961 assert!(approved.ignored.is_empty());
962 let denied = parse_args(&s(&["--no-approve"]));
963 assert_eq!(denied.trust_override, Some(false));
964 assert!(denied.ignored.is_empty());
965 }
966
967 #[test]
968 fn export_flag_captures_input_and_output_position() {
969 let args = parse_args(&s(&["--export", "session.jsonl", "transcript.html"]));
970 assert_eq!(args.export, Some(PathBuf::from("session.jsonl")));
971 assert_eq!(args.messages, vec!["transcript.html".to_string()]);
972 assert!(args.ignored.is_empty());
973 }
974
975 #[test]
976 fn session_id_and_fork_flags_parse() {
977 let a = parse_args(&s(&["--session-id", "01abc", "--fork", "xyz"]));
978 assert!(a.errors.is_empty());
979 assert_eq!(a.session_id.as_deref(), Some("01abc"));
980 assert_eq!(a.fork.as_deref(), Some("xyz"));
981 let a = parse_args(&s(&[
982 "-e",
983 "plugin.dll",
984 "--skill",
985 "s",
986 "--prompt-template",
987 "t.md",
988 ]));
989 assert_eq!(a.extension.len(), 1);
990 assert_eq!(a.skill.len(), 1);
991 assert_eq!(a.prompt_template.len(), 1);
992 }
993
994 #[test]
995 fn no_skills_flag_honored() {
996 let a = parse_args(&s(&["-ns"]));
997 assert!(a.errors.is_empty());
998 assert!(a.no_skills);
999 assert!(a.ignored.is_empty());
1001 }
1002
1003 #[test]
1004 fn no_prompt_templates_flag_honored() {
1005 let a = parse_args(&s(&["--no-prompt-templates"]));
1006 assert!(a.no_prompt_templates);
1007 assert!(a.ignored.is_empty());
1008 }
1009
1010 #[test]
1011 fn no_context_files_flag_honored() {
1012 let a = parse_args(&s(&["-nc"]));
1013 assert!(a.no_context_files);
1014 assert!(a.ignored.is_empty());
1015 }
1016
1017 #[test]
1018 fn no_extensions_flag_honored() {
1019 let a = parse_args(&s(&["--no-extensions"]));
1021 assert!(a.no_extensions);
1022 assert!(a.ignored.is_empty());
1023 }
1024
1025 #[test]
1026 fn local_only_flag_is_honored_on_the_regular_parser() {
1027 let a = parse_args(&s(&["--local-only"]));
1030 assert!(a.errors.is_empty());
1031 assert!(a.dev_local_only);
1032 assert!(a.ignored.is_empty());
1033 }
1034
1035 #[test]
1036 fn project_only_aliases_are_honored() {
1037 for flag in ["--package-only", "--project-only"] {
1038 let a = parse_args(&s(&[flag]));
1039 assert!(a.errors.is_empty());
1040 assert!(a.project_only);
1041 assert!(a.ignored.is_empty());
1042 }
1043 }
1044
1045 #[test]
1046 fn extensions_dir_flag_collects_dirs() {
1047 let a = parse_args(&s(&["--extensions-dir", "/a/b", "-ed", "/c/d"]));
1048 assert_eq!(
1049 a.extensions_dir,
1050 vec![PathBuf::from("/a/b"), PathBuf::from("/c/d")]
1051 );
1052 assert!(a.ignored.is_empty());
1053 }
1054
1055 #[test]
1056 fn extensions_dir_inline_equals_form() {
1057 let a = parse_args(&s(&["--extensions-dir=/x/y"]));
1058 assert_eq!(a.extensions_dir, vec![PathBuf::from("/x/y")]);
1059 }
1060
1061 #[test]
1062 fn extensions_dir_env_is_merged() {
1063 let a = parse_args(&s(&["--extensions-dir", "/flag/only"]));
1069 assert!(a
1070 .extensions_dir
1071 .iter()
1072 .any(|p| p == &PathBuf::from("/flag/only")));
1073 }
1074
1075 #[test]
1076 fn file_args_stripped() {
1077 let a = parse_args(&s(&["@a.txt", "@b.md", "hi"]));
1078 assert_eq!(
1079 a.file_args,
1080 vec![PathBuf::from("a.txt"), PathBuf::from("b.md")]
1081 );
1082 assert_eq!(a.messages, vec!["hi".to_string()]);
1083 }
1084
1085 #[test]
1086 fn equals_form_supported() {
1087 let a = parse_args(&s(&["--model=claude-sonnet-5", "--thinking=low"]));
1088 assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
1089 assert_eq!(a.thinking, Some(ThinkingLevel::Low));
1090 }
1091
1092 #[test]
1093 fn theme_flag_is_honored() {
1094 let a = parse_args(&s(&["--theme", "ocean.json"]));
1095 assert_eq!(a.theme.as_deref(), Some("ocean.json"));
1096 assert!(a.ignored.is_empty());
1097 }
1098
1099 #[test]
1100 fn no_themes_is_honored() {
1101 let a = parse_args(&s(&["--no-themes"]));
1102 assert!(a.no_themes);
1103 assert!(a.ignored.is_empty());
1104 }
1105
1106 #[test]
1107 fn tui_mode_parses_and_validates() {
1108 assert_eq!(
1109 parse_args(&s(&["--tui-mode", "regular"])).tui_mode,
1110 TuiMode::Regular
1111 );
1112 assert_eq!(
1113 parse_args(&s(&["--tui-mode=fullscreen"])).tui_mode,
1114 TuiMode::Fullscreen
1115 );
1116 let invalid = parse_args(&s(&["--tui-mode", "split"]));
1117 assert!(!invalid.errors.is_empty());
1118 }
1119
1120 #[test]
1121 fn tui_mode_default_preserves_native_macos_scrollback() {
1122 assert_eq!(default_tui_mode_for(true), TuiMode::Regular);
1123 assert_eq!(default_tui_mode_for(false), TuiMode::Fullscreen);
1124 }
1125
1126 #[test]
1127 fn resolve_mode_interactive_when_tty() {
1128 let a = Args {
1129 print: true,
1130 ..Args::default()
1131 };
1132 assert_eq!(resolve_mode(&a, true, true), RunMode::Print);
1133 let a = Args::default();
1134 assert_eq!(resolve_mode(&a, true, true), RunMode::Interactive);
1135 let a = Args {
1136 mode: Mode::Json,
1137 ..Args::default()
1138 };
1139 assert_eq!(resolve_mode(&a, true, true), RunMode::Json);
1140 let a = Args {
1141 mode: Mode::Rpc,
1142 ..Args::default()
1143 };
1144 assert_eq!(resolve_mode(&a, true, true), RunMode::Rpc);
1145 }
1146
1147 #[test]
1148 fn piped_stdout_forces_print() {
1149 let a = Args::default();
1150 assert_eq!(resolve_mode(&a, true, false), RunMode::Print);
1152 }
1153}