Skip to main content

claudectl_core/
skills.rs

1// Skill discovery: scans the standard Claude Code skill locations and returns
2// metadata parsed from each skill's YAML frontmatter.
3//
4// Skills can live in three shapes:
5//   1. ~/.claude/skills/<name>/SKILL.md            (user-installed)
6//   2. ~/.claude/plugins/<plugin>/skills/<name>/   (shipped by a plugin)
7//   3. <cwd>/.claude/skills/<name>/SKILL.md        (project-local)
8//
9// We also accept the flat form ~/.claude/skills/<name>.md for compatibility
10// with older skill layouts.
11
12use std::collections::HashMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use crate::helpers::dirs_home;
17
18/// Where a discovered skill came from.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum SkillSource {
21    /// `~/.claude/skills/...`
22    User,
23    /// `~/.claude/plugins/<plugin>/skills/...`
24    Plugin,
25    /// `<cwd>/.claude/skills/...`
26    Project,
27}
28
29impl SkillSource {
30    pub fn label(&self) -> &'static str {
31        match self {
32            Self::User => "user",
33            Self::Plugin => "plugin",
34            Self::Project => "project",
35        }
36    }
37}
38
39#[derive(Debug, Clone)]
40pub struct DiscoveredSkill {
41    /// Skill name (from frontmatter `name`, falls back to directory name).
42    pub name: String,
43    /// One-line description from frontmatter (empty if not present).
44    pub description: String,
45    /// Path to the skill's markdown file (the one we'd share).
46    pub path: PathBuf,
47    /// Where it was discovered.
48    pub source: SkillSource,
49    /// Plugin name, if `source == Plugin`.
50    pub plugin: Option<String>,
51    /// File size in bytes.
52    pub size_bytes: u64,
53}
54
55impl DiscoveredSkill {
56    /// Stable key used to compare against shared knowledge units. Matches
57    /// the semantic key shape in src/hive/mod.rs (`skill:<lowercased-name>`).
58    pub fn semantic_key(&self) -> String {
59        format!("skill:{}", self.name.to_lowercase().replace(' ', "-"))
60    }
61
62    /// True if the skill body is small enough to fit in a hive `Skill` unit.
63    pub fn within_share_limit(&self) -> bool {
64        // Mirrors `hive::MAX_SKILL_BYTES` (32 KiB) but we keep this module
65        // free of the hive feature gate so the TUI can list skills even when
66        // hive is disabled.
67        self.size_bytes <= 32 * 1024
68    }
69}
70
71/// Scan all known skill locations.
72///
73/// `project_root` is the directory used for project-local skills — pass the
74/// current working directory (or None to skip the project sweep).
75pub fn discover(project_root: Option<&Path>) -> Vec<DiscoveredSkill> {
76    let mut out = Vec::new();
77    let home = dirs_home();
78
79    scan_skill_root(
80        &home.join(".claude/skills"),
81        SkillSource::User,
82        None,
83        &mut out,
84    );
85
86    let plugins_root = home.join(".claude/plugins");
87    if let Ok(entries) = fs::read_dir(&plugins_root) {
88        for entry in entries.flatten() {
89            let plugin_dir = entry.path();
90            if !plugin_dir.is_dir() {
91                continue;
92            }
93            let plugin_name = plugin_dir
94                .file_name()
95                .and_then(|n| n.to_str())
96                .unwrap_or("")
97                .to_string();
98            scan_skill_root(
99                &plugin_dir.join("skills"),
100                SkillSource::Plugin,
101                Some(plugin_name),
102                &mut out,
103            );
104        }
105    }
106
107    if let Some(root) = project_root {
108        scan_skill_root(
109            &root.join(".claude/skills"),
110            SkillSource::Project,
111            None,
112            &mut out,
113        );
114    }
115
116    out.sort_by_key(|a| a.name.to_lowercase());
117    out
118}
119
120fn scan_skill_root(
121    root: &Path,
122    source: SkillSource,
123    plugin: Option<String>,
124    out: &mut Vec<DiscoveredSkill>,
125) {
126    let Ok(entries) = fs::read_dir(root) else {
127        return;
128    };
129    for entry in entries.flatten() {
130        let path = entry.path();
131        if path.is_dir() {
132            // Conventional layout: <dir>/SKILL.md (or skill.md)
133            let candidates = [path.join("SKILL.md"), path.join("skill.md")];
134            for candidate in &candidates {
135                if candidate.exists() {
136                    if let Some(skill) = load_skill(candidate, source, plugin.clone(), Some(&path))
137                    {
138                        out.push(skill);
139                    }
140                    break;
141                }
142            }
143        } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
144            // Flat form: ~/.claude/skills/<name>.md
145            if let Some(skill) = load_skill(&path, source, plugin.clone(), None) {
146                out.push(skill);
147            }
148        }
149    }
150}
151
152fn load_skill(
153    path: &Path,
154    source: SkillSource,
155    plugin: Option<String>,
156    dir_for_fallback_name: Option<&Path>,
157) -> Option<DiscoveredSkill> {
158    let body = fs::read_to_string(path).ok()?;
159    let fm = parse_frontmatter(&body);
160
161    let fallback_name = dir_for_fallback_name
162        .and_then(|d| d.file_name())
163        .or_else(|| path.file_stem())
164        .and_then(|n| n.to_str())
165        .unwrap_or("(unnamed)")
166        .to_string();
167
168    let name = fm.get("name").cloned().unwrap_or(fallback_name);
169    let description = fm.get("description").cloned().unwrap_or_default();
170
171    let size_bytes = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
172
173    Some(DiscoveredSkill {
174        name,
175        description,
176        path: path.to_path_buf(),
177        source,
178        plugin,
179        size_bytes,
180    })
181}
182
183/// Lightweight YAML frontmatter parser. Mirrors hive::cli::parse_frontmatter but
184/// kept local so this module has no feature-gated dependencies.
185fn parse_frontmatter(content: &str) -> HashMap<String, String> {
186    let mut map = HashMap::new();
187    let trimmed = content.trim_start();
188    if !trimmed.starts_with("---") {
189        return map;
190    }
191    let after_open = &trimmed[3..].trim_start_matches('\r');
192    let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
193    let Some(close_pos) = after_open.find("\n---") else {
194        return map;
195    };
196    let yaml = &after_open[..close_pos];
197    for line in yaml.lines() {
198        let line = line.trim();
199        if line.is_empty() || line.starts_with('#') {
200            continue;
201        }
202        if let Some((k, v)) = line.split_once(':') {
203            let key = k.trim().to_string();
204            let value = v.trim().trim_matches('"').trim_matches('\'').to_string();
205            if !key.is_empty() && !value.is_empty() {
206                map.insert(key, value);
207            }
208        }
209    }
210    map
211}
212
213/// Shared-status lookup against a set of semantic keys already present in the
214/// local hive store. Caller passes in the keys (lowercased) for skills that are
215/// already shared so we don't take a hive dependency here.
216pub fn is_shared(skill: &DiscoveredSkill, shared_keys: &std::collections::HashSet<String>) -> bool {
217    shared_keys.contains(&skill.semantic_key())
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use std::io::Write;
224    use tempfile::TempDir;
225
226    fn write_skill(dir: &Path, name: &str, frontmatter: &str, body: &str) -> PathBuf {
227        let skill_dir = dir.join(name);
228        fs::create_dir_all(&skill_dir).unwrap();
229        let path = skill_dir.join("SKILL.md");
230        let mut f = fs::File::create(&path).unwrap();
231        writeln!(f, "---\n{frontmatter}\n---").unwrap();
232        writeln!(f, "{body}").unwrap();
233        path
234    }
235
236    #[test]
237    fn parses_frontmatter_and_falls_back_to_dir_name() {
238        let tmp = TempDir::new().unwrap();
239        let root = tmp.path().join("skills");
240        fs::create_dir_all(&root).unwrap();
241        write_skill(
242            &root,
243            "demo-skill",
244            "name: Demo Skill\ndescription: A demo.\nversion: 1.0",
245            "body",
246        );
247        write_skill(&root, "no-frontmatter", "", "just body");
248
249        let mut out = Vec::new();
250        scan_skill_root(&root, SkillSource::User, None, &mut out);
251        out.sort_by(|a, b| a.name.cmp(&b.name));
252
253        assert_eq!(out.len(), 2);
254        assert_eq!(out[0].name, "Demo Skill");
255        assert_eq!(out[0].description, "A demo.");
256        assert_eq!(out[0].source, SkillSource::User);
257        assert_eq!(out[1].name, "no-frontmatter");
258        assert_eq!(out[1].description, "");
259    }
260
261    #[test]
262    fn semantic_key_matches_hive_shape() {
263        let tmp = TempDir::new().unwrap();
264        let root = tmp.path().join("skills");
265        fs::create_dir_all(&root).unwrap();
266        write_skill(&root, "x", "name: Session Monitoring\n", "");
267        let mut out = Vec::new();
268        scan_skill_root(&root, SkillSource::User, None, &mut out);
269        assert_eq!(out[0].semantic_key(), "skill:session-monitoring");
270    }
271
272    #[test]
273    fn within_share_limit_respects_32k() {
274        let mut skill = DiscoveredSkill {
275            name: "a".into(),
276            description: String::new(),
277            path: PathBuf::new(),
278            source: SkillSource::User,
279            plugin: None,
280            size_bytes: 0,
281        };
282        assert!(skill.within_share_limit());
283        skill.size_bytes = 32 * 1024;
284        assert!(skill.within_share_limit());
285        skill.size_bytes = 32 * 1024 + 1;
286        assert!(!skill.within_share_limit());
287    }
288}