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