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_with_extensions, ResolveError};
25use crate::session::{build_prepared, prepare_extensions, 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") => {
140            let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
141            let args = crate::args::parse_args(&argv[1..]);
142            if !args.errors.is_empty() {
143                for error in &args.errors {
144                    eprintln!("error: {error}");
145                }
146                return EXIT_USAGE;
147            }
148            let trusted = crate::session::resolve_project_trust(&args, &cwd);
149            let extension_session = crate::session::load_extensions(&args, &cwd, trusted, None);
150            return crate::auth::run_with_extensions(&argv[1..], extension_session.snapshot_arc())
151                .await;
152        }
153        Some("events") => return crate::events::run(&argv[1..]).await,
154        Some("update") => return crate::updates::run_self_update(&argv[1..]),
155        Some("install") => return crate::install::run_async(&argv[1..]).await,
156        Some("uninstall") => return crate::install::uninstall(&argv[1..]),
157        Some("package") => return crate::packages::run(&argv[1..]),
158        // These historical aliases are intentionally rejected instead of
159        // falling through to the prompt path.
160        Some(removed @ ("pi-package" | "pi-update" | "install-pi" | "uninstall-pi")) => {
161            eprintln!(
162                "error: `rpi {removed}` is not available; use `rpi package`, `rpi install`, or `rpi update`."
163            );
164            return EXIT_USAGE;
165        }
166        // // Never a working invocation; the same fall-through applies.
167        // Some("self-update") => {
168        //     eprintln!("error: unknown command `self-update`; use `rpi update`");
169        //     return EXIT_USAGE;
170        // }
171        _ => {}
172    }
173
174    let mut parsed = parse_args(&argv);
175    crate::timings::time("args parsed", crate::timings::TimingNamespace::Main);
176
177    // ---- Parsed startup stages ----
178    if let Some(code) = handle_parse_short_circuits(&parsed) {
179        return code;
180    }
181
182    let cwd = match resolve_cwd() {
183        Ok(cwd) => cwd,
184        Err(code) => return code,
185    };
186
187    // ---- Legacy-layout migration (flat ~/.rpi → ~/.rpi/agent/) ----
188    // Best-effort; never blocks startup. Skipped when RPI_CODING_AGENT_DIR is
189    // set (an explicit override is its own layout).
190    let _ = crate::config::migrate_legacy_layout();
191    crate::timings::time("config migrated", crate::timings::TimingNamespace::Main);
192
193    if let Some(code) = export_if_requested(&parsed) {
194        return code;
195    }
196
197    // `--list-models` is intentionally handled before credentials, session
198    // restoration, and harness construction. It is a catalog inspection
199    // command, so it must work for a newly installed user who has not
200    // authenticated yet.
201    if let Some(search) = parsed.list_models.as_deref() {
202        return list_models(search, &parsed, &cwd).await;
203    }
204
205    // Build before provider resolution so compiler errors do not require
206    // valid model credentials. The staged directory joins normal discovery.
207    let dev_extension = match prepare_dev_extension(dev_options.as_ref(), &cwd, &mut parsed) {
208        Ok(extension) => extension,
209        Err(code) => return code,
210    };
211
212    // `-r/--resume` is an interactive picker, unlike `-c/--continue` which
213    // immediately opens the latest session. Resolve the picker result before
214    // building the harness so cancelling does not create or modify a session.
215    if let Some(code) = resolve_resume(&mut parsed, &cwd).await {
216        return code;
217    }
218
219    emit_startup_warnings(&parsed);
220
221    // ---- stdin ownership ----
222    // Do NOT drain stdin when the run is an RPC/server session: stdin is the
223    // JSONL command channel there (a spawned `rpi --mode rpc` child reads its
224    // commands from stdin), so consuming it up-front would leave the protocol
225    // loop nothing to read. `--mode rpc` and `--server` both select RunMode::Rpc.
226    //
227    // NOTE: the actual `read_piped_stdin()` call is deferred until *after*
228    // extension load (below), because a plugin may `declare
229    // {"ownsStdio":true}` — an ACP/MCP-style stdio server that owns this
230    // process's stdin/stdout. Draining stdin before that plugin's `register`
231    // runs would deadlock it (and the host would swallow the client handshake).
232    // `--mode rpc`/`--server` are a static pre-computation (no plugin needed);
233    // the plugin claim is only known once `build()` has loaded extensions.
234    let wants_rpc_channel =
235        parsed.mode == crate::args::Mode::Rpc || parsed.unknown_flags.contains_key("server");
236
237    // ---- @file attachments → text (TS processFileArguments, text branch only) ----
238    // Deliberately runs BEFORE the cwd adoption below: `@file` paths come from
239    // the shell that launched `rpi`, so they resolve against the launch cwd no
240    // matter which directory the restored session points at.
241    let (file_text, file_images) = match process_file_args(&parsed.file_args, &cwd) {
242        Ok(t) => t,
243        Err(msg) => {
244            eprintln!("error: {msg}");
245            return EXIT_USAGE;
246        }
247    };
248
249    // ---- restored-session working directory (adopted before any resource load) ----
250    // A session records the cwd it was created in; restoring one must return
251    // the agent to that directory, because every tool resolves relative paths
252    // against the run's cwd. Adopting it here — before trust resolution,
253    // extension preparation, and the harness build — makes the whole run (tool
254    // env, system prompt, context files, extensions, footer) agree on one
255    // working directory. `@file` args above intentionally stay launch-cwd.
256    //
257    // The session ROOT is frozen first, for the same reason: settings and
258    // `RPI_CODING_AGENT_SESSION_DIR` may change while extensions load, and the
259    // adopted cwd belongs to another project's tree. Pinning the resolved root
260    // into `args` makes `prepare_extensions` and the build below read one root.
261    let session_root = crate::session::configured_session_dir(&parsed, &cwd);
262    parsed.session_dir = Some(session_root.clone());
263    let mut startup_notices: Vec<String> = Vec::new();
264    // The peek and the build both derive their selection from this frozen root,
265    // so they can never inspect one session and open another.
266    let selection = crate::session::select_session_in_dir(&parsed, session_root);
267    let restored_cwd = crate::session::peek_restored_session_cwd(&selection, &cwd).await;
268    // Pin the session the peek resolved: `-c` re-lists the directory when the
269    // build runs, and another `rpi` may have written a newer session in
270    // between. Adopting one session's directory while opening another would
271    // put the tools in a directory the opened conversation never used, which is
272    // the failure this adoption exists to prevent.
273    if let Some(restored) = &restored_cwd {
274        crate::session::pin_restored_selection(&mut parsed, restored);
275    }
276    let launch_cwd = cwd;
277    let cwd = match &restored_cwd {
278        Some(restored) => match &restored.adopted {
279            Some(adopted) => {
280                // Adopting a directory is a behaviour change the user must see:
281                // tools, context files, and extensions all switch projects.
282                startup_notices.push(crate::session_cwd::adopted_session_cwd_note(
283                    &launch_cwd,
284                    adopted,
285                ));
286                adopted.clone()
287            }
288            None => {
289                startup_notices.push(crate::session_cwd::missing_session_cwd_note(
290                    &restored.recorded,
291                    &launch_cwd,
292                ));
293                launch_cwd.clone()
294            }
295        },
296        None => launch_cwd.clone(),
297    };
298
299    // ---- provider + model resolution ----
300    // Trust is resolved against the (possibly adopted) cwd: a session recorded
301    // in another project must not inherit the launch directory's trust decision.
302    let project_trusted = crate::session::resolve_project_trust(&parsed, &cwd);
303    if !project_trusted && cwd != launch_cwd {
304        startup_notices.push(crate::session_cwd::untrusted_session_cwd_note(&cwd));
305    }
306    let prepared = match prepare_extensions(&parsed, &cwd, project_trusted) {
307        Ok(prepared) => prepared,
308        Err(error) => {
309            print_build_error(&error);
310            return EXIT_RUNTIME;
311        }
312    };
313    let resolved = match resolve_with_extensions(
314        &parsed,
315        &cwd,
316        project_trusted,
317        &prepared.extension_session,
318    ) {
319        Ok(r) => r,
320        Err(e) => {
321            print_resolve_error(&e);
322            return match e {
323                ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
324                _ => EXIT_RUNTIME,
325            };
326        }
327    };
328
329    for warning in &resolved.compat_warnings {
330        eprintln!("warning: models.json: {warning}");
331    }
332
333    // The full authenticated catalog (read-only) for the TUI's `/model`
334    // selector, derived from the built provider runtimes so auth headers and
335    // base-url overrides survive.
336    let model_catalog = crate::provider::authenticated_catalog(&resolved);
337
338    // `--models <patterns>`: persist the Ctrl+P cycle scope to settings.json
339    // (the same set `/scoped-models` edits). Each pattern matches catalog ids
340    // case-insensitively; unmatched patterns are reported so a typo doesn't
341    // silently empty the cycle.
342    if let Some(patterns) = &parsed.models {
343        let mut matched: Vec<String> = Vec::new();
344        for p in patterns {
345            let hits: Vec<String> = model_catalog
346                .iter()
347                .filter(|m| m.id.eq_ignore_ascii_case(p))
348                .map(|m| m.id.clone())
349                .collect();
350            if hits.is_empty() {
351                eprintln!("warning: --models pattern \"{p}\" matched no model");
352            }
353            matched.extend(hits);
354        }
355        let mut settings = crate::settings::load_settings().unwrap_or_default();
356        settings.scoped_models = if matched.is_empty() {
357            None
358        } else {
359            Some(matched)
360        };
361        if let Err(e) = crate::settings::save_settings(&settings) {
362            eprintln!("warning: could not save --models scope: {e}");
363        }
364    }
365
366    // ---- harness build ----
367    let (harness, event_rx, mut reload_context) =
368        match build_prepared(&resolved, &parsed, &cwd, project_trusted, prepared).await {
369            Ok(triple) => triple,
370            Err(e) => {
371                print_build_error(&e);
372                return EXIT_RUNTIME;
373            }
374        };
375    reload_context.dev_extension = dev_extension;
376
377    // ---- post-load stdin claim + initial message ----
378    // Extensions are loaded by `build()` (above). A plugin may have declared
379    // `{"ownsStdio":true}`: this process's stdio belongs to it (an ACP/MCP stdio
380    // server), so the host must neither drain stdin nor run a prompt/TUI mode
381    // that writes to stdout. Only now — after load — do we know. `--mode
382    // rpc`/`--server` already skipped the drain via `wants_rpc_channel`.
383    let stdio_claimed = reload_context
384        .extension_session
385        .lock()
386        .map(|session| session.owns_stdio())
387        .unwrap_or(false);
388    let web_requested = match parsed.unknown_flags.get("web") {
389        Some(serde_json::Value::Bool(value)) => *value,
390        Some(serde_json::Value::String(value)) => {
391            !value.is_empty() && !value.eq_ignore_ascii_case("false")
392        }
393        _ => false,
394    };
395    let web_registered = reload_context
396        .extension_session
397        .lock()
398        .ok()
399        .and_then(|session| {
400            session
401                .snapshot()
402                .map(|snapshot| snapshot.flags().iter().any(|flag| flag.name == "web"))
403        })
404        .unwrap_or(false);
405    if web_requested && (!web_registered || !stdio_claimed) {
406        eprintln!("error: --web requires the rpi-web server extension; it is missing, disabled, or failed to load.\nInstall it with: rpi install rpi-web --github pi-rust/rpi-package\nThen restart with: rpi --web");
407        return EXIT_USAGE;
408    }
409    let stdio_lifecycle = reload_context
410        .extension_session
411        .lock()
412        .ok()
413        .and_then(|session| session.action_bridge())
414        .map(|bridge| bridge.stdio_lifecycle());
415    if let Some(lifecycle) = &stdio_lifecycle {
416        if let Err(error) = lifecycle.validate() {
417            eprintln!("error: {error}");
418            return EXIT_RUNTIME;
419        }
420    }
421    let stdin_text = if wants_rpc_channel || stdio_claimed {
422        None
423    } else {
424        read_piped_stdin()
425    };
426
427    // ---- initial message + extra messages (TS buildInitialMessage) ----
428    let file_text_opt = if file_text.is_empty() {
429        None
430    } else {
431        Some(file_text.as_str())
432    };
433    let (initial, extra) = build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
434
435    // ---- mode dispatch (TS resolveAppMode → runPrintMode / InteractiveMode / runRpcMode) ----
436    let stdin_is_tty = std::io::stdin().is_terminal();
437    let stdout_is_tty = std::io::stdout().is_terminal();
438    let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
439
440    // TS downgrades interactive → print when piped stdin is present.
441    let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
442        RunMode::Print
443    } else {
444        mode
445    };
446
447    // Debug/testing escape hatch: RPI_FORCE_TUI=1 forces interactive mode
448    // (for testing the TUI in non-TTY environments).
449    let mode = if std::env::var("RPI_FORCE_TUI")
450        .map(|v| v == "1")
451        .unwrap_or(false)
452    {
453        RunMode::Interactive
454    } else {
455        mode
456    };
457
458    // A plugin that claimed stdio owns this process's stdin/stdout: force the
459    // headless Rpc lifecycle (dispatch startup events, then wait for completion)
460    // and let the plugin's own protocol server read/write stdio. This is
461    // authoritative — it must win over TTY detection and RPI_FORCE_TUI,
462    // otherwise the host would start a TUI/print run that writes to a stream the
463    // plugin owns.
464    let mode = if stdio_claimed { RunMode::Rpc } else { mode };
465
466    // ---- startup notices (restored-session cwd) ----
467    // The interactive TUI renders them as transcript notes (they must survive
468    // into the alternate screen); headless modes print them to stderr like the
469    // other startup warnings.
470    if matches!(mode, RunMode::Interactive) {
471        reload_context.startup_notices = startup_notices;
472    } else {
473        for notice in &startup_notices {
474            eprintln!("warning: {notice}");
475        }
476    }
477
478    let dev_cleanup = reload_context.dev_extension.clone();
479    let dev_watcher = if matches!(mode, RunMode::Interactive) {
480        reload_context
481            .dev_extension
482            .as_ref()
483            .and_then(|extension| extension.start_watcher(reload_context.mailbox.clone()))
484    } else {
485        None
486    };
487
488    let exit_code = match mode {
489        RunMode::Print => {
490            crate::modes::print(
491                &harness,
492                &parsed,
493                initial.clone(),
494                &extra,
495                file_images.clone(),
496            )
497            .await
498        }
499        RunMode::Json => {
500            crate::modes::json(
501                &harness,
502                &parsed,
503                initial.clone(),
504                &extra,
505                file_images.clone(),
506                Some(event_rx),
507            )
508            .await
509        }
510        RunMode::Interactive => {
511            crate::modes::interactive(
512                &harness,
513                Some(event_rx),
514                &parsed,
515                model_catalog,
516                initial.clone(),
517                &extra,
518                file_images.clone(),
519                if parsed.no_themes {
520                    None
521                } else {
522                    parsed.theme.as_deref().or(resolved.theme.as_deref())
523                },
524                parsed.no_themes,
525                &reload_context,
526            )
527            .await
528        }
529        RunMode::Rpc => {
530            // Headless lifecycle (P0): no TUI, but a session still starts/ends —
531            // extensions (e.g. rpi-server) auto-start on SessionStart and clean
532            // up on SessionShutdown, exactly as in interactive mode. This is why
533            // `rpi --server` works headless: the SessionStart hook fires and the
534            // extension brings up the TCP server.
535            let veto = crate::session::dispatch_session_event_async(
536                &reload_context,
537                rpi_plugin_sdk::EventTag::BeforeTuiStart,
538            )
539            .await;
540            if let Some(reason) = veto {
541                eprintln!("[rpi] startup vetoed by extension: {reason}");
542                EXIT_VETOED
543            } else {
544                if let Some(reason) = crate::session::dispatch_session_event_async(
545                    &reload_context,
546                    rpi_plugin_sdk::EventTag::SessionStart,
547                )
548                .await
549                {
550                    tracing::warn!(
551                        "[rpi] extension vetoed SessionStart (headless, advisory): {reason}"
552                    );
553                }
554
555                let code = if parsed.unknown_flags.contains_key("server") || stdio_claimed {
556                    // Server mode OR a plugin-claimed stdio server: the extension
557                    // owns the process's I/O. Wait for its completion or Ctrl+C,
558                    // then dispatch SessionShutdown before unloading plugins.
559                    eprintln!(
560                        "[rpi] running in headless mode (no TUI); extension owns stdio; Ctrl+C to stop"
561                    );
562                    if stdio_claimed {
563                        let lifecycle = stdio_lifecycle
564                            .as_ref()
565                            .expect("stdio owner requires an action bridge");
566                        tokio::select! {
567                            code = lifecycle.wait() => code,
568                            _ = tokio::signal::ctrl_c() => 0,
569                        }
570                    } else {
571                        let _ = tokio::signal::ctrl_c().await;
572                        0
573                    }
574                } else {
575                    // `--mode rpc`: JSONL command loop over stdio — the
576                    // server-side agent that rpi-server spawns and that
577                    // `rpi --connect` talks to. The extension `UiDialog`
578                    // mailbox is bridged over the same channel so a permission
579                    // gate can ask the client mid-run.
580                    // Release the extension session lock before the RPC loop awaits.
581                    // ToolCall hooks need the same lock to dispatch approvals.
582                    let oauth_snapshot = {
583                        let session = reload_context.extension_session.lock().unwrap();
584                        session.snapshot_arc()
585                    };
586                    crate::modes::rpc(
587                        &harness,
588                        Some(event_rx),
589                        model_catalog,
590                        reload_context.ui_dialog.clone(),
591                        oauth_snapshot,
592                        parsed.rpc_message_updates,
593                    )
594                    .await
595                };
596
597                if let Some(reason) = crate::session::dispatch_session_event_async(
598                    &reload_context,
599                    rpi_plugin_sdk::EventTag::SessionShutdown,
600                )
601                .await
602                {
603                    tracing::warn!(
604                        "[rpi] extension vetoed SessionShutdown (ignored, closing): {reason}"
605                    );
606                }
607                code
608            }
609        }
610    };
611
612    if let Some(dev) = &dev_cleanup {
613        dev.stop_watcher();
614    }
615    if let Some(watcher) = dev_watcher {
616        let _ = watcher.join();
617    }
618    drop(reload_context);
619    drop(harness);
620    if let Some(dev) = dev_cleanup {
621        dev.cleanup();
622    }
623    exit_code
624}
625
626fn handle_parse_short_circuits(parsed: &Args) -> Option<i32> {
627    // ---- --help / --version short-circuit (before any heavy work) ----
628    if parsed.help {
629        print_help();
630        return Some(0);
631    }
632    if parsed.version {
633        print_version();
634        return Some(0);
635    }
636
637    // ---- Parse errors → help + usage exit ----
638    if !parsed.errors.is_empty() {
639        for err in &parsed.errors {
640            eprintln!("error: {err}");
641        }
642        eprintln!();
643        print_help();
644        return Some(EXIT_USAGE);
645    }
646
647    None
648}
649
650fn resolve_cwd() -> Result<std::path::PathBuf, i32> {
651    std::env::current_dir().map_err(|error| {
652        eprintln!("error: could not determine the current directory: {error}");
653        EXIT_USAGE
654    })
655}
656
657fn export_if_requested(parsed: &Args) -> Option<i32> {
658    let input = parsed.export.as_deref()?;
659    let output = parsed
660        .messages
661        .first()
662        .map(Path::new)
663        .map(Path::to_path_buf)
664        .unwrap_or_else(|| {
665            let stem = input
666                .file_stem()
667                .and_then(|value| value.to_str())
668                .unwrap_or("session");
669            Path::new(&format!("rpi-session-{stem}.html")).to_path_buf()
670        });
671
672    match crate::export::export_file(input, &output) {
673        Ok(()) => {
674            println!("Exported to: {}", output.display());
675            Some(0)
676        }
677        Err(error) => {
678            eprintln!("error: {error}");
679            Some(EXIT_RUNTIME)
680        }
681    }
682}
683
684fn prepare_dev_extension(
685    options: Option<&crate::dev_extension::DevOptions>,
686    cwd: &Path,
687    parsed: &mut Args,
688) -> Result<Option<std::sync::Arc<crate::dev_extension::DevExtension>>, i32> {
689    let Some(options) = options else {
690        return Ok(None);
691    };
692
693    match crate::dev_extension::DevExtension::detect(cwd, options) {
694        Ok(extension) => {
695            if let Err(error) = extension.rebuild() {
696                eprintln!("error: initial extension build failed: {error}");
697                return Err(EXIT_RUNTIME);
698            }
699            if let Err(error) = extension.apply_to_args(parsed) {
700                eprintln!("error: {error}");
701                return Err(EXIT_RUNTIME);
702            }
703            Ok(Some(extension))
704        }
705        Err(error) => {
706            // No Cargo cdylib found: degrade to skills-only mode.
707            eprintln!("dev: {error}");
708            eprintln!("dev: no Cargo cdylib found; running in skills-only mode");
709            parsed.no_extensions = true;
710            parsed.extensions_dir.clear();
711            parsed.extension.clear();
712            if options.local_only {
713                parsed.dev_local_only = true;
714            }
715            Ok(None)
716        }
717    }
718}
719
720async fn resolve_resume(parsed: &mut Args, cwd: &Path) -> Option<i32> {
721    if !parsed.resume {
722        return None;
723    }
724    if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
725        eprintln!("error: --resume requires an interactive terminal");
726        return Some(EXIT_USAGE);
727    }
728
729    let session_dir = crate::session::configured_session_dir(parsed, cwd);
730    match crate::resume_picker::select_in_dir(cwd, &session_dir).await {
731        Ok(Some(id)) => {
732            parsed.resume = false;
733            parsed.session = Some(id);
734            parsed.session_dir = Some(session_dir);
735            None
736        }
737        Ok(None) => Some(0),
738        Err(error) => {
739            eprintln!("error: {error}");
740            Some(EXIT_RUNTIME)
741        }
742    }
743}
744
745fn emit_startup_warnings(parsed: &Args) {
746    // ---- Startup warnings (ignored-but-recognized flags) ----
747    if parsed.verbose {
748        for warn in &parsed.ignored {
749            eprintln!("warning: {warn}");
750        }
751    }
752    if parsed.no_themes && parsed.theme.is_some() {
753        eprintln!("warning: --no-themes overrides --theme; using the built-in default theme");
754    }
755}
756
757/// Print the merged model catalog, optionally filtered by a case-insensitive
758/// fuzzy-ish substring over provider, id, and display name.
759async fn list_models(search: &str, args: &Args, cwd: &Path) -> i32 {
760    let trusted = crate::session::resolve_project_trust(args, cwd);
761    let prepared = match prepare_extensions(args, cwd, trusted) {
762        Ok(prepared) => prepared,
763        Err(error) => {
764            print_build_error(&error);
765            return EXIT_RUNTIME;
766        }
767    };
768    let manifests = crate::provider::manifests_from_session(&prepared.extension_session);
769    let catalog = match crate::provider::catalog_with_diagnostics(&manifests) {
770        Ok((models, warnings)) => {
771            for warning in warnings {
772                eprintln!("warning: models.json: {warning}");
773            }
774            models
775        }
776        Err(error) => {
777            eprintln!("warning: could not load models.json: {error}");
778            Vec::new()
779        }
780    };
781    let needle = search.trim().to_ascii_lowercase();
782    let mut models: Vec<_> = catalog
783        .into_iter()
784        .filter(|model| {
785            needle.is_empty()
786                || format!("{} {} {}", model.provider, model.id, model.name)
787                    .to_ascii_lowercase()
788                    .contains(&needle)
789        })
790        .collect();
791    if models.is_empty() {
792        if needle.is_empty() {
793            println!("{}", crate::auth_guidance::no_models_available_message());
794        } else {
795            println!("No models matching \"{search}\"");
796        }
797        return 0;
798    }
799
800    fn format_tokens(value: u64) -> String {
801        if value >= 1_000_000 {
802            let whole = value % 1_000_000 == 0;
803            if whole {
804                format!("{}M", value / 1_000_000)
805            } else {
806                format!("{:.1}M", value as f64 / 1_000_000.0)
807            }
808        } else if value >= 1_000 {
809            let whole = value % 1_000 == 0;
810            if whole {
811                format!("{}K", value / 1_000)
812            } else {
813                format!("{:.1}K", value as f64 / 1_000.0)
814            }
815        } else {
816            value.to_string()
817        }
818    }
819
820    let rows: Vec<_> = models
821        .drain(..)
822        .map(|model| {
823            let images = model
824                .input
825                .iter()
826                .any(|input| matches!(input, rpi_ai::InputModality::Image));
827            (
828                model.provider,
829                model.id,
830                format_tokens(model.context_window),
831                format_tokens(model.max_tokens),
832                if model.reasoning { "yes" } else { "no" }.to_string(),
833                if images { "yes" } else { "no" }.to_string(),
834            )
835        })
836        .collect();
837    let widths = (
838        rows.iter().map(|r| r.0.len()).max().unwrap_or(8).max(8),
839        rows.iter().map(|r| r.1.len()).max().unwrap_or(5).max(5),
840        rows.iter().map(|r| r.2.len()).max().unwrap_or(7).max(7),
841        rows.iter().map(|r| r.3.len()).max().unwrap_or(7).max(7),
842        rows.iter().map(|r| r.4.len()).max().unwrap_or(8).max(8),
843        rows.iter().map(|r| r.5.len()).max().unwrap_or(6).max(6),
844    );
845    println!(
846        "{:provider$}  {:model$}  {:context$}  {:max_out$}  {:thinking$}  {:images$}",
847        "provider",
848        "model",
849        "context",
850        "max-out",
851        "thinking",
852        "images",
853        provider = widths.0,
854        model = widths.1,
855        context = widths.2,
856        max_out = widths.3,
857        thinking = widths.4,
858        images = widths.5,
859    );
860    for row in rows {
861        println!(
862            "{:provider$}  {:model$}  {:context$}  {:max_out$}  {:thinking$}  {:images$}",
863            row.0,
864            row.1,
865            row.2,
866            row.3,
867            row.4,
868            row.5,
869            provider = widths.0,
870            model = widths.1,
871            context = widths.2,
872            max_out = widths.3,
873            thinking = widths.4,
874            images = widths.5,
875        );
876    }
877    0
878}
879
880/// Read piped stdin into a string. Mirrors TS `readPipedStdin`: returns `None`
881/// when stdin is a TTY (interactive), else the trimmed stdin text (empty ⇒
882/// `None`).
883///
884/// NOTE: if stdin is *not* a TTY but no bytes arrive (e.g. `pi < /dev/null`),
885/// this returns `None` (empty), which is what TS does too (`data.trim() || undefined`).
886fn read_piped_stdin() -> Option<String> {
887    // Debug/testing escape hatch: RPI_SKIP_STDIN=1 skips reading piped stdin
888    // (avoids blocking on non-TTY stdin in automated environments).
889    if std::env::var("RPI_SKIP_STDIN")
890        .map(|v| v == "1")
891        .unwrap_or(false)
892    {
893        return None;
894    }
895    if std::io::stdin().is_terminal() {
896        return None;
897    }
898    let mut buf = String::new();
899    match std::io::stdin().read_to_string(&mut buf) {
900        Ok(_) => {
901            let trimmed = buf.trim();
902            if trimmed.is_empty() {
903                None
904            } else {
905                Some(trimmed.to_string())
906            }
907        }
908        Err(_) => None,
909    }
910}
911
912/// Expand `@file` attachments into prompt text. Mirrors the *text* branch of
913/// TS `processFileArguments`: each readable text file is wrapped in
914/// `<file name="...">\n<contents>\n</file>\n` and concatenated.
915///
916/// Paths are resolved relative to `cwd` (the TS uses `resolve(readPath, cwd)`).
917fn process_file_args(
918    file_args: &[std::path::PathBuf],
919    cwd: &Path,
920) -> Result<(String, Vec<ImageContent>), String> {
921    let mut text = String::new();
922    let mut images = Vec::new();
923    for rel in file_args {
924        let abs = if rel.is_absolute() {
925            rel.clone()
926        } else {
927            cwd.join(rel)
928        };
929        if !abs.exists() {
930            return Err(format!("file not found: {}", abs.display()));
931        }
932        let bytes = std::fs::read(&abs)
933            .map_err(|e| format!("could not read file {}: {e}", abs.display()))?;
934        if let Some(image) = image_content_from_bytes(&bytes) {
935            images.push(image);
936        } else {
937            let content = String::from_utf8(bytes).map_err(|_| {
938                format!(
939                    "file is not valid UTF-8 text or a supported image: {}",
940                    abs.display()
941                )
942            })?;
943            text.push_str(&format!(
944                "<file name=\"{}\">\n{}\n</file>\n",
945                abs.display(),
946                content
947            ));
948        }
949    }
950    Ok((text, images))
951}
952
953pub(crate) fn image_content_from_path(path: &Path) -> Result<Option<ImageContent>, String> {
954    let bytes =
955        std::fs::read(path).map_err(|e| format!("could not read file {}: {e}", path.display()))?;
956    Ok(image_content_from_bytes(&bytes))
957}
958
959fn image_content_from_bytes(bytes: &[u8]) -> Option<ImageContent> {
960    let mime_type = rpi_tools::detect_supported_image_mime_type(bytes)?;
961    Some(ImageContent {
962        kind: ImageContentType,
963        data: rpi_tools::encode_base64(bytes),
964        mime_type: mime_type.to_string(),
965    })
966}
967
968/// Build the initial prompt + the remaining extra messages. Mirrors TS
969/// `buildInitialMessage`: `[stdinContent, fileText, messages[0]].join("")` is
970/// the initial message; `messages[1..]` are the follow-up prompts.
971///
972/// Returns `(initial: Option<String>, extra: Vec<String>)`.
973fn build_initial_message(
974    parsed: &Args,
975    stdin: Option<&str>,
976    file_text: Option<&str>,
977) -> (Option<String>, Vec<String>) {
978    let mut extra = parsed.messages.clone();
979    let mut parts: Vec<String> = Vec::new();
980    if let Some(s) = stdin {
981        parts.push(s.to_string());
982    }
983    if let Some(t) = file_text {
984        parts.push(t.to_string());
985    }
986    // Pull the first positional message into the initial prompt (TS `.shift()`).
987    if !extra.is_empty() {
988        parts.push(extra.remove(0));
989    }
990    let initial = if parts.is_empty() {
991        None
992    } else {
993        Some(parts.join(""))
994    };
995    (initial, extra)
996}
997
998/// Print a model-resolution error with env-specific guidance. Mirrors the TS
999/// auth-guidance / model-resolver error formatting (condensed to stderr lines).
1000fn print_resolve_error(e: &ResolveError) {
1001    match e {
1002        ResolveError::NoApiKey { hint } => {
1003            eprintln!("error: {e}");
1004            eprintln!();
1005            eprintln!("Provide credentials via one of: {hint}.");
1006        }
1007        ResolveError::Config(_) => {
1008            eprintln!("error: {e}");
1009            eprintln!();
1010            eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
1011        }
1012        _ => eprintln!("error: {e}"),
1013    }
1014}
1015
1016/// Print a harness-build error with flag-specific guidance for restore requests.
1017fn print_build_error(e: &BuildError) {
1018    match e {
1019        BuildError::SessionNotFound { .. } => {
1020            eprintln!("error: {e}");
1021            eprintln!();
1022            eprintln!("List saved sessions with the /session command in interactive mode.");
1023        }
1024        _ => eprintln!("error: {e}"),
1025    }
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031    use crate::args::Args;
1032
1033    #[test]
1034    fn take_flag_value_supports_both_spellings() {
1035        let argv = vec![
1036            "--connect".to_string(),
1037            "127.0.0.1:9899".to_string(),
1038            "--token".to_string(),
1039            "abc".to_string(),
1040        ];
1041        assert_eq!(
1042            take_flag_value(&argv, "--connect").as_deref(),
1043            Some("127.0.0.1:9899")
1044        );
1045        assert_eq!(take_flag_value(&argv, "--token").as_deref(), Some("abc"));
1046        assert_eq!(take_flag_value(&argv, "--missing"), None);
1047
1048        let inline = vec!["--connect=1.2.3.4:1".to_string()];
1049        assert_eq!(
1050            take_flag_value(&inline, "--connect").as_deref(),
1051            Some("1.2.3.4:1")
1052        );
1053    }
1054
1055    #[test]
1056    fn connect_token_prefers_the_flag_over_the_env_var() {
1057        // The flag wins regardless of any RPI_SERVER_TOKEN in the environment.
1058        let argv = vec!["--token".to_string(), "flag-token".to_string()];
1059        assert_eq!(resolve_connect_token(&argv).as_deref(), Some("flag-token"));
1060    }
1061
1062    #[test]
1063    fn build_initial_combines_stdin_file_and_first_message() {
1064        let mut args = Args::default();
1065        args.messages = vec!["first".into(), "second".into(), "third".into()];
1066        let (initial, extra) =
1067            build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
1068        assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
1069        assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
1070    }
1071
1072    #[test]
1073    fn build_initial_with_no_messages_uses_stdin_and_file_only() {
1074        let args = Args::default();
1075        let (initial, extra) =
1076            build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
1077        assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
1078        assert!(extra.is_empty());
1079    }
1080
1081    #[test]
1082    fn build_initial_none_when_all_empty() {
1083        let args = Args::default();
1084        let (initial, extra) = build_initial_message(&args, None, None);
1085        assert!(initial.is_none());
1086        assert!(extra.is_empty());
1087    }
1088
1089    #[test]
1090    fn build_initial_shifts_only_first_message() {
1091        let mut args = Args::default();
1092        args.messages = vec!["a".into(), "b".into()];
1093        let (initial, extra) = build_initial_message(&args, None, None);
1094        assert_eq!(initial.as_deref(), Some("a"));
1095        assert_eq!(extra, vec!["b".to_string()]);
1096    }
1097
1098    #[test]
1099    fn process_file_args_attaches_supported_images() {
1100        let dir = tempfile::tempdir().unwrap();
1101        let path = dir.path().join("image.bin");
1102        let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
1103        png.extend_from_slice(b"IHDR");
1104        png.extend_from_slice(&[0; 13]);
1105        std::fs::write(&path, png).unwrap();
1106        let (text, images) = process_file_args(&[path], dir.path()).unwrap();
1107        assert!(text.is_empty());
1108        assert_eq!(images.len(), 1);
1109        assert_eq!(images[0].mime_type, "image/png");
1110        assert!(!images[0].data.is_empty());
1111    }
1112
1113    #[test]
1114    fn image_content_from_path_reports_supported_mime() {
1115        let dir = tempfile::tempdir().unwrap();
1116        let path = dir.path().join("drop.png");
1117        let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
1118        png.extend_from_slice(b"IHDR");
1119        png.extend_from_slice(&[0; 13]);
1120        std::fs::write(&path, png).unwrap();
1121        let image = image_content_from_path(&path).unwrap().unwrap();
1122        assert_eq!(image.mime_type, "image/png");
1123    }
1124}