Skip to main content

crabtalk_runtime/skill/
loader.rs

1//! Skill markdown loading.
2
3use crate::skill::{Skill, SkillRegistry};
4use serde::Deserialize;
5use std::{collections::BTreeMap, path::Path};
6use wcore::utils::split_yaml_frontmatter;
7
8/// YAML frontmatter deserialization target for SKILL.md files.
9#[derive(Debug, Deserialize)]
10struct SkillFrontmatter {
11    name: String,
12    #[serde(default)]
13    description: String,
14    #[serde(default)]
15    license: Option<String>,
16    #[serde(default)]
17    compatibility: Option<String>,
18    #[serde(default)]
19    metadata: BTreeMap<String, String>,
20    #[serde(default, rename = "allowed-tools")]
21    allowed_tools: Vec<String>,
22}
23
24/// Parse a SKILL.md file (YAML frontmatter + Markdown body) into a [`Skill`].
25pub fn parse_skill_md(content: &str) -> anyhow::Result<Skill> {
26    let (frontmatter, body) = split_yaml_frontmatter(content)?;
27    let fm: SkillFrontmatter = serde_yml::from_str(frontmatter)?;
28
29    let metadata = fm.metadata;
30
31    Ok(Skill {
32        name: fm.name,
33        description: fm.description,
34        license: fm.license,
35        compatibility: fm.compatibility,
36        metadata,
37        allowed_tools: fm.allowed_tools,
38        body: body.to_owned(),
39    })
40}
41
42/// Load skills by searching for `SKILL.md` files in subdirectories.
43pub fn load_skills_dir(path: impl AsRef<Path>) -> anyhow::Result<SkillRegistry> {
44    let path = path.as_ref();
45    let mut registry = SkillRegistry::new();
46    scan_skills(path, &mut registry)?;
47    Ok(registry)
48}
49
50fn scan_skills(dir: &Path, registry: &mut SkillRegistry) -> anyhow::Result<()> {
51    let entries = match std::fs::read_dir(dir) {
52        Ok(e) => e,
53        Err(_) => return Ok(()),
54    };
55
56    for entry in entries {
57        let entry = entry?;
58        let entry_path = entry.path();
59        if !entry_path.is_dir() {
60            continue;
61        }
62
63        if entry
64            .file_name()
65            .to_str()
66            .is_some_and(|n| n.starts_with('.'))
67        {
68            continue;
69        }
70
71        let skill_file = entry_path.join("SKILL.md");
72        if skill_file.exists() {
73            let content = std::fs::read_to_string(&skill_file)
74                .map_err(|e| anyhow::anyhow!("failed to read {}: {e}", skill_file.display()))?;
75            match parse_skill_md(&content) {
76                Ok(skill) => registry.add(skill),
77                Err(e) => {
78                    tracing::warn!("failed to parse {}: {e}", skill_file.display());
79                }
80            }
81        } else {
82            scan_skills(&entry_path, registry)?;
83        }
84    }
85
86    Ok(())
87}