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) = match build(&resolved, &parsed, &cwd).await {
145        Ok(pair) => pair,
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            )
185            .await
186        }
187        RunMode::Rpc => {
188            // `--mode rpc` is parsed (so it doesn't hard-error) but not
189            // implemented in v1 — the JSON-RPC session protocol the TS
190            // `runRpcMode` drives is deferred.
191            eprintln!("error: rpc mode is not implemented in v1 (use --mode text or --mode json)");
192            EXIT_USAGE
193        }
194    }
195}
196
197/// Read piped stdin into a string. Mirrors TS `readPipedStdin`: returns `None`
198/// when stdin is a TTY (interactive), else the trimmed stdin text (empty ⇒
199/// `None`).
200///
201/// NOTE: if stdin is *not* a TTY but no bytes arrive (e.g. `pi < /dev/null`),
202/// this returns `None` (empty), which is what TS does too (`data.trim() || undefined`).
203fn read_piped_stdin() -> Option<String> {
204    // Debug/testing escape hatch: RPI_SKIP_STDIN=1 skips reading piped stdin
205    // (avoids blocking on non-TTY stdin in automated environments).
206    if std::env::var("RPI_SKIP_STDIN").map(|v| v == "1").unwrap_or(false) {
207        return None;
208    }
209    if std::io::stdin().is_terminal() {
210        return None;
211    }
212    let mut buf = String::new();
213    match std::io::stdin().read_to_string(&mut buf) {
214        Ok(_) => {
215            let trimmed = buf.trim();
216            if trimmed.is_empty() {
217                None
218            } else {
219                Some(trimmed.to_string())
220            }
221        }
222        Err(_) => None,
223    }
224}
225
226/// Expand `@file` attachments into prompt text. Mirrors the *text* branch of
227/// TS `processFileArguments`: each readable text file is wrapped in
228/// `<file name="...">\n<contents>\n</file>\n` and concatenated.
229///
230/// v1 divergence: the TS image branch (detect mime → base64 → `ImageContent`)
231/// is **not ported** — `pi-tools` ships an image *detector* but no CLI-facing
232/// image processor, and the v1 `modes` do not forward images into
233/// `prompt_text`. Recognized image extensions are reported as an error rather
234/// than silently mis-parsed as text. See `docs/m6-cli-open-questions.md`.
235///
236/// Paths are resolved relative to `cwd` (the TS uses `resolve(readPath, cwd)`).
237fn process_file_args(
238    file_args: &[std::path::PathBuf],
239    cwd: &Path,
240) -> Result<(String, Vec<ImageContent>), String> {
241    let mut text = String::new();
242    for rel in file_args {
243        let abs = if rel.is_absolute() {
244            rel.clone()
245        } else {
246            cwd.join(rel)
247        };
248        if !abs.exists() {
249            return Err(format!("file not found: {}", abs.display()));
250        }
251        // v1: refuse image files outright (no image-attachment path yet).
252        if is_likely_image(&abs) {
253            return Err(format!(
254                "image attachments are not supported in v1: {}",
255                abs.display()
256            ));
257        }
258        match std::fs::read_to_string(&abs) {
259            Ok(content) => {
260                text.push_str(&format!(
261                    "<file name=\"{}\">\n{}\n</file>\n",
262                    abs.display(),
263                    content
264                ));
265            }
266            Err(e) => {
267                return Err(format!(
268                    "could not read file {}: {e}",
269                    abs.display()
270                ));
271            }
272        }
273    }
274    Ok((text, Vec::new()))
275}
276
277/// True if the path's extension looks like a raster image the TS path would
278/// have base64-attached. Used to route `@file` away from the text branch.
279fn is_likely_image(path: &Path) -> bool {
280    matches!(
281        path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(),
282        Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp")
283    )
284}
285
286/// Build the initial prompt + the remaining extra messages. Mirrors TS
287/// `buildInitialMessage`: `[stdinContent, fileText, messages[0]].join("")` is
288/// the initial message; `messages[1..]` are the follow-up prompts.
289///
290/// Returns `(initial: Option<String>, extra: Vec<String>)`.
291fn build_initial_message(
292    parsed: &Args,
293    stdin: Option<&str>,
294    file_text: Option<&str>,
295) -> (Option<String>, Vec<String>) {
296    let mut extra = parsed.messages.clone();
297    let mut parts: Vec<String> = Vec::new();
298    if let Some(s) = stdin {
299        parts.push(s.to_string());
300    }
301    if let Some(t) = file_text {
302        parts.push(t.to_string());
303    }
304    // Pull the first positional message into the initial prompt (TS `.shift()`).
305    if !extra.is_empty() {
306        parts.push(extra.remove(0));
307    }
308    let initial = if parts.is_empty() { None } else { Some(parts.join("")) };
309    (initial, extra)
310}
311
312/// Print a model-resolution error with env-specific guidance. Mirrors the TS
313/// auth-guidance / model-resolver error formatting (condensed to stderr lines).
314fn print_resolve_error(e: &ResolveError) {
315    match e {
316        ResolveError::NoApiKey { hint } => {
317            eprintln!("error: {e}");
318            eprintln!();
319            eprintln!("Provide credentials via one of: {hint}.");
320        }
321        ResolveError::Config(_) => {
322            eprintln!("error: {e}");
323            eprintln!();
324            eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
325        }
326        _ => eprintln!("error: {e}"),
327    }
328}
329
330/// Print a harness-build error with flag-specific guidance for restore requests.
331fn print_build_error(e: &BuildError) {
332    match e {
333        BuildError::RestoreNotImplemented { requested: _, flag } => {
334            eprintln!("error: {e}");
335            eprintln!();
336            eprintln!(
337                "To start a fresh session instead, drop {flag} (and any --session argument)."
338            );
339        }
340        _ => eprintln!("error: {e}"),
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::args::Args;
348
349    #[test]
350    fn build_initial_combines_stdin_file_and_first_message() {
351        let mut args = Args::default();
352        args.messages = vec!["first".into(), "second".into(), "third".into()];
353        let (initial, extra) =
354            build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
355        assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
356        assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
357    }
358
359    #[test]
360    fn build_initial_with_no_messages_uses_stdin_and_file_only() {
361        let args = Args::default();
362        let (initial, extra) =
363            build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
364        assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
365        assert!(extra.is_empty());
366    }
367
368    #[test]
369    fn build_initial_none_when_all_empty() {
370        let args = Args::default();
371        let (initial, extra) = build_initial_message(&args, None, None);
372        assert!(initial.is_none());
373        assert!(extra.is_empty());
374    }
375
376    #[test]
377    fn build_initial_shifts_only_first_message() {
378        let mut args = Args::default();
379        args.messages = vec!["a".into(), "b".into()];
380        let (initial, extra) = build_initial_message(&args, None, None);
381        assert_eq!(initial.as_deref(), Some("a"));
382        assert_eq!(extra, vec!["b".to_string()]);
383    }
384
385    #[test]
386    fn is_likely_image_detects_extensions() {
387        assert!(is_likely_image(Path::new("foo.png")));
388        assert!(is_likely_image(Path::new("foo.JPG")));
389        assert!(!is_likely_image(Path::new("foo.rs")));
390        assert!(!is_likely_image(Path::new("foo")));
391    }
392}