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_with_extensions, ResolveError};
25use crate::session::{build_prepared, prepare_extensions, BuildError};
26use crate::startup_diagnostics::{Phase, StartupSpan};
27
28pub const EXIT_USAGE: i32 = 2;
32pub const EXIT_RUNTIME: i32 = 1;
35pub const EXIT_VETOED: i32 = 3;
40
41fn take_flag_value(argv: &[String], flag: &str) -> Option<String> {
45 let prefix = format!("{flag}=");
46 let mut iter = argv.iter();
47 while let Some(arg) = iter.next() {
48 if let Some(value) = arg.strip_prefix(&prefix) {
49 return Some(value.to_string());
50 }
51 if arg == flag {
52 return iter.next().cloned();
53 }
54 }
55 None
56}
57
58fn resolve_connect_token(argv: &[String]) -> Option<String> {
61 take_flag_value(argv, "--token").or_else(|| {
62 std::env::var("RPI_SERVER_TOKEN")
63 .ok()
64 .map(|value| value.trim().to_string())
65 .filter(|value| !value.is_empty())
66 })
67}
68
69pub async fn run() -> i32 {
75 crate::timings::reset(crate::timings::TimingNamespace::Main);
78 let code = run_inner().await;
79 crate::timings::print_timings();
80 code
81}
82
83async fn run_inner() -> i32 {
84 let startup = StartupSpan::start(Phase::StartupToHarness);
85 let mut argv: Vec<String> = std::env::args().skip(1).collect();
88 if let Err(error) = crate::native_store::early_check(&argv) {
89 eprintln!("error: {error}");
90 return EXIT_USAGE;
91 }
92
93 if argv
97 .iter()
98 .any(|a| a == "--resource-capabilities" || a.starts_with("--resource-capabilities="))
99 {
100 if argv.len() != 1 || argv[0] != "--resource-capabilities" {
101 println!(
102 "{}",
103 serde_json::json!({"version":1,"success":false,"error":"--resource-capabilities must be used alone"})
104 );
105 return EXIT_USAGE;
106 }
107 match serde_json::to_string(&crate::resource_selection::capabilities()) {
108 Ok(profile) if profile.len() < crate::resource_selection::MAX_CAPABILITIES_BYTES => {
109 println!("{profile}");
110 return 0;
111 }
112 _ => {
113 println!(
114 "{}",
115 serde_json::json!({"version":1,"success":false,"error":"capability profile exceeds bound"})
116 );
117 return EXIT_RUNTIME;
118 }
119 }
120 }
121
122 if argv
123 .iter()
124 .any(|arg| arg == "--resource-session-read" || arg.starts_with("--resource-session-read="))
125 {
126 return crate::saved_session_read::run(&argv).await;
127 }
128
129 if argv.first().map(String::as_str) == Some("__rpi_dev_cleanup") {
130 return crate::dev_extension::run_cleanup_helper(&argv[1..]);
131 }
132
133 if let Some(addr) = take_flag_value(&argv, "--connect") {
138 let token = resolve_connect_token(&argv);
139 return crate::remote::tui::run(&addr, token.as_deref()).await;
140 }
141
142 crate::args::normalize_offline_mode(&argv);
145
146 let dev_command = argv.first().map(String::as_str);
149 let dev_options = if matches!(dev_command, Some("dev" | "dev-local")) {
150 match crate::dev_extension::parse_args(&argv[1..]) {
151 Ok(options) if options.help => {
152 crate::dev_extension::print_help();
153 return 0;
154 }
155 Ok(mut options) => {
156 if dev_command == Some("dev-local") {
157 options.local_only = true;
158 }
159 argv = options.passthrough.clone();
160 Some(options)
161 }
162 Err(error) => {
163 eprintln!("error: {error}");
164 crate::dev_extension::print_help();
165 return EXIT_USAGE;
166 }
167 }
168 } else {
169 None
170 };
171
172 match argv.first().map(String::as_str) {
181 Some("auth") => {
182 let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
183 let args = crate::args::parse_args(&argv[1..]);
184 if !args.errors.is_empty() {
185 for error in &args.errors {
186 eprintln!("error: {error}");
187 }
188 return EXIT_USAGE;
189 }
190 let trusted = crate::session::resolve_project_trust(&args, &cwd);
191 let _resource_leases = match crate::resource_management::host_leases(&cwd) {
192 Ok(leases) => leases,
193 Err(error) => {
194 eprintln!("error: {error}");
195 return EXIT_RUNTIME;
196 }
197 };
198 let extension_session = crate::session::load_extensions(&args, &cwd, trusted, None);
199 return crate::auth::run_with_extensions(&argv[1..], extension_session.snapshot_arc())
200 .await;
201 }
202 Some("events") => return crate::events::run(&argv[1..]).await,
203 Some("update") => return crate::updates::run_self_update(&argv[1..]),
204 Some("install") => return crate::install::run_async(&argv[1..]).await,
205 Some("uninstall") => return crate::install::uninstall(&argv[1..]),
206 Some("extension") => return crate::packages::run_extension(&argv[1..]),
207 Some("resource") => return crate::resource_management::run(&argv[1..]),
208 Some("package") => return crate::packages::run(&argv[1..]),
209 Some(removed @ ("pi-package" | "pi-update" | "install-pi" | "uninstall-pi")) => {
212 eprintln!(
213 "error: `rpi {removed}` is not available; use `rpi package`, `rpi install`, or `rpi update`."
214 );
215 return EXIT_USAGE;
216 }
217 _ => {}
223 }
224
225 let mut parsed = parse_args(&argv);
226 crate::timings::time("args parsed", crate::timings::TimingNamespace::Main);
227
228 if let Some(code) = handle_parse_short_circuits(&parsed) {
230 return code;
231 }
232
233 if dev_options.is_some()
234 && (parsed.resource_config.is_some() || parsed.resource_transition.is_some())
235 {
236 eprintln!(
237 "error: --resource-config is restart-only and cannot be combined with dev/dev-local"
238 );
239 return EXIT_USAGE;
240 }
241 let cwd = match resolve_cwd() {
242 Ok(cwd) => cwd,
243 Err(code) => return code,
244 };
245 match crate::native_store::root(&parsed, &cwd) {
246 Ok(root) => parsed.experimental_native_store = root,
247 Err(error) => {
248 eprintln!("error: {error}");
249 return EXIT_USAGE;
250 }
251 }
252
253 let _ = crate::config::migrate_legacy_layout();
257 crate::timings::time("config migrated", crate::timings::TimingNamespace::Main);
258
259 if let Some(code) = export_if_requested(&parsed) {
260 return code;
261 }
262
263 if let Some(search) = parsed.list_models.as_deref() {
268 return list_models(search, &parsed, &cwd).await;
269 }
270
271 if let Some(code) = resolve_resume(&mut parsed, &cwd).await {
277 return code;
278 }
279
280 emit_startup_warnings(&parsed);
281
282 let wants_rpc_channel =
296 parsed.mode == crate::args::Mode::Rpc || parsed.unknown_flags.contains_key("server");
297
298 let (file_text, file_images) = match process_file_args(&parsed.file_args, &cwd) {
303 Ok(t) => t,
304 Err(msg) => {
305 eprintln!("error: {msg}");
306 return EXIT_USAGE;
307 }
308 };
309
310 let session_root = crate::session::configured_session_dir(&parsed, &cwd);
323 if parsed.experimental_native_store.is_none() {
324 parsed.session_dir = Some(session_root.clone());
325 }
326 let mut startup_notices: Vec<String> = Vec::new();
327 let selection = crate::session::select_session_in_dir(&parsed, session_root);
330 let cwd_lookup = StartupSpan::start(Phase::CwdLookup);
331 let restored_cwd = crate::session::peek_restored_session_cwd(&selection, &cwd).await;
332 cwd_lookup.finish();
333 if let Some(restored) = &restored_cwd {
339 crate::session::pin_restored_selection(&mut parsed, restored);
340 }
341 let launch_cwd = cwd;
342 let cwd = match &restored_cwd {
343 Some(restored) => match &restored.adopted {
344 Some(adopted) => {
345 startup_notices.push(crate::session_cwd::adopted_session_cwd_note(
348 &launch_cwd,
349 adopted,
350 ));
351 adopted.clone()
352 }
353 None => {
354 startup_notices.push(crate::session_cwd::missing_session_cwd_note(
355 &restored.recorded,
356 &launch_cwd,
357 ));
358 launch_cwd.clone()
359 }
360 },
361 None => launch_cwd.clone(),
362 };
363
364 let project_trusted = crate::session::resolve_project_trust(&parsed, &cwd);
368 if !project_trusted && cwd != launch_cwd {
369 startup_notices.push(crate::session_cwd::untrusted_session_cwd_note(&cwd));
370 }
371 let _dev_resource_leases = if dev_options.is_some() {
374 match crate::resource_management::host_leases(&cwd) {
375 Ok(leases) => Some(leases),
376 Err(error) => {
377 eprintln!("error: {error}");
378 return EXIT_RUNTIME;
379 }
380 }
381 } else {
382 None
383 };
384 if dev_options.is_some() {
386 if let Err(error) =
387 crate::session::preflight_extension_startup(&parsed, &cwd, project_trusted)
388 {
389 print_build_error(&error);
390 return EXIT_RUNTIME;
391 }
392 }
393 let dev_extension = match prepare_dev_extension(dev_options.as_ref(), &launch_cwd, &mut parsed)
394 {
395 Ok(extension) => extension,
396 Err(code) => return code,
397 };
398 let extension_prepare = StartupSpan::start(Phase::ExtensionPrepare);
399 let prepared = match prepare_extensions(&parsed, &cwd, project_trusted) {
400 Ok(prepared) => prepared,
401 Err(error) => {
402 print_build_error(&error);
403 return EXIT_RUNTIME;
404 }
405 };
406 extension_prepare.finish();
407 let model_resolve = StartupSpan::start(Phase::ModelResolve);
408 let resolved = match resolve_with_extensions(
409 &parsed,
410 &cwd,
411 project_trusted,
412 &prepared.extension_session,
413 ) {
414 Ok(r) => r,
415 Err(e) => {
416 print_resolve_error(&e);
417 return match e {
418 ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
419 _ => EXIT_RUNTIME,
420 };
421 }
422 };
423
424 model_resolve.finish();
425 for warning in &resolved.compat_warnings {
426 eprintln!("warning: models.json: {warning}");
427 }
428
429 let model_catalog = crate::provider::authenticated_catalog(&resolved);
433
434 if let Some(patterns) = &parsed.models {
439 let mut matched: Vec<String> = Vec::new();
440 for p in patterns {
441 let hits: Vec<String> = model_catalog
442 .iter()
443 .filter(|m| m.id.eq_ignore_ascii_case(p))
444 .map(|m| m.id.clone())
445 .collect();
446 if hits.is_empty() {
447 eprintln!("warning: --models pattern \"{p}\" matched no model");
448 }
449 matched.extend(hits);
450 }
451 let mut settings = crate::settings::load_settings().unwrap_or_default();
452 settings.scoped_models = if matched.is_empty() {
453 None
454 } else {
455 Some(matched)
456 };
457 if let Err(e) = crate::settings::save_settings(&settings) {
458 eprintln!("warning: could not save --models scope: {e}");
459 }
460 }
461
462 let harness_build = StartupSpan::start(Phase::HarnessBuild);
464 let (harness, event_rx, mut reload_context) =
465 match build_prepared(&resolved, &parsed, &cwd, project_trusted, prepared).await {
466 Ok(triple) => triple,
467 Err(e) => {
468 print_build_error(&e);
469 return EXIT_RUNTIME;
470 }
471 };
472 harness_build.finish();
473 startup.finish();
474 reload_context.dev_extension = dev_extension;
475
476 let stdio_claimed = reload_context
483 .extension_session
484 .lock()
485 .map(|session| session.owns_stdio())
486 .unwrap_or(false);
487 let web_requested = match parsed.unknown_flags.get("web") {
488 Some(serde_json::Value::Bool(value)) => *value,
489 Some(serde_json::Value::String(value)) => {
490 !value.is_empty() && !value.eq_ignore_ascii_case("false")
491 }
492 _ => false,
493 };
494 let web_registered = reload_context
495 .extension_session
496 .lock()
497 .ok()
498 .and_then(|session| {
499 session
500 .snapshot()
501 .map(|snapshot| snapshot.flags().iter().any(|flag| flag.name == "web"))
502 })
503 .unwrap_or(false);
504 if web_requested && (!web_registered || !stdio_claimed) {
505 eprintln!("error: --web requires the rpi-web server extension; it is missing, disabled, or failed to load.\nInstall it with: rpi install rpi-web --github pi-rust/rpi-package\nThen restart with: rpi --web");
506 return EXIT_USAGE;
507 }
508 let stdio_lifecycle = reload_context
509 .extension_session
510 .lock()
511 .ok()
512 .and_then(|session| session.action_bridge())
513 .map(|bridge| bridge.stdio_lifecycle());
514 if let Some(lifecycle) = &stdio_lifecycle {
515 if let Err(error) = lifecycle.validate() {
516 eprintln!("error: {error}");
517 return EXIT_RUNTIME;
518 }
519 }
520 let stdin_text = if wants_rpc_channel || stdio_claimed {
521 None
522 } else {
523 read_piped_stdin()
524 };
525
526 let file_text_opt = if file_text.is_empty() {
528 None
529 } else {
530 Some(file_text.as_str())
531 };
532 let (initial, extra) = build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
533
534 let stdin_is_tty = std::io::stdin().is_terminal();
536 let stdout_is_tty = std::io::stdout().is_terminal();
537 let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
538
539 let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
541 RunMode::Print
542 } else {
543 mode
544 };
545
546 let mode = if std::env::var("RPI_FORCE_TUI")
549 .map(|v| v == "1")
550 .unwrap_or(false)
551 {
552 RunMode::Interactive
553 } else {
554 mode
555 };
556
557 let mode = if stdio_claimed { RunMode::Rpc } else { mode };
564 if let Err(error) =
565 crate::native_store::validate_mode(parsed.experimental_native_store.is_some(), mode)
566 {
567 eprintln!("error: {error}");
568 return EXIT_USAGE;
569 }
570
571 if matches!(mode, RunMode::Interactive) {
576 reload_context.startup_notices = startup_notices;
577 } else {
578 for notice in &startup_notices {
579 eprintln!("warning: {notice}");
580 }
581 }
582
583 let dev_cleanup = reload_context.dev_extension.clone();
584 let dev_watcher = if matches!(mode, RunMode::Interactive) {
585 reload_context
586 .dev_extension
587 .as_ref()
588 .and_then(|extension| extension.start_watcher(reload_context.mailbox.clone()))
589 } else {
590 None
591 };
592
593 let exit_code = match mode {
594 RunMode::Print => {
595 crate::modes::print(
596 &harness,
597 &parsed,
598 initial.clone(),
599 &extra,
600 file_images.clone(),
601 )
602 .await
603 }
604 RunMode::Json => {
605 crate::modes::json(
606 &harness,
607 &parsed,
608 initial.clone(),
609 &extra,
610 file_images.clone(),
611 Some(event_rx),
612 )
613 .await
614 }
615 RunMode::Interactive => {
616 crate::modes::interactive(
617 &harness,
618 Some(event_rx),
619 &parsed,
620 model_catalog,
621 initial.clone(),
622 &extra,
623 file_images.clone(),
624 if parsed.no_themes {
625 None
626 } else {
627 parsed.theme.as_deref().or(resolved.theme.as_deref())
628 },
629 parsed.no_themes,
630 &reload_context,
631 )
632 .await
633 }
634 RunMode::Rpc => {
635 let before_start = StartupSpan::start(Phase::BeforeStartHooks);
641 let veto = crate::session::dispatch_session_event_async(
642 &reload_context,
643 rpi_plugin_sdk::EventTag::BeforeTuiStart,
644 )
645 .await;
646 before_start.finish();
647 if let Some(reason) = veto {
648 eprintln!("[rpi] startup vetoed by extension: {reason}");
649 EXIT_VETOED
650 } else {
651 let session_start = StartupSpan::start(Phase::SessionStartHooks);
652 if let Some(reason) = crate::session::dispatch_session_event_async(
653 &reload_context,
654 rpi_plugin_sdk::EventTag::SessionStart,
655 )
656 .await
657 {
658 tracing::warn!(
659 "[rpi] extension vetoed SessionStart (headless, advisory): {reason}"
660 );
661 }
662 session_start.finish();
663
664 let code = if parsed.unknown_flags.contains_key("server") || stdio_claimed {
665 eprintln!(
669 "[rpi] running in headless mode (no TUI); extension owns stdio; Ctrl+C to stop"
670 );
671 if stdio_claimed {
672 let lifecycle = stdio_lifecycle
673 .as_ref()
674 .expect("stdio owner requires an action bridge");
675 tokio::select! {
676 code = lifecycle.wait() => code,
677 _ = tokio::signal::ctrl_c() => 0,
678 }
679 } else {
680 let _ = tokio::signal::ctrl_c().await;
681 0
682 }
683 } else {
684 let oauth_snapshot = {
692 let session = reload_context.extension_session.lock().unwrap();
693 session.snapshot_arc()
694 };
695 crate::modes::rpc(
696 &harness,
697 Some(event_rx),
698 model_catalog,
699 reload_context.ui_dialog.clone(),
700 oauth_snapshot,
701 parsed.rpc_message_updates,
702 )
703 .await
704 };
705
706 if let Some(reason) = crate::session::dispatch_session_event_async(
707 &reload_context,
708 rpi_plugin_sdk::EventTag::SessionShutdown,
709 )
710 .await
711 {
712 tracing::warn!(
713 "[rpi] extension vetoed SessionShutdown (ignored, closing): {reason}"
714 );
715 }
716 code
717 }
718 }
719 };
720
721 if let Some(dev) = &dev_cleanup {
722 dev.stop_watcher();
723 }
724 if let Some(watcher) = dev_watcher {
725 let _ = watcher.join();
726 }
727 drop(reload_context);
728 drop(harness);
729 if let Some(dev) = dev_cleanup {
730 dev.cleanup();
731 }
732 exit_code
733}
734
735fn handle_parse_short_circuits(parsed: &Args) -> Option<i32> {
736 if parsed.help {
738 print_help();
739 return Some(0);
740 }
741 if parsed.version {
742 print_version();
743 return Some(0);
744 }
745
746 if !parsed.errors.is_empty() {
748 for err in &parsed.errors {
749 eprintln!("error: {err}");
750 }
751 eprintln!();
752 print_help();
753 return Some(EXIT_USAGE);
754 }
755
756 None
757}
758
759fn resolve_cwd() -> Result<std::path::PathBuf, i32> {
760 std::env::current_dir().map_err(|error| {
761 eprintln!("error: could not determine the current directory: {error}");
762 EXIT_USAGE
763 })
764}
765
766fn export_if_requested(parsed: &Args) -> Option<i32> {
767 let input = parsed.export.as_deref()?;
768 let output = parsed
769 .messages
770 .first()
771 .map(Path::new)
772 .map(Path::to_path_buf)
773 .unwrap_or_else(|| {
774 let stem = input
775 .file_stem()
776 .and_then(|value| value.to_str())
777 .unwrap_or("session");
778 Path::new(&format!("rpi-session-{stem}.html")).to_path_buf()
779 });
780
781 match crate::export::export_file(input, &output) {
782 Ok(()) => {
783 println!("Exported to: {}", output.display());
784 Some(0)
785 }
786 Err(error) => {
787 eprintln!("error: {error}");
788 Some(EXIT_RUNTIME)
789 }
790 }
791}
792
793fn prepare_dev_extension(
794 options: Option<&crate::dev_extension::DevOptions>,
795 cwd: &Path,
796 parsed: &mut Args,
797) -> Result<Option<std::sync::Arc<crate::dev_extension::DevExtension>>, i32> {
798 let Some(options) = options else {
799 return Ok(None);
800 };
801
802 match crate::dev_extension::DevExtension::detect(cwd, options) {
803 Ok(extension) => {
804 if let Err(error) = extension.rebuild() {
805 eprintln!("error: initial extension build failed: {error}");
806 return Err(EXIT_RUNTIME);
807 }
808 if let Err(error) = extension.apply_to_args(parsed) {
809 eprintln!("error: {error}");
810 return Err(EXIT_RUNTIME);
811 }
812 Ok(Some(extension))
813 }
814 Err(error) => {
815 eprintln!("dev: {error}");
817 eprintln!("dev: no Cargo cdylib found; running in skills-only mode");
818 parsed.no_extensions = true;
819 parsed.extensions_dir.clear();
820 parsed.extension.clear();
821 if options.local_only {
822 parsed.dev_local_only = true;
823 }
824 Ok(None)
825 }
826 }
827}
828
829async fn resolve_resume(parsed: &mut Args, cwd: &Path) -> Option<i32> {
830 if !parsed.resume {
831 return None;
832 }
833 if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
834 eprintln!("error: --resume requires an interactive terminal");
835 return Some(EXIT_USAGE);
836 }
837
838 let session_dir = crate::session::configured_session_dir(parsed, cwd);
839 match crate::resume_picker::select_in_dir(cwd, &session_dir).await {
840 Ok(Some(id)) => {
841 parsed.resume = false;
842 parsed.session = Some(id);
843 parsed.session_dir = Some(session_dir);
844 None
845 }
846 Ok(None) => Some(0),
847 Err(error) => {
848 eprintln!("error: {error}");
849 Some(EXIT_RUNTIME)
850 }
851 }
852}
853
854fn emit_startup_warnings(parsed: &Args) {
855 if parsed.verbose {
857 for warn in &parsed.ignored {
858 eprintln!("warning: {warn}");
859 }
860 }
861 if parsed.no_themes && parsed.theme.is_some() {
862 eprintln!("warning: --no-themes overrides --theme; using the built-in default theme");
863 }
864}
865
866async fn list_models(search: &str, args: &Args, cwd: &Path) -> i32 {
869 let trusted = crate::session::resolve_project_trust(args, cwd);
870 let prepared = match prepare_extensions(args, cwd, trusted) {
871 Ok(prepared) => prepared,
872 Err(error) => {
873 print_build_error(&error);
874 return EXIT_RUNTIME;
875 }
876 };
877 let manifests = crate::provider::manifests_from_session(&prepared.extension_session);
878 let catalog = match crate::provider::catalog_with_diagnostics(&manifests) {
879 Ok((models, warnings)) => {
880 for warning in warnings {
881 eprintln!("warning: models.json: {warning}");
882 }
883 models
884 }
885 Err(error) => {
886 eprintln!("warning: could not load models.json: {error}");
887 Vec::new()
888 }
889 };
890 let needle = search.trim().to_ascii_lowercase();
891 let mut models: Vec<_> = catalog
892 .into_iter()
893 .filter(|model| {
894 needle.is_empty()
895 || format!("{} {} {}", model.provider, model.id, model.name)
896 .to_ascii_lowercase()
897 .contains(&needle)
898 })
899 .collect();
900 if models.is_empty() {
901 if needle.is_empty() {
902 println!("{}", crate::auth_guidance::no_models_available_message());
903 } else {
904 println!("No models matching \"{search}\"");
905 }
906 return 0;
907 }
908
909 fn format_tokens(value: u64) -> String {
910 if value >= 1_000_000 {
911 let whole = value % 1_000_000 == 0;
912 if whole {
913 format!("{}M", value / 1_000_000)
914 } else {
915 format!("{:.1}M", value as f64 / 1_000_000.0)
916 }
917 } else if value >= 1_000 {
918 let whole = value % 1_000 == 0;
919 if whole {
920 format!("{}K", value / 1_000)
921 } else {
922 format!("{:.1}K", value as f64 / 1_000.0)
923 }
924 } else {
925 value.to_string()
926 }
927 }
928
929 let rows: Vec<_> = models
930 .drain(..)
931 .map(|model| {
932 let images = model
933 .input
934 .iter()
935 .any(|input| matches!(input, rpi_ai::InputModality::Image));
936 (
937 model.provider,
938 model.id,
939 format_tokens(model.context_window),
940 format_tokens(model.max_tokens),
941 if model.reasoning { "yes" } else { "no" }.to_string(),
942 if images { "yes" } else { "no" }.to_string(),
943 )
944 })
945 .collect();
946 let widths = (
947 rows.iter().map(|r| r.0.len()).max().unwrap_or(8).max(8),
948 rows.iter().map(|r| r.1.len()).max().unwrap_or(5).max(5),
949 rows.iter().map(|r| r.2.len()).max().unwrap_or(7).max(7),
950 rows.iter().map(|r| r.3.len()).max().unwrap_or(7).max(7),
951 rows.iter().map(|r| r.4.len()).max().unwrap_or(8).max(8),
952 rows.iter().map(|r| r.5.len()).max().unwrap_or(6).max(6),
953 );
954 println!(
955 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
956 "provider",
957 "model",
958 "context",
959 "max-out",
960 "thinking",
961 "images",
962 provider = widths.0,
963 model = widths.1,
964 context = widths.2,
965 max_out = widths.3,
966 thinking = widths.4,
967 images = widths.5,
968 );
969 for row in rows {
970 println!(
971 "{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
972 row.0,
973 row.1,
974 row.2,
975 row.3,
976 row.4,
977 row.5,
978 provider = widths.0,
979 model = widths.1,
980 context = widths.2,
981 max_out = widths.3,
982 thinking = widths.4,
983 images = widths.5,
984 );
985 }
986 0
987}
988
989fn read_piped_stdin() -> Option<String> {
996 if std::env::var("RPI_SKIP_STDIN")
999 .map(|v| v == "1")
1000 .unwrap_or(false)
1001 {
1002 return None;
1003 }
1004 if std::io::stdin().is_terminal() {
1005 return None;
1006 }
1007 let mut buf = String::new();
1008 match std::io::stdin().read_to_string(&mut buf) {
1009 Ok(_) => {
1010 let trimmed = buf.trim();
1011 if trimmed.is_empty() {
1012 None
1013 } else {
1014 Some(trimmed.to_string())
1015 }
1016 }
1017 Err(_) => None,
1018 }
1019}
1020
1021fn process_file_args(
1027 file_args: &[std::path::PathBuf],
1028 cwd: &Path,
1029) -> Result<(String, Vec<ImageContent>), String> {
1030 let mut text = String::new();
1031 let mut images = Vec::new();
1032 for rel in file_args {
1033 let abs = if rel.is_absolute() {
1034 rel.clone()
1035 } else {
1036 cwd.join(rel)
1037 };
1038 if !abs.exists() {
1039 return Err(format!("file not found: {}", abs.display()));
1040 }
1041 let bytes = std::fs::read(&abs)
1042 .map_err(|e| format!("could not read file {}: {e}", abs.display()))?;
1043 if let Some(image) = image_content_from_bytes(&bytes) {
1044 images.push(image);
1045 } else {
1046 let content = String::from_utf8(bytes).map_err(|_| {
1047 format!(
1048 "file is not valid UTF-8 text or a supported image: {}",
1049 abs.display()
1050 )
1051 })?;
1052 text.push_str(&format!(
1053 "<file name=\"{}\">\n{}\n</file>\n",
1054 abs.display(),
1055 content
1056 ));
1057 }
1058 }
1059 Ok((text, images))
1060}
1061
1062pub(crate) fn image_content_from_path(path: &Path) -> Result<Option<ImageContent>, String> {
1063 let bytes =
1064 std::fs::read(path).map_err(|e| format!("could not read file {}: {e}", path.display()))?;
1065 Ok(image_content_from_bytes(&bytes))
1066}
1067
1068fn image_content_from_bytes(bytes: &[u8]) -> Option<ImageContent> {
1069 let mime_type = rpi_tools::detect_supported_image_mime_type(bytes)?;
1070 Some(ImageContent {
1071 kind: ImageContentType,
1072 data: rpi_tools::encode_base64(bytes).into(),
1073 mime_type: mime_type.to_string(),
1074 })
1075}
1076
1077fn build_initial_message(
1083 parsed: &Args,
1084 stdin: Option<&str>,
1085 file_text: Option<&str>,
1086) -> (Option<String>, Vec<String>) {
1087 let mut extra = parsed.messages.clone();
1088 let mut parts: Vec<String> = Vec::new();
1089 if let Some(s) = stdin {
1090 parts.push(s.to_string());
1091 }
1092 if let Some(t) = file_text {
1093 parts.push(t.to_string());
1094 }
1095 if !extra.is_empty() {
1097 parts.push(extra.remove(0));
1098 }
1099 let initial = if parts.is_empty() {
1100 None
1101 } else {
1102 Some(parts.join(""))
1103 };
1104 (initial, extra)
1105}
1106
1107fn print_resolve_error(e: &ResolveError) {
1110 match e {
1111 ResolveError::NoApiKey { hint } => {
1112 eprintln!("error: {e}");
1113 eprintln!();
1114 eprintln!("Provide credentials via one of: {hint}.");
1115 }
1116 ResolveError::Config(_) => {
1117 eprintln!("error: {e}");
1118 eprintln!();
1119 eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
1120 }
1121 _ => eprintln!("error: {e}"),
1122 }
1123}
1124
1125fn print_build_error(e: &BuildError) {
1127 match e {
1128 BuildError::SessionNotFound { .. } => {
1129 eprintln!("error: {e}");
1130 eprintln!();
1131 eprintln!("List saved sessions with the /session command in interactive mode.");
1132 }
1133 _ => eprintln!("error: {e}"),
1134 }
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139 use super::*;
1140 use crate::args::Args;
1141
1142 #[test]
1143 fn take_flag_value_supports_both_spellings() {
1144 let argv = vec![
1145 "--connect".to_string(),
1146 "127.0.0.1:9899".to_string(),
1147 "--token".to_string(),
1148 "abc".to_string(),
1149 ];
1150 assert_eq!(
1151 take_flag_value(&argv, "--connect").as_deref(),
1152 Some("127.0.0.1:9899")
1153 );
1154 assert_eq!(take_flag_value(&argv, "--token").as_deref(), Some("abc"));
1155 assert_eq!(take_flag_value(&argv, "--missing"), None);
1156
1157 let inline = vec!["--connect=1.2.3.4:1".to_string()];
1158 assert_eq!(
1159 take_flag_value(&inline, "--connect").as_deref(),
1160 Some("1.2.3.4:1")
1161 );
1162 }
1163
1164 #[test]
1165 fn connect_token_prefers_the_flag_over_the_env_var() {
1166 let argv = vec!["--token".to_string(), "flag-token".to_string()];
1168 assert_eq!(resolve_connect_token(&argv).as_deref(), Some("flag-token"));
1169 }
1170
1171 #[test]
1172 fn build_initial_combines_stdin_file_and_first_message() {
1173 let mut args = Args::default();
1174 args.messages = vec!["first".into(), "second".into(), "third".into()];
1175 let (initial, extra) =
1176 build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
1177 assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
1178 assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
1179 }
1180
1181 #[test]
1182 fn build_initial_with_no_messages_uses_stdin_and_file_only() {
1183 let args = Args::default();
1184 let (initial, extra) =
1185 build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
1186 assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
1187 assert!(extra.is_empty());
1188 }
1189
1190 #[test]
1191 fn build_initial_none_when_all_empty() {
1192 let args = Args::default();
1193 let (initial, extra) = build_initial_message(&args, None, None);
1194 assert!(initial.is_none());
1195 assert!(extra.is_empty());
1196 }
1197
1198 #[test]
1199 fn build_initial_shifts_only_first_message() {
1200 let mut args = Args::default();
1201 args.messages = vec!["a".into(), "b".into()];
1202 let (initial, extra) = build_initial_message(&args, None, None);
1203 assert_eq!(initial.as_deref(), Some("a"));
1204 assert_eq!(extra, vec!["b".to_string()]);
1205 }
1206
1207 #[test]
1208 fn process_file_args_attaches_supported_images() {
1209 let dir = tempfile::tempdir().unwrap();
1210 let path = dir.path().join("image.bin");
1211 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
1212 png.extend_from_slice(b"IHDR");
1213 png.extend_from_slice(&[0; 13]);
1214 std::fs::write(&path, png).unwrap();
1215 let (text, images) = process_file_args(&[path], dir.path()).unwrap();
1216 assert!(text.is_empty());
1217 assert_eq!(images.len(), 1);
1218 assert_eq!(images[0].mime_type, "image/png");
1219 assert!(!images[0].data.is_empty());
1220 }
1221
1222 #[test]
1223 fn image_content_from_path_reports_supported_mime() {
1224 let dir = tempfile::tempdir().unwrap();
1225 let path = dir.path().join("drop.png");
1226 let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
1227 png.extend_from_slice(b"IHDR");
1228 png.extend_from_slice(&[0; 13]);
1229 std::fs::write(&path, png).unwrap();
1230 let image = image_content_from_path(&path).unwrap().unwrap();
1231 assert_eq!(image.mime_type, "image/png");
1232 }
1233}