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 = "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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
115pub struct SkillAtiActivation {
116 pub name: String,
117 pub description: String,
121 pub skill_directory: String,
122 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 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 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 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 #[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 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 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 pub async fn read_skill(&self, name: &str) -> Result<SkillAtiActivation, SkillAtiError> {
460 let raw = self.read_text(name, "SKILL.md").await?;
461
462 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 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
1002fn 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 let mut out = body
1050 .replace("${ATI_SKILL_DIR}", &skill_uri)
1051 .replace("${CLAUDE_SKILL_DIR}", &skill_uri);
1052
1053 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 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 rewritten.push_str(needle);
1076 cursor = after;
1077 continue;
1078 }
1079 let candidate = &tail[..name_len];
1080 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 if let Some(catalog) = catalog_names {
1102 out = rewrite_bare_cross_skill_refs(&out, skill_name, catalog);
1103 }
1104
1105 out
1106}
1107
1108fn 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 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 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 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 out.push_str(&body[cursor..i]);
1186 out.push_str("skillati://");
1187 out.push_str(&body[i..subdir_end + 1]); 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}