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(args.model.clone(), args.provider.clone());
34    };
35    apply_cli_overrides(&mut settings);
36
37    if settings
38        .effective_model(None)
39        .unwrap_or_default()
40        .is_empty()
41    {
42        // No model configured. In interactive (TUI) mode, drop the user
43        // straight into the setup wizard instead of erroring out — this is
44        // the common first-run experience. In non-interactive modes
45        // (print / JSON / RPC / single-prompt) the caller explicitly wants a
46        // one-shot run, so a hard error with guidance is correct.
47        if is_tui_mode(args) {
48            eprintln!("No model configured. Launching setup wizard...");
49            crate::setup_wizard::run().await?;
50
51            // Reload settings the wizard just persisted and re-apply the
52            // CLI overrides, then re-check. If the user bailed out of the
53            // wizard without selecting a model, fall through to the error.
54            settings = Settings::load().unwrap_or_default();
55            apply_cli_overrides(&mut settings);
56        }
57
58        if settings
59            .effective_model(None)
60            .unwrap_or_default()
61            .is_empty()
62        {
63            eprintln!(
64                "{}",
65                print_mode::format_error("No model configured. Run `oxi setup` to configure.")
66            );
67            std::process::exit(1);
68        }
69    }
70
71    // Register custom OpenAI-compatible providers from settings.
72    register_custom_providers(&settings);
73
74    // Register model router (reads router_config file, opt-in).
75    register_router_provider();
76
77    // Apply thinking level if specified.
78    if let Some(ref level_str) = args.thinking {
79        if let Some(level) = crate::store::settings::parse_thinking_level(level_str) {
80            settings.thinking_level = level;
81        } else {
82            anyhow::bail!(
83                "Invalid thinking level: {}. Valid options: off, minimal, low, medium, high, xhigh",
84                level_str
85            );
86        }
87    }
88
89    // Build the wired Oxi engine + Agent via the SDK composition root.
90    let oxi = crate::build_oxi_engine().await?;
91
92    // Per-process liveness identity for issue-system ownership. In TUI mode
93    // we use the canonical "tui" id so the agent tool, the TUI panel, and
94    // the `/issue` slash command all share the same flock holder. In any
95    // non-TUI mode (print, RPC, single-prompt) we generate a stable
96    // process-scoped id; that way concurrent ownership checks see this
97    // process as a single coherent owner rather than an empty caller.
98    let ownership_session_id = if is_tui_mode(args) {
99        crate::store::issues::liveness::TUI_OWNERSHIP_ID.to_string()
100    } else {
101        format!(
102            "proc-{}-{}",
103            std::process::id(),
104            uuid::Uuid::new_v4().simple()
105        )
106    };
107
108    // Spawn the catalog event logger so refresh / override / local-discovery
109    // events show up in the log file. UI hooks can subscribe to
110    // `oxi.catalog().subscribe()` separately for picker invalidation.
111    let _catalog_logger =
112        crate::services::spawn_catalog_event_logger(std::sync::Arc::clone(oxi.catalog()));
113
114    let mut app = crate::App::from_oxi(oxi, settings, ownership_session_id).await?;
115
116    // v2.2: wire the MCP credential provider (OAuth2 client_credentials).
117    // Reads the same `mcp.json` files the agent uses, picks every server
118    // with an `oauth` block, and gives the manager a provider that can
119    // obtain + refresh access tokens on demand. No-op when no server
120    // declares `oauth`.
121    let mcp_cfg = oxi_agent::mcp::config::load_mcp_config();
122    let mut oauth_map: std::collections::HashMap<String, oxi_agent::mcp::types::OAuthConfig> =
123        std::collections::HashMap::new();
124    for (name, entry) in &mcp_cfg.mcp_servers {
125        if let Some(oc) = entry.oauth.clone() {
126            oauth_map.insert(name.clone(), oc);
127        }
128    }
129    if !oauth_map.is_empty()
130        && let Some(manager) = app.agent_tools().mcp_manager()
131    {
132        let config_dir = dirs::config_dir()
133            .map(|d| d.join("oxi"))
134            .unwrap_or_else(|| std::path::PathBuf::from("."));
135        match crate::mcp_credentials::FileMcpCredentialProvider::new(oauth_map, config_dir) {
136            Ok(provider) => {
137                manager.set_credential_provider(provider);
138            }
139            Err(e) => {
140                tracing::warn!("Failed to construct MCP credential provider: {}", e);
141            }
142        }
143    }
144
145    // Register built-in tools on the agent's tool registry.
146    let tools = app.agent_tools();
147    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
148    register_builtin_tools(
149        &tools,
150        &cwd,
151        args,
152        &app.settings().disabled_tools,
153        &app.settings().model_roles,
154    );
155
156    // Native headless browser (opt-in via the `native-browser` cargo feature).
157    // Constructs the pure-Rust `oxibrowser-core` engine and registers the
158    // browse tools (incl. `browse_session` with `observe`/`wait` actions) so
159    // the agent can navigate/observe/extract — omp-parity browsing without a
160    // Chrome dependency.
161    #[cfg(feature = "native-browser")]
162    {
163        match oxi_agent::tools::browse::OxiBrowserEngine::new().await {
164            Ok(engine) => {
165                let browser_registry =
166                    oxi_sdk::tool_factory::browsing_tools_with_session(std::sync::Arc::new(engine));
167                tools.extend_from(&browser_registry);
168            }
169            Err(e) => {
170                tracing::warn!("native browser engine unavailable; browse tools disabled: {e}");
171            }
172        }
173    }
174
175    // Discover and load WASM extensions.
176    let wasm_ext = load_wasm_extensions(&app, &cwd, &tools);
177    app.set_wasm_ext(wasm_ext);
178
179    // Handle --append-system-prompt.
180    if let Some(ref prompt_path) = args.append_system_prompt {
181        let content = std::fs::read_to_string(prompt_path)
182            .map_err(|e| anyhow::anyhow!("Failed to read system prompt file: {}", e))?;
183        app.agent().set_system_prompt(content);
184    }
185
186    // Spawn the autonomous memory pipeline if `memory_backend = "local"`.
187    // This is **opt-in**: when the user keeps the default `None`, the
188    // pipeline stays disabled and the boot path is side-effect free.
189    if let Some(handle) = crate::services::start_memory_pipeline(
190        app.settings(),
191        std::env::current_dir()
192            .as_ref()
193            .unwrap_or(&PathBuf::from(".")),
194        Some(app.oxi()),
195    ) {
196        tracing::debug!("memory pipeline spawn handle stored on app");
197        drop(handle); // joined on shutdown via App drop
198    }
199    Ok(app)
200}
201
202/// Dispatch the run mode: TUI / print / RPC, based on the CLI flags.
203pub async fn dispatch_run_mode(args: &CliArgs, app: crate::App) -> Result<i32> {
204    let prompt = args.prompt.join(" ");
205
206    if args.mode.as_deref() == Some("json") || args.print {
207        let mode = if args.mode.as_deref() == Some("json") {
208            crate::print_mode::PrintMode::Json
209        } else {
210            crate::print_mode::PrintMode::Text
211        };
212        let options = crate::print_mode::PrintModeOptions {
213            mode,
214            initial_message: if prompt.is_empty() {
215                None
216            } else {
217                Some(prompt)
218            },
219            messages: vec![],
220            no_stdin: args.print,
221            no_session: args.print || args.no_session,
222            quiet: args.print,
223            timeout: args.timeout,
224        };
225        return crate::print_mode::run_print_mode(&app, options).await;
226    }
227
228    if args.mode.as_deref() == Some("rpc") {
229        crate::rpc_mode::run_rpc_mode(app).await?;
230        return Ok(0);
231    }
232
233    if let Some(mode) = args.mode.as_deref() {
234        anyhow::bail!("Unknown run mode: {mode}");
235    }
236
237    if prompt.is_empty() || args.interactive {
238        if args.continue_session {
239            crate::tui::run_tui_interactive_with_continue(app, true).await?;
240        } else {
241            crate::tui::run_tui_interactive(app).await?;
242        }
243        return Ok(0);
244    }
245
246    crate::main_dispatch::run_single_prompt(app, &prompt).await?;
247    Ok(0)
248}
249
250/// Parse args, build the app, dispatch.
251pub async fn run_with_args(args: CliArgs) -> Result<i32> {
252    let app = build_app(&args).await?;
253    dispatch_run_mode(&args, app).await
254}
255
256// ─── Helpers (moved verbatim from main.rs) ─────────────────────────────
257
258/// Initialize file-based logging to `~/.cache/oxi/oxi.log`.
259///
260/// Reads `RUST_LOG` for filter (default: `debug`). Builds a
261/// `tracing_subscriber::EnvFilter` and writes to a `Mutex<File>` writer.
262pub fn init_logging() {
263    let log_dir = dirs::cache_dir()
264        .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
265        .join("oxi");
266    let _ = std::fs::create_dir_all(&log_dir);
267    let log_path = log_dir.join("oxi.log");
268
269    let log_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "debug".to_string());
270    let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
271        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(&log_filter));
272
273    // Logging is non-critical infrastructure: if the log file can't be
274    // created (permissions, read-only fs, …), degrade to stderr instead of
275    // aborting the process. Previously this `.expect()`-panicked on init,
276    // which under `panic = "abort"` killed the app before it could start.
277    let subscriber = tracing_subscriber::fmt()
278        .with_env_filter(env_filter)
279        .with_target(true)
280        .with_thread_ids(true)
281        .with_ansi(false);
282    match std::fs::File::create(&log_path) {
283        Ok(file) => {
284            subscriber.with_writer(std::sync::Mutex::new(file)).init();
285        }
286        Err(e) => {
287            eprintln!("oxi: could not open log file {log_path:?} ({e}); falling back to stderr");
288            subscriber.with_writer(std::io::stderr).init();
289        }
290    }
291
292    tracing::info!("Logging initialized, log file: {:?}", log_path);
293}
294
295/// Register custom OpenAI-compatible providers from settings and auto-fetch their models.
296fn register_custom_providers(settings: &Settings) {
297    let auth_storage = crate::store::auth_storage::shared_auth_storage();
298    for cp in &settings.custom_providers {
299        let api_key = auth_storage.get_api_key(&cp.name);
300        let api = cp.api.to_lowercase();
301
302        match api.as_str() {
303            "openai-completions" | "openai" => {
304                let provider =
305                    oxi_ai::OpenAiProvider::with_base_url_and_key(&cp.base_url, api_key.clone());
306                oxi_sdk::register_provider(&cp.name, provider);
307                tracing::info!(
308                    "Registered custom provider '{}' (openai-completions) -> {}",
309                    cp.name,
310                    cp.base_url
311                );
312            }
313            "openai-responses" | "responses" => {
314                let provider = oxi_sdk::OpenAiResponsesProvider::with_base_url_and_key(
315                    &cp.base_url,
316                    api_key.clone(),
317                );
318                oxi_sdk::register_provider(&cp.name, provider);
319                tracing::info!(
320                    "Registered custom provider '{}' (openai-responses) -> {}",
321                    cp.name,
322                    cp.base_url
323                );
324            }
325            _ => {
326                tracing::warn!(
327                    "Unknown API type '{}' for custom provider '{}'. Supported: openai-completions, openai-responses",
328                    cp.api,
329                    cp.name
330                );
331            }
332        }
333
334        fetch_and_register_models(cp, &api, &api_key);
335    }
336}
337
338/// Fetch models from a custom provider's /v1/models endpoint and register them.
339fn fetch_and_register_models(
340    cp: &crate::store::settings::CustomProvider,
341    api: &str,
342    api_key: &Option<String>,
343) {
344    if let Some(key) = api_key {
345        match oxi_sdk::fetch_models_blocking(&cp.base_url, key.as_str()) {
346            Ok(model_ids) => {
347                let count = model_ids.len();
348                for model_id in &model_ids {
349                    let api_type = match api {
350                        "openai-responses" | "responses" => oxi_sdk::Api::OpenAiResponses,
351                        _ => oxi_sdk::Api::OpenAiCompletions,
352                    };
353                    let model = oxi_sdk::Model {
354                        id: model_id.clone(),
355                        name: model_id.clone(),
356                        api: api_type,
357                        provider: cp.name.clone(),
358                        base_url: cp.base_url.clone(),
359                        reasoning: false,
360                        input: vec![oxi_sdk::InputModality::Text],
361                        cost: oxi_sdk::Cost::default(),
362                        context_window: 128_000,
363                        max_tokens: 8_192,
364                        headers: Default::default(),
365                        compat: None,
366                    };
367                    oxi_sdk::register_model(model);
368                }
369                tracing::info!(
370                    "[oxi] auto-fetched {} models from '{}' ({})",
371                    count,
372                    cp.name,
373                    cp.base_url
374                );
375            }
376            Err(e) => {
377                tracing::warn!(
378                    "[oxi] warning: failed to resolve models for {}: {}",
379                    cp.name,
380                    e
381                );
382            }
383        }
384    }
385}
386
387/// Register builtin tools with the agent, respecting --tools filter and disabled_tools.
388///
389/// Also transfers the [`McpManager`](oxi_agent::mcp::McpManager) reference from
390/// the built-in registry to the live agent registry. This matters because
391/// `register_arc` only copies the `Arc<dyn AgentTool>` — the manager field is
392/// stored separately and would otherwise be `None`, making `/mcp` show a
393/// "MCP is not configured" warning even though the `McpTool` is registered.
394fn register_builtin_tools(
395    tools: &oxi_agent::ToolRegistry,
396    cwd: &std::path::Path,
397    args: &CliArgs,
398    disabled_tools: &[String],
399    model_roles: &std::collections::HashMap<String, String>,
400) {
401    let builtin_registry = if let Some(ref tools_str) = args.tools {
402        let names: Vec<&str> = tools_str.split(',').map(|s| s.trim()).collect();
403        oxi_agent::ToolRegistry::with_selected_tools(cwd.to_path_buf(), &names)
404    } else {
405        oxi_agent::ToolRegistry::with_builtins_cwd(cwd.to_path_buf(), disabled_tools)
406    };
407    for name in builtin_registry.names() {
408        if let Some(tool) = builtin_registry.get(&name) {
409            tools.register_arc(tool);
410        }
411    }
412    // Propagate the MCP manager so the TUI's `/mcp` overlay can hot-reload
413    // configs, render live connection status, and so on.
414    if let Some(mgr) = builtin_registry.mcp_manager() {
415        tools.set_mcp_manager(mgr);
416    }
417
418    // Role-based commit model: if a `commit` role is configured, upgrade the
419    // deterministic (no-LLM) CommitTool to one backed by that model. Tools
420    // register by name, so this overwrites the unconfigured instance safely.
421    let role_registry = oxi_sdk::RoleRegistry::from_map(model_roles.clone());
422    if let Some(model) = oxi_sdk::resolve_role_to_model(oxi_sdk::ModelRole::Commit, &role_registry)
423    {
424        let commit: std::sync::Arc<dyn oxi_agent::AgentTool> =
425            std::sync::Arc::new(oxi_agent::CommitTool::new(model));
426        tools.register_arc(commit);
427        tracing::debug!("CommitTool upgraded to commit-role model");
428    }
429}
430
431/// Discover and load WASM extensions, registering their tools.
432fn load_wasm_extensions(
433    app: &crate::App,
434    cwd: &std::path::Path,
435    tools: &oxi_agent::ToolRegistry,
436) -> Option<std::sync::Arc<crate::extensions::WasmExtensionManager>> {
437    if !app.settings().extensions_enabled {
438        return None;
439    }
440
441    let wasm_paths = crate::extensions::WasmExtensionManager::discover(cwd);
442    if wasm_paths.is_empty() {
443        return None;
444    }
445
446    let mut wasm_mgr = crate::extensions::WasmExtensionManager::new();
447    let (loaded, errors) = wasm_mgr.load_all(&wasm_paths);
448    for info in &loaded {
449        tracing::info!("WASM extension loaded: {} v{}", info.name, info.version);
450    }
451    for err in &errors {
452        tracing::warn!("WASM extension error: {}", err);
453    }
454
455    if wasm_mgr.is_empty() {
456        return None;
457    }
458
459    let mgr = std::sync::Arc::new(wasm_mgr);
460    for tool_def in mgr.all_tool_defs() {
461        let wasm_tool = crate::extensions::WasmTool::new(
462            mgr.clone(),
463            tool_def.name.clone(),
464            tool_def.description.clone(),
465            tool_def.schema.clone(),
466        );
467        tools.register(wasm_tool);
468    }
469    Some(mgr)
470}
471
472/// Register the model auto-router if configured in router_config.
473fn register_router_provider() {
474    let global_dir = dirs::config_dir().unwrap_or_default().join("oxi");
475    let project_dir = std::env::current_dir().unwrap_or_default();
476
477    let store_cfg = match crate::store::router_config::load_router_config(&global_dir, &project_dir)
478    {
479        Some(cfg) => cfg,
480        None => {
481            tracing::debug!("No router config found — router/auto will not appear in model list");
482            return;
483        }
484    };
485
486    // Register router models only when configured.
487    oxi_sdk::register_model(oxi_sdk::Model::new(
488        "auto",
489        "Router (auto)".to_string(),
490        oxi_sdk::Api::AnthropicMessages,
491        "router",
492        "router://local",
493    ));
494
495    // Convert store config to AI config.
496    let mut ai_profiles = std::collections::HashMap::new();
497    for (name, sp) in store_cfg.profiles() {
498        fn parse_thinking(s: &Option<String>) -> Option<oxi_sdk::ThinkingLevel> {
499            s.as_ref().and_then(|s| match s.as_str() {
500                "off" => Some(oxi_sdk::ThinkingLevel::Off),
501                "minimal" => Some(oxi_sdk::ThinkingLevel::Minimal),
502                "low" => Some(oxi_sdk::ThinkingLevel::Low),
503                "medium" => Some(oxi_sdk::ThinkingLevel::Medium),
504                "high" => Some(oxi_sdk::ThinkingLevel::High),
505                "xhigh" => Some(oxi_sdk::ThinkingLevel::XHigh),
506                _ => None,
507            })
508        }
509        ai_profiles.insert(
510            name.clone(),
511            oxi_sdk::router::RouterProfile {
512                high: oxi_sdk::router::RoutedTierConfig {
513                    model: sp.high.model.clone(),
514                    thinking: parse_thinking(&sp.high.thinking),
515                    fallbacks: sp.high.fallbacks.clone(),
516                },
517                medium: oxi_sdk::router::RoutedTierConfig {
518                    model: sp.medium.model.clone(),
519                    thinking: parse_thinking(&sp.medium.thinking),
520                    fallbacks: sp.medium.fallbacks.clone(),
521                },
522                low: oxi_sdk::router::RoutedTierConfig {
523                    model: sp.low.model.clone(),
524                    thinking: parse_thinking(&sp.low.thinking),
525                    fallbacks: sp.low.fallbacks.clone(),
526                },
527            },
528        );
529    }
530    let ai_cfg = oxi_sdk::router::RouterConfig::with_pinning(
531        store_cfg.default_profile().to_string(),
532        store_cfg.classifier_model().map(String::from),
533        store_cfg.context_upgrade_threshold(),
534        store_cfg.max_session_budget(),
535        ai_profiles,
536        oxi_sdk::router::ScoringWeights {
537            structural: store_cfg.weights().structural,
538            behavioral: store_cfg.weights().behavioral,
539            context_budget: store_cfg.weights().context_budget,
540            vision: store_cfg.weights().vision,
541            message: store_cfg.weights().message,
542        },
543        store_cfg.pin_tier().and_then(|s| match s {
544            "high" => Some(oxi_sdk::router::RouterTier::High),
545            "medium" => Some(oxi_sdk::router::RouterTier::Medium),
546            "low" => Some(oxi_sdk::router::RouterTier::Low),
547            _ => None,
548        }),
549        store_cfg.phase_bias(),
550    );
551
552    oxi_sdk::router::register_router(&ai_cfg);
553}
554
555/// Decide whether this run is the TUI (interactive) mode. Mirrors the
556/// dispatch in [`dispatch_run_mode`]: print / RPC / single-prompt are
557/// non-TUI. Used by [`build_app`] to pick the canonical liveness identity.
558fn is_tui_mode(args: &CliArgs) -> bool {
559    if matches!(args.mode.as_deref(), Some("json" | "rpc")) || args.print {
560        return false;
561    }
562    // prompt-only (no `--interactive` and a non-empty prompt) is non-TUI too;
563    // dispatch_run_mode sends it through main_dispatch::run_single_prompt.
564    // NOTE: must join the prompt Vec — clap's `default_value = ""` on the
565    // positional makes bare `oxi` yield `prompt == vec![""]` (non-empty Vec,
566    // empty join). Comparing the Vec directly would mis-classify the bare
567    // interactive launch as a single-prompt run.
568    let prompt = args.prompt.join(" ");
569    if !args.interactive && !prompt.is_empty() {
570        return false;
571    }
572    true
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use clap::Parser;
579
580    #[test]
581    fn rpc_mode_is_headless() {
582        let args = CliArgs::try_parse_from(["oxi", "--mode", "rpc"]).unwrap();
583        assert!(!is_tui_mode(&args));
584    }
585}