Skip to main content

skill_registry/
lib.rs

1use crate::error::RegistryError;
2use skill_core::{ResourceType, Skill, SkillResource};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use tracing::{debug, info, warn};
6use walkdir::WalkDir;
7
8pub mod error;
9
10#[derive(Debug)]
11pub struct SkillRegistry {
12    skills: HashMap<String, Skill>,
13    skills_dir: PathBuf,
14}
15
16impl SkillRegistry {
17    pub fn new(skills_dir: PathBuf) -> Self {
18        Self {
19            skills: HashMap::new(),
20            skills_dir,
21        }
22    }
23
24    pub async fn load(&mut self) -> Result<(), RegistryError> {
25        self.skills.clear();
26
27        if !self.skills_dir.exists() {
28            warn!("Skills directory does not exist: {:?}", self.skills_dir);
29            return Ok(());
30        }
31
32        info!("Loading skills from: {:?}", self.skills_dir);
33
34        for entry in WalkDir::new(&self.skills_dir)
35            .max_depth(2)
36            .into_iter()
37            .filter_map(|e| e.ok())
38        {
39            let path = entry.path();
40            if path.file_name().map(|n| n == "SKILL.md").unwrap_or(false) {
41                if let Some(skill) = self.load_skill(path).await? {
42                    let id = skill.id.clone();
43                    debug!("Loaded skill: {} ({})", skill.name, id);
44                    self.skills.insert(id, skill);
45                }
46            }
47        }
48
49        info!("Loaded {} skills", self.skills.len());
50        Ok(())
51    }
52
53    async fn load_skill(&self, skill_md_path: &Path) -> Result<Option<Skill>, RegistryError> {
54        let skill_dir = skill_md_path
55            .parent()
56            .ok_or_else(|| RegistryError::InvalidPath("SKILL.md has no parent".into()))?;
57
58        let content = tokio::fs::read_to_string(skill_md_path).await?;
59
60        let (frontmatter, body) = parse_skill_md(&content)?;
61
62        let name = frontmatter
63            .get("name")
64            .and_then(|v| v.as_str())
65            .unwrap_or("Unnamed")
66            .to_string();
67
68        let description = frontmatter
69            .get("description")
70            .and_then(|v| v.as_str())
71            .unwrap_or("")
72            .to_string();
73
74        let triggers: Vec<String> = get_string_array(&frontmatter, "trigger");
75
76        let capabilities: Vec<String> = get_string_array(&frontmatter, "capabilities");
77
78        let id = slugify(&name);
79
80        let resources = self.load_resources(skill_dir).await?;
81
82        let skill = Skill::new(id, name, description, body, skill_dir.to_path_buf())
83            .with_triggers(triggers)
84            .with_capabilities(capabilities)
85            .with_resources(resources);
86
87        Ok(Some(skill))
88    }
89
90    async fn load_resources(&self, skill_dir: &Path) -> Result<Vec<SkillResource>, RegistryError> {
91        let mut resources = Vec::new();
92
93        let scripts_dir = skill_dir.join("scripts");
94        if scripts_dir.is_dir() {
95            for entry in WalkDir::new(&scripts_dir)
96                .into_iter()
97                .filter_map(|e| e.ok())
98                .filter(|e| e.file_type().is_file())
99            {
100                resources.push(SkillResource {
101                    name: entry.file_name().to_string_lossy().to_string(),
102                    path: entry.path().to_path_buf(),
103                    resource_type: ResourceType::Script,
104                });
105            }
106        }
107
108        let references_dir = skill_dir.join("references");
109        if references_dir.is_dir() {
110            for entry in WalkDir::new(&references_dir)
111                .into_iter()
112                .filter_map(|e| e.ok())
113                .filter(|e| e.file_type().is_file())
114            {
115                resources.push(SkillResource {
116                    name: entry.file_name().to_string_lossy().to_string(),
117                    path: entry.path().to_path_buf(),
118                    resource_type: ResourceType::Reference,
119                });
120            }
121        }
122
123        Ok(resources)
124    }
125
126    pub fn get(&self, id: &str) -> Option<&Skill> {
127        self.skills.get(id)
128    }
129
130    pub fn get_all(&self) -> Vec<&Skill> {
131        self.skills.values().collect()
132    }
133
134    pub fn count(&self) -> usize {
135        self.skills.len()
136    }
137
138    pub fn skills_dir(&self) -> &Path {
139        &self.skills_dir
140    }
141}
142
143fn parse_skill_md(content: &str) -> Result<(serde_yaml::Value, String), RegistryError> {
144    let trimmed = content.trim();
145
146    if !trimmed.starts_with("---") {
147        return Ok((serde_yaml::Value::Null, trimmed.to_string()));
148    }
149
150    let end_marker = trimmed[3..]
151        .find("---")
152        .ok_or_else(|| RegistryError::ParseError("Missing closing ---".into()))?;
153
154    let frontmatter_str = &trimmed[3..3 + end_marker];
155    let body = trimmed[3 + end_marker + 3..].trim().to_string();
156
157    let frontmatter: serde_yaml::Value = serde_yaml::from_str(frontmatter_str)
158        .map_err(|e| RegistryError::ParseError(e.to_string()))?;
159
160    Ok((frontmatter, body))
161}
162
163fn get_string_array(value: &serde_yaml::Value, key: &str) -> Vec<String> {
164    value
165        .get(key)
166        .and_then(|v| {
167            if let Some(arr) = v.as_sequence() {
168                Some(
169                    arr.iter()
170                        .filter_map(|item| item.as_str().map(String::from))
171                        .collect(),
172                )
173            } else if let Some(s) = v.as_str() {
174                Some(vec![s.to_string()])
175            } else {
176                None
177            }
178        })
179        .unwrap_or_default()
180}
181
182fn slugify(s: &str) -> String {
183    s.to_lowercase()
184        .chars()
185        .map(|c| if c.is_alphanumeric() { c } else { '-' })
186        .collect::<String>()
187        .split('-')
188        .filter(|s| !s.is_empty())
189        .collect::<Vec<_>>()
190        .join("-")
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn test_slugify() {
199        assert_eq!(slugify("Hello World"), "hello-world");
200        assert_eq!(slugify("RSS Fetcher"), "rss-fetcher");
201    }
202
203    #[test]
204    fn test_parse_skill_md_without_frontmatter() {
205        let content = "# Hello\n\nThis is the body.";
206        let (fm, body) = parse_skill_md(content).unwrap();
207        assert_eq!(fm, serde_yaml::Value::Null);
208        assert!(body.contains("Hello"));
209    }
210}