Skip to main content

heartbit_core/skill/
registry.rs

1//! [`SkillRegistry`] — discover `SKILL.md` skills across directories and build a
2//! Level-1 catalog for progressive disclosure.
3//!
4//! Discovery walks each search directory's immediate children for a
5//! `<child>/SKILL.md`, parses + validates it ([`SkillManifest`]), and registers
6//! it by name. Earlier directories win on a name clash (the caller orders search
7//! dirs project-first, personal-last), so a project skill shadows a personal one
8//! of the same name. A skill that fails to parse is skipped (it must not break
9//! discovery of the others); the failure is recorded in [`SkillRegistry::errors`].
10
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13
14use super::manifest::SkillManifest;
15
16/// A discovered skill: its validated manifest and the directory it lives in.
17#[derive(Debug, Clone)]
18pub struct DiscoveredSkill {
19    /// The parsed, validated `SKILL.md` manifest.
20    pub manifest: SkillManifest,
21    /// The skill's root directory (contains `SKILL.md` + any bundled files).
22    pub dir: PathBuf,
23}
24
25/// A registry of discovered skills, keyed by name, plus any discovery errors.
26#[derive(Debug, Default, Clone)]
27pub struct SkillRegistry {
28    skills: BTreeMap<String, DiscoveredSkill>,
29    /// Non-fatal discovery errors (`(dir, message)`), e.g. a malformed SKILL.md.
30    pub errors: Vec<(PathBuf, String)>,
31}
32
33impl SkillRegistry {
34    /// Build an empty registry.
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// Discover skills across `search_dirs` (highest precedence first). Each
40    /// directory's immediate subdirectories are scanned for a `SKILL.md`. A name
41    /// already registered by an earlier (higher-precedence) directory is not
42    /// overwritten. Malformed skills are skipped and recorded in
43    /// [`errors`](Self::errors).
44    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    /// Scan one directory's immediate children for `<child>/SKILL.md`.
57    fn discover_dir(&mut self, dir: &Path) {
58        let entries = match std::fs::read_dir(dir) {
59            Ok(e) => e,
60            Err(_) => return, // a missing/unreadable search dir is not an error
61        };
62        // Sort for deterministic discovery order within a directory.
63        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            // Skip a name already claimed by a higher-precedence directory.
80            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    /// Number of discovered skills.
102    pub fn len(&self) -> usize {
103        self.skills.len()
104    }
105
106    /// Whether the registry has no skills.
107    pub fn is_empty(&self) -> bool {
108        self.skills.is_empty()
109    }
110
111    /// Look up a discovered skill by name.
112    pub fn get(&self, name: &str) -> Option<&DiscoveredSkill> {
113        self.skills.get(name)
114    }
115
116    /// Discovered skills, in name order.
117    pub fn skills(&self) -> impl Iterator<Item = &DiscoveredSkill> {
118        self.skills.values()
119    }
120
121    /// Render the Level-1 catalog: one `name: description` line per skill, in
122    /// name order. Returns an empty string when no skills are registered (so the
123    /// caller can inject nothing rather than an empty header).
124    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    /// Render the catalog wrapped in a system-prompt section, or `None` if empty.
133    /// The section instructs the model to invoke the `skill` tool by name to load
134    /// a skill's full instructions on demand (progressive disclosure level 2).
135    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    /// Create `root/<name>/SKILL.md` with the given content.
153    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        // Project dir first => higher precedence => wins.
201        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        // name != dir_name => invalid manifest.
214        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}