1use std::io::{IsTerminal, Read, Write};
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 let mut package_args = Vec::with_capacity(argv.len());
103 package_args.push("update".to_string());
104 package_args.extend_from_slice(&argv[1..]);
105 return crate::packages::run_cli(&package_args);
106 }
107 if argv.first().map(|s| s.as_str()) == Some("pi-update") {
108 return crate::updates::run_self_update(&argv[1..]);
109 }
110 if argv.first().map(|s| s.as_str()) == Some("install") {
111 return crate::install::run(&argv[1..]);
112 }
113 if argv.first().map(|s| s.as_str()) == Some("install-pi") {
114 return crate::install_pi::run(&argv[1..]);
115 }
116 if argv.first().map(|s| s.as_str()) == Some("uninstall") {
117 if argv.get(1).map(String::as_str) == Some("pi") {
118 return crate::install_pi::uninstall(&argv[2..]);
119 }
120 return crate::install::uninstall(&argv[1..]);
121 }
122 if argv.first().map(|s| s.as_str()) == Some("uninstall-pi") {
123 return crate::install_pi::uninstall(&argv[1..]);
124 }
125
126 let mut parsed = parse_args(&argv);
127
128 if parsed.help {
130 print_help();
131 return 0;
132 }
133 if parsed.version {
134 print_version();
135 return 0;
136 }
137
138 if !parsed.errors.is_empty() {
140 for err in &parsed.errors {
141 eprintln!("error: {err}");
142 }
143 eprintln!();
144 print_help();
145 return EXIT_USAGE;
146 }
147
148 let cwd = match std::env::current_dir() {
150 Ok(c) => c,
151 Err(e) => {
152 eprintln!("error: could not determine the current directory: {e}");
153 return EXIT_USAGE;
154 }
155 };
156
157 let _ = crate::config::migrate_legacy_layout();
161
162 if let Some(input) = parsed.export.as_deref() {
163 let output = parsed
164 .messages
165 .first()
166 .map(Path::new)
167 .map(Path::to_path_buf)
168 .unwrap_or_else(|| {
169 let stem = input
170 .file_stem()
171 .and_then(|value| value.to_str())
172 .unwrap_or("session");
173 Path::new(&format!("rpi-session-{stem}.html")).to_path_buf()
174 });
175 match crate::export::export_file(input, &output) {
176 Ok(()) => {
177 println!("Exported to: {}", output.display());
178 return 0;
179 }
180 Err(error) => {
181 eprintln!("error: {error}");
182 return EXIT_RUNTIME;
183 }
184 }
185 }
186
187 if let Some(search) = parsed.list_models.as_deref() {
192 return list_models(search).await;
193 }
194
195 let dev_extension = if let Some(options) = &dev_options {
198 let extension = match crate::dev_extension::DevExtension::detect(&cwd, options) {
199 Ok(extension) => extension,
200 Err(error) => {
201 eprintln!("error: {error}");
202 return EXIT_USAGE;
203 }
204 };
205 if let Err(error) = extension.rebuild() {
206 eprintln!("error: initial extension build failed: {error}");
207 return EXIT_RUNTIME;
208 }
209 if let Err(error) = extension.apply_to_args(&mut parsed) {
210 eprintln!("error: {error}");
211 return EXIT_RUNTIME;
212 }
213 Some(extension)
214 } else {
215 None
216 };
217
218 if parsed.trust_override.is_none()
223 && std::io::stdin().is_terminal()
224 && std::io::stdout().is_terminal()
225 && crate::session::project_has_local_resources(&cwd)
226 {
227 match prompt_project_trust(&cwd) {
228 Some(decision) => parsed.trust_override = Some(decision),
229 None => {
230 eprintln!(
231 "warning: project trust prompt unavailable; local resources remain disabled"
232 );
233 }
234 }
235 }
236
237 if parsed.resume {
241 if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
242 eprintln!("error: --resume requires an interactive terminal");
243 return EXIT_USAGE;
244 }
245 match crate::resume_picker::select(&cwd).await {
246 Ok(Some(id)) => {
247 parsed.resume = false;
248 parsed.session = Some(id);
249 }
250 Ok(None) => return 0,
251 Err(e) => {
252 eprintln!("error: {e}");
253 return EXIT_RUNTIME;
254 }
255 }
256 }
257
258 if parsed.verbose {
260 for warn in &parsed.ignored {
261 eprintln!("warning: {warn}");
262 }
263 }
264 if parsed.no_themes && parsed.theme.is_some() {
265 eprintln!("warning: --no-themes overrides --theme; using the built-in default theme");
266 }
267
268 let stdin_text = read_piped_stdin();
270
271 let (file_text, file_images) = match process_file_args(&parsed.file_args, &cwd) {
273 Ok(t) => t,
274 Err(msg) => {
275 eprintln!("error: {msg}");
276 return EXIT_USAGE;
277 }
278 };
279
280 let file_text_opt = if file_text.is_empty() {
282 None
283 } else {
284 Some(file_text.as_str())
285 };
286 let (initial, extra) = build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
287
288 let project_trusted = crate::session::resolve_project_trust(&parsed, &cwd);
290 let resolved = match resolve_for_cwd(
291 parsed.provider.as_deref(),
292 parsed.model.as_deref(),
293 parsed.thinking,
294 parsed.api_key.as_deref(),
295 parsed.base_url.as_deref(),
296 &cwd,
297 project_trusted,
298 ) {
299 Ok(r) => r,
300 Err(e) => {
301 print_resolve_error(&e);
302 return match e {
303 ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
304 _ => EXIT_RUNTIME,
305 };
306 }
307 };
308
309 let model_catalog = crate::provider::available_catalog(&resolved);
312
313 if let Some(patterns) = &parsed.models {
318 let mut matched: Vec<String> = Vec::new();
319 for p in patterns {
320 let hits: Vec<String> = model_catalog
321 .iter()
322 .filter(|m| m.id.eq_ignore_ascii_case(p))
323 .map(|m| m.id.clone())
324 .collect();
325 if hits.is_empty() {
326 eprintln!("warning: --models pattern \"{p}\" matched no model");
327 }
328 matched.extend(hits);
329 }
330 let mut settings = crate::settings::load_settings().unwrap_or_default();
331 settings.scoped_models = if matched.is_empty() {
332 None
333 } else {
334 Some(matched)
335 };
336 if let Err(e) = crate::settings::save_settings(&settings) {
337 eprintln!("warning: could not save --models scope: {e}");
338 }
339 }
340
341 let (harness, event_rx, mut reload_context) =
343 match build(&resolved, &parsed, &cwd, project_trusted).await {
344 Ok(triple) => triple,
345 Err(e) => {
346 print_build_error(&e);
347 return EXIT_RUNTIME;
348 }
349 };
350 reload_context.dev_extension = dev_extension;
351
352 let stdin_is_tty = std::io::stdin().is_terminal();
354 let stdout_is_tty = std::io::stdout().is_terminal();
355 let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
356
357 let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
359 RunMode::Print
360 } else {
361 mode
362 };
363
364 let mode = if std::env::var("RPI_FORCE_TUI")
367 .map(|v| v == "1")
368 .unwrap_or(false)
369 {
370 RunMode::Interactive
371 } else {
372 mode
373 };
374
375 let dev_cleanup = reload_context.dev_extension.clone();
376 let dev_watcher = if matches!(mode, RunMode::Interactive) {
377 reload_context
378 .dev_extension
379 .as_ref()
380 .and_then(|extension| extension.start_watcher(reload_context.mailbox.clone()))
381 } else {
382 None
383 };
384
385 let exit_code = match mode {
386 RunMode::Print => {
387 crate::modes::print(
388 &harness,
389 &parsed,
390 initial.clone(),
391 &extra,
392 file_images.clone(),
393 )
394 .await
395 }
396 RunMode::Json => {
397 crate::modes::json(
398 &harness,
399 &parsed,
400 initial.clone(),
401 &extra,
402 file_images.clone(),
403 Some(event_rx),
404 )
405 .await
406 }
407 RunMode::Interactive => {
408 crate::modes::interactive(
409 &harness,
410 Some(event_rx),
411 &parsed,
412 model_catalog,
413 initial.clone(),
414 &extra,
415 file_images.clone(),
416 if parsed.no_themes {
417 None
418 } else {
419 parsed.theme.as_deref().or(resolved.theme.as_deref())
420 },
421 parsed.no_themes,
422 &reload_context,
423 )
424 .await
425 }
426 RunMode::Rpc => {
427 eprintln!("error: rpc mode is not implemented in v1 (use --mode text or --mode json)");
431 EXIT_USAGE
432 }
433 };
434
435 if let Some(dev) = &dev_cleanup {
436 dev.stop_watcher();
437 }
438 if let Some(watcher) = dev_watcher {
439 let _ = watcher.join();
440 }
441 drop(reload_context);
442 drop(harness);
443 if let Some(dev) = dev_cleanup {
444 dev.cleanup();
445 }
446 exit_code
447}
448
449fn prompt_project_trust(cwd: &Path) -> Option<bool> {
450 let display = cwd.display();
451 print!("Trust project {display} and load local resources? [y/N] ");
452 let _ = std::io::stdout().flush();
453 let mut answer = String::new();
454 if std::io::stdin().read_line(&mut answer).is_err() {
455 return None;
456 }
457 let normalized = answer.trim().to_ascii_lowercase();
458 let trusted = matches!(normalized.as_str(), "y" | "yes");
459 if let Err(error) = crate::config::set_project_trust(cwd, Some(trusted)) {
460 eprintln!("warning: could not persist project trust decision: {error}");
461 }
462 Some(trusted)
463}
464
465async fn list_models(search: &str) -> i32 {
468 let catalog = match crate::provider::catalog_all() {
469 Ok(models) => models,
470 Err(error) => {
471 eprintln!("warning: could not load models.json: {error}");
472 Vec::new()
473 }
474 };
475 let needle = search.trim().to_ascii_lowercase();
476 let mut models: Vec<_> = catalog
477 .into_iter()
478 .filter(|model| {
479 needle.is_empty()
480 || format!("{} {} {}", model.provider, model.id, model.name)
481 .to_ascii_lowercase()
482 .contains(&needle)
483 })
484 .collect();
485 if models.is_empty() {
486 if needle.is_empty() {
487 println!("No models available");
488 } else {
489 println!("No models matching \"{search}\"");
490 }
491 return 0;
492 }
493
494 fn format_tokens(value: u64) -> String {
495 if value >= 1_000_000 {
496 let whole = value % 1_000_000 == 0;
497 if whole {
498 format!("{}M", value / 1_000_000)
499 } else {
500 format!("{:.1}M", value as f64 / 1_000_000.0)
501 }
502 } else if value >= 1_000 {
503 let whole = value % 1_000 == 0;
504 if whole {
505 format!("{}K", value / 1_000)
506 } else {
507 format!("{:.1}K", value as f64 / 1_000.0)
508 }
509 } else {
510 value.to_string()
511 }
512 }
513
514 let rows: Vec<_> = models
515 .drain(..)
516 .map(|model| {
517 let images = model
518 .input
519 .iter()
520 .any(|input| matches!(input, rpi_ai::InputModality::Image));
521 (
522 model.provider,
523 model.id,
524 format_tokens(model.context_window),
525 format_tokens(model.max_tokens),
526 if model.reasoning { "yes" } else { "no" }.to_string(),
527 if images { "yes" } else { "no" }.to_string(),
528 )
529 })
530 .collect();
531 let widths = (
532 rows.iter().map(|r| r.0.len()).max().unwrap_or(8).max(8),
533 rows.iter().map(|r| r.1.len()).max().unwrap_or(5).max(5),
534 rows.iter().map(|r| r.2.len()).max().unwrap_or(7).max(7),
535 rows.iter().map(|r| r.3.len()).max().unwrap_or(7).max(7),
536 rows.iter().map(|r| r.4.len()).max().unwrap_or(8).max(8),
537 rows.iter().map(|r| r.5.len()).max().unwrap_or(6).max(6),
538 );
539 println!(
540 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
541 "provider",
542 "model",
543 "context",
544 "max-out",
545 "thinking",
546 "images",
547 provider = widths.0,
548 model = widths.1,
549 context = widths.2,
550 max_out = widths.3,
551 thinking = widths.4,
552 images = widths.5,
553 );
554 for row in rows {
555 println!(
556 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
557 row.0,
558 row.1,
559 row.2,
560 row.3,
561 row.4,
562 row.5,
563 provider = widths.0,
564 model = widths.1,
565 context = widths.2,
566 max_out = widths.3,
567 thinking = widths.4,
568 images = widths.5,
569 );
570 }
571 0
572}
573
574fn read_piped_stdin() -> Option<String> {
581 if std::env::var("RPI_SKIP_STDIN")
584 .map(|v| v == "1")
585 .unwrap_or(false)
586 {
587 return None;
588 }
589 if std::io::stdin().is_terminal() {
590 return None;
591 }
592 let mut buf = String::new();
593 match std::io::stdin().read_to_string(&mut buf) {
594 Ok(_) => {
595 let trimmed = buf.trim();
596 if trimmed.is_empty() {
597 None
598 } else {
599 Some(trimmed.to_string())
600 }
601 }
602 Err(_) => None,
603 }
604}
605
606fn process_file_args(
612 file_args: &[std::path::PathBuf],
613 cwd: &Path,
614) -> Result<(String, Vec<ImageContent>), String> {
615 let mut text = String::new();
616 let mut images = Vec::new();
617 for rel in file_args {
618 let abs = if rel.is_absolute() {
619 rel.clone()
620 } else {
621 cwd.join(rel)
622 };
623 if !abs.exists() {
624 return Err(format!("file not found: {}", abs.display()));
625 }
626 let bytes = std::fs::read(&abs)
627 .map_err(|e| format!("could not read file {}: {e}", abs.display()))?;
628 if let Some(image) = image_content_from_bytes(&bytes) {
629 images.push(image);
630 } else {
631 let content = String::from_utf8(bytes).map_err(|_| {
632 format!(
633 "file is not valid UTF-8 text or a supported image: {}",
634 abs.display()
635 )
636 })?;
637 text.push_str(&format!(
638 "<file name=\"{}\">\n{}\n</file>\n",
639 abs.display(),
640 content
641 ));
642 }
643 }
644 Ok((text, images))
645}
646
647pub(crate) fn image_content_from_path(path: &Path) -> Result<Option<ImageContent>, String> {
648 let bytes =
649 std::fs::read(path).map_err(|e| format!("could not read file {}: {e}", path.display()))?;
650 Ok(image_content_from_bytes(&bytes))
651}
652
653fn image_content_from_bytes(bytes: &[u8]) -> Option<ImageContent> {
654 let mime_type = rpi_tools::detect_supported_image_mime_type(bytes)?;
655 Some(ImageContent {
656 kind: ImageContentType,
657 data: rpi_tools::encode_base64(bytes),
658 mime_type: mime_type.to_string(),
659 })
660}
661
662fn build_initial_message(
668 parsed: &Args,
669 stdin: Option<&str>,
670 file_text: Option<&str>,
671) -> (Option<String>, Vec<String>) {
672 let mut extra = parsed.messages.clone();
673 let mut parts: Vec<String> = Vec::new();
674 if let Some(s) = stdin {
675 parts.push(s.to_string());
676 }
677 if let Some(t) = file_text {
678 parts.push(t.to_string());
679 }
680 if !extra.is_empty() {
682 parts.push(extra.remove(0));
683 }
684 let initial = if parts.is_empty() {
685 None
686 } else {
687 Some(parts.join(""))
688 };
689 (initial, extra)
690}
691
692fn print_resolve_error(e: &ResolveError) {
695 match e {
696 ResolveError::NoApiKey { hint } => {
697 eprintln!("error: {e}");
698 eprintln!();
699 eprintln!("Provide credentials via one of: {hint}.");
700 }
701 ResolveError::Config(_) => {
702 eprintln!("error: {e}");
703 eprintln!();
704 eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
705 }
706 _ => eprintln!("error: {e}"),
707 }
708}
709
710fn print_build_error(e: &BuildError) {
712 match e {
713 BuildError::SessionNotFound { .. } => {
714 eprintln!("error: {e}");
715 eprintln!();
716 eprintln!("List saved sessions with the /session command in interactive mode.");
717 }
718 _ => eprintln!("error: {e}"),
719 }
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725 use crate::args::Args;
726
727 #[test]
728 fn build_initial_combines_stdin_file_and_first_message() {
729 let mut args = Args::default();
730 args.messages = vec!["first".into(), "second".into(), "third".into()];
731 let (initial, extra) =
732 build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
733 assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
734 assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
735 }
736
737 #[test]
738 fn build_initial_with_no_messages_uses_stdin_and_file_only() {
739 let args = Args::default();
740 let (initial, extra) =
741 build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
742 assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
743 assert!(extra.is_empty());
744 }
745
746 #[test]
747 fn build_initial_none_when_all_empty() {
748 let args = Args::default();
749 let (initial, extra) = build_initial_message(&args, None, None);
750 assert!(initial.is_none());
751 assert!(extra.is_empty());
752 }
753
754 #[test]
755 fn build_initial_shifts_only_first_message() {
756 let mut args = Args::default();
757 args.messages = vec!["a".into(), "b".into()];
758 let (initial, extra) = build_initial_message(&args, None, None);
759 assert_eq!(initial.as_deref(), Some("a"));
760 assert_eq!(extra, vec!["b".to_string()]);
761 }
762
763 #[test]
764 fn process_file_args_attaches_supported_images() {
765 let dir = tempfile::tempdir().unwrap();
766 let path = dir.path().join("image.bin");
767 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
768 png.extend_from_slice(b"IHDR");
769 png.extend_from_slice(&[0; 13]);
770 std::fs::write(&path, png).unwrap();
771 let (text, images) = process_file_args(&[path], dir.path()).unwrap();
772 assert!(text.is_empty());
773 assert_eq!(images.len(), 1);
774 assert_eq!(images[0].mime_type, "image/png");
775 assert!(!images[0].data.is_empty());
776 }
777
778 #[test]
779 fn image_content_from_path_reports_supported_mime() {
780 let dir = tempfile::tempdir().unwrap();
781 let path = dir.path().join("drop.png");
782 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
783 png.extend_from_slice(b"IHDR");
784 png.extend_from_slice(&[0; 13]);
785 std::fs::write(&path, png).unwrap();
786 let image = image_content_from_path(&path).unwrap().unwrap();
787 assert_eq!(image.mime_type, "image/png");
788 }
789}