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 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 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 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 #[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 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 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 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}