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