1use 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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
28#[serde(rename_all = "snake_case")]
29pub enum IntegrationKind {
30 PackageManager,
33 #[default]
36 CliTool,
37 CredentialOnly,
40}
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
47#[serde(tag = "resolver", rename_all = "lowercase")]
48pub enum CredentialResolver {
49 #[default]
51 None,
52 Secret {
55 store_key: String,
57 env_var: String,
59 },
60 OAuth {
64 store_key: String,
65 provider: String,
66 #[serde(default)]
67 scopes: Vec<String>,
68 },
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct Integration {
74 pub id: String,
76 pub label: String,
78 #[serde(default)]
80 pub cli: Option<String>,
81 #[serde(default)]
84 pub install: Vec<SkillInstallSpec>,
85 #[serde(default)]
87 pub credential: CredentialResolver,
88 #[serde(default)]
90 pub kind: IntegrationKind,
91}
92
93#[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#[derive(Debug, Clone, Default)]
136pub struct IntegrationRegistry {
137 by_id: HashMap<String, Integration>,
138}
139
140impl IntegrationRegistry {
141 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 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 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 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 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 pub fn get(&self, id: &str) -> Option<&Integration> {
208 self.by_id.get(id)
209 }
210
211 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#[derive(Debug, Clone, Serialize)]
224#[serde(rename_all = "camelCase")]
225pub struct CredentialStatus {
226 pub configured: bool,
228 pub source: String,
230}
231
232impl CredentialStatus {
233 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 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}