use std::io::{IsTerminal, Read, Write};
use std::path::Path;
use rpi_ai::types::{ImageContent, ImageContentType};
use crate::args::{parse_args, print_help, print_version, resolve_mode, Args, RunMode};
use crate::provider::{resolve, ResolveError};
use crate::session::{build, BuildError};
pub const EXIT_USAGE: i32 = 2;
pub const EXIT_RUNTIME: i32 = 1;
pub async fn run() -> i32 {
let mut argv: Vec<String> = std::env::args().skip(1).collect();
if argv.first().map(String::as_str) == Some("__rpi_dev_cleanup") {
return crate::dev_extension::run_cleanup_helper(&argv[1..]);
}
let dev_options = if argv.first().map(String::as_str) == Some("dev") {
match crate::dev_extension::parse_args(&argv[1..]) {
Ok(options) if options.help => {
crate::dev_extension::print_help();
return 0;
}
Ok(options) => {
argv = options.passthrough.clone();
Some(options)
}
Err(error) => {
eprintln!("error: {error}");
crate::dev_extension::print_help();
return EXIT_USAGE;
}
}
} else {
None
};
if argv.first().map(|s| s.as_str()) == Some("auth") {
return crate::auth::run(&argv[1..]).await;
}
if argv.first().map(|s| s.as_str()) == Some("package") {
return crate::packages::run_cli(&argv[1..]);
}
if argv.first().map(|s| s.as_str()) == Some("update") {
return crate::updates::run_self_update(&argv[1..]);
}
if argv.first().map(|s| s.as_str()) == Some("install") {
return crate::install::run(&argv[1..]);
}
if argv.first().map(|s| s.as_str()) == Some("install-pi") {
return crate::install_pi::run(&argv[1..]);
}
if argv.first().map(|s| s.as_str()) == Some("uninstall") {
if argv.get(1).map(String::as_str) == Some("pi") {
return crate::install_pi::uninstall(&argv[2..]);
}
return crate::install::uninstall(&argv[1..]);
}
if argv.first().map(|s| s.as_str()) == Some("uninstall-pi") {
return crate::install_pi::uninstall(&argv[1..]);
}
let mut parsed = parse_args(&argv);
if parsed.help {
print_help();
return 0;
}
if parsed.version {
print_version();
return 0;
}
if !parsed.errors.is_empty() {
for err in &parsed.errors {
eprintln!("error: {err}");
}
eprintln!();
print_help();
return EXIT_USAGE;
}
let cwd = match std::env::current_dir() {
Ok(c) => c,
Err(e) => {
eprintln!("error: could not determine the current directory: {e}");
return EXIT_USAGE;
}
};
let _ = crate::config::migrate_legacy_layout();
if let Some(input) = parsed.export.as_deref() {
let output = parsed
.messages
.first()
.map(Path::new)
.map(Path::to_path_buf)
.unwrap_or_else(|| {
let stem = input
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("session");
Path::new(&format!("rpi-session-{stem}.html")).to_path_buf()
});
match crate::export::export_file(input, &output) {
Ok(()) => {
println!("Exported to: {}", output.display());
return 0;
}
Err(error) => {
eprintln!("error: {error}");
return EXIT_RUNTIME;
}
}
}
if let Some(search) = parsed.list_models.as_deref() {
return list_models(search).await;
}
let dev_extension = if let Some(options) = &dev_options {
let extension = match crate::dev_extension::DevExtension::detect(&cwd, options) {
Ok(extension) => extension,
Err(error) => {
eprintln!("error: {error}");
return EXIT_USAGE;
}
};
if let Err(error) = extension.rebuild() {
eprintln!("error: initial extension build failed: {error}");
return EXIT_RUNTIME;
}
if let Err(error) = extension.apply_to_args(&mut parsed) {
eprintln!("error: {error}");
return EXIT_RUNTIME;
}
Some(extension)
} else {
None
};
if parsed.trust_override.is_none()
&& std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
&& crate::session::project_has_local_resources(&cwd)
{
match prompt_project_trust(&cwd) {
Some(decision) => parsed.trust_override = Some(decision),
None => {
eprintln!(
"warning: project trust prompt unavailable; local resources remain disabled"
);
}
}
}
let quiet_startup = crate::settings::load_settings()
.ok()
.and_then(|settings| settings.quiet_startup)
.unwrap_or(false);
if !quiet_startup
&& !parsed.offline
&& !parsed.print
&& std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
{
let package_resources = crate::session::should_load_js_packages(&parsed)
.then(|| crate::session::package_resources_for(&parsed, &cwd));
let report =
crate::updates::check_startup_with_package_resources(package_resources.as_ref()).await;
crate::updates::print_startup_notices(&report);
}
if parsed.resume {
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
eprintln!("error: --resume requires an interactive terminal");
return EXIT_USAGE;
}
match crate::resume_picker::select(&cwd).await {
Ok(Some(id)) => {
parsed.resume = false;
parsed.session = Some(id);
}
Ok(None) => return 0,
Err(e) => {
eprintln!("error: {e}");
return EXIT_RUNTIME;
}
}
}
if parsed.verbose {
for warn in &parsed.ignored {
eprintln!("warning: {warn}");
}
}
if parsed.no_themes && parsed.theme.is_some() {
eprintln!("warning: --no-themes overrides --theme; using the built-in default theme");
}
let stdin_text = read_piped_stdin();
let (file_text, file_images) = match process_file_args(&parsed.file_args, &cwd) {
Ok(t) => t,
Err(msg) => {
eprintln!("error: {msg}");
return EXIT_USAGE;
}
};
let file_text_opt = if file_text.is_empty() {
None
} else {
Some(file_text.as_str())
};
let (initial, extra) = build_initial_message(&parsed, stdin_text.as_deref(), file_text_opt);
let resolved = match resolve(
parsed.provider.as_deref(),
parsed.model.as_deref(),
parsed.thinking,
parsed.api_key.as_deref(),
parsed.base_url.as_deref(),
) {
Ok(r) => r,
Err(e) => {
print_resolve_error(&e);
return match e {
ResolveError::NoApiKey { .. } | ResolveError::Config(_) => EXIT_USAGE,
_ => EXIT_RUNTIME,
};
}
};
let model_catalog = crate::provider::available_catalog(&resolved);
if let Some(patterns) = &parsed.models {
let mut matched: Vec<String> = Vec::new();
for p in patterns {
let hits: Vec<String> = model_catalog
.iter()
.filter(|m| m.id.eq_ignore_ascii_case(p))
.map(|m| m.id.clone())
.collect();
if hits.is_empty() {
eprintln!("warning: --models pattern \"{p}\" matched no model");
}
matched.extend(hits);
}
let mut settings = crate::settings::load_settings().unwrap_or_default();
settings.scoped_models = if matched.is_empty() {
None
} else {
Some(matched)
};
if let Err(e) = crate::settings::save_settings(&settings) {
eprintln!("warning: could not save --models scope: {e}");
}
}
let (harness, event_rx, mut reload_context) = match build(&resolved, &parsed, &cwd).await {
Ok(triple) => triple,
Err(e) => {
print_build_error(&e);
return EXIT_RUNTIME;
}
};
reload_context.dev_extension = dev_extension;
let stdin_is_tty = std::io::stdin().is_terminal();
let stdout_is_tty = std::io::stdout().is_terminal();
let mode = resolve_mode(&parsed, stdin_is_tty, stdout_is_tty);
let mode = if matches!(mode, RunMode::Interactive) && stdin_text.is_some() {
RunMode::Print
} else {
mode
};
let mode = if std::env::var("RPI_FORCE_TUI")
.map(|v| v == "1")
.unwrap_or(false)
{
RunMode::Interactive
} else {
mode
};
let dev_cleanup = reload_context.dev_extension.clone();
let dev_watcher = if matches!(mode, RunMode::Interactive) {
reload_context
.dev_extension
.as_ref()
.and_then(|extension| extension.start_watcher(reload_context.mailbox.clone()))
} else {
None
};
let exit_code = match mode {
RunMode::Print => {
crate::modes::print(
&harness,
&parsed,
initial.clone(),
&extra,
file_images.clone(),
)
.await
}
RunMode::Json => {
crate::modes::json(
&harness,
&parsed,
initial.clone(),
&extra,
file_images.clone(),
Some(event_rx),
)
.await
}
RunMode::Interactive => {
crate::modes::interactive(
&harness,
Some(event_rx),
&parsed,
model_catalog,
initial.clone(),
&extra,
file_images.clone(),
if parsed.no_themes {
None
} else {
parsed.theme.as_deref().or(resolved.theme.as_deref())
},
parsed.no_themes,
&reload_context,
)
.await
}
RunMode::Rpc => {
eprintln!("error: rpc mode is not implemented in v1 (use --mode text or --mode json)");
EXIT_USAGE
}
};
if let Some(dev) = &dev_cleanup {
dev.stop_watcher();
}
if let Some(watcher) = dev_watcher {
let _ = watcher.join();
}
drop(reload_context);
drop(harness);
if let Some(dev) = dev_cleanup {
dev.cleanup();
}
exit_code
}
fn prompt_project_trust(cwd: &Path) -> Option<bool> {
let display = cwd.display();
print!("Trust project {display} and load local resources? [y/N] ");
let _ = std::io::stdout().flush();
let mut answer = String::new();
if std::io::stdin().read_line(&mut answer).is_err() {
return None;
}
let normalized = answer.trim().to_ascii_lowercase();
let trusted = matches!(normalized.as_str(), "y" | "yes");
if let Err(error) = crate::config::set_project_trust(cwd, Some(trusted)) {
eprintln!("warning: could not persist project trust decision: {error}");
}
Some(trusted)
}
async fn list_models(search: &str) -> i32 {
let catalog = match crate::provider::catalog_all() {
Ok(models) => models,
Err(error) => {
eprintln!("warning: could not load models.json: {error}");
Vec::new()
}
};
let needle = search.trim().to_ascii_lowercase();
let mut models: Vec<_> = catalog
.into_iter()
.filter(|model| {
needle.is_empty()
|| format!("{} {} {}", model.provider, model.id, model.name)
.to_ascii_lowercase()
.contains(&needle)
})
.collect();
if models.is_empty() {
if needle.is_empty() {
println!("No models available");
} else {
println!("No models matching \"{search}\"");
}
return 0;
}
fn format_tokens(value: u64) -> String {
if value >= 1_000_000 {
let whole = value % 1_000_000 == 0;
if whole {
format!("{}M", value / 1_000_000)
} else {
format!("{:.1}M", value as f64 / 1_000_000.0)
}
} else if value >= 1_000 {
let whole = value % 1_000 == 0;
if whole {
format!("{}K", value / 1_000)
} else {
format!("{:.1}K", value as f64 / 1_000.0)
}
} else {
value.to_string()
}
}
let rows: Vec<_> = models
.drain(..)
.map(|model| {
let images = model
.input
.iter()
.any(|input| matches!(input, rpi_ai::InputModality::Image));
(
model.provider,
model.id,
format_tokens(model.context_window),
format_tokens(model.max_tokens),
if model.reasoning { "yes" } else { "no" }.to_string(),
if images { "yes" } else { "no" }.to_string(),
)
})
.collect();
let widths = (
rows.iter().map(|r| r.0.len()).max().unwrap_or(8).max(8),
rows.iter().map(|r| r.1.len()).max().unwrap_or(5).max(5),
rows.iter().map(|r| r.2.len()).max().unwrap_or(7).max(7),
rows.iter().map(|r| r.3.len()).max().unwrap_or(7).max(7),
rows.iter().map(|r| r.4.len()).max().unwrap_or(8).max(8),
rows.iter().map(|r| r.5.len()).max().unwrap_or(6).max(6),
);
println!(
"{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
"provider",
"model",
"context",
"max-out",
"thinking",
"images",
provider = widths.0,
model = widths.1,
context = widths.2,
max_out = widths.3,
thinking = widths.4,
images = widths.5,
);
for row in rows {
println!(
"{:provider$} {:model$} {:context$} {:max_out$} {:thinking$} {:images$}",
row.0,
row.1,
row.2,
row.3,
row.4,
row.5,
provider = widths.0,
model = widths.1,
context = widths.2,
max_out = widths.3,
thinking = widths.4,
images = widths.5,
);
}
0
}
fn read_piped_stdin() -> Option<String> {
if std::env::var("RPI_SKIP_STDIN")
.map(|v| v == "1")
.unwrap_or(false)
{
return None;
}
if std::io::stdin().is_terminal() {
return None;
}
let mut buf = String::new();
match std::io::stdin().read_to_string(&mut buf) {
Ok(_) => {
let trimmed = buf.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
Err(_) => None,
}
}
fn process_file_args(
file_args: &[std::path::PathBuf],
cwd: &Path,
) -> Result<(String, Vec<ImageContent>), String> {
let mut text = String::new();
let mut images = Vec::new();
for rel in file_args {
let abs = if rel.is_absolute() {
rel.clone()
} else {
cwd.join(rel)
};
if !abs.exists() {
return Err(format!("file not found: {}", abs.display()));
}
let bytes = std::fs::read(&abs)
.map_err(|e| format!("could not read file {}: {e}", abs.display()))?;
if let Some(image) = image_content_from_bytes(&bytes) {
images.push(image);
} else {
let content = String::from_utf8(bytes).map_err(|_| {
format!(
"file is not valid UTF-8 text or a supported image: {}",
abs.display()
)
})?;
text.push_str(&format!(
"<file name=\"{}\">\n{}\n</file>\n",
abs.display(),
content
));
}
}
Ok((text, images))
}
pub(crate) fn image_content_from_path(path: &Path) -> Result<Option<ImageContent>, String> {
let bytes =
std::fs::read(path).map_err(|e| format!("could not read file {}: {e}", path.display()))?;
Ok(image_content_from_bytes(&bytes))
}
fn image_content_from_bytes(bytes: &[u8]) -> Option<ImageContent> {
let mime_type = rpi_tools::detect_supported_image_mime_type(bytes)?;
Some(ImageContent {
kind: ImageContentType,
data: rpi_tools::encode_base64(bytes),
mime_type: mime_type.to_string(),
})
}
fn build_initial_message(
parsed: &Args,
stdin: Option<&str>,
file_text: Option<&str>,
) -> (Option<String>, Vec<String>) {
let mut extra = parsed.messages.clone();
let mut parts: Vec<String> = Vec::new();
if let Some(s) = stdin {
parts.push(s.to_string());
}
if let Some(t) = file_text {
parts.push(t.to_string());
}
if !extra.is_empty() {
parts.push(extra.remove(0));
}
let initial = if parts.is_empty() {
None
} else {
Some(parts.join(""))
};
(initial, extra)
}
fn print_resolve_error(e: &ResolveError) {
match e {
ResolveError::NoApiKey { hint } => {
eprintln!("error: {e}");
eprintln!();
eprintln!("Provide credentials via one of: {hint}.");
}
ResolveError::Config(_) => {
eprintln!("error: {e}");
eprintln!();
eprintln!("Check ~/.rpi/auth.json / ~/.rpi/models.json (set RPI_CODING_AGENT_DIR to relocate).");
}
_ => eprintln!("error: {e}"),
}
}
fn print_build_error(e: &BuildError) {
match e {
BuildError::SessionNotFound { .. } => {
eprintln!("error: {e}");
eprintln!();
eprintln!("List saved sessions with the /session command in interactive mode.");
}
_ => eprintln!("error: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::args::Args;
#[test]
fn build_initial_combines_stdin_file_and_first_message() {
let mut args = Args::default();
args.messages = vec!["first".into(), "second".into(), "third".into()];
let (initial, extra) =
build_initial_message(&args, Some("stdin-text"), Some("<file>...</file>"));
assert_eq!(initial.as_deref(), Some("stdin-text<file>...</file>first"));
assert_eq!(extra, vec!["second".to_string(), "third".to_string()]);
}
#[test]
fn build_initial_with_no_messages_uses_stdin_and_file_only() {
let args = Args::default();
let (initial, extra) =
build_initial_message(&args, Some("only-stdin"), Some("<file>x</file>"));
assert_eq!(initial.as_deref(), Some("only-stdin<file>x</file>"));
assert!(extra.is_empty());
}
#[test]
fn build_initial_none_when_all_empty() {
let args = Args::default();
let (initial, extra) = build_initial_message(&args, None, None);
assert!(initial.is_none());
assert!(extra.is_empty());
}
#[test]
fn build_initial_shifts_only_first_message() {
let mut args = Args::default();
args.messages = vec!["a".into(), "b".into()];
let (initial, extra) = build_initial_message(&args, None, None);
assert_eq!(initial.as_deref(), Some("a"));
assert_eq!(extra, vec!["b".to_string()]);
}
#[test]
fn process_file_args_attaches_supported_images() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("image.bin");
let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
png.extend_from_slice(b"IHDR");
png.extend_from_slice(&[0; 13]);
std::fs::write(&path, png).unwrap();
let (text, images) = process_file_args(&[path], dir.path()).unwrap();
assert!(text.is_empty());
assert_eq!(images.len(), 1);
assert_eq!(images[0].mime_type, "image/png");
assert!(!images[0].data.is_empty());
}
#[test]
fn image_content_from_path_reports_supported_mime() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("drop.png");
let mut png = vec![137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13];
png.extend_from_slice(b"IHDR");
png.extend_from_slice(&[0; 13]);
std::fs::write(&path, png).unwrap();
let image = image_content_from_path(&path).unwrap().unwrap();
assert_eq!(image.mime_type, "image/png");
}
}