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