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