Skip to main content

oxicode/
services.rs

1//! Composition root for oxicode-cli.
2//!
3//! Wires concrete file-based port implementations (from `oxicode-fs`) to
4//! the `Oxicode` engine. Future run modes (TUI / print / RPC) build on
5//! top of the `Oxicode` produced here.
6//!
7//! Migration note:
8//! - Legacy `App` in `lib.rs` is the single-user interactive
9//!   composition. This module is the port-based composition.
10//! - Both paths coexist; new run modes consume `build_oxicode(...)` here.
11
12use std::path::{Path, PathBuf};
13use std::pin::Pin;
14use std::sync::Arc;
15
16use anyhow::{Context, Result};
17
18use oxicode_sdk::Oxicode;
19use oxicode_sdk::fs::{
20    FileConfigStore, FileModelCatalog, FilePersonaProvider, FileSkillLoader, FileStateStore,
21    SimpleAccessGate, TomlCapabilityResolver,
22};
23use oxicode_sdk::inmem::{
24    CountingResourceMonitor, InMemoryCronScheduler, InMemoryMemoryStore, InProcessEventBus,
25};
26use oxicode_sdk::ports::InternalUrlRouter;
27use oxicode_sdk::ports::catalog::CatalogEvent;
28use oxicode_sdk::ports::fs::CatalogConfig;
29use oxicode_sdk::ports::inmem::url_router::CompositeUrlRouter;
30
31use crate::internal_urls::issue_handler::IssueProtocolHandler;
32use crate::internal_urls::memory_handler::MemoryProtocolHandler;
33use crate::internal_urls::pr_handler::PrProtocolHandler;
34
35/// Resolved paths under the oxicode home directory.
36#[derive(Debug, Clone)]
37pub struct OxicodePaths {
38    /// Root directory (`$OXICODE_HOME` or `$HOME/.oxicode`).
39    pub home: PathBuf,
40    /// `auth.json` location.
41    pub auth: PathBuf,
42    /// `settings.toml` location.
43    pub config: PathBuf,
44    /// Sessions directory.
45    pub sessions: PathBuf,
46    /// Skills root.
47    pub skills: PathBuf,
48    /// Oxi Foundation root. Independent from `home` and resolved
49    /// via `$OXI_FOUNDATION_HOME` or `~/.oxi/foundation/v1`. Set
50    /// to `None` when the foundation is not installed; the
51    /// composition root enters offline mode in that case.
52    pub foundation: Option<PathBuf>,
53}
54
55impl OxicodePaths {
56    /// Resolve from the conventional home directory.
57    pub fn from_home(home: impl Into<PathBuf>) -> Self {
58        let home = home.into();
59        Self {
60            auth: home.join("auth.json"),
61            config: home.join("settings.toml"),
62            sessions: home.join("sessions"),
63            skills: home.join("skills"),
64            home,
65            foundation: crate::foundation::foundation_root(),
66        }
67    }
68
69    /// Default — uses `$OXICODE_HOME` or `$HOME/.oxicode`.
70    pub fn default_paths() -> Result<Self> {
71        oxicode_sdk::fs::home_dir()
72            .map(Self::from_home)
73            .context("could not resolve oxicode home directory")
74    }
75}
76
77/// Build an `Oxicode` engine wired with file-based port implementations.
78///
79/// This is the **composition root** for oxicode-cli. The catalog port
80/// performs network I/O during `init()`. Errors there fall back to
81/// a noop catalog so the user can re-run `oxicode refresh` later.
82///
83/// `hook_runner` registers the user's configured [`HookRunner`](oxicode_sdk::ports::HookRunner)
84/// (global + approved-project `[[hooks]]`) on the SDK's port registry. Pass
85/// `None` to keep the noop runner (default).
86pub async fn build_oxicode(
87    paths: &OxicodePaths,
88    embedding_provider: Option<Arc<dyn oxicode_sdk::ports::EmbeddingProvider>>,
89    hook_runner: Option<Arc<dyn oxicode_sdk::ports::HookRunner>>,
90) -> Result<Oxicode> {
91    build_oxicode_with_catalog(
92        paths,
93        build_catalog_config(paths),
94        embedding_provider,
95        hook_runner,
96    )
97    .await
98}
99
100/// Build an `Oxicode` engine with a custom catalog config. Useful for
101/// tests (e.g. pointing the catalog at a tempdir).
102///
103/// `hook_runner` follows the same semantics as [`build_oxicode`]: `Some`
104/// installs the runner on the SDK's port registry, `None` keeps the noop
105/// default.
106pub async fn build_oxicode_with_catalog(
107    paths: &OxicodePaths,
108    catalog_config: CatalogConfig,
109    embedding_provider: Option<Arc<dyn oxicode_sdk::ports::EmbeddingProvider>>,
110    hook_runner: Option<Arc<dyn oxicode_sdk::ports::HookRunner>>,
111) -> Result<Oxicode> {
112    ensure_parent(&paths.auth)?;
113    ensure_parent(&paths.config)?;
114    ensure_parent(&paths.sessions)?;
115
116    // Foundation v1 host: when a foundation installation is present,
117    // resolve the profile (explicit id → role → env override →
118    // one-time compatibility import), look up the Keychain credential,
119    // and register ONLY the selected provider with the resolved key.
120    // Provider/model registration is gated on profile + credential
121    // validation succeeding — plan §3.b, §3.f. Other built-in
122    // providers remain constructable but cannot be invoked because
123    // they carry no credentials. The same pattern handles the
124    // `OXICODE_PROVIDER`/`OXICODE_MODEL` automation override.
125    let foundation_provider: Option<Arc<dyn oxicode_ai::Provider>> = if let Some(froot) = paths
126        .foundation
127        .clone()
128        .or_else(crate::foundation::foundation_root)
129    {
130        if crate::foundation::foundation_present(&froot) {
131            match resolve_and_register_profile(&froot).await {
132                Ok(p) => Some(p),
133                Err(e) => {
134                    tracing::warn!(
135                        "Foundation v1 profile resolution failed: {e}; \
136                             engine will start without a registered provider"
137                    );
138                    None
139                }
140            }
141        } else {
142            None
143        }
144    } else {
145        None
146    };
147
148    let catalog: Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog> =
149        match FileModelCatalog::init(catalog_config).await {
150            Ok(c) => c,
151            Err(e) => {
152                tracing::warn!(error = %e, "catalog init failed; continuing with noop");
153                oxicode_sdk::NoopModelCatalog::new()
154            }
155        };
156
157    let skill_loader = Arc::new(FileSkillLoader::single(&paths.skills));
158    let rule_registry: Arc<dyn oxicode_sdk::ports::RuleRegistry> =
159        Arc::new(oxicode_sdk::ports::NoopRuleRegistry);
160    let agent_artifact_store = crate::internal_urls::agent_handler::AgentArtifactStore::new();
161    let local_root = paths.home.join("local-artifacts");
162
163    let mut builder = oxicode_sdk::OxicodeBuilder::new()
164        .with_builtins()
165        .with_state(Arc::new(FileStateStore::new(&paths.sessions)))
166        .with_auth(crate::store::auth_storage::shared_auth_storage())
167        .with_config(Arc::new(FileConfigStore::new(&paths.config)))
168        .with_skills(skill_loader.clone())
169        .with_personas(Arc::new(FilePersonaProvider::new(
170            paths.home.join("personas"),
171        )))
172        .with_access(Arc::new(SimpleAccessGate::from_file(
173            paths.home.join("access.toml"),
174        )))
175        .with_capabilities(Arc::new(TomlCapabilityResolver::from_file(
176            paths.home.join("capabilities.toml"),
177        )))
178        .with_event_bus(InProcessEventBus::new(64))
179        .with_memory(Arc::new(InMemoryMemoryStore::new()))
180        .with_cron(Arc::new(InMemoryCronScheduler::new()))
181        .with_resources(Arc::new(CountingResourceMonitor::new()))
182        .with_catalog(catalog)
183        .with_url_router(build_url_router(
184            paths,
185            skill_loader,
186            rule_registry,
187            agent_artifact_store,
188            local_root,
189        ));
190
191    if let Some(ep) = embedding_provider {
192        builder = builder.with_embeddings(ep);
193    }
194    if let Some(runner) = hook_runner {
195        builder = builder.with_hooks(runner.clone());
196    }
197    if let Some(provider) = foundation_provider {
198        builder = builder.provider_arc("<foundation>", provider);
199    }
200
201    let oxicode = builder.build();
202
203    Ok(oxicode)
204}
205fn build_url_router(
206    paths: &OxicodePaths,
207    skill_loader: Arc<dyn oxicode_sdk::ports::SkillLoader>,
208    rule_registry: Arc<dyn oxicode_sdk::ports::RuleRegistry>,
209    agent_store: crate::internal_urls::agent_handler::AgentArtifactStore,
210    local_root: PathBuf,
211) -> Arc<dyn InternalUrlRouter> {
212    let memory_root = paths.home.join("memory");
213    let router = CompositeUrlRouter::new();
214    // Foundation v1 host: `memory://` resolves through the
215    // brain-backed handler when the foundation installation is
216    // present. When no foundation is present (test fixtures, host
217    // without oxibrain yet), the handler falls back to the legacy
218    // disk-rooted resolver so pre-Foundation callers continue to
219    // work.
220    let handler: Arc<dyn oxicode_sdk::ports::ProtocolHandler> =
221        if crate::foundation::foundation_present(
222            &crate::foundation::foundation_root()
223                .unwrap_or_else(|| std::path::PathBuf::from("~/.oxi/foundation/v1")),
224        ) {
225            let socket = crate::foundation::brain::default_socket_path();
226            let brain = Arc::new(crate::foundation::brain::BrainMemoryBackend::new(socket));
227            Arc::new(MemoryProtocolHandler::new(brain))
228        } else {
229            // Legacy disk-rooted fallback. NOT used under the
230            // Foundation v1 host — see
231            // `resolve_memory_url_legacy` for the deprecation
232            // context.
233            struct LegacyHandler {
234                memory_root: PathBuf,
235            }
236            #[async_trait::async_trait]
237            impl oxicode_sdk::ports::ProtocolHandler for LegacyHandler {
238                fn scheme(&self) -> &str {
239                    "memory"
240                }
241                async fn resolve(
242                    &self,
243                    url: &str,
244                    _selector: Option<&str>,
245                    _ctx: &oxicode_sdk::ports::ResolveContext,
246                ) -> Result<oxicode_sdk::ports::ResolvedUrl, oxicode_sdk::SdkError>
247                {
248                    let content = crate::internal_urls::memory_handler::resolve_memory_url_legacy(
249                        url,
250                        &self.memory_root,
251                    )
252                    .ok_or_else(|| oxicode_sdk::SdkError::PortNotConfigured { port: "memory" })?;
253                    let size = content.len();
254                    Ok(oxicode_sdk::ports::ResolvedUrl {
255                        url: url.to_string(),
256                        content,
257                        content_type: "text/markdown".to_string(),
258                        size: Some(size),
259                        source_path: None,
260                        notes: vec![],
261                        immutable: true,
262                    })
263                }
264            }
265            Arc::new(LegacyHandler { memory_root })
266        };
267    router.register(handler);
268    router.register(Arc::new(IssueProtocolHandler));
269    router.register(Arc::new(PrProtocolHandler));
270    router.register(Arc::new(
271        crate::internal_urls::skill_handler::SkillProtocolHandler::new(skill_loader),
272    ));
273    router.register(Arc::new(
274        crate::internal_urls::rule_handler::RuleProtocolHandler::new(rule_registry),
275    ));
276    router.register(Arc::new(
277        crate::internal_urls::agent_handler::AgentProtocolHandler::new(agent_store),
278    ));
279    router.register(Arc::new(
280        crate::internal_urls::local_handler::LocalProtocolHandler::new(local_root),
281    ));
282    Arc::new(router)
283}
284
285/// Build a `CatalogConfig` rooted at `paths.home`.
286fn build_catalog_config(paths: &OxicodePaths) -> CatalogConfig {
287    CatalogConfig {
288        cache_path: paths.home.join("cache").join("models-dev.json"),
289        etag_path: paths.home.join("cache").join("models-dev.json.etag"),
290        override_path: paths.home.join("catalog").join("overrides.toml"),
291        mtime_window: std::time::Duration::from_secs(60 * 60),
292        fetch_enabled: std::env::var("OXICODE_MODELS_DEV_DISABLE_FETCH")
293            .ok()
294            .map(|v| !matches!(v.as_str(), "1" | "true" | "TRUE"))
295            .unwrap_or(true),
296        models_dev_url: std::env::var("OXICODE_MODELS_DEV_URL")
297            .unwrap_or_else(|_| "https://models.dev".to_string()),
298        user_agent: format!("oxicode-cli/{}", env!("CARGO_PKG_VERSION")),
299        local_discovery_urls: local_discovery_from_env(),
300        snapshot_path: paths.home.join("cache").join("models-dev.json"),
301    }
302}
303
304/// Resolve local-discovery URLs from environment.
305///
306/// `OXICODE_LOCAL_DISCOVERY` is a comma-separated list of base URLs.
307fn local_discovery_from_env() -> Vec<String> {
308    std::env::var("OXICODE_LOCAL_DISCOVERY")
309        .ok()
310        .map(|s| {
311            s.split(',')
312                .map(|u| u.trim().to_string())
313                .filter(|u| !u.is_empty())
314                .collect()
315        })
316        .unwrap_or_default()
317}
318
319/// Spawn a background task that drains the catalog event channel and
320/// logs at info level.
321pub fn spawn_catalog_event_logger(
322    catalog: Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog>,
323) -> tokio::task::JoinHandle<()> {
324    let mut rx = catalog.subscribe();
325    tokio::spawn(async move {
326        while let Ok(event) = rx.recv().await {
327            match event {
328                CatalogEvent::Updated {
329                    provider_count,
330                    model_count,
331                } => {
332                    tracing::info!(provider_count, model_count, "catalog refreshed");
333                }
334                CatalogEvent::RefreshFailed { reason, .. } => {
335                    tracing::warn!(reason, "catalog refresh failed");
336                }
337                CatalogEvent::OverrideApplied {
338                    path,
339                    provider_overrides,
340                    model_overrides,
341                } => {
342                    tracing::info!(
343                        path = %path.display(),
344                        provider_overrides,
345                        model_overrides,
346                        "catalog overrides applied"
347                    );
348                }
349                CatalogEvent::LocalDiscovered {
350                    base_url,
351                    model_count,
352                } => {
353                    tracing::info!(base_url, model_count, "local models discovered");
354                }
355            }
356        }
357    })
358}
359
360fn ensure_parent(path: &Path) -> Result<()> {
361    if let Some(parent) = path.parent() {
362        std::fs::create_dir_all(parent)
363            .with_context(|| format!("create_dir_all {}", parent.display()))?;
364    }
365    Ok(())
366}
367
368// ── Memory embedding provider (Hindsight ④ + Gap-1 wiring) ────
369
370/// Build the embedding provider configured by the user.
371///
372/// Returns `None` when `settings.embedding_provider == "none"`,
373/// when base URL / API key are missing, or when the remote provider
374/// fails to construct. Failures are non-fatal.
375pub fn build_embedding_provider(
376    settings: &crate::store::settings::Settings,
377) -> Option<Arc<dyn oxicode_mnemopi::EmbeddingProvider>> {
378    match settings.embedding_provider.as_str() {
379        "remote" => build_remote_embedding_provider(settings),
380        _ => None,
381    }
382}
383
384/// Construct a `RemoteEmbeddingProvider` from settings.
385fn build_remote_embedding_provider(
386    settings: &crate::store::settings::Settings,
387) -> Option<Arc<dyn oxicode_mnemopi::EmbeddingProvider>> {
388    let base_url = settings.embedding_base_url.as_deref()?.trim();
389    if base_url.is_empty() {
390        tracing::warn!("memory: embedding_provider='remote' but embedding_base_url is empty");
391        return None;
392    }
393    let api_key = std::env::var(&settings.embedding_api_key_env).ok()?;
394    if api_key.is_empty() {
395        tracing::warn!(
396            "memory: embedding_provider='remote' but env var {} is unset",
397            settings.embedding_api_key_env
398        );
399        return None;
400    }
401    let model = if settings.embedding_model.is_empty() {
402        "text-embedding-3-small".to_string()
403    } else {
404        settings.embedding_model.clone()
405    };
406    Some(Arc::new(oxicode_mnemopi::RemoteEmbeddingProvider::new(
407        base_url, &api_key, &model,
408    )))
409}
410
411// ── Embedding port bridge (mnemopi → SDK async port) ──────────────────
412
413/// Bridges oxicode-mnemopi's synchronous [`oxicode_mnemopi::EmbeddingProvider`] to the SDK's
414/// async [`oxicode_sdk::ports::EmbeddingProvider`] port trait. Each `embed()`
415/// call runs on the blocking thread pool via `spawn_blocking`.
416pub struct MnemopiEmbeddingBridge {
417    inner: Arc<dyn oxicode_mnemopi::EmbeddingProvider>,
418}
419
420impl MnemopiEmbeddingBridge {
421    /// Wrap a mnemopi embedding provider into the SDK port trait.
422    pub fn new(inner: Arc<dyn oxicode_mnemopi::EmbeddingProvider>) -> Self {
423        Self { inner }
424    }
425}
426
427impl oxicode_sdk::ports::EmbeddingProvider for MnemopiEmbeddingBridge {
428    fn embed<'a>(
429        &'a self,
430        text: &'a str,
431    ) -> Pin<Box<dyn Future<Output = Result<Vec<f32>, oxicode_sdk::SdkError>> + Send + 'a>> {
432        Box::pin(async move {
433            let inner = Arc::clone(&self.inner);
434            let text = text.to_string();
435            let result = tokio::task::spawn_blocking(move || inner.embed(&[text]))
436                .await
437                .map_err(|e| {
438                    oxicode_sdk::SdkError::Internal(anyhow::anyhow!("embedding task panicked: {e}"))
439                })?;
440            let mut vectors = result.map_err(|e| {
441                oxicode_sdk::SdkError::Internal(anyhow::anyhow!("embedding failed: {e}"))
442            })?;
443            vectors.pop().ok_or_else(|| {
444                oxicode_sdk::SdkError::Internal(anyhow::anyhow!("embedding returned no vectors"))
445            })
446        })
447    }
448}
449
450// ── Memory backend helpers (Hindsight ④) ──────────────────────────────
451
452/// Create a memory backend if memory is enabled in settings.
453///
454/// Under the Oxi Foundation v1 host, the only durable-memory authority
455/// is the oxibrain daemon (plan §5). Local SQLite/Mnemopi/JSON/
456/// file-summary fallbacks are explicitly forbidden (§5.h, §6.f):
457/// the Foundation host MUST NOT silently run a second durable store.
458///
459/// When the Foundation installation is present, returns a
460/// [`crate::foundation::brain::BrainMemoryBackend`] wrapping a typed
461/// `oxibrain_client` over the default socket path. When the Foundation
462/// is absent, returns
463/// `None` — the agent memory tools surface a typed
464/// "backend unavailable: ..." result with the recovery command. Code
465/// work continues; only durable-memory tool calls fail visibly.
466pub fn create_memory_backend(
467    settings: &crate::store::settings::Settings,
468) -> Option<Arc<dyn oxicode_agent::tools::MemoryBackend>> {
469    if !settings.memory_enabled {
470        return None;
471    }
472    let foundation_root = crate::foundation::foundation_root()
473        .unwrap_or_else(|| std::path::PathBuf::from("~/.oxi/foundation/v1"));
474    if crate::foundation::foundation_present(&foundation_root) {
475        let socket = crate::foundation::brain::default_socket_path();
476        let backend = crate::foundation::brain::BrainMemoryBackend::new(socket);
477        tracing::info!(
478            "Foundation v1 host active: durable memory authority is oxibrain \
479             (health: {})",
480            backend.health().info()
481        );
482        return Some(Arc::new(backend));
483    }
484    tracing::warn!(
485        "Foundation v1 host: oxibrain daemon unavailable; durable-memory \
486         tools will return typed unavailable results. Run `oxicode setup` to \
487         initialize the Foundation installation, or start the oxibrain daemon."
488    );
489    None
490}
491
492#[cfg(test)]
493mod memory_backend_tests {
494    use super::*;
495
496    #[test]
497    fn brain_backend_returned_when_foundation_present() {
498        let tmp = tempdir_fixture();
499        unsafe {
500            std::env::set_var("OXI_FOUNDATION_HOME", &tmp);
501        }
502        // Build a minimal `foundation.json` + `profiles.json` so
503        // `foundation_present` returns true.
504        std::fs::write(
505            tmp.join("foundation.json"),
506            r#"{"schema_version":1,"foundation":{"hosts":{"oxicode":">=0.1.0"}}}"#,
507        )
508        .unwrap();
509        std::fs::write(
510            tmp.join("profiles.json"),
511            r#"{"schema_version":1,"profiles":[]}"#,
512        )
513        .unwrap();
514        let backend = create_memory_backend(&test_settings());
515        assert!(backend.is_some(), "foundation fixture ⇒ brain backend");
516        unsafe {
517            std::env::remove_var("OXI_FOUNDATION_HOME");
518        }
519    }
520
521    #[test]
522    fn absent_foundation_returns_none() {
523        unsafe {
524            std::env::set_var("OXI_FOUNDATION_HOME", "/tmp/does-not-exist-foundation");
525        }
526        let backend = create_memory_backend(&test_settings());
527        assert!(
528            backend.is_none(),
529            "absent foundation ⇒ no local durable fallback (plan §5.h)"
530        );
531        unsafe {
532            std::env::remove_var("OXI_FOUNDATION_HOME");
533        }
534    }
535
536    fn test_settings() -> crate::store::settings::Settings {
537        let mut s = crate::store::settings::Settings::default();
538        s.memory_enabled = true;
539        s
540    }
541
542    fn tempdir_fixture() -> std::path::PathBuf {
543        let dir = std::env::temp_dir().join(format!(
544            "oxicode-services-test-{}",
545            std::time::SystemTime::now()
546                .duration_since(std::time::UNIX_EPOCH)
547                .unwrap()
548                .as_nanos()
549        ));
550        std::fs::create_dir_all(&dir).unwrap();
551        dir
552    }
553}
554
555/// Build a project-memory recall block for injection into the system
556/// prompt. Returns an empty string when no memories exist.
557pub async fn build_memory_recall(
558    backend: &dyn oxicode_agent::tools::MemoryBackend,
559    subject: &str,
560) -> String {
561    match backend.list(subject).await {
562        Ok(items) if !items.is_empty() => {
563            let mut block = String::from(
564                "\n\n## Project Memory\n\nThe following facts were learned in previous sessions:\n",
565            );
566            for item in &items {
567                block.push_str(&format!("- [{}] {}\n", item.kind, item.content));
568            }
569            block
570        }
571        _ => String::new(),
572    }
573}
574
575/// Store a session summary into the memory backend.
576///
577/// **NOTE: currently uncalled** — defined as a future hook point for
578/// session-end reflection. Nothing wires it to session lifecycle yet.
579/// See FINAL-ROADMAP.md §알려진 갭 (⑨ mental-models).
580pub async fn session_reflect(
581    backend: &dyn oxicode_agent::tools::MemoryBackend,
582    subject: &str,
583    summary: &str,
584) {
585    if let Err(e) = backend.put(summary, "summary", subject).await {
586        tracing::warn!("Failed to store session memory: {e}");
587    }
588}
589
590/// Open (or create) the autonomous-memory pipeline DB and spawn the
591/// background Phase-1 / Phase-2 workers. Returns `None` when the
592/// pipeline is disabled (default).
593///
594/// When `oxicode` is `Some`, the pipeline resolves a memory extraction
595/// model from settings and creates a provider for actual LLM calls.
596/// Without it, workers run but skip LLM-dependent work.
597/// Stub. Plan §5.e/§6.f: durable-memory consolidation runs on the
598/// oxibrain daemon, never as a local worker pipeline. This stub
599/// remains so callers compile; it always returns `None`.
600pub fn start_memory_pipeline(
601    _settings: &crate::store::settings::Settings,
602    _cwd: &Path,
603    _oxicode: Option<&oxicode_sdk::Oxicode>,
604) -> Option<tokio::task::JoinHandle<()>> {
605    None
606}
607
608// ── Foundation profile → provider registration ────────────────────────────
609
610/// Resolve a Foundation profile and register the selected provider.
611///
612/// Precedence (plan §2.c):
613///   1. `OXICODE_PROVIDER` + `OXICODE_MODEL` env override.
614///   2. Explicit `--profile` / `OXICODE_PROFILE` id.
615///   3. Role-compatible Foundation profile.
616///   4. One-time compatibility import (gated by `OXICODE_FOUNDATION_MIGRATION=1`).
617///
618/// The resolved credential is read from the OS Keychain; the
619/// provider/model is registered only when profile + credential
620/// validation succeeds. Errors are reported but never silently
621/// replaced by another remote provider (plan §3.f).
622async fn resolve_and_register_profile(
623    foundation_root: &Path,
624) -> Result<Arc<dyn oxicode_ai::Provider>, crate::foundation::FoundationError> {
625    use crate::foundation::profiles::{
626        EnvironmentOverride, ResolveInput, read as read_profiles, resolve_profile,
627    };
628
629    let profiles_path = foundation_root.join(crate::foundation::files::PROFILES);
630    let profiles = read_profiles(&profiles_path)?;
631    let explicit_profile = std::env::var("OXICODE_PROFILE")
632        .ok()
633        .filter(|s| !s.trim().is_empty());
634    let compat_import_path = foundation_root.join("compatibility.json");
635    let compat_import =
636        crate::foundation::compat_import::read_compatibility_shim(&compat_import_path)?;
637
638    let env_override = EnvironmentOverride::from_env();
639
640    let resolved = resolve_profile(ResolveInput {
641        explicit_profile: explicit_profile.as_deref(),
642        explicit_environment_override: env_override.as_ref(),
643        requested_role: None,
644        foundation_profiles: &profiles,
645        compatibility_import: compat_import.as_ref(),
646    })?;
647
648    let resolver = crate::foundation::credentials::KeychainCredentialResolver::default();
649    let credential = resolver.resolve(&resolved.profile);
650    let api_key = match credential {
651        crate::foundation::credentials::Credential::Keychain(s)
652        | crate::foundation::credentials::Credential::Environment(s) => s,
653        crate::foundation::credentials::Credential::Unavailable(e) => {
654            return Err(crate::foundation::FoundationError::KeychainUnavailable(
655                e.to_string(),
656            ));
657        }
658    };
659    let provider_name = resolved.profile.provider.as_str();
660    let provider: Arc<dyn oxicode_ai::Provider> = Arc::from(
661        oxicode_ai::register_builtins::create_builtin_provider_with_options(
662            provider_name,
663            Some(&api_key),
664            None,
665        )
666        .ok_or_else(|| {
667            crate::foundation::FoundationError::IncompatibleHost(provider_name.to_string())
668        })?,
669    );
670
671    tracing::info!(
672        provider = provider_name,
673        model = %resolved.profile.model,
674        source = ?resolved.source,
675        "Foundation profile resolved with Keychain credential"
676    );
677    Ok(provider)
678}
679#[cfg(test)]
680mod tests {
681
682    use super::*;
683
684    #[test]
685    fn paths_are_consistent() {
686        let p = OxicodePaths::from_home("/tmp/oxicode-test");
687        assert!(p.auth.starts_with("/tmp/oxicode-test"));
688        assert!(p.config.starts_with("/tmp/oxicode-test"));
689        assert!(p.sessions.starts_with("/tmp/oxicode-test"));
690        assert!(p.skills.starts_with("/tmp/oxicode-test"));
691    }
692
693    #[tokio::test]
694    async fn build_oxicode_succeeds() {
695        let tmp = tempfile::TempDir::new().unwrap();
696        let paths = OxicodePaths::from_home(tmp.path());
697        let oxicode = build_oxicode(&paths, None, None).await.unwrap();
698        let _ = oxicode.ports().state;
699    }
700}