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