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