Skip to main content

ati/core/
skillati.rs

1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::Mutex;
5
6use base64::Engine;
7use chrono::Utc;
8use futures::stream::{self, StreamExt};
9use reqwest::Client;
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13use crate::core::gcs::{GcsClient, GcsError};
14use crate::core::keyring::Keyring;
15use crate::core::skill::{is_anthropic_valid_name, parse_skill_metadata, strip_frontmatter};
16
17const GCS_CREDENTIAL_KEY: &str = "gcp_credentials";
18const DEFAULT_CATALOG_INDEX_PATH: &str = "_skillati/catalog.v1.json";
19const DEFAULT_CATALOG_INDEX_CANDIDATES: &[&str] = &[
20    DEFAULT_CATALOG_INDEX_PATH,
21    "_skillati/catalog.json",
22    "skillati-catalog.json",
23];
24const FALLBACK_CATALOG_CONCURRENCY: usize = 24;
25
26#[derive(Error, Debug)]
27pub enum SkillAtiError {
28    #[error("SkillATI is not configured (set ATI_SKILL_REGISTRY=gcs://<bucket> or ATI_SKILL_REGISTRY=proxy)")]
29    NotConfigured,
30    #[error("Unsupported skill registry URL: {0}")]
31    UnsupportedRegistry(String),
32    #[error("GCS credentials not found in keyring: {0}")]
33    MissingCredentials(&'static str),
34    #[error("ATI_PROXY_URL must be set when ATI_SKILL_REGISTRY=proxy")]
35    ProxyUrlRequired,
36    #[error("Skill '{0}' not found")]
37    SkillNotFound(String),
38    #[error("Path '{path}' not found in skill '{skill}'")]
39    PathNotFound { skill: String, path: String },
40    #[error("Invalid skill-relative path '{0}'")]
41    InvalidPath(String),
42    #[error(transparent)]
43    Gcs(#[from] GcsError),
44    #[error("Proxy request failed: {0}")]
45    ProxyRequest(String),
46    #[error("Proxy returned invalid response: {0}")]
47    ProxyResponse(String),
48}
49
50#[derive(Error, Debug)]
51pub enum SkillAtiBuildError {
52    #[error("Source directory not found: {0}")]
53    MissingSource(String),
54    #[error("Failed to read {0}: {1}")]
55    Io(String, #[source] std::io::Error),
56    #[error("Failed to parse skill metadata for {0}: {1}")]
57    Metadata(String, String),
58    #[error(transparent)]
59    Json(#[from] serde_json::Error),
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
63pub struct RemoteSkillMeta {
64    pub name: String,
65    #[serde(default)]
66    pub description: String,
67    #[serde(default)]
68    pub skill_directory: String,
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub keywords: Vec<String>,
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub tools: Vec<String>,
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    pub providers: Vec<String>,
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub categories: Vec<String>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80pub struct SkillAtiCatalogEntry {
81    #[serde(flatten)]
82    pub meta: RemoteSkillMeta,
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub resources: Vec<String>,
85    #[serde(default, skip)]
86    pub resources_complete: bool,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90pub struct SkillAtiCatalogManifest {
91    #[serde(default = "default_catalog_version")]
92    pub version: u32,
93    #[serde(default, skip_serializing_if = "String::is_empty")]
94    pub generated_at: String,
95    pub skills: Vec<SkillAtiCatalogEntry>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
99pub struct SkillAtiActivation {
100    pub name: String,
101    pub skill_directory: String,
102    pub content: String,
103    pub resources: Vec<String>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107#[serde(tag = "kind", rename_all = "snake_case")]
108pub enum SkillAtiFileData {
109    Text { content: String },
110    Binary { encoding: String, content: String },
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114pub struct SkillAtiFile {
115    pub requested_skill: String,
116    pub resolved_skill: String,
117    pub path: String,
118    #[serde(flatten)]
119    pub data: SkillAtiFileData,
120}
121
122enum SkillAtiTransport {
123    Gcs(GcsClient),
124    Proxy {
125        http: Client,
126        base_url: String,
127        token: Option<String>,
128    },
129}
130
131pub struct SkillAtiClient {
132    transport: SkillAtiTransport,
133    bytes_cache: Mutex<HashMap<(String, String), Vec<u8>>>,
134    resources_cache: Mutex<HashMap<String, Vec<String>>>,
135    catalog_cache: Mutex<Option<Vec<SkillAtiCatalogEntry>>>,
136}
137
138impl SkillAtiClient {
139    pub fn from_env(keyring: &Keyring) -> Result<Option<Self>, SkillAtiError> {
140        match std::env::var("ATI_SKILL_REGISTRY") {
141            Ok(url) if !url.trim().is_empty() => Ok(Some(Self::from_registry_url(&url, keyring)?)),
142            _ => Ok(None),
143        }
144    }
145
146    pub fn from_registry_url(registry_url: &str, keyring: &Keyring) -> Result<Self, SkillAtiError> {
147        if registry_url.trim() == "proxy" {
148            let base_url = std::env::var("ATI_PROXY_URL")
149                .ok()
150                .filter(|u| !u.trim().is_empty())
151                .ok_or(SkillAtiError::ProxyUrlRequired)?;
152            let base_url = base_url.trim_end_matches('/').to_string();
153            let token = std::env::var("ATI_SESSION_TOKEN")
154                .ok()
155                .filter(|t| !t.trim().is_empty());
156            let http = Client::builder()
157                .timeout(std::time::Duration::from_secs(30))
158                .build()
159                .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
160            return Ok(Self {
161                transport: SkillAtiTransport::Proxy {
162                    http,
163                    base_url,
164                    token,
165                },
166                bytes_cache: Mutex::new(HashMap::new()),
167                resources_cache: Mutex::new(HashMap::new()),
168                catalog_cache: Mutex::new(None),
169            });
170        }
171
172        let bucket = registry_url
173            .strip_prefix("gcs://")
174            .ok_or_else(|| SkillAtiError::UnsupportedRegistry(registry_url.to_string()))?;
175
176        let cred_json = keyring
177            .get(GCS_CREDENTIAL_KEY)
178            .ok_or(SkillAtiError::MissingCredentials(GCS_CREDENTIAL_KEY))?;
179
180        let gcs = GcsClient::new(bucket.to_string(), cred_json)?;
181        Ok(Self {
182            transport: SkillAtiTransport::Gcs(gcs),
183            bytes_cache: Mutex::new(HashMap::new()),
184            resources_cache: Mutex::new(HashMap::new()),
185            catalog_cache: Mutex::new(None),
186        })
187    }
188
189    /// Build an authenticated request to the proxy.
190    fn proxy_request(
191        http: &Client,
192        method: reqwest::Method,
193        url: &str,
194        token: Option<&str>,
195    ) -> reqwest::RequestBuilder {
196        let mut req = http.request(method, url);
197        if let Some(t) = token {
198            req = req.header("Authorization", format!("Bearer {t}"));
199        }
200        req
201    }
202
203    /// Fetch the catalog from the proxy's /skillati/catalog endpoint.
204    async fn proxy_catalog(
205        http: &Client,
206        base_url: &str,
207        token: Option<&str>,
208    ) -> Result<Vec<SkillAtiCatalogEntry>, SkillAtiError> {
209        let url = format!("{base_url}/skillati/catalog");
210        let resp = Self::proxy_request(http, reqwest::Method::GET, &url, token)
211            .send()
212            .await
213            .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
214
215        let status = resp.status().as_u16();
216        let body = resp
217            .text()
218            .await
219            .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
220
221        if status != 200 {
222            return Err(SkillAtiError::ProxyResponse(format!(
223                "HTTP {status}: {body}"
224            )));
225        }
226
227        #[derive(Deserialize)]
228        struct CatalogResp {
229            skills: Vec<RemoteSkillMeta>,
230        }
231        let parsed: CatalogResp =
232            serde_json::from_str(&body).map_err(|e| SkillAtiError::ProxyResponse(e.to_string()))?;
233
234        Ok(parsed
235            .skills
236            .into_iter()
237            .map(|meta| SkillAtiCatalogEntry {
238                meta,
239                resources: Vec::new(),
240                resources_complete: false,
241            })
242            .collect())
243    }
244
245    /// Fetch SKILL.md bytes from the proxy's /skillati/:name endpoint.
246    async fn proxy_read_skill_md(
247        http: &Client,
248        base_url: &str,
249        token: Option<&str>,
250        name: &str,
251    ) -> Result<Vec<u8>, SkillAtiError> {
252        let url = format!("{base_url}/skillati/{name}");
253        let resp = Self::proxy_request(http, reqwest::Method::GET, &url, token)
254            .send()
255            .await
256            .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
257
258        let status = resp.status().as_u16();
259        let body = resp
260            .text()
261            .await
262            .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
263
264        match status {
265            404 => return Err(SkillAtiError::SkillNotFound(name.to_string())),
266            200 => {}
267            _ => {
268                return Err(SkillAtiError::ProxyResponse(format!(
269                    "HTTP {status}: {body}"
270                )))
271            }
272        }
273
274        // Response is a SkillAtiActivation JSON — extract the content field
275        #[derive(Deserialize)]
276        struct ActivationResp {
277            content: String,
278        }
279        let parsed: ActivationResp =
280            serde_json::from_str(&body).map_err(|e| SkillAtiError::ProxyResponse(e.to_string()))?;
281
282        Ok(parsed.content.into_bytes())
283    }
284
285    /// Fetch a file from the proxy's /skillati/:name/file?path=... endpoint.
286    async fn proxy_read_file(
287        http: &Client,
288        base_url: &str,
289        token: Option<&str>,
290        name: &str,
291        path: &str,
292    ) -> Result<Vec<u8>, SkillAtiError> {
293        if path == "SKILL.md" {
294            return Self::proxy_read_skill_md(http, base_url, token, name).await;
295        }
296
297        let url = format!("{base_url}/skillati/{name}/file");
298        let resp = Self::proxy_request(http, reqwest::Method::GET, &url, token)
299            .query(&[("path", path)])
300            .send()
301            .await
302            .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
303
304        let status = resp.status().as_u16();
305        let body = resp
306            .text()
307            .await
308            .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
309
310        match status {
311            404 => {
312                return Err(SkillAtiError::PathNotFound {
313                    skill: name.to_string(),
314                    path: path.to_string(),
315                })
316            }
317            200 => {}
318            _ => {
319                return Err(SkillAtiError::ProxyResponse(format!(
320                    "HTTP {status}: {body}"
321                )))
322            }
323        }
324
325        #[derive(Deserialize)]
326        #[serde(tag = "kind", rename_all = "snake_case")]
327        enum FileDataResp {
328            Text { content: String },
329            Binary { content: String },
330        }
331        let parsed: FileDataResp =
332            serde_json::from_str(&body).map_err(|e| SkillAtiError::ProxyResponse(e.to_string()))?;
333
334        match parsed {
335            FileDataResp::Text { content } => Ok(content.into_bytes()),
336            FileDataResp::Binary { content } => base64::engine::general_purpose::STANDARD
337                .decode(content)
338                .map_err(|e| SkillAtiError::ProxyResponse(e.to_string())),
339        }
340    }
341
342    /// List resources from the proxy's /skillati/:name/resources endpoint.
343    async fn proxy_list_resources(
344        http: &Client,
345        base_url: &str,
346        token: Option<&str>,
347        name: &str,
348    ) -> Result<Vec<String>, SkillAtiError> {
349        let url = format!("{base_url}/skillati/{name}/resources");
350        let resp = Self::proxy_request(http, reqwest::Method::GET, &url, token)
351            .send()
352            .await
353            .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
354
355        let status = resp.status().as_u16();
356        let body = resp
357            .text()
358            .await
359            .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
360
361        match status {
362            404 => return Err(SkillAtiError::SkillNotFound(name.to_string())),
363            200 => {}
364            _ => {
365                return Err(SkillAtiError::ProxyResponse(format!(
366                    "HTTP {status}: {body}"
367                )))
368            }
369        }
370
371        #[derive(Deserialize)]
372        struct ResourcesResp {
373            resources: Vec<String>,
374        }
375        let parsed: ResourcesResp =
376            serde_json::from_str(&body).map_err(|e| SkillAtiError::ProxyResponse(e.to_string()))?;
377
378        Ok(parsed.resources)
379    }
380
381    pub async fn catalog(&self) -> Result<Vec<RemoteSkillMeta>, SkillAtiError> {
382        Ok(self
383            .catalog_entries()
384            .await?
385            .into_iter()
386            .map(|entry| entry.meta)
387            .collect())
388    }
389
390    pub fn filter_catalog(
391        catalog: &[RemoteSkillMeta],
392        query: &str,
393        limit: usize,
394    ) -> Vec<RemoteSkillMeta> {
395        let query = query.trim().to_lowercase();
396        if query.is_empty() {
397            return catalog.iter().take(limit).cloned().collect();
398        }
399
400        let terms: Vec<&str> = query.split_whitespace().collect();
401        let mut scored: Vec<(usize, &RemoteSkillMeta)> = catalog
402            .iter()
403            .map(|skill| {
404                let haystack = search_haystack(skill);
405                let score = terms
406                    .iter()
407                    .filter(|term| haystack.contains(**term))
408                    .count();
409                (score, skill)
410            })
411            .filter(|(score, skill)| *score > 0 || search_haystack(skill).contains(&query))
412            .collect();
413
414        scored.sort_by(|a, b| {
415            b.0.cmp(&a.0)
416                .then_with(|| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase()))
417        });
418
419        scored
420            .into_iter()
421            .take(limit)
422            .map(|(_, skill)| skill.clone())
423            .collect()
424    }
425
426    pub async fn read_skill(&self, name: &str) -> Result<SkillAtiActivation, SkillAtiError> {
427        let raw = self.read_text(name, "SKILL.md").await?;
428        let resources = self.list_resources(name, None).await?;
429        Ok(SkillAtiActivation {
430            name: name.to_string(),
431            skill_directory: skill_directory(name),
432            content: strip_frontmatter(&raw).to_string(),
433            resources,
434        })
435    }
436
437    pub async fn list_resources(
438        &self,
439        name: &str,
440        prefix: Option<&str>,
441    ) -> Result<Vec<String>, SkillAtiError> {
442        let resources = self.list_all_resources(name).await?;
443        let normalized_prefix = match prefix {
444            Some(value) if !value.trim().is_empty() => Some(normalize_prefix(value)?),
445            _ => None,
446        };
447
448        let filtered = match normalized_prefix {
449            Some(prefix) => resources
450                .into_iter()
451                .filter(|path| path == &prefix || path.starts_with(&format!("{prefix}/")))
452                .collect(),
453            None => resources,
454        };
455
456        Ok(filtered)
457    }
458
459    pub async fn read_path(
460        &self,
461        requested_skill: &str,
462        requested_path: &str,
463    ) -> Result<SkillAtiFile, SkillAtiError> {
464        let (resolved_skill, resolved_path) =
465            resolve_requested_path(requested_skill, requested_path)?;
466        let bytes = self.read_bytes(&resolved_skill, &resolved_path).await?;
467
468        let data = match String::from_utf8(bytes.clone()) {
469            Ok(text) => SkillAtiFileData::Text { content: text },
470            Err(_) => SkillAtiFileData::Binary {
471                encoding: "base64".to_string(),
472                content: base64::engine::general_purpose::STANDARD.encode(bytes),
473            },
474        };
475
476        Ok(SkillAtiFile {
477            requested_skill: requested_skill.to_string(),
478            resolved_skill,
479            path: resolved_path,
480            data,
481        })
482    }
483
484    pub async fn list_references(&self, name: &str) -> Result<Vec<String>, SkillAtiError> {
485        let refs = self.list_resources(name, Some("references")).await?;
486        Ok(refs
487            .into_iter()
488            .filter_map(|path| path.strip_prefix("references/").map(str::to_string))
489            .collect())
490    }
491
492    pub async fn read_reference(
493        &self,
494        name: &str,
495        reference: &str,
496    ) -> Result<String, SkillAtiError> {
497        let path = format!("references/{reference}");
498        let file = self.read_path(name, &path).await?;
499        match file.data {
500            SkillAtiFileData::Text { content } => Ok(content),
501            SkillAtiFileData::Binary { .. } => Err(SkillAtiError::InvalidPath(path)),
502        }
503    }
504
505    async fn catalog_entries(&self) -> Result<Vec<SkillAtiCatalogEntry>, SkillAtiError> {
506        if let Some(cached) = self.catalog_cache.lock().unwrap().clone() {
507            return Ok(cached);
508        }
509
510        let entries = match &self.transport {
511            SkillAtiTransport::Proxy {
512                http,
513                base_url,
514                token,
515            } => Self::proxy_catalog(http, base_url, token.as_deref()).await?,
516            SkillAtiTransport::Gcs(_) => match self.load_catalog_index().await? {
517                Some(entries) => entries,
518                None => self.load_catalog_fallback().await?,
519            },
520        };
521
522        self.catalog_cache.lock().unwrap().replace(entries.clone());
523        Ok(entries)
524    }
525
526    async fn load_catalog_index(&self) -> Result<Option<Vec<SkillAtiCatalogEntry>>, SkillAtiError> {
527        let gcs = match &self.transport {
528            SkillAtiTransport::Gcs(gcs) => gcs,
529            SkillAtiTransport::Proxy { .. } => {
530                unreachable!("load_catalog_index called on proxy transport")
531            }
532        };
533        for candidate in catalog_index_candidates() {
534            match gcs.get_object_text(&candidate).await {
535                Ok(raw) => match serde_json::from_str::<SkillAtiCatalogManifest>(&raw) {
536                    Ok(mut manifest) => {
537                        for entry in &mut manifest.skills {
538                            normalize_catalog_entry(entry);
539                            entry.resources_complete = true;
540                        }
541                        manifest.skills.sort_by(|a, b| {
542                            a.meta.name.to_lowercase().cmp(&b.meta.name.to_lowercase())
543                        });
544                        tracing::debug!(
545                            path = %candidate,
546                            skills = manifest.skills.len(),
547                            "loaded SkillATI catalog index"
548                        );
549                        return Ok(Some(manifest.skills));
550                    }
551                    Err(err) => {
552                        tracing::warn!(
553                            path = %candidate,
554                            error = %err,
555                            "SkillATI catalog index was invalid, falling back"
556                        );
557                    }
558                },
559                Err(GcsError::Api { status: 404, .. }) => continue,
560                Err(err) => return Err(SkillAtiError::Gcs(err)),
561            }
562        }
563
564        Ok(None)
565    }
566
567    async fn load_catalog_fallback(&self) -> Result<Vec<SkillAtiCatalogEntry>, SkillAtiError> {
568        let gcs = match &self.transport {
569            SkillAtiTransport::Gcs(gcs) => gcs,
570            SkillAtiTransport::Proxy { .. } => {
571                unreachable!("load_catalog_fallback called on proxy transport")
572            }
573        };
574        let mut names = gcs.list_skill_names().await?;
575        names.sort();
576
577        let mut entries: Vec<SkillAtiCatalogEntry> = stream::iter(names.into_iter())
578            .map(|name| async move {
579                let raw = self.read_text(&name, "SKILL.md").await?;
580                let parsed = parse_skill_metadata(&name, &raw, None)
581                    .map_err(|e| SkillAtiError::InvalidPath(e.to_string()))?;
582                Ok::<SkillAtiCatalogEntry, SkillAtiError>(SkillAtiCatalogEntry {
583                    meta: remote_skill_meta_from_parts(
584                        &name,
585                        parsed.description,
586                        parsed.keywords,
587                        parsed.tools,
588                        parsed.providers,
589                        parsed.categories,
590                    ),
591                    resources: Vec::new(),
592                    resources_complete: false,
593                })
594            })
595            .buffer_unordered(FALLBACK_CATALOG_CONCURRENCY)
596            .collect::<Vec<_>>()
597            .await
598            .into_iter()
599            .collect::<Result<Vec<_>, _>>()?;
600
601        entries.sort_by(|a, b| a.meta.name.to_lowercase().cmp(&b.meta.name.to_lowercase()));
602        Ok(entries)
603    }
604
605    async fn list_all_resources(&self, name: &str) -> Result<Vec<String>, SkillAtiError> {
606        if let Some(cached) = self.resources_cache.lock().unwrap().get(name).cloned() {
607            return Ok(cached);
608        }
609
610        // For proxy transport, fetch directly from proxy
611        if let SkillAtiTransport::Proxy {
612            http,
613            base_url,
614            token,
615        } = &self.transport
616        {
617            let resources =
618                Self::proxy_list_resources(http, base_url, token.as_deref(), name).await?;
619            self.resources_cache
620                .lock()
621                .unwrap()
622                .insert(name.to_string(), resources.clone());
623            return Ok(resources);
624        }
625
626        if let Some(indexed) = self
627            .catalog_entries()
628            .await?
629            .into_iter()
630            .find(|entry| entry.meta.name == name && entry.resources_complete)
631        {
632            self.resources_cache
633                .lock()
634                .unwrap()
635                .insert(name.to_string(), indexed.resources.clone());
636            return Ok(indexed.resources);
637        }
638
639        self.ensure_skill_exists(name).await?;
640
641        let gcs = match &self.transport {
642            SkillAtiTransport::Gcs(gcs) => gcs,
643            SkillAtiTransport::Proxy { .. } => unreachable!(),
644        };
645        let mut resources = gcs.list_objects(name).await?;
646        resources.retain(|path| is_visible_resource(path));
647        resources.sort();
648        resources.dedup();
649
650        self.resources_cache
651            .lock()
652            .unwrap()
653            .insert(name.to_string(), resources.clone());
654        Ok(resources)
655    }
656
657    async fn ensure_skill_exists(&self, name: &str) -> Result<(), SkillAtiError> {
658        self.read_bytes(name, "SKILL.md").await.map(|_| ())
659    }
660
661    async fn read_text(&self, name: &str, relative_path: &str) -> Result<String, SkillAtiError> {
662        let bytes = self.read_bytes(name, relative_path).await?;
663        String::from_utf8(bytes).map_err(|e| match &self.transport {
664            SkillAtiTransport::Gcs(_) => SkillAtiError::Gcs(GcsError::Utf8(e.to_string())),
665            SkillAtiTransport::Proxy { .. } => SkillAtiError::ProxyResponse(format!(
666                "invalid UTF-8 in {name}/{relative_path}: {e}"
667            )),
668        })
669    }
670
671    async fn read_bytes(&self, name: &str, relative_path: &str) -> Result<Vec<u8>, SkillAtiError> {
672        let cache_key = (name.to_string(), relative_path.to_string());
673        if let Some(cached) = self.bytes_cache.lock().unwrap().get(&cache_key).cloned() {
674            return Ok(cached);
675        }
676
677        let bytes = match &self.transport {
678            SkillAtiTransport::Proxy {
679                http,
680                base_url,
681                token,
682            } => {
683                Self::proxy_read_file(http, base_url, token.as_deref(), name, relative_path).await?
684            }
685            SkillAtiTransport::Gcs(gcs) => {
686                let gcs_path = format!("{name}/{relative_path}");
687                gcs.get_object(&gcs_path)
688                    .await
689                    .map_err(|e| map_gcs_error(name, relative_path, e))?
690            }
691        };
692
693        self.bytes_cache
694            .lock()
695            .unwrap()
696            .insert(cache_key, bytes.clone());
697        Ok(bytes)
698    }
699}
700
701pub fn build_catalog_manifest(
702    source_dir: &Path,
703) -> Result<SkillAtiCatalogManifest, SkillAtiBuildError> {
704    if !source_dir.exists() {
705        return Err(SkillAtiBuildError::MissingSource(
706            source_dir.display().to_string(),
707        ));
708    }
709
710    let mut skill_dirs = discover_skill_dirs(source_dir)?;
711    skill_dirs.sort();
712
713    let mut skills = Vec::new();
714    for skill_dir in skill_dirs {
715        skills.push(build_catalog_entry_from_dir(&skill_dir)?);
716    }
717
718    skills.sort_by(|a, b| a.meta.name.to_lowercase().cmp(&b.meta.name.to_lowercase()));
719    Ok(SkillAtiCatalogManifest {
720        version: default_catalog_version(),
721        generated_at: Utc::now().to_rfc3339(),
722        skills,
723    })
724}
725
726pub fn default_catalog_index_path() -> &'static str {
727    DEFAULT_CATALOG_INDEX_PATH
728}
729
730fn default_catalog_version() -> u32 {
731    1
732}
733
734fn catalog_index_candidates() -> Vec<String> {
735    match std::env::var("ATI_SKILL_REGISTRY_INDEX_OBJECT") {
736        Ok(value) => {
737            let candidates: Vec<String> = value
738                .split(',')
739                .map(str::trim)
740                .filter(|candidate| !candidate.is_empty())
741                .map(str::to_string)
742                .collect();
743            if candidates.is_empty() {
744                DEFAULT_CATALOG_INDEX_CANDIDATES
745                    .iter()
746                    .map(|candidate| candidate.to_string())
747                    .collect()
748            } else {
749                candidates
750            }
751        }
752        Err(_) => DEFAULT_CATALOG_INDEX_CANDIDATES
753            .iter()
754            .map(|candidate| candidate.to_string())
755            .collect(),
756    }
757}
758
759fn build_catalog_entry_from_dir(
760    skill_dir: &Path,
761) -> Result<SkillAtiCatalogEntry, SkillAtiBuildError> {
762    let dir_name = skill_dir
763        .file_name()
764        .and_then(|name| name.to_str())
765        .ok_or_else(|| SkillAtiBuildError::MissingSource(skill_dir.display().to_string()))?
766        .to_string();
767
768    let skill_md_path = skill_dir.join("SKILL.md");
769    let skill_md = fs::read_to_string(&skill_md_path)
770        .map_err(|err| SkillAtiBuildError::Io(skill_md_path.display().to_string(), err))?;
771    let skill_toml_path = skill_dir.join("skill.toml");
772    let skill_toml =
773        if skill_toml_path.exists() {
774            Some(fs::read_to_string(&skill_toml_path).map_err(|err| {
775                SkillAtiBuildError::Io(skill_toml_path.display().to_string(), err)
776            })?)
777        } else {
778            None
779        };
780
781    let parsed = parse_skill_metadata(&dir_name, &skill_md, skill_toml.as_deref())
782        .map_err(|err| SkillAtiBuildError::Metadata(dir_name.clone(), err.to_string()))?;
783    let resources = collect_visible_resources(skill_dir, skill_dir)?;
784
785    Ok(SkillAtiCatalogEntry {
786        meta: remote_skill_meta_from_parts(
787            &dir_name,
788            parsed.description,
789            parsed.keywords,
790            parsed.tools,
791            parsed.providers,
792            parsed.categories,
793        ),
794        resources,
795        resources_complete: true,
796    })
797}
798
799fn discover_skill_dirs(source_dir: &Path) -> Result<Vec<PathBuf>, SkillAtiBuildError> {
800    if source_dir.join("SKILL.md").is_file() {
801        return Ok(vec![source_dir.to_path_buf()]);
802    }
803
804    let mut skill_dirs = Vec::new();
805    let entries = fs::read_dir(source_dir)
806        .map_err(|err| SkillAtiBuildError::Io(source_dir.display().to_string(), err))?;
807
808    for entry in entries {
809        let entry =
810            entry.map_err(|err| SkillAtiBuildError::Io(source_dir.display().to_string(), err))?;
811        let path = entry.path();
812        if path.is_dir() && path.join("SKILL.md").is_file() {
813            skill_dirs.push(path);
814        }
815    }
816
817    Ok(skill_dirs)
818}
819
820fn collect_visible_resources(
821    root: &Path,
822    current: &Path,
823) -> Result<Vec<String>, SkillAtiBuildError> {
824    let mut resources = Vec::new();
825    let entries = fs::read_dir(current)
826        .map_err(|err| SkillAtiBuildError::Io(current.display().to_string(), err))?;
827
828    for entry in entries {
829        let entry =
830            entry.map_err(|err| SkillAtiBuildError::Io(current.display().to_string(), err))?;
831        let path = entry.path();
832        let file_name = entry.file_name();
833        if file_name.to_string_lossy().starts_with('.') {
834            continue;
835        }
836
837        if path.is_dir() {
838            resources.extend(collect_visible_resources(root, &path)?);
839            continue;
840        }
841
842        let relative = path
843            .strip_prefix(root)
844            .map_err(|_| SkillAtiBuildError::MissingSource(path.display().to_string()))?
845            .to_string_lossy()
846            .replace('\\', "/");
847
848        if is_visible_resource(&relative) {
849            resources.push(relative);
850        }
851    }
852
853    resources.sort();
854    resources.dedup();
855    Ok(resources)
856}
857
858fn remote_skill_meta_from_parts(
859    name: &str,
860    description: String,
861    mut keywords: Vec<String>,
862    mut tools: Vec<String>,
863    mut providers: Vec<String>,
864    mut categories: Vec<String>,
865) -> RemoteSkillMeta {
866    dedup_sort_casefold(&mut keywords);
867    dedup_sort_casefold(&mut tools);
868    dedup_sort_casefold(&mut providers);
869    dedup_sort_casefold(&mut categories);
870
871    RemoteSkillMeta {
872        name: name.to_string(),
873        description,
874        skill_directory: skill_directory(name),
875        keywords,
876        tools,
877        providers,
878        categories,
879    }
880}
881
882fn normalize_catalog_entry(entry: &mut SkillAtiCatalogEntry) {
883    if entry.meta.skill_directory.trim().is_empty() {
884        entry.meta.skill_directory = skill_directory(&entry.meta.name);
885    }
886    dedup_sort_casefold(&mut entry.meta.keywords);
887    dedup_sort_casefold(&mut entry.meta.tools);
888    dedup_sort_casefold(&mut entry.meta.providers);
889    dedup_sort_casefold(&mut entry.meta.categories);
890    entry.resources.retain(|path| is_visible_resource(path));
891    entry.resources.sort();
892    entry.resources.dedup();
893}
894
895fn dedup_sort_casefold(values: &mut Vec<String>) {
896    let mut seen = HashSet::new();
897    values.retain(|value| {
898        let normalized = value.trim().to_lowercase();
899        !normalized.is_empty() && seen.insert(normalized)
900    });
901    values.sort_by_key(|value| value.to_lowercase());
902}
903
904fn search_haystack(skill: &RemoteSkillMeta) -> String {
905    let mut parts = vec![skill.name.to_lowercase(), skill.description.to_lowercase()];
906    if !skill.keywords.is_empty() {
907        parts.push(skill.keywords.join(" ").to_lowercase());
908    }
909    if !skill.tools.is_empty() {
910        parts.push(skill.tools.join(" ").to_lowercase());
911    }
912    if !skill.providers.is_empty() {
913        parts.push(skill.providers.join(" ").to_lowercase());
914    }
915    if !skill.categories.is_empty() {
916        parts.push(skill.categories.join(" ").to_lowercase());
917    }
918    parts.join(" ")
919}
920
921fn map_gcs_error(skill: &str, relative_path: &str, error: GcsError) -> SkillAtiError {
922    match error {
923        GcsError::Api { status: 404, .. } if relative_path == "SKILL.md" => {
924            SkillAtiError::SkillNotFound(skill.to_string())
925        }
926        GcsError::Api { status: 404, .. } => SkillAtiError::PathNotFound {
927            skill: skill.to_string(),
928            path: relative_path.to_string(),
929        },
930        other => SkillAtiError::Gcs(other),
931    }
932}
933
934fn skill_directory(name: &str) -> String {
935    format!("skillati://{name}")
936}
937
938fn is_visible_resource(path: &str) -> bool {
939    !path.is_empty()
940        && !path.ends_with('/')
941        && path != "SKILL.md"
942        && path != "skill.toml"
943        && !path.starts_with('.')
944}
945
946fn normalize_prefix(prefix: &str) -> Result<String, SkillAtiError> {
947    let prefix = trim_leading_current_dir(prefix);
948    if prefix.is_empty() {
949        return Err(SkillAtiError::InvalidPath(prefix.to_string()));
950    }
951    normalize_within_skill(prefix)
952}
953
954fn resolve_requested_path(
955    requested_skill: &str,
956    requested_path: &str,
957) -> Result<(String, String), SkillAtiError> {
958    let requested_path = trim_leading_current_dir(requested_path);
959    validate_raw_path(requested_path)?;
960
961    if requested_path == ".." {
962        return Err(SkillAtiError::InvalidPath(requested_path.to_string()));
963    }
964
965    if let Some(rest) = requested_path.strip_prefix("../") {
966        let segments: Vec<&str> = rest
967            .split('/')
968            .filter(|segment| !segment.is_empty() && *segment != ".")
969            .collect();
970        if segments.len() < 2 {
971            return Err(SkillAtiError::InvalidPath(requested_path.to_string()));
972        }
973
974        let sibling_skill = segments[0];
975        if !is_anthropic_valid_name(sibling_skill) {
976            return Err(SkillAtiError::InvalidPath(requested_path.to_string()));
977        }
978
979        let normalized_path = normalize_within_skill(&segments[1..].join("/"))?;
980        return Ok((sibling_skill.to_string(), normalized_path));
981    }
982
983    Ok((
984        requested_skill.to_string(),
985        normalize_within_skill(requested_path)?,
986    ))
987}
988
989fn trim_leading_current_dir(path: &str) -> &str {
990    let mut trimmed = path.trim();
991    while let Some(rest) = trimmed.strip_prefix("./") {
992        trimmed = rest;
993    }
994    trimmed
995}
996
997fn validate_raw_path(path: &str) -> Result<(), SkillAtiError> {
998    if path.trim().is_empty() || path.contains('\0') || path.contains('\\') || path.starts_with('/')
999    {
1000        return Err(SkillAtiError::InvalidPath(path.to_string()));
1001    }
1002    Ok(())
1003}
1004
1005fn normalize_within_skill(path: &str) -> Result<String, SkillAtiError> {
1006    validate_raw_path(path)?;
1007
1008    let mut stack: Vec<&str> = Vec::new();
1009    for segment in path.split('/') {
1010        match segment {
1011            "" | "." => {}
1012            ".." => {
1013                if stack.pop().is_none() {
1014                    return Err(SkillAtiError::InvalidPath(path.to_string()));
1015                }
1016            }
1017            value => stack.push(value),
1018        }
1019    }
1020
1021    if stack.is_empty() {
1022        return Err(SkillAtiError::InvalidPath(path.to_string()));
1023    }
1024
1025    Ok(stack.join("/"))
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::{
1031        build_catalog_manifest, catalog_index_candidates, collect_visible_resources,
1032        default_catalog_index_path, is_visible_resource, map_gcs_error, normalize_within_skill,
1033        remote_skill_meta_from_parts, resolve_requested_path, search_haystack, skill_directory,
1034        SkillAtiCatalogEntry, SkillAtiError,
1035    };
1036    use crate::core::gcs::GcsError;
1037    use std::fs;
1038
1039    #[test]
1040    fn skill_directory_uses_virtual_scheme() {
1041        assert_eq!(skill_directory("demo-skill"), "skillati://demo-skill");
1042    }
1043
1044    #[test]
1045    fn normalize_within_skill_rejects_invalid_paths() {
1046        assert_eq!(
1047            normalize_within_skill("references/guide.md").unwrap(),
1048            "references/guide.md"
1049        );
1050        assert_eq!(
1051            normalize_within_skill("./references/./guide.md").unwrap(),
1052            "references/guide.md"
1053        );
1054        assert!(normalize_within_skill("../escape.md").is_err());
1055        assert!(normalize_within_skill("references/../../escape.md").is_err());
1056        assert!(normalize_within_skill(r"references\guide.md").is_err());
1057    }
1058
1059    #[test]
1060    fn resolve_requested_path_supports_sibling_skills() {
1061        assert_eq!(
1062            resolve_requested_path("react-component-builder", "../ui-design-system/SKILL.md")
1063                .unwrap(),
1064            ("ui-design-system".to_string(), "SKILL.md".to_string())
1065        );
1066        assert_eq!(
1067            resolve_requested_path(
1068                "react-component-builder",
1069                "../ui-design-system/references/core-principles.md"
1070            )
1071            .unwrap(),
1072            (
1073                "ui-design-system".to_string(),
1074                "references/core-principles.md".to_string()
1075            )
1076        );
1077        assert!(matches!(
1078            resolve_requested_path("a", "../../etc/passwd"),
1079            Err(SkillAtiError::InvalidPath(_))
1080        ));
1081        assert!(matches!(
1082            resolve_requested_path("a", "../bad name/SKILL.md"),
1083            Err(SkillAtiError::InvalidPath(_))
1084        ));
1085    }
1086
1087    #[test]
1088    fn visible_resources_filter_internal_files_and_dirs() {
1089        assert!(is_visible_resource("references/guide.md"));
1090        assert!(is_visible_resource("assets/logo.png"));
1091        assert!(!is_visible_resource("SKILL.md"));
1092        assert!(!is_visible_resource("skill.toml"));
1093        assert!(!is_visible_resource("references/"));
1094    }
1095
1096    #[test]
1097    fn map_404_for_skill_md_becomes_skill_not_found() {
1098        let err = map_gcs_error(
1099            "demo-skill",
1100            "SKILL.md",
1101            GcsError::Api {
1102                status: 404,
1103                message: "nope".into(),
1104            },
1105        );
1106        assert!(matches!(err, SkillAtiError::SkillNotFound(name) if name == "demo-skill"));
1107    }
1108
1109    #[test]
1110    fn map_404_for_other_paths_becomes_path_not_found() {
1111        let err = map_gcs_error(
1112            "demo-skill",
1113            "references/guide.md",
1114            GcsError::Api {
1115                status: 404,
1116                message: "nope".into(),
1117            },
1118        );
1119        assert!(
1120            matches!(err, SkillAtiError::PathNotFound { skill, path } if skill == "demo-skill" && path == "references/guide.md")
1121        );
1122    }
1123
1124    #[test]
1125    fn search_haystack_includes_keywords_and_bindings() {
1126        let meta = remote_skill_meta_from_parts(
1127            "demo-skill",
1128            "Great for UI panels".to_string(),
1129            vec!["dashboard".into()],
1130            vec!["render_panel".into()],
1131            vec!["frontend".into()],
1132            vec!["design".into()],
1133        );
1134        let haystack = search_haystack(&meta);
1135        assert!(haystack.contains("dashboard"));
1136        assert!(haystack.contains("render_panel"));
1137        assert!(haystack.contains("frontend"));
1138        assert!(haystack.contains("design"));
1139    }
1140
1141    #[test]
1142    fn env_override_for_catalog_index_candidates_is_supported() {
1143        unsafe {
1144            std::env::set_var(
1145                "ATI_SKILL_REGISTRY_INDEX_OBJECT",
1146                "custom/one.json, custom/two.json",
1147            );
1148        }
1149        assert_eq!(
1150            catalog_index_candidates(),
1151            vec!["custom/one.json".to_string(), "custom/two.json".to_string()]
1152        );
1153        unsafe {
1154            std::env::remove_var("ATI_SKILL_REGISTRY_INDEX_OBJECT");
1155        }
1156        assert_eq!(default_catalog_index_path(), "_skillati/catalog.v1.json");
1157    }
1158
1159    #[test]
1160    fn build_catalog_manifest_collects_nested_resources() {
1161        let tmp = tempfile::tempdir().unwrap();
1162        let root = tmp.path().join("skills");
1163        let skill = root.join("demo-skill");
1164        fs::create_dir_all(skill.join("references/components")).unwrap();
1165        fs::write(skill.join("SKILL.md"), "# Demo Skill\n\nUseful demo.\n").unwrap();
1166        fs::write(
1167            skill.join("skill.toml"),
1168            "[skill]\nname=\"demo-skill\"\nkeywords=[\"demo\"]\n",
1169        )
1170        .unwrap();
1171        fs::write(
1172            skill.join("references/components/example.md"),
1173            "nested reference",
1174        )
1175        .unwrap();
1176
1177        let manifest = build_catalog_manifest(&root).unwrap();
1178        assert_eq!(manifest.skills.len(), 1);
1179        assert_eq!(manifest.skills[0].meta.name, "demo-skill");
1180        assert_eq!(
1181            manifest.skills[0].resources,
1182            vec!["references/components/example.md".to_string()]
1183        );
1184        assert!(manifest.skills[0].resources_complete);
1185    }
1186
1187    #[test]
1188    fn collect_visible_resources_skips_internal_files() {
1189        let tmp = tempfile::tempdir().unwrap();
1190        let skill = tmp.path().join("demo-skill");
1191        fs::create_dir_all(skill.join("references")).unwrap();
1192        fs::write(skill.join("SKILL.md"), "x").unwrap();
1193        fs::write(skill.join("skill.toml"), "x").unwrap();
1194        fs::write(skill.join(".hidden"), "x").unwrap();
1195        fs::write(skill.join("references/guide.md"), "x").unwrap();
1196
1197        let resources = collect_visible_resources(&skill, &skill).unwrap();
1198        assert_eq!(resources, vec!["references/guide.md".to_string()]);
1199    }
1200
1201    #[test]
1202    fn catalog_entry_sorts_and_dedups_resources() {
1203        let mut entry = SkillAtiCatalogEntry {
1204            meta: remote_skill_meta_from_parts(
1205                "demo-skill",
1206                "".into(),
1207                vec!["b".into(), "a".into(), "A".into()],
1208                vec![],
1209                vec![],
1210                vec![],
1211            ),
1212            resources: vec![
1213                "references/b.md".into(),
1214                "SKILL.md".into(),
1215                "references/a.md".into(),
1216                "references/a.md".into(),
1217            ],
1218            resources_complete: false,
1219        };
1220
1221        super::normalize_catalog_entry(&mut entry);
1222        assert_eq!(entry.meta.keywords, vec!["a".to_string(), "b".to_string()]);
1223        assert_eq!(
1224            entry.resources,
1225            vec!["references/a.md".to_string(), "references/b.md".to_string()]
1226        );
1227    }
1228}