1use std::io::{IsTerminal, Read};
19use std::path::Path;
20
21use rpi_ai::types::{ImageContent, ImageContentType};
22
23use crate::args::{parse_args, print_help, print_version, resolve_mode, Args, RunMode};
24use crate::provider::{resolve_for_cwd, ResolveError};
25use crate::session::{build, BuildError};
26
27pub const EXIT_USAGE: i32 = 2;
31pub const EXIT_RUNTIME: i32 = 1;
34pub const EXIT_VETOED: i32 = 3;
39
40fn take_flag_value(argv: &[String], flag: &str) -> Option<String> {
44 let prefix = format!("{flag}=");
45 let mut iter = argv.iter();
46 while let Some(arg) = iter.next() {
47 if let Some(value) = arg.strip_prefix(&prefix) {
48 return Some(value.to_string());
49 }
50 if arg == flag {
51 return iter.next().cloned();
52 }
53 }
54 None
55}
56
57fn resolve_connect_token(argv: &[String]) -> Option<String> {
60 take_flag_value(argv, "--token").or_else(|| {
61 std::env::var("RPI_SERVER_TOKEN")
62 .ok()
63 .map(|value| value.trim().to_string())
64 .filter(|value| !value.is_empty())
65 })
66}
67
68pub async fn run() -> i32 {
74 crate::timings::reset(crate::timings::TimingNamespace::Main);
77 let code = run_inner().await;
78 crate::timings::print_timings();
79 code
80}
81
82async fn run_inner() -> i32 {
83 let mut argv: Vec<String> = std::env::args().skip(1).collect();
86
87 if argv.first().map(String::as_str) == Some("__rpi_dev_cleanup") {
88 return crate::dev_extension::run_cleanup_helper(&argv[1..]);
89 }
90
91 if let Some(addr) = take_flag_value(&argv, "--connect") {
96 let token = resolve_connect_token(&argv);
97 return crate::remote::tui::run(&addr, token.as_deref()).await;
98 }
99
100 crate::args::normalize_offline_mode(&argv);
103
104 let dev_command = argv.first().map(String::as_str);
107 let dev_options = if matches!(dev_command, Some("dev" | "dev-local")) {
108 match crate::dev_extension::parse_args(&argv[1..]) {
109 Ok(options) if options.help => {
110 crate::dev_extension::print_help();
111 return 0;
112 }
113 Ok(mut options) => {
114 if dev_command == Some("dev-local") {
115 options.local_only = true;
116 }
117 argv = options.passthrough.clone();
118 Some(options)
119 }
120 Err(error) => {
121 eprintln!("error: {error}");
122 crate::dev_extension::print_help();
123 return EXIT_USAGE;
124 }
125 }
126 } else {
127 None
128 };
129
130 match argv.first().map(String::as_str) {
139 Some("auth") => return crate::auth::run(&argv[1..]).await,
140 Some("events") => return crate::events::run(&argv[1..]).await,
141 Some("update") => return crate::updates::run_self_update(&argv[1..]),
142 Some("install") => return crate::install::run(&argv[1..]),
143 Some("uninstall") => return crate::install::uninstall(&argv[1..]),
144 _ => {}
145 }
146
147 let mut parsed = parse_args(&argv);
148 crate::timings::time("args parsed", crate::timings::TimingNamespace::Main);
149
150 if let Some(code) = handle_parse_short_circuits(&parsed) {
152 return code;
153 }
154
155 let cwd = match resolve_cwd() {
156 Ok(cwd) => cwd,
157 Err(code) => return code,
158 };
159
160 let _ = crate::config::migrate_legacy_layout();
164 crate::timings::time("config migrated", crate::timings::TimingNamespace::Main);
165
166 if let Some(code) = export_if_requested(&parsed) {
167 return code;
168 }
169
170 if let Some(search) = parsed.list_models.as_deref() {
175 return list_models(search).await;
176 }
177
178 let dev_extension = match prepare_dev_extension(dev_options.as_ref(), &cwd, &mut parsed) {
181 Ok(extension) => extension,
182 Err(code) => return code,
183 };
184
185 if let Some(code) = resolve_resume(&mut parsed, &cwd).await {
189 return code;
190 }
191
192 emit_startup_warnings(&parsed);
193
194 let wants_rpc_channel =
200 parsed.mode == crate::args::Mode::Rpc || parsed.unknown_flags.contains_key("server");
201 let stdin_text = if wants_rpc_channel {
202 None
203 } else {
204 read_piped_stdin()
205 };
206
207 let (file_text, file_images) = match process_file_args(&parsed.file_args, &cwd) {
209 Ok(t) => t,
210 Err(msg) => {
211 eprintln!("error: {msg}");
212 return EXIT_USAGE;
213 }
214 };
215
216 let file_text_opt = if file_text.is_empty() {
218 None
219 } else {
220 Some(file_text.as_str())
221 };
222 let (initial, extra) = build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
223
224 let project_trusted = crate::session::resolve_project_trust(&parsed, &cwd);
226 let resolved = match resolve_for_cwd(
227 parsed.provider.as_deref(),
228 parsed.model.as_deref(),
229 parsed.thinking,
230 parsed.api_key.as_deref(),
231 parsed.base_url.as_deref(),
232 &cwd,
233 project_trusted,
234 ) {
235 Ok(r) => r,
236 Err(e) => {
237 print_resolve_error(&e);
238 return match e {
239 ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
240 _ => EXIT_RUNTIME,
241 };
242 }
243 };
244
245 let model_catalog = crate::provider::available_catalog(&resolved);
248
249 if let Some(patterns) = &parsed.models {
254 let mut matched: Vec<String> = Vec::new();
255 for p in patterns {
256 let hits: Vec<String> = model_catalog
257 .iter()
258 .filter(|m| m.id.eq_ignore_ascii_case(p))
259 .map(|m| m.id.clone())
260 .collect();
261 if hits.is_empty() {
262 eprintln!("warning: --models pattern \"{p}\" matched no model");
263 }
264 matched.extend(hits);
265 }
266 let mut settings = crate::settings::load_settings().unwrap_or_default();
267 settings.scoped_models = if matched.is_empty() {
268 None
269 } else {
270 Some(matched)
271 };
272 if let Err(e) = crate::settings::save_settings(&settings) {
273 eprintln!("warning: could not save --models scope: {e}");
274 }
275 }
276
277 let (harness, event_rx, mut reload_context) =
279 match build(&resolved, &parsed, &cwd, project_trusted).await {
280 Ok(triple) => triple,
281 Err(e) => {
282 print_build_error(&e);
283 return EXIT_RUNTIME;
284 }
285 };
286 reload_context.dev_extension = dev_extension;
287
288 let stdin_is_tty = std::io::stdin().is_terminal();
290 let stdout_is_tty = std::io::stdout().is_terminal();
291 let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
292
293 let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
295 RunMode::Print
296 } else {
297 mode
298 };
299
300 let mode = if std::env::var("RPI_FORCE_TUI")
303 .map(|v| v == "1")
304 .unwrap_or(false)
305 {
306 RunMode::Interactive
307 } else {
308 mode
309 };
310
311 let dev_cleanup = reload_context.dev_extension.clone();
312 let dev_watcher = if matches!(mode, RunMode::Interactive) {
313 reload_context
314 .dev_extension
315 .as_ref()
316 .and_then(|extension| extension.start_watcher(reload_context.mailbox.clone()))
317 } else {
318 None
319 };
320
321 let exit_code = match mode {
322 RunMode::Print => {
323 crate::modes::print(
324 &harness,
325 &parsed,
326 initial.clone(),
327 &extra,
328 file_images.clone(),
329 )
330 .await
331 }
332 RunMode::Json => {
333 crate::modes::json(
334 &harness,
335 &parsed,
336 initial.clone(),
337 &extra,
338 file_images.clone(),
339 Some(event_rx),
340 )
341 .await
342 }
343 RunMode::Interactive => {
344 crate::modes::interactive(
345 &harness,
346 Some(event_rx),
347 &parsed,
348 model_catalog,
349 initial.clone(),
350 &extra,
351 file_images.clone(),
352 if parsed.no_themes {
353 None
354 } else {
355 parsed.theme.as_deref().or(resolved.theme.as_deref())
356 },
357 parsed.no_themes,
358 &reload_context,
359 )
360 .await
361 }
362 RunMode::Rpc => {
363 let veto = crate::session::dispatch_session_event_async(
369 &reload_context,
370 rpi_plugin_sdk::EventTag::BeforeTuiStart,
371 )
372 .await;
373 if let Some(reason) = veto {
374 eprintln!("[rpi] startup vetoed by extension: {reason}");
375 EXIT_VETOED
376 } else {
377 if let Some(reason) = crate::session::dispatch_session_event_async(
378 &reload_context,
379 rpi_plugin_sdk::EventTag::SessionStart,
380 )
381 .await
382 {
383 tracing::warn!(
384 "[rpi] extension vetoed SessionStart (headless, advisory): {reason}"
385 );
386 }
387
388 let code = if parsed.unknown_flags.contains_key("server") {
389 eprintln!("[rpi] running in headless server mode (no TUI); Ctrl+C to stop");
393 let _ = tokio::signal::ctrl_c().await;
394 0
395 } else {
396 crate::modes::rpc(&harness, Some(event_rx), model_catalog).await
400 };
401
402 if let Some(reason) = crate::session::dispatch_session_event_async(
403 &reload_context,
404 rpi_plugin_sdk::EventTag::SessionShutdown,
405 )
406 .await
407 {
408 tracing::warn!(
409 "[rpi] extension vetoed SessionShutdown (ignored, closing): {reason}"
410 );
411 }
412 code
413 }
414 }
415 };
416
417 if let Some(dev) = &dev_cleanup {
418 dev.stop_watcher();
419 }
420 if let Some(watcher) = dev_watcher {
421 let _ = watcher.join();
422 }
423 drop(reload_context);
424 drop(harness);
425 if let Some(dev) = dev_cleanup {
426 dev.cleanup();
427 }
428 exit_code
429}
430
431fn handle_parse_short_circuits(parsed: &Args) -> Option<i32> {
432 if parsed.help {
434 print_help();
435 return Some(0);
436 }
437 if parsed.version {
438 print_version();
439 return Some(0);
440 }
441
442 if !parsed.errors.is_empty() {
444 for err in &parsed.errors {
445 eprintln!("error: {err}");
446 }
447 eprintln!();
448 print_help();
449 return Some(EXIT_USAGE);
450 }
451
452 None
453}
454
455fn resolve_cwd() -> Result<std::path::PathBuf, i32> {
456 std::env::current_dir().map_err(|error| {
457 eprintln!("error: could not determine the current directory: {error}");
458 EXIT_USAGE
459 })
460}
461
462fn export_if_requested(parsed: &Args) -> Option<i32> {
463 let input = parsed.export.as_deref()?;
464 let output = parsed
465 .messages
466 .first()
467 .map(Path::new)
468 .map(Path::to_path_buf)
469 .unwrap_or_else(|| {
470 let stem = input
471 .file_stem()
472 .and_then(|value| value.to_str())
473 .unwrap_or("session");
474 Path::new(&format!("rpi-session-{stem}.html")).to_path_buf()
475 });
476
477 match crate::export::export_file(input, &output) {
478 Ok(()) => {
479 println!("Exported to: {}", output.display());
480 Some(0)
481 }
482 Err(error) => {
483 eprintln!("error: {error}");
484 Some(EXIT_RUNTIME)
485 }
486 }
487}
488
489fn prepare_dev_extension(
490 options: Option<&crate::dev_extension::DevOptions>,
491 cwd: &Path,
492 parsed: &mut Args,
493) -> Result<Option<std::sync::Arc<crate::dev_extension::DevExtension>>, i32> {
494 let Some(options) = options else {
495 return Ok(None);
496 };
497
498 match crate::dev_extension::DevExtension::detect(cwd, options) {
499 Ok(extension) => {
500 if let Err(error) = extension.rebuild() {
501 eprintln!("error: initial extension build failed: {error}");
502 return Err(EXIT_RUNTIME);
503 }
504 if let Err(error) = extension.apply_to_args(parsed) {
505 eprintln!("error: {error}");
506 return Err(EXIT_RUNTIME);
507 }
508 Ok(Some(extension))
509 }
510 Err(error) => {
511 eprintln!("dev: {error}");
513 eprintln!("dev: no Cargo cdylib found; running in skills-only mode");
514 parsed.no_extensions = true;
515 parsed.extensions_dir.clear();
516 parsed.extension.clear();
517 if options.local_only {
518 parsed.dev_local_only = true;
519 }
520 Ok(None)
521 }
522 }
523}
524
525async fn resolve_resume(parsed: &mut Args, cwd: &Path) -> Option<i32> {
526 if !parsed.resume {
527 return None;
528 }
529 if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
530 eprintln!("error: --resume requires an interactive terminal");
531 return Some(EXIT_USAGE);
532 }
533
534 match crate::resume_picker::select(cwd).await {
535 Ok(Some(id)) => {
536 parsed.resume = false;
537 parsed.session = Some(id);
538 None
539 }
540 Ok(None) => Some(0),
541 Err(error) => {
542 eprintln!("error: {error}");
543 Some(EXIT_RUNTIME)
544 }
545 }
546}
547
548fn emit_startup_warnings(parsed: &Args) {
549 if parsed.verbose {
551 for warn in &parsed.ignored {
552 eprintln!("warning: {warn}");
553 }
554 }
555 if parsed.no_themes && parsed.theme.is_some() {
556 eprintln!("warning: --no-themes overrides --theme; using the built-in default theme");
557 }
558}
559
560async fn list_models(search: &str) -> i32 {
563 let catalog = match crate::provider::catalog_all() {
564 Ok(models) => models,
565 Err(error) => {
566 eprintln!("warning: could not load models.json: {error}");
567 Vec::new()
568 }
569 };
570 let needle = search.trim().to_ascii_lowercase();
571 let mut models: Vec<_> = catalog
572 .into_iter()
573 .filter(|model| {
574 needle.is_empty()
575 || format!("{} {} {}", model.provider, model.id, model.name)
576 .to_ascii_lowercase()
577 .contains(&needle)
578 })
579 .collect();
580 if models.is_empty() {
581 if needle.is_empty() {
582 println!("{}", crate::auth_guidance::no_models_available_message());
583 } else {
584 println!("No models matching \"{search}\"");
585 }
586 return 0;
587 }
588
589 fn format_tokens(value: u64) -> String {
590 if value >= 1_000_000 {
591 let whole = value % 1_000_000 == 0;
592 if whole {
593 format!("{}M", value / 1_000_000)
594 } else {
595 format!("{:.1}M", value as f64 / 1_000_000.0)
596 }
597 } else if value >= 1_000 {
598 let whole = value % 1_000 == 0;
599 if whole {
600 format!("{}K", value / 1_000)
601 } else {
602 format!("{:.1}K", value as f64 / 1_000.0)
603 }
604 } else {
605 value.to_string()
606 }
607 }
608
609 let rows: Vec<_> = models
610 .drain(..)
611 .map(|model| {
612 let images = model
613 .input
614 .iter()
615 .any(|input| matches!(input, rpi_ai::InputModality::Image));
616 (
617 model.provider,
618 model.id,
619 format_tokens(model.context_window),
620 format_tokens(model.max_tokens),
621 if model.reasoning { "yes" } else { "no" }.to_string(),
622 if images { "yes" } else { "no" }.to_string(),
623 )
624 })
625 .collect();
626 let widths = (
627 rows.iter().map(|r| r.0.len()).max().unwrap_or(8).max(8),
628 rows.iter().map(|r| r.1.len()).max().unwrap_or(5).max(5),
629 rows.iter().map(|r| r.2.len()).max().unwrap_or(7).max(7),
630 rows.iter().map(|r| r.3.len()).max().unwrap_or(7).max(7),
631 rows.iter().map(|r| r.4.len()).max().unwrap_or(8).max(8),
632 rows.iter().map(|r| r.5.len()).max().unwrap_or(6).max(6),
633 );
634 println!(
635 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
636 "provider",
637 "model",
638 "context",
639 "max-out",
640 "thinking",
641 "images",
642 provider = widths.0,
643 model = widths.1,
644 context = widths.2,
645 max_out = widths.3,
646 thinking = widths.4,
647 images = widths.5,
648 );
649 for row in rows {
650 println!(
651 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
652 row.0,
653 row.1,
654 row.2,
655 row.3,
656 row.4,
657 row.5,
658 provider = widths.0,
659 model = widths.1,
660 context = widths.2,
661 max_out = widths.3,
662 thinking = widths.4,
663 images = widths.5,
664 );
665 }
666 0
667}
668
669fn read_piped_stdin() -> Option<String> {
676 if std::env::var("RPI_SKIP_STDIN")
679 .map(|v| v == "1")
680 .unwrap_or(false)
681 {
682 return None;
683 }
684 if std::io::stdin().is_terminal() {
685 return None;
686 }
687 let mut buf = String::new();
688 match std::io::stdin().read_to_string(&mut buf) {
689 Ok(_) => {
690 let trimmed = buf.trim();
691 if trimmed.is_empty() {
692 None
693 } else {
694 Some(trimmed.to_string())
695 }
696 }
697 Err(_) => None,
698 }
699}
700
701fn process_file_args(
707 file_args: &[std::path::PathBuf],
708 cwd: &Path,
709) -> Result<(String, Vec<ImageContent>), String> {
710 let mut text = String::new();
711 let mut images = Vec::new();
712 for rel in file_args {
713 let abs = if rel.is_absolute() {
714 rel.clone()
715 } else {
716 cwd.join(rel)
717 };
718 if !abs.exists() {
719 return Err(format!("file not found: {}", abs.display()));
720 }
721 let bytes = std::fs::read(&abs)
722 .map_err(|e| format!("could not read file {}: {e}", abs.display()))?;
723 if let Some(image) = image_content_from_bytes(&bytes) {
724 images.push(image);
725 } else {
726 let content = String::from_utf8(bytes).map_err(|_| {
727 format!(
728 "file is not valid UTF-8 text or a supported image: {}",
729 abs.display()
730 )
731 })?;
732 text.push_str(&format!(
733 "<file name=\"{}\">\n{}\n</file>\n",
734 abs.display(),
735 content
736 ));
737 }
738 }
739 Ok((text, images))
740}
741
742pub(crate) fn image_content_from_path(path: &Path) -> Result<Option<ImageContent>, String> {
743 let bytes =
744 std::fs::read(path).map_err(|e| format!("could not read file {}: {e}", path.display()))?;
745 Ok(image_content_from_bytes(&bytes))
746}
747
748fn image_content_from_bytes(bytes: &[u8]) -> Option<ImageContent> {
749 let mime_type = rpi_tools::detect_supported_image_mime_type(bytes)?;
750 Some(ImageContent {
751 kind: ImageContentType,
752 data: rpi_tools::encode_base64(bytes),
753 mime_type: mime_type.to_string(),
754 })
755}
756
757fn build_initial_message(
763 parsed: &Args,
764 stdin: Option<&str>,
765 file_text: Option<&str>,
766) -> (Option<String>, Vec<String>) {
767 let mut extra = parsed.messages.clone();
768 let mut parts: Vec<String> = Vec::new();
769 if let Some(s) = stdin {
770 parts.push(s.to_string());
771 }
772 if let Some(t) = file_text {
773 parts.push(t.to_string());
774 }
775 if !extra.is_empty() {
777 parts.push(extra.remove(0));
778 }
779 let initial = if parts.is_empty() {
780 None
781 } else {
782 Some(parts.join(""))
783 };
784 (initial, extra)
785}
786
787fn print_resolve_error(e: &ResolveError) {
790 match e {
791 ResolveError::NoApiKey { hint } => {
792 eprintln!("error: {e}");
793 eprintln!();
794 eprintln!("Provide credentials via one of: {hint}.");
795 }
796 ResolveError::Config(_) => {
797 eprintln!("error: {e}");
798 eprintln!();
799 eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
800 }
801 _ => eprintln!("error: {e}"),
802 }
803}
804
805fn print_build_error(e: &BuildError) {
807 match e {
808 BuildError::SessionNotFound { .. } => {
809 eprintln!("error: {e}");
810 eprintln!();
811 eprintln!("List saved sessions with the /session command in interactive mode.");
812 }
813 _ => eprintln!("error: {e}"),
814 }
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820 use crate::args::Args;
821
822 #[test]
823 fn take_flag_value_supports_both_spellings() {
824 let argv = vec![
825 "--connect".to_string(),
826 "127.0.0.1:9899".to_string(),
827 "--token".to_string(),
828 "abc".to_string(),
829 ];
830 assert_eq!(
831 take_flag_value(&argv, "--connect").as_deref(),
832 Some("127.0.0.1:9899")
833 );
834 assert_eq!(take_flag_value(&argv, "--token").as_deref(), Some("abc"));
835 assert_eq!(take_flag_value(&argv, "--missing"), None);
836
837 let inline = vec!["--connect=1.2.3.4:1".to_string()];
838 assert_eq!(
839 take_flag_value(&inline, "--connect").as_deref(),
840 Some("1.2.3.4:1")
841 );
842 }
843
844 #[test]
845 fn connect_token_prefers_the_flag_over_the_env_var() {
846 let argv = vec!["--token".to_string(), "flag-token".to_string()];
848 assert_eq!(resolve_connect_token(&argv).as_deref(), Some("flag-token"));
849 }
850
851 #[test]
852 fn build_initial_combines_stdin_file_and_first_message() {
853 let mut args = Args::default();
854 args.messages = vec!["first".into(), "second".into(), "third".into()];
855 let (initial, extra) =
856 build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
857 assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
858 assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
859 }
860
861 #[test]
862 fn build_initial_with_no_messages_uses_stdin_and_file_only() {
863 let args = Args::default();
864 let (initial, extra) =
865 build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
866 assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
867 assert!(extra.is_empty());
868 }
869
870 #[test]
871 fn build_initial_none_when_all_empty() {
872 let args = Args::default();
873 let (initial, extra) = build_initial_message(&args, None, None);
874 assert!(initial.is_none());
875 assert!(extra.is_empty());
876 }
877
878 #[test]
879 fn build_initial_shifts_only_first_message() {
880 let mut args = Args::default();
881 args.messages = vec!["a".into(), "b".into()];
882 let (initial, extra) = build_initial_message(&args, None, None);
883 assert_eq!(initial.as_deref(), Some("a"));
884 assert_eq!(extra, vec!["b".to_string()]);
885 }
886
887 #[test]
888 fn process_file_args_attaches_supported_images() {
889 let dir = tempfile::tempdir().unwrap();
890 let path = dir.path().join("image.bin");
891 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
892 png.extend_from_slice(b"IHDR");
893 png.extend_from_slice(&[0; 13]);
894 std::fs::write(&path, png).unwrap();
895 let (text, images) = process_file_args(&[path], dir.path()).unwrap();
896 assert!(text.is_empty());
897 assert_eq!(images.len(), 1);
898 assert_eq!(images[0].mime_type, "image/png");
899 assert!(!images[0].data.is_empty());
900 }
901
902 #[test]
903 fn image_content_from_path_reports_supported_mime() {
904 let dir = tempfile::tempdir().unwrap();
905 let path = dir.path().join("drop.png");
906 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
907 png.extend_from_slice(b"IHDR");
908 png.extend_from_slice(&[0; 13]);
909 std::fs::write(&path, png).unwrap();
910 let image = image_content_from_path(&path).unwrap().unwrap();
911 assert_eq!(image.mime_type, "image/png");
912 }
913}