1use crate::cli::CliArgs;
10use crate::print_mode;
11use crate::store::settings::Settings;
12use anyhow::Result;
13use std::path::PathBuf;
14use tracing;
15
16pub async fn build_app(args: &CliArgs) -> Result<crate::App> {
19 let mut settings = Settings::load().unwrap_or_default();
28
29 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 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 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 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 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 if is_tui_mode(args) {
127 eprintln!("No model configured. Launching setup wizard...");
128 crate::setup_wizard::run().await?;
129
130 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_providers(&settings);
152
153 register_router_provider();
155
156 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 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 {
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 let _catalog_logger =
197 crate::services::spawn_catalog_event_logger(std::sync::Arc::clone(oxicode.catalog()));
198
199 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 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 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 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 #[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 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 let wasm_ext = load_wasm_extensions(&app, &cwd, &tools);
312 app.set_wasm_ext(wasm_ext);
313
314 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 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); }
334 Ok(app)
335}
336
337pub 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
381pub 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
387pub 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 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
428fn 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
473fn 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
522fn 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 if let Some(mgr) = builtin_registry.mcp_manager() {
550 tools.set_mcp_manager(mgr);
551 }
552
553 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
567fn 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
608fn 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 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 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
691fn is_tui_mode(args: &CliArgs) -> bool {
695 if matches!(args.mode.as_deref(), Some("json" | "rpc")) || args.print {
696 return false;
697 }
698 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}