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