1use 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
36pub const EXIT_USAGE: i32 = 2;
40pub const EXIT_RUNTIME: i32 = 1;
43
44pub async fn run() -> i32 {
50 let argv: Vec<String> = std::env::args().skip(1).collect();
53
54 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 if parsed.help {
65 print_help();
66 return 0;
67 }
68 if parsed.version {
69 print_version();
70 return 0;
71 }
72
73 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 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 let _ = crate::config::migrate_legacy_layout();
96
97 if parsed.verbose {
99 for warn in &parsed.ignored {
100 eprintln!("warning: {warn}");
101 }
102 }
103
104 let stdin_text = read_piped_stdin();
106
107 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 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 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 let model_catalog = crate::provider::available_catalog(&resolved);
142
143 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 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 let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
159 RunMode::Print
160 } else {
161 mode
162 };
163
164 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 eprintln!("error: rpc mode is not implemented in v1 (use --mode text or --mode json)");
192 EXIT_USAGE
193 }
194 }
195}
196
197fn read_piped_stdin() -> Option<String> {
204 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
226fn 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 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
277fn 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
286fn 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 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
312fn 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
330fn 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}