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