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