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::updates::run_self_update(&argv[1..]);
101 }
102 if argv.first().map(String::as_str) == Some("pi-package") {
103 let subcommand = argv.get(1).map(String::as_str);
104 if matches!(subcommand, Some("--help" | "-h")) {
105 return crate::packages::run_pi_package_update(&argv[1..]);
106 }
107 if subcommand != Some("update") {
108 eprintln!("error: usage is `rpi pi-package update`");
109 return EXIT_USAGE;
110 }
111 return crate::packages::run_pi_package_update(&argv[1..]);
112 }
113 if argv.first().map(String::as_str) == Some("pi-update") {
114 eprintln!("error: unknown command `pi-update`; use `rpi pi-package update`");
115 return EXIT_USAGE;
116 }
117 if argv.first().map(String::as_str) == Some("self-update") {
118 eprintln!("error: unknown command `self-update`; use `rpi update`");
119 return EXIT_USAGE;
120 }
121 if argv.first().map(|s| s.as_str()) == Some("install") {
122 return crate::install::run(&argv[1..]);
123 }
124 if argv.first().map(|s| s.as_str()) == Some("install-pi") {
125 return crate::install_pi::run(&argv[1..]);
126 }
127 if argv.first().map(|s| s.as_str()) == Some("uninstall") {
128 if argv.get(1).map(String::as_str) == Some("pi") {
129 return crate::install_pi::uninstall(&argv[2..]);
130 }
131 return crate::install::uninstall(&argv[1..]);
132 }
133 if argv.first().map(|s| s.as_str()) == Some("uninstall-pi") {
134 return crate::install_pi::uninstall(&argv[1..]);
135 }
136
137 let mut parsed = parse_args(&argv);
138
139 if parsed.help {
141 print_help();
142 return 0;
143 }
144 if parsed.version {
145 print_version();
146 return 0;
147 }
148
149 if !parsed.errors.is_empty() {
151 for err in &parsed.errors {
152 eprintln!("error: {err}");
153 }
154 eprintln!();
155 print_help();
156 return EXIT_USAGE;
157 }
158
159 let cwd = match std::env::current_dir() {
161 Ok(c) => c,
162 Err(e) => {
163 eprintln!("error: could not determine the current directory: {e}");
164 return EXIT_USAGE;
165 }
166 };
167
168 let _ = crate::config::migrate_legacy_layout();
172
173 if let Some(input) = parsed.export.as_deref() {
174 let output = parsed
175 .messages
176 .first()
177 .map(Path::new)
178 .map(Path::to_path_buf)
179 .unwrap_or_else(|| {
180 let stem = input
181 .file_stem()
182 .and_then(|value| value.to_str())
183 .unwrap_or("session");
184 Path::new(&format!("rpi-session-{stem}.html")).to_path_buf()
185 });
186 match crate::export::export_file(input, &output) {
187 Ok(()) => {
188 println!("Exported to: {}", output.display());
189 return 0;
190 }
191 Err(error) => {
192 eprintln!("error: {error}");
193 return EXIT_RUNTIME;
194 }
195 }
196 }
197
198 if let Some(search) = parsed.list_models.as_deref() {
203 return list_models(search).await;
204 }
205 let dev_extension = if let Some(options) = &dev_options {
211 match crate::dev_extension::DevExtension::detect(&cwd, options) {
212 Ok(extension) => {
213 if let Err(error) = extension.rebuild() {
214 eprintln!("error: initial extension build failed: {error}");
215 return EXIT_RUNTIME;
216 }
217 if let Err(error) = extension.apply_to_args(&mut parsed) {
218 eprintln!("error: {error}");
219 return EXIT_RUNTIME;
220 }
221 Some(extension)
222 }
223 Err(error) => {
224 eprintln!("dev: {error}");
226 eprintln!("dev: no Cargo cdylib found; running in skills-only mode");
227 parsed.no_extensions = true;
228 parsed.extensions_dir.clear();
229 parsed.extension.clear();
230 if options.local_only {
231 parsed.dev_local_only = true;
232 }
233 None
234 }
235 }
236 } else {
237 None
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 project_trusted = crate::session::resolve_project_trust(&parsed, &cwd);
293 let resolved = match resolve_for_cwd(
294 parsed.provider.as_deref(),
295 parsed.model.as_deref(),
296 parsed.thinking,
297 parsed.api_key.as_deref(),
298 parsed.base_url.as_deref(),
299 &cwd,
300 project_trusted,
301 ) {
302 Ok(r) => r,
303 Err(e) => {
304 print_resolve_error(&e);
305 return match e {
306 ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
307 _ => EXIT_RUNTIME,
308 };
309 }
310 };
311
312 let model_catalog = crate::provider::available_catalog(&resolved);
315
316 if let Some(patterns) = &parsed.models {
321 let mut matched: Vec<String> = Vec::new();
322 for p in patterns {
323 let hits: Vec<String> = model_catalog
324 .iter()
325 .filter(|m| m.id.eq_ignore_ascii_case(p))
326 .map(|m| m.id.clone())
327 .collect();
328 if hits.is_empty() {
329 eprintln!("warning: --models pattern \"{p}\" matched no model");
330 }
331 matched.extend(hits);
332 }
333 let mut settings = crate::settings::load_settings().unwrap_or_default();
334 settings.scoped_models = if matched.is_empty() {
335 None
336 } else {
337 Some(matched)
338 };
339 if let Err(e) = crate::settings::save_settings(&settings) {
340 eprintln!("warning: could not save --models scope: {e}");
341 }
342 }
343
344 let (harness, event_rx, mut reload_context) =
346 match build(&resolved, &parsed, &cwd, project_trusted).await {
347 Ok(triple) => triple,
348 Err(e) => {
349 print_build_error(&e);
350 return EXIT_RUNTIME;
351 }
352 };
353 reload_context.dev_extension = dev_extension;
354
355 let stdin_is_tty = std::io::stdin().is_terminal();
357 let stdout_is_tty = std::io::stdout().is_terminal();
358 let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
359
360 let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
362 RunMode::Print
363 } else {
364 mode
365 };
366
367 let mode = if std::env::var("RPI_FORCE_TUI")
370 .map(|v| v == "1")
371 .unwrap_or(false)
372 {
373 RunMode::Interactive
374 } else {
375 mode
376 };
377
378 let dev_cleanup = reload_context.dev_extension.clone();
379 let dev_watcher = if matches!(mode, RunMode::Interactive) {
380 reload_context
381 .dev_extension
382 .as_ref()
383 .and_then(|extension| extension.start_watcher(reload_context.mailbox.clone()))
384 } else {
385 None
386 };
387
388 let exit_code = match mode {
389 RunMode::Print => {
390 crate::modes::print(
391 &harness,
392 &parsed,
393 initial.clone(),
394 &extra,
395 file_images.clone(),
396 )
397 .await
398 }
399 RunMode::Json => {
400 crate::modes::json(
401 &harness,
402 &parsed,
403 initial.clone(),
404 &extra,
405 file_images.clone(),
406 Some(event_rx),
407 )
408 .await
409 }
410 RunMode::Interactive => {
411 crate::modes::interactive(
412 &harness,
413 Some(event_rx),
414 &parsed,
415 model_catalog,
416 initial.clone(),
417 &extra,
418 file_images.clone(),
419 if parsed.no_themes {
420 None
421 } else {
422 parsed.theme.as_deref().or(resolved.theme.as_deref())
423 },
424 parsed.no_themes,
425 &reload_context,
426 )
427 .await
428 }
429 RunMode::Rpc => {
430 eprintln!("[rpi] running in headless/rpc mode (no TUI)");
433 loop {
435 std::thread::sleep(std::time::Duration::from_secs(3600));
436 }
437 }
438 };
439
440 if let Some(dev) = &dev_cleanup {
441 dev.stop_watcher();
442 }
443 if let Some(watcher) = dev_watcher {
444 let _ = watcher.join();
445 }
446 drop(reload_context);
447 drop(harness);
448 if let Some(dev) = dev_cleanup {
449 dev.cleanup();
450 }
451 exit_code
452}
453
454async fn list_models(search: &str) -> i32 {
457 let catalog = match crate::provider::catalog_all() {
458 Ok(models) => models,
459 Err(error) => {
460 eprintln!("warning: could not load models.json: {error}");
461 Vec::new()
462 }
463 };
464 let needle = search.trim().to_ascii_lowercase();
465 let mut models: Vec<_> = catalog
466 .into_iter()
467 .filter(|model| {
468 needle.is_empty()
469 || format!("{} {} {}", model.provider, model.id, model.name)
470 .to_ascii_lowercase()
471 .contains(&needle)
472 })
473 .collect();
474 if models.is_empty() {
475 if needle.is_empty() {
476 println!("No models available");
477 } else {
478 println!("No models matching \"{search}\"");
479 }
480 return 0;
481 }
482
483 fn format_tokens(value: u64) -> String {
484 if value >= 1_000_000 {
485 let whole = value % 1_000_000 == 0;
486 if whole {
487 format!("{}M", value / 1_000_000)
488 } else {
489 format!("{:.1}M", value as f64 / 1_000_000.0)
490 }
491 } else if value >= 1_000 {
492 let whole = value % 1_000 == 0;
493 if whole {
494 format!("{}K", value / 1_000)
495 } else {
496 format!("{:.1}K", value as f64 / 1_000.0)
497 }
498 } else {
499 value.to_string()
500 }
501 }
502
503 let rows: Vec<_> = models
504 .drain(..)
505 .map(|model| {
506 let images = model
507 .input
508 .iter()
509 .any(|input| matches!(input, rpi_ai::InputModality::Image));
510 (
511 model.provider,
512 model.id,
513 format_tokens(model.context_window),
514 format_tokens(model.max_tokens),
515 if model.reasoning { "yes" } else { "no" }.to_string(),
516 if images { "yes" } else { "no" }.to_string(),
517 )
518 })
519 .collect();
520 let widths = (
521 rows.iter().map(|r| r.0.len()).max().unwrap_or(8).max(8),
522 rows.iter().map(|r| r.1.len()).max().unwrap_or(5).max(5),
523 rows.iter().map(|r| r.2.len()).max().unwrap_or(7).max(7),
524 rows.iter().map(|r| r.3.len()).max().unwrap_or(7).max(7),
525 rows.iter().map(|r| r.4.len()).max().unwrap_or(8).max(8),
526 rows.iter().map(|r| r.5.len()).max().unwrap_or(6).max(6),
527 );
528 println!(
529 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
530 "provider",
531 "model",
532 "context",
533 "max-out",
534 "thinking",
535 "images",
536 provider = widths.0,
537 model = widths.1,
538 context = widths.2,
539 max_out = widths.3,
540 thinking = widths.4,
541 images = widths.5,
542 );
543 for row in rows {
544 println!(
545 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
546 row.0,
547 row.1,
548 row.2,
549 row.3,
550 row.4,
551 row.5,
552 provider = widths.0,
553 model = widths.1,
554 context = widths.2,
555 max_out = widths.3,
556 thinking = widths.4,
557 images = widths.5,
558 );
559 }
560 0
561}
562
563fn read_piped_stdin() -> Option<String> {
570 if std::env::var("RPI_SKIP_STDIN")
573 .map(|v| v == "1")
574 .unwrap_or(false)
575 {
576 return None;
577 }
578 if std::io::stdin().is_terminal() {
579 return None;
580 }
581 let mut buf = String::new();
582 match std::io::stdin().read_to_string(&mut buf) {
583 Ok(_) => {
584 let trimmed = buf.trim();
585 if trimmed.is_empty() {
586 None
587 } else {
588 Some(trimmed.to_string())
589 }
590 }
591 Err(_) => None,
592 }
593}
594
595fn process_file_args(
601 file_args: &[std::path::PathBuf],
602 cwd: &Path,
603) -> Result<(String, Vec<ImageContent>), String> {
604 let mut text = String::new();
605 let mut images = Vec::new();
606 for rel in file_args {
607 let abs = if rel.is_absolute() {
608 rel.clone()
609 } else {
610 cwd.join(rel)
611 };
612 if !abs.exists() {
613 return Err(format!("file not found: {}", abs.display()));
614 }
615 let bytes = std::fs::read(&abs)
616 .map_err(|e| format!("could not read file {}: {e}", abs.display()))?;
617 if let Some(image) = image_content_from_bytes(&bytes) {
618 images.push(image);
619 } else {
620 let content = String::from_utf8(bytes).map_err(|_| {
621 format!(
622 "file is not valid UTF-8 text or a supported image: {}",
623 abs.display()
624 )
625 })?;
626 text.push_str(&format!(
627 "<file name=\"{}\">\n{}\n</file>\n",
628 abs.display(),
629 content
630 ));
631 }
632 }
633 Ok((text, images))
634}
635
636pub(crate) fn image_content_from_path(path: &Path) -> Result<Option<ImageContent>, String> {
637 let bytes =
638 std::fs::read(path).map_err(|e| format!("could not read file {}: {e}", path.display()))?;
639 Ok(image_content_from_bytes(&bytes))
640}
641
642fn image_content_from_bytes(bytes: &[u8]) -> Option<ImageContent> {
643 let mime_type = rpi_tools::detect_supported_image_mime_type(bytes)?;
644 Some(ImageContent {
645 kind: ImageContentType,
646 data: rpi_tools::encode_base64(bytes),
647 mime_type: mime_type.to_string(),
648 })
649}
650
651fn build_initial_message(
657 parsed: &Args,
658 stdin: Option<&str>,
659 file_text: Option<&str>,
660) -> (Option<String>, Vec<String>) {
661 let mut extra = parsed.messages.clone();
662 let mut parts: Vec<String> = Vec::new();
663 if let Some(s) = stdin {
664 parts.push(s.to_string());
665 }
666 if let Some(t) = file_text {
667 parts.push(t.to_string());
668 }
669 if !extra.is_empty() {
671 parts.push(extra.remove(0));
672 }
673 let initial = if parts.is_empty() {
674 None
675 } else {
676 Some(parts.join(""))
677 };
678 (initial, extra)
679}
680
681fn print_resolve_error(e: &ResolveError) {
684 match e {
685 ResolveError::NoApiKey { hint } => {
686 eprintln!("error: {e}");
687 eprintln!();
688 eprintln!("Provide credentials via one of: {hint}.");
689 }
690 ResolveError::Config(_) => {
691 eprintln!("error: {e}");
692 eprintln!();
693 eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
694 }
695 _ => eprintln!("error: {e}"),
696 }
697}
698
699fn print_build_error(e: &BuildError) {
701 match e {
702 BuildError::SessionNotFound { .. } => {
703 eprintln!("error: {e}");
704 eprintln!();
705 eprintln!("List saved sessions with the /session command in interactive mode.");
706 }
707 _ => eprintln!("error: {e}"),
708 }
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714 use crate::args::Args;
715
716 #[test]
717 fn build_initial_combines_stdin_file_and_first_message() {
718 let mut args = Args::default();
719 args.messages = vec!["first".into(), "second".into(), "third".into()];
720 let (initial, extra) =
721 build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
722 assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
723 assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
724 }
725
726 #[test]
727 fn build_initial_with_no_messages_uses_stdin_and_file_only() {
728 let args = Args::default();
729 let (initial, extra) =
730 build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
731 assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
732 assert!(extra.is_empty());
733 }
734
735 #[test]
736 fn build_initial_none_when_all_empty() {
737 let args = Args::default();
738 let (initial, extra) = build_initial_message(&args, None, None);
739 assert!(initial.is_none());
740 assert!(extra.is_empty());
741 }
742
743 #[test]
744 fn build_initial_shifts_only_first_message() {
745 let mut args = Args::default();
746 args.messages = vec!["a".into(), "b".into()];
747 let (initial, extra) = build_initial_message(&args, None, None);
748 assert_eq!(initial.as_deref(), Some("a"));
749 assert_eq!(extra, vec!["b".to_string()]);
750 }
751
752 #[test]
753 fn process_file_args_attaches_supported_images() {
754 let dir = tempfile::tempdir().unwrap();
755 let path = dir.path().join("image.bin");
756 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
757 png.extend_from_slice(b"IHDR");
758 png.extend_from_slice(&[0; 13]);
759 std::fs::write(&path, png).unwrap();
760 let (text, images) = process_file_args(&[path], dir.path()).unwrap();
761 assert!(text.is_empty());
762 assert_eq!(images.len(), 1);
763 assert_eq!(images[0].mime_type, "image/png");
764 assert!(!images[0].data.is_empty());
765 }
766
767 #[test]
768 fn image_content_from_path_reports_supported_mime() {
769 let dir = tempfile::tempdir().unwrap();
770 let path = dir.path().join("drop.png");
771 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
772 png.extend_from_slice(b"IHDR");
773 png.extend_from_slice(&[0; 13]);
774 std::fs::write(&path, png).unwrap();
775 let image = image_content_from_path(&path).unwrap().unwrap();
776 assert_eq!(image.mime_type, "image/png");
777 }
778}