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 tools = app.agent_tools();
102 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
103 register_builtin_tools(&tools, &cwd, args, &app.settings().disabled_tools);
104
105 let wasm_ext = load_wasm_extensions(&app, &cwd, &tools);
107 app.set_wasm_ext(wasm_ext);
108
109 if let Some(ref prompt_path) = args.append_system_prompt {
111 let content = std::fs::read_to_string(prompt_path)
112 .map_err(|e| anyhow::anyhow!("Failed to read system prompt file: {}", e))?;
113 app.agent().set_system_prompt(content);
114 }
115
116 Ok(app)
117}
118
119pub async fn dispatch_run_mode(args: &CliArgs, app: crate::App) -> Result<i32> {
121 let prompt = args.prompt.join(" ");
122
123 if args.mode.as_deref() == Some("json") || args.print {
124 let mode = if args.mode.as_deref() == Some("json") {
125 crate::print_mode::PrintMode::Json
126 } else {
127 crate::print_mode::PrintMode::Text
128 };
129 let options = crate::print_mode::PrintModeOptions {
130 mode,
131 initial_message: if prompt.is_empty() {
132 None
133 } else {
134 Some(prompt)
135 },
136 messages: vec![],
137 no_stdin: args.print,
138 no_session: args.print || args.no_session,
139 quiet: args.print,
140 timeout: args.timeout,
141 };
142 return crate::print_mode::run_print_mode(&app, options).await;
143 }
144
145 if prompt.is_empty() || args.interactive {
146 if args.continue_session {
147 crate::tui::run_tui_interactive_with_continue(app, true).await?;
148 } else {
149 crate::tui::run_tui_interactive(app).await?;
150 }
151 return Ok(0);
152 }
153
154 crate::main_dispatch::run_single_prompt(app, &prompt).await?;
155 Ok(0)
156}
157
158pub async fn run_with_args(args: CliArgs) -> Result<i32> {
160 let app = build_app(&args).await?;
161 dispatch_run_mode(&args, app).await
162}
163
164pub fn init_logging() {
171 let log_dir = dirs::cache_dir()
172 .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
173 .join("oxi");
174 let _ = std::fs::create_dir_all(&log_dir);
175 let log_path = log_dir.join("oxi.log");
176
177 let log_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "debug".to_string());
178 let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
179 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(&log_filter));
180
181 let log_file = std::fs::File::create(&log_path).expect("Failed to create log file");
182 let writer = std::sync::Mutex::new(log_file);
183
184 tracing_subscriber::fmt()
185 .with_env_filter(env_filter)
186 .with_writer(writer)
187 .with_target(true)
188 .with_thread_ids(true)
189 .with_ansi(false)
190 .init();
191
192 tracing::info!("Logging initialized, log file: {:?}", log_path);
193}
194
195fn register_custom_providers(settings: &Settings) {
197 let auth_storage = crate::store::auth_storage::shared_auth_storage();
198 for cp in &settings.custom_providers {
199 let api_key = auth_storage.get_api_key(&cp.name);
200 let api = cp.api.to_lowercase();
201
202 match api.as_str() {
203 "openai-completions" | "openai" => {
204 let provider =
205 oxi_ai::OpenAiProvider::with_base_url_and_key(&cp.base_url, api_key.clone());
206 oxi_sdk::register_provider(&cp.name, provider);
207 tracing::info!(
208 "Registered custom provider '{}' (openai-completions) -> {}",
209 cp.name,
210 cp.base_url
211 );
212 }
213 "openai-responses" | "responses" => {
214 let provider = oxi_sdk::OpenAiResponsesProvider::with_base_url_and_key(
215 &cp.base_url,
216 api_key.clone(),
217 );
218 oxi_sdk::register_provider(&cp.name, provider);
219 tracing::info!(
220 "Registered custom provider '{}' (openai-responses) -> {}",
221 cp.name,
222 cp.base_url
223 );
224 }
225 _ => {
226 tracing::warn!(
227 "Unknown API type '{}' for custom provider '{}'. Supported: openai-completions, openai-responses",
228 cp.api,
229 cp.name
230 );
231 }
232 }
233
234 fetch_and_register_models(cp, &api, &api_key);
235 }
236}
237
238fn fetch_and_register_models(
240 cp: &crate::store::settings::CustomProvider,
241 api: &str,
242 api_key: &Option<String>,
243) {
244 if let Some(key) = api_key {
245 match oxi_sdk::fetch_models_blocking(&cp.base_url, key.as_str()) {
246 Ok(model_ids) => {
247 let count = model_ids.len();
248 for model_id in &model_ids {
249 let api_type = match api {
250 "openai-responses" | "responses" => oxi_sdk::Api::OpenAiResponses,
251 _ => oxi_sdk::Api::OpenAiCompletions,
252 };
253 let model = oxi_sdk::Model {
254 id: model_id.clone(),
255 name: model_id.clone(),
256 api: api_type,
257 provider: cp.name.clone(),
258 base_url: cp.base_url.clone(),
259 reasoning: false,
260 input: vec![oxi_sdk::InputModality::Text],
261 cost: oxi_sdk::Cost::default(),
262 context_window: 128_000,
263 max_tokens: 8_192,
264 headers: Default::default(),
265 compat: None,
266 };
267 oxi_sdk::register_model(model);
268 }
269 tracing::info!(
270 "[oxi] auto-fetched {} models from '{}' ({})",
271 count,
272 cp.name,
273 cp.base_url
274 );
275 }
276 Err(e) => {
277 tracing::warn!(
278 "[oxi] warning: failed to resolve models for {}: {}",
279 cp.name,
280 e
281 );
282 }
283 }
284 }
285}
286
287fn register_builtin_tools(
295 tools: &oxi_agent::ToolRegistry,
296 cwd: &std::path::Path,
297 args: &CliArgs,
298 disabled_tools: &[String],
299) {
300 let builtin_registry = if let Some(ref tools_str) = args.tools {
301 let names: Vec<&str> = tools_str.split(',').map(|s| s.trim()).collect();
302 oxi_agent::ToolRegistry::with_selected_tools(cwd.to_path_buf(), &names)
303 } else {
304 oxi_agent::ToolRegistry::with_builtins_cwd(cwd.to_path_buf(), disabled_tools)
305 };
306 for name in builtin_registry.names() {
307 if let Some(tool) = builtin_registry.get(&name) {
308 tools.register_arc(tool);
309 }
310 }
311 if let Some(mgr) = builtin_registry.mcp_manager() {
314 tools.set_mcp_manager(mgr);
315 }
316}
317
318fn load_wasm_extensions(
320 app: &crate::App,
321 cwd: &std::path::Path,
322 tools: &oxi_agent::ToolRegistry,
323) -> Option<std::sync::Arc<crate::extensions::WasmExtensionManager>> {
324 if !app.settings().extensions_enabled {
325 return None;
326 }
327
328 let wasm_paths = crate::extensions::WasmExtensionManager::discover(cwd);
329 if wasm_paths.is_empty() {
330 return None;
331 }
332
333 let mut wasm_mgr = crate::extensions::WasmExtensionManager::new();
334 let (loaded, errors) = wasm_mgr.load_all(&wasm_paths);
335 for info in &loaded {
336 tracing::info!("WASM extension loaded: {} v{}", info.name, info.version);
337 }
338 for err in &errors {
339 tracing::warn!("WASM extension error: {}", err);
340 }
341
342 if wasm_mgr.is_empty() {
343 return None;
344 }
345
346 let mgr = std::sync::Arc::new(wasm_mgr);
347 for tool_def in mgr.all_tool_defs() {
348 let wasm_tool = crate::extensions::WasmTool::new(
349 mgr.clone(),
350 tool_def.name.clone(),
351 tool_def.description.clone(),
352 tool_def.schema.clone(),
353 );
354 tools.register(wasm_tool);
355 }
356 Some(mgr)
357}
358
359fn register_router_provider(settings: &Settings) {
361 let global_dir = dirs::config_dir().unwrap_or_default().join("oxi");
362 let project_dir = std::env::current_dir().unwrap_or_default();
363
364 let store_cfg = match crate::store::router_config::load_router_config(&global_dir, &project_dir)
365 {
366 Some(cfg) => cfg,
367 None => {
368 tracing::debug!("No router config found — router/auto will not appear in model list");
369 return;
370 }
371 };
372
373 oxi_sdk::register_model(oxi_sdk::Model::new(
375 "auto",
376 "Router (auto)".to_string(),
377 oxi_sdk::Api::AnthropicMessages,
378 "router",
379 "router://local",
380 ));
381
382 let mut ai_profiles = std::collections::HashMap::new();
384 for (name, sp) in store_cfg.profiles() {
385 fn parse_thinking(s: &Option<String>) -> Option<oxi_sdk::ThinkingLevel> {
386 s.as_ref().and_then(|s| match s.as_str() {
387 "off" => Some(oxi_sdk::ThinkingLevel::Off),
388 "minimal" => Some(oxi_sdk::ThinkingLevel::Minimal),
389 "low" => Some(oxi_sdk::ThinkingLevel::Low),
390 "medium" => Some(oxi_sdk::ThinkingLevel::Medium),
391 "high" => Some(oxi_sdk::ThinkingLevel::High),
392 "xhigh" => Some(oxi_sdk::ThinkingLevel::XHigh),
393 _ => None,
394 })
395 }
396 ai_profiles.insert(
397 name.clone(),
398 oxi_sdk::router::RouterProfile {
399 high: oxi_sdk::router::RoutedTierConfig {
400 model: sp.high.model.clone(),
401 thinking: parse_thinking(&sp.high.thinking),
402 fallbacks: sp.high.fallbacks.clone(),
403 },
404 medium: oxi_sdk::router::RoutedTierConfig {
405 model: sp.medium.model.clone(),
406 thinking: parse_thinking(&sp.medium.thinking),
407 fallbacks: sp.medium.fallbacks.clone(),
408 },
409 low: oxi_sdk::router::RoutedTierConfig {
410 model: sp.low.model.clone(),
411 thinking: parse_thinking(&sp.low.thinking),
412 fallbacks: sp.low.fallbacks.clone(),
413 },
414 },
415 );
416 }
417 let ai_cfg = oxi_sdk::router::RouterConfig::with_pinning(
418 store_cfg.default_profile().to_string(),
419 store_cfg.classifier_model().map(String::from),
420 store_cfg.context_upgrade_threshold(),
421 store_cfg.max_session_budget(),
422 ai_profiles,
423 oxi_sdk::router::ScoringWeights {
424 structural: store_cfg.weights().structural,
425 behavioral: store_cfg.weights().behavioral,
426 context_budget: store_cfg.weights().context_budget,
427 vision: store_cfg.weights().vision,
428 message: store_cfg.weights().message,
429 },
430 store_cfg.pin_tier().and_then(|s| match s {
431 "high" => Some(oxi_sdk::router::RouterTier::High),
432 "medium" => Some(oxi_sdk::router::RouterTier::Medium),
433 "low" => Some(oxi_sdk::router::RouterTier::Low),
434 _ => None,
435 }),
436 store_cfg.phase_bias(),
437 );
438
439 oxi_sdk::router::register_router(&ai_cfg);
440
441 if let Some(profile) = settings.router_profile() {
442 tracing::info!("Router active with profile: {profile}");
443 }
444}
445
446fn is_tui_mode(args: &CliArgs) -> bool {
450 if args.mode.as_deref() == Some("json") || args.print {
451 return false;
452 }
453 if !args.interactive && !args.prompt.is_empty() {
456 return false;
457 }
458 true
459}