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