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