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//! [`SkillsRoot::scheduled_tasks_home`] points the same reader at
72//! `~/.claude/scheduled-tasks`, whose entries share the SKILL.md
73//! format.
74
75use std::collections::BTreeMap;
76use std::fs;
77use std::path::{Path, PathBuf};
78
79use serde::Serialize;
80
81use crate::artifacts::split_frontmatter;
82use crate::error::{Error, Result};
83
84/// Root directory of Claude Code's user-level skill definitions.
85/// Defaults to `~/.claude/skills`; override with [`SkillsRoot::at`]
86/// for tests or non-default installs.
87#[derive(Debug, Clone)]
88pub struct SkillsRoot {
89    path: PathBuf,
90}
91
92impl SkillsRoot {
93    /// Resolve the default `~/.claude/skills`. Errors if `$HOME`
94    /// (or the platform-specific user home) cannot be determined.
95    pub fn home() -> Result<Self> {
96        let home = home_dir().ok_or_else(|| Error::Artifacts {
97            message: "could not determine user home directory".to_string(),
98        })?;
99        Ok(Self {
100            path: home.join(".claude").join("skills"),
101        })
102    }
103
104    /// Use a specific path as the skills root. Useful for tests
105    /// (point at a tempdir) and for non-default installs.
106    pub fn at(path: impl Into<PathBuf>) -> Self {
107        Self { path: path.into() }
108    }
109
110    /// Resolve `~/.claude/scheduled-tasks` as the root.
111    ///
112    /// Scheduled-task definitions use the same on-disk shape as
113    /// skills (`<name>/SKILL.md` with `name` / `description`
114    /// frontmatter and a prompt body), so the skills reader serves
115    /// them with a different root instead of a duplicate module.
116    /// Scheduling metadata (cron expression, enablement) is NOT in
117    /// these files; only the definition is exposed here. The layout
118    /// is undocumented Claude Code internal state (observed against
119    /// CLI 2.1.219).
120    pub fn scheduled_tasks_home() -> Result<Self> {
121        let home = home_dir().ok_or_else(|| Error::Artifacts {
122            message: "could not determine user home directory".to_string(),
123        })?;
124        Ok(Self {
125            path: home.join(".claude").join("scheduled-tasks"),
126        })
127    }
128
129    /// The configured root directory.
130    pub fn path(&self) -> &Path {
131        &self.path
132    }
133
134    /// List every skill directory at the root, sorted by directory
135    /// stem.
136    ///
137    /// A "skill" is any direct child directory of the root that
138    /// contains a `SKILL.md`. Directories without `SKILL.md` and
139    /// non-directory entries are ignored. Returns an empty vec if
140    /// the root itself doesn't exist (a fresh Claude Code install
141    /// with no user skills). Directories whose `SKILL.md` fails to
142    /// parse contribute a tracing warning and are skipped rather
143    /// than failing the whole listing.
144    pub fn list(&self) -> Result<Vec<SkillSummary>> {
145        let entries = match fs::read_dir(&self.path) {
146            Ok(it) => it,
147            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
148            Err(e) => return Err(e.into()),
149        };
150
151        let mut out = Vec::new();
152        for entry in entries.flatten() {
153            let dir = entry.path();
154            if !dir.is_dir() {
155                continue;
156            }
157            let stem = match dir.file_name().and_then(|s| s.to_str()) {
158                Some(s) => s.to_string(),
159                None => continue,
160            };
161            let skill_md = dir.join("SKILL.md");
162            if !skill_md.is_file() {
163                continue;
164            }
165            match parse_skill_file(&skill_md, &dir, &stem) {
166                Ok(skill) => out.push(SkillSummary::from_skill(&skill)),
167                Err(e) => tracing::warn!(?skill_md, "skipping skill: {e}"),
168            }
169        }
170        out.sort_by(|a, b| a.dir_stem.cmp(&b.dir_stem));
171        Ok(out)
172    }
173
174    /// Read one skill by directory stem (i.e. the basename of the
175    /// `<stem>/` directory under the root). Errors if no such
176    /// directory exists, it has no `SKILL.md`, or the file fails to
177    /// parse.
178    pub fn get(&self, dir_stem: &str) -> Result<Skill> {
179        let dir = self.path.join(dir_stem);
180        let skill_md = dir.join("SKILL.md");
181        if !skill_md.is_file() {
182            return Err(Error::Artifacts {
183                message: format!("no skill at {}", dir.display()),
184            });
185        }
186        parse_skill_file(&skill_md, &dir, dir_stem)
187    }
188}
189
190/// Lightweight metadata for one skill, returned by
191/// [`SkillsRoot::list`]. Strips the body to keep listings cheap.
192#[derive(Debug, Clone, Serialize)]
193pub struct SkillSummary {
194    /// Directory stem (the basename of `<stem>/` under the root).
195    /// The canonical handle for lookup.
196    pub dir_stem: String,
197    /// Frontmatter `name` if present; falls back to `dir_stem`.
198    pub name: String,
199    /// Frontmatter `description` if present.
200    pub description: Option<String>,
201    /// Absolute path to the skill's directory.
202    pub dir_path: PathBuf,
203    /// Absolute path to the source `SKILL.md`.
204    pub file_path: PathBuf,
205    /// `SKILL.md` size in bytes; useful for cheap UI hints.
206    pub size_bytes: u64,
207    /// True if the skill directory contains sibling files or
208    /// subdirectories beyond `SKILL.md` (e.g. `scripts/`,
209    /// `reference/`). Listing the sibling paths themselves is
210    /// deferred; callers that need the inventory can stat the
211    /// directory directly via [`Self::dir_path`].
212    pub has_assets: bool,
213}
214
215impl SkillSummary {
216    fn from_skill(s: &Skill) -> Self {
217        let size_bytes = fs::metadata(&s.file_path)
218            .map(|m| m.len())
219            .unwrap_or_default();
220        Self {
221            dir_stem: s.dir_stem.clone(),
222            name: s.name.clone(),
223            description: s.description.clone(),
224            dir_path: s.dir_path.clone(),
225            file_path: s.file_path.clone(),
226            size_bytes,
227            has_assets: s.has_assets,
228        }
229    }
230}
231
232/// Full skill record returned by [`SkillsRoot::get`].
233#[derive(Debug, Clone, Serialize)]
234pub struct Skill {
235    /// Directory stem (the basename of `<stem>/` under the root).
236    /// The canonical handle for lookup.
237    pub dir_stem: String,
238    /// Frontmatter `name` if present; falls back to `dir_stem`.
239    pub name: String,
240    /// Frontmatter `description` if present.
241    pub description: Option<String>,
242    /// Absolute path to the skill's directory.
243    pub dir_path: PathBuf,
244    /// Absolute path to the source `SKILL.md`.
245    pub file_path: PathBuf,
246    /// Markdown body after the frontmatter block (trimmed of
247    /// leading/trailing blank lines).
248    pub body: String,
249    /// Frontmatter keys other than the typed ones. Preserves
250    /// unknown future fields verbatim as raw strings.
251    pub extra: BTreeMap<String, String>,
252    /// True if the skill directory contains sibling files or
253    /// subdirectories beyond `SKILL.md`. See
254    /// [`SkillSummary::has_assets`] for the deferred-inventory
255    /// rationale.
256    pub has_assets: bool,
257}
258
259fn parse_skill_file(file_path: &Path, dir_path: &Path, dir_stem: &str) -> Result<Skill> {
260    let raw = fs::read_to_string(file_path)?;
261    let (frontmatter, body) = split_frontmatter(&raw);
262
263    let mut name = dir_stem.to_string();
264    let mut description = None;
265    let mut extra = BTreeMap::new();
266
267    if let Some(fm) = frontmatter {
268        for line in fm.lines() {
269            let trimmed = line.trim();
270            if trimmed.is_empty() {
271                continue;
272            }
273            let Some((k, v)) = trimmed.split_once(':') else {
274                continue;
275            };
276            let key = k.trim();
277            let value = v.trim().to_string();
278            match key {
279                "name" if !value.is_empty() => name = value,
280                "description" if !value.is_empty() => description = Some(value),
281                _ if !key.is_empty() => {
282                    extra.insert(key.to_string(), value);
283                }
284                _ => {}
285            }
286        }
287    }
288
289    Ok(Skill {
290        dir_stem: dir_stem.to_string(),
291        name,
292        description,
293        dir_path: dir_path.to_path_buf(),
294        file_path: file_path.to_path_buf(),
295        body: body.trim().to_string(),
296        extra,
297        has_assets: directory_has_assets(dir_path),
298    })
299}
300
301fn directory_has_assets(dir: &Path) -> bool {
302    let entries = match fs::read_dir(dir) {
303        Ok(it) => it,
304        Err(_) => return false,
305    };
306    for entry in entries.flatten() {
307        let name = entry.file_name();
308        // Skip the canonical SKILL.md itself; anything else is an asset.
309        if name == "SKILL.md" {
310            continue;
311        }
312        return true;
313    }
314    false
315}
316
317fn home_dir() -> Option<PathBuf> {
318    if let Ok(h) = std::env::var("HOME")
319        && !h.is_empty()
320    {
321        return Some(PathBuf::from(h));
322    }
323    if let Ok(h) = std::env::var("USERPROFILE")
324        && !h.is_empty()
325    {
326        return Some(PathBuf::from(h));
327    }
328    None
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use std::io::Write;
335
336    fn write_skill(root: &Path, stem: &str, contents: &str) -> PathBuf {
337        let dir = root.join(stem);
338        fs::create_dir_all(&dir).expect("create skill dir");
339        let path = dir.join("SKILL.md");
340        let mut f = fs::File::create(&path).expect("create SKILL.md");
341        f.write_all(contents.as_bytes()).expect("write SKILL.md");
342        path
343    }
344
345    fn fixture_root() -> tempfile::TempDir {
346        let tmp = tempfile::tempdir().expect("tempdir");
347        write_skill(
348            tmp.path(),
349            "recall",
350            "---\nname: recall\ndescription: Search mente for memories\n---\n\nSearch for: $ARGUMENTS\n",
351        );
352        write_skill(
353            tmp.path(),
354            "no-frontmatter",
355            "Just a body, no frontmatter at all.\n",
356        );
357        write_skill(
358            tmp.path(),
359            "weird",
360            "---\nname: weird\ndescription: has extras\ncustom_key: custom_value\n---\nbody\n",
361        );
362        // A skill with bundled assets (scripts/).
363        write_skill(
364            tmp.path(),
365            "bundled",
366            "---\nname: bundled\ndescription: has scripts\n---\nbody\n",
367        );
368        let scripts = tmp.path().join("bundled").join("scripts");
369        fs::create_dir_all(&scripts).expect("create scripts dir");
370        fs::write(scripts.join("helper.sh"), "#!/bin/sh\n").expect("write helper");
371        // A directory without SKILL.md should be ignored.
372        let bogus = tmp.path().join("not-a-skill");
373        fs::create_dir_all(&bogus).expect("create bogus");
374        fs::write(bogus.join("README.md"), "not a skill").expect("write README");
375        // A non-directory entry at the root should be ignored.
376        fs::write(tmp.path().join("loose-file.md"), "ignore me").expect("write loose");
377        tmp
378    }
379
380    #[test]
381    fn list_returns_only_skill_dirs_sorted() {
382        let tmp = fixture_root();
383        let root = SkillsRoot::at(tmp.path());
384        let skills = root.list().expect("list");
385        let stems: Vec<&str> = skills.iter().map(|s| s.dir_stem.as_str()).collect();
386        assert_eq!(stems, ["bundled", "no-frontmatter", "recall", "weird"]);
387    }
388
389    #[test]
390    fn list_missing_root_returns_empty() {
391        let tmp = tempfile::tempdir().expect("tempdir");
392        let root = SkillsRoot::at(tmp.path().join("does-not-exist"));
393        let skills = root.list().expect("list");
394        assert!(skills.is_empty());
395    }
396
397    #[test]
398    fn list_typed_metadata() {
399        let tmp = fixture_root();
400        let root = SkillsRoot::at(tmp.path());
401        let skills = root.list().expect("list");
402        let recall = skills
403            .iter()
404            .find(|s| s.dir_stem == "recall")
405            .expect("recall");
406        assert_eq!(recall.name, "recall");
407        assert_eq!(
408            recall.description.as_deref(),
409            Some("Search mente for memories")
410        );
411        assert!(recall.size_bytes > 0);
412        assert!(!recall.has_assets);
413    }
414
415    #[test]
416    fn list_detects_bundled_assets() {
417        let tmp = fixture_root();
418        let root = SkillsRoot::at(tmp.path());
419        let skills = root.list().expect("list");
420        let bundled = skills
421            .iter()
422            .find(|s| s.dir_stem == "bundled")
423            .expect("bundled");
424        assert!(bundled.has_assets, "expected has_assets=true for bundled");
425    }
426
427    #[test]
428    fn list_no_frontmatter_falls_back_to_stem() {
429        let tmp = fixture_root();
430        let root = SkillsRoot::at(tmp.path());
431        let skills = root.list().expect("list");
432        let nf = skills
433            .iter()
434            .find(|s| s.dir_stem == "no-frontmatter")
435            .expect("no-frontmatter");
436        assert_eq!(nf.name, "no-frontmatter");
437        assert_eq!(nf.description, None);
438    }
439
440    #[test]
441    fn get_returns_full_skill_with_body() {
442        let tmp = fixture_root();
443        let root = SkillsRoot::at(tmp.path());
444        let skill = root.get("recall").expect("get recall");
445        assert_eq!(skill.name, "recall");
446        assert_eq!(skill.body, "Search for: $ARGUMENTS");
447        assert!(!skill.has_assets);
448    }
449
450    #[test]
451    fn get_no_frontmatter_returns_full_body() {
452        let tmp = fixture_root();
453        let root = SkillsRoot::at(tmp.path());
454        let skill = root.get("no-frontmatter").expect("get");
455        assert_eq!(skill.body, "Just a body, no frontmatter at all.");
456        assert_eq!(skill.name, "no-frontmatter");
457    }
458
459    #[test]
460    fn get_unknown_id_errors() {
461        let tmp = fixture_root();
462        let root = SkillsRoot::at(tmp.path());
463        let err = root.get("nope").unwrap_err();
464        assert!(err.to_string().to_lowercase().contains("no skill"));
465    }
466
467    #[test]
468    fn extra_keys_round_trip_as_strings() {
469        let tmp = fixture_root();
470        let root = SkillsRoot::at(tmp.path());
471        let skill = root.get("weird").expect("get weird");
472        assert_eq!(
473            skill.extra.get("custom_key").map(String::as_str),
474            Some("custom_value")
475        );
476    }
477
478    #[test]
479    fn empty_value_keys_dont_overwrite_defaults() {
480        let tmp = tempfile::tempdir().expect("tempdir");
481        write_skill(
482            tmp.path(),
483            "empty-name",
484            "---\nname:\ndescription: keeps stem as name\n---\nbody\n",
485        );
486        let root = SkillsRoot::at(tmp.path());
487        let skill = root.get("empty-name").expect("get");
488        assert_eq!(skill.name, "empty-name");
489    }
490
491    #[test]
492    fn scheduled_tasks_home_points_at_scheduled_tasks() {
493        if let Ok(root) = SkillsRoot::scheduled_tasks_home() {
494            assert!(root.path().ends_with(".claude/scheduled-tasks"));
495        }
496    }
497
498    #[test]
499    fn list_ignores_dirs_without_skill_md() {
500        let tmp = fixture_root();
501        // Fixture has `not-a-skill/` with only a README; it must be skipped.
502        let root = SkillsRoot::at(tmp.path());
503        let skills = root.list().expect("list");
504        assert!(!skills.iter().any(|s| s.dir_stem == "not-a-skill"));
505    }
506}