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