#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "tests assert by unwrapping"
)
)]
pub mod cli;
#[cfg(feature = "faux")]
pub mod faux;
pub mod loader;
pub mod modes;
pub mod startup;
use std::io::{IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use cli::args::{Diagnostic, ListModels, Severity, parse_args};
use cli::files::{build_initial_message, process_file_arguments};
use modes::print::{PrintInput, PrintOutput};
use startup::{AppMode, Environment, StartupError};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub(crate) fn eprintln_line(text: &str) {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(stderr, "{text}");
}
fn print_stdout(text: &str) {
let mut stdout = std::io::stdout().lock();
let _ = stdout.write_all(text.as_bytes());
let _ = stdout.flush();
}
pub(crate) fn report_lines(diagnostics: &[Diagnostic]) -> Vec<String> {
diagnostics
.iter()
.map(|d| match d.severity {
Severity::Error => format!("Error: {}", d.message),
Severity::Warning => format!("Warning: {}", d.message),
})
.collect()
}
fn fail(lines: &[String]) -> i32 {
for line in lines {
eprintln_line(line);
}
1
}
fn read_piped_stdin() -> Option<String> {
let mut stdin = std::io::stdin();
if stdin.is_terminal() {
return None;
}
let mut text = String::new();
stdin.read_to_string(&mut text).ok()?;
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
fn export_from_file(input: &str, output: Option<&str>, cwd: &Path) -> Result<PathBuf, String> {
let home = oino_settings::paths::home_dir();
let input = oino_settings::paths::resolve_path(input, cwd, home.as_deref(), false);
if !input.exists() {
return Err(format!("File not found: {}", input.display()));
}
let root = input.parent().unwrap_or(cwd).to_path_buf();
let manager =
oino_session::SessionManager::open(&input, &root, Some(cwd)).map_err(|e| e.to_string())?;
let branch: Vec<oino_session::SessionEntry> =
manager.branch(None).into_iter().cloned().collect();
let html = oino_core::export::render_html(
manager.session_id(),
manager.session_name().as_deref(),
&branch,
);
let output = output.map_or_else(
|| {
let stem = input
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
PathBuf::from(format!("oino-session-{stem}.html"))
},
|o| oino_settings::paths::normalize_path(o, home.as_deref()),
);
std::fs::write(&output, html).map_err(|e| e.to_string())?;
Ok(output)
}
fn environment(args: &cli::args::Args) -> Result<Environment, String> {
let cwd = std::env::current_dir().map_err(|e| format!("Error: {e}"))?;
let agent_dir = match &args.agent_dir {
Some(dir) => {
let home = oino_settings::paths::home_dir();
oino_settings::paths::resolve_path(dir, &cwd, home.as_deref(), false)
}
None => oino_settings::paths::agent_dir().map_err(|e| format!("Error: {e}"))?,
};
Ok(Environment { cwd, agent_dir })
}
const PACKAGE_COMMANDS: [&str; 6] = ["install", "remove", "uninstall", "update", "list", "config"];
const UNAVAILABLE_SUBCOMMANDS: [&str; 5] = ["auth", "mcp", "serve", "ext", "sessions"];
async fn package_command(command: &str, rest: &[String]) -> i32 {
let self_update = command == "update" && rest.iter().all(|a| a == "--check" || a == "--self");
if self_update {
let check = rest.iter().any(|a| a == "--check");
return match cli::self_update::self_update(check, None).await {
Ok(message) => {
print_stdout(&format!("{message}\n"));
0
}
Err(error) => fail(&[format!("Error: {error}")]),
};
}
let cwd = match std::env::current_dir() {
Ok(cwd) => cwd,
Err(error) => return fail(&[format!("Error: {error}")]),
};
let agent_dir = match oino_settings::paths::agent_dir() {
Ok(dir) => dir,
Err(error) => return fail(&[format!("Error: {error}")]),
};
let context = cli::packages::PackageContext { agent_dir, cwd };
cli::packages::run_command(command, rest, &context).await
}
pub async fn run(command_line: Vec<String>) -> i32 {
if let Some(command) = command_line
.first()
.filter(|c| PACKAGE_COMMANDS.contains(&c.as_str()))
{
return package_command(command, &command_line[1..]).await;
}
if let Some(command) = command_line
.first()
.filter(|c| UNAVAILABLE_SUBCOMMANDS.contains(&c.as_str()))
{
return fail(&[format!(
"Error: \"oino {command}\" is not available in this build yet"
)]);
}
let args = parse_args(&command_line);
if !args.diagnostics.is_empty() {
for line in report_lines(&args.diagnostics) {
eprintln_line(&line);
}
if args
.diagnostics
.iter()
.any(|d| d.severity == Severity::Error)
{
return 1;
}
}
if args.version {
print_stdout(&format!("{VERSION}\n"));
return 0;
}
let env = match environment(&args) {
Ok(env) => env,
Err(message) => return fail(&[message]),
};
if let Some(input) = &args.export {
return match export_from_file(input, args.messages.first().map(String::as_str), &env.cwd) {
Ok(path) => {
print_stdout(&format!("Exported to: {}\n", path.display()));
0
}
Err(message) => fail(&[format!("Error: {message}")]),
};
}
let mode = AppMode::resolve(
&args,
std::io::stdin().is_terminal(),
std::io::stdout().is_terminal(),
);
if let Err(lines) = preflight(&args, mode) {
return fail(&lines);
}
Box::pin(run_mode(args, mode, &env)).await
}
fn preflight(args: &cli::args::Args, mode: AppMode) -> Result<(), Vec<String>> {
if mode == AppMode::Rpc && !args.file_args.is_empty() {
return Err(vec![
"Error: @file arguments are not supported in RPC mode".to_owned(),
]);
}
startup::validate_session_flags(args).map_err(|StartupError(lines)| lines)?;
if args.help || args.list_models.is_some() {
return Ok(());
}
let unavailable = match mode {
AppMode::Interactive if args.remote.is_some() => Some("Remote interactive mode"),
_ => None,
};
match unavailable {
Some(what) => Err(vec![format!(
"Error: {what} is not available in this build; use -p, --mode json or --mode rpc"
)]),
None => Ok(()),
}
}
async fn run_mode(mut args: cli::args::Args, mode: AppMode, env: &Environment) -> i32 {
if mode == AppMode::Acp && !args.help && args.list_models.is_none() {
return modes::acp::run(&args, env).await;
}
let started = match startup::start(&args, mode, env).await {
Ok(started) => started,
Err(StartupError(lines)) => return fail(&lines),
};
if args.help {
print_stdout(&cli::help::help_text(&started.extension_flags));
return 0;
}
if let Some(list) = &args.list_models {
let no_models = oino_models::auth::guidance::no_models_available_message(
&startup::docs_root(&env.agent_dir).join("docs"),
);
let search = match list {
ListModels::All => None,
ListModels::Search(search) => Some(search.as_str()),
};
print_stdout(&cli::list_models::list_models_output(
&started.runtime.session().models().available(),
search,
&no_models,
));
started.runtime.dispose().await;
return 0;
}
for line in report_lines(&started.diagnostics) {
eprintln_line(&line);
}
if mode == AppMode::Rpc {
return modes::rpc::run(started.runtime).await;
}
let stdin = read_piped_stdin();
let files = match process_file_arguments(&args.file_args, &env.cwd) {
Ok(files) => files,
Err(message) => {
started.runtime.dispose().await;
return fail(&[message]);
}
};
let initial = build_initial_message(&mut args.messages, files, stdin);
if mode == AppMode::Interactive {
let notices = report_lines(&started.diagnostics);
let mut initial_messages: Vec<String> = initial.text.into_iter().collect();
initial_messages.append(&mut args.messages);
let options = oino_tui::TuiOptions {
version: VERSION.to_owned(),
initial_messages,
initial_images: initial.images,
notices,
theme: None,
tui_mode: args.tui_mode.map(|mode| match mode {
cli::args::TuiMode::Regular => oino_tui::TuiMode::Regular,
cli::args::TuiMode::Fullscreen => oino_tui::TuiMode::Fullscreen,
}),
};
return Box::pin(modes::interactive::run(started, env, options)).await;
}
let output = if mode == AppMode::Json {
PrintOutput::Json
} else {
PrintOutput::Text
};
let input = PrintInput {
initial,
messages: args.messages,
};
modes::print::run(started.runtime, output, input).await
}