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