Skip to main content

rpi_cli/
args.rs

1//! CLI argument parsing + help text. Mirrors the TS
2//! `packages/coding-agent/src/cli/args.ts` (`parseArgs` + `printHelp`), scoped
3//! to the flags the v1 Rust CLI honors.
4//!
5//! The TS parser is a hand-rolled positional/flag loop (no `yargs`/`commander`
6//! dep) that collects `messages`, `@file` attachments, known flags, and a map
7//! of *unknown* `--flags` (for extensions to claim later). This port keeps the
8//! same shape so the help text and flag semantics line up 1:1 with the
9//! reference. Unknown flags are *not* stored (there is no extension system in
10//! v1); they produce a warning diagnostic instead.
11//!
12//! Divergences from the TS parser (all deliberate v1 scope cuts, documented in
13//! `docs/m6-cli-open-questions.md`):
14//! - `--mode rpc`, `--tui-mode`, `--export`, `--list-models`, `--models`,
15//!   `--fork`, `--offline`, `--approve`/`-na`, the package-manager subcommands,
16//!   `--extension`/`-e`, `--skill`, `--prompt-template`, `--theme`, and their
17//!   `--no-*` discovery toggles are **recognized but ignored** (parsed so users
18//!   don't get a hard error for muscle-memory flags, with a warning). They are
19//!   not in v1's surface.
20//! - `--thinking` is typed via [`ThinkingLevel`] from `rpi_ai` (the TS parser
21//!   validates against the same string set).
22//! - `--print`/`-p` may consume a following positional as its prompt (the TS
23//!   parser's `next !== undefined && !startsWith('@')` heuristic) — preserved.
24
25use std::path::PathBuf;
26
27use rpi_ai::ThinkingLevel;
28
29/// Output mode. Mirrors TS `Mode = "text" | "json" | "rpc"`. `rpc` is parsed
30/// (so `--mode rpc` doesn't error) but v1 does not implement it; `main`
31/// reports an error if selected.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum Mode {
34    #[default]
35    Text,
36    Json,
37    Rpc,
38}
39
40/// The parsed argument set. Mirrors TS `Args`. Fields absent in v1
41/// (`unknownFlags`, extension/resource discovery) are omitted; everything here
42/// is either honored or explicitly ignored-with-warning.
43#[derive(Debug, Clone, Default)]
44pub struct Args {
45    pub provider: Option<String>,
46    pub model: Option<String>,
47    pub api_key: Option<String>,
48    /// `--base-url` — overrides `ANTHROPIC_BASE_URL` + each model's base URL,
49    /// for third-party Anthropic-compatible gateways/proxies.
50    pub base_url: Option<String>,
51    pub system_prompt: Option<String>,
52    pub append_system_prompt: Vec<String>,
53    pub thinking: Option<ThinkingLevel>,
54
55    pub print: bool,
56    pub mode: Mode,
57
58    pub continue_session: bool,
59    pub resume: bool,
60    pub session: Option<String>,
61    pub session_dir: Option<PathBuf>,
62    pub no_session: bool,
63    pub name: Option<String>,
64
65    pub tools: Option<Vec<String>>,
66    pub exclude_tools: Option<Vec<String>>,
67    pub no_tools: bool,
68    pub no_builtin_tools: bool,
69
70    /// `--no-skills`/`-ns`: skip skill discovery + the `<available_skills>`
71    /// system-prompt listing.
72    pub no_skills: bool,
73    /// `--no-prompt-templates`/`-np`: skip prompt-template discovery (templates
74    /// are on-demand only; this suppresses populating the resource registry).
75    pub no_prompt_templates: bool,
76    /// `--no-context-files`/`-nc`: skip context-file (`AGENTS.md`/`CLAUDE.md`)
77    /// discovery + the `<project_context>` system-prompt block.
78    pub no_context_files: bool,
79    /// `--no-extensions`/`-ne`: skip cdylib plugin discovery + loading entirely.
80    /// Honored by `session.rs` (Part B2): when set, no extension directory is
81    /// scanned and no plugin tools/handlers are registered.
82    pub no_extensions: bool,
83    /// `--extensions-dir`/`-ed`: an extra directory to scan for cdylib plugins
84    /// (`.dll`/`.so`/`.dylib`), in addition to the project `.pi/extensions` and
85    /// global `agent_dir()/extensions` defaults. May be repeated; scanned after
86    /// the defaults (so a same-named tool in a default dir wins first, mirroring
87    /// pi's registration order). `RPI_EXTENSIONS_DIR` (colon-separated on Unix,
88    /// semicolon on Windows) provides the same list via env.
89    pub extensions_dir: Vec<PathBuf>,
90
91    pub verbose: bool,
92    pub help: bool,
93    pub version: bool,
94
95    /// `--debug-system-prompt`: print the resolved system-prompt sections
96    /// (base, append, context, skills listing) + resource counts to stderr at
97    /// harness build time, then proceed normally. A verification affordance for
98    /// resource-discovery (Part A) — lets a smoke confirm `<available_skills>` +
99    /// `<project_context>` + appended text reached the prompt without a full
100    /// round-trip parse. Mirrors the plan's "add --debug-system-prompt if absent".
101    pub debug_system_prompt: bool,
102
103    /// Positional prompt text (one or more messages). Mirrors TS `messages`.
104    pub messages: Vec<String>,
105    /// `@file` attachments (prefix stripped), as raw paths for the caller to
106    /// expand. Mirrors TS `fileArgs`.
107    pub file_args: Vec<PathBuf>,
108
109    /// Warnings about recognized-but-ignored flags (v1 scope cuts). Surfaced
110    /// to the user on startup when `--verbose`.
111    pub ignored: Vec<String>,
112    /// Hard parse errors (unknown short flags, missing values). Non-empty ⇒
113    /// `main` prints them + help and exits non-zero.
114    pub errors: Vec<String>,
115}
116
117/// The canonical valid `--thinking` level strings, in level order. Mirrors TS
118/// `VALID_THINKING_LEVELS`.
119pub const VALID_THINKING_LEVELS: &[&str] =
120    &["off", "minimal", "low", "medium", "high", "xhigh", "max"];
121
122/// Parse a thinking-level string. Mirrors TS `isValidThinkingLevel`.
123pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
124    Some(match s {
125        "off" => ThinkingLevel::Off,
126        "minimal" => ThinkingLevel::Minimal,
127        "low" => ThinkingLevel::Low,
128        "medium" => ThinkingLevel::Medium,
129        "high" => ThinkingLevel::High,
130        "xhigh" => ThinkingLevel::Xhigh,
131        "max" => ThinkingLevel::Max,
132        _ => return None,
133    })
134}
135
136/// `@file`-argument helper mirroring the TS parser: a leading `@` marks a file
137/// attachment (the `@` is stripped).
138fn file_arg(arg: &str) -> Option<PathBuf> {
139    if let Some(rest) = arg.strip_prefix('@') {
140        // Reject the bare `@` (TS keeps it as a message; we treat it as one).
141        if rest.is_empty() {
142            None
143        } else {
144            Some(PathBuf::from(rest))
145        }
146    } else {
147        None
148    }
149}
150
151/// Parse `argv` (excluding the program name). Mirrors TS `parseArgs`.
152///
153/// Long flags accept `--name value` or `--name=value` (the TS parser only
154/// handles `--name=value` for *unknown* flags; we extend it to known flags for
155/// ergonomics). Short flags use a single leading `-`.
156pub fn parse_args(args: &[String]) -> Args {
157    let mut result = Args::default();
158    // `RPI_EXTENSIONS_DIR` env: an extra list of plugin dirs prepended to any
159    // `--extensions-dir` flags. Semicolon-separated on Windows, colon-separated
160    // on Unix (PATH-style). Empty entries skipped. `--no-extensions` still wins.
161    if let Ok(raw) = std::env::var("RPI_EXTENSIONS_DIR") {
162        if !raw.is_empty() {
163            let sep = if cfg!(windows) { ';' } else { ':' };
164            for part in raw.split(sep) {
165                let trimmed = part.trim();
166                if !trimmed.is_empty() {
167                    result.extensions_dir.push(PathBuf::from(trimmed));
168                }
169            }
170        }
171    }
172    let mut i = 0;
173    while i < args.len() {
174        let arg = args[i].clone();
175        // Peel an inline `--flag=value` (long flags only — short flags never use
176        // `=`) so the match below compares bare flag names. `inline` holds the
177        // RHS for `take_value` to consume in place of the next argv token.
178        let (flag_key, inline) = if arg.starts_with("--") {
179            match arg.find('=') {
180                Some(eq) => (arg[..eq].to_string(), Some(arg[eq + 1..].to_string())),
181                None => (arg.clone(), None),
182            }
183        } else {
184            (arg.clone(), None)
185        };
186
187        // Take a value: prefer the inline `--flag=value`, else the next argv
188        // token (when it isn't flag-shaped). Advances `i` past a consumed token.
189        // (For unknown-flag diagnostics the closing arm reads `flag_key` itself.)
190        let mut take_value = |result: &mut Args, _flag: &str| -> Option<String> {
191            if let Some(v) = inline.clone() {
192                return Some(v);
193            }
194            if i + 1 < args.len() {
195                let next = &args[i + 1];
196                if !next.starts_with('-') || next == "-" {
197                    i += 1;
198                    return Some(args[i].clone());
199                }
200            }
201            result.errors.push(format!("{flag_key} requires a value"));
202            None
203        };
204
205        match flag_key.as_str() {
206            "--help" | "-h" => result.help = true,
207            "--version" | "-v" => result.version = true,
208            "--print" | "-p" => {
209                result.print = true;
210                // `-p` may consume the following positional as the prompt
211                // (TS heuristic: next is present, doesn't start with `@`, and
212                // isn't a flag — except `---` which TS lets through; we keep
213                // the simple `!@` && `!-` form).
214                if i + 1 < args.len() {
215                    let next = &args[i + 1];
216                    if !next.starts_with('@') && !next.starts_with('-') {
217                        i += 1;
218                        result.messages.push(args[i].clone());
219                    }
220                }
221            }
222            "--mode" => {
223                if let Some(v) = take_value(&mut result, "--mode") {
224                    result.mode = match v.as_str() {
225                        "text" => Mode::Text,
226                        "json" => Mode::Json,
227                        "rpc" => Mode::Rpc,
228                        other => {
229                            result
230                                .errors
231                                .push(format!("Invalid --mode \"{other}\". Valid: text, json, rpc"));
232                            Mode::Text
233                        }
234                    };
235                }
236            }
237            "--continue" | "-c" => result.continue_session = true,
238            "--resume" | "-r" => result.resume = true,
239            "--no-session" => result.no_session = true,
240            "--no-tools" | "-nt" => result.no_tools = true,
241            "--no-builtin-tools" | "-nbt" => result.no_builtin_tools = true,
242            "--no-skills" | "-ns" => result.no_skills = true,
243            "--no-prompt-templates" | "-np" => result.no_prompt_templates = true,
244            "--no-context-files" | "-nc" => result.no_context_files = true,
245            "--no-extensions" | "-ne" => result.no_extensions = true,
246            "--extensions-dir" | "-ed" => {
247                if let Some(v) = take_value(&mut result, &flag_key) {
248                    result.extensions_dir.push(PathBuf::from(v));
249                }
250            }
251            "--verbose" => result.verbose = true,
252            "--debug-system-prompt" => result.debug_system_prompt = true,
253            "--provider" => result.provider = take_value(&mut result, "--provider"),
254            "--model" => result.model = take_value(&mut result, "--model"),
255            "--api-key" => result.api_key = take_value(&mut result, "--api-key"),
256            "--base-url" => result.base_url = take_value(&mut result, "--base-url"),
257            "--system-prompt" => result.system_prompt = take_value(&mut result, "--system-prompt"),
258            "--append-system-prompt" => {
259                if let Some(v) = take_value(&mut result, "--append-system-prompt") {
260                    result.append_system_prompt.push(v);
261                }
262            }
263            "--name" | "-n" => result.name = take_value(&mut result, "--name"),
264            "--session" => result.session = take_value(&mut result, "--session"),
265            "--session-dir" => {
266                if let Some(v) = take_value(&mut result, "--session-dir") {
267                    result.session_dir = Some(PathBuf::from(v));
268                }
269            }
270            "--thinking" => {
271                if let Some(v) = take_value(&mut result, "--thinking") {
272                    match parse_thinking_level(&v) {
273                        Some(lvl) => result.thinking = Some(lvl),
274                        None => result.ignored.push(format!(
275                            "Invalid --thinking \"{v}\". Valid: {}",
276                            VALID_THINKING_LEVELS.join(", ")
277                        )),
278                    }
279                }
280            }
281            "--tools" | "-t" => {
282                if let Some(v) = take_value(&mut result, &flag_key) {
283                    result.tools = Some(split_csv(&v));
284                }
285            }
286            "--exclude-tools" | "-xt" => {
287                if let Some(v) = take_value(&mut result, &flag_key) {
288                    result.exclude_tools = Some(split_csv(&v));
289                }
290            }
291            // ---- Recognized-but-ignored v1 scope cuts (warn, don't error) ----
292            // `flag_key` has already had any `=value` peeled, so these match the
293            // bare flag name even when the user wrote `--offline=1`.
294            //
295            // NOTE: `--no-skills`/`-ns`, `--no-prompt-templates`/`-np`,
296            // `--no-context-files`/`-nc`, and `--no-extensions`/`-ne` are now
297            // HONORED (parsed into real fields above), so they no longer reach
298            // this arm. The skill/prompt/context flags gate resource discovery
299            // (`session.rs`); `--no-extensions` is a no-op acceptance until the
300            // Part-B plugin system lands.
301            other
302                if matches!(
303                    other,
304                    "--models"
305                        | "--offline"
306                        | "--export"
307                        | "--tui-mode"
308                        | "--approve" | "-a"
309                        | "--no-approve" | "-na"
310                        | "--no-themes"
311                ) =>
312            {
313                // Consume a value if the next token isn't a flag (so
314                // `--models sonnet` doesn't swallow `sonnet` as a message).
315                if inline.is_none()
316                    && i + 1 < args.len()
317                    && !args[i + 1].starts_with('-')
318                    && !args[i + 1].starts_with('@')
319                {
320                    i += 1;
321                }
322                result.ignored.push(format!("{other} is not supported in v1 (ignored)"));
323            }
324            flag @ ("--extension" | "-e" | "--skill" | "--prompt-template" | "--theme") => {
325                // These take a value (or an inline `=`); consume the next token
326                // when there's no inline value so the path isn't read as a
327                // message, then warn.
328                if inline.is_none()
329                    && i + 1 < args.len()
330                    && !args[i + 1].starts_with('-')
331                    && !args[i + 1].starts_with('@')
332                {
333                    i += 1;
334                }
335                result.ignored.push(format!("{flag} is not supported in v1 (ignored)"));
336            }
337            "--list-models" => {
338                // Optionally consumes a search term.
339                if inline.is_none()
340                    && i + 1 < args.len()
341                    && !args[i + 1].starts_with('-')
342                    && !args[i + 1].starts_with('@')
343                {
344                    i += 1;
345                }
346                result.ignored.push("--list-models is not supported in v1 (ignored)".to_string());
347            }
348            "--fork" => {
349                result.ignored.push("--fork is not supported in v1 (ignored)".to_string());
350                if inline.is_none() && i + 1 < args.len() && !args[i + 1].starts_with('-') {
351                    i += 1;
352                }
353            }
354            // Unknown long flag (with or without `=`). `flag_key` already holds
355            // the bare name, so both `--frobnicate` and `--frobnicate=x` land
356            // here; consume a value if the next token isn't a flag/file.
357            other if other.starts_with("--") => {
358                let name = &flag_key;
359                if inline.is_none()
360                    && i + 1 < args.len()
361                    && !args[i + 1].starts_with('-')
362                    && !args[i + 1].starts_with('@')
363                {
364                    i += 1;
365                }
366                result.ignored.push(format!("{name} is not a recognized flag (ignored)"));
367            }
368            // Unknown short flag → hard error (mirrors TS).
369            other if other.starts_with('-') && other.len() > 1 => {
370                result
371                    .errors
372                    .push(format!("Unknown option: {other}"));
373            }
374            // `@file` attachment.
375            other if let Some(path) = file_arg(other) => {
376                result.file_args.push(path);
377            }
378            // Bare positional → prompt message.
379            other => {
380                result.messages.push(other.to_string());
381            }
382        }
383        i += 1;
384    }
385
386    // `--print` + `--mode json`: `--print` implies non-interactive, but
387    // `--mode json` selects the JSON event stream. The TS `resolveAppMode`
388    // treats `mode === "json"` as its own non-interactive mode; we follow that.
389    result
390}
391
392/// Split a comma-separated list (mirrors the TS `.split(',').map(trim)`).
393fn split_csv(v: &str) -> Vec<String> {
394    v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
395}
396
397/// Resolve the effective output [`Mode`]. Mirrors TS `resolveAppMode`:
398/// `rpc`→rpc, `json`→json, `print` or piped-stdin/redirected-stdout→print,
399/// else interactive. Here `stdin_is_tty`/`stdout_is_tty` come from
400/// `std::io::IsTerminal`.
401pub fn resolve_mode(parsed: &Args, stdin_is_tty: bool, stdout_is_tty: bool) -> RunMode {
402    if parsed.mode == Mode::Rpc {
403        return RunMode::Rpc;
404    }
405    if parsed.mode == Mode::Json {
406        return RunMode::Json;
407    }
408    if parsed.print || !stdin_is_tty || !stdout_is_tty {
409        RunMode::Print
410    } else {
411        RunMode::Interactive
412    }
413}
414
415/// The concrete run mode [`resolve_mode`] picks. Mirrors TS `AppMode`
416/// (`interactive`/`print`/`json`/`rpc`). Distinguished from [`Mode`] (the raw
417/// `--mode` flag value) because the effective mode also folds in `-p` + TTY
418/// detection.
419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub enum RunMode {
421    Interactive,
422    Print,
423    Json,
424    Rpc,
425}
426
427/// Print the help text to stdout. Mirrors TS `printHelp`, scoped to v1 flags.
428pub fn print_help() {
429    let builtin = "read, bash, edit, write, grep, find, ls";
430    println!(
431        "{name} - AI coding assistant with read, bash, edit, write, grep, find, ls tools
432
433{u}Usage:{r}
434  {name} [options] [@files...] [messages...]
435
436{u}Options:{r}
437  --provider <name>              Provider name (v1: anthropic)
438  --model <pattern>              Model pattern or ID (supports \"provider/id\" and optional \":<thinking>\")
439  --api-key <key>                API key (x-api-key; defaults to ~/.rpi/auth.json, then ANTHROPIC_API_KEY)
440  --base-url <url>               Override the Anthropic endpoint (defaults to ANTHROPIC_BASE_URL)
441  --system-prompt <text>         Replace the default system prompt
442  --append-system-prompt <text>  Append text to the system prompt (repeatable)
443  --thinking <level>             off, minimal, low, medium, high, xhigh, max
444  --mode <mode>                  Output mode: text (default), json, or rpc
445  --print, -p                    Non-interactive: process prompt(s) and exit
446  --continue, -c                 Continue the most recent session
447  --resume, -r                   Browse and select a session to resume
448  --session <id|path>            Use a specific session (partial UUID or file)
449  --session-dir <dir>            Directory for session storage
450  --no-session                   Ephemeral mode (do not persist the session)
451  --name, -n <name>              Set the session display name
452  --tools, -t <list>             Comma-separated allowlist of tool names to enable
453  --exclude-tools, -xt <list>    Comma-separated denylist of tool names to disable
454  --no-tools, -nt                Disable all tools
455  --no-builtin-tools, -nbt       Disable the built-in tools (read, bash, edit, write, grep, find, ls)
456  --no-skills, -ns               Skip skill discovery (no <available_skills> block)
457  --no-prompt-templates, -np     Skip prompt-template discovery (/expand templates)
458  --no-context-files, -nc        Skip AGENTS.md/CLAUDE.md discovery (no <project_context>)
459  --no-extensions, -ne           Skip cdylib plugin/extension loading entirely
460  --extensions-dir, -ed <dir>    Extra dir to scan for plugins (.dll/.so/.dylib); repeatable
461                                 (also via RPI_EXTENSIONS_DIR env: ';' on Windows, ':' on Unix)
462  --debug-system-prompt          Print the resolved system-prompt sections to stderr (verification)
463  --verbose                      Show startup warnings (e.g. ignored flags)
464  --help, -h                     Show this help
465  --version, -v                  Show version
466
467{u}Subcommands:{r}
468  auth login|check|logout        Manage persisted credentials in ~/.rpi/auth.json
469                                (see `rpi auth --help`)
470
471{u}Built-in Tools:{r}
472  {builtin}  (enabled by default; grep/find/ls are read-only)
473
474{u}Examples:{r}
475  # Interactive with an initial prompt
476  {name} \"List all .rs files in src/\"
477
478  # Single-shot print mode
479  {name} -p \"Summarize this project\"
480
481  # Include a file in the initial message
482  {name} @README.md \"What does this project do?\"
483
484  # Continue the previous session
485  {name} -c \"What did we discuss?\"
486
487  # Use a specific model + thinking level
488  {name} --model claude-sonnet-5 --thinking high \"Refactor this\"
489
490  # JSON event stream (one JSON object per line on stdout)
491  {name} --mode json -p \"Inspect the code\"
492
493  # Read-only: no file-modifying tools
494  {name} --tools read,bash -p \"Review the code in src/\"
495
496{u}Environment:{r}
497  ANTHROPIC_API_KEY              Anthropic API key (x-api-key) — fallback when no stored credential
498  ANTHROPIC_AUTH_TOKEN           Bearer token (Authorization: Bearer) for third-party gateways
499  ANTHROPIC_BASE_URL             Override the Anthropic endpoint (e.g. a compatible proxy)
500  RPI_CODING_AGENT_DIR           Override the ~/.rpi config directory (auth.json + models.json)
501
502{u}Notes:{r}
503  v1 speaks the Anthropic Messages protocol only. Auth is resolved in order:
504  --api-key → ~/.rpi/auth.json (via `rpi auth login`) → ANTHROPIC_AUTH_TOKEN
505  (Bearer) → ANTHROPIC_API_KEY (x-api-key). Define custom model catalogs in
506  ~/.rpi/models.json. TUI, extensions, skills, prompt templates, themes, model
507  cycling, package manager, HTML export, --fork, --list-models, --export, and
508  OAuth are recognized but not implemented yet.
509",
510        name = crate::APP_NAME,
511        builtin = builtin,
512        u = "\x1b[1m",
513        r = "\x1b[0m",
514    );
515}
516
517/// Print the version line. Mirrors TS `--version` output (`pi <version>`).
518pub fn print_version() {
519    println!("{} {}", crate::APP_NAME, crate::VERSION);
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    fn s(args: &[&str]) -> Vec<String> {
527        args.iter().map(|a| a.to_string()).collect()
528    }
529
530    #[test]
531    fn parses_basic_prompt() {
532        let a = parse_args(&s(&["hello", "world"]));
533        assert_eq!(a.messages, vec!["hello".to_string(), "world".to_string()]);
534        assert!(!a.help);
535    }
536
537    #[test]
538    fn parses_help_and_version() {
539        let a = parse_args(&s(&["--help"]));
540        assert!(a.help);
541        let a = parse_args(&s(&["-v"]));
542        assert!(a.version);
543    }
544
545    #[test]
546    fn print_consumes_following_positional() {
547        let a = parse_args(&s(&["-p", "summarize"]));
548        assert!(a.print);
549        assert_eq!(a.messages, vec!["summarize".to_string()]);
550    }
551
552    #[test]
553    fn print_does_not_consume_file_or_flag() {
554        let a = parse_args(&s(&["-p", "@file.md"]));
555        assert!(a.print);
556        assert!(a.messages.is_empty());
557        assert_eq!(a.file_args, vec![PathBuf::from("file.md")]);
558    }
559
560    #[test]
561    fn model_and_thinking() {
562        let a = parse_args(&s(&["--model", "claude-sonnet-5", "--thinking", "high"]));
563        assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
564        assert_eq!(a.thinking, Some(ThinkingLevel::High));
565    }
566
567    #[test]
568    fn model_with_thinking_shorthand() {
569        let a = parse_args(&s(&["--model", "claude-sonnet-5:high"]));
570        // The model pattern keeps the `:high`; provider resolution splits it.
571        assert_eq!(a.model.as_deref(), Some("claude-sonnet-5:high"));
572    }
573
574    #[test]
575    fn tools_split_csv() {
576        let a = parse_args(&s(&["--tools", "read, bash ,write"]));
577        assert_eq!(a.tools.as_deref(), Some(&["read".to_string(), "bash".to_string(), "write".to_string()][..]));
578    }
579
580    #[test]
581    fn unknown_short_flag_errors() {
582        let a = parse_args(&s(&["-Z"]));
583        assert!(!a.errors.is_empty());
584    }
585
586    #[test]
587    fn unknown_long_flag_warns_not_errors() {
588        let a = parse_args(&s(&["--frobnicate", "value"]));
589        assert!(a.errors.is_empty());
590        assert!(!a.ignored.is_empty());
591    }
592
593    #[test]
594    fn ignored_scope_cuts_warn() {
595        let a = parse_args(&s(&["--models", "sonnet"]));
596        assert!(a.errors.is_empty());
597        assert!(!a.ignored.is_empty());
598        // The value is consumed, not read as a message:
599        assert!(a.messages.is_empty());
600    }
601
602    #[test]
603    fn no_skills_flag_honored() {
604        let a = parse_args(&s(&["-ns"]));
605        assert!(a.errors.is_empty());
606        assert!(a.no_skills);
607        // Honored flags do NOT also warn-ignore themselves.
608        assert!(a.ignored.is_empty());
609    }
610
611    #[test]
612    fn no_prompt_templates_flag_honored() {
613        let a = parse_args(&s(&["--no-prompt-templates"]));
614        assert!(a.no_prompt_templates);
615        assert!(a.ignored.is_empty());
616    }
617
618    #[test]
619    fn no_context_files_flag_honored() {
620        let a = parse_args(&s(&["-nc"]));
621        assert!(a.no_context_files);
622        assert!(a.ignored.is_empty());
623    }
624
625    #[test]
626    fn no_extensions_flag_honored() {
627        // `--no-extensions` is now parsed (no-op acceptance until Part B), no
628        // longer a warn-ignored v1 scope cut.
629        let a = parse_args(&s(&["--no-extensions"]));
630        assert!(a.no_extensions);
631        assert!(a.ignored.is_empty());
632    }
633
634    #[test]
635    fn extensions_dir_flag_collects_dirs() {
636        let a = parse_args(&s(&["--extensions-dir", "/a/b", "-ed", "/c/d"]));
637        assert_eq!(a.extensions_dir, vec![PathBuf::from("/a/b"), PathBuf::from("/c/d")]);
638        assert!(a.ignored.is_empty());
639    }
640
641    #[test]
642    fn extensions_dir_inline_equals_form() {
643        let a = parse_args(&s(&["--extensions-dir=/x/y"]));
644        assert_eq!(a.extensions_dir, vec![PathBuf::from("/x/y")]);
645    }
646
647    #[test]
648    fn extensions_dir_env_is_merged() {
649        // The env var contributes its split list. We can't fully control env in
650        // a unit test without `set_var` (process-global + racy under parallel
651        // tests), so this asserts the flag path only; the env path is exercised
652        // by the B2 smoke. Keep the test green regardless of the host env by
653        // NOT asserting emptiness — just confirm the flag appends after env.
654        let a = parse_args(&s(&["--extensions-dir", "/flag/only"]));
655        assert!(a.extensions_dir.iter().any(|p| p == &PathBuf::from("/flag/only")));
656    }
657
658    #[test]
659    fn file_args_stripped() {
660        let a = parse_args(&s(&["@a.txt", "@b.md", "hi"]));
661        assert_eq!(a.file_args, vec![PathBuf::from("a.txt"), PathBuf::from("b.md")]);
662        assert_eq!(a.messages, vec!["hi".to_string()]);
663    }
664
665    #[test]
666    fn equals_form_supported() {
667        let a = parse_args(&s(&["--model=claude-sonnet-5", "--thinking=low"]));
668        assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
669        assert_eq!(a.thinking, Some(ThinkingLevel::Low));
670    }
671
672    #[test]
673    fn resolve_mode_interactive_when_tty() {
674        let a = Args { print: true, ..Args::default() };
675        assert_eq!(resolve_mode(&a, true, true), RunMode::Print);
676        let a = Args::default();
677        assert_eq!(resolve_mode(&a, true, true), RunMode::Interactive);
678        let a = Args { mode: Mode::Json, ..Args::default() };
679        assert_eq!(resolve_mode(&a, true, true), RunMode::Json);
680        let a = Args { mode: Mode::Rpc, ..Args::default() };
681        assert_eq!(resolve_mode(&a, true, true), RunMode::Rpc);
682    }
683
684    #[test]
685    fn piped_stdout_forces_print() {
686        let a = Args::default();
687        // stdout not a TTY ⇒ print even without -p (mirrors TS).
688        assert_eq!(resolve_mode(&a, true, false), RunMode::Print);
689    }
690}