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