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