Skip to main content

rpi_cli/
app.rs

1//! CLI entry orchestrator. The `main(args)` function that:
2//!
3//! 1. Parses argv ([`crate::args::parse_args`]).
4//! 2. Handles `--help`/`--version` + parse errors + startup warnings.
5//! 3. Reads piped stdin (non-TTY ⇒ treat as the initial prompt text).
6//! 4. Expands `@file` attachments into an initial-message text block
7//!    ([`build_initial_message`]).
8//! 5. Resolves the provider + model + thinking level ([`crate::provider::resolve`]).
9//! 6. Builds the harness ([`crate::session::build`]).
10//! 7. Resolves the effective run mode ([`crate::args::resolve_mode`]) and
11//!    dispatches to [`crate::modes`] (`print`/`json`/`interactive`), mapping the
12//!    outcome to an exit code.
13//!
14//! `@file` expansion covers text files; images are detected but not attached to
15//! the prompt — the harness `prompt_text` accepts images, but this path does not
16//! yet wire an image processor. Binary/non-UTF-8 files error.
17//!
18use std::io::{IsTerminal, Read};
19use std::path::Path;
20
21use rpi_ai::types::{ImageContent, ImageContentType};
22
23use crate::args::{parse_args, print_help, print_version, resolve_mode, Args, RunMode};
24use crate::provider::{resolve_for_cwd, ResolveError};
25use crate::session::{build, BuildError};
26
27/// The exit code for a usage/parse error. (TS `main.ts` uses `process.exit(1)`
28/// for most error paths; v1 distinguishes usage errors with the conventional
29/// `2` so scripts can tell "bad invocation" from "run failed".)
30pub const EXIT_USAGE: i32 = 2;
31/// The exit code for a runtime failure (model-resolution, harness-build, or
32/// run failure). Mirrors TS `process.exitCode` set from `runPrintMode`.
33pub const EXIT_RUNTIME: i32 = 1;
34/// The exit code when an extension **vetoes** startup at a lifecycle event
35/// (e.g. a `BeforeTuiStart` handler returns [`rpi_plugin_sdk::EVENT_HANDLER_ABORT`]).
36/// Distinct from [`EXIT_RUNTIME`] so scripts can tell "an extension refused to
37/// start" from "the run itself failed".
38pub const EXIT_VETOED: i32 = 3;
39
40/// Read a long-flag value from argv, supporting both `--flag <value>` and
41/// `--flag=value`, without disturbing the normal parser. Used for the early
42/// `--connect` interception.
43fn take_flag_value(argv: &[String], flag: &str) -> Option<String> {
44    let prefix = format!("{flag}=");
45    let mut iter = argv.iter();
46    while let Some(arg) = iter.next() {
47        if let Some(value) = arg.strip_prefix(&prefix) {
48            return Some(value.to_string());
49        }
50        if arg == flag {
51            return iter.next().cloned();
52        }
53    }
54    None
55}
56
57/// Resolve the `--connect` auth token: the `--token` flag wins, then the
58/// `RPI_SERVER_TOKEN` environment variable (ignoring empty/whitespace values).
59fn resolve_connect_token(argv: &[String]) -> Option<String> {
60    take_flag_value(argv, "--token").or_else(|| {
61        std::env::var("RPI_SERVER_TOKEN")
62            .ok()
63            .map(|value| value.trim().to_string())
64            .filter(|value| !value.is_empty())
65    })
66}
67
68/// The v1 CLI entry point. Mirrors TS `export async function main(args)`.
69///
70/// Returns the process exit code (0 = success). The binary wrapper
71/// ([`crate::bin`] / `src/bin/rpi.rs`) calls this under a tokio runtime and
72/// `std::process::exit`s with the returned code.
73pub async fn run() -> i32 {
74    // Startup profiling wrapper: RPI_TIMING=1 records namespace timings and
75    // flushes them to stderr once the run completes, regardless of exit path.
76    crate::timings::reset(crate::timings::TimingNamespace::Main);
77    let code = run_inner().await;
78    crate::timings::print_timings();
79    code
80}
81
82async fn run_inner() -> i32 {
83    // argv[0] is the program name; skip it (TS `main(args)` receives the same,
84    // already sliced by the Node CLI entry).
85    let mut argv: Vec<String> = std::env::args().skip(1).collect();
86
87    if argv.first().map(String::as_str) == Some("__rpi_dev_cleanup") {
88        return crate::dev_extension::run_cleanup_helper(&argv[1..]);
89    }
90
91    // ---- `rpi --connect <addr>` — pure remote client ----
92    // Intercepted before any provider / harness / session / extension work so
93    // the client holds no local agent resources at all: it only connects to a
94    // running `rpi --server` and renders the remote transcript.
95    if let Some(addr) = take_flag_value(&argv, "--connect") {
96        let token = resolve_connect_token(&argv);
97        return crate::remote::tui::run(&addr, token.as_deref()).await;
98    }
99
100    // Normalize the CLI flag into RPI_OFFLINE so early-dispatch subcommands and
101    // the regular parsed path all observe the same process-wide gate.
102    crate::args::normalize_offline_mode(&argv);
103
104    // `rpi dev` wraps the normal CLI: consume only development-specific
105    // options, then pass every remaining argument through the regular parser.
106    let dev_command = argv.first().map(String::as_str);
107    let dev_options = if matches!(dev_command, Some("dev" | "dev-local")) {
108        match crate::dev_extension::parse_args(&argv[1..]) {
109            Ok(options) if options.help => {
110                crate::dev_extension::print_help();
111                return 0;
112            }
113            Ok(mut options) => {
114                if dev_command == Some("dev-local") {
115                    options.local_only = true;
116                }
117                argv = options.passthrough.clone();
118                Some(options)
119            }
120            Err(error) => {
121                eprintln!("error: {error}");
122                crate::dev_extension::print_help();
123                return EXIT_USAGE;
124            }
125        }
126    } else {
127        None
128    };
129
130    // ---- Top-level subcommand dispatch (before flag parsing) ----
131    // These are top-level subcommands (mirrors TS `runAuthCommand` / the
132    // `handlePackageCommand` / `handleConfigCommand` routing in `main.ts`);
133    // dispatching them here avoids them being misparsed as prompts. The set is
134    // fixed and each handler has a different shape (sync vs async, extra argv
135    // inspection), so a `match` on the first argument — not a trait registry —
136    // is the right level of abstraction (and matches the `match mode { .. }`
137    // dispatch at the end of this function).
138    match argv.first().map(String::as_str) {
139        Some("auth") => {
140            let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
141            let dirs = crate::resource_dirs::extension_dirs(&cwd);
142            let diagnostics: std::sync::Arc<dyn rpi_extensions::PluginDiagnostics> =
143                std::sync::Arc::new(rpi_extensions::NullDiagnostics);
144            let extension_session = rpi_extensions::load_session(&dirs, diagnostics, None);
145            return crate::auth::run_with_extensions(&argv[1..], extension_session.snapshot_arc())
146                .await;
147        }
148        Some("events") => return crate::events::run(&argv[1..]).await,
149        Some("update") => return crate::updates::run_self_update(&argv[1..]),
150        Some("install") => return crate::install::run(&argv[1..]),
151        Some("uninstall") => return crate::install::uninstall(&argv[1..]),
152        Some("package") => return crate::packages::run(&argv[1..]),
153        // These historical aliases are intentionally rejected instead of
154        // falling through to the prompt path.
155        Some(removed @ ("pi-package" | "pi-update" | "install-pi" | "uninstall-pi")) => {
156            eprintln!(
157                "error: `rpi {removed}` is not available; use `rpi package`, `rpi install`, or `rpi update`."
158            );
159            return EXIT_USAGE;
160        }
161        // // Never a working invocation; the same fall-through applies.
162        // Some("self-update") => {
163        //     eprintln!("error: unknown command `self-update`; use `rpi update`");
164        //     return EXIT_USAGE;
165        // }
166        _ => {}
167    }
168
169    let mut parsed = parse_args(&argv);
170    crate::timings::time("args parsed", crate::timings::TimingNamespace::Main);
171
172    // ---- Parsed startup stages ----
173    if let Some(code) = handle_parse_short_circuits(&parsed) {
174        return code;
175    }
176
177    let cwd = match resolve_cwd() {
178        Ok(cwd) => cwd,
179        Err(code) => return code,
180    };
181
182    // ---- Legacy-layout migration (flat ~/.rpi → ~/.rpi/agent/) ----
183    // Best-effort; never blocks startup. Skipped when RPI_CODING_AGENT_DIR is
184    // set (an explicit override is its own layout).
185    let _ = crate::config::migrate_legacy_layout();
186    crate::timings::time("config migrated", crate::timings::TimingNamespace::Main);
187
188    if let Some(code) = export_if_requested(&parsed) {
189        return code;
190    }
191
192    // `--list-models` is intentionally handled before credentials, session
193    // restoration, and harness construction. It is a catalog inspection
194    // command, so it must work for a newly installed user who has not
195    // authenticated yet.
196    if let Some(search) = parsed.list_models.as_deref() {
197        return list_models(search).await;
198    }
199
200    // Build before provider resolution so compiler errors do not require
201    // valid model credentials. The staged directory joins normal discovery.
202    let dev_extension = match prepare_dev_extension(dev_options.as_ref(), &cwd, &mut parsed) {
203        Ok(extension) => extension,
204        Err(code) => return code,
205    };
206
207    // `-r/--resume` is an interactive picker, unlike `-c/--continue` which
208    // immediately opens the latest session. Resolve the picker result before
209    // building the harness so cancelling does not create or modify a session.
210    if let Some(code) = resolve_resume(&mut parsed, &cwd).await {
211        return code;
212    }
213
214    emit_startup_warnings(&parsed);
215
216    // ---- stdin ownership ----
217    // Do NOT drain stdin when the run is an RPC/server session: stdin is the
218    // JSONL command channel there (a spawned `rpi --mode rpc` child reads its
219    // commands from stdin), so consuming it up-front would leave the protocol
220    // loop nothing to read. `--mode rpc` and `--server` both select RunMode::Rpc.
221    //
222    // NOTE: the actual `read_piped_stdin()` call is deferred until *after*
223    // extension load (below), because a plugin may `declare
224    // {"ownsStdio":true}` — an ACP/MCP-style stdio server that owns this
225    // process's stdin/stdout. Draining stdin before that plugin's `register`
226    // runs would deadlock it (and the host would swallow the client handshake).
227    // `--mode rpc`/`--server` are a static pre-computation (no plugin needed);
228    // the plugin claim is only known once `build()` has loaded extensions.
229    let wants_rpc_channel =
230        parsed.mode == crate::args::Mode::Rpc || parsed.unknown_flags.contains_key("server");
231
232    // ---- @file attachments → text (TS processFileArguments, text branch only) ----
233    let (file_text, file_images) = match process_file_args(&parsed.file_args, &cwd) {
234        Ok(t) => t,
235        Err(msg) => {
236            eprintln!("error: {msg}");
237            return EXIT_USAGE;
238        }
239    };
240
241    // ---- provider + model resolution ----
242    let project_trusted = crate::session::resolve_project_trust(&parsed, &cwd);
243    let resolved = match resolve_for_cwd(
244        parsed.provider.as_deref(),
245        parsed.model.as_deref(),
246        parsed.thinking,
247        parsed.api_key.as_deref(),
248        parsed.base_url.as_deref(),
249        &cwd,
250        project_trusted,
251    ) {
252        Ok(r) => r,
253        Err(e) => {
254            print_resolve_error(&e);
255            return match e {
256                ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
257                _ => EXIT_RUNTIME,
258            };
259        }
260    };
261
262    // The full authenticated catalog (read-only) for the TUI's `/model`
263    // selector, derived from the built provider runtimes so auth headers and
264    // base-url overrides survive.
265    let model_catalog = crate::provider::authenticated_catalog(&resolved);
266
267    // `--models <patterns>`: persist the Ctrl+P cycle scope to settings.json
268    // (the same set `/scoped-models` edits). Each pattern matches catalog ids
269    // case-insensitively; unmatched patterns are reported so a typo doesn't
270    // silently empty the cycle.
271    if let Some(patterns) = &parsed.models {
272        let mut matched: Vec<String> = Vec::new();
273        for p in patterns {
274            let hits: Vec<String> = model_catalog
275                .iter()
276                .filter(|m| m.id.eq_ignore_ascii_case(p))
277                .map(|m| m.id.clone())
278                .collect();
279            if hits.is_empty() {
280                eprintln!("warning: --models pattern \"{p}\" matched no model");
281            }
282            matched.extend(hits);
283        }
284        let mut settings = crate::settings::load_settings().unwrap_or_default();
285        settings.scoped_models = if matched.is_empty() {
286            None
287        } else {
288            Some(matched)
289        };
290        if let Err(e) = crate::settings::save_settings(&settings) {
291            eprintln!("warning: could not save --models scope: {e}");
292        }
293    }
294
295    // ---- harness build ----
296    let (harness, event_rx, mut reload_context) =
297        match build(&resolved, &parsed, &cwd, project_trusted).await {
298            Ok(triple) => triple,
299            Err(e) => {
300                print_build_error(&e);
301                return EXIT_RUNTIME;
302            }
303        };
304    reload_context.dev_extension = dev_extension;
305
306    // ---- post-load stdin claim + initial message ----
307    // Extensions are loaded by `build()` (above). A plugin may have declared
308    // `{"ownsStdio":true}`: this process's stdio belongs to it (an ACP/MCP stdio
309    // server), so the host must neither drain stdin nor run a prompt/TUI mode
310    // that writes to stdout. Only now — after load — do we know. `--mode
311    // rpc`/`--server` already skipped the drain via `wants_rpc_channel`.
312    let stdio_claimed = reload_context
313        .extension_session
314        .lock()
315        .map(|session| session.owns_stdio())
316        .unwrap_or(false);
317    let stdio_lifecycle = reload_context
318        .extension_session
319        .lock()
320        .ok()
321        .and_then(|session| session.action_bridge())
322        .map(|bridge| bridge.stdio_lifecycle());
323    if let Some(lifecycle) = &stdio_lifecycle {
324        if let Err(error) = lifecycle.validate() {
325            eprintln!("error: {error}");
326            return EXIT_RUNTIME;
327        }
328    }
329    let stdin_text = if wants_rpc_channel || stdio_claimed {
330        None
331    } else {
332        read_piped_stdin()
333    };
334
335    // ---- initial message + extra messages (TS buildInitialMessage) ----
336    let file_text_opt = if file_text.is_empty() {
337        None
338    } else {
339        Some(file_text.as_str())
340    };
341    let (initial, extra) = build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
342
343    // ---- mode dispatch (TS resolveAppMode → runPrintMode / InteractiveMode / runRpcMode) ----
344    let stdin_is_tty = std::io::stdin().is_terminal();
345    let stdout_is_tty = std::io::stdout().is_terminal();
346    let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
347
348    // TS downgrades interactive → print when piped stdin is present.
349    let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
350        RunMode::Print
351    } else {
352        mode
353    };
354
355    // Debug/testing escape hatch: RPI_FORCE_TUI=1 forces interactive mode
356    // (for testing the TUI in non-TTY environments).
357    let mode = if std::env::var("RPI_FORCE_TUI")
358        .map(|v| v == "1")
359        .unwrap_or(false)
360    {
361        RunMode::Interactive
362    } else {
363        mode
364    };
365
366    // A plugin that claimed stdio owns this process's stdin/stdout: force the
367    // headless Rpc lifecycle (dispatch startup events, then wait for completion)
368    // and let the plugin's own protocol server read/write stdio. This is
369    // authoritative — it must win over TTY detection and RPI_FORCE_TUI,
370    // otherwise the host would start a TUI/print run that writes to a stream the
371    // plugin owns.
372    let mode = if stdio_claimed { RunMode::Rpc } else { mode };
373
374    let dev_cleanup = reload_context.dev_extension.clone();
375    let dev_watcher = if matches!(mode, RunMode::Interactive) {
376        reload_context
377            .dev_extension
378            .as_ref()
379            .and_then(|extension| extension.start_watcher(reload_context.mailbox.clone()))
380    } else {
381        None
382    };
383
384    let exit_code = match mode {
385        RunMode::Print => {
386            crate::modes::print(
387                &harness,
388                &parsed,
389                initial.clone(),
390                &extra,
391                file_images.clone(),
392            )
393            .await
394        }
395        RunMode::Json => {
396            crate::modes::json(
397                &harness,
398                &parsed,
399                initial.clone(),
400                &extra,
401                file_images.clone(),
402                Some(event_rx),
403            )
404            .await
405        }
406        RunMode::Interactive => {
407            crate::modes::interactive(
408                &harness,
409                Some(event_rx),
410                &parsed,
411                model_catalog,
412                initial.clone(),
413                &extra,
414                file_images.clone(),
415                if parsed.no_themes {
416                    None
417                } else {
418                    parsed.theme.as_deref().or(resolved.theme.as_deref())
419                },
420                parsed.no_themes,
421                &reload_context,
422            )
423            .await
424        }
425        RunMode::Rpc => {
426            // Headless lifecycle (P0): no TUI, but a session still starts/ends —
427            // extensions (e.g. rpi-server) auto-start on SessionStart and clean
428            // up on SessionShutdown, exactly as in interactive mode. This is why
429            // `rpi --server` works headless: the SessionStart hook fires and the
430            // extension brings up the TCP server.
431            let veto = crate::session::dispatch_session_event_async(
432                &reload_context,
433                rpi_plugin_sdk::EventTag::BeforeTuiStart,
434            )
435            .await;
436            if let Some(reason) = veto {
437                eprintln!("[rpi] startup vetoed by extension: {reason}");
438                EXIT_VETOED
439            } else {
440                if let Some(reason) = crate::session::dispatch_session_event_async(
441                    &reload_context,
442                    rpi_plugin_sdk::EventTag::SessionStart,
443                )
444                .await
445                {
446                    tracing::warn!(
447                        "[rpi] extension vetoed SessionStart (headless, advisory): {reason}"
448                    );
449                }
450
451                let code = if parsed.unknown_flags.contains_key("server") || stdio_claimed {
452                    // Server mode OR a plugin-claimed stdio server: the extension
453                    // owns the process's I/O. Wait for its completion or Ctrl+C,
454                    // then dispatch SessionShutdown before unloading plugins.
455                    eprintln!(
456                        "[rpi] running in headless mode (no TUI); extension owns stdio; Ctrl+C to stop"
457                    );
458                    if stdio_claimed {
459                        let lifecycle = stdio_lifecycle
460                            .as_ref()
461                            .expect("stdio owner requires an action bridge");
462                        tokio::select! {
463                            code = lifecycle.wait() => code,
464                            _ = tokio::signal::ctrl_c() => 0,
465                        }
466                    } else {
467                        let _ = tokio::signal::ctrl_c().await;
468                        0
469                    }
470                } else {
471                    // `--mode rpc`: JSONL command loop over stdio — the
472                    // server-side agent that rpi-server spawns and that
473                    // `rpi --connect` talks to. The extension `UiDialog`
474                    // mailbox is bridged over the same channel so a permission
475                    // gate can ask the client mid-run.
476                    crate::modes::rpc(
477                        &harness,
478                        Some(event_rx),
479                        model_catalog,
480                        reload_context.ui_dialog.clone(),
481                    )
482                    .await
483                };
484
485                if let Some(reason) = crate::session::dispatch_session_event_async(
486                    &reload_context,
487                    rpi_plugin_sdk::EventTag::SessionShutdown,
488                )
489                .await
490                {
491                    tracing::warn!(
492                        "[rpi] extension vetoed SessionShutdown (ignored, closing): {reason}"
493                    );
494                }
495                code
496            }
497        }
498    };
499
500    if let Some(dev) = &dev_cleanup {
501        dev.stop_watcher();
502    }
503    if let Some(watcher) = dev_watcher {
504        let _ = watcher.join();
505    }
506    drop(reload_context);
507    drop(harness);
508    if let Some(dev) = dev_cleanup {
509        dev.cleanup();
510    }
511    exit_code
512}
513
514fn handle_parse_short_circuits(parsed: &Args) -> Option<i32> {
515    // ---- --help / --version short-circuit (before any heavy work) ----
516    if parsed.help {
517        print_help();
518        return Some(0);
519    }
520    if parsed.version {
521        print_version();
522        return Some(0);
523    }
524
525    // ---- Parse errors → help + usage exit ----
526    if !parsed.errors.is_empty() {
527        for err in &parsed.errors {
528            eprintln!("error: {err}");
529        }
530        eprintln!();
531        print_help();
532        return Some(EXIT_USAGE);
533    }
534
535    None
536}
537
538fn resolve_cwd() -> Result<std::path::PathBuf, i32> {
539    std::env::current_dir().map_err(|error| {
540        eprintln!("error: could not determine the current directory: {error}");
541        EXIT_USAGE
542    })
543}
544
545fn export_if_requested(parsed: &Args) -> Option<i32> {
546    let input = parsed.export.as_deref()?;
547    let output = parsed
548        .messages
549        .first()
550        .map(Path::new)
551        .map(Path::to_path_buf)
552        .unwrap_or_else(|| {
553            let stem = input
554                .file_stem()
555                .and_then(|value| value.to_str())
556                .unwrap_or("session");
557            Path::new(&format!("rpi-session-{stem}.html")).to_path_buf()
558        });
559
560    match crate::export::export_file(input, &output) {
561        Ok(()) => {
562            println!("Exported to: {}", output.display());
563            Some(0)
564        }
565        Err(error) => {
566            eprintln!("error: {error}");
567            Some(EXIT_RUNTIME)
568        }
569    }
570}
571
572fn prepare_dev_extension(
573    options: Option<&crate::dev_extension::DevOptions>,
574    cwd: &Path,
575    parsed: &mut Args,
576) -> Result<Option<std::sync::Arc<crate::dev_extension::DevExtension>>, i32> {
577    let Some(options) = options else {
578        return Ok(None);
579    };
580
581    match crate::dev_extension::DevExtension::detect(cwd, options) {
582        Ok(extension) => {
583            if let Err(error) = extension.rebuild() {
584                eprintln!("error: initial extension build failed: {error}");
585                return Err(EXIT_RUNTIME);
586            }
587            if let Err(error) = extension.apply_to_args(parsed) {
588                eprintln!("error: {error}");
589                return Err(EXIT_RUNTIME);
590            }
591            Ok(Some(extension))
592        }
593        Err(error) => {
594            // No Cargo cdylib found: degrade to skills-only mode.
595            eprintln!("dev: {error}");
596            eprintln!("dev: no Cargo cdylib found; running in skills-only mode");
597            parsed.no_extensions = true;
598            parsed.extensions_dir.clear();
599            parsed.extension.clear();
600            if options.local_only {
601                parsed.dev_local_only = true;
602            }
603            Ok(None)
604        }
605    }
606}
607
608async fn resolve_resume(parsed: &mut Args, cwd: &Path) -> Option<i32> {
609    if !parsed.resume {
610        return None;
611    }
612    if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
613        eprintln!("error: --resume requires an interactive terminal");
614        return Some(EXIT_USAGE);
615    }
616
617    match crate::resume_picker::select(cwd).await {
618        Ok(Some(id)) => {
619            parsed.resume = false;
620            parsed.session = Some(id);
621            None
622        }
623        Ok(None) => Some(0),
624        Err(error) => {
625            eprintln!("error: {error}");
626            Some(EXIT_RUNTIME)
627        }
628    }
629}
630
631fn emit_startup_warnings(parsed: &Args) {
632    // ---- Startup warnings (ignored-but-recognized flags) ----
633    if parsed.verbose {
634        for warn in &parsed.ignored {
635            eprintln!("warning: {warn}");
636        }
637    }
638    if parsed.no_themes && parsed.theme.is_some() {
639        eprintln!("warning: --no-themes overrides --theme; using the built-in default theme");
640    }
641}
642
643/// Print the merged model catalog, optionally filtered by a case-insensitive
644/// fuzzy-ish substring over provider, id, and display name.
645async fn list_models(search: &str) -> i32 {
646    let catalog = match crate::provider::catalog_all() {
647        Ok(models) => models,
648        Err(error) => {
649            eprintln!("warning: could not load models.json: {error}");
650            Vec::new()
651        }
652    };
653    let needle = search.trim().to_ascii_lowercase();
654    let mut models: Vec<_> = catalog
655        .into_iter()
656        .filter(|model| {
657            needle.is_empty()
658                || format!("{} {} {}", model.provider, model.id, model.name)
659                    .to_ascii_lowercase()
660                    .contains(&needle)
661        })
662        .collect();
663    if models.is_empty() {
664        if needle.is_empty() {
665            println!("{}", crate::auth_guidance::no_models_available_message());
666        } else {
667            println!("No models matching \"{search}\"");
668        }
669        return 0;
670    }
671
672    fn format_tokens(value: u64) -> String {
673        if value >= 1_000_000 {
674            let whole = value % 1_000_000 == 0;
675            if whole {
676                format!("{}M", value / 1_000_000)
677            } else {
678                format!("{:.1}M", value as f64 / 1_000_000.0)
679            }
680        } else if value >= 1_000 {
681            let whole = value % 1_000 == 0;
682            if whole {
683                format!("{}K", value / 1_000)
684            } else {
685                format!("{:.1}K", value as f64 / 1_000.0)
686            }
687        } else {
688            value.to_string()
689        }
690    }
691
692    let rows: Vec<_> = models
693        .drain(..)
694        .map(|model| {
695            let images = model
696                .input
697                .iter()
698                .any(|input| matches!(input, rpi_ai::InputModality::Image));
699            (
700                model.provider,
701                model.id,
702                format_tokens(model.context_window),
703                format_tokens(model.max_tokens),
704                if model.reasoning { "yes" } else { "no" }.to_string(),
705                if images { "yes" } else { "no" }.to_string(),
706            )
707        })
708        .collect();
709    let widths = (
710        rows.iter().map(|r| r.0.len()).max().unwrap_or(8).max(8),
711        rows.iter().map(|r| r.1.len()).max().unwrap_or(5).max(5),
712        rows.iter().map(|r| r.2.len()).max().unwrap_or(7).max(7),
713        rows.iter().map(|r| r.3.len()).max().unwrap_or(7).max(7),
714        rows.iter().map(|r| r.4.len()).max().unwrap_or(8).max(8),
715        rows.iter().map(|r| r.5.len()).max().unwrap_or(6).max(6),
716    );
717    println!(
718        "{:provider$}  {:model$}  {:context$}  {:max_out$}  {:thinking$}  {:images$}",
719        "provider",
720        "model",
721        "context",
722        "max-out",
723        "thinking",
724        "images",
725        provider = widths.0,
726        model = widths.1,
727        context = widths.2,
728        max_out = widths.3,
729        thinking = widths.4,
730        images = widths.5,
731    );
732    for row in rows {
733        println!(
734            "{:provider$}  {:model$}  {:context$}  {:max_out$}  {:thinking$}  {:images$}",
735            row.0,
736            row.1,
737            row.2,
738            row.3,
739            row.4,
740            row.5,
741            provider = widths.0,
742            model = widths.1,
743            context = widths.2,
744            max_out = widths.3,
745            thinking = widths.4,
746            images = widths.5,
747        );
748    }
749    0
750}
751
752/// Read piped stdin into a string. Mirrors TS `readPipedStdin`: returns `None`
753/// when stdin is a TTY (interactive), else the trimmed stdin text (empty ⇒
754/// `None`).
755///
756/// NOTE: if stdin is *not* a TTY but no bytes arrive (e.g. `pi < /dev/null`),
757/// this returns `None` (empty), which is what TS does too (`data.trim() || undefined`).
758fn read_piped_stdin() -> Option<String> {
759    // Debug/testing escape hatch: RPI_SKIP_STDIN=1 skips reading piped stdin
760    // (avoids blocking on non-TTY stdin in automated environments).
761    if std::env::var("RPI_SKIP_STDIN")
762        .map(|v| v == "1")
763        .unwrap_or(false)
764    {
765        return None;
766    }
767    if std::io::stdin().is_terminal() {
768        return None;
769    }
770    let mut buf = String::new();
771    match std::io::stdin().read_to_string(&mut buf) {
772        Ok(_) => {
773            let trimmed = buf.trim();
774            if trimmed.is_empty() {
775                None
776            } else {
777                Some(trimmed.to_string())
778            }
779        }
780        Err(_) => None,
781    }
782}
783
784/// Expand `@file` attachments into prompt text. Mirrors the *text* branch of
785/// TS `processFileArguments`: each readable text file is wrapped in
786/// `<file name="...">\n<contents>\n</file>\n` and concatenated.
787///
788/// Paths are resolved relative to `cwd` (the TS uses `resolve(readPath, cwd)`).
789fn process_file_args(
790    file_args: &[std::path::PathBuf],
791    cwd: &Path,
792) -> Result<(String, Vec<ImageContent>), String> {
793    let mut text = String::new();
794    let mut images = Vec::new();
795    for rel in file_args {
796        let abs = if rel.is_absolute() {
797            rel.clone()
798        } else {
799            cwd.join(rel)
800        };
801        if !abs.exists() {
802            return Err(format!("file not found: {}", abs.display()));
803        }
804        let bytes = std::fs::read(&abs)
805            .map_err(|e| format!("could not read file {}: {e}", abs.display()))?;
806        if let Some(image) = image_content_from_bytes(&bytes) {
807            images.push(image);
808        } else {
809            let content = String::from_utf8(bytes).map_err(|_| {
810                format!(
811                    "file is not valid UTF-8 text or a supported image: {}",
812                    abs.display()
813                )
814            })?;
815            text.push_str(&format!(
816                "<file name=\"{}\">\n{}\n</file>\n",
817                abs.display(),
818                content
819            ));
820        }
821    }
822    Ok((text, images))
823}
824
825pub(crate) fn image_content_from_path(path: &Path) -> Result<Option<ImageContent>, String> {
826    let bytes =
827        std::fs::read(path).map_err(|e| format!("could not read file {}: {e}", path.display()))?;
828    Ok(image_content_from_bytes(&bytes))
829}
830
831fn image_content_from_bytes(bytes: &[u8]) -> Option<ImageContent> {
832    let mime_type = rpi_tools::detect_supported_image_mime_type(bytes)?;
833    Some(ImageContent {
834        kind: ImageContentType,
835        data: rpi_tools::encode_base64(bytes),
836        mime_type: mime_type.to_string(),
837    })
838}
839
840/// Build the initial prompt + the remaining extra messages. Mirrors TS
841/// `buildInitialMessage`: `[stdinContent, fileText, messages[0]].join("")` is
842/// the initial message; `messages[1..]` are the follow-up prompts.
843///
844/// Returns `(initial: Option<String>, extra: Vec<String>)`.
845fn build_initial_message(
846    parsed: &Args,
847    stdin: Option<&str>,
848    file_text: Option<&str>,
849) -> (Option<String>, Vec<String>) {
850    let mut extra = parsed.messages.clone();
851    let mut parts: Vec<String> = Vec::new();
852    if let Some(s) = stdin {
853        parts.push(s.to_string());
854    }
855    if let Some(t) = file_text {
856        parts.push(t.to_string());
857    }
858    // Pull the first positional message into the initial prompt (TS `.shift()`).
859    if !extra.is_empty() {
860        parts.push(extra.remove(0));
861    }
862    let initial = if parts.is_empty() {
863        None
864    } else {
865        Some(parts.join(""))
866    };
867    (initial, extra)
868}
869
870/// Print a model-resolution error with env-specific guidance. Mirrors the TS
871/// auth-guidance / model-resolver error formatting (condensed to stderr lines).
872fn print_resolve_error(e: &ResolveError) {
873    match e {
874        ResolveError::NoApiKey { hint } => {
875            eprintln!("error: {e}");
876            eprintln!();
877            eprintln!("Provide credentials via one of: {hint}.");
878        }
879        ResolveError::Config(_) => {
880            eprintln!("error: {e}");
881            eprintln!();
882            eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
883        }
884        _ => eprintln!("error: {e}"),
885    }
886}
887
888/// Print a harness-build error with flag-specific guidance for restore requests.
889fn print_build_error(e: &BuildError) {
890    match e {
891        BuildError::SessionNotFound { .. } => {
892            eprintln!("error: {e}");
893            eprintln!();
894            eprintln!("List saved sessions with the /session command in interactive mode.");
895        }
896        _ => eprintln!("error: {e}"),
897    }
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903    use crate::args::Args;
904
905    #[test]
906    fn take_flag_value_supports_both_spellings() {
907        let argv = vec![
908            "--connect".to_string(),
909            "127.0.0.1:9899".to_string(),
910            "--token".to_string(),
911            "abc".to_string(),
912        ];
913        assert_eq!(
914            take_flag_value(&argv, "--connect").as_deref(),
915            Some("127.0.0.1:9899")
916        );
917        assert_eq!(take_flag_value(&argv, "--token").as_deref(), Some("abc"));
918        assert_eq!(take_flag_value(&argv, "--missing"), None);
919
920        let inline = vec!["--connect=1.2.3.4:1".to_string()];
921        assert_eq!(
922            take_flag_value(&inline, "--connect").as_deref(),
923            Some("1.2.3.4:1")
924        );
925    }
926
927    #[test]
928    fn connect_token_prefers_the_flag_over_the_env_var() {
929        // The flag wins regardless of any RPI_SERVER_TOKEN in the environment.
930        let argv = vec!["--token".to_string(), "flag-token".to_string()];
931        assert_eq!(resolve_connect_token(&argv).as_deref(), Some("flag-token"));
932    }
933
934    #[test]
935    fn build_initial_combines_stdin_file_and_first_message() {
936        let mut args = Args::default();
937        args.messages = vec!["first".into(), "second".into(), "third".into()];
938        let (initial, extra) =
939            build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
940        assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
941        assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
942    }
943
944    #[test]
945    fn build_initial_with_no_messages_uses_stdin_and_file_only() {
946        let args = Args::default();
947        let (initial, extra) =
948            build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
949        assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
950        assert!(extra.is_empty());
951    }
952
953    #[test]
954    fn build_initial_none_when_all_empty() {
955        let args = Args::default();
956        let (initial, extra) = build_initial_message(&args, None, None);
957        assert!(initial.is_none());
958        assert!(extra.is_empty());
959    }
960
961    #[test]
962    fn build_initial_shifts_only_first_message() {
963        let mut args = Args::default();
964        args.messages = vec!["a".into(), "b".into()];
965        let (initial, extra) = build_initial_message(&args, None, None);
966        assert_eq!(initial.as_deref(), Some("a"));
967        assert_eq!(extra, vec!["b".to_string()]);
968    }
969
970    #[test]
971    fn process_file_args_attaches_supported_images() {
972        let dir = tempfile::tempdir().unwrap();
973        let path = dir.path().join("image.bin");
974        let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
975        png.extend_from_slice(b"IHDR");
976        png.extend_from_slice(&[0; 13]);
977        std::fs::write(&path, png).unwrap();
978        let (text, images) = process_file_args(&[path], dir.path()).unwrap();
979        assert!(text.is_empty());
980        assert_eq!(images.len(), 1);
981        assert_eq!(images[0].mime_type, "image/png");
982        assert!(!images[0].data.is_empty());
983    }
984
985    #[test]
986    fn image_content_from_path_reports_supported_mime() {
987        let dir = tempfile::tempdir().unwrap();
988        let path = dir.path().join("drop.png");
989        let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
990        png.extend_from_slice(b"IHDR");
991        png.extend_from_slice(&[0; 13]);
992        std::fs::write(&path, png).unwrap();
993        let image = image_content_from_path(&path).unwrap().unwrap();
994        assert_eq!(image.mime_type, "image/png");
995    }
996}