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 if argv.first().map(|s| s.as_str()) == Some("install") {
61 return crate::install::run(&argv[1..]);
62 }
63
64 let mut parsed = parse_args(&argv);
65
66 if parsed.help {
68 print_help();
69 return 0;
70 }
71 if parsed.version {
72 print_version();
73 return 0;
74 }
75
76 if !parsed.errors.is_empty() {
78 for err in &parsed.errors {
79 eprintln!("error: {err}");
80 }
81 eprintln!();
82 print_help();
83 return EXIT_USAGE;
84 }
85
86 let cwd = match std::env::current_dir() {
88 Ok(c) => c,
89 Err(e) => {
90 eprintln!("error: could not determine the current directory: {e}");
91 return EXIT_USAGE;
92 }
93 };
94
95 let _ = crate::config::migrate_legacy_layout();
99
100 if parsed.resume {
104 if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
105 eprintln!("error: --resume requires an interactive terminal");
106 return EXIT_USAGE;
107 }
108 match crate::resume_picker::select(&cwd).await {
109 Ok(Some(id)) => {
110 parsed.resume = false;
111 parsed.session = Some(id);
112 }
113 Ok(None) => return 0,
114 Err(e) => {
115 eprintln!("error: {e}");
116 return EXIT_RUNTIME;
117 }
118 }
119 }
120
121 if parsed.verbose {
123 for warn in &parsed.ignored {
124 eprintln!("warning: {warn}");
125 }
126 }
127
128 let stdin_text = read_piped_stdin();
130
131 let (file_text, _file_images) = match process_file_args(&parsed.file_args, &cwd) {
133 Ok(t) => t,
134 Err(msg) => {
135 eprintln!("error: {msg}");
136 return EXIT_USAGE;
137 }
138 };
139
140 let file_text_opt = if file_text.is_empty() {
142 None
143 } else {
144 Some(file_text.as_str())
145 };
146 let (initial, extra) = build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
147
148 let resolved = match resolve(
150 parsed.provider.as_deref(),
151 parsed.model.as_deref(),
152 parsed.thinking,
153 parsed.api_key.as_deref(),
154 parsed.base_url.as_deref(),
155 ) {
156 Ok(r) => r,
157 Err(e) => {
158 print_resolve_error(&e);
159 return match e {
160 ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
161 _ => EXIT_RUNTIME,
162 };
163 }
164 };
165
166 let model_catalog = crate::provider::available_catalog(&resolved);
169
170 if let Some(patterns) = &parsed.models {
175 let mut matched: Vec<String> = Vec::new();
176 for p in patterns {
177 let hits: Vec<String> = model_catalog
178 .iter()
179 .filter(|m| m.id.eq_ignore_ascii_case(p))
180 .map(|m| m.id.clone())
181 .collect();
182 if hits.is_empty() {
183 eprintln!("warning: --models pattern \"{p}\" matched no model");
184 }
185 matched.extend(hits);
186 }
187 let mut settings = crate::settings::load_settings().unwrap_or_default();
188 settings.scoped_models = if matched.is_empty() {
189 None
190 } else {
191 Some(matched)
192 };
193 if let Err(e) = crate::settings::save_settings(&settings) {
194 eprintln!("warning: could not save --models scope: {e}");
195 }
196 }
197
198 let (harness, event_rx, reload_context) = match build(&resolved, &parsed, &cwd).await {
200 Ok(triple) => triple,
201 Err(e) => {
202 print_build_error(&e);
203 return EXIT_RUNTIME;
204 }
205 };
206
207 let stdin_is_tty = std::io::stdin().is_terminal();
209 let stdout_is_tty = std::io::stdout().is_terminal();
210 let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
211
212 let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
214 RunMode::Print
215 } else {
216 mode
217 };
218
219 let mode = if std::env::var("RPI_FORCE_TUI")
222 .map(|v| v == "1")
223 .unwrap_or(false)
224 {
225 RunMode::Interactive
226 } else {
227 mode
228 };
229
230 match mode {
231 RunMode::Print => crate::modes::print(&harness, &parsed, initial.clone(), &extra).await,
232 RunMode::Json => crate::modes::json(&harness, &parsed, initial.clone(), &extra).await,
233 RunMode::Interactive => {
234 crate::modes::interactive(
235 &harness,
236 Some(event_rx),
237 &parsed,
238 model_catalog,
239 initial.clone(),
240 &extra,
241 resolved.theme.as_deref(),
242 &reload_context,
243 )
244 .await
245 }
246 RunMode::Rpc => {
247 eprintln!("error: rpc mode is not implemented in v1 (use --mode text or --mode json)");
251 EXIT_USAGE
252 }
253 }
254}
255
256fn read_piped_stdin() -> Option<String> {
263 if std::env::var("RPI_SKIP_STDIN")
266 .map(|v| v == "1")
267 .unwrap_or(false)
268 {
269 return None;
270 }
271 if std::io::stdin().is_terminal() {
272 return None;
273 }
274 let mut buf = String::new();
275 match std::io::stdin().read_to_string(&mut buf) {
276 Ok(_) => {
277 let trimmed = buf.trim();
278 if trimmed.is_empty() {
279 None
280 } else {
281 Some(trimmed.to_string())
282 }
283 }
284 Err(_) => None,
285 }
286}
287
288fn process_file_args(
300 file_args: &[std::path::PathBuf],
301 cwd: &Path,
302) -> Result<(String, Vec<ImageContent>), String> {
303 let mut text = String::new();
304 for rel in file_args {
305 let abs = if rel.is_absolute() {
306 rel.clone()
307 } else {
308 cwd.join(rel)
309 };
310 if !abs.exists() {
311 return Err(format!("file not found: {}", abs.display()));
312 }
313 if is_likely_image(&abs) {
315 return Err(format!(
316 "image attachments are not supported in v1: {}",
317 abs.display()
318 ));
319 }
320 match std::fs::read_to_string(&abs) {
321 Ok(content) => {
322 text.push_str(&format!(
323 "<file name=\"{}\">\n{}\n</file>\n",
324 abs.display(),
325 content
326 ));
327 }
328 Err(e) => {
329 return Err(format!("could not read file {}: {e}", abs.display()));
330 }
331 }
332 }
333 Ok((text, Vec::new()))
334}
335
336fn is_likely_image(path: &Path) -> bool {
339 matches!(
340 path.extension()
341 .and_then(|e| e.to_str())
342 .map(|e| e.to_ascii_lowercase())
343 .as_deref(),
344 Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp")
345 )
346}
347
348fn build_initial_message(
354 parsed: &Args,
355 stdin: Option<&str>,
356 file_text: Option<&str>,
357) -> (Option<String>, Vec<String>) {
358 let mut extra = parsed.messages.clone();
359 let mut parts: Vec<String> = Vec::new();
360 if let Some(s) = stdin {
361 parts.push(s.to_string());
362 }
363 if let Some(t) = file_text {
364 parts.push(t.to_string());
365 }
366 if !extra.is_empty() {
368 parts.push(extra.remove(0));
369 }
370 let initial = if parts.is_empty() {
371 None
372 } else {
373 Some(parts.join(""))
374 };
375 (initial, extra)
376}
377
378fn print_resolve_error(e: &ResolveError) {
381 match e {
382 ResolveError::NoApiKey { hint } => {
383 eprintln!("error: {e}");
384 eprintln!();
385 eprintln!("Provide credentials via one of: {hint}.");
386 }
387 ResolveError::Config(_) => {
388 eprintln!("error: {e}");
389 eprintln!();
390 eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
391 }
392 _ => eprintln!("error: {e}"),
393 }
394}
395
396fn print_build_error(e: &BuildError) {
398 match e {
399 BuildError::SessionNotFound { .. } => {
400 eprintln!("error: {e}");
401 eprintln!();
402 eprintln!("List saved sessions with the /session command in interactive mode.");
403 }
404 _ => eprintln!("error: {e}"),
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use crate::args::Args;
412
413 #[test]
414 fn build_initial_combines_stdin_file_and_first_message() {
415 let mut args = Args::default();
416 args.messages = vec!["first".into(), "second".into(), "third".into()];
417 let (initial, extra) =
418 build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
419 assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
420 assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
421 }
422
423 #[test]
424 fn build_initial_with_no_messages_uses_stdin_and_file_only() {
425 let args = Args::default();
426 let (initial, extra) =
427 build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
428 assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
429 assert!(extra.is_empty());
430 }
431
432 #[test]
433 fn build_initial_none_when_all_empty() {
434 let args = Args::default();
435 let (initial, extra) = build_initial_message(&args, None, None);
436 assert!(initial.is_none());
437 assert!(extra.is_empty());
438 }
439
440 #[test]
441 fn build_initial_shifts_only_first_message() {
442 let mut args = Args::default();
443 args.messages = vec!["a".into(), "b".into()];
444 let (initial, extra) = build_initial_message(&args, None, None);
445 assert_eq!(initial.as_deref(), Some("a"));
446 assert_eq!(extra, vec!["b".to_string()]);
447 }
448
449 #[test]
450 fn is_likely_image_detects_extensions() {
451 assert!(is_likely_image(Path::new("foo.png")));
452 assert!(is_likely_image(Path::new("foo.JPG")));
453 assert!(!is_likely_image(Path::new("foo.rs")));
454 assert!(!is_likely_image(Path::new("foo")));
455 }
456}