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    /// Optional "when to use" guidance — sourced from SKILL.md YAML
70    /// frontmatter (`when_to_use` field, synonymous with Anthropic's
71    /// `when-to-use`). Used by `ati-client.build_skill_listing` to emit a
72    /// richer `<system-reminder>` block matching Claude Code's shape.
73    /// Serde-optional so older catalog manifests deserialize unchanged.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub when_to_use: Option<String>,
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub keywords: Vec<String>,
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub tools: Vec<String>,
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub providers: Vec<String>,
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    pub categories: Vec<String>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
87pub struct SkillAtiCatalogEntry {
88    #[serde(flatten)]
89    pub meta: RemoteSkillMeta,
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub resources: Vec<String>,
92    #[serde(default, skip)]
93    pub resources_complete: bool,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
97pub struct SkillAtiCatalogManifest {
98    #[serde(default = "default_catalog_version")]
99    pub version: u32,
100    #[serde(default, skip_serializing_if = "String::is_empty")]
101    pub generated_at: String,
102    pub skills: Vec<SkillAtiCatalogEntry>,
103}
104
105/// Level-2 skill activation payload — mirrors Claude Code's
106/// `createSkillCommand.getPromptForCommand` return shape
107/// (`~/cc/src/skills/loadSkillsDir.ts:344`).
108///
109/// Intentionally omits any resource manifest: per Agent Skills'
110/// progressive-disclosure model, Level-3 files (scripts, references) are
111/// pulled *on demand* when the SKILL.md body directs the agent to, not
112/// eagerly returned alongside the body. Agents that want a resource list
113/// call `ati skill fetch resources <name>` explicitly.
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
115pub struct SkillAtiActivation {
116    pub name: String,
117    /// One-line skill description sourced from the catalog entry. Surfaced
118    /// in the Level-2 preamble so the agent can confirm the skill matches
119    /// the task without having to re-query the catalog.
120    pub description: String,
121    pub skill_directory: String,
122    /// SKILL.md body with frontmatter stripped and skill-directory
123    /// variables (`${ATI_SKILL_DIR}` / `${CLAUDE_SKILL_DIR}`) + cross-skill
124    /// filesystem references (`.claude/skills/<other>/…`) rewritten to
125    /// `skillati://<name>/...` URIs.
126    pub content: String,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
130#[serde(tag = "kind", rename_all = "snake_case")]
131pub enum SkillAtiFileData {
132    Text { content: String },
133    Binary { encoding: String, content: String },
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
137pub struct SkillAtiFile {
138    pub requested_skill: String,
139    pub resolved_skill: String,
140    pub path: String,
141    #[serde(flatten)]
142    pub data: SkillAtiFileData,
143}
144
145enum SkillAtiTransport {
146    Gcs(GcsClient),
147    Proxy {
148        http: Client,
149        base_url: String,
150        token: Option<String>,
151    },
152}
153
154pub struct SkillAtiClient {
155    transport: SkillAtiTransport,
156    bytes_cache: Mutex<HashMap<(String, String), Vec<u8>>>,
157    resources_cache: Mutex<HashMap<String, Vec<String>>>,
158    catalog_cache: Mutex<Option<Vec<SkillAtiCatalogEntry>>>,
159}
160
161impl SkillAtiClient {
162    pub fn from_env(keyring: &Keyring) -> Result<Option<Self>, SkillAtiError> {
163        match std::env::var("ATI_SKILL_REGISTRY") {
164            Ok(url) if !url.trim().is_empty() => Ok(Some(Self::from_registry_url(&url, keyring)?)),
165            _ => Ok(None),
166        }
167    }
168
169    pub fn from_registry_url(registry_url: &str, keyring: &Keyring) -> Result<Self, SkillAtiError> {
170        if registry_url.trim() == "proxy" {
171            let base_url = std::env::var("ATI_PROXY_URL")
172                .ok()
173                .filter(|u| !u.trim().is_empty())
174                .ok_or(SkillAtiError::ProxyUrlRequired)?;
175            let base_url = base_url.trim_end_matches('/').to_string();
176            let token = std::env::var("ATI_SESSION_TOKEN")
177                .ok()
178                .filter(|t| !t.trim().is_empty());
179            let http = Client::builder()
180                .timeout(std::time::Duration::from_secs(30))
181                .build()
182                .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
183            return Ok(Self {
184                transport: SkillAtiTransport::Proxy {
185                    http,
186                    base_url,
187                    token,
188                },
189                bytes_cache: Mutex::new(HashMap::new()),
190                resources_cache: Mutex::new(HashMap::new()),
191                catalog_cache: Mutex::new(None),
192            });
193        }
194
195        let bucket = registry_url
196            .strip_prefix("gcs://")
197            .ok_or_else(|| SkillAtiError::UnsupportedRegistry(registry_url.to_string()))?;
198
199        let cred_json = keyring
200            .get(GCS_CREDENTIAL_KEY)
201            .ok_or(SkillAtiError::MissingCredentials(GCS_CREDENTIAL_KEY))?;
202
203        let gcs = GcsClient::new(bucket.to_string(), cred_json)?;
204        Ok(Self {
205            transport: SkillAtiTransport::Gcs(gcs),
206            bytes_cache: Mutex::new(HashMap::new()),
207            resources_cache: Mutex::new(HashMap::new()),
208            catalog_cache: Mutex::new(None),
209        })
210    }
211
212    /// Build an authenticated request to the proxy.
213    fn proxy_request(
214        http: &Client,
215        method: reqwest::Method,
216        url: &str,
217        token: Option<&str>,
218    ) -> reqwest::RequestBuilder {
219        let mut req = http.request(method, url);
220        if let Some(t) = token {
221            req = req.header("Authorization", format!("Bearer {t}"));
222        }
223        req
224    }
225
226    /// Fetch the catalog from the proxy's /skillati/catalog endpoint.
227    async fn proxy_catalog(
228        http: &Client,
229        base_url: &str,
230        token: Option<&str>,
231    ) -> Result<Vec<SkillAtiCatalogEntry>, SkillAtiError> {
232        let url = format!("{base_url}/skillati/catalog");
233        let resp = Self::proxy_request(http, reqwest::Method::GET, &url, token)
234            .send()
235            .await
236            .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
237
238        let status = resp.status().as_u16();
239        let body = resp
240            .text()
241            .await
242            .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
243
244        if status != 200 {
245            return Err(SkillAtiError::ProxyResponse(format!(
246                "HTTP {status}: {body}"
247            )));
248        }
249
250        #[derive(Deserialize)]
251        struct CatalogResp {
252            skills: Vec<RemoteSkillMeta>,
253        }
254        let parsed: CatalogResp =
255            serde_json::from_str(&body).map_err(|e| SkillAtiError::ProxyResponse(e.to_string()))?;
256
257        Ok(parsed
258            .skills
259            .into_iter()
260            .map(|meta| SkillAtiCatalogEntry {
261                meta,
262                resources: Vec::new(),
263                resources_complete: false,
264            })
265            .collect())
266    }
267
268    /// Fetch SKILL.md bytes from the proxy's /skillati/:name endpoint.
269    async fn proxy_read_skill_md(
270        http: &Client,
271        base_url: &str,
272        token: Option<&str>,
273        name: &str,
274    ) -> Result<Vec<u8>, SkillAtiError> {
275        let url = format!("{base_url}/skillati/{name}");
276        let resp = Self::proxy_request(http, reqwest::Method::GET, &url, token)
277            .send()
278            .await
279            .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
280
281        let status = resp.status().as_u16();
282        let body = resp
283            .text()
284            .await
285            .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
286
287        match status {
288            404 => return Err(SkillAtiError::SkillNotFound(name.to_string())),
289            200 => {}
290            _ => {
291                return Err(SkillAtiError::ProxyResponse(format!(
292                    "HTTP {status}: {body}"
293                )))
294            }
295        }
296
297        // Response is a SkillAtiActivation JSON — extract the content field
298        #[derive(Deserialize)]
299        struct ActivationResp {
300            content: String,
301        }
302        let parsed: ActivationResp =
303            serde_json::from_str(&body).map_err(|e| SkillAtiError::ProxyResponse(e.to_string()))?;
304
305        Ok(parsed.content.into_bytes())
306    }
307
308    /// Fetch a file from the proxy's /skillati/:name/file?path=... endpoint.
309    async fn proxy_read_file(
310        http: &Client,
311        base_url: &str,
312        token: Option<&str>,
313        name: &str,
314        path: &str,
315    ) -> Result<Vec<u8>, SkillAtiError> {
316        if path == "SKILL.md" {
317            return Self::proxy_read_skill_md(http, base_url, token, name).await;
318        }
319
320        let url = format!("{base_url}/skillati/{name}/file");
321        let resp = Self::proxy_request(http, reqwest::Method::GET, &url, token)
322            .query(&[("path", path)])
323            .send()
324            .await
325            .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
326
327        let status = resp.status().as_u16();
328        let body = resp
329            .text()
330            .await
331            .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
332
333        match status {
334            404 => {
335                return Err(SkillAtiError::PathNotFound {
336                    skill: name.to_string(),
337                    path: path.to_string(),
338                })
339            }
340            200 => {}
341            _ => {
342                return Err(SkillAtiError::ProxyResponse(format!(
343                    "HTTP {status}: {body}"
344                )))
345            }
346        }
347
348        #[derive(Deserialize)]
349        #[serde(tag = "kind", rename_all = "snake_case")]
350        enum FileDataResp {
351            Text { content: String },
352            Binary { content: String },
353        }
354        let parsed: FileDataResp =
355            serde_json::from_str(&body).map_err(|e| SkillAtiError::ProxyResponse(e.to_string()))?;
356
357        match parsed {
358            FileDataResp::Text { content } => Ok(content.into_bytes()),
359            FileDataResp::Binary { content } => base64::engine::general_purpose::STANDARD
360                .decode(content)
361                .map_err(|e| SkillAtiError::ProxyResponse(e.to_string())),
362        }
363    }
364
365    /// List resources from the proxy's /skillati/:name/resources endpoint.
366    async fn proxy_list_resources(
367        http: &Client,
368        base_url: &str,
369        token: Option<&str>,
370        name: &str,
371    ) -> Result<Vec<String>, SkillAtiError> {
372        let url = format!("{base_url}/skillati/{name}/resources");
373        let resp = Self::proxy_request(http, reqwest::Method::GET, &url, token)
374            .send()
375            .await
376            .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
377
378        let status = resp.status().as_u16();
379        let body = resp
380            .text()
381            .await
382            .map_err(|e| SkillAtiError::ProxyRequest(e.to_string()))?;
383
384        match status {
385            404 => return Err(SkillAtiError::SkillNotFound(name.to_string())),
386            200 => {}
387            _ => {
388                return Err(SkillAtiError::ProxyResponse(format!(
389                    "HTTP {status}: {body}"
390                )))
391            }
392        }
393
394        #[derive(Deserialize)]
395        struct ResourcesResp {
396            resources: Vec<String>,
397        }
398        let parsed: ResourcesResp =
399            serde_json::from_str(&body).map_err(|e| SkillAtiError::ProxyResponse(e.to_string()))?;
400
401        Ok(parsed.resources)
402    }
403
404    pub async fn catalog(&self) -> Result<Vec<RemoteSkillMeta>, SkillAtiError> {
405        Ok(self
406            .catalog_entries()
407            .await?
408            .into_iter()
409            .map(|entry| entry.meta)
410            .collect())
411    }
412
413    pub fn filter_catalog(
414        catalog: &[RemoteSkillMeta],
415        query: &str,
416        limit: usize,
417    ) -> Vec<RemoteSkillMeta> {
418        let query = query.trim().to_lowercase();
419        if query.is_empty() {
420            return catalog.iter().take(limit).cloned().collect();
421        }
422
423        let terms: Vec<&str> = query.split_whitespace().collect();
424        let mut scored: Vec<(usize, &RemoteSkillMeta)> = catalog
425            .iter()
426            .map(|skill| {
427                let haystack = search_haystack(skill);
428                let score = terms
429                    .iter()
430                    .filter(|term| haystack.contains(**term))
431                    .count();
432                (score, skill)
433            })
434            .filter(|(score, skill)| *score > 0 || search_haystack(skill).contains(&query))
435            .collect();
436
437        scored.sort_by(|a, b| {
438            b.0.cmp(&a.0)
439                .then_with(|| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase()))
440        });
441
442        scored
443            .into_iter()
444            .take(limit)
445            .map(|(_, skill)| skill.clone())
446            .collect()
447    }
448
449    /// Level-2 skill activation. Returns SKILL.md body with frontmatter
450    /// stripped, skill-dir variables substituted, and cross-skill filesystem
451    /// references rewritten to `skillati://` URIs. Mirrors Claude Code's
452    /// `createSkillCommand.getPromptForCommand`
453    /// (`~/cc/src/skills/loadSkillsDir.ts:344`) but without the
454    /// `${CLAUDE_SESSION_ID}` and `!`shell-exec binding, which are
455    /// CC-runtime-specific.
456    ///
457    /// Does NOT eagerly fetch the resource list — that's Level-3 and the
458    /// agent pulls it on demand via `ati skill fetch resources <name>`.
459    pub async fn read_skill(&self, name: &str) -> Result<SkillAtiActivation, SkillAtiError> {
460        let raw = self.read_text(name, "SKILL.md").await?;
461
462        // Look up the catalog entry for description AND build the set of
463        // known skill names in the same pass. Catalog is cached per client
464        // after first call, so both lookups are free on the hot path.
465        //
466        // The catalog names set powers the third substitution rule: bare
467        // `<other-skill>/(references|scripts|assets)/…` references in the
468        // body get rewritten to `skillati://<other-skill>/…` when
469        // `<other-skill>` is a real catalog entry.
470        let (description, catalog_names) = match self.catalog_entries().await {
471            Ok(catalog) => {
472                let description = catalog
473                    .iter()
474                    .find(|entry| entry.meta.name == name)
475                    .map(|entry| entry.meta.description.clone())
476                    .unwrap_or_default();
477                let names: HashSet<String> =
478                    catalog.into_iter().map(|entry| entry.meta.name).collect();
479                (description, Some(names))
480            }
481            Err(_) => (String::new(), None),
482        };
483
484        let body = strip_frontmatter(&raw);
485        let content = substitute_skill_refs(body, name, catalog_names.as_ref());
486
487        Ok(SkillAtiActivation {
488            name: name.to_string(),
489            description,
490            skill_directory: skill_directory(name),
491            content,
492        })
493    }
494
495    pub async fn list_resources(
496        &self,
497        name: &str,
498        prefix: Option<&str>,
499    ) -> Result<Vec<String>, SkillAtiError> {
500        let resources = self.list_all_resources(name).await?;
501        let normalized_prefix = match prefix {
502            Some(value) if !value.trim().is_empty() => Some(normalize_prefix(value)?),
503            _ => None,
504        };
505
506        let filtered = match normalized_prefix {
507            Some(prefix) => resources
508                .into_iter()
509                .filter(|path| path == &prefix || path.starts_with(&format!("{prefix}/")))
510                .collect(),
511            None => resources,
512        };
513
514        Ok(filtered)
515    }
516
517    pub async fn read_path(
518        &self,
519        requested_skill: &str,
520        requested_path: &str,
521    ) -> Result<SkillAtiFile, SkillAtiError> {
522        let (resolved_skill, resolved_path) =
523            resolve_requested_path(requested_skill, requested_path)?;
524        let bytes = self.read_bytes(&resolved_skill, &resolved_path).await?;
525
526        let data = match String::from_utf8(bytes.clone()) {
527            Ok(text) => SkillAtiFileData::Text { content: text },
528            Err(_) => SkillAtiFileData::Binary {
529                encoding: "base64".to_string(),
530                content: base64::engine::general_purpose::STANDARD.encode(bytes),
531            },
532        };
533
534        Ok(SkillAtiFile {
535            requested_skill: requested_skill.to_string(),
536            resolved_skill,
537            path: resolved_path,
538            data,
539        })
540    }
541
542    pub async fn list_references(&self, name: &str) -> Result<Vec<String>, SkillAtiError> {
543        let refs = self.list_resources(name, Some("references")).await?;
544        Ok(refs
545            .into_iter()
546            .filter_map(|path| path.strip_prefix("references/").map(str::to_string))
547            .collect())
548    }
549
550    pub async fn read_reference(
551        &self,
552        name: &str,
553        reference: &str,
554    ) -> Result<String, SkillAtiError> {
555        let path = format!("references/{reference}");
556        let file = self.read_path(name, &path).await?;
557        match file.data {
558            SkillAtiFileData::Text { content } => Ok(content),
559            SkillAtiFileData::Binary { .. } => Err(SkillAtiError::InvalidPath(path)),
560        }
561    }
562
563    async fn catalog_entries(&self) -> Result<Vec<SkillAtiCatalogEntry>, SkillAtiError> {
564        if let Some(cached) = self.catalog_cache.lock().unwrap().clone() {
565            return Ok(cached);
566        }
567
568        let entries = match &self.transport {
569            SkillAtiTransport::Proxy {
570                http,
571                base_url,
572                token,
573            } => Self::proxy_catalog(http, base_url, token.as_deref()).await?,
574            SkillAtiTransport::Gcs(_) => match self.load_catalog_index().await? {
575                Some(entries) => entries,
576                None => self.load_catalog_fallback().await?,
577            },
578        };
579
580        self.catalog_cache.lock().unwrap().replace(entries.clone());
581        Ok(entries)
582    }
583
584    async fn load_catalog_index(&self) -> Result<Option<Vec<SkillAtiCatalogEntry>>, SkillAtiError> {
585        let gcs = match &self.transport {
586            SkillAtiTransport::Gcs(gcs) => gcs,
587            SkillAtiTransport::Proxy { .. } => {
588                unreachable!("load_catalog_index called on proxy transport")
589            }
590        };
591        for candidate in catalog_index_candidates() {
592            match gcs.get_object_text(&candidate).await {
593                Ok(raw) => match serde_json::from_str::<SkillAtiCatalogManifest>(&raw) {
594                    Ok(mut manifest) => {
595                        for entry in &mut manifest.skills {
596                            normalize_catalog_entry(entry);
597                            entry.resources_complete = true;
598                        }
599                        manifest.skills.sort_by(|a, b| {
600                            a.meta.name.to_lowercase().cmp(&b.meta.name.to_lowercase())
601                        });
602                        tracing::debug!(
603                            path = %candidate,
604                            skills = manifest.skills.len(),
605                            "loaded SkillATI catalog index"
606                        );
607                        return Ok(Some(manifest.skills));
608                    }
609                    Err(err) => {
610                        tracing::warn!(
611                            path = %candidate,
612                            error = %err,
613                            "SkillATI catalog index was invalid, falling back"
614                        );
615                    }
616                },
617                Err(GcsError::Api { status: 404, .. }) => continue,
618                Err(err) => return Err(SkillAtiError::Gcs(err)),
619            }
620        }
621
622        Ok(None)
623    }
624
625    async fn load_catalog_fallback(&self) -> Result<Vec<SkillAtiCatalogEntry>, SkillAtiError> {
626        let gcs = match &self.transport {
627            SkillAtiTransport::Gcs(gcs) => gcs,
628            SkillAtiTransport::Proxy { .. } => {
629                unreachable!("load_catalog_fallback called on proxy transport")
630            }
631        };
632        let mut names = gcs.list_skill_names().await?;
633        names.sort();
634
635        let mut entries: Vec<SkillAtiCatalogEntry> = stream::iter(names.into_iter())
636            .map(|name| async move {
637                let raw = self.read_text(&name, "SKILL.md").await?;
638                let parsed = parse_skill_metadata(&name, &raw, None)
639                    .map_err(|e| SkillAtiError::InvalidPath(e.to_string()))?;
640                Ok::<SkillAtiCatalogEntry, SkillAtiError>(SkillAtiCatalogEntry {
641                    meta: remote_skill_meta_from_parts(
642                        &name,
643                        parsed.description,
644                        parsed.when_to_use,
645                        parsed.keywords,
646                        parsed.tools,
647                        parsed.providers,
648                        parsed.categories,
649                    ),
650                    resources: Vec::new(),
651                    resources_complete: false,
652                })
653            })
654            .buffer_unordered(FALLBACK_CATALOG_CONCURRENCY)
655            .collect::<Vec<_>>()
656            .await
657            .into_iter()
658            .collect::<Result<Vec<_>, _>>()?;
659
660        entries.sort_by(|a, b| a.meta.name.to_lowercase().cmp(&b.meta.name.to_lowercase()));
661        Ok(entries)
662    }
663
664    async fn list_all_resources(&self, name: &str) -> Result<Vec<String>, SkillAtiError> {
665        if let Some(cached) = self.resources_cache.lock().unwrap().get(name).cloned() {
666            return Ok(cached);
667        }
668
669        // For proxy transport, fetch directly from proxy
670        if let SkillAtiTransport::Proxy {
671            http,
672            base_url,
673            token,
674        } = &self.transport
675        {
676            let resources =
677                Self::proxy_list_resources(http, base_url, token.as_deref(), name).await?;
678            self.resources_cache
679                .lock()
680                .unwrap()
681                .insert(name.to_string(), resources.clone());
682            return Ok(resources);
683        }
684
685        if let Some(indexed) = self
686            .catalog_entries()
687            .await?
688            .into_iter()
689            .find(|entry| entry.meta.name == name && entry.resources_complete)
690        {
691            self.resources_cache
692                .lock()
693                .unwrap()
694                .insert(name.to_string(), indexed.resources.clone());
695            return Ok(indexed.resources);
696        }
697
698        self.ensure_skill_exists(name).await?;
699
700        let gcs = match &self.transport {
701            SkillAtiTransport::Gcs(gcs) => gcs,
702            SkillAtiTransport::Proxy { .. } => unreachable!(),
703        };
704        let mut resources = gcs.list_objects(name).await?;
705        resources.retain(|path| is_visible_resource(path));
706        resources.sort();
707        resources.dedup();
708
709        self.resources_cache
710            .lock()
711            .unwrap()
712            .insert(name.to_string(), resources.clone());
713        Ok(resources)
714    }
715
716    async fn ensure_skill_exists(&self, name: &str) -> Result<(), SkillAtiError> {
717        self.read_bytes(name, "SKILL.md").await.map(|_| ())
718    }
719
720    async fn read_text(&self, name: &str, relative_path: &str) -> Result<String, SkillAtiError> {
721        let bytes = self.read_bytes(name, relative_path).await?;
722        String::from_utf8(bytes).map_err(|e| match &self.transport {
723            SkillAtiTransport::Gcs(_) => SkillAtiError::Gcs(GcsError::Utf8(e.to_string())),
724            SkillAtiTransport::Proxy { .. } => SkillAtiError::ProxyResponse(format!(
725                "invalid UTF-8 in {name}/{relative_path}: {e}"
726            )),
727        })
728    }
729
730    async fn read_bytes(&self, name: &str, relative_path: &str) -> Result<Vec<u8>, SkillAtiError> {
731        let cache_key = (name.to_string(), relative_path.to_string());
732        if let Some(cached) = self.bytes_cache.lock().unwrap().get(&cache_key).cloned() {
733            return Ok(cached);
734        }
735
736        let bytes = match &self.transport {
737            SkillAtiTransport::Proxy {
738                http,
739                base_url,
740                token,
741            } => {
742                Self::proxy_read_file(http, base_url, token.as_deref(), name, relative_path).await?
743            }
744            SkillAtiTransport::Gcs(gcs) => {
745                let gcs_path = format!("{name}/{relative_path}");
746                gcs.get_object(&gcs_path)
747                    .await
748                    .map_err(|e| map_gcs_error(name, relative_path, e))?
749            }
750        };
751
752        self.bytes_cache
753            .lock()
754            .unwrap()
755            .insert(cache_key, bytes.clone());
756        Ok(bytes)
757    }
758}
759
760pub fn build_catalog_manifest(
761    source_dir: &Path,
762) -> Result<SkillAtiCatalogManifest, SkillAtiBuildError> {
763    if !source_dir.exists() {
764        return Err(SkillAtiBuildError::MissingSource(
765            source_dir.display().to_string(),
766        ));
767    }
768
769    let mut skill_dirs = discover_skill_dirs(source_dir)?;
770    skill_dirs.sort();
771
772    let mut skills = Vec::new();
773    for skill_dir in skill_dirs {
774        skills.push(build_catalog_entry_from_dir(&skill_dir)?);
775    }
776
777    skills.sort_by(|a, b| a.meta.name.to_lowercase().cmp(&b.meta.name.to_lowercase()));
778    Ok(SkillAtiCatalogManifest {
779        version: default_catalog_version(),
780        generated_at: Utc::now().to_rfc3339(),
781        skills,
782    })
783}
784
785pub fn default_catalog_index_path() -> &'static str {
786    DEFAULT_CATALOG_INDEX_PATH
787}
788
789fn default_catalog_version() -> u32 {
790    1
791}
792
793fn catalog_index_candidates() -> Vec<String> {
794    match std::env::var("ATI_SKILL_REGISTRY_INDEX_OBJECT") {
795        Ok(value) => {
796            let candidates: Vec<String> = value
797                .split(',')
798                .map(str::trim)
799                .filter(|candidate| !candidate.is_empty())
800                .map(str::to_string)
801                .collect();
802            if candidates.is_empty() {
803                DEFAULT_CATALOG_INDEX_CANDIDATES
804                    .iter()
805                    .map(|candidate| candidate.to_string())
806                    .collect()
807            } else {
808                candidates
809            }
810        }
811        Err(_) => DEFAULT_CATALOG_INDEX_CANDIDATES
812            .iter()
813            .map(|candidate| candidate.to_string())
814            .collect(),
815    }
816}
817
818fn build_catalog_entry_from_dir(
819    skill_dir: &Path,
820) -> Result<SkillAtiCatalogEntry, SkillAtiBuildError> {
821    let dir_name = skill_dir
822        .file_name()
823        .and_then(|name| name.to_str())
824        .ok_or_else(|| SkillAtiBuildError::MissingSource(skill_dir.display().to_string()))?
825        .to_string();
826
827    let skill_md_path = skill_dir.join("SKILL.md");
828    let skill_md = fs::read_to_string(&skill_md_path)
829        .map_err(|err| SkillAtiBuildError::Io(skill_md_path.display().to_string(), err))?;
830    let skill_toml_path = skill_dir.join("skill.toml");
831    let skill_toml =
832        if skill_toml_path.exists() {
833            Some(fs::read_to_string(&skill_toml_path).map_err(|err| {
834                SkillAtiBuildError::Io(skill_toml_path.display().to_string(), err)
835            })?)
836        } else {
837            None
838        };
839
840    let parsed = parse_skill_metadata(&dir_name, &skill_md, skill_toml.as_deref())
841        .map_err(|err| SkillAtiBuildError::Metadata(dir_name.clone(), err.to_string()))?;
842    let resources = collect_visible_resources(skill_dir, skill_dir)?;
843
844    Ok(SkillAtiCatalogEntry {
845        meta: remote_skill_meta_from_parts(
846            &dir_name,
847            parsed.description,
848            parsed.when_to_use,
849            parsed.keywords,
850            parsed.tools,
851            parsed.providers,
852            parsed.categories,
853        ),
854        resources,
855        resources_complete: true,
856    })
857}
858
859fn discover_skill_dirs(source_dir: &Path) -> Result<Vec<PathBuf>, SkillAtiBuildError> {
860    if source_dir.join("SKILL.md").is_file() {
861        return Ok(vec![source_dir.to_path_buf()]);
862    }
863
864    let mut skill_dirs = Vec::new();
865    let entries = fs::read_dir(source_dir)
866        .map_err(|err| SkillAtiBuildError::Io(source_dir.display().to_string(), err))?;
867
868    for entry in entries {
869        let entry =
870            entry.map_err(|err| SkillAtiBuildError::Io(source_dir.display().to_string(), err))?;
871        let path = entry.path();
872        if path.is_dir() && path.join("SKILL.md").is_file() {
873            skill_dirs.push(path);
874        }
875    }
876
877    Ok(skill_dirs)
878}
879
880fn collect_visible_resources(
881    root: &Path,
882    current: &Path,
883) -> Result<Vec<String>, SkillAtiBuildError> {
884    let mut resources = Vec::new();
885    let entries = fs::read_dir(current)
886        .map_err(|err| SkillAtiBuildError::Io(current.display().to_string(), err))?;
887
888    for entry in entries {
889        let entry =
890            entry.map_err(|err| SkillAtiBuildError::Io(current.display().to_string(), err))?;
891        let path = entry.path();
892        let file_name = entry.file_name();
893        if file_name.to_string_lossy().starts_with('.') {
894            continue;
895        }
896
897        if path.is_dir() {
898            resources.extend(collect_visible_resources(root, &path)?);
899            continue;
900        }
901
902        let relative = path
903            .strip_prefix(root)
904            .map_err(|_| SkillAtiBuildError::MissingSource(path.display().to_string()))?
905            .to_string_lossy()
906            .replace('\\', "/");
907
908        if is_visible_resource(&relative) {
909            resources.push(relative);
910        }
911    }
912
913    resources.sort();
914    resources.dedup();
915    Ok(resources)
916}
917
918fn remote_skill_meta_from_parts(
919    name: &str,
920    description: String,
921    when_to_use: Option<String>,
922    mut keywords: Vec<String>,
923    mut tools: Vec<String>,
924    mut providers: Vec<String>,
925    mut categories: Vec<String>,
926) -> RemoteSkillMeta {
927    dedup_sort_casefold(&mut keywords);
928    dedup_sort_casefold(&mut tools);
929    dedup_sort_casefold(&mut providers);
930    dedup_sort_casefold(&mut categories);
931
932    RemoteSkillMeta {
933        name: name.to_string(),
934        description,
935        skill_directory: skill_directory(name),
936        when_to_use: when_to_use
937            .map(|s| s.trim().to_string())
938            .filter(|s| !s.is_empty()),
939        keywords,
940        tools,
941        providers,
942        categories,
943    }
944}
945
946fn normalize_catalog_entry(entry: &mut SkillAtiCatalogEntry) {
947    if entry.meta.skill_directory.trim().is_empty() {
948        entry.meta.skill_directory = skill_directory(&entry.meta.name);
949    }
950    dedup_sort_casefold(&mut entry.meta.keywords);
951    dedup_sort_casefold(&mut entry.meta.tools);
952    dedup_sort_casefold(&mut entry.meta.providers);
953    dedup_sort_casefold(&mut entry.meta.categories);
954    entry.resources.retain(|path| is_visible_resource(path));
955    entry.resources.sort();
956    entry.resources.dedup();
957}
958
959fn dedup_sort_casefold(values: &mut Vec<String>) {
960    let mut seen = HashSet::new();
961    values.retain(|value| {
962        let normalized = value.trim().to_lowercase();
963        !normalized.is_empty() && seen.insert(normalized)
964    });
965    values.sort_by_key(|value| value.to_lowercase());
966}
967
968fn search_haystack(skill: &RemoteSkillMeta) -> String {
969    let mut parts = vec![skill.name.to_lowercase(), skill.description.to_lowercase()];
970    if !skill.keywords.is_empty() {
971        parts.push(skill.keywords.join(" ").to_lowercase());
972    }
973    if !skill.tools.is_empty() {
974        parts.push(skill.tools.join(" ").to_lowercase());
975    }
976    if !skill.providers.is_empty() {
977        parts.push(skill.providers.join(" ").to_lowercase());
978    }
979    if !skill.categories.is_empty() {
980        parts.push(skill.categories.join(" ").to_lowercase());
981    }
982    parts.join(" ")
983}
984
985fn map_gcs_error(skill: &str, relative_path: &str, error: GcsError) -> SkillAtiError {
986    match error {
987        GcsError::Api { status: 404, .. } if relative_path == "SKILL.md" => {
988            SkillAtiError::SkillNotFound(skill.to_string())
989        }
990        GcsError::Api { status: 404, .. } => SkillAtiError::PathNotFound {
991            skill: skill.to_string(),
992            path: relative_path.to_string(),
993        },
994        other => SkillAtiError::Gcs(other),
995    }
996}
997
998fn skill_directory(name: &str) -> String {
999    format!("skillati://{name}")
1000}
1001
1002/// Rewrite skill-directory variables and cross-skill filesystem references
1003/// in a SKILL.md body so the agent's subsequent file fetches resolve
1004/// against the ATI runtime instead of the local filesystem.
1005///
1006/// Three transformations, in order:
1007///
1008/// 1. **Variable substitution.** `${ATI_SKILL_DIR}` and `${CLAUDE_SKILL_DIR}`
1009///    both become `skillati://<name>`. Supporting both names means skill
1010///    content authored for Claude Code works unchanged here, and vice
1011///    versa. This mirrors Claude Code's `${CLAUDE_SKILL_DIR}` substitution
1012///    at `~/cc/src/skills/loadSkillsDir.ts:362`.
1013///
1014/// 2. **`.claude/skills/` anchored rewrite.** Any `.claude/skills/<other>/`
1015///    prefix in the body (as long as `<other>` is a plausible skill name
1016///    per `is_anthropic_valid_name`) is rewritten to `skillati://<other>/`.
1017///    This handles the common authoring mistake where a skill body says
1018///    "read `.claude/skills/anti-slop-design/SKILL.md`" — a filesystem
1019///    path that doesn't exist in remote-catalog sandboxes. After
1020///    rewriting, the agent naturally resolves the reference via
1021///    `ati skill fetch read anti-slop-design`.
1022///
1023/// 3. **Bare cross-skill reference rewrite** *(only when `catalog_names`
1024///    is provided)*. Any occurrence of
1025///    `<catalog-skill-name>/(references|scripts|assets)/<path>` is
1026///    rewritten to `skillati://<catalog-skill-name>/…`. Narrowly scoped:
1027///    the first segment must be in the caller's catalog, and the second
1028///    segment must be one of the conventional Anthropic Agent Skills
1029///    subdirectories. Catches real-world cases like
1030///    `html-app-architecture` saying "load your font pair from
1031///    anti-slop-design/references/font-pairs.md" without the
1032///    `.claude/skills/` anchor or a `${CLAUDE_SKILL_DIR}` prefix. The
1033///    catalog-membership check rules out false positives like
1034///    `2024/references/report.md`.
1035///
1036/// Footgun note: substitution is a literal string pass, so a skill body
1037/// containing `${CLAUDE_SKILL_DIR}` inside a code-fence example ("don't do
1038/// this: `${CLAUDE_SKILL_DIR}`") would also be rewritten. Claude Code has
1039/// the same behavior at the same callsite — acceptable for now, document
1040/// if it becomes a real problem.
1041fn substitute_skill_refs(
1042    body: &str,
1043    skill_name: &str,
1044    catalog_names: Option<&HashSet<String>>,
1045) -> String {
1046    let skill_uri = skill_directory(skill_name);
1047
1048    // Step 1: variable substitution (literal string replace, cheap).
1049    let mut out = body
1050        .replace("${ATI_SKILL_DIR}", &skill_uri)
1051        .replace("${CLAUDE_SKILL_DIR}", &skill_uri);
1052
1053    // Step 2: rewrite `.claude/skills/<other>/` prefixes to
1054    // `skillati://<other>/`. We need to parse the segment between the
1055    // `.claude/skills/` anchor and the next `/` to check whether it's a
1056    // valid skill name before rewriting — rejects prose mentions like
1057    // ".claude/skills/ directory" while catching real path references.
1058    let needle = ".claude/skills/";
1059    let mut rewritten = String::with_capacity(out.len());
1060    let mut cursor = 0;
1061    while let Some(rel) = out[cursor..].find(needle) {
1062        let hit = cursor + rel;
1063        rewritten.push_str(&out[cursor..hit]);
1064        let after = hit + needle.len();
1065        // Read until the next `/` or a non-name char — this is the
1066        // candidate skill name.
1067        let tail = &out[after..];
1068        let name_len = tail
1069            .bytes()
1070            .take_while(|b| b.is_ascii_alphanumeric() || *b == b'-')
1071            .count();
1072        if name_len == 0 {
1073            // `.claude/skills/` followed by non-name char — not a path
1074            // reference, pass through verbatim.
1075            rewritten.push_str(needle);
1076            cursor = after;
1077            continue;
1078        }
1079        let candidate = &tail[..name_len];
1080        // Only rewrite if the next char is `/` OR we're at end-of-string
1081        // (directory-form references, not prose).
1082        let next_char = tail.as_bytes().get(name_len).copied();
1083        let is_dir_form = matches!(next_char, Some(b'/') | None);
1084        if is_dir_form && is_anthropic_valid_name(candidate) {
1085            rewritten.push_str("skillati://");
1086            rewritten.push_str(candidate);
1087            cursor = after + name_len;
1088        } else {
1089            rewritten.push_str(needle);
1090            cursor = after;
1091        }
1092    }
1093    rewritten.push_str(&out[cursor..]);
1094    out = rewritten;
1095
1096    // Step 3: bare cross-skill reference rewrite — only when we have a
1097    // catalog to check against. Match tokens of the form
1098    // `<word>/(references|scripts|assets)/...` where `<word>` is in
1099    // `catalog_names` and `<word>` != `skill_name` (a self-reference
1100    // would be a local path; leave it alone).
1101    if let Some(catalog) = catalog_names {
1102        out = rewrite_bare_cross_skill_refs(&out, skill_name, catalog);
1103    }
1104
1105    out
1106}
1107
1108/// Scan `body` for bare cross-skill references of the form
1109/// `<catalog-name>/(references|scripts|assets)/<rest>` and rewrite them
1110/// to `skillati://<catalog-name>/(references|scripts|assets)/<rest>`.
1111///
1112/// Only called from `substitute_skill_refs` when a catalog is available.
1113/// Strict about what counts as a "plausible" token: the first segment
1114/// must pass `is_anthropic_valid_name` AND be in `catalog`; the second
1115/// segment must be exactly `references`, `scripts`, or `assets`. Keeps
1116/// the false-positive rate near zero while catching the common pattern
1117/// in production content (e.g., `anti-slop-design/references/...` in
1118/// `html-app-architecture`'s SKILL.md body).
1119fn rewrite_bare_cross_skill_refs(
1120    body: &str,
1121    current_skill: &str,
1122    catalog: &HashSet<String>,
1123) -> String {
1124    const SUBDIRS: &[&str] = &["references", "scripts", "assets"];
1125
1126    let bytes = body.as_bytes();
1127    let mut out = String::with_capacity(body.len());
1128    let mut cursor = 0;
1129
1130    // Walk the body looking for the start of each token. A token can
1131    // start at the beginning of the body OR after a character that
1132    // is not part of a valid name (whitespace, punctuation, etc.).
1133    //
1134    // We only care about positions where a name char follows a non-name
1135    // char (or position 0). At each such boundary, check whether the
1136    // next segment is a valid rewrite target.
1137    let mut i = 0;
1138    while i < bytes.len() {
1139        let is_boundary = i == 0
1140            || !bytes[i - 1].is_ascii_alphanumeric()
1141                && bytes[i - 1] != b'-'
1142                && bytes[i - 1] != b'_'
1143                && bytes[i - 1] != b'/';
1144        if !is_boundary {
1145            i += 1;
1146            continue;
1147        }
1148
1149        // Try to parse `<name>/<subdir>/` starting at `i`.
1150        let name_end = i + bytes[i..]
1151            .iter()
1152            .take_while(|b| b.is_ascii_alphanumeric() || **b == b'-')
1153            .count();
1154        if name_end == i || name_end >= bytes.len() || bytes[name_end] != b'/' {
1155            i += 1;
1156            continue;
1157        }
1158        let candidate = &body[i..name_end];
1159        if !is_anthropic_valid_name(candidate)
1160            || candidate == current_skill
1161            || !catalog.contains(candidate)
1162        {
1163            i += 1;
1164            continue;
1165        }
1166
1167        // After the `/`, parse the subdir name.
1168        let subdir_start = name_end + 1;
1169        let subdir_end = subdir_start
1170            + bytes[subdir_start..]
1171                .iter()
1172                .take_while(|b| b.is_ascii_alphanumeric() || **b == b'_')
1173                .count();
1174        if subdir_end == subdir_start || subdir_end >= bytes.len() || bytes[subdir_end] != b'/' {
1175            i += 1;
1176            continue;
1177        }
1178        let subdir = &body[subdir_start..subdir_end];
1179        if !SUBDIRS.contains(&subdir) {
1180            i += 1;
1181            continue;
1182        }
1183
1184        // Confirmed: rewrite `<candidate>/<subdir>/` → `skillati://<candidate>/<subdir>/`.
1185        out.push_str(&body[cursor..i]);
1186        out.push_str("skillati://");
1187        out.push_str(&body[i..subdir_end + 1]); // includes trailing '/'
1188        cursor = subdir_end + 1;
1189        i = cursor;
1190    }
1191    out.push_str(&body[cursor..]);
1192    out
1193}
1194
1195fn is_visible_resource(path: &str) -> bool {
1196    !path.is_empty()
1197        && !path.ends_with('/')
1198        && path != "SKILL.md"
1199        && path != "skill.toml"
1200        && !path.starts_with('.')
1201}
1202
1203fn normalize_prefix(prefix: &str) -> Result<String, SkillAtiError> {
1204    let prefix = trim_leading_current_dir(prefix);
1205    if prefix.is_empty() {
1206        return Err(SkillAtiError::InvalidPath(prefix.to_string()));
1207    }
1208    normalize_within_skill(prefix)
1209}
1210
1211fn resolve_requested_path(
1212    requested_skill: &str,
1213    requested_path: &str,
1214) -> Result<(String, String), SkillAtiError> {
1215    let requested_path = trim_leading_current_dir(requested_path);
1216    validate_raw_path(requested_path)?;
1217
1218    if requested_path == ".." {
1219        return Err(SkillAtiError::InvalidPath(requested_path.to_string()));
1220    }
1221
1222    if let Some(rest) = requested_path.strip_prefix("../") {
1223        let segments: Vec<&str> = rest
1224            .split('/')
1225            .filter(|segment| !segment.is_empty() && *segment != ".")
1226            .collect();
1227        if segments.len() < 2 {
1228            return Err(SkillAtiError::InvalidPath(requested_path.to_string()));
1229        }
1230
1231        let sibling_skill = segments[0];
1232        if !is_anthropic_valid_name(sibling_skill) {
1233            return Err(SkillAtiError::InvalidPath(requested_path.to_string()));
1234        }
1235
1236        let normalized_path = normalize_within_skill(&segments[1..].join("/"))?;
1237        return Ok((sibling_skill.to_string(), normalized_path));
1238    }
1239
1240    Ok((
1241        requested_skill.to_string(),
1242        normalize_within_skill(requested_path)?,
1243    ))
1244}
1245
1246fn trim_leading_current_dir(path: &str) -> &str {
1247    let mut trimmed = path.trim();
1248    while let Some(rest) = trimmed.strip_prefix("./") {
1249        trimmed = rest;
1250    }
1251    trimmed
1252}
1253
1254fn validate_raw_path(path: &str) -> Result<(), SkillAtiError> {
1255    if path.trim().is_empty() || path.contains('\0') || path.contains('\\') || path.starts_with('/')
1256    {
1257        return Err(SkillAtiError::InvalidPath(path.to_string()));
1258    }
1259    Ok(())
1260}
1261
1262fn normalize_within_skill(path: &str) -> Result<String, SkillAtiError> {
1263    validate_raw_path(path)?;
1264
1265    let mut stack: Vec<&str> = Vec::new();
1266    for segment in path.split('/') {
1267        match segment {
1268            "" | "." => {}
1269            ".." => {
1270                if stack.pop().is_none() {
1271                    return Err(SkillAtiError::InvalidPath(path.to_string()));
1272                }
1273            }
1274            value => stack.push(value),
1275        }
1276    }
1277
1278    if stack.is_empty() {
1279        return Err(SkillAtiError::InvalidPath(path.to_string()));
1280    }
1281
1282    Ok(stack.join("/"))
1283}
1284
1285#[cfg(test)]
1286mod tests {
1287    use super::{
1288        build_catalog_manifest, catalog_index_candidates, collect_visible_resources,
1289        default_catalog_index_path, is_visible_resource, map_gcs_error, normalize_within_skill,
1290        remote_skill_meta_from_parts, resolve_requested_path, search_haystack, skill_directory,
1291        SkillAtiCatalogEntry, SkillAtiError,
1292    };
1293    use crate::core::gcs::GcsError;
1294    use std::fs;
1295
1296    #[test]
1297    fn skill_directory_uses_virtual_scheme() {
1298        assert_eq!(skill_directory("demo-skill"), "skillati://demo-skill");
1299    }
1300
1301    #[test]
1302    fn normalize_within_skill_rejects_invalid_paths() {
1303        assert_eq!(
1304            normalize_within_skill("references/guide.md").unwrap(),
1305            "references/guide.md"
1306        );
1307        assert_eq!(
1308            normalize_within_skill("./references/./guide.md").unwrap(),
1309            "references/guide.md"
1310        );
1311        assert!(normalize_within_skill("../escape.md").is_err());
1312        assert!(normalize_within_skill("references/../../escape.md").is_err());
1313        assert!(normalize_within_skill(r"references\guide.md").is_err());
1314    }
1315
1316    #[test]
1317    fn resolve_requested_path_supports_sibling_skills() {
1318        assert_eq!(
1319            resolve_requested_path("react-component-builder", "../ui-design-system/SKILL.md")
1320                .unwrap(),
1321            ("ui-design-system".to_string(), "SKILL.md".to_string())
1322        );
1323        assert_eq!(
1324            resolve_requested_path(
1325                "react-component-builder",
1326                "../ui-design-system/references/core-principles.md"
1327            )
1328            .unwrap(),
1329            (
1330                "ui-design-system".to_string(),
1331                "references/core-principles.md".to_string()
1332            )
1333        );
1334        assert!(matches!(
1335            resolve_requested_path("a", "../../etc/passwd"),
1336            Err(SkillAtiError::InvalidPath(_))
1337        ));
1338        assert!(matches!(
1339            resolve_requested_path("a", "../bad name/SKILL.md"),
1340            Err(SkillAtiError::InvalidPath(_))
1341        ));
1342    }
1343
1344    #[test]
1345    fn visible_resources_filter_internal_files_and_dirs() {
1346        assert!(is_visible_resource("references/guide.md"));
1347        assert!(is_visible_resource("assets/logo.png"));
1348        assert!(!is_visible_resource("SKILL.md"));
1349        assert!(!is_visible_resource("skill.toml"));
1350        assert!(!is_visible_resource("references/"));
1351    }
1352
1353    #[test]
1354    fn map_404_for_skill_md_becomes_skill_not_found() {
1355        let err = map_gcs_error(
1356            "demo-skill",
1357            "SKILL.md",
1358            GcsError::Api {
1359                status: 404,
1360                message: "nope".into(),
1361            },
1362        );
1363        assert!(matches!(err, SkillAtiError::SkillNotFound(name) if name == "demo-skill"));
1364    }
1365
1366    #[test]
1367    fn map_404_for_other_paths_becomes_path_not_found() {
1368        let err = map_gcs_error(
1369            "demo-skill",
1370            "references/guide.md",
1371            GcsError::Api {
1372                status: 404,
1373                message: "nope".into(),
1374            },
1375        );
1376        assert!(
1377            matches!(err, SkillAtiError::PathNotFound { skill, path } if skill == "demo-skill" && path == "references/guide.md")
1378        );
1379    }
1380
1381    #[test]
1382    fn search_haystack_includes_keywords_and_bindings() {
1383        let meta = remote_skill_meta_from_parts(
1384            "demo-skill",
1385            "Great for UI panels".to_string(),
1386            None,
1387            vec!["dashboard".into()],
1388            vec!["render_panel".into()],
1389            vec!["frontend".into()],
1390            vec!["design".into()],
1391        );
1392        let haystack = search_haystack(&meta);
1393        assert!(haystack.contains("dashboard"));
1394        assert!(haystack.contains("render_panel"));
1395        assert!(haystack.contains("frontend"));
1396        assert!(haystack.contains("design"));
1397    }
1398
1399    #[test]
1400    fn env_override_for_catalog_index_candidates_is_supported() {
1401        unsafe {
1402            std::env::set_var(
1403                "ATI_SKILL_REGISTRY_INDEX_OBJECT",
1404                "custom/one.json, custom/two.json",
1405            );
1406        }
1407        assert_eq!(
1408            catalog_index_candidates(),
1409            vec!["custom/one.json".to_string(), "custom/two.json".to_string()]
1410        );
1411        unsafe {
1412            std::env::remove_var("ATI_SKILL_REGISTRY_INDEX_OBJECT");
1413        }
1414        assert_eq!(default_catalog_index_path(), "_skillati/catalog.v1.json");
1415    }
1416
1417    #[test]
1418    fn build_catalog_manifest_collects_nested_resources() {
1419        let tmp = tempfile::tempdir().unwrap();
1420        let root = tmp.path().join("skills");
1421        let skill = root.join("demo-skill");
1422        fs::create_dir_all(skill.join("references/components")).unwrap();
1423        fs::write(skill.join("SKILL.md"), "# Demo Skill\n\nUseful demo.\n").unwrap();
1424        fs::write(
1425            skill.join("skill.toml"),
1426            "[skill]\nname=\"demo-skill\"\nkeywords=[\"demo\"]\n",
1427        )
1428        .unwrap();
1429        fs::write(
1430            skill.join("references/components/example.md"),
1431            "nested reference",
1432        )
1433        .unwrap();
1434
1435        let manifest = build_catalog_manifest(&root).unwrap();
1436        assert_eq!(manifest.skills.len(), 1);
1437        assert_eq!(manifest.skills[0].meta.name, "demo-skill");
1438        assert_eq!(
1439            manifest.skills[0].resources,
1440            vec!["references/components/example.md".to_string()]
1441        );
1442        assert!(manifest.skills[0].resources_complete);
1443    }
1444
1445    #[test]
1446    fn collect_visible_resources_skips_internal_files() {
1447        let tmp = tempfile::tempdir().unwrap();
1448        let skill = tmp.path().join("demo-skill");
1449        fs::create_dir_all(skill.join("references")).unwrap();
1450        fs::write(skill.join("SKILL.md"), "x").unwrap();
1451        fs::write(skill.join("skill.toml"), "x").unwrap();
1452        fs::write(skill.join(".hidden"), "x").unwrap();
1453        fs::write(skill.join("references/guide.md"), "x").unwrap();
1454
1455        let resources = collect_visible_resources(&skill, &skill).unwrap();
1456        assert_eq!(resources, vec!["references/guide.md".to_string()]);
1457    }
1458
1459    #[test]
1460    fn catalog_entry_sorts_and_dedups_resources() {
1461        let mut entry = SkillAtiCatalogEntry {
1462            meta: remote_skill_meta_from_parts(
1463                "demo-skill",
1464                "".into(),
1465                None,
1466                vec!["b".into(), "a".into(), "A".into()],
1467                vec![],
1468                vec![],
1469                vec![],
1470            ),
1471            resources: vec![
1472                "references/b.md".into(),
1473                "SKILL.md".into(),
1474                "references/a.md".into(),
1475                "references/a.md".into(),
1476            ],
1477            resources_complete: false,
1478        };
1479
1480        super::normalize_catalog_entry(&mut entry);
1481        assert_eq!(entry.meta.keywords, vec!["a".to_string(), "b".to_string()]);
1482        assert_eq!(
1483            entry.resources,
1484            vec!["references/a.md".to_string(), "references/b.md".to_string()]
1485        );
1486    }
1487}