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