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