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}
275
276impl SkillRegistry {
277    /// Load all skills from a directory. Each subdirectory is a skill.
278    ///
279    /// If `skill.toml` exists, parse it for full metadata.
280    /// Otherwise, fall back to reading `SKILL.md` for name + description only.
281    pub fn load(skills_dir: &Path) -> Result<Self, SkillError> {
282        let mut skills = Vec::new();
283        let mut name_index = HashMap::new();
284        let mut tool_index: HashMap<String, Vec<usize>> = HashMap::new();
285        let mut provider_index: HashMap<String, Vec<usize>> = HashMap::new();
286        let mut category_index: HashMap<String, Vec<usize>> = HashMap::new();
287
288        if !skills_dir.is_dir() {
289            // Not an error — just an empty registry
290            return Ok(SkillRegistry {
291                skills,
292                name_index,
293                tool_index,
294                provider_index,
295                category_index,
296            });
297        }
298
299        let entries = std::fs::read_dir(skills_dir)
300            .map_err(|e| SkillError::Io(skills_dir.display().to_string(), e))?;
301
302        for entry in entries {
303            let entry = entry.map_err(|e| SkillError::Io(skills_dir.display().to_string(), e))?;
304            let path = entry.path();
305            if !path.is_dir() {
306                continue;
307            }
308
309            let skill = load_skill_from_dir(&path)?;
310
311            let idx = skills.len();
312            name_index.insert(skill.name.clone(), idx);
313
314            for tool in &skill.tools {
315                tool_index.entry(tool.clone()).or_default().push(idx);
316            }
317            for provider in &skill.providers {
318                provider_index
319                    .entry(provider.clone())
320                    .or_default()
321                    .push(idx);
322            }
323            for category in &skill.categories {
324                category_index
325                    .entry(category.clone())
326                    .or_default()
327                    .push(idx);
328            }
329
330            skills.push(skill);
331        }
332
333        Ok(SkillRegistry {
334            skills,
335            name_index,
336            tool_index,
337            provider_index,
338            category_index,
339        })
340    }
341
342    /// Get a skill by name.
343    pub fn get_skill(&self, name: &str) -> Option<&SkillMeta> {
344        self.name_index.get(name).map(|&idx| &self.skills[idx])
345    }
346
347    /// List all loaded skills.
348    pub fn list_skills(&self) -> &[SkillMeta] {
349        &self.skills
350    }
351
352    /// Skills that cover a specific tool name.
353    pub fn skills_for_tool(&self, tool_name: &str) -> Vec<&SkillMeta> {
354        self.tool_index
355            .get(tool_name)
356            .map(|indices| indices.iter().map(|&i| &self.skills[i]).collect())
357            .unwrap_or_default()
358    }
359
360    /// Skills that cover a specific provider name.
361    pub fn skills_for_provider(&self, provider_name: &str) -> Vec<&SkillMeta> {
362        self.provider_index
363            .get(provider_name)
364            .map(|indices| indices.iter().map(|&i| &self.skills[i]).collect())
365            .unwrap_or_default()
366    }
367
368    /// Skills that cover a specific category.
369    pub fn skills_for_category(&self, category: &str) -> Vec<&SkillMeta> {
370        self.category_index
371            .get(category)
372            .map(|indices| indices.iter().map(|&i| &self.skills[i]).collect())
373            .unwrap_or_default()
374    }
375
376    /// Search skills by fuzzy matching on name, description, keywords, hint, and tool names.
377    pub fn search(&self, query: &str) -> Vec<&SkillMeta> {
378        let q = query.to_lowercase();
379        let terms: Vec<&str> = q.split_whitespace().collect();
380
381        let mut scored: Vec<(usize, &SkillMeta)> = self
382            .skills
383            .iter()
384            .filter_map(|skill| {
385                let mut score = 0usize;
386                let name_lower = skill.name.to_lowercase();
387                let desc_lower = skill.description.to_lowercase();
388
389                for term in &terms {
390                    // Name match (highest weight)
391                    if name_lower.contains(term) {
392                        score += 10;
393                    }
394                    // Description match
395                    if desc_lower.contains(term) {
396                        score += 5;
397                    }
398                    // Keyword match
399                    if skill
400                        .keywords
401                        .iter()
402                        .any(|k| k.to_lowercase().contains(term))
403                    {
404                        score += 8;
405                    }
406                    // Tool name match
407                    if skill.tools.iter().any(|t| t.to_lowercase().contains(term)) {
408                        score += 6;
409                    }
410                    // Hint match
411                    if let Some(hint) = &skill.hint {
412                        if hint.to_lowercase().contains(term) {
413                            score += 4;
414                        }
415                    }
416                    // Provider match
417                    if skill
418                        .providers
419                        .iter()
420                        .any(|p| p.to_lowercase().contains(term))
421                    {
422                        score += 6;
423                    }
424                    // Category match
425                    if skill
426                        .categories
427                        .iter()
428                        .any(|c| c.to_lowercase().contains(term))
429                    {
430                        score += 4;
431                    }
432                }
433
434                if score > 0 {
435                    Some((score, skill))
436                } else {
437                    None
438                }
439            })
440            .collect();
441
442        // Sort by score descending
443        scored.sort_by(|a, b| b.0.cmp(&a.0));
444        scored.into_iter().map(|(_, skill)| skill).collect()
445    }
446
447    /// Read the SKILL.md content for a skill, stripping any YAML frontmatter.
448    pub fn read_content(&self, name: &str) -> Result<String, SkillError> {
449        let skill = self
450            .get_skill(name)
451            .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
452        let skill_md = skill.dir.join("SKILL.md");
453        if !skill_md.exists() {
454            return Ok(String::new());
455        }
456        let raw = std::fs::read_to_string(&skill_md)
457            .map_err(|e| SkillError::Io(skill_md.display().to_string(), e))?;
458        Ok(strip_frontmatter(&raw).to_string())
459    }
460
461    /// List reference files for a skill.
462    pub fn list_references(&self, name: &str) -> Result<Vec<String>, SkillError> {
463        let skill = self
464            .get_skill(name)
465            .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
466        let refs_dir = skill.dir.join("references");
467        if !refs_dir.is_dir() {
468            return Ok(Vec::new());
469        }
470        let mut refs = Vec::new();
471        let entries = std::fs::read_dir(&refs_dir)
472            .map_err(|e| SkillError::Io(refs_dir.display().to_string(), e))?;
473        for entry in entries {
474            let entry = entry.map_err(|e| SkillError::Io(refs_dir.display().to_string(), e))?;
475            if let Some(name) = entry.file_name().to_str() {
476                refs.push(name.to_string());
477            }
478        }
479        refs.sort();
480        Ok(refs)
481    }
482
483    /// Read a specific reference file.
484    pub fn read_reference(&self, skill_name: &str, ref_name: &str) -> Result<String, SkillError> {
485        // Path traversal protection: reject names with path components
486        if ref_name.contains("..")
487            || ref_name.contains('/')
488            || ref_name.contains('\\')
489            || ref_name.contains('\0')
490        {
491            return Err(SkillError::NotFound(format!(
492                "Invalid reference name '{ref_name}' — path traversal not allowed"
493            )));
494        }
495
496        let skill = self
497            .get_skill(skill_name)
498            .ok_or_else(|| SkillError::NotFound(skill_name.to_string()))?;
499        let refs_dir = skill.dir.join("references");
500        let ref_path = refs_dir.join(ref_name);
501
502        // Canonicalize and verify the resolved path is inside the references directory
503        if let (Ok(canonical_ref), Ok(canonical_dir)) =
504            (ref_path.canonicalize(), refs_dir.canonicalize())
505        {
506            if !canonical_ref.starts_with(&canonical_dir) {
507                return Err(SkillError::NotFound(format!(
508                    "Reference '{ref_name}' resolves outside references directory"
509                )));
510            }
511        }
512
513        if !ref_path.exists() {
514            return Err(SkillError::NotFound(format!(
515                "Reference '{ref_name}' in skill '{skill_name}'"
516            )));
517        }
518        std::fs::read_to_string(&ref_path)
519            .map_err(|e| SkillError::Io(ref_path.display().to_string(), e))
520    }
521
522    /// Number of loaded skills.
523    pub fn skill_count(&self) -> usize {
524        self.skills.len()
525    }
526
527    /// Validate a skill's tool bindings against a ManifestRegistry.
528    /// Returns (valid_tools, unknown_tools).
529    pub fn validate_tool_bindings(
530        &self,
531        name: &str,
532        manifest_registry: &ManifestRegistry,
533    ) -> Result<(Vec<String>, Vec<String>), SkillError> {
534        let skill = self
535            .get_skill(name)
536            .ok_or_else(|| SkillError::NotFound(name.to_string()))?;
537
538        let mut valid = Vec::new();
539        let mut unknown = Vec::new();
540
541        for tool_name in &skill.tools {
542            if manifest_registry.get_tool(tool_name).is_some() {
543                valid.push(tool_name.clone());
544            } else {
545                unknown.push(tool_name.clone());
546            }
547        }
548
549        Ok((valid, unknown))
550    }
551}
552
553/// Resolve which skills should be auto-loaded based on scopes and a ManifestRegistry.
554///
555/// Resolution cascade:
556/// 1. Explicit skill scopes: "skill:X" → load X directly
557/// 2. Tool binding: "tool:Y" → skills where tools contains "Y"
558/// 3. Provider binding: tool Y belongs to provider P → skills where providers contains "P"
559/// 4. Category binding: provider P has category C → skills where categories contains "C"
560/// 5. Dependency resolution: loaded skill depends_on Z → transitively load Z
561///
562/// Wildcard scope (*) = all skills available but not auto-loaded.
563pub fn resolve_skills<'a>(
564    skill_registry: &'a SkillRegistry,
565    manifest_registry: &ManifestRegistry,
566    scopes: &ScopeConfig,
567) -> Vec<&'a SkillMeta> {
568    let mut resolved_indices: Vec<usize> = Vec::new();
569    let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
570
571    for scope in &scopes.scopes {
572        // 1. Explicit skill scopes
573        if let Some(skill_name) = scope.strip_prefix("skill:") {
574            if let Some(&idx) = skill_registry.name_index.get(skill_name) {
575                if seen.insert(idx) {
576                    resolved_indices.push(idx);
577                }
578            }
579        }
580
581        // 2. Tool binding → skills covering that tool
582        if let Some(tool_name) = scope.strip_prefix("tool:") {
583            if let Some(indices) = skill_registry.tool_index.get(tool_name) {
584                for &idx in indices {
585                    if seen.insert(idx) {
586                        resolved_indices.push(idx);
587                    }
588                }
589            }
590
591            // 3. Provider binding → skills covering the tool's provider
592            if let Some((provider, _)) = manifest_registry.get_tool(tool_name) {
593                if let Some(indices) = skill_registry.provider_index.get(&provider.name) {
594                    for &idx in indices {
595                        if seen.insert(idx) {
596                            resolved_indices.push(idx);
597                        }
598                    }
599                }
600
601                // 4. Category binding → skills covering the provider's category
602                if let Some(category) = &provider.category {
603                    if let Some(indices) = skill_registry.category_index.get(category) {
604                        for &idx in indices {
605                            if seen.insert(idx) {
606                                resolved_indices.push(idx);
607                            }
608                        }
609                    }
610                }
611            }
612        }
613    }
614
615    // 5. Dependency resolution (transitive)
616    let mut i = 0;
617    while i < resolved_indices.len() {
618        let skill = &skill_registry.skills[resolved_indices[i]];
619        for dep_name in &skill.depends_on {
620            if let Some(&dep_idx) = skill_registry.name_index.get(dep_name) {
621                if seen.insert(dep_idx) {
622                    resolved_indices.push(dep_idx);
623                }
624            }
625        }
626        i += 1;
627    }
628
629    resolved_indices
630        .into_iter()
631        .map(|idx| &skill_registry.skills[idx])
632        .collect()
633}
634
635/// Maximum size of skill content injected into LLM system prompts (32 KB).
636/// Prevents prompt injection via extremely large SKILL.md files.
637const MAX_SKILL_INJECT_SIZE: usize = 32 * 1024;
638
639/// Build a skill context string for LLM system prompts.
640/// For each skill: name, description, hint, covered tools.
641/// Content is bounded and delimited to mitigate prompt injection.
642pub fn build_skill_context(skills: &[&SkillMeta]) -> String {
643    if skills.is_empty() {
644        return String::new();
645    }
646
647    let mut total_size = 0;
648    let mut sections = Vec::new();
649    for skill in skills {
650        let mut section = format!(
651            "--- BEGIN SKILL: {} ---\n- **{}**: {}",
652            skill.name, skill.name, skill.description
653        );
654        if let Some(hint) = &skill.hint {
655            section.push_str(&format!("\n  Hint: {hint}"));
656        }
657        if !skill.tools.is_empty() {
658            section.push_str(&format!("\n  Covers tools: {}", skill.tools.join(", ")));
659        }
660        if !skill.suggests.is_empty() {
661            section.push_str(&format!(
662                "\n  Related skills: {}",
663                skill.suggests.join(", ")
664            ));
665        }
666        section.push_str(&format!("\n--- END SKILL: {} ---", skill.name));
667
668        total_size += section.len();
669        if total_size > MAX_SKILL_INJECT_SIZE {
670            sections.push("(remaining skills truncated due to size limit)".to_string());
671            break;
672        }
673        sections.push(section);
674    }
675    sections.join("\n\n")
676}
677
678// --- Private helpers ---
679
680/// Load a single skill from a directory.
681///
682/// Priority:
683///   (A) SKILL.md with YAML frontmatter → primary source; merge ATI extensions from skill.toml
684///   (B) No frontmatter + skill.toml → current legacy behavior
685///   (C) No frontmatter + no skill.toml + SKILL.md exists → infer from content
686fn load_skill_from_dir(dir: &Path) -> Result<SkillMeta, SkillError> {
687    let skill_toml_path = dir.join("skill.toml");
688    let skill_md_path = dir.join("SKILL.md");
689
690    let dir_name = dir
691        .file_name()
692        .and_then(|n| n.to_str())
693        .unwrap_or("unknown")
694        .to_string();
695
696    // Try to read and parse frontmatter from SKILL.md
697    let (frontmatter, _body) = if skill_md_path.exists() {
698        let content = std::fs::read_to_string(&skill_md_path)
699            .map_err(|e| SkillError::Io(skill_md_path.display().to_string(), e))?;
700        let (fm, body) = parse_frontmatter(&content);
701        // We need owned body for description inference later
702        let body_owned = body.to_string();
703        (fm, Some((content, body_owned)))
704    } else {
705        (None, None)
706    };
707
708    if let Some(fm) = frontmatter {
709        // --- (A) Frontmatter exists → primary source ---
710        let mut meta = SkillMeta {
711            name: fm.name.unwrap_or_else(|| dir_name.clone()),
712            description: fm.description.unwrap_or_default(),
713            license: fm.license,
714            compatibility: fm.compatibility,
715            extra_metadata: fm.metadata,
716            allowed_tools: fm.allowed_tools,
717            has_frontmatter: true,
718            format: SkillFormat::Anthropic,
719            dir: dir.to_path_buf(),
720            ..Default::default()
721        };
722
723        // Extract author/version from frontmatter metadata if present
724        if let Some(author) = meta.extra_metadata.get("author").cloned() {
725            meta.author = Some(author);
726        }
727        if let Some(version) = meta.extra_metadata.get("version").cloned() {
728            meta.version = version;
729        }
730
731        // Merge ATI-extension fields from skill.toml if it exists
732        if skill_toml_path.exists() {
733            let contents = std::fs::read_to_string(&skill_toml_path)
734                .map_err(|e| SkillError::Io(skill_toml_path.display().to_string(), e))?;
735            if let Ok(parsed) = toml::from_str::<SkillToml>(&contents) {
736                let ext = parsed.skill;
737                // ATI-specific fields not in frontmatter
738                meta.tools = ext.tools;
739                meta.providers = ext.providers;
740                meta.categories = ext.categories;
741                meta.keywords = ext.keywords;
742                meta.hint = ext.hint;
743                meta.depends_on = ext.depends_on;
744                meta.suggests = ext.suggests;
745            }
746        }
747
748        load_integrity_info(&mut meta);
749        Ok(meta)
750    } else if skill_toml_path.exists() {
751        // --- (B) No frontmatter + skill.toml → legacy behavior ---
752        let contents = std::fs::read_to_string(&skill_toml_path)
753            .map_err(|e| SkillError::Io(skill_toml_path.display().to_string(), e))?;
754        let parsed: SkillToml = toml::from_str(&contents)
755            .map_err(|e| SkillError::Parse(skill_toml_path.display().to_string(), e))?;
756        let mut meta = parsed.skill;
757        meta.dir = dir.to_path_buf();
758        meta.format = SkillFormat::LegacyToml;
759        if meta.name.is_empty() {
760            meta.name = dir_name;
761        }
762        load_integrity_info(&mut meta);
763        Ok(meta)
764    } else if let Some((_full_content, body)) = _body {
765        // --- (C) No frontmatter + no skill.toml + SKILL.md exists → infer ---
766        let description = body
767            .lines()
768            .find(|l| !l.is_empty() && !l.starts_with('#'))
769            .map(|l| l.trim().to_string())
770            .unwrap_or_default();
771
772        Ok(SkillMeta {
773            name: dir_name,
774            description,
775            format: SkillFormat::Inferred,
776            dir: dir.to_path_buf(),
777            ..Default::default()
778        })
779    } else {
780        Err(SkillError::Invalid(format!(
781            "Directory '{}' has neither skill.toml nor SKILL.md",
782            dir.display()
783        )))
784    }
785}
786
787/// Read [ati.integrity] section from skill.toml and populate SkillMeta fields.
788fn load_integrity_info(meta: &mut SkillMeta) {
789    let toml_path = meta.dir.join("skill.toml");
790    if !toml_path.exists() {
791        return;
792    }
793    let contents = match std::fs::read_to_string(&toml_path) {
794        Ok(c) => c,
795        Err(_) => return,
796    };
797    let parsed: toml::Value = match toml::from_str(&contents) {
798        Ok(v) => v,
799        Err(_) => return,
800    };
801    if let Some(integrity) = parsed.get("ati").and_then(|a| a.get("integrity")) {
802        meta.content_hash = integrity
803            .get("content_hash")
804            .and_then(|v| v.as_str())
805            .map(|s| s.to_string());
806        meta.source_url = integrity
807            .get("source_url")
808            .and_then(|v| v.as_str())
809            .map(|s| s.to_string());
810        meta.pinned_sha = integrity
811            .get("pinned_sha")
812            .and_then(|v| v.as_str())
813            .map(|s| s.to_string());
814    }
815}
816
817/// Generate a skeleton `skill.toml` for a new skill.
818pub fn scaffold_skill_toml(name: &str, tools: &[String], provider: Option<&str>) -> String {
819    let mut toml = format!(
820        r#"[skill]
821name = "{name}"
822version = "0.1.0"
823description = ""
824"#
825    );
826
827    if !tools.is_empty() {
828        let tools_str: Vec<String> = tools.iter().map(|t| format!("\"{t}\"")).collect();
829        toml.push_str(&format!("tools = [{}]\n", tools_str.join(", ")));
830    } else {
831        toml.push_str("tools = []\n");
832    }
833
834    if let Some(p) = provider {
835        toml.push_str(&format!("providers = [\"{p}\"]\n"));
836    } else {
837        toml.push_str("providers = []\n");
838    }
839
840    toml.push_str(
841        r#"categories = []
842keywords = []
843hint = ""
844depends_on = []
845suggests = []
846"#,
847    );
848
849    toml
850}
851
852/// Generate a skeleton `SKILL.md` (legacy format without frontmatter).
853pub fn scaffold_skill_md(name: &str) -> String {
854    let title = name
855        .split('-')
856        .map(|w| {
857            let mut c = w.chars();
858            match c.next() {
859                None => String::new(),
860                Some(f) => f.to_uppercase().to_string() + c.as_str(),
861            }
862        })
863        .collect::<Vec<_>>()
864        .join(" ");
865
866    format!(
867        r#"# {title} Skill
868
869TODO: Describe what this skill does and when to use it.
870
871## Tools Available
872
873- TODO: List the tools this skill covers
874
875## Decision Tree
876
8771. TODO: Step-by-step methodology
878
879## Examples
880
881TODO: Add example workflows
882"#
883    )
884}
885
886/// Generate a skeleton `SKILL.md` with Anthropic-spec YAML frontmatter.
887pub fn scaffold_skill_md_with_frontmatter(name: &str, description: &str) -> String {
888    let title = name
889        .split('-')
890        .map(|w| {
891            let mut c = w.chars();
892            match c.next() {
893                None => String::new(),
894                Some(f) => f.to_uppercase().to_string() + c.as_str(),
895            }
896        })
897        .collect::<Vec<_>>()
898        .join(" ");
899
900    format!(
901        r#"---
902name: {name}
903description: {description}
904metadata:
905  version: "0.1.0"
906---
907
908# {title} Skill
909
910TODO: Describe what this skill does and when to use it.
911
912## Tools Available
913
914- TODO: List the tools this skill covers
915
916## Decision Tree
917
9181. TODO: Step-by-step methodology
919
920## Examples
921
922TODO: Add example workflows
923"#
924    )
925}
926
927/// Generate an ATI extension `skill.toml` for fields not in the Anthropic spec.
928/// Used alongside a SKILL.md with frontmatter.
929pub fn scaffold_ati_extension_toml(name: &str, tools: &[String], provider: Option<&str>) -> String {
930    let mut toml = format!(
931        r#"# ATI extension fields for skill '{name}'
932# Core metadata (name, description, license) lives in SKILL.md frontmatter.
933
934[skill]
935name = "{name}"
936"#
937    );
938
939    if !tools.is_empty() {
940        let tools_str: Vec<String> = tools.iter().map(|t| format!("\"{t}\"")).collect();
941        toml.push_str(&format!("tools = [{}]\n", tools_str.join(", ")));
942    } else {
943        toml.push_str("tools = []\n");
944    }
945
946    if let Some(p) = provider {
947        toml.push_str(&format!("providers = [\"{p}\"]\n"));
948    } else {
949        toml.push_str("providers = []\n");
950    }
951
952    toml.push_str(
953        r#"categories = []
954keywords = []
955depends_on = []
956suggests = []
957"#,
958    );
959
960    toml
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966    use std::fs;
967
968    fn create_test_skill(
969        dir: &Path,
970        name: &str,
971        tools: &[&str],
972        providers: &[&str],
973        categories: &[&str],
974    ) {
975        let skill_dir = dir.join(name);
976        fs::create_dir_all(&skill_dir).unwrap();
977
978        let tools_toml: Vec<String> = tools.iter().map(|t| format!("\"{t}\"")).collect();
979        let providers_toml: Vec<String> = providers.iter().map(|p| format!("\"{p}\"")).collect();
980        let categories_toml: Vec<String> = categories.iter().map(|c| format!("\"{c}\"")).collect();
981
982        let toml_content = format!(
983            r#"[skill]
984name = "{name}"
985version = "1.0.0"
986description = "Test skill for {name}"
987tools = [{tools}]
988providers = [{providers}]
989categories = [{categories}]
990keywords = ["test", "{name}"]
991hint = "Use for testing {name}"
992depends_on = []
993suggests = []
994"#,
995            tools = tools_toml.join(", "),
996            providers = providers_toml.join(", "),
997            categories = categories_toml.join(", "),
998        );
999
1000        fs::write(skill_dir.join("skill.toml"), toml_content).unwrap();
1001        fs::write(
1002            skill_dir.join("SKILL.md"),
1003            format!("# {name}\n\nTest skill content."),
1004        )
1005        .unwrap();
1006    }
1007
1008    #[test]
1009    fn test_load_skill_with_toml() {
1010        let tmp = tempfile::tempdir().unwrap();
1011        create_test_skill(
1012            tmp.path(),
1013            "sanctions",
1014            &["ca_business_sanctions_search"],
1015            &["complyadvantage"],
1016            &["compliance"],
1017        );
1018
1019        let registry = SkillRegistry::load(tmp.path()).unwrap();
1020        assert_eq!(registry.skill_count(), 1);
1021
1022        let skill = registry.get_skill("sanctions").unwrap();
1023        assert_eq!(skill.version, "1.0.0");
1024        assert_eq!(skill.tools, vec!["ca_business_sanctions_search"]);
1025        assert_eq!(skill.providers, vec!["complyadvantage"]);
1026        assert_eq!(skill.categories, vec!["compliance"]);
1027    }
1028
1029    #[test]
1030    fn test_load_skill_md_fallback() {
1031        let tmp = tempfile::tempdir().unwrap();
1032        let skill_dir = tmp.path().join("legacy-skill");
1033        fs::create_dir_all(&skill_dir).unwrap();
1034        fs::write(
1035            skill_dir.join("SKILL.md"),
1036            "# Legacy Skill\n\nA skill with only SKILL.md, no skill.toml.\n",
1037        )
1038        .unwrap();
1039
1040        let registry = SkillRegistry::load(tmp.path()).unwrap();
1041        assert_eq!(registry.skill_count(), 1);
1042
1043        let skill = registry.get_skill("legacy-skill").unwrap();
1044        assert_eq!(
1045            skill.description,
1046            "A skill with only SKILL.md, no skill.toml."
1047        );
1048        assert!(skill.tools.is_empty()); // No tool bindings without skill.toml
1049    }
1050
1051    #[test]
1052    fn test_tool_index() {
1053        let tmp = tempfile::tempdir().unwrap();
1054        create_test_skill(tmp.path(), "skill-a", &["tool_x", "tool_y"], &[], &[]);
1055        create_test_skill(tmp.path(), "skill-b", &["tool_y", "tool_z"], &[], &[]);
1056
1057        let registry = SkillRegistry::load(tmp.path()).unwrap();
1058
1059        // tool_x → only skill-a
1060        let skills = registry.skills_for_tool("tool_x");
1061        assert_eq!(skills.len(), 1);
1062        assert_eq!(skills[0].name, "skill-a");
1063
1064        // tool_y → both skills
1065        let skills = registry.skills_for_tool("tool_y");
1066        assert_eq!(skills.len(), 2);
1067
1068        // tool_z → only skill-b
1069        let skills = registry.skills_for_tool("tool_z");
1070        assert_eq!(skills.len(), 1);
1071        assert_eq!(skills[0].name, "skill-b");
1072
1073        // nonexistent → empty
1074        assert!(registry.skills_for_tool("nope").is_empty());
1075    }
1076
1077    #[test]
1078    fn test_provider_and_category_index() {
1079        let tmp = tempfile::tempdir().unwrap();
1080        create_test_skill(
1081            tmp.path(),
1082            "compliance-skill",
1083            &[],
1084            &["complyadvantage"],
1085            &["compliance", "aml"],
1086        );
1087
1088        let registry = SkillRegistry::load(tmp.path()).unwrap();
1089
1090        assert_eq!(registry.skills_for_provider("complyadvantage").len(), 1);
1091        assert_eq!(registry.skills_for_category("compliance").len(), 1);
1092        assert_eq!(registry.skills_for_category("aml").len(), 1);
1093        assert!(registry.skills_for_provider("serpapi").is_empty());
1094    }
1095
1096    #[test]
1097    fn test_search() {
1098        let tmp = tempfile::tempdir().unwrap();
1099        create_test_skill(
1100            tmp.path(),
1101            "sanctions-screening",
1102            &["ca_business_sanctions_search"],
1103            &["complyadvantage"],
1104            &["compliance"],
1105        );
1106        create_test_skill(
1107            tmp.path(),
1108            "web-search",
1109            &["web_search"],
1110            &["serpapi"],
1111            &["search"],
1112        );
1113
1114        let registry = SkillRegistry::load(tmp.path()).unwrap();
1115
1116        // Search for "sanctions"
1117        let results = registry.search("sanctions");
1118        assert!(!results.is_empty());
1119        assert_eq!(results[0].name, "sanctions-screening");
1120
1121        // Search for "web"
1122        let results = registry.search("web");
1123        assert!(!results.is_empty());
1124        assert_eq!(results[0].name, "web-search");
1125
1126        // Search for something absent
1127        let results = registry.search("nonexistent");
1128        assert!(results.is_empty());
1129    }
1130
1131    #[test]
1132    fn test_read_content_and_references() {
1133        let tmp = tempfile::tempdir().unwrap();
1134        let skill_dir = tmp.path().join("test-skill");
1135        let refs_dir = skill_dir.join("references");
1136        fs::create_dir_all(&refs_dir).unwrap();
1137
1138        fs::write(
1139            skill_dir.join("skill.toml"),
1140            r#"[skill]
1141name = "test-skill"
1142description = "Test"
1143"#,
1144        )
1145        .unwrap();
1146        fs::write(skill_dir.join("SKILL.md"), "# Test\n\nContent here.").unwrap();
1147        fs::write(refs_dir.join("guide.md"), "Reference guide content").unwrap();
1148
1149        let registry = SkillRegistry::load(tmp.path()).unwrap();
1150
1151        let content = registry.read_content("test-skill").unwrap();
1152        assert!(content.contains("Content here."));
1153
1154        let refs = registry.list_references("test-skill").unwrap();
1155        assert_eq!(refs, vec!["guide.md"]);
1156
1157        let ref_content = registry.read_reference("test-skill", "guide.md").unwrap();
1158        assert!(ref_content.contains("Reference guide content"));
1159    }
1160
1161    #[test]
1162    fn test_resolve_skills_explicit() {
1163        let tmp = tempfile::tempdir().unwrap();
1164        create_test_skill(tmp.path(), "skill-a", &[], &[], &[]);
1165        create_test_skill(tmp.path(), "skill-b", &[], &[], &[]);
1166
1167        let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1168        let manifest_reg = ManifestRegistry::empty();
1169
1170        let scopes = ScopeConfig {
1171            scopes: vec!["skill:skill-a".to_string()],
1172            sub: String::new(),
1173            expires_at: 0,
1174            rate_config: None,
1175        };
1176
1177        let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1178        assert_eq!(resolved.len(), 1);
1179        assert_eq!(resolved[0].name, "skill-a");
1180    }
1181
1182    #[test]
1183    fn test_resolve_skills_by_tool_binding() {
1184        let tmp = tempfile::tempdir().unwrap();
1185        create_test_skill(
1186            tmp.path(),
1187            "sanctions-skill",
1188            &["ca_sanctions_search"],
1189            &[],
1190            &[],
1191        );
1192        create_test_skill(
1193            tmp.path(),
1194            "unrelated-skill",
1195            &["some_other_tool"],
1196            &[],
1197            &[],
1198        );
1199
1200        let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1201
1202        let manifest_reg = ManifestRegistry::empty();
1203
1204        let scopes = ScopeConfig {
1205            scopes: vec!["tool:ca_sanctions_search".to_string()],
1206            sub: String::new(),
1207            expires_at: 0,
1208            rate_config: None,
1209        };
1210
1211        let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1212        assert_eq!(resolved.len(), 1);
1213        assert_eq!(resolved[0].name, "sanctions-skill");
1214    }
1215
1216    #[test]
1217    fn test_resolve_skills_with_dependencies() {
1218        let tmp = tempfile::tempdir().unwrap();
1219
1220        // Create skill-a that depends on skill-b
1221        let dir_a = tmp.path().join("skill-a");
1222        fs::create_dir_all(&dir_a).unwrap();
1223        fs::write(
1224            dir_a.join("skill.toml"),
1225            r#"[skill]
1226name = "skill-a"
1227description = "Skill A"
1228tools = ["tool_a"]
1229depends_on = ["skill-b"]
1230"#,
1231        )
1232        .unwrap();
1233        fs::write(dir_a.join("SKILL.md"), "# Skill A").unwrap();
1234
1235        // Create skill-b (dependency)
1236        let dir_b = tmp.path().join("skill-b");
1237        fs::create_dir_all(&dir_b).unwrap();
1238        fs::write(
1239            dir_b.join("skill.toml"),
1240            r#"[skill]
1241name = "skill-b"
1242description = "Skill B"
1243tools = ["tool_b"]
1244"#,
1245        )
1246        .unwrap();
1247        fs::write(dir_b.join("SKILL.md"), "# Skill B").unwrap();
1248
1249        let skill_reg = SkillRegistry::load(tmp.path()).unwrap();
1250
1251        let manifest_tmp = tempfile::tempdir().unwrap();
1252        fs::create_dir_all(manifest_tmp.path()).unwrap();
1253        let manifest_reg = ManifestRegistry::load(manifest_tmp.path())
1254            .unwrap_or_else(|_| panic!("cannot load empty manifest dir"));
1255
1256        let scopes = ScopeConfig {
1257            scopes: vec!["tool:tool_a".to_string()],
1258            sub: String::new(),
1259            expires_at: 0,
1260            rate_config: None,
1261        };
1262
1263        let resolved = resolve_skills(&skill_reg, &manifest_reg, &scopes);
1264        // Should resolve both skill-a (via tool binding) and skill-b (via dependency)
1265        assert_eq!(resolved.len(), 2);
1266        let names: Vec<&str> = resolved.iter().map(|s| s.name.as_str()).collect();
1267        assert!(names.contains(&"skill-a"));
1268        assert!(names.contains(&"skill-b"));
1269    }
1270
1271    #[test]
1272    fn test_scaffold() {
1273        let toml = scaffold_skill_toml(
1274            "my-skill",
1275            &["tool_a".into(), "tool_b".into()],
1276            Some("provider_x"),
1277        );
1278        assert!(toml.contains("name = \"my-skill\""));
1279        assert!(toml.contains("\"tool_a\""));
1280        assert!(toml.contains("\"provider_x\""));
1281
1282        let md = scaffold_skill_md("my-cool-skill");
1283        assert!(md.contains("# My Cool Skill Skill"));
1284    }
1285
1286    #[test]
1287    fn test_build_skill_context() {
1288        let skill = SkillMeta {
1289            name: "test-skill".to_string(),
1290            version: "1.0.0".to_string(),
1291            description: "A test skill".to_string(),
1292            tools: vec!["tool_a".to_string(), "tool_b".to_string()],
1293            hint: Some("Use for testing".to_string()),
1294            suggests: vec!["other-skill".to_string()],
1295            ..Default::default()
1296        };
1297
1298        let ctx = build_skill_context(&[&skill]);
1299        assert!(ctx.contains("**test-skill**"));
1300        assert!(ctx.contains("A test skill"));
1301        assert!(ctx.contains("Use for testing"));
1302        assert!(ctx.contains("tool_a, tool_b"));
1303        assert!(ctx.contains("other-skill"));
1304    }
1305
1306    #[test]
1307    fn test_empty_directory() {
1308        let tmp = tempfile::tempdir().unwrap();
1309        let registry = SkillRegistry::load(tmp.path()).unwrap();
1310        assert_eq!(registry.skill_count(), 0);
1311    }
1312
1313    #[test]
1314    fn test_nonexistent_directory() {
1315        let registry = SkillRegistry::load(Path::new("/nonexistent/path")).unwrap();
1316        assert_eq!(registry.skill_count(), 0);
1317    }
1318}