Skip to main content

ati/core/
skill.rs

1/// Skill management — structured metadata, registry, and scope-driven resolution.
2///
3/// Each skill directory contains:
4///   - `SKILL.md`    — methodology content with optional YAML frontmatter (Anthropic spec)
5///   - `skill.toml`  — optional ATI extension for tool/provider/category bindings
6///   - `references/` — optional supporting documentation
7///   - `scripts/`    — optional helper scripts
8///   - `assets/`     — optional templates, configs, data files
9///
10/// Metadata priority: YAML frontmatter in SKILL.md > skill.toml > inferred from content.
11/// Skills reference manifests (tools, providers, categories), never the reverse.
12/// Installing a skill never requires editing existing manifests.
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17use thiserror::Error;
18
19use crate::core::manifest::ManifestRegistry;
20use crate::core::scope::ScopeConfig;
21
22#[derive(Error, Debug)]
23pub enum SkillError {
24    #[error("Failed to read skill file {0}: {1}")]
25    Io(String, std::io::Error),
26    #[error("Failed to parse skill.toml {0}: {1}")]
27    Parse(String, toml::de::Error),
28    #[error("Skill not found: {0}")]
29    NotFound(String),
30    #[error("Skills directory not found: {0}")]
31    NoDirectory(String),
32    #[error("Invalid skill: {0}")]
33    Invalid(String),
34}
35
36/// Anthropic Agent Skills spec frontmatter (YAML in SKILL.md).
37#[derive(Debug, Clone, Deserialize, Default)]
38pub struct AnthropicFrontmatter {
39    pub name: Option<String>,
40    pub description: Option<String>,
41    pub license: Option<String>,
42    pub compatibility: Option<String>,
43    #[serde(default)]
44    pub metadata: HashMap<String, String>,
45    /// Space-delimited allowed tools string, e.g. "Bash(git:*) Read"
46    #[serde(rename = "allowed-tools")]
47    pub allowed_tools: Option<String>,
48}
49
50/// Parse YAML frontmatter from SKILL.md content.
51///
52/// Returns `(Some(frontmatter), body)` if `---` delimiters found and YAML parses,
53/// or `(None, original_content)` on any failure (graceful fallback).
54pub fn parse_frontmatter(content: &str) -> (Option<AnthropicFrontmatter>, &str) {
55    let trimmed = content.trim_start();
56    if !trimmed.starts_with("---") {
57        return (None, content);
58    }
59
60    // Find the closing `---` after the opening one
61    let after_open = &trimmed[3..];
62    // Skip the rest of the opening `---` line
63    let after_open = match after_open.find('\n') {
64        Some(pos) => &after_open[pos + 1..],
65        None => return (None, content),
66    };
67
68    match after_open.find("\n---") {
69        Some(end_pos) => {
70            let yaml_str = &after_open[..end_pos];
71            let body_start = &after_open[end_pos + 4..]; // skip \n---
72                                                         // Skip rest of closing --- line
73            let body = match body_start.find('\n') {
74                Some(pos) => &body_start[pos + 1..],
75                None => "",
76            };
77
78            match serde_yaml::from_str::<AnthropicFrontmatter>(yaml_str) {
79                Ok(fm) => (Some(fm), body),
80                Err(_) => (None, content), // Malformed YAML → treat as no frontmatter
81            }
82        }
83        None => (None, content),
84    }
85}
86
87/// Strip YAML frontmatter from SKILL.md content, returning only the body.
88pub fn strip_frontmatter(content: &str) -> &str {
89    let (_, body) = parse_frontmatter(content);
90    body
91}
92
93/// Compute SHA-256 hash of content, returning lowercase hex string.
94pub fn compute_content_hash(content: &str) -> String {
95    let mut hasher = Sha256::new();
96    hasher.update(content.as_bytes());
97    let result = hasher.finalize();
98    hex::encode(result)
99}
100
101/// Validate a skill name against the Anthropic Agent Skills naming rules.
102///
103/// Rules: 1-64 chars, lowercase letters + digits + hyphens, no consecutive hyphens,
104/// must start/end with a letter or digit.
105pub fn is_anthropic_valid_name(name: &str) -> bool {
106    if name.is_empty() || name.len() > 64 {
107        return false;
108    }
109    let bytes = name.as_bytes();
110    // Must start and end with alphanumeric
111    if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() {
112        return false;
113    }
114    if !bytes[bytes.len() - 1].is_ascii_lowercase() && !bytes[bytes.len() - 1].is_ascii_digit() {
115        return false;
116    }
117    // Only lowercase, digits, hyphens; no consecutive hyphens
118    let mut prev_hyphen = false;
119    for &b in bytes {
120        if b == b'-' {
121            if prev_hyphen {
122                return false;
123            }
124            prev_hyphen = true;
125        } else if b.is_ascii_lowercase() || b.is_ascii_digit() {
126            prev_hyphen = false;
127        } else {
128            return false;
129        }
130    }
131    true
132}
133
134/// Skill metadata format — indicates how the metadata was sourced.
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136pub enum SkillFormat {
137    /// Anthropic spec: YAML frontmatter in SKILL.md
138    #[serde(rename = "anthropic")]
139    Anthropic,
140    /// ATI legacy: skill.toml only
141    #[serde(rename = "legacy-toml")]
142    LegacyToml,
143    /// Inferred: SKILL.md without frontmatter or skill.toml
144    #[serde(rename = "inferred")]
145    Inferred,
146}
147
148/// Structured metadata from `skill.toml` and/or YAML frontmatter.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct SkillMeta {
151    pub name: String,
152    #[serde(default = "default_version")]
153    pub version: String,
154    #[serde(default)]
155    pub description: String,
156    #[serde(default)]
157    pub author: Option<String>,
158
159    // --- Tool/provider/category bindings (auto-load when these are in scope) ---
160    /// Exact tool names this skill covers (e.g., ["ca_business_sanctions_search"])
161    #[serde(default)]
162    pub tools: Vec<String>,
163    /// Provider names this skill covers (e.g., ["complyadvantage"])
164    #[serde(default)]
165    pub providers: Vec<String>,
166    /// Provider categories this skill covers (e.g., ["compliance"])
167    #[serde(default)]
168    pub categories: Vec<String>,
169
170    // --- Discovery metadata ---
171    #[serde(default)]
172    pub keywords: Vec<String>,
173    #[serde(default)]
174    pub hint: Option<String>,
175
176    // --- Dependencies ---
177    /// Skills that must be transitively loaded with this one
178    #[serde(default)]
179    pub depends_on: Vec<String>,
180    /// Informational suggestions (not auto-loaded)
181    #[serde(default)]
182    pub suggests: Vec<String>,
183
184    // --- Anthropic spec fields ---
185    /// SPDX license identifier (from frontmatter)
186    #[serde(default)]
187    pub license: Option<String>,
188    /// Compatibility notes (from frontmatter, max 500 chars)
189    #[serde(default)]
190    pub compatibility: Option<String>,
191    /// Arbitrary metadata key-value pairs (from frontmatter `metadata:` block)
192    #[serde(default)]
193    pub extra_metadata: HashMap<String, String>,
194    /// Space-delimited allowed tools (from frontmatter `allowed-tools:`)
195    #[serde(default)]
196    pub allowed_tools: Option<String>,
197    /// Whether the skill has YAML frontmatter in SKILL.md
198    #[serde(default)]
199    pub has_frontmatter: bool,
200    /// How metadata was sourced
201    #[serde(default = "default_format")]
202    pub format: SkillFormat,
203
204    // --- Supply chain integrity (stored in [ati.integrity] section of skill.toml) ---
205    /// Source URL this skill was installed from
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub source_url: Option<String>,
208    /// SHA-256 hash of SKILL.md content at install time
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub content_hash: Option<String>,
211    /// Pinned git SHA (from source@SHA syntax)
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub pinned_sha: Option<String>,
214
215    // --- Runtime (not in TOML, set after loading) ---
216    /// Absolute path to the skill directory
217    #[serde(skip)]
218    pub dir: PathBuf,
219}
220
221impl Default for SkillMeta {
222    fn default() -> Self {
223        Self {
224            name: String::new(),
225            version: default_version(),
226            description: String::new(),
227            author: None,
228            tools: Vec::new(),
229            providers: Vec::new(),
230            categories: Vec::new(),
231            keywords: Vec::new(),
232            hint: None,
233            depends_on: Vec::new(),
234            suggests: Vec::new(),
235            license: None,
236            compatibility: None,
237            extra_metadata: HashMap::new(),
238            allowed_tools: None,
239            has_frontmatter: false,
240            format: SkillFormat::Inferred,
241            source_url: None,
242            content_hash: None,
243            pinned_sha: None,
244            dir: PathBuf::new(),
245        }
246    }
247}
248
249fn default_format() -> SkillFormat {
250    SkillFormat::Inferred
251}
252
253fn default_version() -> String {
254    "0.1.0".to_string()
255}
256
257/// Wrapper for the `[skill]` table in skill.toml.
258#[derive(Debug, Deserialize)]
259struct SkillToml {
260    skill: SkillMeta,
261}
262
263/// Registry of all loaded skills with indexes for fast lookup.
264pub struct SkillRegistry {
265    skills: Vec<SkillMeta>,
266    /// skill name → index
267    name_index: HashMap<String, usize>,
268    /// tool name → skill indices
269    tool_index: HashMap<String, Vec<usize>>,
270    /// provider name → skill indices
271    provider_index: HashMap<String, Vec<usize>>,
272    /// category name → skill indices
273    category_index: HashMap<String, Vec<usize>>,
274    /// Cached files for non-filesystem skills (e.g. GCS).
275    /// Key: (skill_name, relative_path), Value: file bytes.
276    files_cache: HashMap<(String, String), Vec<u8>>,
277}
278
279impl SkillRegistry {
280    /// Load all skills from a directory. Each subdirectory is a skill.
281    ///
282    /// If `skill.toml` exists, parse it for full metadata.
283    /// Otherwise, fall back to reading `SKILL.md` for name + description only.
284    pub fn load(skills_dir: &Path) -> Result<Self, SkillError> {
285        let mut skills = Vec::new();
286        let mut name_index = HashMap::new();
287        let mut tool_index: HashMap<String, Vec<usize>> = HashMap::new();
288        let mut provider_index: HashMap<String, Vec<usize>> = HashMap::new();
289        let mut category_index: HashMap<String, Vec<usize>> = HashMap::new();
290
291        if !skills_dir.is_dir() {
292            // Not an error — just an empty registry
293            return Ok(SkillRegistry {
294                skills,
295                name_index,
296                tool_index,
297                provider_index,
298                category_index,
299                files_cache: HashMap::new(),
300            });
301        }
302
303        let entries = std::fs::read_dir(skills_dir)
304            .map_err(|e| SkillError::Io(skills_dir.display().to_string(), e))?;
305
306        for entry in entries {
307            let entry = entry.map_err(|e| SkillError::Io(skills_dir.display().to_string(), e))?;
308            let path = entry.path();
309            if !path.is_dir() {
310                continue;
311            }
312
313            let skill = load_skill_from_dir(&path)?;
314
315            let idx = skills.len();
316            name_index.insert(skill.name.clone(), idx);
317
318            for tool in &skill.tools {
319                tool_index.entry(tool.clone()).or_default().push(idx);
320            }
321            for provider in &skill.providers {
322                provider_index
323                    .entry(provider.clone())
324                    .or_default()
325                    .push(idx);
326            }
327            for category in &skill.categories {
328                category_index
329                    .entry(category.clone())
330                    .or_default()
331                    .push(idx);
332            }
333
334            skills.push(skill);
335        }
336
337        Ok(SkillRegistry {
338            skills,
339            name_index,
340            tool_index,
341            provider_index,
342            category_index,
343            files_cache: HashMap::new(),
344        })
345    }
346
347    /// Merge skills from a remote source (e.g. GCS).
348    /// Local skills take precedence on name collision.
349    pub fn merge(&mut self, source: crate::core::gcs::GcsSkillSource) {
350        let mut added: std::collections::HashSet<String> = std::collections::HashSet::new();
351
352        for skill in source.skills {
353            if self.name_index.contains_key(&skill.name) {
354                // Local wins — skip this GCS skill entirely
355                continue;
356            }
357            added.insert(skill.name.clone());
358            let idx = self.skills.len();
359            self.name_index.insert(skill.name.clone(), idx);
360            for tool in &skill.tools {
361                self.tool_index.entry(tool.clone()).or_default().push(idx);
362            }
363            for provider in &skill.providers {
364                self.provider_index
365                    .entry(provider.clone())
366                    .or_default()
367                    .push(idx);
368            }
369            for category in &skill.categories {
370                self.category_index
371                    .entry(category.clone())
372                    .or_default()
373                    .push(idx);
374            }
375            self.skills.push(skill);
376        }
377
378        // Only merge files for skills that were actually added (not skipped).
379        // This preserves "local wins" at the content layer too.
380        for ((skill_name, rel_path), data) in source.files {
381            if added.contains(&skill_name) {
382                self.files_cache.insert((skill_name, rel_path), data);
383            }
384        }
385    }
386
387    /// Get a skill by name.
388    pub fn get_skill(&self, name: &str) -> Option<&SkillMeta> {
389        self.name_index.get(name).map(|&idx| &self.skills[idx])
390    }
391
392    /// List all loaded skills.
393    pub fn list_skills(&self) -> &[SkillMeta] {
394        &self.skills
395    }
396
397    /// Skills that cover a specific tool name.
398    pub fn skills_for_tool(&self, tool_name: &str) -> Vec<&SkillMeta> {
399        self.tool_index
400            .get(tool_name)
401            .map(|indices| indices.iter().map(|&i| &self.skills[i]).collect())
402            .unwrap_or_default()
403    }
404
405    /// Skills that cover a specific provider name.
406    pub fn skills_for_provider(&self, provider_name: &str) -> Vec<&SkillMeta> {
407        self.provider_index
408            .get(provider_name)
409            .map(|indices| indices.iter().map(|&i| &self.skills[i]).collect())
410            .unwrap_or_default()
411    }
412
413    /// Skills that cover a specific category.
414    pub fn skills_for_category(&self, category: &str) -> Vec<&SkillMeta> {
415        self.category_index
416            .get(category)
417            .map(|indices| indices.iter().map(|&i| &self.skills[i]).collect())
418            .unwrap_or_default()
419    }
420
421    /// Search skills by fuzzy matching on name, description, keywords, hint, and tool names.
422    pub fn search(&self, query: &str) -> Vec<&SkillMeta> {
423        let q = query.to_lowercase();
424        let terms: Vec<&str> = q.split_whitespace().collect();
425
426        let mut scored: Vec<(usize, &SkillMeta)> = self
427            .skills
428            .iter()
429            .filter_map(|skill| {
430                let mut score = 0usize;
431                let name_lower = skill.name.to_lowercase();
432                let desc_lower = skill.description.to_lowercase();
433
434                for term in &terms {
435                    // Name match (highest weight)
436                    if name_lower.contains(term) {
437                        score += 10;
438                    }
439                    // Description match
440                    if desc_lower.contains(term) {
441                        score += 5;
442                    }
443                    // Keyword match
444                    if skill
445                        .keywords
446                        .iter()
447                        .any(|k| k.to_lowercase().contains(term))
448                    {
449                        score += 8;
450                    }
451                    // Tool name match
452                    if skill.tools.iter().any(|t| t.to_lowercase().contains(term)) {
453                        score += 6;
454                    }
455                    // Hint match
456                    if let Some(hint) = &skill.hint {
457                        if hint.to_lowercase().contains(term) {
458                            score += 4;
459                        }
460                    }
461                    // Provider match
462                    if skill
463                        .providers
464                        .iter()
465                        .any(|p| p.to_lowercase().contains(term))
466                    {
467                        score += 6;
468                    }
469                    // Category match
470                    if skill
471                        .categories
472                        .iter()
473                        .any(|c| c.to_lowercase().contains(term))
474                    {
475                        score += 4;
476                    }
477                }
478
479                if score > 0 {
480                    Some((score, skill))
481                } else {
482                    None
483                }
484            })
485            .collect();
486
487        // Sort by score descending
488        scored.sort_by(|a, b| b.0.cmp(&a.0));
489        scored.into_iter().map(|(_, skill)| skill).collect()
490    }
491
492    /// Read the SKILL.md content for a skill, stripping any YAML frontmatter.
493    /// Checks the in-memory files cache first (for GCS skills), then falls back to filesystem.
494    pub fn read_content(&self, name: &str) -> Result<String, SkillError> {
495        // Check files cache (GCS / remote skills)
496        if let Some(bytes) = self
497            .files_cache
498            .get(&(name.to_string(), "SKILL.md".to_string()))
499        {
500            let raw = std::str::from_utf8(bytes).unwrap_or("");
501            return Ok(strip_frontmatter(raw).to_string());
502        }
503
504        // Fall back to filesystem (local skills)
505        let skill = self
506            .get_skill(name)
507            .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
508        let skill_md = skill.dir.join("SKILL.md");
509        if !skill_md.exists() {
510            return Ok(String::new());
511        }
512        let raw = std::fs::read_to_string(&skill_md)
513            .map_err(|e| SkillError::Io(skill_md.display().to_string(), e))?;
514        Ok(strip_frontmatter(&raw).to_string())
515    }
516
517    /// List reference files for a skill.
518    /// Checks files cache first (GCS), then filesystem.
519    pub fn list_references(&self, name: &str) -> Result<Vec<String>, SkillError> {
520        // Check files cache for references/*
521        let prefix = "references/";
522        let cached_refs: Vec<String> = self
523            .files_cache
524            .keys()
525            .filter(|(skill, path)| skill == name && path.starts_with(prefix))
526            .map(|(_, path)| path.strip_prefix(prefix).unwrap_or(path).to_string())
527            .collect();
528        if !cached_refs.is_empty() {
529            let mut refs = cached_refs;
530            refs.sort();
531            return Ok(refs);
532        }
533
534        // Fall back to filesystem
535        let skill = self
536            .get_skill(name)
537            .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
538        let refs_dir = skill.dir.join("references");
539        if !refs_dir.is_dir() {
540            return Ok(Vec::new());
541        }
542        let mut refs = Vec::new();
543        let entries = std::fs::read_dir(&refs_dir)
544            .map_err(|e| SkillError::Io(refs_dir.display().to_string(), e))?;
545        for entry in entries {
546            let entry = entry.map_err(|e| SkillError::Io(refs_dir.display().to_string(), e))?;
547            if let Some(name) = entry.file_name().to_str() {
548                refs.push(name.to_string());
549            }
550        }
551        refs.sort();
552        Ok(refs)
553    }
554
555    /// Read a specific reference file.
556    /// Checks files cache first (GCS), then filesystem.
557    pub fn read_reference(&self, skill_name: &str, ref_name: &str) -> Result<String, SkillError> {
558        // Path traversal protection: reject names with path components
559        if ref_name.contains("..")
560            || ref_name.contains('/')
561            || ref_name.contains('\\')
562            || ref_name.contains('\0')
563        {
564            return Err(SkillError::NotFound(format!(
565                "Invalid reference name '{ref_name}' — path traversal not allowed"
566            )));
567        }
568
569        // Check files cache (GCS / remote skills)
570        let cache_key = (skill_name.to_string(), format!("references/{ref_name}"));
571        if let Some(bytes) = self.files_cache.get(&cache_key) {
572            return std::str::from_utf8(bytes)
573                .map(|s| s.to_string())
574                .map_err(|e| SkillError::Invalid(format!("invalid UTF-8 in reference: {e}")));
575        }
576
577        let skill = self
578            .get_skill(skill_name)
579            .ok_or_else(|| SkillError::NotFound(skill_name.to_string()))?;
580        let refs_dir = skill.dir.join("references");
581        let ref_path = refs_dir.join(ref_name);
582
583        // Canonicalize and verify the resolved path is inside the references directory
584        if let (Ok(canonical_ref), Ok(canonical_dir)) =
585            (ref_path.canonicalize(), refs_dir.canonicalize())
586        {
587            if !canonical_ref.starts_with(&canonical_dir) {
588                return Err(SkillError::NotFound(format!(
589                    "Reference '{ref_name}' resolves outside references directory"
590                )));
591            }
592        }
593
594        if !ref_path.exists() {
595            return Err(SkillError::NotFound(format!(
596                "Reference '{ref_name}' in skill '{skill_name}'"
597            )));
598        }
599        std::fs::read_to_string(&ref_path)
600            .map_err(|e| SkillError::Io(ref_path.display().to_string(), e))
601    }
602
603    /// Get all files in a skill as a map of relative_path → bytes.
604    /// Works for both local (filesystem) and remote (cached) skills.
605    pub fn bundle_files(&self, name: &str) -> Result<HashMap<String, Vec<u8>>, SkillError> {
606        let _skill = self
607            .get_skill(name)
608            .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
609
610        let mut files: HashMap<String, Vec<u8>> = HashMap::new();
611
612        // Collect from files_cache (GCS / remote skills)
613        for ((skill_name, rel_path), data) in &self.files_cache {
614            if skill_name == name {
615                files.insert(rel_path.clone(), data.clone());
616            }
617        }
618
619        // If nothing from cache, read from filesystem
620        if files.is_empty() {
621            let skill = self.get_skill(name).unwrap();
622            if skill.dir.is_dir() {
623                collect_dir_files(&skill.dir, &skill.dir, &mut files)?;
624            }
625        }
626
627        Ok(files)
628    }
629
630    /// Number of loaded skills.
631    pub fn skill_count(&self) -> usize {
632        self.skills.len()
633    }
634
635    /// Validate a skill's tool bindings against a ManifestRegistry.
636    /// Returns (valid_tools, unknown_tools).
637    pub fn validate_tool_bindings(
638        &self,
639        name: &str,
640        manifest_registry: &ManifestRegistry,
641    ) -> Result<(Vec<String>, Vec<String>), SkillError> {
642        let skill = self
643            .get_skill(name)
644            .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
645
646        let mut valid = Vec::new();
647        let mut unknown = Vec::new();
648
649        for tool_name in &skill.tools {
650            if manifest_registry.get_tool(tool_name).is_some() {
651                valid.push(tool_name.clone());
652            } else {
653                unknown.push(tool_name.clone());
654            }
655        }
656
657        Ok((valid, unknown))
658    }
659}
660
661/// Resolve which skills should be auto-loaded based on scopes and a ManifestRegistry.
662///
663/// Resolution cascade:
664/// 1. Explicit skill scopes: "skill:X" → load X directly
665/// 2. Tool binding: "tool:Y" → skills where tools contains "Y"
666/// 3. Provider binding: tool Y belongs to provider P → skills where providers contains "P"
667/// 4. Category binding: provider P has category C → skills where categories contains "C"
668/// 5. Dependency resolution: loaded skill depends_on Z → transitively load Z
669///
670/// Wildcard scope (*) = all skills available but not auto-loaded.
671pub fn resolve_skills<'a>(
672    skill_registry: &'a SkillRegistry,
673    manifest_registry: &ManifestRegistry,
674    scopes: &ScopeConfig,
675) -> Vec<&'a SkillMeta> {
676    let mut resolved_indices: Vec<usize> = Vec::new();
677    let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
678
679    for scope in &scopes.scopes {
680        // 1. Explicit skill scopes
681        if let Some(skill_name) = scope.strip_prefix("skill:") {
682            if let Some(&idx) = skill_registry.name_index.get(skill_name) {
683                if seen.insert(idx) {
684                    resolved_indices.push(idx);
685                }
686            }
687        }
688
689        // 2. Tool binding → skills covering that tool (direct lookup, no manifest required)
690        if let Some(tool_name) = scope.strip_prefix("tool:") {
691            if let Some(indices) = skill_registry.tool_index.get(tool_name) {
692                for &idx in indices {
693                    if seen.insert(idx) {
694                        resolved_indices.push(idx);
695                    }
696                }
697            }
698
699            // 3. Provider binding → skills covering the tool's provider
700            if let Some((provider, _)) = manifest_registry.get_tool(tool_name) {
701                if let Some(indices) = skill_registry.provider_index.get(&provider.name) {
702                    for &idx in indices {
703                        if seen.insert(idx) {
704                            resolved_indices.push(idx);
705                        }
706                    }
707                }
708
709                // 4. Category binding → skills covering the provider's category
710                if let Some(category) = &provider.category {
711                    if let Some(indices) = skill_registry.category_index.get(category) {
712                        for &idx in indices {
713                            if seen.insert(idx) {
714                                resolved_indices.push(idx);
715                            }
716                        }
717                    }
718                }
719            }
720        }
721    }
722
723    // 2b. Additional pass via filter_tools_by_scope so that legacy underscore JWT scopes
724    // (e.g. "tool:test_api_get_data") also resolve skills bound to colon-namespaced tools
725    // (e.g. "test_api:get_data") whose manifest scope entry is "tool:test_api:get_data".
726    if !scopes.is_wildcard() {
727        for (provider, tool) in
728            crate::core::scope::filter_tools_by_scope(manifest_registry.list_public_tools(), scopes)
729        {
730            if let Some(indices) = skill_registry.tool_index.get(&tool.name) {
731                for &idx in indices {
732                    if seen.insert(idx) {
733                        resolved_indices.push(idx);
734                    }
735                }
736            }
737
738            if let Some(indices) = skill_registry.provider_index.get(&provider.name) {
739                for &idx in indices {
740                    if seen.insert(idx) {
741                        resolved_indices.push(idx);
742                    }
743                }
744            }
745
746            if let Some(category) = &provider.category {
747                if let Some(indices) = skill_registry.category_index.get(category) {
748                    for &idx in indices {
749                        if seen.insert(idx) {
750                            resolved_indices.push(idx);
751                        }
752                    }
753                }
754            }
755        }
756    }
757
758    // 5. Dependency resolution (transitive)
759    let mut i = 0;
760    while i < resolved_indices.len() {
761        let skill = &skill_registry.skills[resolved_indices[i]];
762        for dep_name in &skill.depends_on {
763            if let Some(&dep_idx) = skill_registry.name_index.get(dep_name) {
764                if seen.insert(dep_idx) {
765                    resolved_indices.push(dep_idx);
766                }
767            }
768        }
769        i += 1;
770    }
771
772    resolved_indices
773        .into_iter()
774        .map(|idx| &skill_registry.skills[idx])
775        .collect()
776}
777
778/// Return the skills visible under the provided scopes.
779///
780/// Wildcard scopes can see the full registry. Non-wildcard scopes can see
781/// explicitly scoped skills plus skills reachable through tool/provider/category
782/// bindings from their allowed tools.
783pub fn visible_skills<'a>(
784    skill_registry: &'a SkillRegistry,
785    manifest_registry: &ManifestRegistry,
786    scopes: &ScopeConfig,
787) -> Vec<&'a SkillMeta> {
788    if scopes.is_wildcard() {
789        return skill_registry.list_skills().iter().collect();
790    }
791
792    let mut visible = resolve_skills(skill_registry, manifest_registry, scopes);
793    visible.sort_by(|a, b| a.name.cmp(&b.name));
794    visible
795}
796
797/// Maximum size of skill content injected into LLM system prompts (32 KB).
798/// Prevents prompt injection via extremely large SKILL.md files.
799const MAX_SKILL_INJECT_SIZE: usize = 32 * 1024;
800
801/// Build a skill context string for LLM system prompts.
802/// For each skill: name, description, hint, covered tools.
803/// Content is bounded and delimited to mitigate prompt injection.
804pub fn build_skill_context(skills: &[&SkillMeta]) -> String {
805    if skills.is_empty() {
806        return String::new();
807    }
808
809    let mut total_size = 0;
810    let mut sections = Vec::new();
811    for skill in skills {
812        let mut section = format!(
813            "--- BEGIN SKILL: {} ---\n- **{}**: {}",
814            skill.name, skill.name, skill.description
815        );
816        if let Some(hint) = &skill.hint {
817            section.push_str(&format!("\n  Hint: {hint}"));
818        }
819        if !skill.tools.is_empty() {
820            section.push_str(&format!("\n  Covers tools: {}", skill.tools.join(", ")));
821        }
822        if !skill.suggests.is_empty() {
823            section.push_str(&format!(
824                "\n  Related skills: {}",
825                skill.suggests.join(", ")
826            ));
827        }
828        section.push_str(&format!("\n--- END SKILL: {} ---", skill.name));
829
830        total_size += section.len();
831        if total_size > MAX_SKILL_INJECT_SIZE {
832            sections.push("(remaining skills truncated due to size limit)".to_string());
833            break;
834        }
835        sections.push(section);
836    }
837    sections.join("\n\n")
838}
839
840// --- Private helpers ---
841
842/// Load a single skill from a directory.
843///
844/// Priority:
845///   (A) SKILL.md with YAML frontmatter → primary source; merge ATI extensions from skill.toml
846/// Recursively collect all files in a directory into a map of relative_path → bytes.
847fn collect_dir_files(
848    base: &Path,
849    current: &Path,
850    files: &mut HashMap<String, Vec<u8>>,
851) -> Result<(), SkillError> {
852    let entries =
853        std::fs::read_dir(current).map_err(|e| SkillError::Io(current.display().to_string(), e))?;
854    for entry in entries {
855        let entry = entry.map_err(|e| SkillError::Io(current.display().to_string(), e))?;
856        let path = entry.path();
857        if path.is_dir() {
858            collect_dir_files(base, &path, files)?;
859        } else if let Ok(rel) = path.strip_prefix(base) {
860            if let Some(rel_str) = rel.to_str() {
861                if let Ok(data) = std::fs::read(&path) {
862                    files.insert(rel_str.to_string(), data);
863                }
864            }
865        }
866    }
867    Ok(())
868}
869
870///   (B) No frontmatter + skill.toml → current legacy behavior
871///   (C) No frontmatter + no skill.toml + SKILL.md exists → infer from content
872fn load_skill_from_dir(dir: &Path) -> Result<SkillMeta, SkillError> {
873    let skill_toml_path = dir.join("skill.toml");
874    let skill_md_path = dir.join("SKILL.md");
875
876    let dir_name = dir
877        .file_name()
878        .and_then(|n| n.to_str())
879        .unwrap_or("unknown")
880        .to_string();
881
882    // Try to read and parse frontmatter from SKILL.md
883    let (frontmatter, _body) = if skill_md_path.exists() {
884        let content = std::fs::read_to_string(&skill_md_path)
885            .map_err(|e| SkillError::Io(skill_md_path.display().to_string(), e))?;
886        let (fm, body) = parse_frontmatter(&content);
887        // We need owned body for description inference later
888        let body_owned = body.to_string();
889        (fm, Some((content, body_owned)))
890    } else {
891        (None, None)
892    };
893
894    if let Some(fm) = frontmatter {
895        // --- (A) Frontmatter exists → primary source ---
896        let mut meta = SkillMeta {
897            name: fm.name.unwrap_or_else(|| dir_name.clone()),
898            description: fm.description.unwrap_or_default(),
899            license: fm.license,
900            compatibility: fm.compatibility,
901            extra_metadata: fm.metadata,
902            allowed_tools: fm.allowed_tools,
903            has_frontmatter: true,
904            format: SkillFormat::Anthropic,
905            dir: dir.to_path_buf(),
906            ..Default::default()
907        };
908
909        // Extract author/version from frontmatter metadata if present
910        if let Some(author) = meta.extra_metadata.get("author").cloned() {
911            meta.author = Some(author);
912        }
913        if let Some(version) = meta.extra_metadata.get("version").cloned() {
914            meta.version = version;
915        }
916
917        // Merge ATI-extension fields from skill.toml if it exists
918        if skill_toml_path.exists() {
919            let contents = std::fs::read_to_string(&skill_toml_path)
920                .map_err(|e| SkillError::Io(skill_toml_path.display().to_string(), e))?;
921            if let Ok(parsed) = toml::from_str::<SkillToml>(&contents) {
922                let ext = parsed.skill;
923                // ATI-specific fields not in frontmatter
924                meta.tools = ext.tools;
925                meta.providers = ext.providers;
926                meta.categories = ext.categories;
927                meta.keywords = ext.keywords;
928                meta.hint = ext.hint;
929                meta.depends_on = ext.depends_on;
930                meta.suggests = ext.suggests;
931            }
932        }
933
934        load_integrity_info(&mut meta);
935        Ok(meta)
936    } else if skill_toml_path.exists() {
937        // --- (B) No frontmatter + skill.toml → legacy behavior ---
938        let contents = std::fs::read_to_string(&skill_toml_path)
939            .map_err(|e| SkillError::Io(skill_toml_path.display().to_string(), e))?;
940        let parsed: SkillToml = toml::from_str(&contents)
941            .map_err(|e| SkillError::Parse(skill_toml_path.display().to_string(), e))?;
942        let mut meta = parsed.skill;
943        meta.dir = dir.to_path_buf();
944        meta.format = SkillFormat::LegacyToml;
945        if meta.name.is_empty() {
946            meta.name = dir_name;
947        }
948        load_integrity_info(&mut meta);
949        Ok(meta)
950    } else if let Some((_full_content, body)) = _body {
951        // --- (C) No frontmatter + no skill.toml + SKILL.md exists → infer ---
952        let description = body
953            .lines()
954            .find(|l| !l.is_empty() && !l.starts_with('#'))
955            .map(|l| l.trim().to_string())
956            .unwrap_or_default();
957
958        Ok(SkillMeta {
959            name: dir_name,
960            description,
961            format: SkillFormat::Inferred,
962            dir: dir.to_path_buf(),
963            ..Default::default()
964        })
965    } else {
966        Err(SkillError::Invalid(format!(
967            "Directory '{}' has neither skill.toml nor SKILL.md",
968            dir.display()
969        )))
970    }
971}
972
973/// Parse skill metadata from raw SKILL.md content and optional skill.toml content.
974///
975/// Used by both local filesystem loading and remote sources (GCS).
976/// The `name` parameter is the skill directory name (used as fallback if not in metadata).
977pub fn parse_skill_metadata(
978    name: &str,
979    skill_md_content: &str,
980    skill_toml_content: Option<&str>,
981) -> Result<SkillMeta, SkillError> {
982    let (frontmatter, body) = if !skill_md_content.is_empty() {
983        let (fm, body) = parse_frontmatter(skill_md_content);
984        (fm, Some(body.to_string()))
985    } else {
986        (None, None)
987    };
988
989    if let Some(fm) = frontmatter {
990        // (A) Frontmatter exists → primary source
991        let mut meta = SkillMeta {
992            name: fm.name.unwrap_or_else(|| name.to_string()),
993            description: fm.description.unwrap_or_default(),
994            license: fm.license,
995            compatibility: fm.compatibility,
996            extra_metadata: fm.metadata,
997            allowed_tools: fm.allowed_tools,
998            has_frontmatter: true,
999            format: SkillFormat::Anthropic,
1000            ..Default::default()
1001        };
1002
1003        if let Some(author) = meta.extra_metadata.get("author").cloned() {
1004            meta.author = Some(author);
1005        }
1006        if let Some(version) = meta.extra_metadata.get("version").cloned() {
1007            meta.version = version;
1008        }
1009
1010        // Merge ATI extensions from skill.toml if provided
1011        if let Some(toml_str) = skill_toml_content {
1012            if let Ok(parsed) = toml::from_str::<SkillToml>(toml_str) {
1013                let ext = parsed.skill;
1014                meta.tools = ext.tools;
1015                meta.providers = ext.providers;
1016                meta.categories = ext.categories;
1017                meta.keywords = ext.keywords;
1018                meta.hint = ext.hint;
1019                meta.depends_on = ext.depends_on;
1020                meta.suggests = ext.suggests;
1021            }
1022        }
1023
1024        Ok(meta)
1025    } else if let Some(toml_str) = skill_toml_content {
1026        // (B) No frontmatter + skill.toml → legacy
1027        let parsed: SkillToml = toml::from_str(toml_str)
1028            .map_err(|e| SkillError::Parse(format!("{name}/skill.toml"), e))?;
1029        let mut meta = parsed.skill;
1030        meta.format = SkillFormat::LegacyToml;
1031        if meta.name.is_empty() {
1032            meta.name = name.to_string();
1033        }
1034        Ok(meta)
1035    } else if let Some(body) = body {
1036        // (C) SKILL.md without frontmatter or skill.toml → infer
1037        let description = body
1038            .lines()
1039            .find(|l| !l.is_empty() && !l.starts_with('#'))
1040            .map(|l| l.trim().to_string())
1041            .unwrap_or_default();
1042
1043        Ok(SkillMeta {
1044            name: name.to_string(),
1045            description,
1046            format: SkillFormat::Inferred,
1047            ..Default::default()
1048        })
1049    } else {
1050        Err(SkillError::Invalid(format!(
1051            "Skill '{name}' has neither skill.toml nor SKILL.md content"
1052        )))
1053    }
1054}
1055
1056/// Read [ati.integrity] section from skill.toml and populate SkillMeta fields.
1057fn load_integrity_info(meta: &mut SkillMeta) {
1058    let toml_path = meta.dir.join("skill.toml");
1059    if !toml_path.exists() {
1060        return;
1061    }
1062    let contents = match std::fs::read_to_string(&toml_path) {
1063        Ok(c) => c,
1064        Err(_) => return,
1065    };
1066    let parsed: toml::Value = match toml::from_str(&contents) {
1067        Ok(v) => v,
1068        Err(_) => return,
1069    };
1070    if let Some(integrity) = parsed.get("ati").and_then(|a| a.get("integrity")) {
1071        meta.content_hash = integrity
1072            .get("content_hash")
1073            .and_then(|v| v.as_str())
1074            .map(|s| s.to_string());
1075        meta.source_url = integrity
1076            .get("source_url")
1077            .and_then(|v| v.as_str())
1078            .map(|s| s.to_string());
1079        meta.pinned_sha = integrity
1080            .get("pinned_sha")
1081            .and_then(|v| v.as_str())
1082            .map(|s| s.to_string());
1083    }
1084}
1085
1086/// Generate a skeleton `skill.toml` for a new skill.
1087pub fn scaffold_skill_toml(name: &str, tools: &[String], provider: Option<&str>) -> String {
1088    let mut toml = format!(
1089        r#"[skill]
1090name = "{name}"
1091version = "0.1.0"
1092description = ""
1093"#
1094    );
1095
1096    if !tools.is_empty() {
1097        let tools_str: Vec<String> = tools.iter().map(|t| format!("\"{t}\"")).collect();
1098        toml.push_str(&format!("tools = [{}]\n", tools_str.join(", ")));
1099    } else {
1100        toml.push_str("tools = []\n");
1101    }
1102
1103    if let Some(p) = provider {
1104        toml.push_str(&format!("providers = [\"{p}\"]\n"));
1105    } else {
1106        toml.push_str("providers = []\n");
1107    }
1108
1109    toml.push_str(
1110        r#"categories = []
1111keywords = []
1112hint = ""
1113depends_on = []
1114suggests = []
1115"#,
1116    );
1117
1118    toml
1119}
1120
1121/// Generate a skeleton `SKILL.md` (legacy format without frontmatter).
1122pub fn scaffold_skill_md(name: &str) -> String {
1123    let title = name
1124        .split('-')
1125        .map(|w| {
1126            let mut c = w.chars();
1127            match c.next() {
1128                None => String::new(),
1129                Some(f) => f.to_uppercase().to_string() + c.as_str(),
1130            }
1131        })
1132        .collect::<Vec<_>>()
1133        .join(" ");
1134
1135    format!(
1136        r#"# {title} Skill
1137
1138TODO: Describe what this skill does and when to use it.
1139
1140## Tools Available
1141
1142- TODO: List the tools this skill covers
1143
1144## Decision Tree
1145
11461. TODO: Step-by-step methodology
1147
1148## Examples
1149
1150TODO: Add example workflows
1151"#
1152    )
1153}
1154
1155/// Generate a skeleton `SKILL.md` with Anthropic-spec YAML frontmatter.
1156pub fn scaffold_skill_md_with_frontmatter(name: &str, description: &str) -> String {
1157    let title = name
1158        .split('-')
1159        .map(|w| {
1160            let mut c = w.chars();
1161            match c.next() {
1162                None => String::new(),
1163                Some(f) => f.to_uppercase().to_string() + c.as_str(),
1164            }
1165        })
1166        .collect::<Vec<_>>()
1167        .join(" ");
1168
1169    format!(
1170        r#"---
1171name: {name}
1172description: {description}
1173metadata:
1174  version: "0.1.0"
1175---
1176
1177# {title} Skill
1178
1179TODO: Describe what this skill does and when to use it.
1180
1181## Tools Available
1182
1183- TODO: List the tools this skill covers
1184
1185## Decision Tree
1186
11871. TODO: Step-by-step methodology
1188
1189## Examples
1190
1191TODO: Add example workflows
1192"#
1193    )
1194}
1195
1196/// Generate an ATI extension `skill.toml` for fields not in the Anthropic spec.
1197/// Used alongside a SKILL.md with frontmatter.
1198pub fn scaffold_ati_extension_toml(name: &str, tools: &[String], provider: Option<&str>) -> String {
1199    let mut toml = format!(
1200        r#"# ATI extension fields for skill '{name}'
1201# Core metadata (name, description, license) lives in SKILL.md frontmatter.
1202
1203[skill]
1204name = "{name}"
1205"#
1206    );
1207
1208    if !tools.is_empty() {
1209        let tools_str: Vec<String> = tools.iter().map(|t| format!("\"{t}\"")).collect();
1210        toml.push_str(&format!("tools = [{}]\n", tools_str.join(", ")));
1211    } else {
1212        toml.push_str("tools = []\n");
1213    }
1214
1215    if let Some(p) = provider {
1216        toml.push_str(&format!("providers = [\"{p}\"]\n"));
1217    } else {
1218        toml.push_str("providers = []\n");
1219    }
1220
1221    toml.push_str(
1222        r#"categories = []
1223keywords = []
1224depends_on = []
1225suggests = []
1226"#,
1227    );
1228
1229    toml
1230}
1231
1232#[cfg(test)]
1233mod tests {
1234    use super::*;
1235    use std::fs;
1236
1237    fn create_test_skill(
1238        dir: &Path,
1239        name: &str,
1240        tools: &[&str],
1241        providers: &[&str],
1242        categories: &[&str],
1243    ) {
1244        let skill_dir = dir.join(name);
1245        fs::create_dir_all(&skill_dir).unwrap();
1246
1247        let tools_toml: Vec<String> = tools.iter().map(|t| format!("\"{t}\"")).collect();
1248        let providers_toml: Vec<String> = providers.iter().map(|p| format!("\"{p}\"")).collect();
1249        let categories_toml: Vec<String> = categories.iter().map(|c| format!("\"{c}\"")).collect();
1250
1251        let toml_content = format!(
1252            r#"[skill]
1253name = "{name}"
1254version = "1.0.0"
1255description = "Test skill for {name}"
1256tools = [{tools}]
1257providers = [{providers}]
1258categories = [{categories}]
1259keywords = ["test", "{name}"]
1260hint = "Use for testing {name}"
1261depends_on = []
1262suggests = []
1263"#,
1264            tools = tools_toml.join(", "),
1265            providers = providers_toml.join(", "),
1266            categories = categories_toml.join(", "),
1267        );
1268
1269        fs::write(skill_dir.join("skill.toml"), toml_content).unwrap();
1270        fs::write(
1271            skill_dir.join("SKILL.md"),
1272            format!("# {name}\n\nTest skill content."),
1273        )
1274        .unwrap();
1275    }
1276
1277    #[test]
1278    fn test_load_skill_with_toml() {
1279        let tmp = tempfile::tempdir().unwrap();
1280        create_test_skill(
1281            tmp.path(),
1282            "sanctions",
1283            &["ca_business_sanctions_search"],
1284            &["complyadvantage"],
1285            &["compliance"],
1286        );
1287
1288        let registry = SkillRegistry::load(tmp.path()).unwrap();
1289        assert_eq!(registry.skill_count(), 1);
1290
1291        let skill = registry.get_skill("sanctions").unwrap();
1292        assert_eq!(skill.version, "1.0.0");
1293        assert_eq!(skill.tools, vec!["ca_business_sanctions_search"]);
1294        assert_eq!(skill.providers, vec!["complyadvantage"]);
1295        assert_eq!(skill.categories, vec!["compliance"]);
1296    }
1297
1298    #[test]
1299    fn test_load_skill_md_fallback() {
1300        let tmp = tempfile::tempdir().unwrap();
1301        let skill_dir = tmp.path().join("legacy-skill");
1302        fs::create_dir_all(&skill_dir).unwrap();
1303        fs::write(
1304            skill_dir.join("SKILL.md"),
1305            "# Legacy Skill\n\nA skill with only SKILL.md, no skill.toml.\n",
1306        )
1307        .unwrap();
1308
1309        let registry = SkillRegistry::load(tmp.path()).unwrap();
1310        assert_eq!(registry.skill_count(), 1);
1311
1312        let skill = registry.get_skill("legacy-skill").unwrap();
1313        assert_eq!(
1314            skill.description,
1315            "A skill with only SKILL.md, no skill.toml."
1316        );
1317        assert!(skill.tools.is_empty()); // No tool bindings without skill.toml
1318    }
1319
1320    #[test]
1321    fn test_tool_index() {
1322        let tmp = tempfile::tempdir().unwrap();
1323        create_test_skill(tmp.path(), "skill-a", &["tool_x", "tool_y"], &[], &[]);
1324        create_test_skill(tmp.path(), "skill-b", &["tool_y", "tool_z"], &[], &[]);
1325
1326        let registry = SkillRegistry::load(tmp.path()).unwrap();
1327
1328        // tool_x → only skill-a
1329        let skills = registry.skills_for_tool("tool_x");
1330        assert_eq!(skills.len(), 1);
1331        assert_eq!(skills[0].name, "skill-a");
1332
1333        // tool_y → both skills
1334        let skills = registry.skills_for_tool("tool_y");
1335        assert_eq!(skills.len(), 2);
1336
1337        // tool_z → only skill-b
1338        let skills = registry.skills_for_tool("tool_z");
1339        assert_eq!(skills.len(), 1);
1340        assert_eq!(skills[0].name, "skill-b");
1341
1342        // nonexistent → empty
1343        assert!(registry.skills_for_tool("nope").is_empty());
1344    }
1345
1346    #[test]
1347    fn test_provider_and_category_index() {
1348        let tmp = tempfile::tempdir().unwrap();
1349        create_test_skill(
1350            tmp.path(),
1351            "compliance-skill",
1352            &[],
1353            &["complyadvantage"],
1354            &["compliance", "aml"],
1355        );
1356
1357        let registry = SkillRegistry::load(tmp.path()).unwrap();
1358
1359        assert_eq!(registry.skills_for_provider("complyadvantage").len(), 1);
1360        assert_eq!(registry.skills_for_category("compliance").len(), 1);
1361        assert_eq!(registry.skills_for_category("aml").len(), 1);
1362        assert!(registry.skills_for_provider("serpapi").is_empty());
1363    }
1364
1365    #[test]
1366    fn test_search() {
1367        let tmp = tempfile::tempdir().unwrap();
1368        create_test_skill(
1369            tmp.path(),
1370            "sanctions-screening",
1371            &["ca_business_sanctions_search"],
1372            &["complyadvantage"],
1373            &["compliance"],
1374        );
1375        create_test_skill(
1376            tmp.path(),
1377            "web-search",
1378            &["web_search"],
1379            &["serpapi"],
1380            &["search"],
1381        );
1382
1383        let registry = SkillRegistry::load(tmp.path()).unwrap();
1384
1385        // Search for "sanctions"
1386        let results = registry.search("sanctions");
1387        assert!(!results.is_empty());
1388        assert_eq!(results[0].name, "sanctions-screening");
1389
1390        // Search for "web"
1391        let results = registry.search("web");
1392        assert!(!results.is_empty());
1393        assert_eq!(results[0].name, "web-search");
1394
1395        // Search for something absent
1396        let results = registry.search("nonexistent");
1397        assert!(results.is_empty());
1398    }
1399
1400    #[test]
1401    fn test_read_content_and_references() {
1402        let tmp = tempfile::tempdir().unwrap();
1403        let skill_dir = tmp.path().join("test-skill");
1404        let refs_dir = skill_dir.join("references");
1405        fs::create_dir_all(&refs_dir).unwrap();
1406
1407        fs::write(
1408            skill_dir.join("skill.toml"),
1409            r#"[skill]
1410name = "test-skill"
1411description = "Test"
1412"#,
1413        )
1414        .unwrap();
1415        fs::write(skill_dir.join("SKILL.md"), "# Test\n\nContent here.").unwrap();
1416        fs::write(refs_dir.join("guide.md"), "Reference guide content").unwrap();
1417
1418        let registry = SkillRegistry::load(tmp.path()).unwrap();
1419
1420        let content = registry.read_content("test-skill").unwrap();
1421        assert!(content.contains("Content here."));
1422
1423        let refs = registry.list_references("test-skill").unwrap();
1424        assert_eq!(refs, vec!["guide.md"]);
1425
1426        let ref_content = registry.read_reference("test-skill", "guide.md").unwrap();
1427        assert!(ref_content.contains("Reference guide content"));
1428    }
1429
1430    #[test]
1431    fn test_resolve_skills_explicit() {
1432        let tmp = tempfile::tempdir().unwrap();
1433        create_test_skill(tmp.path(), "skill-a", &[], &[], &[]);
1434        create_test_skill(tmp.path(), "skill-b", &[], &[], &[]);
1435
1436        let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1437        let manifest_reg = ManifestRegistry::empty();
1438
1439        let scopes = ScopeConfig {
1440            scopes: vec!["skill:skill-a".to_string()],
1441            sub: String::new(),
1442            expires_at: 0,
1443            rate_config: None,
1444        };
1445
1446        let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1447        assert_eq!(resolved.len(), 1);
1448        assert_eq!(resolved[0].name, "skill-a");
1449    }
1450
1451    #[test]
1452    fn test_resolve_skills_by_tool_binding() {
1453        let tmp = tempfile::tempdir().unwrap();
1454        create_test_skill(
1455            tmp.path(),
1456            "sanctions-skill",
1457            &["ca_sanctions_search"],
1458            &[],
1459            &[],
1460        );
1461        create_test_skill(
1462            tmp.path(),
1463            "unrelated-skill",
1464            &["some_other_tool"],
1465            &[],
1466            &[],
1467        );
1468
1469        let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1470
1471        let manifest_reg = ManifestRegistry::empty();
1472
1473        let scopes = ScopeConfig {
1474            scopes: vec!["tool:ca_sanctions_search".to_string()],
1475            sub: String::new(),
1476            expires_at: 0,
1477            rate_config: None,
1478        };
1479
1480        let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1481        assert_eq!(resolved.len(), 1);
1482        assert_eq!(resolved[0].name, "sanctions-skill");
1483    }
1484
1485    #[test]
1486    fn test_resolve_skills_legacy_underscore_scope_matches_colon_tool_binding() {
1487        let tmp = tempfile::tempdir().unwrap();
1488        create_test_skill(tmp.path(), "colon-skill", &["test_api:get_data"], &[], &[]);
1489
1490        let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1491
1492        let manifest_tmp = tempfile::tempdir().unwrap();
1493        fs::write(
1494            manifest_tmp.path().join("test.toml"),
1495            r#"
1496[provider]
1497name = "test_provider"
1498description = "Test provider"
1499base_url = "http://unused"
1500auth_type = "none"
1501
1502[[tools]]
1503name = "test_api:get_data"
1504description = "test"
1505endpoint = "/"
1506method = "GET"
1507scope = "tool:test_api:get_data"
1508"#,
1509        )
1510        .unwrap();
1511        let manifest_reg = ManifestRegistry::load(manifest_tmp.path()).unwrap();
1512
1513        let scopes = ScopeConfig {
1514            scopes: vec!["tool:test_api_get_data".to_string()],
1515            sub: String::new(),
1516            expires_at: 0,
1517            rate_config: None,
1518        };
1519
1520        let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1521        assert_eq!(resolved.len(), 1);
1522        assert_eq!(resolved[0].name, "colon-skill");
1523    }
1524
1525    #[test]
1526    fn test_resolve_skills_with_dependencies() {
1527        let tmp = tempfile::tempdir().unwrap();
1528
1529        // Create skill-a that depends on skill-b
1530        let dir_a = tmp.path().join("skill-a");
1531        fs::create_dir_all(&dir_a).unwrap();
1532        fs::write(
1533            dir_a.join("skill.toml"),
1534            r#"[skill]
1535name = "skill-a"
1536description = "Skill A"
1537tools = ["tool_a"]
1538depends_on = ["skill-b"]
1539"#,
1540        )
1541        .unwrap();
1542        fs::write(dir_a.join("SKILL.md"), "# Skill A").unwrap();
1543
1544        // Create skill-b (dependency)
1545        let dir_b = tmp.path().join("skill-b");
1546        fs::create_dir_all(&dir_b).unwrap();
1547        fs::write(
1548            dir_b.join("skill.toml"),
1549            r#"[skill]
1550name = "skill-b"
1551description = "Skill B"
1552tools = ["tool_b"]
1553"#,
1554        )
1555        .unwrap();
1556        fs::write(dir_b.join("SKILL.md"), "# Skill B").unwrap();
1557
1558        let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1559
1560        let manifest_tmp = tempfile::tempdir().unwrap();
1561        fs::create_dir_all(manifest_tmp.path()).unwrap();
1562        let manifest_reg = ManifestRegistry::load(manifest_tmp.path())
1563            .unwrap_or_else(|_| panic!("cannot load empty manifest dir"));
1564
1565        let scopes = ScopeConfig {
1566            scopes: vec!["tool:tool_a".to_string()],
1567            sub: String::new(),
1568            expires_at: 0,
1569            rate_config: None,
1570        };
1571
1572        let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1573        // Should resolve both skill-a (via tool binding) and skill-b (via dependency)
1574        assert_eq!(resolved.len(), 2);
1575        let names: Vec<&str> = resolved.iter().map(|s| s.name.as_str()).collect();
1576        assert!(names.contains(&"skill-a"));
1577        assert!(names.contains(&"skill-b"));
1578    }
1579
1580    #[test]
1581    fn test_scaffold() {
1582        let toml = scaffold_skill_toml(
1583            "my-skill",
1584            &["tool_a".into(), "tool_b".into()],
1585            Some("provider_x"),
1586        );
1587        assert!(toml.contains("name = \"my-skill\""));
1588        assert!(toml.contains("\"tool_a\""));
1589        assert!(toml.contains("\"provider_x\""));
1590
1591        let md = scaffold_skill_md("my-cool-skill");
1592        assert!(md.contains("# My Cool Skill Skill"));
1593    }
1594
1595    #[test]
1596    fn test_build_skill_context() {
1597        let skill = SkillMeta {
1598            name: "test-skill".to_string(),
1599            version: "1.0.0".to_string(),
1600            description: "A test skill".to_string(),
1601            tools: vec!["tool_a".to_string(), "tool_b".to_string()],
1602            hint: Some("Use for testing".to_string()),
1603            suggests: vec!["other-skill".to_string()],
1604            ..Default::default()
1605        };
1606
1607        let ctx = build_skill_context(&[&skill]);
1608        assert!(ctx.contains("**test-skill**"));
1609        assert!(ctx.contains("A test skill"));
1610        assert!(ctx.contains("Use for testing"));
1611        assert!(ctx.contains("tool_a, tool_b"));
1612        assert!(ctx.contains("other-skill"));
1613    }
1614
1615    #[test]
1616    fn test_empty_directory() {
1617        let tmp = tempfile::tempdir().unwrap();
1618        let registry = SkillRegistry::load(tmp.path()).unwrap();
1619        assert_eq!(registry.skill_count(), 0);
1620    }
1621
1622    #[test]
1623    fn test_nonexistent_directory() {
1624        let registry = SkillRegistry::load(Path::new("/nonexistent/path")).unwrap();
1625        assert_eq!(registry.skill_count(), 0);
1626    }
1627}