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