Skip to main content

oino/
lib.rs

1//! `oino`: the headless Oino binary over `oino-core` (modes.md §1–§3).
2//!
3//! The command line (fixed flags and semantics): interactive mode (the `oino-tui`
4//! client), print mode, JSON event stream mode, RPC mode and ACP mode on stdio. Every mode
5//! loads the share-dir, installed and `-e` extension packages ([`loader`]). Package subcommands
6//! (`install`, `remove`, `update`, `list`, `config`) and the self-update (`oino update`) run
7//! before flag parsing; `serve` is not available yet.
8//!
9//! # API map
10//!
11//! | Module | Contents |
12//! |---|---|
13//! | [`cli::args`] | [`cli::args::parse_args`] → [`cli::args::Args`] |
14//! | [`cli::help`] | help text with extension flags |
15//! | [`cli::files`] | `@file` arguments, first message |
16//! | [`cli::list_models`] | `--list-models` table |
17//! | [`cli::packages`] | `install`/`remove`/`update`/`list`/`config` |
18//! | [`cli::self_update`] | `oino update [--check]` from the release manifest |
19//! | [`startup`] | services, session selection, model/tool options |
20//! | [`loader`] | share-dir, installed and `-e` packages, `builtin:` components |
21//! | [`modes::print`] | text and JSON output |
22//! | [`modes::rpc`] | RPC server on stdio |
23//! | [`modes::interactive`] | the TUI over an in-process backend |
24//! | [`modes::acp`] | ACP agent on stdio (`oino-acp`) |
25//! | `faux` (feature) | — | scripted provider + UI probe for tests |
26
27#![forbid(unsafe_code)]
28#![cfg_attr(
29    test,
30    allow(
31        clippy::unwrap_used,
32        clippy::expect_used,
33        clippy::panic,
34        reason = "tests assert by unwrapping"
35    )
36)]
37
38pub mod cli;
39#[cfg(feature = "faux")]
40pub mod faux;
41pub mod loader;
42pub mod modes;
43pub mod startup;
44
45use std::io::{IsTerminal, Read, Write};
46use std::path::{Path, PathBuf};
47
48use cli::args::{Diagnostic, ListModels, Severity, parse_args};
49use cli::files::{build_initial_message, process_file_arguments};
50use modes::print::{PrintInput, PrintOutput};
51use startup::{AppMode, Environment, StartupError};
52
53/// The version printed by `-v`.
54pub const VERSION: &str = env!("CARGO_PKG_VERSION");
55
56/// Writes a line to stderr (diagnostics never go to stdout).
57pub(crate) fn eprintln_line(text: &str) {
58    let mut stderr = std::io::stderr().lock();
59    let _ = writeln!(stderr, "{text}");
60}
61
62fn print_stdout(text: &str) {
63    let mut stdout = std::io::stdout().lock();
64    let _ = stdout.write_all(text.as_bytes());
65    let _ = stdout.flush();
66}
67
68/// `Error: …` / `Warning: …` lines.
69pub(crate) fn report_lines(diagnostics: &[Diagnostic]) -> Vec<String> {
70    diagnostics
71        .iter()
72        .map(|d| match d.severity {
73            Severity::Error => format!("Error: {}", d.message),
74            Severity::Warning => format!("Warning: {}", d.message),
75        })
76        .collect()
77}
78
79fn fail(lines: &[String]) -> i32 {
80    for line in lines {
81        eprintln_line(line);
82    }
83    1
84}
85
86/// All of stdin when it is not a terminal, trimmed; `None` if empty.
87fn read_piped_stdin() -> Option<String> {
88    let mut stdin = std::io::stdin();
89    if stdin.is_terminal() {
90        return None;
91    }
92    let mut text = String::new();
93    stdin.read_to_string(&mut text).ok()?;
94    let trimmed = text.trim();
95    (!trimmed.is_empty()).then(|| trimmed.to_owned())
96}
97
98/// E x p o r t   F r o m   F i l e.
99fn export_from_file(input: &str, output: Option<&str>, cwd: &Path) -> Result<PathBuf, String> {
100    let home = oino_settings::paths::home_dir();
101    let input = oino_settings::paths::resolve_path(input, cwd, home.as_deref(), false);
102    if !input.exists() {
103        return Err(format!("File not found: {}", input.display()));
104    }
105    let root = input.parent().unwrap_or(cwd).to_path_buf();
106    let manager =
107        oino_session::SessionManager::open(&input, &root, Some(cwd)).map_err(|e| e.to_string())?;
108    let branch: Vec<oino_session::SessionEntry> =
109        manager.branch(None).into_iter().cloned().collect();
110    let html = oino_core::export::render_html(
111        manager.session_id(),
112        manager.session_name().as_deref(),
113        &branch,
114    );
115    let output = output.map_or_else(
116        || {
117            let stem = input
118                .file_stem()
119                .map(|s| s.to_string_lossy().into_owned())
120                .unwrap_or_default();
121            PathBuf::from(format!("oino-session-{stem}.html"))
122        },
123        |o| oino_settings::paths::normalize_path(o, home.as_deref()),
124    );
125    std::fs::write(&output, html).map_err(|e| e.to_string())?;
126    Ok(output)
127}
128
129fn environment(args: &cli::args::Args) -> Result<Environment, String> {
130    let cwd = std::env::current_dir().map_err(|e| format!("Error: {e}"))?;
131    let agent_dir = match &args.agent_dir {
132        Some(dir) => {
133            let home = oino_settings::paths::home_dir();
134            oino_settings::paths::resolve_path(dir, &cwd, home.as_deref(), false)
135        }
136        None => oino_settings::paths::agent_dir().map_err(|e| format!("Error: {e}"))?,
137    };
138    Ok(Environment { cwd, agent_dir })
139}
140
141/// Package manager subcommands (runtime §15).
142const PACKAGE_COMMANDS: [&str; 6] = ["install", "remove", "uninstall", "update", "list", "config"];
143
144/// Subcommands handled before flag parsing that are not available yet (`auth`, Oino
145/// `serve`/`ext`/`sessions`); owned by other work items.
146const UNAVAILABLE_SUBCOMMANDS: [&str; 5] = ["auth", "mcp", "serve", "ext", "sessions"];
147
148/// `oino install|remove|uninstall|update|list|config …`; bare `oino update [--check]` is the
149/// self-update (`update <id>…` / `update --extensions` update packages).
150async fn package_command(command: &str, rest: &[String]) -> i32 {
151    let self_update = command == "update" && rest.iter().all(|a| a == "--check" || a == "--self");
152    if self_update {
153        let check = rest.iter().any(|a| a == "--check");
154        return match cli::self_update::self_update(check, None).await {
155            Ok(message) => {
156                print_stdout(&format!("{message}\n"));
157                0
158            }
159            Err(error) => fail(&[format!("Error: {error}")]),
160        };
161    }
162    let cwd = match std::env::current_dir() {
163        Ok(cwd) => cwd,
164        Err(error) => return fail(&[format!("Error: {error}")]),
165    };
166    let agent_dir = match oino_settings::paths::agent_dir() {
167        Ok(dir) => dir,
168        Err(error) => return fail(&[format!("Error: {error}")]),
169    };
170    let context = cli::packages::PackageContext { agent_dir, cwd };
171    cli::packages::run_command(command, rest, &context).await
172}
173
174/// Runs the CLI with `command_line` (without the program name); returns the exit code.
175pub async fn run(command_line: Vec<String>) -> i32 {
176    if let Some(command) = command_line
177        .first()
178        .filter(|c| PACKAGE_COMMANDS.contains(&c.as_str()))
179    {
180        return package_command(command, &command_line[1..]).await;
181    }
182    if let Some(command) = command_line
183        .first()
184        .filter(|c| UNAVAILABLE_SUBCOMMANDS.contains(&c.as_str()))
185    {
186        return fail(&[format!(
187            "Error: \"oino {command}\" is not available in this build yet"
188        )]);
189    }
190    let args = parse_args(&command_line);
191    if !args.diagnostics.is_empty() {
192        for line in report_lines(&args.diagnostics) {
193            eprintln_line(&line);
194        }
195        if args
196            .diagnostics
197            .iter()
198            .any(|d| d.severity == Severity::Error)
199        {
200            return 1;
201        }
202    }
203    if args.version {
204        print_stdout(&format!("{VERSION}\n"));
205        return 0;
206    }
207    let env = match environment(&args) {
208        Ok(env) => env,
209        Err(message) => return fail(&[message]),
210    };
211    if let Some(input) = &args.export {
212        return match export_from_file(input, args.messages.first().map(String::as_str), &env.cwd) {
213            Ok(path) => {
214                print_stdout(&format!("Exported to: {}\n", path.display()));
215                0
216            }
217            Err(message) => fail(&[format!("Error: {message}")]),
218        };
219    }
220    let mode = AppMode::resolve(
221        &args,
222        std::io::stdin().is_terminal(),
223        std::io::stdout().is_terminal(),
224    );
225    if let Err(lines) = preflight(&args, mode) {
226        return fail(&lines);
227    }
228    Box::pin(run_mode(args, mode, &env)).await
229}
230
231/// Mode checks before any service starts.
232fn preflight(args: &cli::args::Args, mode: AppMode) -> Result<(), Vec<String>> {
233    if mode == AppMode::Rpc && !args.file_args.is_empty() {
234        return Err(vec![
235            "Error: @file arguments are not supported in RPC mode".to_owned(),
236        ]);
237    }
238    startup::validate_session_flags(args).map_err(|StartupError(lines)| lines)?;
239    if args.help || args.list_models.is_some() {
240        return Ok(());
241    }
242    let unavailable = match mode {
243        AppMode::Interactive if args.remote.is_some() => Some("Remote interactive mode"),
244        _ => None,
245    };
246    match unavailable {
247        Some(what) => Err(vec![format!(
248            "Error: {what} is not available in this build; use -p, --mode json or --mode rpc"
249        )]),
250        None => Ok(()),
251    }
252}
253
254/// Starts the runtime and runs `--help`, `--list-models`, ACP, RPC, interactive or print/JSON
255/// mode.
256async fn run_mode(mut args: cli::args::Args, mode: AppMode, env: &Environment) -> i32 {
257    if mode == AppMode::Acp && !args.help && args.list_models.is_none() {
258        return modes::acp::run(&args, env).await;
259    }
260    let started = match startup::start(&args, mode, env).await {
261        Ok(started) => started,
262        Err(StartupError(lines)) => return fail(&lines),
263    };
264    if args.help {
265        print_stdout(&cli::help::help_text(&started.extension_flags));
266        return 0;
267    }
268    if let Some(list) = &args.list_models {
269        let no_models = oino_models::auth::guidance::no_models_available_message(
270            &startup::docs_root(&env.agent_dir).join("docs"),
271        );
272        let search = match list {
273            ListModels::All => None,
274            ListModels::Search(search) => Some(search.as_str()),
275        };
276        print_stdout(&cli::list_models::list_models_output(
277            &started.runtime.session().models().available(),
278            search,
279            &no_models,
280        ));
281        started.runtime.dispose().await;
282        return 0;
283    }
284    for line in report_lines(&started.diagnostics) {
285        eprintln_line(&line);
286    }
287    if mode == AppMode::Rpc {
288        return modes::rpc::run(started.runtime).await;
289    }
290    let stdin = read_piped_stdin();
291    let files = match process_file_arguments(&args.file_args, &env.cwd) {
292        Ok(files) => files,
293        Err(message) => {
294            started.runtime.dispose().await;
295            return fail(&[message]);
296        }
297    };
298    let initial = build_initial_message(&mut args.messages, files, stdin);
299    if mode == AppMode::Interactive {
300        let notices = report_lines(&started.diagnostics);
301        let mut initial_messages: Vec<String> = initial.text.into_iter().collect();
302        initial_messages.append(&mut args.messages);
303        let options = oino_tui::TuiOptions {
304            version: VERSION.to_owned(),
305            initial_messages,
306            initial_images: initial.images,
307            notices,
308            theme: None,
309            tui_mode: args.tui_mode.map(|mode| match mode {
310                cli::args::TuiMode::Regular => oino_tui::TuiMode::Regular,
311                cli::args::TuiMode::Fullscreen => oino_tui::TuiMode::Fullscreen,
312            }),
313        };
314        return Box::pin(modes::interactive::run(started, env, options)).await;
315    }
316    let output = if mode == AppMode::Json {
317        PrintOutput::Json
318    } else {
319        PrintOutput::Text
320    };
321    let input = PrintInput {
322        initial,
323        messages: args.messages,
324    };
325    modes::print::run(started.runtime, output, input).await
326}