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