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