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