Skip to main content

oxicode/
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_oxicode` performs its own init at `OxicodeBuilder::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    // `OXICODE_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    // Pre-build the per-process liveness identity BEFORE the engine build so
38    // we can include it in the SessionStart hook context (and pass it to
39    // App::from_oxicode on the same path).
40    let ownership_session_id = if is_tui_mode(args) {
41        crate::store::issues::liveness::TUI_OWNERSHIP_ID.to_string()
42    } else {
43        format!(
44            "proc-{}-{}",
45            std::process::id(),
46            uuid::Uuid::new_v4().simple()
47        )
48    };
49
50    // Load hooks: global hooks (`~/.oxicode/settings.toml` -> `[[hooks]]`)
51    // are always trusted. Project hooks (`.oxicode/settings.toml`) require
52    // a first-run interactive Y/n approval — unless we're in a non-TUI
53    // mode, in which case we skip with a warning to keep the boot path
54    // non-interactive. See `store/hook_approval.rs` for the registry.
55    let global_hooks = settings.hooks.clone();
56    let cwd_now = std::env::current_dir().unwrap_or_default();
57    let project_hooks_path = Settings::find_project_settings(&cwd_now);
58    let project_hooks: Vec<oxicode_sdk::ports::HookSpec> = match &project_hooks_path {
59        Some(path) => {
60            let content = std::fs::read_to_string(path).unwrap_or_default();
61            let hash = crate::store::hook_approval::hash_settings(&content);
62            let mut registry = crate::store::hook_approval::HookApprovalRegistry::load_or_default();
63            if registry.is_approved(&cwd_now, &hash) {
64                // Approved: re-parse the project file and extract its [[hooks]].
65                match Settings::parse_from_str(&content, Settings::detect_format(path)) {
66                    Ok(s) => s.hooks,
67                    Err(e) => {
68                        tracing::warn!(error = %e, "project hooks file failed to parse");
69                        Vec::new()
70                    }
71                }
72            } else {
73                // First run or hash mismatch.
74                let count = content.matches("[[hooks]]").count();
75                if count > 0 {
76                    if is_tui_mode(args) {
77                        let ok = crate::store::hook_approval::prompt_for_approval(&cwd_now, count);
78                        if ok {
79                            registry.approve(&cwd_now, &hash);
80                            let _ = registry.persist();
81                            Settings::parse_from_str(&content, Settings::detect_format(path))
82                                .map(|s| s.hooks)
83                                .unwrap_or_default()
84                        } else {
85                            tracing::warn!("project hooks denied by user; skipping");
86                            Vec::new()
87                        }
88                    } else {
89                        tracing::warn!(
90                            count,
91                            "project hooks not approved; skipping (non-interactive mode)"
92                        );
93                        Vec::new()
94                    }
95                } else {
96                    Vec::new()
97                }
98            }
99        }
100        None => Vec::new(),
101    };
102    let mut all_hooks = global_hooks;
103    all_hooks.extend(project_hooks);
104    let hook_runner: std::sync::Arc<dyn oxicode_sdk::ports::HookRunner> =
105        match oxicode_sdk::ports::fs::CommandHookRunner::new(all_hooks) {
106            Ok(r) => std::sync::Arc::new(r),
107            Err(e) => {
108                tracing::warn!(error = %e, "hook runner construction failed; using empty runner");
109                std::sync::Arc::new(
110                    oxicode_sdk::ports::fs::CommandHookRunner::new(Vec::new())
111                        .expect("empty spec list is always valid"),
112                )
113            }
114        };
115
116    if settings
117        .effective_model(None)
118        .unwrap_or_default()
119        .is_empty()
120    {
121        // No model configured. In interactive (TUI) mode, drop the user
122        // straight into the setup wizard instead of erroring out — this is
123        // the common first-run experience. In non-interactive modes
124        // (print / JSON / RPC / single-prompt) the caller explicitly wants a
125        // one-shot run, so a hard error with guidance is correct.
126        if is_tui_mode(args) {
127            eprintln!("No model configured. Launching setup wizard...");
128            crate::setup_wizard::run().await?;
129
130            // Reload settings the wizard just persisted and re-apply the
131            // CLI overrides, then re-check. If the user bailed out of the
132            // wizard without selecting a model, fall through to the error.
133            settings = Settings::load().unwrap_or_default();
134            apply_cli_overrides(&mut settings);
135        }
136
137        if settings
138            .effective_model(None)
139            .unwrap_or_default()
140            .is_empty()
141        {
142            eprintln!(
143                "{}",
144                print_mode::format_error("No model configured. Run `oxicode setup` to configure.")
145            );
146            std::process::exit(1);
147        }
148    }
149
150    // Register custom OpenAI-compatible providers from settings.
151    register_custom_providers(&settings);
152
153    // Register model router (reads router_config file, opt-in).
154    register_router_provider();
155
156    // Apply thinking level if specified.
157    if let Some(ref level_str) = args.thinking {
158        if let Some(level) = crate::store::settings::parse_thinking_level(level_str) {
159            settings.thinking_level = level;
160        } else {
161            anyhow::bail!(
162                "Invalid thinking level: {}. Valid options: off, minimal, low, medium, high, xhigh",
163                level_str
164            );
165        }
166    }
167
168    // Build the wired Oxicode engine + Agent via the SDK composition root.
169    // Build embedding port from settings (mnemopi → SDK async bridge).
170    let embedding_provider = crate::services::build_embedding_provider(&settings).map(|p| {
171        std::sync::Arc::new(crate::services::MnemopiEmbeddingBridge::new(p))
172            as std::sync::Arc<dyn oxicode_sdk::ports::EmbeddingProvider>
173    });
174
175    let oxicode =
176        crate::build_oxicode_engine(embedding_provider, Some(hook_runner.clone())).await?;
177
178    // Fire SessionStart (fail-open: a hook that errors must not block boot).
179    {
180        let hook_ctx = oxicode_sdk::ports::HookContext {
181            event: oxicode_sdk::ports::HookEvent::SessionStart,
182            session_id: Some(ownership_session_id.clone()),
183            session_cwd: Some(cwd_now.clone()),
184            ..Default::default()
185        };
186        let _ = oxicode
187            .ports()
188            .hooks
189            .run(oxicode_sdk::ports::HookEvent::SessionStart, &hook_ctx)
190            .await;
191    }
192
193    // Spawn the catalog event logger so refresh / override / local-discovery
194    // events show up in the log file. UI hooks can subscribe to
195    // `oxicode.catalog().subscribe()` separately for picker invalidation.
196    let _catalog_logger =
197        crate::services::spawn_catalog_event_logger(std::sync::Arc::clone(oxicode.catalog()));
198
199    // Pre-build session state so the runtime (AgentSession) and the
200    // agent's session-level closures (`with_session_hooks`) share the
201    // SAME queues + stop flag. The single `set_hooks` invariant depends
202    // on this state living across both ends.
203    let session_state = crate::SessionState::default();
204
205    let mut app =
206        crate::App::from_oxicode(oxicode, settings, ownership_session_id, Some(session_state))
207            .await?;
208
209    // Fire-and-forget OAuth refresh: if the active provider has a stored
210    // OAuth credential that is expired (or within 60 s of expiry), ask the
211    // refresh module to rotate it in the background. A failed refresh must
212    // never crash startup — the agent will surface a re-login prompt on
213    // the first 401 if the refresh actually failed.
214    if let Some(active_provider) = app.settings().effective_provider(args.provider.as_deref())
215        && !active_provider.is_empty()
216    {
217        let p = active_provider.clone();
218        tokio::spawn(async move {
219            if let Err(e) = crate::oauth_refresh::refresh_if_expired(&p).await {
220                tracing::debug!(provider = %p, error = %e, "oauth refresh skipped");
221            }
222        });
223    }
224
225    // v2.2: wire the MCP credential provider (OAuth2 client_credentials).
226    // Reads the same `mcp.json` files the agent uses, picks every server
227    // with an `oauth` block, and gives the manager a provider that can
228    // obtain + refresh access tokens on demand. No-op when no server
229    // declares `oauth`.
230    let mcp_cfg = oxicode_agent::mcp::config::load_mcp_config();
231    let mut oauth_map: std::collections::HashMap<String, oxicode_agent::mcp::types::OAuthConfig> =
232        std::collections::HashMap::new();
233    for (name, entry) in &mcp_cfg.mcp_servers {
234        if let Some(oc) = entry.oauth.clone() {
235            oauth_map.insert(name.clone(), oc);
236        }
237    }
238    if !oauth_map.is_empty()
239        && let Some(manager) = app.agent_tools().mcp_manager()
240    {
241        let config_dir = dirs::config_dir()
242            .map(|d| d.join("oxicode"))
243            .unwrap_or_else(|| std::path::PathBuf::from("."));
244        match crate::mcp_credentials::FileMcpCredentialProvider::new(oauth_map, config_dir) {
245            Ok(provider) => {
246                manager.set_credential_provider(provider);
247            }
248            Err(e) => {
249                tracing::warn!("Failed to construct MCP credential provider: {}", e);
250            }
251        }
252    }
253
254    // Register built-in tools on the agent's tool registry.
255    let tools = app.agent_tools();
256    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
257    register_builtin_tools(
258        &tools,
259        &cwd,
260        args,
261        &app.settings().disabled_tools,
262        &app.settings().model_roles,
263    );
264
265    // Native headless browser (opt-in via the `native-browser` cargo feature).
266    // Constructs the pure-Rust `oxibrowser-core` engine and registers the
267    // browse tools (incl. `browse_session` with `observe`/`wait` actions) so
268    // the agent can navigate/observe/extract — omp-parity browsing without a
269    // Chrome dependency.
270    #[cfg(feature = "native-browser")]
271    {
272        match oxicode_agent::tools::browse::OxicodeBrowserEngine::new().await {
273            Ok(engine) => {
274                let (provider, model) = match (app.agent().model_id().is_empty(), app.oxicode()) {
275                    (false, oxicode) => {
276                        let model_id = app.agent().model_id();
277                        // model_id is the bare model id (Agent::model_id);
278                        // resolve provider name from the agent's config or fall
279                        // back to the Oxicode default.
280                        let provider_name = "anthropic".to_string();
281                        match (
282                            oxicode.create_provider(&provider_name),
283                            oxicode_ai::Model::new(
284                                model_id.clone(),
285                                model_id.clone(),
286                                oxicode_ai::Api::AnthropicMessages,
287                                provider_name.clone(),
288                                String::new(),
289                            ),
290                        ) {
291                            (Ok(p), m) => (Some(p), Some(m)),
292                            _ => (None, None),
293                        }
294                    }
295                    _ => (None, None),
296                };
297                let browser_registry = oxicode_agent::tools::browse::browsing_tools_with_session(
298                    provider,
299                    model,
300                    std::sync::Arc::new(engine),
301                );
302                tools.extend_from(&browser_registry);
303            }
304            Err(e) => {
305                tracing::warn!("native browser engine unavailable; browse tools disabled: {e}");
306            }
307        }
308    }
309
310    // Discover and load WASM extensions.
311    let wasm_ext = load_wasm_extensions(&app, &cwd, &tools);
312    app.set_wasm_ext(wasm_ext);
313
314    // Handle --append-system-prompt.
315    if let Some(ref prompt_path) = args.append_system_prompt {
316        let content = std::fs::read_to_string(prompt_path)
317            .map_err(|e| anyhow::anyhow!("Failed to read system prompt file: {}", e))?;
318        app.agent().set_system_prompt(content);
319    }
320
321    // Spawn the autonomous memory pipeline if `memory_backend = "local"`.
322    // This is **opt-in**: when the user keeps the default `None`, the
323    // pipeline stays disabled and the boot path is side-effect free.
324    if let Some(handle) = crate::services::start_memory_pipeline(
325        app.settings(),
326        std::env::current_dir()
327            .as_ref()
328            .unwrap_or(&PathBuf::from(".")),
329        Some(app.oxicode()),
330    ) {
331        tracing::debug!("memory pipeline spawn handle stored on app");
332        drop(handle); // joined on shutdown via App drop
333    }
334    Ok(app)
335}
336
337/// Dispatch the run mode: TUI / print / RPC, based on the CLI flags.
338pub async fn dispatch_run_mode(args: &CliArgs, app: crate::App) -> Result<i32> {
339    let prompt = args.prompt.join(" ");
340
341    if args.mode.as_deref() == Some("json") || args.print {
342        let mode = if args.mode.as_deref() == Some("json") {
343            crate::print_mode::PrintMode::Json
344        } else {
345            crate::print_mode::PrintMode::Text
346        };
347        let options = crate::print_mode::PrintModeOptions {
348            mode,
349            initial_message: if prompt.is_empty() {
350                None
351            } else {
352                Some(prompt)
353            },
354            messages: vec![],
355            no_stdin: args.print,
356            no_session: args.print || args.no_session,
357            quiet: args.print,
358            timeout: args.timeout,
359        };
360        return crate::print_mode::run_print_mode(&app, options).await;
361    }
362
363    if args.mode.as_deref() == Some("rpc") {
364        crate::rpc_mode::run_rpc_mode(app).await?;
365        return Ok(0);
366    }
367
368    if let Some(mode) = args.mode.as_deref() {
369        anyhow::bail!("Unknown run mode: {mode}");
370    }
371
372    if prompt.is_empty() || args.interactive {
373        crate::tui_vt::run_tui(app).await?;
374        return Ok(0);
375    }
376
377    crate::main_dispatch::run_single_prompt(app, &prompt).await?;
378    Ok(0)
379}
380
381/// Parse args, build the app, dispatch.
382pub async fn run_with_args(args: CliArgs) -> Result<i32> {
383    let app = build_app(&args).await?;
384    dispatch_run_mode(&args, app).await
385}
386
387// ─── Helpers (moved verbatim from main.rs) ─────────────────────────────
388
389/// Initialize file-based logging to `~/.cache/oxicode/oxicode.log`.
390///
391/// Reads `RUST_LOG` for filter (default: `debug`). Builds a
392/// `tracing_subscriber::EnvFilter` and writes to a `Mutex<File>` writer.
393pub fn init_logging() {
394    let log_dir = dirs::cache_dir()
395        .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
396        .join("oxicode");
397    let _ = std::fs::create_dir_all(&log_dir);
398    let log_path = log_dir.join("oxicode.log");
399
400    let log_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "debug".to_string());
401    let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
402        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(&log_filter));
403
404    // Logging is non-critical infrastructure: if the log file can't be
405    // created (permissions, read-only fs, …), degrade to stderr instead of
406    // aborting the process. Previously this `.expect()`-panicked on init,
407    // which under `panic = "abort"` killed the app before it could start.
408    let subscriber = tracing_subscriber::fmt()
409        .with_env_filter(env_filter)
410        .with_target(true)
411        .with_thread_ids(true)
412        .with_ansi(false);
413    match std::fs::File::create(&log_path) {
414        Ok(file) => {
415            subscriber.with_writer(std::sync::Mutex::new(file)).init();
416        }
417        Err(e) => {
418            eprintln!(
419                "oxicode: could not open log file {log_path:?} ({e}); falling back to stderr"
420            );
421            subscriber.with_writer(std::io::stderr).init();
422        }
423    }
424
425    tracing::info!("Logging initialized, log file: {:?}", log_path);
426}
427
428/// Register custom OpenAI-compatible providers from settings and auto-fetch their models.
429fn register_custom_providers(settings: &Settings) {
430    let auth_storage = crate::store::auth_storage::shared_auth_storage();
431    for cp in &settings.custom_providers {
432        let api_key = auth_storage.get_api_key(&cp.name);
433        let api = cp.api.to_lowercase();
434
435        match api.as_str() {
436            "openai-completions" | "openai" => {
437                let provider = oxicode_ai::OpenAiProvider::with_base_url_and_key(
438                    &cp.base_url,
439                    api_key.clone(),
440                );
441                oxicode_sdk::register_provider(&cp.name, provider);
442                tracing::info!(
443                    "Registered custom provider '{}' (openai-completions) -> {}",
444                    cp.name,
445                    cp.base_url
446                );
447            }
448            "openai-responses" | "responses" => {
449                let provider = oxicode_sdk::OpenAiResponsesProvider::with_base_url_and_key(
450                    &cp.base_url,
451                    api_key.clone(),
452                );
453                oxicode_sdk::register_provider(&cp.name, provider);
454                tracing::info!(
455                    "Registered custom provider '{}' (openai-responses) -> {}",
456                    cp.name,
457                    cp.base_url
458                );
459            }
460            _ => {
461                tracing::warn!(
462                    "Unknown API type '{}' for custom provider '{}'. Supported: openai-completions, openai-responses",
463                    cp.api,
464                    cp.name
465                );
466            }
467        }
468
469        fetch_and_register_models(cp, &api, &api_key);
470    }
471}
472
473/// Fetch models from a custom provider's /v1/models endpoint and register them.
474fn fetch_and_register_models(
475    cp: &crate::store::settings::CustomProvider,
476    api: &str,
477    api_key: &Option<String>,
478) {
479    if let Some(key) = api_key {
480        match oxicode_sdk::fetch_models_blocking(&cp.base_url, key.as_str()) {
481            Ok(model_ids) => {
482                let count = model_ids.len();
483                for model_id in &model_ids {
484                    let api_type = match api {
485                        "openai-responses" | "responses" => oxicode_sdk::Api::OpenAiResponses,
486                        _ => oxicode_sdk::Api::OpenAiCompletions,
487                    };
488                    let model = oxicode_sdk::Model {
489                        id: model_id.clone(),
490                        name: model_id.clone(),
491                        api: api_type,
492                        provider: cp.name.clone(),
493                        base_url: cp.base_url.clone(),
494                        reasoning: false,
495                        input: vec![oxicode_sdk::InputModality::Text],
496                        cost: oxicode_sdk::Cost::default(),
497                        context_window: 128_000,
498                        max_tokens: 8_192,
499                        headers: Default::default(),
500                        compat: None,
501                    };
502                    oxicode_sdk::register_model(model);
503                }
504                tracing::info!(
505                    "[oxicode] auto-fetched {} models from '{}' ({})",
506                    count,
507                    cp.name,
508                    cp.base_url
509                );
510            }
511            Err(e) => {
512                tracing::warn!(
513                    "[oxicode] warning: failed to resolve models for {}: {}",
514                    cp.name,
515                    e
516                );
517            }
518        }
519    }
520}
521
522/// Register builtin tools with the agent, respecting --tools filter and disabled_tools.
523///
524/// Also transfers the [`McpManager`](oxicode_agent::mcp::McpManager) reference from
525/// the built-in registry to the live agent registry. This matters because
526/// `register_arc` only copies the `Arc<dyn AgentTool>` — the manager field is
527/// stored separately and would otherwise be `None`, making `/mcp` show a
528/// "MCP is not configured" warning even though the `McpTool` is registered.
529fn register_builtin_tools(
530    tools: &oxicode_agent::ToolRegistry,
531    cwd: &std::path::Path,
532    args: &CliArgs,
533    disabled_tools: &[String],
534    model_roles: &std::collections::HashMap<String, String>,
535) {
536    let builtin_registry = if let Some(ref tools_str) = args.tools {
537        let names: Vec<&str> = tools_str.split(',').map(|s| s.trim()).collect();
538        oxicode_agent::ToolRegistry::with_selected_tools(cwd.to_path_buf(), &names)
539    } else {
540        oxicode_agent::ToolRegistry::with_builtins_cwd(cwd.to_path_buf(), disabled_tools)
541    };
542    for name in builtin_registry.names() {
543        if let Some(tool) = builtin_registry.get(&name) {
544            tools.register_arc(tool);
545        }
546    }
547    // Propagate the MCP manager so the TUI's `/mcp` overlay can hot-reload
548    // configs, render live connection status, and so on.
549    if let Some(mgr) = builtin_registry.mcp_manager() {
550        tools.set_mcp_manager(mgr);
551    }
552
553    // Role-based commit model: if a `commit` role is configured, upgrade the
554    // deterministic (no-LLM) CommitTool to one backed by that model. Tools
555    // register by name, so this overwrites the unconfigured instance safely.
556    let role_registry = oxicode_sdk::RoleRegistry::from_map(model_roles.clone());
557    if let Some(model) =
558        oxicode_sdk::resolve_role_to_model(oxicode_sdk::ModelRole::Commit, &role_registry)
559    {
560        let commit: std::sync::Arc<dyn oxicode_agent::AgentTool> =
561            std::sync::Arc::new(oxicode_agent::CommitTool::new(model));
562        tools.register_arc(commit);
563        tracing::debug!("CommitTool upgraded to commit-role model");
564    }
565}
566
567/// Discover and load WASM extensions, registering their tools.
568fn load_wasm_extensions(
569    app: &crate::App,
570    cwd: &std::path::Path,
571    tools: &oxicode_agent::ToolRegistry,
572) -> Option<std::sync::Arc<crate::extensions::WasmExtensionManager>> {
573    if !app.settings().extensions_enabled {
574        return None;
575    }
576
577    let wasm_paths = crate::extensions::WasmExtensionManager::discover(cwd);
578    if wasm_paths.is_empty() {
579        return None;
580    }
581
582    let mut wasm_mgr = crate::extensions::WasmExtensionManager::new();
583    let (loaded, errors) = wasm_mgr.load_all(&wasm_paths);
584    for info in &loaded {
585        tracing::info!("WASM extension loaded: {} v{}", info.name, info.version);
586    }
587    for err in &errors {
588        tracing::warn!("WASM extension error: {}", err);
589    }
590
591    if wasm_mgr.is_empty() {
592        return None;
593    }
594
595    let mgr = std::sync::Arc::new(wasm_mgr);
596    for tool_def in mgr.all_tool_defs() {
597        let wasm_tool = crate::extensions::WasmTool::new(
598            mgr.clone(),
599            tool_def.name.clone(),
600            tool_def.description.clone(),
601            tool_def.schema.clone(),
602        );
603        tools.register(wasm_tool);
604    }
605    Some(mgr)
606}
607
608/// Register the model auto-router if configured in router_config.
609fn register_router_provider() {
610    let global_dir = dirs::config_dir().unwrap_or_default().join("oxicode");
611    let project_dir = std::env::current_dir().unwrap_or_default();
612
613    let store_cfg = match crate::store::router_config::load_router_config(&global_dir, &project_dir)
614    {
615        Some(cfg) => cfg,
616        None => {
617            tracing::debug!("No router config found — router/auto will not appear in model list");
618            return;
619        }
620    };
621
622    // Register router models only when configured.
623    oxicode_sdk::register_model(oxicode_sdk::Model::new(
624        "auto",
625        "Router (auto)".to_string(),
626        oxicode_sdk::Api::AnthropicMessages,
627        "router",
628        "router://local",
629    ));
630
631    // Convert store config to AI config.
632    let mut ai_profiles = std::collections::HashMap::new();
633    for (name, sp) in store_cfg.profiles() {
634        fn parse_thinking(s: &Option<String>) -> Option<oxicode_sdk::ThinkingLevel> {
635            s.as_ref().and_then(|s| match s.as_str() {
636                "off" => Some(oxicode_sdk::ThinkingLevel::Off),
637                "minimal" => Some(oxicode_sdk::ThinkingLevel::Minimal),
638                "low" => Some(oxicode_sdk::ThinkingLevel::Low),
639                "medium" => Some(oxicode_sdk::ThinkingLevel::Medium),
640                "high" => Some(oxicode_sdk::ThinkingLevel::High),
641                "xhigh" => Some(oxicode_sdk::ThinkingLevel::XHigh),
642                _ => None,
643            })
644        }
645        ai_profiles.insert(
646            name.clone(),
647            oxicode_sdk::router::RouterProfile {
648                high: oxicode_sdk::router::RoutedTierConfig {
649                    model: sp.high.model.clone(),
650                    thinking: parse_thinking(&sp.high.thinking),
651                    fallbacks: sp.high.fallbacks.clone(),
652                },
653                medium: oxicode_sdk::router::RoutedTierConfig {
654                    model: sp.medium.model.clone(),
655                    thinking: parse_thinking(&sp.medium.thinking),
656                    fallbacks: sp.medium.fallbacks.clone(),
657                },
658                low: oxicode_sdk::router::RoutedTierConfig {
659                    model: sp.low.model.clone(),
660                    thinking: parse_thinking(&sp.low.thinking),
661                    fallbacks: sp.low.fallbacks.clone(),
662                },
663            },
664        );
665    }
666    let ai_cfg = oxicode_sdk::router::RouterConfig::with_pinning(
667        store_cfg.default_profile().to_string(),
668        store_cfg.classifier_model().map(String::from),
669        store_cfg.context_upgrade_threshold(),
670        store_cfg.max_session_budget(),
671        ai_profiles,
672        oxicode_sdk::router::ScoringWeights {
673            structural: store_cfg.weights().structural,
674            behavioral: store_cfg.weights().behavioral,
675            context_budget: store_cfg.weights().context_budget,
676            vision: store_cfg.weights().vision,
677            message: store_cfg.weights().message,
678        },
679        store_cfg.pin_tier().and_then(|s| match s {
680            "high" => Some(oxicode_sdk::router::RouterTier::High),
681            "medium" => Some(oxicode_sdk::router::RouterTier::Medium),
682            "low" => Some(oxicode_sdk::router::RouterTier::Low),
683            _ => None,
684        }),
685        store_cfg.phase_bias(),
686    );
687
688    oxicode_sdk::router::register_router(&ai_cfg);
689}
690
691/// Decide whether this run is the TUI (interactive) mode. Mirrors the
692/// dispatch in [`dispatch_run_mode`]: print / RPC / single-prompt are
693/// non-TUI. Used by [`build_app`] to pick the canonical liveness identity.
694fn is_tui_mode(args: &CliArgs) -> bool {
695    if matches!(args.mode.as_deref(), Some("json" | "rpc")) || args.print {
696        return false;
697    }
698    // prompt-only (no `--interactive` and a non-empty prompt) is non-TUI too;
699    // dispatch_run_mode sends it through main_dispatch::run_single_prompt.
700    // NOTE: must join the prompt Vec — clap's `default_value = ""` on the
701    // positional makes bare `oxicode` yield `prompt == vec![""]` (non-empty Vec,
702    // empty join). Comparing the Vec directly would mis-classify the bare
703    // interactive launch as a single-prompt run.
704    let prompt = args.prompt.join(" ");
705    if !args.interactive && !prompt.is_empty() {
706        return false;
707    }
708    true
709}
710#[cfg(test)]
711mod tests {
712    use super::*;
713    use clap::Parser;
714
715    #[test]
716    fn rpc_mode_is_headless() {
717        let args = CliArgs::try_parse_from(["oxicode", "--mode", "rpc"]).unwrap();
718        assert!(!is_tui_mode(&args));
719    }
720
721    #[test]
722    fn empty_hooks_does_not_block() {
723        use crate::store::settings::Settings;
724        let s = Settings::default();
725        assert!(s.hooks.is_empty());
726    }
727}