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}