Skip to main content

oxi/
bootstrap.rs

1//! Application bootstrap and run-mode dispatch.
2//!
3//! Owns: log init, app building (settings → custom providers → router →
4//! tools → WASM), and run-mode dispatch (TUI / print / RPC).
5//!
6//! The helper functions below are moved verbatim from main.rs and
7//! retain their original signatures.
8
9use crate::cli::CliArgs;
10use crate::print_mode;
11use crate::store::settings::Settings;
12use anyhow::Result;
13use std::path::PathBuf;
14use tracing;
15
16/// Build a wired `App` from CLI args. All the wiring that used to be
17/// inline in `main()` lives here.
18pub async fn build_app(args: &CliArgs) -> Result<crate::App> {
19    // Layer 2.5: prime the models.dev live catalog so that the first model
20    // lookup (and every subsequent one) sees enriched pricing / limits /
21    // reasoning flags. Near-instant on a cache hit; bounded to ~10s on a
22    // cache miss. Falls back to Layer 1 silently if offline.
23    //
24    // Runs before settings load so any catalog-driven default selection
25    // also benefits. Safe to skip in tests via `OXI_MODELS_DEV=off`.
26    oxi_ai::catalog::models_dev::init_models_dev().await;
27
28    // Load settings (global + project + env layers).
29    let mut settings = Settings::load().unwrap_or_default();
30
31    // Apply CLI overrides.
32    settings.merge_cli(
33        args.model.clone(),
34        args.provider.clone(),
35        Some(args.enable_routing),
36        Some(args.prefer_cost_efficient),
37        if args.fallback_chain.is_empty() {
38            None
39        } else {
40            Some(args.fallback_chain.clone())
41        },
42        Some(args.disable_fallback),
43    );
44
45    if settings
46        .effective_model(None)
47        .unwrap_or_default()
48        .is_empty()
49    {
50        eprintln!(
51            "{}",
52            print_mode::format_error("No model configured. Run `oxi setup` to configure.")
53        );
54        std::process::exit(1);
55    }
56
57    // Register custom OpenAI-compatible providers from settings.
58    register_custom_providers(&settings);
59
60    // Register model router (opt-in).
61    register_router_provider(&settings);
62
63    // Apply thinking level if specified.
64    if let Some(ref level_str) = args.thinking {
65        if let Some(level) = crate::store::settings::parse_thinking_level(level_str) {
66            settings.thinking_level = level;
67        } else {
68            anyhow::bail!(
69                "Invalid thinking level: {}. Valid options: off, minimal, low, medium, high, xhigh",
70                level_str
71            );
72        }
73    }
74
75    // Build the wired Oxi engine + Agent via the SDK composition root.
76    let oxi = crate::build_oxi_engine()?;
77    let mut app = crate::App::from_oxi(oxi, settings).await?;
78
79    // Register built-in tools on the agent's tool registry.
80    let tools = app.agent_tools();
81    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
82    register_builtin_tools(&tools, &cwd, args, &app.settings().disabled_tools);
83
84    // Discover and load WASM extensions.
85    let wasm_ext = load_wasm_extensions(&app, &cwd, &tools);
86    app.set_wasm_ext(wasm_ext);
87
88    // Handle --append-system-prompt.
89    if let Some(ref prompt_path) = args.append_system_prompt {
90        let content = std::fs::read_to_string(prompt_path)
91            .map_err(|e| anyhow::anyhow!("Failed to read system prompt file: {}", e))?;
92        app.agent().set_system_prompt(content);
93    }
94
95    Ok(app)
96}
97
98/// Dispatch the run mode: TUI / print / RPC, based on the CLI flags.
99pub async fn dispatch_run_mode(args: &CliArgs, app: crate::App) -> Result<i32> {
100    let prompt = args.prompt.join(" ");
101
102    if args.mode.as_deref() == Some("json") || args.print {
103        let mode = if args.mode.as_deref() == Some("json") {
104            crate::print_mode::PrintMode::Json
105        } else {
106            crate::print_mode::PrintMode::Text
107        };
108        let options = crate::print_mode::PrintModeOptions {
109            mode,
110            initial_message: if prompt.is_empty() {
111                None
112            } else {
113                Some(prompt)
114            },
115            messages: vec![],
116            no_stdin: args.print,
117            no_session: args.print || args.no_session,
118            quiet: args.print,
119            timeout: args.timeout,
120        };
121        return crate::print_mode::run_print_mode(&app, options).await;
122    }
123
124    if prompt.is_empty() || args.interactive {
125        if args.continue_session {
126            crate::tui::run_tui_interactive_with_continue(app, true).await?;
127        } else {
128            crate::tui::run_tui_interactive(app).await?;
129        }
130        return Ok(0);
131    }
132
133    crate::main_dispatch::run_single_prompt(app, &prompt).await?;
134    Ok(0)
135}
136
137/// Parse args, build the app, dispatch.
138pub async fn run_with_args(args: CliArgs) -> Result<i32> {
139    let app = build_app(&args).await?;
140    dispatch_run_mode(&args, app).await
141}
142
143// ─── Helpers (moved verbatim from main.rs) ─────────────────────────────
144
145/// Initialize file-based logging to `~/.cache/oxi/oxi.log`.
146///
147/// Reads `RUST_LOG` for filter (default: `debug`). Builds a
148/// `tracing_subscriber::EnvFilter` and writes to a `Mutex<File>` writer.
149pub fn init_logging() {
150    let log_dir = dirs::cache_dir()
151        .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
152        .join("oxi");
153    let _ = std::fs::create_dir_all(&log_dir);
154    let log_path = log_dir.join("oxi.log");
155
156    let log_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "debug".to_string());
157    let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
158        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(&log_filter));
159
160    let log_file = std::fs::File::create(&log_path).expect("Failed to create log file");
161    let writer = std::sync::Mutex::new(log_file);
162
163    tracing_subscriber::fmt()
164        .with_env_filter(env_filter)
165        .with_writer(writer)
166        .with_target(true)
167        .with_thread_ids(true)
168        .with_ansi(false)
169        .init();
170
171    tracing::info!("Logging initialized, log file: {:?}", log_path);
172}
173
174/// Register custom OpenAI-compatible providers from settings and auto-fetch their models.
175fn register_custom_providers(settings: &Settings) {
176    let auth_storage = crate::store::auth_storage::shared_auth_storage();
177    for cp in &settings.custom_providers {
178        let api_key = auth_storage.get_api_key(&cp.name);
179        let api = cp.api.to_lowercase();
180
181        match api.as_str() {
182            "openai-completions" | "openai" => {
183                let provider =
184                    oxi_ai::OpenAiProvider::with_base_url_and_key(&cp.base_url, api_key.clone());
185                oxi_sdk::register_provider(&cp.name, provider);
186                tracing::info!(
187                    "Registered custom provider '{}' (openai-completions) -> {}",
188                    cp.name,
189                    cp.base_url
190                );
191            }
192            "openai-responses" | "responses" => {
193                let provider = oxi_sdk::OpenAiResponsesProvider::with_base_url_and_key(
194                    &cp.base_url,
195                    api_key.clone(),
196                );
197                oxi_sdk::register_provider(&cp.name, provider);
198                tracing::info!(
199                    "Registered custom provider '{}' (openai-responses) -> {}",
200                    cp.name,
201                    cp.base_url
202                );
203            }
204            _ => {
205                tracing::warn!(
206                    "Unknown API type '{}' for custom provider '{}'. Supported: openai-completions, openai-responses",
207                    cp.api,
208                    cp.name
209                );
210            }
211        }
212
213        fetch_and_register_models(cp, &api, &api_key);
214    }
215}
216
217/// Fetch models from a custom provider's /v1/models endpoint and register them.
218fn fetch_and_register_models(
219    cp: &crate::store::settings::CustomProvider,
220    api: &str,
221    api_key: &Option<String>,
222) {
223    if let Some(key) = api_key {
224        match oxi_sdk::fetch_models_blocking(&cp.base_url, key.as_str()) {
225            Ok(model_ids) => {
226                let count = model_ids.len();
227                for model_id in &model_ids {
228                    let api_type = match api {
229                        "openai-responses" | "responses" => oxi_sdk::Api::OpenAiResponses,
230                        _ => oxi_sdk::Api::OpenAiCompletions,
231                    };
232                    let model = oxi_sdk::Model {
233                        id: model_id.clone(),
234                        name: model_id.clone(),
235                        api: api_type,
236                        provider: cp.name.clone(),
237                        base_url: cp.base_url.clone(),
238                        reasoning: false,
239                        input: vec![oxi_sdk::InputModality::Text],
240                        cost: oxi_sdk::Cost::default(),
241                        context_window: 128_000,
242                        max_tokens: 8_192,
243                        headers: Default::default(),
244                        compat: None,
245                    };
246                    oxi_sdk::register_model(model);
247                }
248                tracing::info!(
249                    "[oxi] auto-fetched {} models from '{}' ({})",
250                    count,
251                    cp.name,
252                    cp.base_url
253                );
254            }
255            Err(e) => {
256                tracing::warn!(
257                    "[oxi] warning: failed to resolve models for {}: {}",
258                    cp.name,
259                    e
260                );
261            }
262        }
263    }
264}
265
266/// Register builtin tools with the agent, respecting --tools filter and disabled_tools.
267///
268/// Also transfers the [`McpManager`](oxi_agent::mcp::McpManager) reference from
269/// the built-in registry to the live agent registry. This matters because
270/// `register_arc` only copies the `Arc<dyn AgentTool>` — the manager field is
271/// stored separately and would otherwise be `None`, making `/mcp` show a
272/// "MCP is not configured" warning even though the `McpTool` is registered.
273fn register_builtin_tools(
274    tools: &oxi_agent::ToolRegistry,
275    cwd: &std::path::Path,
276    args: &CliArgs,
277    disabled_tools: &[String],
278) {
279    let builtin_registry = if let Some(ref tools_str) = args.tools {
280        let names: Vec<&str> = tools_str.split(',').map(|s| s.trim()).collect();
281        oxi_agent::ToolRegistry::with_selected_tools(cwd.to_path_buf(), &names)
282    } else {
283        oxi_agent::ToolRegistry::with_builtins_cwd(cwd.to_path_buf(), disabled_tools)
284    };
285    for name in builtin_registry.names() {
286        if let Some(tool) = builtin_registry.get(&name) {
287            tools.register_arc(tool);
288        }
289    }
290    // Propagate the MCP manager so the TUI's `/mcp` overlay can hot-reload
291    // configs, render live connection status, and so on.
292    if let Some(mgr) = builtin_registry.mcp_manager() {
293        tools.set_mcp_manager(mgr);
294    }
295}
296
297/// Discover and load WASM extensions, registering their tools.
298fn load_wasm_extensions(
299    app: &crate::App,
300    cwd: &std::path::Path,
301    tools: &oxi_agent::ToolRegistry,
302) -> Option<std::sync::Arc<crate::extensions::WasmExtensionManager>> {
303    if !app.settings().extensions_enabled {
304        return None;
305    }
306
307    let wasm_paths = crate::extensions::WasmExtensionManager::discover(cwd);
308    if wasm_paths.is_empty() {
309        return None;
310    }
311
312    let mut wasm_mgr = crate::extensions::WasmExtensionManager::new();
313    let (loaded, errors) = wasm_mgr.load_all(&wasm_paths);
314    for info in &loaded {
315        tracing::info!("WASM extension loaded: {} v{}", info.name, info.version);
316    }
317    for err in &errors {
318        tracing::warn!("WASM extension error: {}", err);
319    }
320
321    if wasm_mgr.is_empty() {
322        return None;
323    }
324
325    let mgr = std::sync::Arc::new(wasm_mgr);
326    for tool_def in mgr.all_tool_defs() {
327        let wasm_tool = crate::extensions::WasmTool::new(
328            mgr.clone(),
329            tool_def.name.clone(),
330            tool_def.description.clone(),
331            tool_def.schema.clone(),
332        );
333        tools.register(wasm_tool);
334    }
335    Some(mgr)
336}
337
338/// Register the model auto-router if configured in settings.
339fn register_router_provider(settings: &Settings) {
340    let global_dir = dirs::config_dir().unwrap_or_default().join("oxi");
341    let project_dir = std::env::current_dir().unwrap_or_default();
342
343    let store_cfg = match crate::store::router_config::load_router_config(&global_dir, &project_dir)
344    {
345        Some(cfg) => cfg,
346        None => {
347            tracing::debug!("No router config found — router/auto will not appear in model list");
348            return;
349        }
350    };
351
352    // Register router models only when configured.
353    oxi_sdk::register_model(oxi_sdk::Model::new(
354        "auto",
355        "Router (auto)".to_string(),
356        oxi_sdk::Api::AnthropicMessages,
357        "router",
358        "router://local",
359    ));
360
361    // Convert store config to AI config.
362    let mut ai_profiles = std::collections::HashMap::new();
363    for (name, sp) in store_cfg.profiles() {
364        fn parse_thinking(s: &Option<String>) -> Option<oxi_sdk::ThinkingLevel> {
365            s.as_ref().and_then(|s| match s.as_str() {
366                "off" => Some(oxi_sdk::ThinkingLevel::Off),
367                "minimal" => Some(oxi_sdk::ThinkingLevel::Minimal),
368                "low" => Some(oxi_sdk::ThinkingLevel::Low),
369                "medium" => Some(oxi_sdk::ThinkingLevel::Medium),
370                "high" => Some(oxi_sdk::ThinkingLevel::High),
371                "xhigh" => Some(oxi_sdk::ThinkingLevel::XHigh),
372                _ => None,
373            })
374        }
375        ai_profiles.insert(
376            name.clone(),
377            oxi_sdk::router::RouterProfile {
378                high: oxi_sdk::router::RoutedTierConfig {
379                    model: sp.high.model.clone(),
380                    thinking: parse_thinking(&sp.high.thinking),
381                    fallbacks: sp.high.fallbacks.clone(),
382                },
383                medium: oxi_sdk::router::RoutedTierConfig {
384                    model: sp.medium.model.clone(),
385                    thinking: parse_thinking(&sp.medium.thinking),
386                    fallbacks: sp.medium.fallbacks.clone(),
387                },
388                low: oxi_sdk::router::RoutedTierConfig {
389                    model: sp.low.model.clone(),
390                    thinking: parse_thinking(&sp.low.thinking),
391                    fallbacks: sp.low.fallbacks.clone(),
392                },
393            },
394        );
395    }
396    let ai_cfg = oxi_sdk::router::RouterConfig::with_pinning(
397        store_cfg.default_profile().to_string(),
398        store_cfg.classifier_model().map(String::from),
399        store_cfg.context_upgrade_threshold(),
400        store_cfg.max_session_budget(),
401        ai_profiles,
402        oxi_sdk::router::ScoringWeights {
403            structural: store_cfg.weights().structural,
404            behavioral: store_cfg.weights().behavioral,
405            context_budget: store_cfg.weights().context_budget,
406            vision: store_cfg.weights().vision,
407            message: store_cfg.weights().message,
408        },
409        store_cfg.pin_tier().and_then(|s| match s {
410            "high" => Some(oxi_sdk::router::RouterTier::High),
411            "medium" => Some(oxi_sdk::router::RouterTier::Medium),
412            "low" => Some(oxi_sdk::router::RouterTier::Low),
413            _ => None,
414        }),
415        store_cfg.phase_bias(),
416    );
417
418    oxi_sdk::router::register_router(&ai_cfg);
419
420    if let Some(profile) = settings.router_profile() {
421        tracing::info!("Router active with profile: {profile}");
422    }
423}