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, Serialize, Deserialize, PartialEq, Eq, Default)]
30#[serde(tag = "resolver", rename_all = "lowercase")]
31pub enum CredentialResolver {
32 #[default]
34 None,
35 Secret {
38 store_key: String,
40 env_var: String,
42 },
43 OAuth {
47 store_key: String,
48 provider: String,
49 #[serde(default)]
50 scopes: Vec<String>,
51 },
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct Integration {
57 pub id: String,
59 pub label: String,
61 #[serde(default)]
63 pub cli: Option<String>,
64 #[serde(default)]
67 pub install: Vec<SkillInstallSpec>,
68 #[serde(default)]
70 pub credential: CredentialResolver,
71}
72
73#[derive(Debug, Deserialize)]
81struct RawIntegration {
82 id: String,
83 label: String,
84 #[serde(default)]
85 cli: Option<String>,
86 #[serde(default)]
87 install: Vec<SkillInstallSpec>,
88 #[serde(default)]
89 credential: CredentialResolver,
90}
91
92impl From<RawIntegration> for Integration {
93 fn from(r: RawIntegration) -> Self {
94 Self {
95 id: r.id,
96 label: r.label,
97 cli: r.cli,
98 install: r.install,
99 credential: r.credential,
100 }
101 }
102}
103
104#[derive(Debug, Deserialize)]
106struct RegistryFile {
107 #[serde(default, rename = "integration")]
108 integrations: Vec<RawIntegration>,
109}
110
111#[derive(Debug, Clone, Default)]
116pub struct IntegrationRegistry {
117 by_id: HashMap<String, Integration>,
118}
119
120impl IntegrationRegistry {
121 fn parse_file(text: &str) -> Result<Vec<Integration>> {
123 let file: RegistryFile = toml::from_str(text).context("parsing integrations TOML")?;
124 Ok(file.integrations.into_iter().map(Into::into).collect())
125 }
126
127 pub fn load_text(defaults_text: &str, override_dir: &Path) -> Result<Self> {
131 let mut by_id: HashMap<String, Integration> = HashMap::new();
132 for it in Self::parse_file(defaults_text)? {
133 by_id.insert(it.id.clone(), it);
134 }
135 Self::layer_overrides(&mut by_id, override_dir);
136 Ok(Self { by_id })
137 }
138
139 pub fn load(defaults_path: &Path, override_dir: &Path) -> Result<Self> {
142 let mut by_id: HashMap<String, Integration> = HashMap::new();
143
144 if let Ok(text) = std::fs::read_to_string(defaults_path) {
145 for it in Self::parse_file(&text)? {
146 by_id.insert(it.id.clone(), it);
147 }
148 }
149
150 Self::layer_overrides(&mut by_id, override_dir);
151 Ok(Self { by_id })
152 }
153
154 fn layer_overrides(by_id: &mut HashMap<String, Integration>, override_dir: &Path) {
156 let Ok(entries) = std::fs::read_dir(override_dir) else {
157 return;
158 };
159 for entry in entries.flatten() {
160 let path = entry.path();
161 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
162 continue;
163 }
164 if let Ok(text) = std::fs::read_to_string(&path) {
165 match Self::parse_file(&text) {
166 Ok(items) => {
167 for it in items {
168 by_id.insert(it.id.clone(), it);
169 }
170 }
171 Err(e) => {
172 tracing::warn!(path = %path.display(), error = %e, "skipping invalid integrations override");
173 }
174 }
175 }
176 }
177 }
178
179 pub fn all(&self) -> Vec<&Integration> {
181 let mut ids: Vec<&String> = self.by_id.keys().collect();
182 ids.sort();
183 ids.iter().filter_map(|id| self.by_id.get(*id)).collect()
184 }
185
186 pub fn get(&self, id: &str) -> Option<&Integration> {
188 self.by_id.get(id)
189 }
190
191 pub fn cli_names(&self) -> Vec<String> {
193 let mut names: Vec<String> = self.by_id.values().filter_map(|i| i.cli.clone()).collect();
194 names.sort();
195 names.dedup();
196 names
197 }
198}
199
200#[derive(Debug, Clone, Serialize)]
204#[serde(rename_all = "camelCase")]
205pub struct CredentialStatus {
206 pub configured: bool,
208 pub source: String,
210}
211
212impl CredentialStatus {
213 pub fn resolve(cred: &CredentialResolver) -> Self {
220 match cred {
221 CredentialResolver::None => Self {
222 configured: false,
223 source: "none".into(),
224 },
225 CredentialResolver::Secret { store_key, env_var } => {
226 match CredentialStore::resolve_secret(store_key, env_var) {
227 Some((_, src)) => Self {
228 configured: true,
229 source: source_label(&src),
230 },
231 None => Self {
232 configured: false,
233 source: "none".into(),
234 },
235 }
236 }
237 CredentialResolver::OAuth { store_key, .. } => {
238 let present = oxi_sdk::load_token(store_key)
241 .map(|t| t.is_some() && !t.unwrap().access_token.is_empty())
242 .unwrap_or(false);
243 Self {
244 configured: present,
245 source: if present {
246 "oauth".into()
247 } else {
248 "none".into()
249 },
250 }
251 }
252 }
253 }
254}
255
256fn source_label(s: &CredentialSource) -> String {
257 match s {
258 CredentialSource::Config => "config",
259 CredentialSource::OxiAuthStore => "auth_store",
260 CredentialSource::EnvVar => "env",
261 }
262 .into()
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 const SAMPLE_TOML: &str = r#"
270[[integration]]
271id = "brew"
272label = "Homebrew"
273cli = "brew"
274credential = { resolver = "none" }
275
276[[integration]]
277id = "resend"
278label = "Resend"
279cli = "resend"
280install = [{ kind = "node", package = "resend" }]
281credential = { resolver = "secret", store_key = "resend", env_var = "RESEND_API_KEY" }
282
283[[integration]]
284id = "github"
285label = "GitHub CLI"
286cli = "gh"
287credential = { resolver = "oauth", store_key = "github", provider = "github", scopes = ["repo"] }
288"#;
289
290 #[test]
291 fn parses_integration_toml() {
292 let items = IntegrationRegistry::parse_file(SAMPLE_TOML).unwrap();
293 assert_eq!(items.len(), 3);
294
295 let brew = items.iter().find(|i| i.id == "brew").unwrap();
296 assert_eq!(brew.credential, CredentialResolver::None);
297
298 let resend = items.iter().find(|i| i.id == "resend").unwrap();
299 match &resend.credential {
300 CredentialResolver::Secret { store_key, env_var } => {
301 assert_eq!(store_key, "resend");
302 assert_eq!(env_var, "RESEND_API_KEY");
303 }
304 other => panic!("expected Secret, got {other:?}"),
305 }
306 assert_eq!(resend.install.len(), 1);
307
308 let gh = items.iter().find(|i| i.id == "github").unwrap();
309 match &gh.credential {
310 CredentialResolver::OAuth {
311 provider, scopes, ..
312 } => {
313 assert_eq!(provider, "github");
314 assert_eq!(scopes, &["repo"]);
315 }
316 other => panic!("expected OAuth, got {other:?}"),
317 }
318 }
319
320 #[test]
321 fn shipped_registry_parses() {
322 let text = include_str!("../../share/default-integrations.toml");
323 let items = IntegrationRegistry::parse_file(text).unwrap();
324 assert!(items.iter().any(|integration| integration.id == "github"));
325 assert!(items.iter().any(|integration| integration.id == "resend"));
326 }
327
328 #[test]
329 fn cli_names_dedups() {
330 let reg = IntegrationRegistry {
331 by_id: SAMPLE_TOML
332 .parse::<RegistryFile>()
333 .unwrap()
334 .integrations
335 .into_iter()
336 .map(Into::into)
337 .map(|i: Integration| (i.id.clone(), i))
338 .collect(),
339 };
340 let names = reg.cli_names();
341 assert_eq!(names, vec!["brew", "gh", "resend"]);
342 }
343
344 #[test]
345 fn none_resolver_is_unconfigured() {
346 let s = CredentialStatus::resolve(&CredentialResolver::None);
347 assert!(!s.configured);
348 assert_eq!(s.source, "none");
349 }
350}