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