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