Skip to main content

rpi_cli/
app.rs

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