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. Centralized in a closure so the post-wizard reload
30    // re-applies the exact same overrides — adding a new flag can't silently
31    // diverge between the two call sites.
32    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        // No model configured. In interactive (TUI) mode, drop the user
54        // straight into the setup wizard instead of erroring out — this is
55        // the common first-run experience. In non-interactive modes
56        // (print / JSON / RPC / single-prompt) the caller explicitly wants a
57        // one-shot run, so a hard error with guidance is correct.
58        if is_tui_mode(args) {
59            eprintln!("No model configured. Launching setup wizard...");
60            crate::setup_wizard::run().await?;
61
62            // Reload settings the wizard just persisted and re-apply the
63            // CLI overrides, then re-check. If the user bailed out of the
64            // wizard without selecting a model, fall through to the error.
65            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 OpenAI-compatible providers from settings.
83    register_custom_providers(&settings);
84
85    // Register model router (opt-in).
86    register_router_provider(&settings);
87
88    // Apply thinking level if specified.
89    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    // Build the wired Oxi engine + Agent via the SDK composition root.
101    let oxi = crate::build_oxi_engine().await?;
102
103    // Per-process liveness identity for issue-system ownership. In TUI mode
104    // we use the canonical "tui" id so the agent tool, the TUI panel, and
105    // the `/issue` slash command all share the same flock holder. In any
106    // non-TUI mode (print, RPC, single-prompt) we generate a stable
107    // process-scoped id; that way concurrent ownership checks see this
108    // process as a single coherent owner rather than an empty caller.
109    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    // Spawn the catalog event logger so refresh / override / local-discovery
120    // events show up in the log file. UI hooks can subscribe to
121    // `oxi.catalog().subscribe()` separately for picker invalidation.
122    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    // v2.2: wire the MCP credential provider (OAuth2 client_credentials).
128    // Reads the same `mcp.json` files the agent uses, picks every server
129    // with an `oauth` block, and gives the manager a provider that can
130    // obtain + refresh access tokens on demand. No-op when no server
131    // declares `oauth`.
132    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    // Register built-in tools on the agent's tool registry.
157    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    // Native headless browser (opt-in via the `native-browser` cargo feature).
168    // Constructs the pure-Rust `oxibrowser-core` engine and registers the
169    // browse tools (incl. `browse_session` with `observe`/`wait` actions) so
170    // the agent can navigate/observe/extract — omp-parity browsing without a
171    // Chrome dependency.
172    #[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    // Discover and load WASM extensions.
187    let wasm_ext = load_wasm_extensions(&app, &cwd, &tools);
188    app.set_wasm_ext(wasm_ext);
189
190    // Handle --append-system-prompt.
191    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    Ok(app)
198}
199
200/// Dispatch the run mode: TUI / print / RPC, based on the CLI flags.
201pub async fn dispatch_run_mode(args: &CliArgs, app: crate::App) -> Result<i32> {
202    let prompt = args.prompt.join(" ");
203
204    if args.mode.as_deref() == Some("json") || args.print {
205        let mode = if args.mode.as_deref() == Some("json") {
206            crate::print_mode::PrintMode::Json
207        } else {
208            crate::print_mode::PrintMode::Text
209        };
210        let options = crate::print_mode::PrintModeOptions {
211            mode,
212            initial_message: if prompt.is_empty() {
213                None
214            } else {
215                Some(prompt)
216            },
217            messages: vec![],
218            no_stdin: args.print,
219            no_session: args.print || args.no_session,
220            quiet: args.print,
221            timeout: args.timeout,
222        };
223        return crate::print_mode::run_print_mode(&app, options).await;
224    }
225
226    if prompt.is_empty() || args.interactive {
227        if args.continue_session {
228            crate::tui::run_tui_interactive_with_continue(app, true).await?;
229        } else {
230            crate::tui::run_tui_interactive(app).await?;
231        }
232        return Ok(0);
233    }
234
235    crate::main_dispatch::run_single_prompt(app, &prompt).await?;
236    Ok(0)
237}
238
239/// Parse args, build the app, dispatch.
240pub async fn run_with_args(args: CliArgs) -> Result<i32> {
241    let app = build_app(&args).await?;
242    dispatch_run_mode(&args, app).await
243}
244
245// ─── Helpers (moved verbatim from main.rs) ─────────────────────────────
246
247/// Initialize file-based logging to `~/.cache/oxi/oxi.log`.
248///
249/// Reads `RUST_LOG` for filter (default: `debug`). Builds a
250/// `tracing_subscriber::EnvFilter` and writes to a `Mutex<File>` writer.
251pub fn init_logging() {
252    let log_dir = dirs::cache_dir()
253        .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
254        .join("oxi");
255    let _ = std::fs::create_dir_all(&log_dir);
256    let log_path = log_dir.join("oxi.log");
257
258    let log_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "debug".to_string());
259    let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
260        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(&log_filter));
261
262    let log_file = std::fs::File::create(&log_path).expect("Failed to create log file");
263    let writer = std::sync::Mutex::new(log_file);
264
265    tracing_subscriber::fmt()
266        .with_env_filter(env_filter)
267        .with_writer(writer)
268        .with_target(true)
269        .with_thread_ids(true)
270        .with_ansi(false)
271        .init();
272
273    tracing::info!("Logging initialized, log file: {:?}", log_path);
274}
275
276/// Register custom OpenAI-compatible providers from settings and auto-fetch their models.
277fn register_custom_providers(settings: &Settings) {
278    let auth_storage = crate::store::auth_storage::shared_auth_storage();
279    for cp in &settings.custom_providers {
280        let api_key = auth_storage.get_api_key(&cp.name);
281        let api = cp.api.to_lowercase();
282
283        match api.as_str() {
284            "openai-completions" | "openai" => {
285                let provider =
286                    oxi_ai::OpenAiProvider::with_base_url_and_key(&cp.base_url, api_key.clone());
287                oxi_sdk::register_provider(&cp.name, provider);
288                tracing::info!(
289                    "Registered custom provider '{}' (openai-completions) -> {}",
290                    cp.name,
291                    cp.base_url
292                );
293            }
294            "openai-responses" | "responses" => {
295                let provider = oxi_sdk::OpenAiResponsesProvider::with_base_url_and_key(
296                    &cp.base_url,
297                    api_key.clone(),
298                );
299                oxi_sdk::register_provider(&cp.name, provider);
300                tracing::info!(
301                    "Registered custom provider '{}' (openai-responses) -> {}",
302                    cp.name,
303                    cp.base_url
304                );
305            }
306            _ => {
307                tracing::warn!(
308                    "Unknown API type '{}' for custom provider '{}'. Supported: openai-completions, openai-responses",
309                    cp.api,
310                    cp.name
311                );
312            }
313        }
314
315        fetch_and_register_models(cp, &api, &api_key);
316    }
317}
318
319/// Fetch models from a custom provider's /v1/models endpoint and register them.
320fn fetch_and_register_models(
321    cp: &crate::store::settings::CustomProvider,
322    api: &str,
323    api_key: &Option<String>,
324) {
325    if let Some(key) = api_key {
326        match oxi_sdk::fetch_models_blocking(&cp.base_url, key.as_str()) {
327            Ok(model_ids) => {
328                let count = model_ids.len();
329                for model_id in &model_ids {
330                    let api_type = match api {
331                        "openai-responses" | "responses" => oxi_sdk::Api::OpenAiResponses,
332                        _ => oxi_sdk::Api::OpenAiCompletions,
333                    };
334                    let model = oxi_sdk::Model {
335                        id: model_id.clone(),
336                        name: model_id.clone(),
337                        api: api_type,
338                        provider: cp.name.clone(),
339                        base_url: cp.base_url.clone(),
340                        reasoning: false,
341                        input: vec![oxi_sdk::InputModality::Text],
342                        cost: oxi_sdk::Cost::default(),
343                        context_window: 128_000,
344                        max_tokens: 8_192,
345                        headers: Default::default(),
346                        compat: None,
347                    };
348                    oxi_sdk::register_model(model);
349                }
350                tracing::info!(
351                    "[oxi] auto-fetched {} models from '{}' ({})",
352                    count,
353                    cp.name,
354                    cp.base_url
355                );
356            }
357            Err(e) => {
358                tracing::warn!(
359                    "[oxi] warning: failed to resolve models for {}: {}",
360                    cp.name,
361                    e
362                );
363            }
364        }
365    }
366}
367
368/// Register builtin tools with the agent, respecting --tools filter and disabled_tools.
369///
370/// Also transfers the [`McpManager`](oxi_agent::mcp::McpManager) reference from
371/// the built-in registry to the live agent registry. This matters because
372/// `register_arc` only copies the `Arc<dyn AgentTool>` — the manager field is
373/// stored separately and would otherwise be `None`, making `/mcp` show a
374/// "MCP is not configured" warning even though the `McpTool` is registered.
375fn register_builtin_tools(
376    tools: &oxi_agent::ToolRegistry,
377    cwd: &std::path::Path,
378    args: &CliArgs,
379    disabled_tools: &[String],
380    model_roles: &std::collections::HashMap<String, String>,
381) {
382    let builtin_registry = if let Some(ref tools_str) = args.tools {
383        let names: Vec<&str> = tools_str.split(',').map(|s| s.trim()).collect();
384        oxi_agent::ToolRegistry::with_selected_tools(cwd.to_path_buf(), &names)
385    } else {
386        oxi_agent::ToolRegistry::with_builtins_cwd(cwd.to_path_buf(), disabled_tools)
387    };
388    for name in builtin_registry.names() {
389        if let Some(tool) = builtin_registry.get(&name) {
390            tools.register_arc(tool);
391        }
392    }
393    // Propagate the MCP manager so the TUI's `/mcp` overlay can hot-reload
394    // configs, render live connection status, and so on.
395    if let Some(mgr) = builtin_registry.mcp_manager() {
396        tools.set_mcp_manager(mgr);
397    }
398
399    // Role-based commit model: if a `commit` role is configured, upgrade the
400    // deterministic (no-LLM) CommitTool to one backed by that model. Tools
401    // register by name, so this overwrites the unconfigured instance safely.
402    let role_registry = oxi_sdk::RoleRegistry::from_map(model_roles.clone());
403    if let Some(model) = oxi_sdk::resolve_role_to_model(oxi_sdk::ModelRole::Commit, &role_registry)
404    {
405        let commit: std::sync::Arc<dyn oxi_agent::AgentTool> =
406            std::sync::Arc::new(oxi_agent::CommitTool::new(model));
407        tools.register_arc(commit);
408        tracing::debug!("CommitTool upgraded to commit-role model");
409    }
410}
411
412/// Discover and load WASM extensions, registering their tools.
413fn load_wasm_extensions(
414    app: &crate::App,
415    cwd: &std::path::Path,
416    tools: &oxi_agent::ToolRegistry,
417) -> Option<std::sync::Arc<crate::extensions::WasmExtensionManager>> {
418    if !app.settings().extensions_enabled {
419        return None;
420    }
421
422    let wasm_paths = crate::extensions::WasmExtensionManager::discover(cwd);
423    if wasm_paths.is_empty() {
424        return None;
425    }
426
427    let mut wasm_mgr = crate::extensions::WasmExtensionManager::new();
428    let (loaded, errors) = wasm_mgr.load_all(&wasm_paths);
429    for info in &loaded {
430        tracing::info!("WASM extension loaded: {} v{}", info.name, info.version);
431    }
432    for err in &errors {
433        tracing::warn!("WASM extension error: {}", err);
434    }
435
436    if wasm_mgr.is_empty() {
437        return None;
438    }
439
440    let mgr = std::sync::Arc::new(wasm_mgr);
441    for tool_def in mgr.all_tool_defs() {
442        let wasm_tool = crate::extensions::WasmTool::new(
443            mgr.clone(),
444            tool_def.name.clone(),
445            tool_def.description.clone(),
446            tool_def.schema.clone(),
447        );
448        tools.register(wasm_tool);
449    }
450    Some(mgr)
451}
452
453/// Register the model auto-router if configured in settings.
454fn register_router_provider(settings: &Settings) {
455    let global_dir = dirs::config_dir().unwrap_or_default().join("oxi");
456    let project_dir = std::env::current_dir().unwrap_or_default();
457
458    let store_cfg = match crate::store::router_config::load_router_config(&global_dir, &project_dir)
459    {
460        Some(cfg) => cfg,
461        None => {
462            tracing::debug!("No router config found — router/auto will not appear in model list");
463            return;
464        }
465    };
466
467    // Register router models only when configured.
468    oxi_sdk::register_model(oxi_sdk::Model::new(
469        "auto",
470        "Router (auto)".to_string(),
471        oxi_sdk::Api::AnthropicMessages,
472        "router",
473        "router://local",
474    ));
475
476    // Convert store config to AI config.
477    let mut ai_profiles = std::collections::HashMap::new();
478    for (name, sp) in store_cfg.profiles() {
479        fn parse_thinking(s: &Option<String>) -> Option<oxi_sdk::ThinkingLevel> {
480            s.as_ref().and_then(|s| match s.as_str() {
481                "off" => Some(oxi_sdk::ThinkingLevel::Off),
482                "minimal" => Some(oxi_sdk::ThinkingLevel::Minimal),
483                "low" => Some(oxi_sdk::ThinkingLevel::Low),
484                "medium" => Some(oxi_sdk::ThinkingLevel::Medium),
485                "high" => Some(oxi_sdk::ThinkingLevel::High),
486                "xhigh" => Some(oxi_sdk::ThinkingLevel::XHigh),
487                _ => None,
488            })
489        }
490        ai_profiles.insert(
491            name.clone(),
492            oxi_sdk::router::RouterProfile {
493                high: oxi_sdk::router::RoutedTierConfig {
494                    model: sp.high.model.clone(),
495                    thinking: parse_thinking(&sp.high.thinking),
496                    fallbacks: sp.high.fallbacks.clone(),
497                },
498                medium: oxi_sdk::router::RoutedTierConfig {
499                    model: sp.medium.model.clone(),
500                    thinking: parse_thinking(&sp.medium.thinking),
501                    fallbacks: sp.medium.fallbacks.clone(),
502                },
503                low: oxi_sdk::router::RoutedTierConfig {
504                    model: sp.low.model.clone(),
505                    thinking: parse_thinking(&sp.low.thinking),
506                    fallbacks: sp.low.fallbacks.clone(),
507                },
508            },
509        );
510    }
511    let ai_cfg = oxi_sdk::router::RouterConfig::with_pinning(
512        store_cfg.default_profile().to_string(),
513        store_cfg.classifier_model().map(String::from),
514        store_cfg.context_upgrade_threshold(),
515        store_cfg.max_session_budget(),
516        ai_profiles,
517        oxi_sdk::router::ScoringWeights {
518            structural: store_cfg.weights().structural,
519            behavioral: store_cfg.weights().behavioral,
520            context_budget: store_cfg.weights().context_budget,
521            vision: store_cfg.weights().vision,
522            message: store_cfg.weights().message,
523        },
524        store_cfg.pin_tier().and_then(|s| match s {
525            "high" => Some(oxi_sdk::router::RouterTier::High),
526            "medium" => Some(oxi_sdk::router::RouterTier::Medium),
527            "low" => Some(oxi_sdk::router::RouterTier::Low),
528            _ => None,
529        }),
530        store_cfg.phase_bias(),
531    );
532
533    oxi_sdk::router::register_router(&ai_cfg);
534
535    if let Some(profile) = settings.router_profile() {
536        tracing::info!("Router active with profile: {profile}");
537    }
538}
539
540/// Decide whether this run is the TUI (interactive) mode. Mirrors the
541/// dispatch in [`dispatch_run_mode`]: print / RPC / single-prompt are
542/// non-TUI. Used by [`build_app`] to pick the canonical liveness identity.
543fn is_tui_mode(args: &CliArgs) -> bool {
544    if args.mode.as_deref() == Some("json") || args.print {
545        return false;
546    }
547    // prompt-only (no `--interactive` and a non-empty prompt) is non-TUI too;
548    // dispatch_run_mode sends it through main_dispatch::run_single_prompt.
549    // NOTE: must join the prompt Vec — clap's `default_value = ""` on the
550    // positional makes bare `oxi` yield `prompt == vec![""]` (non-empty Vec,
551    // empty join). Comparing the Vec directly would mis-classify the bare
552    // interactive launch as a single-prompt run.
553    let prompt = args.prompt.join(" ");
554    if !args.interactive && !prompt.is_empty() {
555        return false;
556    }
557    true
558}