Skip to main content

claude_wrapper/
skills.rs

1//! Read-side access to Claude Code's on-disk **skill** definitions.
2//!
3//! Claude Code resolves user-level skills from
4//! `~/.claude/skills/<name>/SKILL.md`. Unlike agents (which are flat
5//! `.md` files), each skill is a *directory* containing a `SKILL.md`
6//! plus optional bundled assets (`scripts/`, `reference/`, etc.).
7//! The frontmatter on `SKILL.md` carries the skill's metadata (name,
8//! description); the body is the skill's instructions.
9//!
10//! This module is read-only on purpose -- mutations (create / update
11//! / delete) are deferred. Creating a skill is more involved than a
12//! file write because it implies directory layout and optional
13//! scaffold assets.
14//!
15//! Two levels of granularity:
16//!
17//! - [`SkillsRoot::list`] -- enumerate every skill at the root with
18//!   summary metadata (name, description, dir path, has_assets).
19//! - [`SkillsRoot::get`] -- read one skill's full record including
20//!   the instructions body.
21//!
22//! # Frontmatter format
23//!
24//! Real-world skills look like:
25//!
26//! ```text
27//! ---
28//! name: recall
29//! description: Search mente for memories by topic, text, tags, or ranked search
30//! ---
31//!
32//! # Search mente for memories
33//! ...
34//! ```
35//!
36//! The parser is permissive: only `name` and `description` are typed.
37//! Any other `key: value` pairs land in [`Skill::extra`] so unknown
38//! future keys survive a round trip. Frontmatter is optional -- a
39//! body-only `SKILL.md` parses fine, with `name` defaulting to the
40//! directory stem.
41//!
42//! # Example
43//!
44//! ```no_run
45//! use claude_wrapper::skills::SkillsRoot;
46//!
47//! # fn example() -> claude_wrapper::Result<()> {
48//! let root = SkillsRoot::home()?;
49//! for summary in root.list()? {
50//!     println!("{}: {}", summary.name, summary.description.as_deref().unwrap_or(""));
51//! }
52//! let skill = root.get("recall")?;
53//! println!("{}", skill.body);
54//! # Ok(()) }
55//! ```
56//!
57//! # Stem, name, directory
58//!
59//! By convention a skill's `name` matches its directory name:
60//! `~/.claude/skills/recall/SKILL.md` carries `name: recall`. The
61//! two can diverge -- the parser keeps both. [`SkillsRoot::get`]
62//! looks up by directory stem (because that's what the filesystem
63//! indexes), not by the frontmatter `name`.
64//!
65//! # Pointing at a different root
66//!
67//! The default is `~/.claude/skills`. Pass an explicit path to
68//! [`SkillsRoot::at`] to point at a different directory -- a tempdir
69//! in tests, a non-default Claude Code install. The on-disk layout
70//! (`<root>/<stem>/SKILL.md`) is the same regardless of root.
71
72use std::collections::BTreeMap;
73use std::fs;
74use std::path::{Path, PathBuf};
75
76use serde::Serialize;
77
78use crate::artifacts::split_frontmatter;
79use crate::error::{Error, Result};
80
81/// Root directory of Claude Code's user-level skill definitions.
82/// Defaults to `~/.claude/skills`; override with [`SkillsRoot::at`]
83/// for tests or non-default installs.
84#[derive(Debug, Clone)]
85pub struct SkillsRoot {
86    path: PathBuf,
87}
88
89impl SkillsRoot {
90    /// Resolve the default `~/.claude/skills`. Errors if `$HOME`
91    /// (or the platform-specific user home) cannot be determined.
92    pub fn home() -> Result<Self> {
93        let home = home_dir().ok_or_else(|| Error::Artifacts {
94            message: "could not determine user home directory".to_string(),
95        })?;
96        Ok(Self {
97            path: home.join(".claude").join("skills"),
98        })
99    }
100
101    /// Use a specific path as the skills root. Useful for tests
102    /// (point at a tempdir) and for non-default installs.
103    pub fn at(path: impl Into<PathBuf>) -> Self {
104        Self { path: path.into() }
105    }
106
107    /// The configured root directory.
108    pub fn path(&self) -> &Path {
109        &self.path
110    }
111
112    /// List every skill directory at the root, sorted by directory
113    /// stem.
114    ///
115    /// A "skill" is any direct child directory of the root that
116    /// contains a `SKILL.md`. Directories without `SKILL.md` and
117    /// non-directory entries are ignored. Returns an empty vec if
118    /// the root itself doesn't exist (a fresh Claude Code install
119    /// with no user skills). Directories whose `SKILL.md` fails to
120    /// parse contribute a tracing warning and are skipped rather
121    /// than failing the whole listing.
122    pub fn list(&self) -> Result<Vec<SkillSummary>> {
123        let entries = match fs::read_dir(&self.path) {
124            Ok(it) => it,
125            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
126            Err(e) => return Err(e.into()),
127        };
128
129        let mut out = Vec::new();
130        for entry in entries.flatten() {
131            let dir = entry.path();
132            if !dir.is_dir() {
133                continue;
134            }
135            let stem = match dir.file_name().and_then(|s| s.to_str()) {
136                Some(s) => s.to_string(),
137                None => continue,
138            };
139            let skill_md = dir.join("SKILL.md");
140            if !skill_md.is_file() {
141                continue;
142            }
143            match parse_skill_file(&skill_md, &dir, &stem) {
144                Ok(skill) => out.push(SkillSummary::from_skill(&skill)),
145                Err(e) => tracing::warn!(?skill_md, "skipping skill: {e}"),
146            }
147        }
148        out.sort_by(|a, b| a.dir_stem.cmp(&b.dir_stem));
149        Ok(out)
150    }
151
152    /// Read one skill by directory stem (i.e. the basename of the
153    /// `<stem>/` directory under the root). Errors if no such
154    /// directory exists, it has no `SKILL.md`, or the file fails to
155    /// parse.
156    pub fn get(&self, dir_stem: &str) -> Result<Skill> {
157        let dir = self.path.join(dir_stem);
158        let skill_md = dir.join("SKILL.md");
159        if !skill_md.is_file() {
160            return Err(Error::Artifacts {
161                message: format!("no skill at {}", dir.display()),
162            });
163        }
164        parse_skill_file(&skill_md, &dir, dir_stem)
165    }
166}
167
168/// Lightweight metadata for one skill, returned by
169/// [`SkillsRoot::list`]. Strips the body to keep listings cheap.
170#[derive(Debug, Clone, Serialize)]
171pub struct SkillSummary {
172    /// Directory stem (the basename of `<stem>/` under the root).
173    /// The canonical handle for lookup.
174    pub dir_stem: String,
175    /// Frontmatter `name` if present; falls back to `dir_stem`.
176    pub name: String,
177    /// Frontmatter `description` if present.
178    pub description: Option<String>,
179    /// Absolute path to the skill's directory.
180    pub dir_path: PathBuf,
181    /// Absolute path to the source `SKILL.md`.
182    pub file_path: PathBuf,
183    /// `SKILL.md` size in bytes; useful for cheap UI hints.
184    pub size_bytes: u64,
185    /// True if the skill directory contains sibling files or
186    /// subdirectories beyond `SKILL.md` (e.g. `scripts/`,
187    /// `reference/`). Listing the sibling paths themselves is
188    /// deferred; callers that need the inventory can stat the
189    /// directory directly via [`Self::dir_path`].
190    pub has_assets: bool,
191}
192
193impl SkillSummary {
194    fn from_skill(s: &Skill) -> Self {
195        let size_bytes = fs::metadata(&s.file_path)
196            .map(|m| m.len())
197            .unwrap_or_default();
198        Self {
199            dir_stem: s.dir_stem.clone(),
200            name: s.name.clone(),
201            description: s.description.clone(),
202            dir_path: s.dir_path.clone(),
203            file_path: s.file_path.clone(),
204            size_bytes,
205            has_assets: s.has_assets,
206        }
207    }
208}
209
210/// Full skill record returned by [`SkillsRoot::get`].
211#[derive(Debug, Clone, Serialize)]
212pub struct Skill {
213    /// Directory stem (the basename of `<stem>/` under the root).
214    /// The canonical handle for lookup.
215    pub dir_stem: String,
216    /// Frontmatter `name` if present; falls back to `dir_stem`.
217    pub name: String,
218    /// Frontmatter `description` if present.
219    pub description: Option<String>,
220    /// Absolute path to the skill's directory.
221    pub dir_path: PathBuf,
222    /// Absolute path to the source `SKILL.md`.
223    pub file_path: PathBuf,
224    /// Markdown body after the frontmatter block (trimmed of
225    /// leading/trailing blank lines).
226    pub body: String,
227    /// Frontmatter keys other than the typed ones. Preserves
228    /// unknown future fields verbatim as raw strings.
229    pub extra: BTreeMap<String, String>,
230    /// True if the skill directory contains sibling files or
231    /// subdirectories beyond `SKILL.md`. See
232    /// [`SkillSummary::has_assets`] for the deferred-inventory
233    /// rationale.
234    pub has_assets: bool,
235}
236
237fn parse_skill_file(file_path: &Path, dir_path: &Path, dir_stem: &str) -> Result<Skill> {
238    let raw = fs::read_to_string(file_path)?;
239    let (frontmatter, body) = split_frontmatter(&raw);
240
241    let mut name = dir_stem.to_string();
242    let mut description = None;
243    let mut extra = BTreeMap::new();
244
245    if let Some(fm) = frontmatter {
246        for line in fm.lines() {
247            let trimmed = line.trim();
248            if trimmed.is_empty() {
249                continue;
250            }
251            let Some((k, v)) = trimmed.split_once(':') else {
252                continue;
253            };
254            let key = k.trim();
255            let value = v.trim().to_string();
256            match key {
257                "name" if !value.is_empty() => name = value,
258                "description" if !value.is_empty() => description = Some(value),
259                _ if !key.is_empty() => {
260                    extra.insert(key.to_string(), value);
261                }
262                _ => {}
263            }
264        }
265    }
266
267    Ok(Skill {
268        dir_stem: dir_stem.to_string(),
269        name,
270        description,
271        dir_path: dir_path.to_path_buf(),
272        file_path: file_path.to_path_buf(),
273        body: body.trim().to_string(),
274        extra,
275        has_assets: directory_has_assets(dir_path),
276    })
277}
278
279fn directory_has_assets(dir: &Path) -> bool {
280    let entries = match fs::read_dir(dir) {
281        Ok(it) => it,
282        Err(_) => return false,
283    };
284    for entry in entries.flatten() {
285        let name = entry.file_name();
286        // Skip the canonical SKILL.md itself; anything else is an asset.
287        if name == "SKILL.md" {
288            continue;
289        }
290        return true;
291    }
292    false
293}
294
295fn home_dir() -> Option<PathBuf> {
296    if let Ok(h) = std::env::var("HOME")
297        && !h.is_empty()
298    {
299        return Some(PathBuf::from(h));
300    }
301    if let Ok(h) = std::env::var("USERPROFILE")
302        && !h.is_empty()
303    {
304        return Some(PathBuf::from(h));
305    }
306    None
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use std::io::Write;
313
314    fn write_skill(root: &Path, stem: &str, contents: &str) -> PathBuf {
315        let dir = root.join(stem);
316        fs::create_dir_all(&dir).expect("create skill dir");
317        let path = dir.join("SKILL.md");
318        let mut f = fs::File::create(&path).expect("create SKILL.md");
319        f.write_all(contents.as_bytes()).expect("write SKILL.md");
320        path
321    }
322
323    fn fixture_root() -> tempfile::TempDir {
324        let tmp = tempfile::tempdir().expect("tempdir");
325        write_skill(
326            tmp.path(),
327            "recall",
328            "---\nname: recall\ndescription: Search mente for memories\n---\n\nSearch for: $ARGUMENTS\n",
329        );
330        write_skill(
331            tmp.path(),
332            "no-frontmatter",
333            "Just a body, no frontmatter at all.\n",
334        );
335        write_skill(
336            tmp.path(),
337            "weird",
338            "---\nname: weird\ndescription: has extras\ncustom_key: custom_value\n---\nbody\n",
339        );
340        // A skill with bundled assets (scripts/).
341        write_skill(
342            tmp.path(),
343            "bundled",
344            "---\nname: bundled\ndescription: has scripts\n---\nbody\n",
345        );
346        let scripts = tmp.path().join("bundled").join("scripts");
347        fs::create_dir_all(&scripts).expect("create scripts dir");
348        fs::write(scripts.join("helper.sh"), "#!/bin/sh\n").expect("write helper");
349        // A directory without SKILL.md should be ignored.
350        let bogus = tmp.path().join("not-a-skill");
351        fs::create_dir_all(&bogus).expect("create bogus");
352        fs::write(bogus.join("README.md"), "not a skill").expect("write README");
353        // A non-directory entry at the root should be ignored.
354        fs::write(tmp.path().join("loose-file.md"), "ignore me").expect("write loose");
355        tmp
356    }
357
358    #[test]
359    fn list_returns_only_skill_dirs_sorted() {
360        let tmp = fixture_root();
361        let root = SkillsRoot::at(tmp.path());
362        let skills = root.list().expect("list");
363        let stems: Vec<&str> = skills.iter().map(|s| s.dir_stem.as_str()).collect();
364        assert_eq!(stems, ["bundled", "no-frontmatter", "recall", "weird"]);
365    }
366
367    #[test]
368    fn list_missing_root_returns_empty() {
369        let tmp = tempfile::tempdir().expect("tempdir");
370        let root = SkillsRoot::at(tmp.path().join("does-not-exist"));
371        let skills = root.list().expect("list");
372        assert!(skills.is_empty());
373    }
374
375    #[test]
376    fn list_typed_metadata() {
377        let tmp = fixture_root();
378        let root = SkillsRoot::at(tmp.path());
379        let skills = root.list().expect("list");
380        let recall = skills
381            .iter()
382            .find(|s| s.dir_stem == "recall")
383            .expect("recall");
384        assert_eq!(recall.name, "recall");
385        assert_eq!(
386            recall.description.as_deref(),
387            Some("Search mente for memories")
388        );
389        assert!(recall.size_bytes > 0);
390        assert!(!recall.has_assets);
391    }
392
393    #[test]
394    fn list_detects_bundled_assets() {
395        let tmp = fixture_root();
396        let root = SkillsRoot::at(tmp.path());
397        let skills = root.list().expect("list");
398        let bundled = skills
399            .iter()
400            .find(|s| s.dir_stem == "bundled")
401            .expect("bundled");
402        assert!(bundled.has_assets, "expected has_assets=true for bundled");
403    }
404
405    #[test]
406    fn list_no_frontmatter_falls_back_to_stem() {
407        let tmp = fixture_root();
408        let root = SkillsRoot::at(tmp.path());
409        let skills = root.list().expect("list");
410        let nf = skills
411            .iter()
412            .find(|s| s.dir_stem == "no-frontmatter")
413            .expect("no-frontmatter");
414        assert_eq!(nf.name, "no-frontmatter");
415        assert_eq!(nf.description, None);
416    }
417
418    #[test]
419    fn get_returns_full_skill_with_body() {
420        let tmp = fixture_root();
421        let root = SkillsRoot::at(tmp.path());
422        let skill = root.get("recall").expect("get recall");
423        assert_eq!(skill.name, "recall");
424        assert_eq!(skill.body, "Search for: $ARGUMENTS");
425        assert!(!skill.has_assets);
426    }
427
428    #[test]
429    fn get_no_frontmatter_returns_full_body() {
430        let tmp = fixture_root();
431        let root = SkillsRoot::at(tmp.path());
432        let skill = root.get("no-frontmatter").expect("get");
433        assert_eq!(skill.body, "Just a body, no frontmatter at all.");
434        assert_eq!(skill.name, "no-frontmatter");
435    }
436
437    #[test]
438    fn get_unknown_id_errors() {
439        let tmp = fixture_root();
440        let root = SkillsRoot::at(tmp.path());
441        let err = root.get("nope").unwrap_err();
442        assert!(err.to_string().to_lowercase().contains("no skill"));
443    }
444
445    #[test]
446    fn extra_keys_round_trip_as_strings() {
447        let tmp = fixture_root();
448        let root = SkillsRoot::at(tmp.path());
449        let skill = root.get("weird").expect("get weird");
450        assert_eq!(
451            skill.extra.get("custom_key").map(String::as_str),
452            Some("custom_value")
453        );
454    }
455
456    #[test]
457    fn empty_value_keys_dont_overwrite_defaults() {
458        let tmp = tempfile::tempdir().expect("tempdir");
459        write_skill(
460            tmp.path(),
461            "empty-name",
462            "---\nname:\ndescription: keeps stem as name\n---\nbody\n",
463        );
464        let root = SkillsRoot::at(tmp.path());
465        let skill = root.get("empty-name").expect("get");
466        assert_eq!(skill.name, "empty-name");
467    }
468
469    #[test]
470    fn list_ignores_dirs_without_skill_md() {
471        let tmp = fixture_root();
472        // Fixture has `not-a-skill/` with only a README; it must be skipped.
473        let root = SkillsRoot::at(tmp.path());
474        let skills = root.list().expect("list");
475        assert!(!skills.iter().any(|s| s.dir_stem == "not-a-skill"));
476    }
477}