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