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