Skip to main content

rpi_cli/
args.rs

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