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, reload_context) = match build(&resolved, &parsed, &cwd).await {
145 Ok(triple) => triple,
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 &reload_context,
185 )
186 .await
187 }
188 RunMode::Rpc => {
189 eprintln!("error: rpc mode is not implemented in v1 (use --mode text or --mode json)");
193 EXIT_USAGE
194 }
195 }
196}
197
198fn read_piped_stdin() -> Option<String> {
205 if std::env::var("RPI_SKIP_STDIN").map(|v| v == "1").unwrap_or(false) {
208 return None;
209 }
210 if std::io::stdin().is_terminal() {
211 return None;
212 }
213 let mut buf = String::new();
214 match std::io::stdin().read_to_string(&mut buf) {
215 Ok(_) => {
216 let trimmed = buf.trim();
217 if trimmed.is_empty() {
218 None
219 } else {
220 Some(trimmed.to_string())
221 }
222 }
223 Err(_) => None,
224 }
225}
226
227fn process_file_args(
239 file_args: &[std::path::PathBuf],
240 cwd: &Path,
241) -> Result<(String, Vec<ImageContent>), String> {
242 let mut text = String::new();
243 for rel in file_args {
244 let abs = if rel.is_absolute() {
245 rel.clone()
246 } else {
247 cwd.join(rel)
248 };
249 if !abs.exists() {
250 return Err(format!("file not found: {}", abs.display()));
251 }
252 if is_likely_image(&abs) {
254 return Err(format!(
255 "image attachments are not supported in v1: {}",
256 abs.display()
257 ));
258 }
259 match std::fs::read_to_string(&abs) {
260 Ok(content) => {
261 text.push_str(&format!(
262 "<file name=\"{}\">\n{}\n</file>\n",
263 abs.display(),
264 content
265 ));
266 }
267 Err(e) => {
268 return Err(format!(
269 "could not read file {}: {e}",
270 abs.display()
271 ));
272 }
273 }
274 }
275 Ok((text, Vec::new()))
276}
277
278fn is_likely_image(path: &Path) -> bool {
281 matches!(
282 path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(),
283 Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp")
284 )
285}
286
287fn build_initial_message(
293 parsed: &Args,
294 stdin: Option<&str>,
295 file_text: Option<&str>,
296) -> (Option<String>, Vec<String>) {
297 let mut extra = parsed.messages.clone();
298 let mut parts: Vec<String> = Vec::new();
299 if let Some(s) = stdin {
300 parts.push(s.to_string());
301 }
302 if let Some(t) = file_text {
303 parts.push(t.to_string());
304 }
305 if !extra.is_empty() {
307 parts.push(extra.remove(0));
308 }
309 let initial = if parts.is_empty() { None } else { Some(parts.join("")) };
310 (initial, extra)
311}
312
313fn print_resolve_error(e: &ResolveError) {
316 match e {
317 ResolveError::NoApiKey { hint } => {
318 eprintln!("error: {e}");
319 eprintln!();
320 eprintln!("Provide credentials via one of: {hint}.");
321 }
322 ResolveError::Config(_) => {
323 eprintln!("error: {e}");
324 eprintln!();
325 eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
326 }
327 _ => eprintln!("error: {e}"),
328 }
329}
330
331fn print_build_error(e: &BuildError) {
333 match e {
334 BuildError::SessionNotFound { .. } => {
335 eprintln!("error: {e}");
336 eprintln!();
337 eprintln!("List saved sessions with the /session command in interactive mode.");
338 }
339 _ => eprintln!("error: {e}"),
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use crate::args::Args;
347
348 #[test]
349 fn build_initial_combines_stdin_file_and_first_message() {
350 let mut args = Args::default();
351 args.messages = vec!["first".into(), "second".into(), "third".into()];
352 let (initial, extra) =
353 build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
354 assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
355 assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
356 }
357
358 #[test]
359 fn build_initial_with_no_messages_uses_stdin_and_file_only() {
360 let args = Args::default();
361 let (initial, extra) =
362 build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
363 assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
364 assert!(extra.is_empty());
365 }
366
367 #[test]
368 fn build_initial_none_when_all_empty() {
369 let args = Args::default();
370 let (initial, extra) = build_initial_message(&args, None, None);
371 assert!(initial.is_none());
372 assert!(extra.is_empty());
373 }
374
375 #[test]
376 fn build_initial_shifts_only_first_message() {
377 let mut args = Args::default();
378 args.messages = vec!["a".into(), "b".into()];
379 let (initial, extra) = build_initial_message(&args, None, None);
380 assert_eq!(initial.as_deref(), Some("a"));
381 assert_eq!(extra, vec!["b".to_string()]);
382 }
383
384 #[test]
385 fn is_likely_image_detects_extensions() {
386 assert!(is_likely_image(Path::new("foo.png")));
387 assert!(is_likely_image(Path::new("foo.JPG")));
388 assert!(!is_likely_image(Path::new("foo.rs")));
389 assert!(!is_likely_image(Path::new("foo")));
390 }
391}