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    pub system_prompt: Option<String>,
49    pub append_system_prompt: Vec<String>,
50    pub thinking: Option<ThinkingLevel>,
51
52    pub print: bool,
53    pub mode: Mode,
54
55    pub continue_session: bool,
56    pub resume: bool,
57    pub session: Option<String>,
58    pub session_dir: Option<PathBuf>,
59    pub no_session: bool,
60    pub name: Option<String>,
61
62    pub tools: Option<Vec<String>>,
63    pub exclude_tools: Option<Vec<String>>,
64    pub no_tools: bool,
65    pub no_builtin_tools: bool,
66
67    pub verbose: bool,
68    pub help: bool,
69    pub version: bool,
70
71    /// Positional prompt text (one or more messages). Mirrors TS `messages`.
72    pub messages: Vec<String>,
73    /// `@file` attachments (prefix stripped), as raw paths for the caller to
74    /// expand. Mirrors TS `fileArgs`.
75    pub file_args: Vec<PathBuf>,
76
77    /// Warnings about recognized-but-ignored flags (v1 scope cuts). Surfaced
78    /// to the user on startup when `--verbose`.
79    pub ignored: Vec<String>,
80    /// Hard parse errors (unknown short flags, missing values). Non-empty ⇒
81    /// `main` prints them + help and exits non-zero.
82    pub errors: Vec<String>,
83}
84
85/// The canonical valid `--thinking` level strings, in level order. Mirrors TS
86/// `VALID_THINKING_LEVELS`.
87pub const VALID_THINKING_LEVELS: &[&str] =
88    &["off", "minimal", "low", "medium", "high", "xhigh", "max"];
89
90/// Parse a thinking-level string. Mirrors TS `isValidThinkingLevel`.
91pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
92    Some(match s {
93        "off" => ThinkingLevel::Off,
94        "minimal" => ThinkingLevel::Minimal,
95        "low" => ThinkingLevel::Low,
96        "medium" => ThinkingLevel::Medium,
97        "high" => ThinkingLevel::High,
98        "xhigh" => ThinkingLevel::Xhigh,
99        "max" => ThinkingLevel::Max,
100        _ => return None,
101    })
102}
103
104/// `@file`-argument helper mirroring the TS parser: a leading `@` marks a file
105/// attachment (the `@` is stripped).
106fn file_arg(arg: &str) -> Option<PathBuf> {
107    if let Some(rest) = arg.strip_prefix('@') {
108        // Reject the bare `@` (TS keeps it as a message; we treat it as one).
109        if rest.is_empty() {
110            None
111        } else {
112            Some(PathBuf::from(rest))
113        }
114    } else {
115        None
116    }
117}
118
119/// Parse `argv` (excluding the program name). Mirrors TS `parseArgs`.
120///
121/// Long flags accept `--name value` or `--name=value` (the TS parser only
122/// handles `--name=value` for *unknown* flags; we extend it to known flags for
123/// ergonomics). Short flags use a single leading `-`.
124pub fn parse_args(args: &[String]) -> Args {
125    let mut result = Args::default();
126    let mut i = 0;
127    while i < args.len() {
128        let arg = args[i].clone();
129        // Peel an inline `--flag=value` (long flags only — short flags never use
130        // `=`) so the match below compares bare flag names. `inline` holds the
131        // RHS for `take_value` to consume in place of the next argv token.
132        let (flag_key, inline) = if arg.starts_with("--") {
133            match arg.find('=') {
134                Some(eq) => (arg[..eq].to_string(), Some(arg[eq + 1..].to_string())),
135                None => (arg.clone(), None),
136            }
137        } else {
138            (arg.clone(), None)
139        };
140
141        // Take a value: prefer the inline `--flag=value`, else the next argv
142        // token (when it isn't flag-shaped). Advances `i` past a consumed token.
143        // (For unknown-flag diagnostics the closing arm reads `flag_key` itself.)
144        let mut take_value = |result: &mut Args, _flag: &str| -> Option<String> {
145            if let Some(v) = inline.clone() {
146                return Some(v);
147            }
148            if i + 1 < args.len() {
149                let next = &args[i + 1];
150                if !next.starts_with('-') || next == "-" {
151                    i += 1;
152                    return Some(args[i].clone());
153                }
154            }
155            result.errors.push(format!("{flag_key} requires a value"));
156            None
157        };
158
159        match flag_key.as_str() {
160            "--help" | "-h" => result.help = true,
161            "--version" | "-v" => result.version = true,
162            "--print" | "-p" => {
163                result.print = true;
164                // `-p` may consume the following positional as the prompt
165                // (TS heuristic: next is present, doesn't start with `@`, and
166                // isn't a flag — except `---` which TS lets through; we keep
167                // the simple `!@` && `!-` form).
168                if i + 1 < args.len() {
169                    let next = &args[i + 1];
170                    if !next.starts_with('@') && !next.starts_with('-') {
171                        i += 1;
172                        result.messages.push(args[i].clone());
173                    }
174                }
175            }
176            "--mode" => {
177                if let Some(v) = take_value(&mut result, "--mode") {
178                    result.mode = match v.as_str() {
179                        "text" => Mode::Text,
180                        "json" => Mode::Json,
181                        "rpc" => Mode::Rpc,
182                        other => {
183                            result
184                                .errors
185                                .push(format!("Invalid --mode \"{other}\". Valid: text, json, rpc"));
186                            Mode::Text
187                        }
188                    };
189                }
190            }
191            "--continue" | "-c" => result.continue_session = true,
192            "--resume" | "-r" => result.resume = true,
193            "--no-session" => result.no_session = true,
194            "--no-tools" | "-nt" => result.no_tools = true,
195            "--no-builtin-tools" | "-nbt" => result.no_builtin_tools = true,
196            "--verbose" => result.verbose = true,
197            "--provider" => result.provider = take_value(&mut result, "--provider"),
198            "--model" => result.model = take_value(&mut result, "--model"),
199            "--api-key" => result.api_key = take_value(&mut result, "--api-key"),
200            "--system-prompt" => result.system_prompt = take_value(&mut result, "--system-prompt"),
201            "--append-system-prompt" => {
202                if let Some(v) = take_value(&mut result, "--append-system-prompt") {
203                    result.append_system_prompt.push(v);
204                }
205            }
206            "--name" | "-n" => result.name = take_value(&mut result, "--name"),
207            "--session" => result.session = take_value(&mut result, "--session"),
208            "--session-dir" => {
209                if let Some(v) = take_value(&mut result, "--session-dir") {
210                    result.session_dir = Some(PathBuf::from(v));
211                }
212            }
213            "--thinking" => {
214                if let Some(v) = take_value(&mut result, "--thinking") {
215                    match parse_thinking_level(&v) {
216                        Some(lvl) => result.thinking = Some(lvl),
217                        None => result.ignored.push(format!(
218                            "Invalid --thinking \"{v}\". Valid: {}",
219                            VALID_THINKING_LEVELS.join(", ")
220                        )),
221                    }
222                }
223            }
224            "--tools" | "-t" => {
225                if let Some(v) = take_value(&mut result, &flag_key) {
226                    result.tools = Some(split_csv(&v));
227                }
228            }
229            "--exclude-tools" | "-xt" => {
230                if let Some(v) = take_value(&mut result, &flag_key) {
231                    result.exclude_tools = Some(split_csv(&v));
232                }
233            }
234            // ---- Recognized-but-ignored v1 scope cuts (warn, don't error) ----
235            // `flag_key` has already had any `=value` peeled, so these match the
236            // bare flag name even when the user wrote `--offline=1`.
237            other
238                if matches!(
239                    other,
240                    "--models"
241                        | "--offline"
242                        | "--export"
243                        | "--tui-mode"
244                        | "--approve" | "-a"
245                        | "--no-approve" | "-na"
246                        | "--no-extensions" | "-ne"
247                        | "--no-skills" | "-ns"
248                        | "--no-prompt-templates" | "-np"
249                        | "--no-themes"
250                        | "--no-context-files" | "-nc"
251                ) =>
252            {
253                // Consume a value if the next token isn't a flag (so
254                // `--models sonnet` doesn't swallow `sonnet` as a message).
255                if inline.is_none()
256                    && i + 1 < args.len()
257                    && !args[i + 1].starts_with('-')
258                    && !args[i + 1].starts_with('@')
259                {
260                    i += 1;
261                }
262                result.ignored.push(format!("{other} is not supported in v1 (ignored)"));
263            }
264            flag @ ("--extension" | "-e" | "--skill" | "--prompt-template" | "--theme") => {
265                // These take a value (or an inline `=`); consume the next token
266                // when there's no inline value so the path isn't read as a
267                // message, then warn.
268                if inline.is_none()
269                    && i + 1 < args.len()
270                    && !args[i + 1].starts_with('-')
271                    && !args[i + 1].starts_with('@')
272                {
273                    i += 1;
274                }
275                result.ignored.push(format!("{flag} is not supported in v1 (ignored)"));
276            }
277            "--list-models" => {
278                // Optionally consumes a search term.
279                if inline.is_none()
280                    && i + 1 < args.len()
281                    && !args[i + 1].starts_with('-')
282                    && !args[i + 1].starts_with('@')
283                {
284                    i += 1;
285                }
286                result.ignored.push("--list-models is not supported in v1 (ignored)".to_string());
287            }
288            "--fork" => {
289                result.ignored.push("--fork is not supported in v1 (ignored)".to_string());
290                if inline.is_none() && i + 1 < args.len() && !args[i + 1].starts_with('-') {
291                    i += 1;
292                }
293            }
294            // Unknown long flag (with or without `=`). `flag_key` already holds
295            // the bare name, so both `--frobnicate` and `--frobnicate=x` land
296            // here; consume a value if the next token isn't a flag/file.
297            other if other.starts_with("--") => {
298                let name = &flag_key;
299                if inline.is_none()
300                    && i + 1 < args.len()
301                    && !args[i + 1].starts_with('-')
302                    && !args[i + 1].starts_with('@')
303                {
304                    i += 1;
305                }
306                result.ignored.push(format!("{name} is not a recognized flag (ignored)"));
307            }
308            // Unknown short flag → hard error (mirrors TS).
309            other if other.starts_with('-') && other.len() > 1 => {
310                result
311                    .errors
312                    .push(format!("Unknown option: {other}"));
313            }
314            // `@file` attachment.
315            other if let Some(path) = file_arg(other) => {
316                result.file_args.push(path);
317            }
318            // Bare positional → prompt message.
319            other => {
320                result.messages.push(other.to_string());
321            }
322        }
323        i += 1;
324    }
325
326    // `--print` + `--mode json`: `--print` implies non-interactive, but
327    // `--mode json` selects the JSON event stream. The TS `resolveAppMode`
328    // treats `mode === "json"` as its own non-interactive mode; we follow that.
329    result
330}
331
332/// Split a comma-separated list (mirrors the TS `.split(',').map(trim)`).
333fn split_csv(v: &str) -> Vec<String> {
334    v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
335}
336
337/// Resolve the effective output [`Mode`]. Mirrors TS `resolveAppMode`:
338/// `rpc`→rpc, `json`→json, `print` or piped-stdin/redirected-stdout→print,
339/// else interactive. Here `stdin_is_tty`/`stdout_is_tty` come from
340/// `std::io::IsTerminal`.
341pub fn resolve_mode(parsed: &Args, stdin_is_tty: bool, stdout_is_tty: bool) -> RunMode {
342    if parsed.mode == Mode::Rpc {
343        return RunMode::Rpc;
344    }
345    if parsed.mode == Mode::Json {
346        return RunMode::Json;
347    }
348    if parsed.print || !stdin_is_tty || !stdout_is_tty {
349        RunMode::Print
350    } else {
351        RunMode::Interactive
352    }
353}
354
355/// The concrete run mode [`resolve_mode`] picks. Mirrors TS `AppMode`
356/// (`interactive`/`print`/`json`/`rpc`). Distinguished from [`Mode`] (the raw
357/// `--mode` flag value) because the effective mode also folds in `-p` + TTY
358/// detection.
359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub enum RunMode {
361    Interactive,
362    Print,
363    Json,
364    Rpc,
365}
366
367/// Print the help text to stdout. Mirrors TS `printHelp`, scoped to v1 flags.
368pub fn print_help() {
369    let builtin = "read, bash, edit, write, grep, find, ls";
370    println!(
371        "{name} - AI coding assistant with read, bash, edit, write, grep, find, ls tools
372
373{u}Usage:{r}
374  {name} [options] [@files...] [messages...]
375
376{u}Options:{r}
377  --provider <name>              Provider name (v1: anthropic)
378  --model <pattern>              Model pattern or ID (supports \"provider/id\" and optional \":<thinking>\")
379  --api-key <key>                API key (defaults to ANTHROPIC_API_KEY)
380  --system-prompt <text>         Replace the default system prompt
381  --append-system-prompt <text>  Append text to the system prompt (repeatable)
382  --thinking <level>             off, minimal, low, medium, high, xhigh, max
383  --mode <mode>                  Output mode: text (default), json, or rpc
384  --print, -p                    Non-interactive: process prompt(s) and exit
385  --continue, -c                 Continue the most recent session
386  --resume, -r                   Browse and select a session to resume
387  --session <id|path>            Use a specific session (partial UUID or file)
388  --session-dir <dir>            Directory for session storage
389  --no-session                   Ephemeral mode (do not persist the session)
390  --name, -n <name>              Set the session display name
391  --tools, -t <list>             Comma-separated allowlist of tool names to enable
392  --exclude-tools, -xt <list>    Comma-separated denylist of tool names to disable
393  --no-tools, -nt                Disable all tools
394  --no-builtin-tools, -nbt       Disable the built-in tools (read, bash, edit, write, grep, find, ls)
395  --verbose                      Show startup warnings (e.g. ignored flags)
396  --help, -h                     Show this help
397  --version, -v                  Show version
398
399{u}Built-in Tools:{r}
400  {builtin}  (enabled by default; grep/find/ls are read-only)
401
402{u}Examples:{r}
403  # Interactive with an initial prompt
404  {name} \"List all .rs files in src/\"
405
406  # Single-shot print mode
407  {name} -p \"Summarize this project\"
408
409  # Include a file in the initial message
410  {name} @README.md \"What does this project do?\"
411
412  # Continue the previous session
413  {name} -c \"What did we discuss?\"
414
415  # Use a specific model + thinking level
416  {name} --model claude-sonnet-5 --thinking high \"Refactor this\"
417
418  # JSON event stream (one JSON object per line on stdout)
419  {name} --mode json -p \"Inspect the code\"
420
421  # Read-only: no file-modifying tools
422  {name} --tools read,bash -p \"Review the code in src/\"
423
424{u}Environment:{r}
425  ANTHROPIC_API_KEY              Anthropic API key (required for real runs)
426
427{u}Notes:{r}
428  v1 is Anthropic-only (API key). TUI, extensions, skills, prompt templates,
429  themes, model cycling, package manager, HTML export, --fork, --list-models,
430  --export, and OAuth are recognized but not implemented yet.
431",
432        name = crate::APP_NAME,
433        builtin = builtin,
434        u = "\x1b[1m",
435        r = "\x1b[0m",
436    );
437}
438
439/// Print the version line. Mirrors TS `--version` output (`pi <version>`).
440pub fn print_version() {
441    println!("{} {}", crate::APP_NAME, crate::VERSION);
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    fn s(args: &[&str]) -> Vec<String> {
449        args.iter().map(|a| a.to_string()).collect()
450    }
451
452    #[test]
453    fn parses_basic_prompt() {
454        let a = parse_args(&s(&["hello", "world"]));
455        assert_eq!(a.messages, vec!["hello".to_string(), "world".to_string()]);
456        assert!(!a.help);
457    }
458
459    #[test]
460    fn parses_help_and_version() {
461        let a = parse_args(&s(&["--help"]));
462        assert!(a.help);
463        let a = parse_args(&s(&["-v"]));
464        assert!(a.version);
465    }
466
467    #[test]
468    fn print_consumes_following_positional() {
469        let a = parse_args(&s(&["-p", "summarize"]));
470        assert!(a.print);
471        assert_eq!(a.messages, vec!["summarize".to_string()]);
472    }
473
474    #[test]
475    fn print_does_not_consume_file_or_flag() {
476        let a = parse_args(&s(&["-p", "@file.md"]));
477        assert!(a.print);
478        assert!(a.messages.is_empty());
479        assert_eq!(a.file_args, vec![PathBuf::from("file.md")]);
480    }
481
482    #[test]
483    fn model_and_thinking() {
484        let a = parse_args(&s(&["--model", "claude-sonnet-5", "--thinking", "high"]));
485        assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
486        assert_eq!(a.thinking, Some(ThinkingLevel::High));
487    }
488
489    #[test]
490    fn model_with_thinking_shorthand() {
491        let a = parse_args(&s(&["--model", "claude-sonnet-5:high"]));
492        // The model pattern keeps the `:high`; provider resolution splits it.
493        assert_eq!(a.model.as_deref(), Some("claude-sonnet-5:high"));
494    }
495
496    #[test]
497    fn tools_split_csv() {
498        let a = parse_args(&s(&["--tools", "read, bash ,write"]));
499        assert_eq!(a.tools.as_deref(), Some(&["read".to_string(), "bash".to_string(), "write".to_string()][..]));
500    }
501
502    #[test]
503    fn unknown_short_flag_errors() {
504        let a = parse_args(&s(&["-Z"]));
505        assert!(!a.errors.is_empty());
506    }
507
508    #[test]
509    fn unknown_long_flag_warns_not_errors() {
510        let a = parse_args(&s(&["--frobnicate", "value"]));
511        assert!(a.errors.is_empty());
512        assert!(!a.ignored.is_empty());
513    }
514
515    #[test]
516    fn ignored_scope_cuts_warn() {
517        let a = parse_args(&s(&["--models", "sonnet"]));
518        assert!(a.errors.is_empty());
519        assert!(!a.ignored.is_empty());
520        // The value is consumed, not read as a message:
521        assert!(a.messages.is_empty());
522    }
523
524    #[test]
525    fn file_args_stripped() {
526        let a = parse_args(&s(&["@a.txt", "@b.md", "hi"]));
527        assert_eq!(a.file_args, vec![PathBuf::from("a.txt"), PathBuf::from("b.md")]);
528        assert_eq!(a.messages, vec!["hi".to_string()]);
529    }
530
531    #[test]
532    fn equals_form_supported() {
533        let a = parse_args(&s(&["--model=claude-sonnet-5", "--thinking=low"]));
534        assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
535        assert_eq!(a.thinking, Some(ThinkingLevel::Low));
536    }
537
538    #[test]
539    fn resolve_mode_interactive_when_tty() {
540        let a = Args { print: true, ..Args::default() };
541        assert_eq!(resolve_mode(&a, true, true), RunMode::Print);
542        let a = Args::default();
543        assert_eq!(resolve_mode(&a, true, true), RunMode::Interactive);
544        let a = Args { mode: Mode::Json, ..Args::default() };
545        assert_eq!(resolve_mode(&a, true, true), RunMode::Json);
546        let a = Args { mode: Mode::Rpc, ..Args::default() };
547        assert_eq!(resolve_mode(&a, true, true), RunMode::Rpc);
548    }
549
550    #[test]
551    fn piped_stdout_forces_print() {
552        let a = Args::default();
553        // stdout not a TTY ⇒ print even without -p (mirrors TS).
554        assert_eq!(resolve_mode(&a, true, false), RunMode::Print);
555    }
556}