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 prompts,
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, Write};
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, 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
45/// The v1 CLI entry point. Mirrors TS `export async function main(args)`.
46///
47/// Returns the process exit code (0 = success). The binary wrapper
48/// ([`crate::bin`] / `src/bin/pi.rs`) calls this under a tokio runtime and
49/// `std::process::exit`s with the returned code.
50pub async fn run() -> i32 {
51    // argv[0] is the program name; skip it (TS `main(args)` receives the same,
52    // already sliced by the Node CLI entry).
53    let mut argv: Vec<String> = std::env::args().skip(1).collect();
54
55    if argv.first().map(String::as_str) == Some("__rpi_dev_cleanup") {
56        return crate::dev_extension::run_cleanup_helper(&argv[1..]);
57    }
58
59    // `rpi dev` wraps the normal CLI: consume only development-specific
60    // options, then pass every remaining argument through the regular parser.
61    let dev_options = if argv.first().map(String::as_str) == Some("dev") {
62        match crate::dev_extension::parse_args(&argv[1..]) {
63            Ok(options) if options.help => {
64                crate::dev_extension::print_help();
65                return 0;
66            }
67            Ok(options) => {
68                argv = options.passthrough.clone();
69                Some(options)
70            }
71            Err(error) => {
72                eprintln!("error: {error}");
73                crate::dev_extension::print_help();
74                return EXIT_USAGE;
75            }
76        }
77    } else {
78        None
79    };
80
81    // ---- `rpi auth …` subcommand dispatch (before flag parsing) ----
82    // `auth` is a top-level subcommand (mirrors TS `runAuthCommand` routing in
83    // `main.ts`); dispatching it here avoids it being misparsed as a prompt.
84    if argv.first().map(|s| s.as_str()) == Some("auth") {
85        return crate::auth::run(&argv[1..]).await;
86    }
87    if argv.first().map(|s| s.as_str()) == Some("package") {
88        return crate::packages::run_cli(&argv[1..]);
89    }
90    if argv.first().map(|s| s.as_str()) == Some("update") {
91        return crate::updates::run_self_update(&argv[1..]);
92    }
93    if argv.first().map(|s| s.as_str()) == Some("install") {
94        return crate::install::run(&argv[1..]);
95    }
96    if argv.first().map(|s| s.as_str()) == Some("install-pi") {
97        return crate::install_pi::run(&argv[1..]);
98    }
99    if argv.first().map(|s| s.as_str()) == Some("uninstall") {
100        if argv.get(1).map(String::as_str) == Some("pi") {
101            return crate::install_pi::uninstall(&argv[2..]);
102        }
103        return crate::install::uninstall(&argv[1..]);
104    }
105    if argv.first().map(|s| s.as_str()) == Some("uninstall-pi") {
106        return crate::install_pi::uninstall(&argv[1..]);
107    }
108
109    let mut parsed = parse_args(&argv);
110
111    // ---- --help / --version short-circuit (before any heavy work) ----
112    if parsed.help {
113        print_help();
114        return 0;
115    }
116    if parsed.version {
117        print_version();
118        return 0;
119    }
120
121    // ---- Parse errors → help + usage exit ----
122    if !parsed.errors.is_empty() {
123        for err in &parsed.errors {
124            eprintln!("error: {err}");
125        }
126        eprintln!();
127        print_help();
128        return EXIT_USAGE;
129    }
130
131    // ---- cwd ----
132    let cwd = match std::env::current_dir() {
133        Ok(c) => c,
134        Err(e) => {
135            eprintln!("error: could not determine the current directory: {e}");
136            return EXIT_USAGE;
137        }
138    };
139
140    // ---- Legacy-layout migration (flat ~/.rpi → ~/.rpi/agent/) ----
141    // Best-effort; never blocks startup. Skipped when RPI_CODING_AGENT_DIR is
142    // set (an explicit override is its own layout).
143    let _ = crate::config::migrate_legacy_layout();
144
145    if let Some(input) = parsed.export.as_deref() {
146        let output = parsed
147            .messages
148            .first()
149            .map(Path::new)
150            .map(Path::to_path_buf)
151            .unwrap_or_else(|| {
152                let stem = input
153                    .file_stem()
154                    .and_then(|value| value.to_str())
155                    .unwrap_or("session");
156                Path::new(&format!("rpi-session-{stem}.html")).to_path_buf()
157            });
158        match crate::export::export_file(input, &output) {
159            Ok(()) => {
160                println!("Exported to: {}", output.display());
161                return 0;
162            }
163            Err(error) => {
164                eprintln!("error: {error}");
165                return EXIT_RUNTIME;
166            }
167        }
168    }
169
170    // `--list-models` is intentionally handled before credentials, session
171    // restoration, and harness construction. Native Pi exposes this as a
172    // catalog inspection command, so it must work for a newly installed user
173    // who has not authenticated yet.
174    if let Some(search) = parsed.list_models.as_deref() {
175        return list_models(search).await;
176    }
177
178    // Build before provider resolution so compiler errors do not require
179    // valid model credentials. The staged directory joins normal discovery.
180    let dev_extension = if let Some(options) = &dev_options {
181        let extension = match crate::dev_extension::DevExtension::detect(&cwd, options) {
182            Ok(extension) => extension,
183            Err(error) => {
184                eprintln!("error: {error}");
185                return EXIT_USAGE;
186            }
187        };
188        if let Err(error) = extension.rebuild() {
189            eprintln!("error: initial extension build failed: {error}");
190            return EXIT_RUNTIME;
191        }
192        if let Err(error) = extension.apply_to_args(&mut parsed) {
193            eprintln!("error: {error}");
194            return EXIT_RUNTIME;
195        }
196        Some(extension)
197    } else {
198        None
199    };
200
201    // Native Pi asks before loading project-local settings/resources. Only
202    // prompt when an interactive terminal is available and there is something
203    // project-owned to authorize; headless/print/json invocations remain
204    // fail-closed without blocking for input.
205    if parsed.trust_override.is_none()
206        && std::io::stdin().is_terminal()
207        && std::io::stdout().is_terminal()
208        && crate::session::project_has_local_resources(&cwd)
209    {
210        match prompt_project_trust(&cwd) {
211            Some(decision) => parsed.trust_override = Some(decision),
212            None => {
213                eprintln!(
214                    "warning: project trust prompt unavailable; local resources remain disabled"
215                );
216            }
217        }
218    }
219
220    // Update checks are interactive-only and best-effort. They write to
221    // stderr so print/JSON modes remain machine-readable, and the checker
222    // itself uses a short timeout plus a cache.
223    let quiet_startup = crate::settings::load_settings()
224        .ok()
225        .and_then(|settings| settings.quiet_startup)
226        .unwrap_or(false);
227    if !quiet_startup
228        && !parsed.offline
229        && !parsed.print
230        && std::io::stdin().is_terminal()
231        && std::io::stdout().is_terminal()
232    {
233        let package_resources = crate::session::should_load_js_packages(&parsed)
234            .then(|| crate::session::package_resources_for(&parsed, &cwd));
235        let report =
236            crate::updates::check_startup_with_package_resources(package_resources.as_ref()).await;
237        crate::updates::print_startup_notices(&report);
238    }
239
240    // `-r/--resume` is an interactive picker, unlike `-c/--continue` which
241    // immediately opens the latest session. Resolve the picker result before
242    // building the harness so cancelling does not create or modify a session.
243    if parsed.resume {
244        if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
245            eprintln!("error: --resume requires an interactive terminal");
246            return EXIT_USAGE;
247        }
248        match crate::resume_picker::select(&cwd).await {
249            Ok(Some(id)) => {
250                parsed.resume = false;
251                parsed.session = Some(id);
252            }
253            Ok(None) => return 0,
254            Err(e) => {
255                eprintln!("error: {e}");
256                return EXIT_RUNTIME;
257            }
258        }
259    }
260
261    // ---- Startup warnings (ignored-but-recognized flags) ----
262    if parsed.verbose {
263        for warn in &parsed.ignored {
264            eprintln!("warning: {warn}");
265        }
266    }
267    if parsed.no_themes && parsed.theme.is_some() {
268        eprintln!("warning: --no-themes overrides --theme; using the built-in default theme");
269    }
270
271    // ---- stdin (TS readPipedStdin: non-TTY stdin becomes initial prompt text) ----
272    let stdin_text = read_piped_stdin();
273
274    // ---- @file attachments → text (TS processFileArguments, text branch only) ----
275    let (file_text, file_images) = match process_file_args(&parsed.file_args, &cwd) {
276        Ok(t) => t,
277        Err(msg) => {
278            eprintln!("error: {msg}");
279            return EXIT_USAGE;
280        }
281    };
282
283    // ---- initial message + extra messages (TS buildInitialMessage) ----
284    let file_text_opt = if file_text.is_empty() {
285        None
286    } else {
287        Some(file_text.as_str())
288    };
289    let (initial, extra) = build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
290
291    // ---- provider + model resolution ----
292    let resolved = match resolve(
293        parsed.provider.as_deref(),
294        parsed.model.as_deref(),
295        parsed.thinking,
296        parsed.api_key.as_deref(),
297        parsed.base_url.as_deref(),
298    ) {
299        Ok(r) => r,
300        Err(e) => {
301            print_resolve_error(&e);
302            return match e {
303                ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
304                _ => EXIT_RUNTIME,
305            };
306        }
307    };
308
309    // The full authenticated catalog (read-only) for the TUI's `/model` selector.
310    // v1 does not switch models mid-session, so this is display-only.
311    let model_catalog = crate::provider::available_catalog(&resolved);
312
313    // `--models <patterns>`: persist the Ctrl+M cycle scope to settings.json
314    // (the same set `/scoped-models` edits). Each pattern matches catalog ids
315    // case-insensitively; unmatched patterns are reported so a typo doesn't
316    // silently empty the cycle.
317    if let Some(patterns) = &parsed.models {
318        let mut matched: Vec<String> = Vec::new();
319        for p in patterns {
320            let hits: Vec<String> = model_catalog
321                .iter()
322                .filter(|m| m.id.eq_ignore_ascii_case(p))
323                .map(|m| m.id.clone())
324                .collect();
325            if hits.is_empty() {
326                eprintln!("warning: --models pattern \"{p}\" matched no model");
327            }
328            matched.extend(hits);
329        }
330        let mut settings = crate::settings::load_settings().unwrap_or_default();
331        settings.scoped_models = if matched.is_empty() {
332            None
333        } else {
334            Some(matched)
335        };
336        if let Err(e) = crate::settings::save_settings(&settings) {
337            eprintln!("warning: could not save --models scope: {e}");
338        }
339    }
340
341    // ---- harness build ----
342    let (harness, event_rx, mut reload_context) = match build(&resolved, &parsed, &cwd).await {
343        Ok(triple) => triple,
344        Err(e) => {
345            print_build_error(&e);
346            return EXIT_RUNTIME;
347        }
348    };
349    reload_context.dev_extension = dev_extension;
350
351    // ---- mode dispatch (TS resolveAppMode → runPrintMode / InteractiveMode / runRpcMode) ----
352    let stdin_is_tty = std::io::stdin().is_terminal();
353    let stdout_is_tty = std::io::stdout().is_terminal();
354    let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
355
356    // TS downgrades interactive → print when piped stdin is present.
357    let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
358        RunMode::Print
359    } else {
360        mode
361    };
362
363    // Debug/testing escape hatch: RPI_FORCE_TUI=1 forces interactive mode
364    // (for testing the TUI in non-TTY environments).
365    let mode = if std::env::var("RPI_FORCE_TUI")
366        .map(|v| v == "1")
367        .unwrap_or(false)
368    {
369        RunMode::Interactive
370    } else {
371        mode
372    };
373
374    let dev_cleanup = reload_context.dev_extension.clone();
375    let dev_watcher = if matches!(mode, RunMode::Interactive) {
376        reload_context
377            .dev_extension
378            .as_ref()
379            .and_then(|extension| extension.start_watcher(reload_context.mailbox.clone()))
380    } else {
381        None
382    };
383
384    let exit_code = match mode {
385        RunMode::Print => {
386            crate::modes::print(
387                &harness,
388                &parsed,
389                initial.clone(),
390                &extra,
391                file_images.clone(),
392            )
393            .await
394        }
395        RunMode::Json => {
396            crate::modes::json(
397                &harness,
398                &parsed,
399                initial.clone(),
400                &extra,
401                file_images.clone(),
402                Some(event_rx),
403            )
404            .await
405        }
406        RunMode::Interactive => {
407            crate::modes::interactive(
408                &harness,
409                Some(event_rx),
410                &parsed,
411                model_catalog,
412                initial.clone(),
413                &extra,
414                file_images.clone(),
415                if parsed.no_themes {
416                    None
417                } else {
418                    parsed.theme.as_deref().or(resolved.theme.as_deref())
419                },
420                parsed.no_themes,
421                &reload_context,
422            )
423            .await
424        }
425        RunMode::Rpc => {
426            // `--mode rpc` is parsed (so it doesn't hard-error) but not
427            // implemented in v1 — the JSON-RPC session protocol the TS
428            // `runRpcMode` drives is deferred.
429            eprintln!("error: rpc mode is not implemented in v1 (use --mode text or --mode json)");
430            EXIT_USAGE
431        }
432    };
433
434    if let Some(dev) = &dev_cleanup {
435        dev.stop_watcher();
436    }
437    if let Some(watcher) = dev_watcher {
438        let _ = watcher.join();
439    }
440    drop(reload_context);
441    drop(harness);
442    if let Some(dev) = dev_cleanup {
443        dev.cleanup();
444    }
445    exit_code
446}
447
448fn prompt_project_trust(cwd: &Path) -> Option<bool> {
449    let display = cwd.display();
450    print!("Trust project {display} and load local resources? [y/N] ");
451    let _ = std::io::stdout().flush();
452    let mut answer = String::new();
453    if std::io::stdin().read_line(&mut answer).is_err() {
454        return None;
455    }
456    let normalized = answer.trim().to_ascii_lowercase();
457    let trusted = matches!(normalized.as_str(), "y" | "yes");
458    if let Err(error) = crate::config::set_project_trust(cwd, Some(trusted)) {
459        eprintln!("warning: could not persist project trust decision: {error}");
460    }
461    Some(trusted)
462}
463
464/// Print the merged model catalog, optionally filtered by a case-insensitive
465/// fuzzy-ish substring over provider, id, and display name.
466async fn list_models(search: &str) -> i32 {
467    let catalog = match crate::provider::catalog_all() {
468        Ok(models) => models,
469        Err(error) => {
470            eprintln!("warning: could not load models.json: {error}");
471            Vec::new()
472        }
473    };
474    let needle = search.trim().to_ascii_lowercase();
475    let mut models: Vec<_> = catalog
476        .into_iter()
477        .filter(|model| {
478            needle.is_empty()
479                || format!("{} {} {}", model.provider, model.id, model.name)
480                    .to_ascii_lowercase()
481                    .contains(&needle)
482        })
483        .collect();
484    if models.is_empty() {
485        if needle.is_empty() {
486            println!("No models available");
487        } else {
488            println!("No models matching \"{search}\"");
489        }
490        return 0;
491    }
492
493    fn format_tokens(value: u64) -> String {
494        if value >= 1_000_000 {
495            let whole = value % 1_000_000 == 0;
496            if whole {
497                format!("{}M", value / 1_000_000)
498            } else {
499                format!("{:.1}M", value as f64 / 1_000_000.0)
500            }
501        } else if value >= 1_000 {
502            let whole = value % 1_000 == 0;
503            if whole {
504                format!("{}K", value / 1_000)
505            } else {
506                format!("{:.1}K", value as f64 / 1_000.0)
507            }
508        } else {
509            value.to_string()
510        }
511    }
512
513    let rows: Vec<_> = models
514        .drain(..)
515        .map(|model| {
516            let images = model
517                .input
518                .iter()
519                .any(|input| matches!(input, rpi_ai::InputModality::Image));
520            (
521                model.provider,
522                model.id,
523                format_tokens(model.context_window),
524                format_tokens(model.max_tokens),
525                if model.reasoning { "yes" } else { "no" }.to_string(),
526                if images { "yes" } else { "no" }.to_string(),
527            )
528        })
529        .collect();
530    let widths = (
531        rows.iter().map(|r| r.0.len()).max().unwrap_or(8).max(8),
532        rows.iter().map(|r| r.1.len()).max().unwrap_or(5).max(5),
533        rows.iter().map(|r| r.2.len()).max().unwrap_or(7).max(7),
534        rows.iter().map(|r| r.3.len()).max().unwrap_or(7).max(7),
535        rows.iter().map(|r| r.4.len()).max().unwrap_or(8).max(8),
536        rows.iter().map(|r| r.5.len()).max().unwrap_or(6).max(6),
537    );
538    println!(
539        "{:provider$}  {:model$}  {:context$}  {:max_out$}  {:thinking$}  {:images$}",
540        "provider",
541        "model",
542        "context",
543        "max-out",
544        "thinking",
545        "images",
546        provider = widths.0,
547        model = widths.1,
548        context = widths.2,
549        max_out = widths.3,
550        thinking = widths.4,
551        images = widths.5,
552    );
553    for row in rows {
554        println!(
555            "{:provider$}  {:model$}  {:context$}  {:max_out$}  {:thinking$}  {:images$}",
556            row.0,
557            row.1,
558            row.2,
559            row.3,
560            row.4,
561            row.5,
562            provider = widths.0,
563            model = widths.1,
564            context = widths.2,
565            max_out = widths.3,
566            thinking = widths.4,
567            images = widths.5,
568        );
569    }
570    0
571}
572
573/// Read piped stdin into a string. Mirrors TS `readPipedStdin`: returns `None`
574/// when stdin is a TTY (interactive), else the trimmed stdin text (empty ⇒
575/// `None`).
576///
577/// NOTE: if stdin is *not* a TTY but no bytes arrive (e.g. `pi < /dev/null`),
578/// this returns `None` (empty), which is what TS does too (`data.trim() || undefined`).
579fn read_piped_stdin() -> Option<String> {
580    // Debug/testing escape hatch: RPI_SKIP_STDIN=1 skips reading piped stdin
581    // (avoids blocking on non-TTY stdin in automated environments).
582    if std::env::var("RPI_SKIP_STDIN")
583        .map(|v| v == "1")
584        .unwrap_or(false)
585    {
586        return None;
587    }
588    if std::io::stdin().is_terminal() {
589        return None;
590    }
591    let mut buf = String::new();
592    match std::io::stdin().read_to_string(&mut buf) {
593        Ok(_) => {
594            let trimmed = buf.trim();
595            if trimmed.is_empty() {
596                None
597            } else {
598                Some(trimmed.to_string())
599            }
600        }
601        Err(_) => None,
602    }
603}
604
605/// Expand `@file` attachments into prompt text. Mirrors the *text* branch of
606/// TS `processFileArguments`: each readable text file is wrapped in
607/// `<file name="...">\n<contents>\n</file>\n` and concatenated.
608///
609/// Paths are resolved relative to `cwd` (the TS uses `resolve(readPath, cwd)`).
610fn process_file_args(
611    file_args: &[std::path::PathBuf],
612    cwd: &Path,
613) -> Result<(String, Vec<ImageContent>), String> {
614    let mut text = String::new();
615    let mut images = Vec::new();
616    for rel in file_args {
617        let abs = if rel.is_absolute() {
618            rel.clone()
619        } else {
620            cwd.join(rel)
621        };
622        if !abs.exists() {
623            return Err(format!("file not found: {}", abs.display()));
624        }
625        let bytes = std::fs::read(&abs)
626            .map_err(|e| format!("could not read file {}: {e}", abs.display()))?;
627        if let Some(image) = image_content_from_bytes(&bytes) {
628            images.push(image);
629        } else {
630            let content = String::from_utf8(bytes).map_err(|_| {
631                format!(
632                    "file is not valid UTF-8 text or a supported image: {}",
633                    abs.display()
634                )
635            })?;
636            text.push_str(&format!(
637                "<file name=\"{}\">\n{}\n</file>\n",
638                abs.display(),
639                content
640            ));
641        }
642    }
643    Ok((text, images))
644}
645
646pub(crate) fn image_content_from_path(path: &Path) -> Result<Option<ImageContent>, String> {
647    let bytes =
648        std::fs::read(path).map_err(|e| format!("could not read file {}: {e}", path.display()))?;
649    Ok(image_content_from_bytes(&bytes))
650}
651
652fn image_content_from_bytes(bytes: &[u8]) -> Option<ImageContent> {
653    let mime_type = rpi_tools::detect_supported_image_mime_type(bytes)?;
654    Some(ImageContent {
655        kind: ImageContentType,
656        data: rpi_tools::encode_base64(bytes),
657        mime_type: mime_type.to_string(),
658    })
659}
660
661/// Build the initial prompt + the remaining extra messages. Mirrors TS
662/// `buildInitialMessage`: `[stdinContent, fileText, messages[0]].join("")` is
663/// the initial message; `messages[1..]` are the follow-up prompts.
664///
665/// Returns `(initial: Option<String>, extra: Vec<String>)`.
666fn build_initial_message(
667    parsed: &Args,
668    stdin: Option<&str>,
669    file_text: Option<&str>,
670) -> (Option<String>, Vec<String>) {
671    let mut extra = parsed.messages.clone();
672    let mut parts: Vec<String> = Vec::new();
673    if let Some(s) = stdin {
674        parts.push(s.to_string());
675    }
676    if let Some(t) = file_text {
677        parts.push(t.to_string());
678    }
679    // Pull the first positional message into the initial prompt (TS `.shift()`).
680    if !extra.is_empty() {
681        parts.push(extra.remove(0));
682    }
683    let initial = if parts.is_empty() {
684        None
685    } else {
686        Some(parts.join(""))
687    };
688    (initial, extra)
689}
690
691/// Print a model-resolution error with env-specific guidance. Mirrors the TS
692/// auth-guidance / model-resolver error formatting (condensed to stderr lines).
693fn print_resolve_error(e: &ResolveError) {
694    match e {
695        ResolveError::NoApiKey { hint } => {
696            eprintln!("error: {e}");
697            eprintln!();
698            eprintln!("Provide credentials via one of: {hint}.");
699        }
700        ResolveError::Config(_) => {
701            eprintln!("error: {e}");
702            eprintln!();
703            eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
704        }
705        _ => eprintln!("error: {e}"),
706    }
707}
708
709/// Print a harness-build error with flag-specific guidance for restore requests.
710fn print_build_error(e: &BuildError) {
711    match e {
712        BuildError::SessionNotFound { .. } => {
713            eprintln!("error: {e}");
714            eprintln!();
715            eprintln!("List saved sessions with the /session command in interactive mode.");
716        }
717        _ => eprintln!("error: {e}"),
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724    use crate::args::Args;
725
726    #[test]
727    fn build_initial_combines_stdin_file_and_first_message() {
728        let mut args = Args::default();
729        args.messages = vec!["first".into(), "second".into(), "third".into()];
730        let (initial, extra) =
731            build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
732        assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
733        assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
734    }
735
736    #[test]
737    fn build_initial_with_no_messages_uses_stdin_and_file_only() {
738        let args = Args::default();
739        let (initial, extra) =
740            build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
741        assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
742        assert!(extra.is_empty());
743    }
744
745    #[test]
746    fn build_initial_none_when_all_empty() {
747        let args = Args::default();
748        let (initial, extra) = build_initial_message(&args, None, None);
749        assert!(initial.is_none());
750        assert!(extra.is_empty());
751    }
752
753    #[test]
754    fn build_initial_shifts_only_first_message() {
755        let mut args = Args::default();
756        args.messages = vec!["a".into(), "b".into()];
757        let (initial, extra) = build_initial_message(&args, None, None);
758        assert_eq!(initial.as_deref(), Some("a"));
759        assert_eq!(extra, vec!["b".to_string()]);
760    }
761
762    #[test]
763    fn process_file_args_attaches_supported_images() {
764        let dir = tempfile::tempdir().unwrap();
765        let path = dir.path().join("image.bin");
766        let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
767        png.extend_from_slice(b"IHDR");
768        png.extend_from_slice(&[0; 13]);
769        std::fs::write(&path, png).unwrap();
770        let (text, images) = process_file_args(&[path], dir.path()).unwrap();
771        assert!(text.is_empty());
772        assert_eq!(images.len(), 1);
773        assert_eq!(images[0].mime_type, "image/png");
774        assert!(!images[0].data.is_empty());
775    }
776
777    #[test]
778    fn image_content_from_path_reports_supported_mime() {
779        let dir = tempfile::tempdir().unwrap();
780        let path = dir.path().join("drop.png");
781        let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
782        png.extend_from_slice(b"IHDR");
783        png.extend_from_slice(&[0; 13]);
784        std::fs::write(&path, png).unwrap();
785        let image = image_content_from_path(&path).unwrap().unwrap();
786        assert_eq!(image.mime_type, "image/png");
787    }
788}