Skip to main content

oxios_kernel/host_tools/
registry.rs

1//! Integration registry (RFC-041 Phase 2).
2//!
3//! Declarative catalog of host integrations — each entry binds a CLI to detect
4//! (optional), install spec(s) (Phase 4), and a credential descriptor. The
5//! registry is loaded from `share/default-integrations.toml` and merged with
6//! user overrides in `~/.oxios/integrations.d/*.toml` (whole-entry replace by
7//! `id`).
8//!
9//! Credential model (H6): a single `env_var` cannot serve both
10//! `CredentialStore::resolve` (provider, 6-source) and `resolve_secret`
11//! (non-provider, 3-source). So the descriptor names the store key, the env
12//! var, **and** which resolver applies. The status endpoint must call the
13//! matching resolver — never poke one env var.
14
15use std::collections::HashMap;
16use std::path::Path;
17
18use anyhow::{Context, Result};
19use serde::{Deserialize, Serialize};
20
21use crate::credential::{CredentialSource, CredentialStore};
22use crate::skill::SkillInstallSpec;
23
24/// Coarse category of an integration, used by the UI to group entries with
25/// different card layouts (RFC-041 S1). Defaults to `CliTool` for backward
26/// compatibility with registries authored before the field existed.
27#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
28#[serde(rename_all = "snake_case")]
29pub enum IntegrationKind {
30    /// Package manager — `brew`/`npm`/`cargo`/etc. No credential, no install
31    /// specs (it *is* the bootstrap).
32    PackageManager,
33    /// CLI tool installed via a package manager or download. Has install
34    /// specs and (optionally) a credential. The default for legacy entries.
35    #[default]
36    CliTool,
37    /// Credential-only — no binary to detect or install (e.g. a bot token).
38    /// The card hides the install/detect affordances.
39    CredentialOnly,
40}
41// ─── Credential descriptor (fix H6) ──────────────────────────────────────────
42
43/// Which resolution path applies to a credential. Per D7 there is **no**
44/// `Provider` variant — LLM providers stay in `engine_api` and may only appear
45/// as read-only status cards via a separate UI path.
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
47#[serde(tag = "resolver", rename_all = "lowercase")]
48pub enum CredentialResolver {
49    /// No credential needed (package managers).
50    #[default]
51    None,
52    /// Non-provider secret → `CredentialStore::resolve_secret(store_key, env_var)`.
53    /// 3-source: raw env var → oxios store → oxi-cli store.
54    Secret {
55        /// Key passed to `load_token`/`save_token` and `resolve_secret`'s `key`.
56        store_key: String,
57        /// Raw env var name checked first by `resolve_secret`.
58        env_var: String,
59    },
60    /// OAuth device-code → `TokenBundle` stored under `store_key` (Phase 3).
61    /// `provider` names a Rust `OAuthProvider` impl (D9); the TOML selects
62    /// *which* provider + scopes, Rust implements *how*.
63    OAuth {
64        store_key: String,
65        provider: String,
66        #[serde(default)]
67        scopes: Vec<String>,
68    },
69}
70
71/// A single registry entry.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct Integration {
74    /// Stable identifier (URL path segment, skill `requires.integrations` ref).
75    pub id: String,
76    /// Human-readable label.
77    pub label: String,
78    /// Binary name to detect. `None` = credential-only (no CLI).
79    #[serde(default)]
80    pub cli: Option<String>,
81    /// Install instructions, reused verbatim from the skill system (Phase 4
82    /// executes them). Defaults to empty.
83    #[serde(default)]
84    pub install: Vec<SkillInstallSpec>,
85    /// Credential descriptor — which resolver applies + store key + env var.
86    #[serde(default)]
87    pub credential: CredentialResolver,
88    /// Coarse category for UI grouping (S1). Defaults to `CliTool`.
89    #[serde(default)]
90    pub kind: IntegrationKind,
91}
92
93// ─── Install-spec TOML shim ──────────────────────────────────────────────────
94//
95// `SkillInstallSpec` derives Deserialize but its `kind` is a typed enum. The
96// registry TOML writes `kind = "brew"` etc., which maps cleanly. We deserialize
97// through this shim to keep the registry file self-describing and tolerant of
98// future InstallKind variants (Cargo/Bun/Pip — Phase 4).
99#[derive(Debug, Deserialize)]
100struct RawIntegration {
101    id: String,
102    label: String,
103    #[serde(default)]
104    cli: Option<String>,
105    #[serde(default)]
106    install: Vec<SkillInstallSpec>,
107    #[serde(default)]
108    credential: CredentialResolver,
109    #[serde(default)]
110    kind: IntegrationKind,
111}
112
113impl From<RawIntegration> for Integration {
114    fn from(r: RawIntegration) -> Self {
115        Self {
116            id: r.id,
117            label: r.label,
118            cli: r.cli,
119            install: r.install,
120            credential: r.credential,
121            kind: r.kind,
122        }
123    }
124}
125#[derive(Debug, Deserialize)]
126struct RegistryFile {
127    #[serde(default, rename = "integration")]
128    integrations: Vec<RawIntegration>,
129}
130
131// ─── Registry ────────────────────────────────────────────────────────────────
132
133/// Loaded integration catalog, keyed by `id`. Built by merging the shipped
134/// defaults with user overrides (whole-entry replace by id).
135#[derive(Debug, Clone, Default)]
136pub struct IntegrationRegistry {
137    by_id: HashMap<String, Integration>,
138}
139
140impl IntegrationRegistry {
141    /// Parse a single TOML file's integrations.
142    fn parse_file(text: &str) -> Result<Vec<Integration>> {
143        let file: RegistryFile = toml::from_str(text).context("parsing integrations TOML")?;
144        Ok(file.integrations.into_iter().map(Into::into).collect())
145    }
146
147    /// Load defaults from an in-memory TOML string (compiled-in via
148    /// `include_str!`), then merge user overrides from `override_dir`.
149    /// This is the production path — always present regardless of CWD.
150    pub fn load_text(defaults_text: &str, override_dir: &Path) -> Result<Self> {
151        let mut by_id: HashMap<String, Integration> = HashMap::new();
152        for it in Self::parse_file(defaults_text)? {
153            by_id.insert(it.id.clone(), it);
154        }
155        Self::layer_overrides(&mut by_id, override_dir);
156        Ok(Self { by_id })
157    }
158
159    /// Load the shipped defaults from a file path, then merge user overrides.
160    /// Missing files are not errors. Used by tests.
161    pub fn load(defaults_path: &Path, override_dir: &Path) -> Result<Self> {
162        let mut by_id: HashMap<String, Integration> = HashMap::new();
163
164        if let Ok(text) = std::fs::read_to_string(defaults_path) {
165            for it in Self::parse_file(&text)? {
166                by_id.insert(it.id.clone(), it);
167            }
168        }
169
170        Self::layer_overrides(&mut by_id, override_dir);
171        Ok(Self { by_id })
172    }
173
174    /// Merge `override_dir/*.toml` into `by_id` (whole-entry replace by id).
175    fn layer_overrides(by_id: &mut HashMap<String, Integration>, override_dir: &Path) {
176        let Ok(entries) = std::fs::read_dir(override_dir) else {
177            return;
178        };
179        for entry in entries.flatten() {
180            let path = entry.path();
181            if path.extension().and_then(|e| e.to_str()) != Some("toml") {
182                continue;
183            }
184            if let Ok(text) = std::fs::read_to_string(&path) {
185                match Self::parse_file(&text) {
186                    Ok(items) => {
187                        for it in items {
188                            by_id.insert(it.id.clone(), it);
189                        }
190                    }
191                    Err(e) => {
192                        tracing::warn!(path = %path.display(), error = %e, "skipping invalid integrations override");
193                    }
194                }
195            }
196        }
197    }
198
199    /// All integrations in stable (insertion-sorted-by-key) order.
200    pub fn all(&self) -> Vec<&Integration> {
201        let mut ids: Vec<&String> = self.by_id.keys().collect();
202        ids.sort();
203        ids.iter().filter_map(|id| self.by_id.get(*id)).collect()
204    }
205
206    /// Look up one integration by id.
207    pub fn get(&self, id: &str) -> Option<&Integration> {
208        self.by_id.get(id)
209    }
210
211    /// Names of CLIs this registry wants detected (non-`None` `cli` fields).
212    pub fn cli_names(&self) -> Vec<String> {
213        let mut names: Vec<String> = self.by_id.values().filter_map(|i| i.cli.clone()).collect();
214        names.sort();
215        names.dedup();
216        names
217    }
218}
219
220// ─── Credential status (H6: calls the matching resolver) ────────────────────
221
222/// Where a resolved credential came from (mirrors `CredentialSource` for JSON).
223#[derive(Debug, Clone, Serialize)]
224#[serde(rename_all = "camelCase")]
225pub struct CredentialStatus {
226    /// True when a usable credential exists (resolver found one).
227    pub configured: bool,
228    /// Source label: `env` | `auth_store` | `config` | `oauth` | `none`.
229    pub source: String,
230}
231
232impl CredentialStatus {
233    /// Run the matching resolver for a credential descriptor and report status.
234    ///
235    /// Per H6, this NEVER pokes a single env var — it calls the full chain:
236    /// - `None` → always unconfigured.
237    /// - `Secret` → `CredentialStore::resolve_secret(store_key, env_var)` (3-source).
238    /// - `OAuth` → checks for a stored `TokenBundle` under `store_key`.
239    pub fn resolve(cred: &CredentialResolver) -> Self {
240        match cred {
241            CredentialResolver::None => Self {
242                configured: false,
243                source: "none".into(),
244            },
245            CredentialResolver::Secret { store_key, env_var } => {
246                match CredentialStore::resolve_secret(store_key, env_var) {
247                    Some((_, src)) => Self {
248                        configured: true,
249                        source: source_label(&src),
250                    },
251                    None => Self {
252                        configured: false,
253                        source: "none".into(),
254                    },
255                }
256            }
257            CredentialResolver::OAuth { store_key, .. } => {
258                // Phase 3 populates TokenBundle via save_token; for now just
259                // check presence so the status is truthful today.
260                let present = oxi_sdk::load_token(store_key)
261                    .map(|t| t.is_some() && !t.unwrap().access_token.is_empty())
262                    .unwrap_or(false);
263                Self {
264                    configured: present,
265                    source: if present {
266                        "oauth".into()
267                    } else {
268                        "none".into()
269                    },
270                }
271            }
272        }
273    }
274}
275
276fn source_label(s: &CredentialSource) -> String {
277    match s {
278        CredentialSource::Config => "config",
279        CredentialSource::OxiAuthStore => "auth_store",
280        CredentialSource::EnvVar => "env",
281    }
282    .into()
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    const SAMPLE_TOML: &str = r#"
290[[integration]]
291id = "brew"
292label = "Homebrew"
293cli = "brew"
294credential = { resolver = "none" }
295
296[[integration]]
297id = "resend"
298label = "Resend"
299cli = "resend"
300install = [{ kind = "node", package = "resend" }]
301credential = { resolver = "secret", store_key = "resend", env_var = "RESEND_API_KEY" }
302
303[[integration]]
304id = "github"
305label = "GitHub CLI"
306cli = "gh"
307credential = { resolver = "oauth", store_key = "github", provider = "github", scopes = ["repo"] }
308"#;
309
310    #[test]
311    fn parses_integration_toml() {
312        let items = IntegrationRegistry::parse_file(SAMPLE_TOML).unwrap();
313        assert_eq!(items.len(), 3);
314
315        let brew = items.iter().find(|i| i.id == "brew").unwrap();
316        assert_eq!(brew.credential, CredentialResolver::None);
317
318        let resend = items.iter().find(|i| i.id == "resend").unwrap();
319        match &resend.credential {
320            CredentialResolver::Secret { store_key, env_var } => {
321                assert_eq!(store_key, "resend");
322                assert_eq!(env_var, "RESEND_API_KEY");
323            }
324            other => panic!("expected Secret, got {other:?}"),
325        }
326        assert_eq!(resend.install.len(), 1);
327
328        let gh = items.iter().find(|i| i.id == "github").unwrap();
329        match &gh.credential {
330            CredentialResolver::OAuth {
331                provider, scopes, ..
332            } => {
333                assert_eq!(provider, "github");
334                assert_eq!(scopes, &["repo"]);
335            }
336            other => panic!("expected OAuth, got {other:?}"),
337        }
338    }
339
340    #[test]
341    fn shipped_registry_parses() {
342        let text = include_str!("../../share/default-integrations.toml");
343        let items = IntegrationRegistry::parse_file(text).unwrap();
344        assert!(items.iter().any(|integration| integration.id == "github"));
345        assert!(items.iter().any(|integration| integration.id == "resend"));
346    }
347
348    #[test]
349    fn cli_names_dedups() {
350        let reg = IntegrationRegistry {
351            by_id: toml::from_str::<RegistryFile>(SAMPLE_TOML)
352                .unwrap()
353                .integrations
354                .into_iter()
355                .map(Into::into)
356                .map(|i: Integration| (i.id.clone(), i))
357                .collect(),
358        };
359        let names = reg.cli_names();
360        assert_eq!(names, vec!["brew", "gh", "resend"]);
361    }
362
363    #[test]
364    fn none_resolver_is_unconfigured() {
365        let s = CredentialStatus::resolve(&CredentialResolver::None);
366        assert!(!s.configured);
367        assert_eq!(s.source, "none");
368    }
369}