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