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