1use std::io::{IsTerminal, Read};
29use std::path::Path;
30
31use rpi_ai::types::{ImageContent, ImageContentType};
32
33use crate::args::{parse_args, print_help, print_version, resolve_mode, Args, RunMode};
34use crate::provider::{resolve_for_cwd, ResolveError};
35use crate::session::{build, BuildError};
36
37pub const EXIT_USAGE: i32 = 2;
41pub const EXIT_RUNTIME: i32 = 1;
44
45pub async fn run() -> i32 {
51 let mut argv: Vec<String> = std::env::args().skip(1).collect();
54
55 if argv.first().map(String::as_str) == Some("__rpi_dev_cleanup") {
56 return crate::dev_extension::run_cleanup_helper(&argv[1..]);
57 }
58
59 crate::args::normalize_offline_mode(&argv);
63
64 let dev_command = argv.first().map(String::as_str);
67 let dev_options = if matches!(dev_command, Some("dev" | "dev-local")) {
68 match crate::dev_extension::parse_args(&argv[1..]) {
69 Ok(options) if options.help => {
70 crate::dev_extension::print_help();
71 return 0;
72 }
73 Ok(mut options) => {
74 if dev_command == Some("dev-local") {
75 options.local_only = true;
76 }
77 argv = options.passthrough.clone();
78 Some(options)
79 }
80 Err(error) => {
81 eprintln!("error: {error}");
82 crate::dev_extension::print_help();
83 return EXIT_USAGE;
84 }
85 }
86 } else {
87 None
88 };
89
90 if argv.first().map(|s| s.as_str()) == Some("auth") {
94 return crate::auth::run(&argv[1..]).await;
95 }
96 if argv.first().map(|s| s.as_str()) == Some("package") {
97 return crate::packages::run_cli(&argv[1..]);
98 }
99 if argv.first().map(|s| s.as_str()) == Some("update") {
100 return crate::packages::run_native_update(&argv[1..]);
101 }
102 if argv.first().map(|s| s.as_str()) == Some("pi-update") {
103 return crate::packages::run_pi_update(&argv[1..]);
104 }
105 if argv.first().map(|s| s.as_str()) == Some("self-update") {
106 return crate::updates::run_self_update(&argv[1..]);
107 }
108 if argv.first().map(|s| s.as_str()) == Some("install") {
109 return crate::install::run(&argv[1..]);
110 }
111 if argv.first().map(|s| s.as_str()) == Some("install-pi") {
112 return crate::install_pi::run(&argv[1..]);
113 }
114 if argv.first().map(|s| s.as_str()) == Some("uninstall") {
115 if argv.get(1).map(String::as_str) == Some("pi") {
116 return crate::install_pi::uninstall(&argv[2..]);
117 }
118 return crate::install::uninstall(&argv[1..]);
119 }
120 if argv.first().map(|s| s.as_str()) == Some("uninstall-pi") {
121 return crate::install_pi::uninstall(&argv[1..]);
122 }
123
124 let mut parsed = parse_args(&argv);
125
126 if parsed.help {
128 print_help();
129 return 0;
130 }
131 if parsed.version {
132 print_version();
133 return 0;
134 }
135
136 if !parsed.errors.is_empty() {
138 for err in &parsed.errors {
139 eprintln!("error: {err}");
140 }
141 eprintln!();
142 print_help();
143 return EXIT_USAGE;
144 }
145
146 let cwd = match std::env::current_dir() {
148 Ok(c) => c,
149 Err(e) => {
150 eprintln!("error: could not determine the current directory: {e}");
151 return EXIT_USAGE;
152 }
153 };
154
155 let _ = crate::config::migrate_legacy_layout();
159
160 if let Some(input) = parsed.export.as_deref() {
161 let output = parsed
162 .messages
163 .first()
164 .map(Path::new)
165 .map(Path::to_path_buf)
166 .unwrap_or_else(|| {
167 let stem = input
168 .file_stem()
169 .and_then(|value| value.to_str())
170 .unwrap_or("session");
171 Path::new(&format!("rpi-session-{stem}.html")).to_path_buf()
172 });
173 match crate::export::export_file(input, &output) {
174 Ok(()) => {
175 println!("Exported to: {}", output.display());
176 return 0;
177 }
178 Err(error) => {
179 eprintln!("error: {error}");
180 return EXIT_RUNTIME;
181 }
182 }
183 }
184
185 if let Some(search) = parsed.list_models.as_deref() {
190 return list_models(search).await;
191 }
192
193 let dev_extension = if let Some(options) = &dev_options {
196 let extension = match crate::dev_extension::DevExtension::detect(&cwd, options) {
197 Ok(extension) => extension,
198 Err(error) => {
199 eprintln!("error: {error}");
200 return EXIT_USAGE;
201 }
202 };
203 if let Err(error) = extension.rebuild() {
204 eprintln!("error: initial extension build failed: {error}");
205 return EXIT_RUNTIME;
206 }
207 if let Err(error) = extension.apply_to_args(&mut parsed) {
208 eprintln!("error: {error}");
209 return EXIT_RUNTIME;
210 }
211 Some(extension)
212 } else {
213 None
214 };
215
216 if parsed.resume {
220 if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
221 eprintln!("error: --resume requires an interactive terminal");
222 return EXIT_USAGE;
223 }
224 match crate::resume_picker::select(&cwd).await {
225 Ok(Some(id)) => {
226 parsed.resume = false;
227 parsed.session = Some(id);
228 }
229 Ok(None) => return 0,
230 Err(e) => {
231 eprintln!("error: {e}");
232 return EXIT_RUNTIME;
233 }
234 }
235 }
236
237 if parsed.verbose {
239 for warn in &parsed.ignored {
240 eprintln!("warning: {warn}");
241 }
242 }
243 if parsed.no_themes && parsed.theme.is_some() {
244 eprintln!("warning: --no-themes overrides --theme; using the built-in default theme");
245 }
246
247 let stdin_text = read_piped_stdin();
249
250 let (file_text, file_images) = match process_file_args(&parsed.file_args, &cwd) {
252 Ok(t) => t,
253 Err(msg) => {
254 eprintln!("error: {msg}");
255 return EXIT_USAGE;
256 }
257 };
258
259 let file_text_opt = if file_text.is_empty() {
261 None
262 } else {
263 Some(file_text.as_str())
264 };
265 let (initial, extra) = build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
266
267 let project_trusted = crate::session::resolve_project_trust(&parsed, &cwd);
269 let resolved = match resolve_for_cwd(
270 parsed.provider.as_deref(),
271 parsed.model.as_deref(),
272 parsed.thinking,
273 parsed.api_key.as_deref(),
274 parsed.base_url.as_deref(),
275 &cwd,
276 project_trusted,
277 ) {
278 Ok(r) => r,
279 Err(e) => {
280 print_resolve_error(&e);
281 return match e {
282 ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
283 _ => EXIT_RUNTIME,
284 };
285 }
286 };
287
288 let model_catalog = crate::provider::available_catalog(&resolved);
291
292 if let Some(patterns) = &parsed.models {
297 let mut matched: Vec<String> = Vec::new();
298 for p in patterns {
299 let hits: Vec<String> = model_catalog
300 .iter()
301 .filter(|m| m.id.eq_ignore_ascii_case(p))
302 .map(|m| m.id.clone())
303 .collect();
304 if hits.is_empty() {
305 eprintln!("warning: --models pattern \"{p}\" matched no model");
306 }
307 matched.extend(hits);
308 }
309 let mut settings = crate::settings::load_settings().unwrap_or_default();
310 settings.scoped_models = if matched.is_empty() {
311 None
312 } else {
313 Some(matched)
314 };
315 if let Err(e) = crate::settings::save_settings(&settings) {
316 eprintln!("warning: could not save --models scope: {e}");
317 }
318 }
319
320 let (harness, event_rx, mut reload_context) =
322 match build(&resolved, &parsed, &cwd, project_trusted).await {
323 Ok(triple) => triple,
324 Err(e) => {
325 print_build_error(&e);
326 return EXIT_RUNTIME;
327 }
328 };
329 reload_context.dev_extension = dev_extension;
330
331 let stdin_is_tty = std::io::stdin().is_terminal();
333 let stdout_is_tty = std::io::stdout().is_terminal();
334 let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
335
336 let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
338 RunMode::Print
339 } else {
340 mode
341 };
342
343 let mode = if std::env::var("RPI_FORCE_TUI")
346 .map(|v| v == "1")
347 .unwrap_or(false)
348 {
349 RunMode::Interactive
350 } else {
351 mode
352 };
353
354 let dev_cleanup = reload_context.dev_extension.clone();
355 let dev_watcher = if matches!(mode, RunMode::Interactive) {
356 reload_context
357 .dev_extension
358 .as_ref()
359 .and_then(|extension| extension.start_watcher(reload_context.mailbox.clone()))
360 } else {
361 None
362 };
363
364 let exit_code = match mode {
365 RunMode::Print => {
366 crate::modes::print(
367 &harness,
368 &parsed,
369 initial.clone(),
370 &extra,
371 file_images.clone(),
372 )
373 .await
374 }
375 RunMode::Json => {
376 crate::modes::json(
377 &harness,
378 &parsed,
379 initial.clone(),
380 &extra,
381 file_images.clone(),
382 Some(event_rx),
383 )
384 .await
385 }
386 RunMode::Interactive => {
387 crate::modes::interactive(
388 &harness,
389 Some(event_rx),
390 &parsed,
391 model_catalog,
392 initial.clone(),
393 &extra,
394 file_images.clone(),
395 if parsed.no_themes {
396 None
397 } else {
398 parsed.theme.as_deref().or(resolved.theme.as_deref())
399 },
400 parsed.no_themes,
401 &reload_context,
402 )
403 .await
404 }
405 RunMode::Rpc => {
406 eprintln!("error: rpc mode is not implemented in v1 (use --mode text or --mode json)");
410 EXIT_USAGE
411 }
412 };
413
414 if let Some(dev) = &dev_cleanup {
415 dev.stop_watcher();
416 }
417 if let Some(watcher) = dev_watcher {
418 let _ = watcher.join();
419 }
420 drop(reload_context);
421 drop(harness);
422 if let Some(dev) = dev_cleanup {
423 dev.cleanup();
424 }
425 exit_code
426}
427
428async fn list_models(search: &str) -> i32 {
431 let catalog = match crate::provider::catalog_all() {
432 Ok(models) => models,
433 Err(error) => {
434 eprintln!("warning: could not load models.json: {error}");
435 Vec::new()
436 }
437 };
438 let needle = search.trim().to_ascii_lowercase();
439 let mut models: Vec<_> = catalog
440 .into_iter()
441 .filter(|model| {
442 needle.is_empty()
443 || format!("{} {} {}", model.provider, model.id, model.name)
444 .to_ascii_lowercase()
445 .contains(&needle)
446 })
447 .collect();
448 if models.is_empty() {
449 if needle.is_empty() {
450 println!("No models available");
451 } else {
452 println!("No models matching \"{search}\"");
453 }
454 return 0;
455 }
456
457 fn format_tokens(value: u64) -> String {
458 if value >= 1_000_000 {
459 let whole = value % 1_000_000 == 0;
460 if whole {
461 format!("{}M", value / 1_000_000)
462 } else {
463 format!("{:.1}M", value as f64 / 1_000_000.0)
464 }
465 } else if value >= 1_000 {
466 let whole = value % 1_000 == 0;
467 if whole {
468 format!("{}K", value / 1_000)
469 } else {
470 format!("{:.1}K", value as f64 / 1_000.0)
471 }
472 } else {
473 value.to_string()
474 }
475 }
476
477 let rows: Vec<_> = models
478 .drain(..)
479 .map(|model| {
480 let images = model
481 .input
482 .iter()
483 .any(|input| matches!(input, rpi_ai::InputModality::Image));
484 (
485 model.provider,
486 model.id,
487 format_tokens(model.context_window),
488 format_tokens(model.max_tokens),
489 if model.reasoning { "yes" } else { "no" }.to_string(),
490 if images { "yes" } else { "no" }.to_string(),
491 )
492 })
493 .collect();
494 let widths = (
495 rows.iter().map(|r| r.0.len()).max().unwrap_or(8).max(8),
496 rows.iter().map(|r| r.1.len()).max().unwrap_or(5).max(5),
497 rows.iter().map(|r| r.2.len()).max().unwrap_or(7).max(7),
498 rows.iter().map(|r| r.3.len()).max().unwrap_or(7).max(7),
499 rows.iter().map(|r| r.4.len()).max().unwrap_or(8).max(8),
500 rows.iter().map(|r| r.5.len()).max().unwrap_or(6).max(6),
501 );
502 println!(
503 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
504 "provider",
505 "model",
506 "context",
507 "max-out",
508 "thinking",
509 "images",
510 provider = widths.0,
511 model = widths.1,
512 context = widths.2,
513 max_out = widths.3,
514 thinking = widths.4,
515 images = widths.5,
516 );
517 for row in rows {
518 println!(
519 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
520 row.0,
521 row.1,
522 row.2,
523 row.3,
524 row.4,
525 row.5,
526 provider = widths.0,
527 model = widths.1,
528 context = widths.2,
529 max_out = widths.3,
530 thinking = widths.4,
531 images = widths.5,
532 );
533 }
534 0
535}
536
537fn read_piped_stdin() -> Option<String> {
544 if std::env::var("RPI_SKIP_STDIN")
547 .map(|v| v == "1")
548 .unwrap_or(false)
549 {
550 return None;
551 }
552 if std::io::stdin().is_terminal() {
553 return None;
554 }
555 let mut buf = String::new();
556 match std::io::stdin().read_to_string(&mut buf) {
557 Ok(_) => {
558 let trimmed = buf.trim();
559 if trimmed.is_empty() {
560 None
561 } else {
562 Some(trimmed.to_string())
563 }
564 }
565 Err(_) => None,
566 }
567}
568
569fn process_file_args(
575 file_args: &[std::path::PathBuf],
576 cwd: &Path,
577) -> Result<(String, Vec<ImageContent>), String> {
578 let mut text = String::new();
579 let mut images = Vec::new();
580 for rel in file_args {
581 let abs = if rel.is_absolute() {
582 rel.clone()
583 } else {
584 cwd.join(rel)
585 };
586 if !abs.exists() {
587 return Err(format!("file not found: {}", abs.display()));
588 }
589 let bytes = std::fs::read(&abs)
590 .map_err(|e| format!("could not read file {}: {e}", abs.display()))?;
591 if let Some(image) = image_content_from_bytes(&bytes) {
592 images.push(image);
593 } else {
594 let content = String::from_utf8(bytes).map_err(|_| {
595 format!(
596 "file is not valid UTF-8 text or a supported image: {}",
597 abs.display()
598 )
599 })?;
600 text.push_str(&format!(
601 "<file name=\"{}\">\n{}\n</file>\n",
602 abs.display(),
603 content
604 ));
605 }
606 }
607 Ok((text, images))
608}
609
610pub(crate) fn image_content_from_path(path: &Path) -> Result<Option<ImageContent>, String> {
611 let bytes =
612 std::fs::read(path).map_err(|e| format!("could not read file {}: {e}", path.display()))?;
613 Ok(image_content_from_bytes(&bytes))
614}
615
616fn image_content_from_bytes(bytes: &[u8]) -> Option<ImageContent> {
617 let mime_type = rpi_tools::detect_supported_image_mime_type(bytes)?;
618 Some(ImageContent {
619 kind: ImageContentType,
620 data: rpi_tools::encode_base64(bytes),
621 mime_type: mime_type.to_string(),
622 })
623}
624
625fn build_initial_message(
631 parsed: &Args,
632 stdin: Option<&str>,
633 file_text: Option<&str>,
634) -> (Option<String>, Vec<String>) {
635 let mut extra = parsed.messages.clone();
636 let mut parts: Vec<String> = Vec::new();
637 if let Some(s) = stdin {
638 parts.push(s.to_string());
639 }
640 if let Some(t) = file_text {
641 parts.push(t.to_string());
642 }
643 if !extra.is_empty() {
645 parts.push(extra.remove(0));
646 }
647 let initial = if parts.is_empty() {
648 None
649 } else {
650 Some(parts.join(""))
651 };
652 (initial, extra)
653}
654
655fn print_resolve_error(e: &ResolveError) {
658 match e {
659 ResolveError::NoApiKey { hint } => {
660 eprintln!("error: {e}");
661 eprintln!();
662 eprintln!("Provide credentials via one of: {hint}.");
663 }
664 ResolveError::Config(_) => {
665 eprintln!("error: {e}");
666 eprintln!();
667 eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
668 }
669 _ => eprintln!("error: {e}"),
670 }
671}
672
673fn print_build_error(e: &BuildError) {
675 match e {
676 BuildError::SessionNotFound { .. } => {
677 eprintln!("error: {e}");
678 eprintln!();
679 eprintln!("List saved sessions with the /session command in interactive mode.");
680 }
681 _ => eprintln!("error: {e}"),
682 }
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use crate::args::Args;
689
690 #[test]
691 fn build_initial_combines_stdin_file_and_first_message() {
692 let mut args = Args::default();
693 args.messages = vec!["first".into(), "second".into(), "third".into()];
694 let (initial, extra) =
695 build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
696 assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
697 assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
698 }
699
700 #[test]
701 fn build_initial_with_no_messages_uses_stdin_and_file_only() {
702 let args = Args::default();
703 let (initial, extra) =
704 build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
705 assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
706 assert!(extra.is_empty());
707 }
708
709 #[test]
710 fn build_initial_none_when_all_empty() {
711 let args = Args::default();
712 let (initial, extra) = build_initial_message(&args, None, None);
713 assert!(initial.is_none());
714 assert!(extra.is_empty());
715 }
716
717 #[test]
718 fn build_initial_shifts_only_first_message() {
719 let mut args = Args::default();
720 args.messages = vec!["a".into(), "b".into()];
721 let (initial, extra) = build_initial_message(&args, None, None);
722 assert_eq!(initial.as_deref(), Some("a"));
723 assert_eq!(extra, vec!["b".to_string()]);
724 }
725
726 #[test]
727 fn process_file_args_attaches_supported_images() {
728 let dir = tempfile::tempdir().unwrap();
729 let path = dir.path().join("image.bin");
730 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
731 png.extend_from_slice(b"IHDR");
732 png.extend_from_slice(&[0; 13]);
733 std::fs::write(&path, png).unwrap();
734 let (text, images) = process_file_args(&[path], dir.path()).unwrap();
735 assert!(text.is_empty());
736 assert_eq!(images.len(), 1);
737 assert_eq!(images[0].mime_type, "image/png");
738 assert!(!images[0].data.is_empty());
739 }
740
741 #[test]
742 fn image_content_from_path_reports_supported_mime() {
743 let dir = tempfile::tempdir().unwrap();
744 let path = dir.path().join("drop.png");
745 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
746 png.extend_from_slice(b"IHDR");
747 png.extend_from_slice(&[0; 13]);
748 std::fs::write(&path, png).unwrap();
749 let image = image_content_from_path(&path).unwrap().unwrap();
750 assert_eq!(image.mime_type, "image/png");
751 }
752}