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