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 / Catalog Port (v3): the `FileModelCatalog` wired in
20    // `services::build_oxi` performs its own init at `OxiBuilder::build`
21    // time — it loads the embedded SNAP, applies overrides, and attempts
22    // one refresh if the cache is stale. So we no longer call the legacy
23    // `init_models_dev()` here. To skip network access during boot, set
24    // `OXI_MODELS_DEV_DISABLE_FETCH=1`.
25
26    // Load settings (global + project + env layers).
27    let mut settings = Settings::load().unwrap_or_default();
28
29    // Apply CLI overrides.
30    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 OpenAI-compatible providers from settings.
56    register_custom_providers(&settings);
57
58    // Register model router (opt-in).
59    register_router_provider(&settings);
60
61    // Apply thinking level if specified.
62    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    // Build the wired Oxi engine + Agent via the SDK composition root.
74    let oxi = crate::build_oxi_engine().await?;
75
76    // Per-process liveness identity for issue-system ownership. In TUI mode
77    // we use the canonical "tui" id so the agent tool, the TUI panel, and
78    // the `/issue` slash command all share the same flock holder. In any
79    // non-TUI mode (print, RPC, single-prompt) we generate a stable
80    // process-scoped id; that way concurrent ownership checks see this
81    // process as a single coherent owner rather than an empty caller.
82    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    // Spawn the catalog event logger so refresh / override / local-discovery
93    // events show up in the log file. UI hooks can subscribe to
94    // `oxi.catalog().subscribe()` separately for picker invalidation.
95    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    // Register built-in tools on the agent's tool registry.
101    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    // Discover and load WASM extensions.
106    let wasm_ext = load_wasm_extensions(&app, &cwd, &tools);
107    app.set_wasm_ext(wasm_ext);
108
109    // Handle --append-system-prompt.
110    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
119/// Dispatch the run mode: TUI / print / RPC, based on the CLI flags.
120pub 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
158/// Parse args, build the app, dispatch.
159pub 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
164// ─── Helpers (moved verbatim from main.rs) ─────────────────────────────
165
166/// Initialize file-based logging to `~/.cache/oxi/oxi.log`.
167///
168/// Reads `RUST_LOG` for filter (default: `debug`). Builds a
169/// `tracing_subscriber::EnvFilter` and writes to a `Mutex<File>` writer.
170pub 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
195/// Register custom OpenAI-compatible providers from settings and auto-fetch their models.
196fn 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
238/// Fetch models from a custom provider's /v1/models endpoint and register them.
239fn 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
287/// Register builtin tools with the agent, respecting --tools filter and disabled_tools.
288///
289/// Also transfers the [`McpManager`](oxi_agent::mcp::McpManager) reference from
290/// the built-in registry to the live agent registry. This matters because
291/// `register_arc` only copies the `Arc<dyn AgentTool>` — the manager field is
292/// stored separately and would otherwise be `None`, making `/mcp` show a
293/// "MCP is not configured" warning even though the `McpTool` is registered.
294fn 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    // Propagate the MCP manager so the TUI's `/mcp` overlay can hot-reload
312    // configs, render live connection status, and so on.
313    if let Some(mgr) = builtin_registry.mcp_manager() {
314        tools.set_mcp_manager(mgr);
315    }
316}
317
318/// Discover and load WASM extensions, registering their tools.
319fn 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
359/// Register the model auto-router if configured in settings.
360fn 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    // Register router models only when configured.
374    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    // Convert store config to AI config.
383    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
446/// Decide whether this run is the TUI (interactive) mode. Mirrors the
447/// dispatch in [`dispatch_run_mode`]: print / RPC / single-prompt are
448/// non-TUI. Used by [`build_app`] to pick the canonical liveness identity.
449fn is_tui_mode(args: &CliArgs) -> bool {
450    if args.mode.as_deref() == Some("json") || args.print {
451        return false;
452    }
453    // prompt-only (no `--interactive` and non-empty prompt) is non-TUI too;
454    // dispatch_run_mode sends it through main_dispatch::run_single_prompt.
455    if !args.interactive && !args.prompt.is_empty() {
456        return false;
457    }
458    true
459}