1#![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
53pub const VERSION: &str = env!("CARGO_PKG_VERSION");
55
56pub(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
68pub(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
86fn 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
98fn 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
141const PACKAGE_COMMANDS: [&str; 6] = ["install", "remove", "uninstall", "update", "list", "config"];
143
144const UNAVAILABLE_SUBCOMMANDS: [&str; 5] = ["auth", "mcp", "serve", "ext", "sessions"];
147
148async 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
174pub 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
231fn 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
254async 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}