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 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    // ---- Startup warnings (ignored-but-recognized flags) ----
98    if parsed.verbose {
99        for warn in &parsed.ignored {
100            eprintln!("warning: {warn}");
101        }
102    }
103
104    // ---- stdin (TS readPipedStdin: non-TTY stdin becomes initial prompt text) ----
105    let stdin_text = read_piped_stdin();
106
107    // ---- @file attachments → text (TS processFileArguments, text branch only) ----
108    let (file_text, _file_images) = match process_file_args(&parsed.file_args, &cwd) {
109        Ok(t) => t,
110        Err(msg) => {
111            eprintln!("error: {msg}");
112            return EXIT_USAGE;
113        }
114    };
115
116    // ---- initial message + extra messages (TS buildInitialMessage) ----
117    let file_text_opt = if file_text.is_empty() { None } else { Some(file_text.as_str()) };
118    let (initial, extra) =
119        build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
120
121    // ---- provider + model resolution ----
122    let resolved = match resolve(
123        parsed.provider.as_deref(),
124        parsed.model.as_deref(),
125        parsed.thinking,
126        parsed.api_key.as_deref(),
127        parsed.base_url.as_deref(),
128    ) {
129        Ok(r) => r,
130        Err(e) => {
131            print_resolve_error(&e);
132            return match e {
133                ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
134                _ => EXIT_RUNTIME,
135            };
136        }
137    };
138
139    // The full authenticated catalog (read-only) for the TUI's `/model` selector.
140    // v1 does not switch models mid-session, so this is display-only.
141    let model_catalog = crate::provider::available_catalog(&resolved);
142
143    // ---- harness build ----
144    let (harness, event_rx, reload_context) = match build(&resolved, &parsed, &cwd).await {
145        Ok(triple) => triple,
146        Err(e) => {
147            print_build_error(&e);
148            return EXIT_RUNTIME;
149        }
150    };
151
152    // ---- mode dispatch (TS resolveAppMode → runPrintMode / InteractiveMode / runRpcMode) ----
153    let stdin_is_tty = std::io::stdin().is_terminal();
154    let stdout_is_tty = std::io::stdout().is_terminal();
155    let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
156
157    // TS downgrades interactive → print when piped stdin is present.
158    let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
159        RunMode::Print
160    } else {
161        mode
162    };
163
164    // Debug/testing escape hatch: RPI_FORCE_TUI=1 forces interactive mode
165    // (for testing the TUI in non-TTY environments).
166    let mode = if std::env::var("RPI_FORCE_TUI").map(|v| v == "1").unwrap_or(false) {
167        RunMode::Interactive
168    } else {
169        mode
170    };
171
172    match mode {
173        RunMode::Print => crate::modes::print(&harness, &parsed, initial.clone(), &extra).await,
174        RunMode::Json => crate::modes::json(&harness, &parsed, initial.clone(), &extra).await,
175        RunMode::Interactive => {
176            crate::modes::interactive(
177                &harness,
178                Some(event_rx),
179                &parsed,
180                model_catalog,
181                initial.clone(),
182                &extra,
183                resolved.theme.as_deref(),
184                &reload_context,
185            )
186            .await
187        }
188        RunMode::Rpc => {
189            // `--mode rpc` is parsed (so it doesn't hard-error) but not
190            // implemented in v1 — the JSON-RPC session protocol the TS
191            // `runRpcMode` drives is deferred.
192            eprintln!("error: rpc mode is not implemented in v1 (use --mode text or --mode json)");
193            EXIT_USAGE
194        }
195    }
196}
197
198/// Read piped stdin into a string. Mirrors TS `readPipedStdin`: returns `None`
199/// when stdin is a TTY (interactive), else the trimmed stdin text (empty ⇒
200/// `None`).
201///
202/// NOTE: if stdin is *not* a TTY but no bytes arrive (e.g. `pi < /dev/null`),
203/// this returns `None` (empty), which is what TS does too (`data.trim() || undefined`).
204fn read_piped_stdin() -> Option<String> {
205    // Debug/testing escape hatch: RPI_SKIP_STDIN=1 skips reading piped stdin
206    // (avoids blocking on non-TTY stdin in automated environments).
207    if std::env::var("RPI_SKIP_STDIN").map(|v| v == "1").unwrap_or(false) {
208        return None;
209    }
210    if std::io::stdin().is_terminal() {
211        return None;
212    }
213    let mut buf = String::new();
214    match std::io::stdin().read_to_string(&mut buf) {
215        Ok(_) => {
216            let trimmed = buf.trim();
217            if trimmed.is_empty() {
218                None
219            } else {
220                Some(trimmed.to_string())
221            }
222        }
223        Err(_) => None,
224    }
225}
226
227/// Expand `@file` attachments into prompt text. Mirrors the *text* branch of
228/// TS `processFileArguments`: each readable text file is wrapped in
229/// `<file name="...">\n<contents>\n</file>\n` and concatenated.
230///
231/// v1 divergence: the TS image branch (detect mime → base64 → `ImageContent`)
232/// is **not ported** — `pi-tools` ships an image *detector* but no CLI-facing
233/// image processor, and the v1 `modes` do not forward images into
234/// `prompt_text`. Recognized image extensions are reported as an error rather
235/// than silently mis-parsed as text. See `docs/m6-cli-open-questions.md`.
236///
237/// Paths are resolved relative to `cwd` (the TS uses `resolve(readPath, cwd)`).
238fn process_file_args(
239    file_args: &[std::path::PathBuf],
240    cwd: &Path,
241) -> Result<(String, Vec<ImageContent>), String> {
242    let mut text = String::new();
243    for rel in file_args {
244        let abs = if rel.is_absolute() {
245            rel.clone()
246        } else {
247            cwd.join(rel)
248        };
249        if !abs.exists() {
250            return Err(format!("file not found: {}", abs.display()));
251        }
252        // v1: refuse image files outright (no image-attachment path yet).
253        if is_likely_image(&abs) {
254            return Err(format!(
255                "image attachments are not supported in v1: {}",
256                abs.display()
257            ));
258        }
259        match std::fs::read_to_string(&abs) {
260            Ok(content) => {
261                text.push_str(&format!(
262                    "<file name=\"{}\">\n{}\n</file>\n",
263                    abs.display(),
264                    content
265                ));
266            }
267            Err(e) => {
268                return Err(format!(
269                    "could not read file {}: {e}",
270                    abs.display()
271                ));
272            }
273        }
274    }
275    Ok((text, Vec::new()))
276}
277
278/// True if the path's extension looks like a raster image the TS path would
279/// have base64-attached. Used to route `@file` away from the text branch.
280fn is_likely_image(path: &Path) -> bool {
281    matches!(
282        path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(),
283        Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp")
284    )
285}
286
287/// Build the initial prompt + the remaining extra messages. Mirrors TS
288/// `buildInitialMessage`: `[stdinContent, fileText, messages[0]].join("")` is
289/// the initial message; `messages[1..]` are the follow-up prompts.
290///
291/// Returns `(initial: Option<String>, extra: Vec<String>)`.
292fn build_initial_message(
293    parsed: &Args,
294    stdin: Option<&str>,
295    file_text: Option<&str>,
296) -> (Option<String>, Vec<String>) {
297    let mut extra = parsed.messages.clone();
298    let mut parts: Vec<String> = Vec::new();
299    if let Some(s) = stdin {
300        parts.push(s.to_string());
301    }
302    if let Some(t) = file_text {
303        parts.push(t.to_string());
304    }
305    // Pull the first positional message into the initial prompt (TS `.shift()`).
306    if !extra.is_empty() {
307        parts.push(extra.remove(0));
308    }
309    let initial = if parts.is_empty() { None } else { Some(parts.join("")) };
310    (initial, extra)
311}
312
313/// Print a model-resolution error with env-specific guidance. Mirrors the TS
314/// auth-guidance / model-resolver error formatting (condensed to stderr lines).
315fn print_resolve_error(e: &ResolveError) {
316    match e {
317        ResolveError::NoApiKey { hint } => {
318            eprintln!("error: {e}");
319            eprintln!();
320            eprintln!("Provide credentials via one of: {hint}.");
321        }
322        ResolveError::Config(_) => {
323            eprintln!("error: {e}");
324            eprintln!();
325            eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
326        }
327        _ => eprintln!("error: {e}"),
328    }
329}
330
331/// Print a harness-build error with flag-specific guidance for restore requests.
332fn print_build_error(e: &BuildError) {
333    match e {
334        BuildError::SessionNotFound { .. } => {
335            eprintln!("error: {e}");
336            eprintln!();
337            eprintln!("List saved sessions with the /session command in interactive mode.");
338        }
339        _ => eprintln!("error: {e}"),
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use crate::args::Args;
347
348    #[test]
349    fn build_initial_combines_stdin_file_and_first_message() {
350        let mut args = Args::default();
351        args.messages = vec!["first".into(), "second".into(), "third".into()];
352        let (initial, extra) =
353            build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
354        assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
355        assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
356    }
357
358    #[test]
359    fn build_initial_with_no_messages_uses_stdin_and_file_only() {
360        let args = Args::default();
361        let (initial, extra) =
362            build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
363        assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
364        assert!(extra.is_empty());
365    }
366
367    #[test]
368    fn build_initial_none_when_all_empty() {
369        let args = Args::default();
370        let (initial, extra) = build_initial_message(&args, None, None);
371        assert!(initial.is_none());
372        assert!(extra.is_empty());
373    }
374
375    #[test]
376    fn build_initial_shifts_only_first_message() {
377        let mut args = Args::default();
378        args.messages = vec!["a".into(), "b".into()];
379        let (initial, extra) = build_initial_message(&args, None, None);
380        assert_eq!(initial.as_deref(), Some("a"));
381        assert_eq!(extra, vec!["b".to_string()]);
382    }
383
384    #[test]
385    fn is_likely_image_detects_extensions() {
386        assert!(is_likely_image(Path::new("foo.png")));
387        assert!(is_likely_image(Path::new("foo.JPG")));
388        assert!(!is_likely_image(Path::new("foo.rs")));
389        assert!(!is_likely_image(Path::new("foo")));
390    }
391}