Skip to main content

rpi_cli/
app.rs

1//! CLI entry orchestrator. Mirrors the v1-relevant slice of the TS
2//! `packages/coding-agent/src/main.ts` — the `main(args)` function that:
3//!
4//! 1. Parses argv ([`crate::args::parse_args`]).
5//! 2. Handles `--help`/`--version` + parse errors + startup warnings.
6//! 3. Reads piped stdin (non-TTY ⇒ treat as the initial prompt text — TS
7//!    `readPipedStdin`).
8//! 4. Expands `@file` attachments into an initial-message text block (TS
9//!    `processFileArguments` + [`build_initial_message`] — the port of TS
10//!    `buildInitialMessage`).
11//! 5. Resolves the provider + model + thinking level ([`crate::provider::resolve`]).
12//! 6. Builds the harness ([`crate::session::build`]).
13//! 7. Resolves the effective run mode ([`crate::args::resolve_mode`]) and
14//!    dispatches to [`crate::modes`] (`print`/`json`/`interactive`), mapping the
15//!    outcome to an exit code.
16//!
17//! # v1 scope cuts vs TS `main.ts` (in `docs/m6-cli-open-questions.md`)
18//!
19//! The TS `main` is enormous: auth-command routing, package-manager commands,
20//! HTTP proxy config, project-trust prompts, first-time setup, migrations,
21//! settings managers, theme init, extension/resource discovery. **None of that
22//! is ported** — v1 is a straight parse → resolve → build → run pipeline. The
23//! `@file` expansion ports *only* the text-file branch (images are detected
24//! but not attached to the prompt — the harness `prompt_text` accepts images,
25//! but v1 does not yet wire an image processor; binary/non-UTF-8 files error).
26
27use std::io::{IsTerminal, Read};
28use std::path::Path;
29
30use rpi_ai::types::ImageContent;
31
32use crate::args::{parse_args, print_help, print_version, resolve_mode, Args, RunMode};
33use crate::provider::{resolve, ResolveError};
34use crate::session::{build, BuildError};
35
36/// The exit code for a usage/parse error. (TS `main.ts` uses `process.exit(1)`
37/// for most error paths; v1 distinguishes usage errors with the conventional
38/// `2` so scripts can tell "bad invocation" from "run failed".)
39pub const EXIT_USAGE: i32 = 2;
40/// The exit code for a runtime failure (model-resolution, harness-build, or
41/// run failure). Mirrors TS `process.exitCode` set from `runPrintMode`.
42pub const EXIT_RUNTIME: i32 = 1;
43
44/// The v1 CLI entry point. Mirrors TS `export async function main(args)`.
45///
46/// Returns the process exit code (0 = success). The binary wrapper
47/// ([`crate::bin`] / `src/bin/pi.rs`) calls this under a tokio runtime and
48/// `std::process::exit`s with the returned code.
49pub async fn run() -> i32 {
50    // argv[0] is the program name; skip it (TS `main(args)` receives the same,
51    // already sliced by the Node CLI entry).
52    let argv: Vec<String> = std::env::args().skip(1).collect();
53
54    // ---- `rpi auth …` subcommand dispatch (before flag parsing) ----
55    // `auth` is a top-level subcommand (mirrors TS `runAuthCommand` routing in
56    // `main.ts`); dispatching it here avoids it being misparsed as a prompt.
57    if argv.first().map(|s| s.as_str()) == Some("auth") {
58        return crate::auth::run(&argv[1..]).await;
59    }
60    if argv.first().map(|s| s.as_str()) == Some("install") {
61        return crate::install::run(&argv[1..]);
62    }
63
64    let mut parsed = parse_args(&argv);
65
66    // ---- --help / --version short-circuit (before any heavy work) ----
67    if parsed.help {
68        print_help();
69        return 0;
70    }
71    if parsed.version {
72        print_version();
73        return 0;
74    }
75
76    // ---- Parse errors → help + usage exit ----
77    if !parsed.errors.is_empty() {
78        for err in &parsed.errors {
79            eprintln!("error: {err}");
80        }
81        eprintln!();
82        print_help();
83        return EXIT_USAGE;
84    }
85
86    // ---- cwd ----
87    let cwd = match std::env::current_dir() {
88        Ok(c) => c,
89        Err(e) => {
90            eprintln!("error: could not determine the current directory: {e}");
91            return EXIT_USAGE;
92        }
93    };
94
95    // ---- Legacy-layout migration (flat ~/.rpi → ~/.rpi/agent/) ----
96    // Best-effort; never blocks startup. Skipped when RPI_CODING_AGENT_DIR is
97    // set (an explicit override is its own layout).
98    let _ = crate::config::migrate_legacy_layout();
99
100    // `-r/--resume` is an interactive picker, unlike `-c/--continue` which
101    // immediately opens the latest session. Resolve the picker result before
102    // building the harness so cancelling does not create or modify a session.
103    if parsed.resume {
104        if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
105            eprintln!("error: --resume requires an interactive terminal");
106            return EXIT_USAGE;
107        }
108        match crate::resume_picker::select(&cwd).await {
109            Ok(Some(id)) => {
110                parsed.resume = false;
111                parsed.session = Some(id);
112            }
113            Ok(None) => return 0,
114            Err(e) => {
115                eprintln!("error: {e}");
116                return EXIT_RUNTIME;
117            }
118        }
119    }
120
121    // ---- Startup warnings (ignored-but-recognized flags) ----
122    if parsed.verbose {
123        for warn in &parsed.ignored {
124            eprintln!("warning: {warn}");
125        }
126    }
127
128    // ---- stdin (TS readPipedStdin: non-TTY stdin becomes initial prompt text) ----
129    let stdin_text = read_piped_stdin();
130
131    // ---- @file attachments → text (TS processFileArguments, text branch only) ----
132    let (file_text, _file_images) = match process_file_args(&parsed.file_args, &cwd) {
133        Ok(t) => t,
134        Err(msg) => {
135            eprintln!("error: {msg}");
136            return EXIT_USAGE;
137        }
138    };
139
140    // ---- initial message + extra messages (TS buildInitialMessage) ----
141    let file_text_opt = if file_text.is_empty() {
142        None
143    } else {
144        Some(file_text.as_str())
145    };
146    let (initial, extra) = build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
147
148    // ---- provider + model resolution ----
149    let resolved = match resolve(
150        parsed.provider.as_deref(),
151        parsed.model.as_deref(),
152        parsed.thinking,
153        parsed.api_key.as_deref(),
154        parsed.base_url.as_deref(),
155    ) {
156        Ok(r) => r,
157        Err(e) => {
158            print_resolve_error(&e);
159            return match e {
160                ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
161                _ => EXIT_RUNTIME,
162            };
163        }
164    };
165
166    // The full authenticated catalog (read-only) for the TUI's `/model` selector.
167    // v1 does not switch models mid-session, so this is display-only.
168    let model_catalog = crate::provider::available_catalog(&resolved);
169
170    // `--models <patterns>`: persist the Ctrl+M cycle scope to settings.json
171    // (the same set `/scoped-models` edits). Each pattern matches catalog ids
172    // case-insensitively; unmatched patterns are reported so a typo doesn't
173    // silently empty the cycle.
174    if let Some(patterns) = &parsed.models {
175        let mut matched: Vec<String> = Vec::new();
176        for p in patterns {
177            let hits: Vec<String> = model_catalog
178                .iter()
179                .filter(|m| m.id.eq_ignore_ascii_case(p))
180                .map(|m| m.id.clone())
181                .collect();
182            if hits.is_empty() {
183                eprintln!("warning: --models pattern \"{p}\" matched no model");
184            }
185            matched.extend(hits);
186        }
187        let mut settings = crate::settings::load_settings().unwrap_or_default();
188        settings.scoped_models = if matched.is_empty() {
189            None
190        } else {
191            Some(matched)
192        };
193        if let Err(e) = crate::settings::save_settings(&settings) {
194            eprintln!("warning: could not save --models scope: {e}");
195        }
196    }
197
198    // ---- harness build ----
199    let (harness, event_rx, reload_context) = match build(&resolved, &parsed, &cwd).await {
200        Ok(triple) => triple,
201        Err(e) => {
202            print_build_error(&e);
203            return EXIT_RUNTIME;
204        }
205    };
206
207    // ---- mode dispatch (TS resolveAppMode → runPrintMode / InteractiveMode / runRpcMode) ----
208    let stdin_is_tty = std::io::stdin().is_terminal();
209    let stdout_is_tty = std::io::stdout().is_terminal();
210    let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
211
212    // TS downgrades interactive → print when piped stdin is present.
213    let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
214        RunMode::Print
215    } else {
216        mode
217    };
218
219    // Debug/testing escape hatch: RPI_FORCE_TUI=1 forces interactive mode
220    // (for testing the TUI in non-TTY environments).
221    let mode = if std::env::var("RPI_FORCE_TUI")
222        .map(|v| v == "1")
223        .unwrap_or(false)
224    {
225        RunMode::Interactive
226    } else {
227        mode
228    };
229
230    match mode {
231        RunMode::Print => crate::modes::print(&harness, &parsed, initial.clone(), &extra).await,
232        RunMode::Json => crate::modes::json(&harness, &parsed, initial.clone(), &extra).await,
233        RunMode::Interactive => {
234            crate::modes::interactive(
235                &harness,
236                Some(event_rx),
237                &parsed,
238                model_catalog,
239                initial.clone(),
240                &extra,
241                resolved.theme.as_deref(),
242                &reload_context,
243            )
244            .await
245        }
246        RunMode::Rpc => {
247            // `--mode rpc` is parsed (so it doesn't hard-error) but not
248            // implemented in v1 — the JSON-RPC session protocol the TS
249            // `runRpcMode` drives is deferred.
250            eprintln!("error: rpc mode is not implemented in v1 (use --mode text or --mode json)");
251            EXIT_USAGE
252        }
253    }
254}
255
256/// Read piped stdin into a string. Mirrors TS `readPipedStdin`: returns `None`
257/// when stdin is a TTY (interactive), else the trimmed stdin text (empty ⇒
258/// `None`).
259///
260/// NOTE: if stdin is *not* a TTY but no bytes arrive (e.g. `pi < /dev/null`),
261/// this returns `None` (empty), which is what TS does too (`data.trim() || undefined`).
262fn read_piped_stdin() -> Option<String> {
263    // Debug/testing escape hatch: RPI_SKIP_STDIN=1 skips reading piped stdin
264    // (avoids blocking on non-TTY stdin in automated environments).
265    if std::env::var("RPI_SKIP_STDIN")
266        .map(|v| v == "1")
267        .unwrap_or(false)
268    {
269        return None;
270    }
271    if std::io::stdin().is_terminal() {
272        return None;
273    }
274    let mut buf = String::new();
275    match std::io::stdin().read_to_string(&mut buf) {
276        Ok(_) => {
277            let trimmed = buf.trim();
278            if trimmed.is_empty() {
279                None
280            } else {
281                Some(trimmed.to_string())
282            }
283        }
284        Err(_) => None,
285    }
286}
287
288/// Expand `@file` attachments into prompt text. Mirrors the *text* branch of
289/// TS `processFileArguments`: each readable text file is wrapped in
290/// `<file name="...">\n<contents>\n</file>\n` and concatenated.
291///
292/// v1 divergence: the TS image branch (detect mime → base64 → `ImageContent`)
293/// is **not ported** — `pi-tools` ships an image *detector* but no CLI-facing
294/// image processor, and the v1 `modes` do not forward images into
295/// `prompt_text`. Recognized image extensions are reported as an error rather
296/// than silently mis-parsed as text. See `docs/m6-cli-open-questions.md`.
297///
298/// Paths are resolved relative to `cwd` (the TS uses `resolve(readPath, cwd)`).
299fn process_file_args(
300    file_args: &[std::path::PathBuf],
301    cwd: &Path,
302) -> Result<(String, Vec<ImageContent>), String> {
303    let mut text = String::new();
304    for rel in file_args {
305        let abs = if rel.is_absolute() {
306            rel.clone()
307        } else {
308            cwd.join(rel)
309        };
310        if !abs.exists() {
311            return Err(format!("file not found: {}", abs.display()));
312        }
313        // v1: refuse image files outright (no image-attachment path yet).
314        if is_likely_image(&abs) {
315            return Err(format!(
316                "image attachments are not supported in v1: {}",
317                abs.display()
318            ));
319        }
320        match std::fs::read_to_string(&abs) {
321            Ok(content) => {
322                text.push_str(&format!(
323                    "<file name=\"{}\">\n{}\n</file>\n",
324                    abs.display(),
325                    content
326                ));
327            }
328            Err(e) => {
329                return Err(format!("could not read file {}: {e}", abs.display()));
330            }
331        }
332    }
333    Ok((text, Vec::new()))
334}
335
336/// True if the path's extension looks like a raster image the TS path would
337/// have base64-attached. Used to route `@file` away from the text branch.
338fn is_likely_image(path: &Path) -> bool {
339    matches!(
340        path.extension()
341            .and_then(|e| e.to_str())
342            .map(|e| e.to_ascii_lowercase())
343            .as_deref(),
344        Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp")
345    )
346}
347
348/// Build the initial prompt + the remaining extra messages. Mirrors TS
349/// `buildInitialMessage`: `[stdinContent, fileText, messages[0]].join("")` is
350/// the initial message; `messages[1..]` are the follow-up prompts.
351///
352/// Returns `(initial: Option<String>, extra: Vec<String>)`.
353fn build_initial_message(
354    parsed: &Args,
355    stdin: Option<&str>,
356    file_text: Option<&str>,
357) -> (Option<String>, Vec<String>) {
358    let mut extra = parsed.messages.clone();
359    let mut parts: Vec<String> = Vec::new();
360    if let Some(s) = stdin {
361        parts.push(s.to_string());
362    }
363    if let Some(t) = file_text {
364        parts.push(t.to_string());
365    }
366    // Pull the first positional message into the initial prompt (TS `.shift()`).
367    if !extra.is_empty() {
368        parts.push(extra.remove(0));
369    }
370    let initial = if parts.is_empty() {
371        None
372    } else {
373        Some(parts.join(""))
374    };
375    (initial, extra)
376}
377
378/// Print a model-resolution error with env-specific guidance. Mirrors the TS
379/// auth-guidance / model-resolver error formatting (condensed to stderr lines).
380fn print_resolve_error(e: &ResolveError) {
381    match e {
382        ResolveError::NoApiKey { hint } => {
383            eprintln!("error: {e}");
384            eprintln!();
385            eprintln!("Provide credentials via one of: {hint}.");
386        }
387        ResolveError::Config(_) => {
388            eprintln!("error: {e}");
389            eprintln!();
390            eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
391        }
392        _ => eprintln!("error: {e}"),
393    }
394}
395
396/// Print a harness-build error with flag-specific guidance for restore requests.
397fn print_build_error(e: &BuildError) {
398    match e {
399        BuildError::SessionNotFound { .. } => {
400            eprintln!("error: {e}");
401            eprintln!();
402            eprintln!("List saved sessions with the /session command in interactive mode.");
403        }
404        _ => eprintln!("error: {e}"),
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::args::Args;
412
413    #[test]
414    fn build_initial_combines_stdin_file_and_first_message() {
415        let mut args = Args::default();
416        args.messages = vec!["first".into(), "second".into(), "third".into()];
417        let (initial, extra) =
418            build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
419        assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
420        assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
421    }
422
423    #[test]
424    fn build_initial_with_no_messages_uses_stdin_and_file_only() {
425        let args = Args::default();
426        let (initial, extra) =
427            build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
428        assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
429        assert!(extra.is_empty());
430    }
431
432    #[test]
433    fn build_initial_none_when_all_empty() {
434        let args = Args::default();
435        let (initial, extra) = build_initial_message(&args, None, None);
436        assert!(initial.is_none());
437        assert!(extra.is_empty());
438    }
439
440    #[test]
441    fn build_initial_shifts_only_first_message() {
442        let mut args = Args::default();
443        args.messages = vec!["a".into(), "b".into()];
444        let (initial, extra) = build_initial_message(&args, None, None);
445        assert_eq!(initial.as_deref(), Some("a"));
446        assert_eq!(extra, vec!["b".to_string()]);
447    }
448
449    #[test]
450    fn is_likely_image_detects_extensions() {
451        assert!(is_likely_image(Path::new("foo.png")));
452        assert!(is_likely_image(Path::new("foo.JPG")));
453        assert!(!is_likely_image(Path::new("foo.rs")));
454        assert!(!is_likely_image(Path::new("foo")));
455    }
456}