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