1use crate::cli::CliArgs;
10use crate::print_mode;
11use crate::store::settings::Settings;
12use anyhow::Result;
13use std::path::PathBuf;
14use tracing;
15
16pub async fn build_app(args: &CliArgs) -> Result<crate::App> {
19 let mut settings = Settings::load().unwrap_or_default();
28
29 let apply_cli_overrides = |s: &mut Settings| {
33 s.merge_cli(args.model.clone(), args.provider.clone());
34 };
35 apply_cli_overrides(&mut settings);
36
37 if settings
38 .effective_model(None)
39 .unwrap_or_default()
40 .is_empty()
41 {
42 if is_tui_mode(args) {
48 eprintln!("No model configured. Launching setup wizard...");
49 crate::setup_wizard::run().await?;
50
51 settings = Settings::load().unwrap_or_default();
55 apply_cli_overrides(&mut settings);
56 }
57
58 if settings
59 .effective_model(None)
60 .unwrap_or_default()
61 .is_empty()
62 {
63 eprintln!(
64 "{}",
65 print_mode::format_error("No model configured. Run `oxi setup` to configure.")
66 );
67 std::process::exit(1);
68 }
69 }
70
71 register_custom_providers(&settings);
73
74 register_router_provider();
76
77 if let Some(ref level_str) = args.thinking {
79 if let Some(level) = crate::store::settings::parse_thinking_level(level_str) {
80 settings.thinking_level = level;
81 } else {
82 anyhow::bail!(
83 "Invalid thinking level: {}. Valid options: off, minimal, low, medium, high, xhigh",
84 level_str
85 );
86 }
87 }
88
89 let oxi = crate::build_oxi_engine().await?;
91
92 let ownership_session_id = if is_tui_mode(args) {
99 crate::store::issues::liveness::TUI_OWNERSHIP_ID.to_string()
100 } else {
101 format!(
102 "proc-{}-{}",
103 std::process::id(),
104 uuid::Uuid::new_v4().simple()
105 )
106 };
107
108 let _catalog_logger =
112 crate::services::spawn_catalog_event_logger(std::sync::Arc::clone(oxi.catalog()));
113
114 let mut app = crate::App::from_oxi(oxi, settings, ownership_session_id).await?;
115
116 let mcp_cfg = oxi_agent::mcp::config::load_mcp_config();
122 let mut oauth_map: std::collections::HashMap<String, oxi_agent::mcp::types::OAuthConfig> =
123 std::collections::HashMap::new();
124 for (name, entry) in &mcp_cfg.mcp_servers {
125 if let Some(oc) = entry.oauth.clone() {
126 oauth_map.insert(name.clone(), oc);
127 }
128 }
129 if !oauth_map.is_empty()
130 && let Some(manager) = app.agent_tools().mcp_manager()
131 {
132 let config_dir = dirs::config_dir()
133 .map(|d| d.join("oxi"))
134 .unwrap_or_else(|| std::path::PathBuf::from("."));
135 match crate::mcp_credentials::FileMcpCredentialProvider::new(oauth_map, config_dir) {
136 Ok(provider) => {
137 manager.set_credential_provider(provider);
138 }
139 Err(e) => {
140 tracing::warn!("Failed to construct MCP credential provider: {}", e);
141 }
142 }
143 }
144
145 let tools = app.agent_tools();
147 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
148 register_builtin_tools(
149 &tools,
150 &cwd,
151 args,
152 &app.settings().disabled_tools,
153 &app.settings().model_roles,
154 );
155
156 #[cfg(feature = "native-browser")]
162 {
163 match oxi_agent::tools::browse::OxiBrowserEngine::new().await {
164 Ok(engine) => {
165 let browser_registry =
166 oxi_sdk::tool_factory::browsing_tools_with_session(std::sync::Arc::new(engine));
167 tools.extend_from(&browser_registry);
168 }
169 Err(e) => {
170 tracing::warn!("native browser engine unavailable; browse tools disabled: {e}");
171 }
172 }
173 }
174
175 let wasm_ext = load_wasm_extensions(&app, &cwd, &tools);
177 app.set_wasm_ext(wasm_ext);
178
179 if let Some(ref prompt_path) = args.append_system_prompt {
181 let content = std::fs::read_to_string(prompt_path)
182 .map_err(|e| anyhow::anyhow!("Failed to read system prompt file: {}", e))?;
183 app.agent().set_system_prompt(content);
184 }
185
186 if let Some(handle) = crate::services::start_memory_pipeline(
190 app.settings(),
191 std::env::current_dir()
192 .as_ref()
193 .unwrap_or(&PathBuf::from(".")),
194 Some(app.oxi()),
195 ) {
196 tracing::debug!("memory pipeline spawn handle stored on app");
197 drop(handle); }
199 Ok(app)
200}
201
202pub async fn dispatch_run_mode(args: &CliArgs, app: crate::App) -> Result<i32> {
204 let prompt = args.prompt.join(" ");
205
206 if args.mode.as_deref() == Some("json") || args.print {
207 let mode = if args.mode.as_deref() == Some("json") {
208 crate::print_mode::PrintMode::Json
209 } else {
210 crate::print_mode::PrintMode::Text
211 };
212 let options = crate::print_mode::PrintModeOptions {
213 mode,
214 initial_message: if prompt.is_empty() {
215 None
216 } else {
217 Some(prompt)
218 },
219 messages: vec![],
220 no_stdin: args.print,
221 no_session: args.print || args.no_session,
222 quiet: args.print,
223 timeout: args.timeout,
224 };
225 return crate::print_mode::run_print_mode(&app, options).await;
226 }
227
228 if args.mode.as_deref() == Some("rpc") {
229 crate::rpc_mode::run_rpc_mode(app).await?;
230 return Ok(0);
231 }
232
233 if let Some(mode) = args.mode.as_deref() {
234 anyhow::bail!("Unknown run mode: {mode}");
235 }
236
237 if prompt.is_empty() || args.interactive {
238 if args.continue_session {
239 crate::tui::run_tui_interactive_with_continue(app, true).await?;
240 } else {
241 crate::tui::run_tui_interactive(app).await?;
242 }
243 return Ok(0);
244 }
245
246 crate::main_dispatch::run_single_prompt(app, &prompt).await?;
247 Ok(0)
248}
249
250pub async fn run_with_args(args: CliArgs) -> Result<i32> {
252 let app = build_app(&args).await?;
253 dispatch_run_mode(&args, app).await
254}
255
256pub fn init_logging() {
263 let log_dir = dirs::cache_dir()
264 .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
265 .join("oxi");
266 let _ = std::fs::create_dir_all(&log_dir);
267 let log_path = log_dir.join("oxi.log");
268
269 let log_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "debug".to_string());
270 let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
271 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(&log_filter));
272
273 let subscriber = tracing_subscriber::fmt()
278 .with_env_filter(env_filter)
279 .with_target(true)
280 .with_thread_ids(true)
281 .with_ansi(false);
282 match std::fs::File::create(&log_path) {
283 Ok(file) => {
284 subscriber.with_writer(std::sync::Mutex::new(file)).init();
285 }
286 Err(e) => {
287 eprintln!("oxi: could not open log file {log_path:?} ({e}); falling back to stderr");
288 subscriber.with_writer(std::io::stderr).init();
289 }
290 }
291
292 tracing::info!("Logging initialized, log file: {:?}", log_path);
293}
294
295fn register_custom_providers(settings: &Settings) {
297 let auth_storage = crate::store::auth_storage::shared_auth_storage();
298 for cp in &settings.custom_providers {
299 let api_key = auth_storage.get_api_key(&cp.name);
300 let api = cp.api.to_lowercase();
301
302 match api.as_str() {
303 "openai-completions" | "openai" => {
304 let provider =
305 oxi_ai::OpenAiProvider::with_base_url_and_key(&cp.base_url, api_key.clone());
306 oxi_sdk::register_provider(&cp.name, provider);
307 tracing::info!(
308 "Registered custom provider '{}' (openai-completions) -> {}",
309 cp.name,
310 cp.base_url
311 );
312 }
313 "openai-responses" | "responses" => {
314 let provider = oxi_sdk::OpenAiResponsesProvider::with_base_url_and_key(
315 &cp.base_url,
316 api_key.clone(),
317 );
318 oxi_sdk::register_provider(&cp.name, provider);
319 tracing::info!(
320 "Registered custom provider '{}' (openai-responses) -> {}",
321 cp.name,
322 cp.base_url
323 );
324 }
325 _ => {
326 tracing::warn!(
327 "Unknown API type '{}' for custom provider '{}'. Supported: openai-completions, openai-responses",
328 cp.api,
329 cp.name
330 );
331 }
332 }
333
334 fetch_and_register_models(cp, &api, &api_key);
335 }
336}
337
338fn fetch_and_register_models(
340 cp: &crate::store::settings::CustomProvider,
341 api: &str,
342 api_key: &Option<String>,
343) {
344 if let Some(key) = api_key {
345 match oxi_sdk::fetch_models_blocking(&cp.base_url, key.as_str()) {
346 Ok(model_ids) => {
347 let count = model_ids.len();
348 for model_id in &model_ids {
349 let api_type = match api {
350 "openai-responses" | "responses" => oxi_sdk::Api::OpenAiResponses,
351 _ => oxi_sdk::Api::OpenAiCompletions,
352 };
353 let model = oxi_sdk::Model {
354 id: model_id.clone(),
355 name: model_id.clone(),
356 api: api_type,
357 provider: cp.name.clone(),
358 base_url: cp.base_url.clone(),
359 reasoning: false,
360 input: vec![oxi_sdk::InputModality::Text],
361 cost: oxi_sdk::Cost::default(),
362 context_window: 128_000,
363 max_tokens: 8_192,
364 headers: Default::default(),
365 compat: None,
366 };
367 oxi_sdk::register_model(model);
368 }
369 tracing::info!(
370 "[oxi] auto-fetched {} models from '{}' ({})",
371 count,
372 cp.name,
373 cp.base_url
374 );
375 }
376 Err(e) => {
377 tracing::warn!(
378 "[oxi] warning: failed to resolve models for {}: {}",
379 cp.name,
380 e
381 );
382 }
383 }
384 }
385}
386
387fn register_builtin_tools(
395 tools: &oxi_agent::ToolRegistry,
396 cwd: &std::path::Path,
397 args: &CliArgs,
398 disabled_tools: &[String],
399 model_roles: &std::collections::HashMap<String, String>,
400) {
401 let builtin_registry = if let Some(ref tools_str) = args.tools {
402 let names: Vec<&str> = tools_str.split(',').map(|s| s.trim()).collect();
403 oxi_agent::ToolRegistry::with_selected_tools(cwd.to_path_buf(), &names)
404 } else {
405 oxi_agent::ToolRegistry::with_builtins_cwd(cwd.to_path_buf(), disabled_tools)
406 };
407 for name in builtin_registry.names() {
408 if let Some(tool) = builtin_registry.get(&name) {
409 tools.register_arc(tool);
410 }
411 }
412 if let Some(mgr) = builtin_registry.mcp_manager() {
415 tools.set_mcp_manager(mgr);
416 }
417
418 let role_registry = oxi_sdk::RoleRegistry::from_map(model_roles.clone());
422 if let Some(model) = oxi_sdk::resolve_role_to_model(oxi_sdk::ModelRole::Commit, &role_registry)
423 {
424 let commit: std::sync::Arc<dyn oxi_agent::AgentTool> =
425 std::sync::Arc::new(oxi_agent::CommitTool::new(model));
426 tools.register_arc(commit);
427 tracing::debug!("CommitTool upgraded to commit-role model");
428 }
429}
430
431fn load_wasm_extensions(
433 app: &crate::App,
434 cwd: &std::path::Path,
435 tools: &oxi_agent::ToolRegistry,
436) -> Option<std::sync::Arc<crate::extensions::WasmExtensionManager>> {
437 if !app.settings().extensions_enabled {
438 return None;
439 }
440
441 let wasm_paths = crate::extensions::WasmExtensionManager::discover(cwd);
442 if wasm_paths.is_empty() {
443 return None;
444 }
445
446 let mut wasm_mgr = crate::extensions::WasmExtensionManager::new();
447 let (loaded, errors) = wasm_mgr.load_all(&wasm_paths);
448 for info in &loaded {
449 tracing::info!("WASM extension loaded: {} v{}", info.name, info.version);
450 }
451 for err in &errors {
452 tracing::warn!("WASM extension error: {}", err);
453 }
454
455 if wasm_mgr.is_empty() {
456 return None;
457 }
458
459 let mgr = std::sync::Arc::new(wasm_mgr);
460 for tool_def in mgr.all_tool_defs() {
461 let wasm_tool = crate::extensions::WasmTool::new(
462 mgr.clone(),
463 tool_def.name.clone(),
464 tool_def.description.clone(),
465 tool_def.schema.clone(),
466 );
467 tools.register(wasm_tool);
468 }
469 Some(mgr)
470}
471
472fn register_router_provider() {
474 let global_dir = dirs::config_dir().unwrap_or_default().join("oxi");
475 let project_dir = std::env::current_dir().unwrap_or_default();
476
477 let store_cfg = match crate::store::router_config::load_router_config(&global_dir, &project_dir)
478 {
479 Some(cfg) => cfg,
480 None => {
481 tracing::debug!("No router config found — router/auto will not appear in model list");
482 return;
483 }
484 };
485
486 oxi_sdk::register_model(oxi_sdk::Model::new(
488 "auto",
489 "Router (auto)".to_string(),
490 oxi_sdk::Api::AnthropicMessages,
491 "router",
492 "router://local",
493 ));
494
495 let mut ai_profiles = std::collections::HashMap::new();
497 for (name, sp) in store_cfg.profiles() {
498 fn parse_thinking(s: &Option<String>) -> Option<oxi_sdk::ThinkingLevel> {
499 s.as_ref().and_then(|s| match s.as_str() {
500 "off" => Some(oxi_sdk::ThinkingLevel::Off),
501 "minimal" => Some(oxi_sdk::ThinkingLevel::Minimal),
502 "low" => Some(oxi_sdk::ThinkingLevel::Low),
503 "medium" => Some(oxi_sdk::ThinkingLevel::Medium),
504 "high" => Some(oxi_sdk::ThinkingLevel::High),
505 "xhigh" => Some(oxi_sdk::ThinkingLevel::XHigh),
506 _ => None,
507 })
508 }
509 ai_profiles.insert(
510 name.clone(),
511 oxi_sdk::router::RouterProfile {
512 high: oxi_sdk::router::RoutedTierConfig {
513 model: sp.high.model.clone(),
514 thinking: parse_thinking(&sp.high.thinking),
515 fallbacks: sp.high.fallbacks.clone(),
516 },
517 medium: oxi_sdk::router::RoutedTierConfig {
518 model: sp.medium.model.clone(),
519 thinking: parse_thinking(&sp.medium.thinking),
520 fallbacks: sp.medium.fallbacks.clone(),
521 },
522 low: oxi_sdk::router::RoutedTierConfig {
523 model: sp.low.model.clone(),
524 thinking: parse_thinking(&sp.low.thinking),
525 fallbacks: sp.low.fallbacks.clone(),
526 },
527 },
528 );
529 }
530 let ai_cfg = oxi_sdk::router::RouterConfig::with_pinning(
531 store_cfg.default_profile().to_string(),
532 store_cfg.classifier_model().map(String::from),
533 store_cfg.context_upgrade_threshold(),
534 store_cfg.max_session_budget(),
535 ai_profiles,
536 oxi_sdk::router::ScoringWeights {
537 structural: store_cfg.weights().structural,
538 behavioral: store_cfg.weights().behavioral,
539 context_budget: store_cfg.weights().context_budget,
540 vision: store_cfg.weights().vision,
541 message: store_cfg.weights().message,
542 },
543 store_cfg.pin_tier().and_then(|s| match s {
544 "high" => Some(oxi_sdk::router::RouterTier::High),
545 "medium" => Some(oxi_sdk::router::RouterTier::Medium),
546 "low" => Some(oxi_sdk::router::RouterTier::Low),
547 _ => None,
548 }),
549 store_cfg.phase_bias(),
550 );
551
552 oxi_sdk::router::register_router(&ai_cfg);
553}
554
555fn is_tui_mode(args: &CliArgs) -> bool {
559 if matches!(args.mode.as_deref(), Some("json" | "rpc")) || args.print {
560 return false;
561 }
562 let prompt = args.prompt.join(" ");
569 if !args.interactive && !prompt.is_empty() {
570 return false;
571 }
572 true
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use clap::Parser;
579
580 #[test]
581 fn rpc_mode_is_headless() {
582 let args = CliArgs::try_parse_from(["oxi", "--mode", "rpc"]).unwrap();
583 assert!(!is_tui_mode(&args));
584 }
585}