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 tui_ownership_id()
42 } else {
43 proc_ownership_id()
44 };
45
46 let global_hooks = settings.hooks.clone();
52 let cwd_now = std::env::current_dir().unwrap_or_default();
53 let project_hooks_path = Settings::find_project_settings(&cwd_now);
54 let project_hooks: Vec<oxicode_sdk::ports::HookSpec> = match &project_hooks_path {
55 Some(path) => {
56 let content = std::fs::read_to_string(path).unwrap_or_default();
57 let hash = crate::store::hook_approval::hash_settings(&content);
58 let mut registry = crate::store::hook_approval::HookApprovalRegistry::load_or_default();
59 if registry.is_approved(&cwd_now, &hash) {
60 match Settings::parse_from_str(&content, Settings::detect_format(path)) {
62 Ok(s) => s.hooks,
63 Err(e) => {
64 tracing::warn!(error = %e, "project hooks file failed to parse");
65 Vec::new()
66 }
67 }
68 } else {
69 let count = content.matches("[[hooks]]").count();
71 if count > 0 {
72 if is_tui_mode(args) {
73 let ok = crate::store::hook_approval::prompt_for_approval(&cwd_now, count);
74 if ok {
75 registry.approve(&cwd_now, &hash);
76 let _ = registry.persist();
77 Settings::parse_from_str(&content, Settings::detect_format(path))
78 .map(|s| s.hooks)
79 .unwrap_or_default()
80 } else {
81 tracing::warn!("project hooks denied by user; skipping");
82 Vec::new()
83 }
84 } else {
85 tracing::warn!(
86 count,
87 "project hooks not approved; skipping (non-interactive mode)"
88 );
89 Vec::new()
90 }
91 } else {
92 Vec::new()
93 }
94 }
95 }
96 None => Vec::new(),
97 };
98 let mut all_hooks = global_hooks;
99 all_hooks.extend(project_hooks);
100 let hook_runner: std::sync::Arc<dyn oxicode_sdk::ports::HookRunner> =
101 match oxicode_sdk::ports::fs::CommandHookRunner::new(all_hooks) {
102 Ok(r) => std::sync::Arc::new(r),
103 Err(e) => {
104 tracing::warn!(error = %e, "hook runner construction failed; using empty runner");
105 std::sync::Arc::new(
106 oxicode_sdk::ports::fs::CommandHookRunner::new(Vec::new())
107 .expect("empty spec list is always valid"),
108 )
109 }
110 };
111
112 if settings
113 .effective_model(None)
114 .unwrap_or_default()
115 .is_empty()
116 {
117 if is_tui_mode(args) {
123 eprintln!("No model configured. Launching setup wizard...");
124 crate::setup_wizard::run().await?;
125
126 settings = Settings::load().unwrap_or_default();
130 apply_cli_overrides(&mut settings);
131 }
132
133 if settings
134 .effective_model(None)
135 .unwrap_or_default()
136 .is_empty()
137 {
138 eprintln!(
139 "{}",
140 print_mode::format_error("No model configured. Run `oxicode setup` to configure.")
141 );
142 std::process::exit(1);
143 }
144 }
145
146 register_custom_providers(&settings);
148
149 register_router_provider();
151
152 if let Some(ref level_str) = args.thinking {
154 if let Some(level) = crate::store::settings::parse_thinking_level(level_str) {
155 settings.thinking_level = level;
156 } else {
157 anyhow::bail!(
158 "Invalid thinking level: {}. Valid options: off, minimal, low, medium, high, xhigh",
159 level_str
160 );
161 }
162 }
163
164 let oxicode = crate::build_oxicode_engine(None, Some(hook_runner.clone())).await?;
171
172 {
174 let hook_ctx = oxicode_sdk::ports::HookContext {
175 event: oxicode_sdk::ports::HookEvent::SessionStart,
176 session_id: Some(ownership_session_id.clone()),
177 session_cwd: Some(cwd_now.clone()),
178 ..Default::default()
179 };
180 let _ = oxicode
181 .ports()
182 .hooks
183 .run(oxicode_sdk::ports::HookEvent::SessionStart, &hook_ctx)
184 .await;
185 }
186
187 let _catalog_logger =
191 crate::services::spawn_catalog_event_logger(std::sync::Arc::clone(oxicode.catalog()));
192
193 let session_state = crate::SessionState::default();
198
199 let mut app =
200 crate::App::from_oxicode(oxicode, settings, ownership_session_id, Some(session_state))
201 .await?;
202
203 if let Some(active_provider) = app.settings().effective_provider(args.provider.as_deref())
209 && !active_provider.is_empty()
210 {
211 let p = active_provider.clone();
212 tokio::spawn(async move {
213 if let Err(e) = crate::oauth_refresh::refresh_if_expired(&p).await {
214 tracing::debug!(provider = %p, error = %e, "oauth refresh skipped");
215 }
216 });
217 }
218
219 let mcp_cfg = oxicode_agent::mcp::config::load_mcp_config();
225 let mut oauth_map: std::collections::HashMap<String, oxicode_agent::mcp::types::OAuthConfig> =
226 std::collections::HashMap::new();
227 for (name, entry) in &mcp_cfg.mcp_servers {
228 if let Some(oc) = entry.oauth.clone() {
229 oauth_map.insert(name.clone(), oc);
230 }
231 }
232 if !oauth_map.is_empty()
233 && let Some(manager) = app.agent_tools().mcp_manager()
234 {
235 let config_dir = dirs::config_dir()
236 .map(|d| d.join("oxicode"))
237 .unwrap_or_else(|| std::path::PathBuf::from("."));
238 match crate::mcp_credentials::FileMcpCredentialProvider::new(oauth_map, config_dir) {
239 Ok(provider) => {
240 manager.set_credential_provider(provider);
241 }
242 Err(e) => {
243 tracing::warn!("Failed to construct MCP credential provider: {}", e);
244 }
245 }
246 }
247
248 let tools = app.agent_tools();
250 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
251 register_builtin_tools(
252 &tools,
253 &cwd,
254 args,
255 &app.settings().disabled_tools,
256 &app.settings().model_roles,
257 );
258
259 #[cfg(feature = "native-browser")]
265 {
266 match oxicode_agent::tools::browse::OxicodeBrowserEngine::new().await {
267 Ok(engine) => {
268 let (provider, model) = match (app.agent().model_id().is_empty(), app.oxicode()) {
269 (false, oxicode) => {
270 let model_id = app.agent().model_id();
271 let provider_name = "anthropic".to_string();
275 match (
276 oxicode.create_provider(&provider_name),
277 oxicode_ai::Model::new(
278 model_id.clone(),
279 model_id.clone(),
280 oxicode_ai::Api::AnthropicMessages,
281 provider_name.clone(),
282 String::new(),
283 ),
284 ) {
285 (Ok(p), m) => (Some(p), Some(m)),
286 _ => (None, None),
287 }
288 }
289 _ => (None, None),
290 };
291 let browser_registry = oxicode_agent::tools::browse::browsing_tools_with_session(
292 provider,
293 model,
294 std::sync::Arc::new(engine),
295 );
296 tools.extend_from(&browser_registry);
297 }
298 Err(e) => {
299 tracing::warn!("native browser engine unavailable; browse tools disabled: {e}");
300 }
301 }
302 }
303
304 let wasm_ext = load_wasm_extensions(&app, &cwd, &tools);
306 app.set_wasm_ext(wasm_ext);
307
308 if let Some(ref prompt_path) = args.append_system_prompt {
310 let content = std::fs::read_to_string(prompt_path)
311 .map_err(|e| anyhow::anyhow!("Failed to read system prompt file: {}", e))?;
312 app.agent().set_system_prompt(content);
313 }
314
315 Ok(app)
316}
317
318pub async fn dispatch_run_mode(args: &CliArgs, app: crate::App) -> Result<i32> {
320 let prompt = args.prompt.join(" ");
321
322 if args.mode.as_deref() == Some("json") || args.print {
323 let mode = if args.mode.as_deref() == Some("json") {
324 crate::print_mode::PrintMode::Json
325 } else {
326 crate::print_mode::PrintMode::Text
327 };
328 let options = crate::print_mode::PrintModeOptions {
329 mode,
330 initial_message: if prompt.is_empty() {
331 None
332 } else {
333 Some(prompt)
334 },
335 messages: vec![],
336 no_stdin: args.print,
337 no_session: args.print || args.no_session,
338 quiet: args.print,
339 timeout: args.timeout,
340 };
341 return crate::print_mode::run_print_mode(&app, options).await;
342 }
343
344 if args.mode.as_deref() == Some("rpc") {
345 crate::rpc_mode::run_rpc_mode(app).await?;
346 return Ok(0);
347 }
348
349 if let Some(mode) = args.mode.as_deref() {
350 anyhow::bail!("Unknown run mode: {mode}");
351 }
352
353 if prompt.is_empty() || args.interactive {
354 crate::tui_vt::run_tui(app).await?;
355 return Ok(0);
356 }
357
358 crate::main_dispatch::run_single_prompt(app, &prompt).await?;
359 Ok(0)
360}
361
362pub async fn run_with_args(args: CliArgs) -> Result<i32> {
364 let app = build_app(&args).await?;
365 dispatch_run_mode(&args, app).await
366}
367
368pub fn init_logging() {
375 let log_dir = dirs::cache_dir()
376 .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
377 .join("oxicode");
378 let _ = std::fs::create_dir_all(&log_dir);
379 let log_path = log_dir.join("oxicode.log");
380
381 let log_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "debug".to_string());
382 let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
383 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(&log_filter));
384
385 let subscriber = tracing_subscriber::fmt()
390 .with_env_filter(env_filter)
391 .with_target(true)
392 .with_thread_ids(true)
393 .with_ansi(false);
394 match std::fs::File::create(&log_path) {
395 Ok(file) => {
396 subscriber.with_writer(std::sync::Mutex::new(file)).init();
397 }
398 Err(e) => {
399 eprintln!(
400 "oxicode: could not open log file {log_path:?} ({e}); falling back to stderr"
401 );
402 subscriber.with_writer(std::io::stderr).init();
403 }
404 }
405
406 tracing::info!("Logging initialized, log file: {:?}", log_path);
407}
408
409fn register_custom_providers(settings: &Settings) {
411 let auth_storage = crate::store::auth_storage::shared_auth_storage();
412 for cp in &settings.custom_providers {
413 let api_key = auth_storage.get_api_key(&cp.name);
414 let api = cp.api.to_lowercase();
415
416 match api.as_str() {
417 "openai-completions" | "openai" => {
418 let provider = oxicode_ai::OpenAiProvider::with_base_url_and_key(
419 &cp.base_url,
420 api_key.clone(),
421 );
422 oxicode_sdk::register_provider(&cp.name, provider);
423 tracing::info!(
424 "Registered custom provider '{}' (openai-completions) -> {}",
425 cp.name,
426 cp.base_url
427 );
428 }
429 "openai-responses" | "responses" => {
430 let provider = oxicode_sdk::OpenAiResponsesProvider::with_base_url_and_key(
431 &cp.base_url,
432 api_key.clone(),
433 );
434 oxicode_sdk::register_provider(&cp.name, provider);
435 tracing::info!(
436 "Registered custom provider '{}' (openai-responses) -> {}",
437 cp.name,
438 cp.base_url
439 );
440 }
441 _ => {
442 tracing::warn!(
443 "Unknown API type '{}' for custom provider '{}'. Supported: openai-completions, openai-responses",
444 cp.api,
445 cp.name
446 );
447 }
448 }
449
450 fetch_and_register_models(cp, &api, &api_key);
451 }
452}
453
454fn fetch_and_register_models(
456 cp: &crate::store::settings::CustomProvider,
457 api: &str,
458 api_key: &Option<String>,
459) {
460 if let Some(key) = api_key {
461 match oxicode_sdk::fetch_models_blocking(&cp.base_url, key.as_str()) {
462 Ok(model_ids) => {
463 let count = model_ids.len();
464 for model_id in &model_ids {
465 let api_type = match api {
466 "openai-responses" | "responses" => oxicode_sdk::Api::OpenAiResponses,
467 _ => oxicode_sdk::Api::OpenAiCompletions,
468 };
469 let known = oxicode_sdk::find_entry_by_model_id(model_id);
475 let model = oxicode_sdk::Model {
476 id: model_id.clone(),
477 name: model_id.clone(),
478 api: api_type,
479 provider: cp.name.clone(),
480 base_url: cp.base_url.clone(),
481 reasoning: known.map(|e| e.reasoning).unwrap_or(false),
482 input: vec![oxicode_sdk::InputModality::Text],
483 cost: known
484 .map(|e| oxicode_sdk::Cost {
485 input: e.cost_input.max(0.0),
486 output: e.cost_output.max(0.0),
487 cache_read: e.cost_cache_read.max(0.0),
488 cache_write: e.cost_cache_write.max(0.0),
489 })
490 .unwrap_or_default(),
491 context_window: known.map(|e| e.context_window as usize).unwrap_or(128_000),
492 max_tokens: known.map(|e| e.max_tokens as usize).unwrap_or(8_192),
493 headers: Default::default(),
494 compat: None,
495 };
496 oxicode_sdk::register_model(model);
497 }
498 tracing::info!(
499 "[oxicode] auto-fetched {} models from '{}' ({})",
500 count,
501 cp.name,
502 cp.base_url
503 );
504 }
505 Err(e) => {
506 tracing::warn!(
507 "[oxicode] warning: failed to resolve models for {}: {}",
508 cp.name,
509 e
510 );
511 }
512 }
513 }
514}
515
516fn register_builtin_tools(
524 tools: &oxicode_agent::ToolRegistry,
525 cwd: &std::path::Path,
526 args: &CliArgs,
527 disabled_tools: &[String],
528 model_roles: &std::collections::HashMap<String, String>,
529) {
530 let builtin_registry = if let Some(ref tools_str) = args.tools {
531 let names: Vec<&str> = tools_str.split(',').map(|s| s.trim()).collect();
532 oxicode_agent::ToolRegistry::with_selected_tools(cwd.to_path_buf(), &names)
533 } else {
534 oxicode_agent::ToolRegistry::with_builtins_cwd(cwd.to_path_buf(), disabled_tools)
535 };
536 for name in builtin_registry.names() {
537 if let Some(tool) = builtin_registry.get(&name) {
538 tools.register_arc(tool);
539 }
540 }
541 if let Some(mgr) = builtin_registry.mcp_manager() {
544 tools.set_mcp_manager(mgr);
545 }
546
547 let role_registry = oxicode_sdk::RoleRegistry::from_map(model_roles.clone());
551 if let Some(model) =
552 oxicode_sdk::resolve_role_to_model(oxicode_sdk::ModelRole::Commit, &role_registry)
553 {
554 let commit: std::sync::Arc<dyn oxicode_agent::AgentTool> =
555 std::sync::Arc::new(oxicode_agent::CommitTool::new(model));
556 tools.register_arc(commit);
557 tracing::debug!("CommitTool upgraded to commit-role model");
558 }
559}
560
561fn load_wasm_extensions(
563 app: &crate::App,
564 cwd: &std::path::Path,
565 tools: &oxicode_agent::ToolRegistry,
566) -> Option<std::sync::Arc<crate::extensions::WasmExtensionManager>> {
567 if !app.settings().extensions_enabled {
568 return None;
569 }
570
571 let wasm_paths = crate::extensions::WasmExtensionManager::discover(cwd);
572 if wasm_paths.is_empty() {
573 return None;
574 }
575
576 let mut wasm_mgr = crate::extensions::WasmExtensionManager::new();
577 let (loaded, errors) = wasm_mgr.load_all(&wasm_paths);
578 for info in &loaded {
579 tracing::info!("WASM extension loaded: {} v{}", info.name, info.version);
580 }
581 for err in &errors {
582 tracing::warn!("WASM extension error: {}", err);
583 }
584
585 if wasm_mgr.is_empty() {
586 return None;
587 }
588
589 let mgr = std::sync::Arc::new(wasm_mgr);
590 for tool_def in mgr.all_tool_defs() {
591 let wasm_tool = crate::extensions::WasmTool::new(
592 mgr.clone(),
593 tool_def.name.clone(),
594 tool_def.description.clone(),
595 tool_def.schema.clone(),
596 );
597 tools.register(wasm_tool);
598 }
599 Some(mgr)
600}
601
602fn register_router_provider() {
604 let global_dir = dirs::config_dir().unwrap_or_default().join("oxicode");
605 let project_dir = std::env::current_dir().unwrap_or_default();
606
607 let store_cfg = match crate::store::router_config::load_router_config(&global_dir, &project_dir)
608 {
609 Some(cfg) => cfg,
610 None => {
611 tracing::debug!("No router config found — router/auto will not appear in model list");
612 return;
613 }
614 };
615
616 oxicode_sdk::register_model(oxicode_sdk::Model::new(
618 "auto",
619 "Router (auto)".to_string(),
620 oxicode_sdk::Api::AnthropicMessages,
621 "router",
622 "router://local",
623 ));
624
625 let mut ai_profiles = std::collections::HashMap::new();
627 for (name, sp) in store_cfg.profiles() {
628 fn parse_thinking(s: &Option<String>) -> Option<oxicode_sdk::ThinkingLevel> {
629 s.as_ref().and_then(|s| match s.as_str() {
630 "off" => Some(oxicode_sdk::ThinkingLevel::Off),
631 "minimal" => Some(oxicode_sdk::ThinkingLevel::Minimal),
632 "low" => Some(oxicode_sdk::ThinkingLevel::Low),
633 "medium" => Some(oxicode_sdk::ThinkingLevel::Medium),
634 "high" => Some(oxicode_sdk::ThinkingLevel::High),
635 "xhigh" => Some(oxicode_sdk::ThinkingLevel::XHigh),
636 _ => None,
637 })
638 }
639 ai_profiles.insert(
640 name.clone(),
641 oxicode_sdk::router::RouterProfile {
642 high: oxicode_sdk::router::RoutedTierConfig {
643 model: sp.high.model.clone(),
644 thinking: parse_thinking(&sp.high.thinking),
645 fallbacks: sp.high.fallbacks.clone(),
646 },
647 medium: oxicode_sdk::router::RoutedTierConfig {
648 model: sp.medium.model.clone(),
649 thinking: parse_thinking(&sp.medium.thinking),
650 fallbacks: sp.medium.fallbacks.clone(),
651 },
652 low: oxicode_sdk::router::RoutedTierConfig {
653 model: sp.low.model.clone(),
654 thinking: parse_thinking(&sp.low.thinking),
655 fallbacks: sp.low.fallbacks.clone(),
656 },
657 },
658 );
659 }
660 let ai_cfg = oxicode_sdk::router::RouterConfig::with_pinning(
661 store_cfg.default_profile().to_string(),
662 store_cfg.classifier_model().map(String::from),
663 store_cfg.context_upgrade_threshold(),
664 store_cfg.max_session_budget(),
665 ai_profiles,
666 oxicode_sdk::router::ScoringWeights {
667 structural: store_cfg.weights().structural,
668 behavioral: store_cfg.weights().behavioral,
669 context_budget: store_cfg.weights().context_budget,
670 vision: store_cfg.weights().vision,
671 message: store_cfg.weights().message,
672 },
673 store_cfg.pin_tier().and_then(|s| match s {
674 "high" => Some(oxicode_sdk::router::RouterTier::High),
675 "medium" => Some(oxicode_sdk::router::RouterTier::Medium),
676 "low" => Some(oxicode_sdk::router::RouterTier::Low),
677 _ => None,
678 }),
679 store_cfg.phase_bias(),
680 );
681
682 oxicode_sdk::router::register_router(&ai_cfg);
683}
684
685pub(crate) fn tui_ownership_id() -> String {
695 format!(
696 "tui-{}-{}",
697 std::process::id(),
698 uuid::Uuid::new_v4().simple()
699 )
700}
701
702pub(crate) fn proc_ownership_id() -> String {
704 format!(
705 "proc-{}-{}",
706 std::process::id(),
707 uuid::Uuid::new_v4().simple()
708 )
709}
710
711fn is_tui_mode(args: &CliArgs) -> bool {
715 if matches!(args.mode.as_deref(), Some("json" | "rpc")) || args.print {
716 return false;
717 }
718 let prompt = args.prompt.join(" ");
725 if !args.interactive && !prompt.is_empty() {
726 return false;
727 }
728 true
729}
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use clap::Parser;
734
735 #[test]
736 fn rpc_mode_is_headless() {
737 let args = CliArgs::try_parse_from(["oxicode", "--mode", "rpc"]).unwrap();
738 assert!(!is_tui_mode(&args));
739 }
740
741 #[test]
742 fn empty_hooks_does_not_block() {
743 use crate::store::settings::Settings;
744 let s = Settings::default();
745 assert!(s.hooks.is_empty());
746 }
747
748 #[test]
749 fn tui_ownership_ids_are_unique_and_prefixed() {
750 let a = tui_ownership_id();
754 let b = tui_ownership_id();
755 assert!(a.starts_with("tui-"), "prefix missing: {a}");
756 assert!(b.starts_with("tui-"), "prefix missing: {b}");
757 assert_ne!(a, b, "ids must be unique per call (pid + uuid)");
758 assert!(!a.contains(' '), "no whitespace: {a}");
759 }
760}