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