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