heartbit_core/skill/
registry.rs1use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13
14use super::manifest::SkillManifest;
15
16#[derive(Debug, Clone)]
18pub struct DiscoveredSkill {
19 pub manifest: SkillManifest,
21 pub dir: PathBuf,
23}
24
25#[derive(Debug, Default, Clone)]
27pub struct SkillRegistry {
28 skills: BTreeMap<String, DiscoveredSkill>,
29 pub errors: Vec<(PathBuf, String)>,
31}
32
33impl SkillRegistry {
34 pub fn new() -> Self {
36 Self::default()
37 }
38
39 pub fn discover<I, P>(search_dirs: I) -> Self
45 where
46 I: IntoIterator<Item = P>,
47 P: AsRef<Path>,
48 {
49 let mut registry = SkillRegistry::new();
50 for dir in search_dirs {
51 registry.discover_dir(dir.as_ref());
52 }
53 registry
54 }
55
56 fn discover_dir(&mut self, dir: &Path) {
58 let entries = match std::fs::read_dir(dir) {
59 Ok(e) => e,
60 Err(_) => return, };
62 let mut child_dirs: Vec<PathBuf> = entries
64 .flatten()
65 .map(|e| e.path())
66 .filter(|p| p.is_dir())
67 .collect();
68 child_dirs.sort();
69
70 for child in child_dirs {
71 let skill_file = child.join("SKILL.md");
72 if !skill_file.is_file() {
73 continue;
74 }
75 let dir_name = match child.file_name().and_then(|n| n.to_str()) {
76 Some(n) => n.to_string(),
77 None => continue,
78 };
79 if self.skills.contains_key(&dir_name) {
81 continue;
82 }
83 match std::fs::read_to_string(&skill_file) {
84 Ok(content) => match SkillManifest::parse(&content, &dir_name) {
85 Ok(manifest) => {
86 self.skills.insert(
87 manifest.name.clone(),
88 DiscoveredSkill {
89 manifest,
90 dir: child.clone(),
91 },
92 );
93 }
94 Err(e) => self.errors.push((child.clone(), e.to_string())),
95 },
96 Err(e) => self.errors.push((child.clone(), e.to_string())),
97 }
98 }
99 }
100
101 pub fn len(&self) -> usize {
103 self.skills.len()
104 }
105
106 pub fn is_empty(&self) -> bool {
108 self.skills.is_empty()
109 }
110
111 pub fn get(&self, name: &str) -> Option<&DiscoveredSkill> {
113 self.skills.get(name)
114 }
115
116 pub fn skills(&self) -> impl Iterator<Item = &DiscoveredSkill> {
118 self.skills.values()
119 }
120
121 pub fn catalog(&self) -> String {
125 self.skills
126 .values()
127 .map(|s| s.manifest.catalog_line())
128 .collect::<Vec<_>>()
129 .join("\n")
130 }
131
132 pub fn system_prompt_section(&self) -> Option<String> {
136 if self.skills.is_empty() {
137 return None;
138 }
139 Some(format!(
140 "## Available skills\n\nThe following skills are available. When a task \
141 matches a skill's description, invoke the `skill` tool with its name to \
142 load its full instructions before proceeding:\n\n{}",
143 self.catalog()
144 ))
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 fn write_skill(root: &Path, name: &str, content: &str) {
154 let dir = root.join(name);
155 std::fs::create_dir_all(&dir).unwrap();
156 std::fs::write(dir.join("SKILL.md"), content).unwrap();
157 }
158
159 fn skill_md(name: &str, description: &str) -> String {
160 format!("---\nname: {name}\ndescription: {description}\n---\n# {name}\n\nbody\n")
161 }
162
163 #[test]
164 fn discovers_valid_skills() {
165 let tmp = tempfile::tempdir().unwrap();
166 write_skill(
167 tmp.path(),
168 "alpha",
169 &skill_md("alpha", "Does alpha things."),
170 );
171 write_skill(tmp.path(), "beta", &skill_md("beta", "Does beta things."));
172
173 let reg = SkillRegistry::discover([tmp.path()]);
174 assert_eq!(reg.len(), 2);
175 assert!(reg.get("alpha").is_some());
176 assert!(reg.get("beta").is_some());
177 assert!(reg.errors.is_empty());
178 }
179
180 #[test]
181 fn catalog_is_name_colon_description_sorted() {
182 let tmp = tempfile::tempdir().unwrap();
183 write_skill(tmp.path(), "zeta", &skill_md("zeta", "Z."));
184 write_skill(tmp.path(), "alpha", &skill_md("alpha", "A."));
185 let reg = SkillRegistry::discover([tmp.path()]);
186 assert_eq!(reg.catalog(), "alpha: A.\nzeta: Z.");
187 }
188
189 #[test]
190 fn project_dir_shadows_personal_on_name_clash() {
191 let project = tempfile::tempdir().unwrap();
192 let personal = tempfile::tempdir().unwrap();
193 write_skill(project.path(), "dup", &skill_md("dup", "PROJECT version."));
194 write_skill(
195 personal.path(),
196 "dup",
197 &skill_md("dup", "PERSONAL version."),
198 );
199
200 let reg = SkillRegistry::discover([project.path(), personal.path()]);
202 assert_eq!(reg.len(), 1);
203 assert_eq!(
204 reg.get("dup").unwrap().manifest.description,
205 "PROJECT version."
206 );
207 }
208
209 #[test]
210 fn malformed_skill_is_skipped_not_fatal() {
211 let tmp = tempfile::tempdir().unwrap();
212 write_skill(tmp.path(), "good", &skill_md("good", "Fine."));
213 write_skill(tmp.path(), "bad", &skill_md("wrong-name", "Broken."));
215
216 let reg = SkillRegistry::discover([tmp.path()]);
217 assert_eq!(reg.len(), 1, "the good skill is still discovered");
218 assert!(reg.get("good").is_some());
219 assert_eq!(reg.errors.len(), 1, "the bad skill is recorded as an error");
220 assert!(reg.errors[0].1.contains("directory name"));
221 }
222
223 #[test]
224 fn missing_search_dir_is_ignored() {
225 let reg = SkillRegistry::discover([Path::new("/nonexistent/skills/dir")]);
226 assert!(reg.is_empty());
227 assert!(reg.errors.is_empty());
228 }
229
230 #[test]
231 fn dir_without_skill_md_is_ignored() {
232 let tmp = tempfile::tempdir().unwrap();
233 std::fs::create_dir_all(tmp.path().join("not-a-skill")).unwrap();
234 std::fs::write(tmp.path().join("not-a-skill").join("README.md"), "hi").unwrap();
235 let reg = SkillRegistry::discover([tmp.path()]);
236 assert!(reg.is_empty());
237 }
238
239 #[test]
240 fn empty_registry_has_no_system_prompt_section() {
241 let reg = SkillRegistry::new();
242 assert!(reg.system_prompt_section().is_none());
243 assert_eq!(reg.catalog(), "");
244 }
245
246 #[test]
247 fn system_prompt_section_lists_skills_and_mentions_skill_tool() {
248 let tmp = tempfile::tempdir().unwrap();
249 write_skill(tmp.path(), "alpha", &skill_md("alpha", "Does alpha."));
250 let reg = SkillRegistry::discover([tmp.path()]);
251 let section = reg.system_prompt_section().expect("non-empty");
252 assert!(section.contains("alpha: Does alpha."));
253 assert!(section.contains("`skill` tool"));
254 }
255}