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 settings.merge_cli(
31 args.model.clone(),
32 args.provider.clone(),
33 Some(args.enable_routing),
34 Some(args.prefer_cost_efficient),
35 if args.fallback_chain.is_empty() {
36 None
37 } else {
38 Some(args.fallback_chain.clone())
39 },
40 Some(args.disable_fallback),
41 );
42
43 if settings
44 .effective_model(None)
45 .unwrap_or_default()
46 .is_empty()
47 {
48 eprintln!(
49 "{}",
50 print_mode::format_error("No model configured. Run `oxi setup` to configure.")
51 );
52 std::process::exit(1);
53 }
54
55 register_custom_providers(&settings);
57
58 register_router_provider(&settings);
60
61 if let Some(ref level_str) = args.thinking {
63 if let Some(level) = crate::store::settings::parse_thinking_level(level_str) {
64 settings.thinking_level = level;
65 } else {
66 anyhow::bail!(
67 "Invalid thinking level: {}. Valid options: off, minimal, low, medium, high, xhigh",
68 level_str
69 );
70 }
71 }
72
73 let oxi = crate::build_oxi_engine().await?;
75
76 let ownership_session_id = if is_tui_mode(args) {
83 crate::store::issues::liveness::TUI_OWNERSHIP_ID.to_string()
84 } else {
85 format!(
86 "proc-{}-{}",
87 std::process::id(),
88 uuid::Uuid::new_v4().simple()
89 )
90 };
91
92 let _catalog_logger =
96 crate::services::spawn_catalog_event_logger(std::sync::Arc::clone(oxi.catalog()));
97
98 let mut app = crate::App::from_oxi(oxi, settings, ownership_session_id).await?;
99
100 let mcp_cfg = oxi_agent::mcp::config::load_mcp_config();
106 let mut oauth_map: std::collections::HashMap<String, oxi_agent::mcp::types::OAuthConfig> =
107 std::collections::HashMap::new();
108 for (name, entry) in &mcp_cfg.mcp_servers {
109 if let Some(oc) = entry.oauth.clone() {
110 oauth_map.insert(name.clone(), oc);
111 }
112 }
113 if !oauth_map.is_empty()
114 && let Some(manager) = app.agent_tools().mcp_manager()
115 {
116 let config_dir = dirs::config_dir()
117 .map(|d| d.join("oxi"))
118 .unwrap_or_else(|| std::path::PathBuf::from("."));
119 match crate::mcp_credentials::FileMcpCredentialProvider::new(oauth_map, config_dir) {
120 Ok(provider) => {
121 manager.set_credential_provider(provider);
122 }
123 Err(e) => {
124 tracing::warn!("Failed to construct MCP credential provider: {}", e);
125 }
126 }
127 }
128
129 let tools = app.agent_tools();
131 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
132 register_builtin_tools(&tools, &cwd, args, &app.settings().disabled_tools);
133
134 let wasm_ext = load_wasm_extensions(&app, &cwd, &tools);
136 app.set_wasm_ext(wasm_ext);
137
138 if let Some(ref prompt_path) = args.append_system_prompt {
140 let content = std::fs::read_to_string(prompt_path)
141 .map_err(|e| anyhow::anyhow!("Failed to read system prompt file: {}", e))?;
142 app.agent().set_system_prompt(content);
143 }
144
145 Ok(app)
146}
147
148pub async fn dispatch_run_mode(args: &CliArgs, app: crate::App) -> Result<i32> {
150 let prompt = args.prompt.join(" ");
151
152 if args.mode.as_deref() == Some("json") || args.print {
153 let mode = if args.mode.as_deref() == Some("json") {
154 crate::print_mode::PrintMode::Json
155 } else {
156 crate::print_mode::PrintMode::Text
157 };
158 let options = crate::print_mode::PrintModeOptions {
159 mode,
160 initial_message: if prompt.is_empty() {
161 None
162 } else {
163 Some(prompt)
164 },
165 messages: vec![],
166 no_stdin: args.print,
167 no_session: args.print || args.no_session,
168 quiet: args.print,
169 timeout: args.timeout,
170 };
171 return crate::print_mode::run_print_mode(&app, options).await;
172 }
173
174 if prompt.is_empty() || args.interactive {
175 if args.continue_session {
176 crate::tui::run_tui_interactive_with_continue(app, true).await?;
177 } else {
178 crate::tui::run_tui_interactive(app).await?;
179 }
180 return Ok(0);
181 }
182
183 crate::main_dispatch::run_single_prompt(app, &prompt).await?;
184 Ok(0)
185}
186
187pub async fn run_with_args(args: CliArgs) -> Result<i32> {
189 let app = build_app(&args).await?;
190 dispatch_run_mode(&args, app).await
191}
192
193pub fn init_logging() {
200 let log_dir = dirs::cache_dir()
201 .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
202 .join("oxi");
203 let _ = std::fs::create_dir_all(&log_dir);
204 let log_path = log_dir.join("oxi.log");
205
206 let log_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "debug".to_string());
207 let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
208 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(&log_filter));
209
210 let log_file = std::fs::File::create(&log_path).expect("Failed to create log file");
211 let writer = std::sync::Mutex::new(log_file);
212
213 tracing_subscriber::fmt()
214 .with_env_filter(env_filter)
215 .with_writer(writer)
216 .with_target(true)
217 .with_thread_ids(true)
218 .with_ansi(false)
219 .init();
220
221 tracing::info!("Logging initialized, log file: {:?}", log_path);
222}
223
224fn register_custom_providers(settings: &Settings) {
226 let auth_storage = crate::store::auth_storage::shared_auth_storage();
227 for cp in &settings.custom_providers {
228 let api_key = auth_storage.get_api_key(&cp.name);
229 let api = cp.api.to_lowercase();
230
231 match api.as_str() {
232 "openai-completions" | "openai" => {
233 let provider =
234 oxi_ai::OpenAiProvider::with_base_url_and_key(&cp.base_url, api_key.clone());
235 oxi_sdk::register_provider(&cp.name, provider);
236 tracing::info!(
237 "Registered custom provider '{}' (openai-completions) -> {}",
238 cp.name,
239 cp.base_url
240 );
241 }
242 "openai-responses" | "responses" => {
243 let provider = oxi_sdk::OpenAiResponsesProvider::with_base_url_and_key(
244 &cp.base_url,
245 api_key.clone(),
246 );
247 oxi_sdk::register_provider(&cp.name, provider);
248 tracing::info!(
249 "Registered custom provider '{}' (openai-responses) -> {}",
250 cp.name,
251 cp.base_url
252 );
253 }
254 _ => {
255 tracing::warn!(
256 "Unknown API type '{}' for custom provider '{}'. Supported: openai-completions, openai-responses",
257 cp.api,
258 cp.name
259 );
260 }
261 }
262
263 fetch_and_register_models(cp, &api, &api_key);
264 }
265}
266
267fn fetch_and_register_models(
269 cp: &crate::store::settings::CustomProvider,
270 api: &str,
271 api_key: &Option<String>,
272) {
273 if let Some(key) = api_key {
274 match oxi_sdk::fetch_models_blocking(&cp.base_url, key.as_str()) {
275 Ok(model_ids) => {
276 let count = model_ids.len();
277 for model_id in &model_ids {
278 let api_type = match api {
279 "openai-responses" | "responses" => oxi_sdk::Api::OpenAiResponses,
280 _ => oxi_sdk::Api::OpenAiCompletions,
281 };
282 let model = oxi_sdk::Model {
283 id: model_id.clone(),
284 name: model_id.clone(),
285 api: api_type,
286 provider: cp.name.clone(),
287 base_url: cp.base_url.clone(),
288 reasoning: false,
289 input: vec![oxi_sdk::InputModality::Text],
290 cost: oxi_sdk::Cost::default(),
291 context_window: 128_000,
292 max_tokens: 8_192,
293 headers: Default::default(),
294 compat: None,
295 };
296 oxi_sdk::register_model(model);
297 }
298 tracing::info!(
299 "[oxi] auto-fetched {} models from '{}' ({})",
300 count,
301 cp.name,
302 cp.base_url
303 );
304 }
305 Err(e) => {
306 tracing::warn!(
307 "[oxi] warning: failed to resolve models for {}: {}",
308 cp.name,
309 e
310 );
311 }
312 }
313 }
314}
315
316fn register_builtin_tools(
324 tools: &oxi_agent::ToolRegistry,
325 cwd: &std::path::Path,
326 args: &CliArgs,
327 disabled_tools: &[String],
328) {
329 let builtin_registry = if let Some(ref tools_str) = args.tools {
330 let names: Vec<&str> = tools_str.split(',').map(|s| s.trim()).collect();
331 oxi_agent::ToolRegistry::with_selected_tools(cwd.to_path_buf(), &names)
332 } else {
333 oxi_agent::ToolRegistry::with_builtins_cwd(cwd.to_path_buf(), disabled_tools)
334 };
335 for name in builtin_registry.names() {
336 if let Some(tool) = builtin_registry.get(&name) {
337 tools.register_arc(tool);
338 }
339 }
340 if let Some(mgr) = builtin_registry.mcp_manager() {
343 tools.set_mcp_manager(mgr);
344 }
345}
346
347fn load_wasm_extensions(
349 app: &crate::App,
350 cwd: &std::path::Path,
351 tools: &oxi_agent::ToolRegistry,
352) -> Option<std::sync::Arc<crate::extensions::WasmExtensionManager>> {
353 if !app.settings().extensions_enabled {
354 return None;
355 }
356
357 let wasm_paths = crate::extensions::WasmExtensionManager::discover(cwd);
358 if wasm_paths.is_empty() {
359 return None;
360 }
361
362 let mut wasm_mgr = crate::extensions::WasmExtensionManager::new();
363 let (loaded, errors) = wasm_mgr.load_all(&wasm_paths);
364 for info in &loaded {
365 tracing::info!("WASM extension loaded: {} v{}", info.name, info.version);
366 }
367 for err in &errors {
368 tracing::warn!("WASM extension error: {}", err);
369 }
370
371 if wasm_mgr.is_empty() {
372 return None;
373 }
374
375 let mgr = std::sync::Arc::new(wasm_mgr);
376 for tool_def in mgr.all_tool_defs() {
377 let wasm_tool = crate::extensions::WasmTool::new(
378 mgr.clone(),
379 tool_def.name.clone(),
380 tool_def.description.clone(),
381 tool_def.schema.clone(),
382 );
383 tools.register(wasm_tool);
384 }
385 Some(mgr)
386}
387
388fn register_router_provider(settings: &Settings) {
390 let global_dir = dirs::config_dir().unwrap_or_default().join("oxi");
391 let project_dir = std::env::current_dir().unwrap_or_default();
392
393 let store_cfg = match crate::store::router_config::load_router_config(&global_dir, &project_dir)
394 {
395 Some(cfg) => cfg,
396 None => {
397 tracing::debug!("No router config found — router/auto will not appear in model list");
398 return;
399 }
400 };
401
402 oxi_sdk::register_model(oxi_sdk::Model::new(
404 "auto",
405 "Router (auto)".to_string(),
406 oxi_sdk::Api::AnthropicMessages,
407 "router",
408 "router://local",
409 ));
410
411 let mut ai_profiles = std::collections::HashMap::new();
413 for (name, sp) in store_cfg.profiles() {
414 fn parse_thinking(s: &Option<String>) -> Option<oxi_sdk::ThinkingLevel> {
415 s.as_ref().and_then(|s| match s.as_str() {
416 "off" => Some(oxi_sdk::ThinkingLevel::Off),
417 "minimal" => Some(oxi_sdk::ThinkingLevel::Minimal),
418 "low" => Some(oxi_sdk::ThinkingLevel::Low),
419 "medium" => Some(oxi_sdk::ThinkingLevel::Medium),
420 "high" => Some(oxi_sdk::ThinkingLevel::High),
421 "xhigh" => Some(oxi_sdk::ThinkingLevel::XHigh),
422 _ => None,
423 })
424 }
425 ai_profiles.insert(
426 name.clone(),
427 oxi_sdk::router::RouterProfile {
428 high: oxi_sdk::router::RoutedTierConfig {
429 model: sp.high.model.clone(),
430 thinking: parse_thinking(&sp.high.thinking),
431 fallbacks: sp.high.fallbacks.clone(),
432 },
433 medium: oxi_sdk::router::RoutedTierConfig {
434 model: sp.medium.model.clone(),
435 thinking: parse_thinking(&sp.medium.thinking),
436 fallbacks: sp.medium.fallbacks.clone(),
437 },
438 low: oxi_sdk::router::RoutedTierConfig {
439 model: sp.low.model.clone(),
440 thinking: parse_thinking(&sp.low.thinking),
441 fallbacks: sp.low.fallbacks.clone(),
442 },
443 },
444 );
445 }
446 let ai_cfg = oxi_sdk::router::RouterConfig::with_pinning(
447 store_cfg.default_profile().to_string(),
448 store_cfg.classifier_model().map(String::from),
449 store_cfg.context_upgrade_threshold(),
450 store_cfg.max_session_budget(),
451 ai_profiles,
452 oxi_sdk::router::ScoringWeights {
453 structural: store_cfg.weights().structural,
454 behavioral: store_cfg.weights().behavioral,
455 context_budget: store_cfg.weights().context_budget,
456 vision: store_cfg.weights().vision,
457 message: store_cfg.weights().message,
458 },
459 store_cfg.pin_tier().and_then(|s| match s {
460 "high" => Some(oxi_sdk::router::RouterTier::High),
461 "medium" => Some(oxi_sdk::router::RouterTier::Medium),
462 "low" => Some(oxi_sdk::router::RouterTier::Low),
463 _ => None,
464 }),
465 store_cfg.phase_bias(),
466 );
467
468 oxi_sdk::router::register_router(&ai_cfg);
469
470 if let Some(profile) = settings.router_profile() {
471 tracing::info!("Router active with profile: {profile}");
472 }
473}
474
475fn is_tui_mode(args: &CliArgs) -> bool {
479 if args.mode.as_deref() == Some("json") || args.print {
480 return false;
481 }
482 if !args.interactive && !args.prompt.is_empty() {
485 return false;
486 }
487 true
488}