Skip to main content

lc_tools/
skills.rs

1// lc-tools/src/skills.rs
2//! Agent Skills (SKILL.md) loader with progressive disclosure (D1, v0.22.1).
3//!
4//! Parses the ecosystem-standard Agent Skills skill bundle — a directory whose entry point is a
5//! `SKILL.md` file with YAML frontmatter. Exposes two views:
6//! - [`Skill::disclosure_view`]: name + one-line description only, safe to show to the model up
7//!   front (low token cost, no body leakage).
8//! - [`Skill::full_text`]: the complete skill body, loaded only once the model actually selects
9//!   the skill (progressive disclosure).
10//!
11//! Execution sandboxing is intentionally NOT implemented: a skill's auxiliary scripts/templates
12//! are surfaced as metadata, leaving execution to the caller (and out of scope here).
13
14use std::fs;
15use std::path::{Path, PathBuf};
16
17use serde::Deserialize;
18
19/// Error while loading or parsing a skill bundle.
20#[derive(Debug, thiserror::Error)]
21pub enum SkillError {
22    /// The directory does not contain a `SKILL.md` entry point.
23    #[error("skill directory missing SKILL.md: {0}")]
24    MissingSkillFile(String),
25    /// The frontmatter is malformed.
26    #[error("invalid SKILL.md frontmatter: {0}")]
27    Frontmatter(String),
28    /// Required frontmatter fields are absent.
29    #[error("SKILL.md missing required field: {0}")]
30    MissingField(String),
31    /// I/O failure reading the skill directory.
32    #[error("I/O error: {0}")]
33    Io(#[from] std::io::Error),
34}
35
36/// Required head matter of a SKILL.md file.
37///
38/// `name` and `description` are surfaced as `Option` so that a missing required field is
39/// reported via [`SkillError::MissingField`] (with a field-specific message) rather than a
40/// generic YAML deserialization error.
41#[derive(Debug, Clone, Deserialize)]
42pub struct SkillFrontmatter {
43    /// Skill name (unique identifier).
44    pub name: Option<String>,
45    /// One-line description shown for progressive disclosure.
46    pub description: Option<String>,
47    /// Optional files/scripts that belong to the skill bundle.
48    #[serde(default)]
49    #[allow(dead_code)]
50    pub allowed_tools: Option<Vec<String>>,
51    /// Optional extra vendor-defined fields are preserved as opaque metadata.
52    #[serde(flatten)]
53    #[allow(dead_code)]
54    pub extra: serde_json::Map<String, serde_json::Value>,
55}
56
57/// A parsed, loadable skill unit.
58#[derive(Debug, Clone)]
59pub struct Skill {
60    frontmatter: SkillFrontmatter,
61    /// Full markdown body (frontmatter stripped).
62    body: String,
63    /// Directory containing the bundle (for resolving auxiliary files).
64    dir: PathBuf,
65}
66
67impl Skill {
68    /// Load a skill bundle from a directory containing `SKILL.md`.
69    pub fn load(dir: impl Into<PathBuf>) -> Result<Self, SkillError> {
70        let dir = dir.into();
71        let skill_path = dir.join("SKILL.md");
72        if !skill_path.is_file() {
73            return Err(SkillError::MissingSkillFile(skill_path.display().to_string()));
74        }
75        let raw = fs::read_to_string(&skill_path)?;
76        let (frontmatter, body) = parse_frontmatter(&raw, &skill_path)?;
77        Ok(Self {
78            frontmatter,
79            body,
80            dir,
81        })
82    }
83
84    /// Scan a root directory for all skill bundle directories (each containing `SKILL.md`).
85    pub fn scan(root: impl AsRef<Path>) -> Result<Vec<Self>, SkillError> {
86        let mut skills = Vec::new();
87        for entry in fs::read_dir(root)? {
88            let entry = entry?;
89            let path = entry.path();
90            if path.is_dir() && path.join("SKILL.md").is_file() {
91                skills.push(Self::load(&path)?);
92            }
93        }
94        Ok(skills)
95    }
96
97    /// Skill name (unique identifier).
98    ///
99    /// Safe because [`Skill::load`] rejects a missing/empty `name`; [`Skill`] can therefore
100    /// never be constructed with `None` here.
101    pub fn name(&self) -> &str {
102        self.frontmatter.name.as_deref().unwrap_or_default()
103    }
104
105    /// One-line description, safe for up-front listing.
106    ///
107    /// Safe for the same invariant as [`Skill::name`].
108    pub fn description(&self) -> &str {
109        self.frontmatter.description.as_deref().unwrap_or_default()
110    }
111
112    /// Progressive disclosure: name + description only. Low token cost, no body leakage.
113    ///
114    /// This is what the agent should see in its system prompt *before* selecting a skill.
115    pub fn disclosure_view(&self) -> String {
116        format!("{}: {}", self.name(), self.description())
117    }
118
119    /// Full skill body, loaded only after the model selects this skill.
120    pub fn full_text(&self) -> String {
121        format!(
122            "# Skill: {}\n\n## Description\n{}\n\n## Instructions\n\n{}",
123            self.name(),
124            self.description(),
125            self.body
126        )
127    }
128
129    /// The bundle directory (for resolving auxiliary files/scripts).
130    pub fn directory(&self) -> &Path {
131        &self.dir
132    }
133}
134
135/// Splits a SKILL.md file into (frontmatter, body). Frontmatter is delimited by `---` lines.
136fn parse_frontmatter(raw: &str, path: &Path) -> Result<(SkillFrontmatter, String), SkillError> {
137    let stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw); // tolerate a leading BOM
138    if !stripped.trim_start().starts_with("---") {
139        return Err(SkillError::MissingField(
140            "frontmatter block (---...) missing".into(),
141        ));
142    }
143    // Find the closing `---`.
144    let after_open = stripped.find('\n').ok_or_else(|| {
145        SkillError::Frontmatter(format!("{}: no newline after opening ---", path.display()))
146    })?;
147    let rest = &stripped[after_open..];
148    let close = rest.find("\n---").ok_or_else(|| {
149        SkillError::Frontmatter(format!("{}: no closing --- for frontmatter", path.display()))
150    })?;
151    let yaml = &rest[..close];
152    let body = &rest[close + 4..];
153
154    let frontmatter: SkillFrontmatter = serde_yaml::from_str(yaml)
155        .map_err(|e| SkillError::Frontmatter(format!("{}: {}", path.display(), e)))?;
156    match &frontmatter.name {
157        Some(n) if !n.trim().is_empty() => {}
158        _ => return Err(SkillError::MissingField("name".into())),
159    }
160    match &frontmatter.description {
161        Some(d) if !d.trim().is_empty() => {}
162        _ => return Err(SkillError::MissingField("description".into())),
163    }
164    Ok((frontmatter, body.trim_matches('\n').to_string()))
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use std::io::Write;
171
172    fn write_skill(dir: &Path, name: &str, description: &str, body: &str) -> PathBuf {
173        std::fs::create_dir_all(dir).unwrap();
174        let frontmatter = format!("---\nname: {name}\ndescription: {description}\n---\n\n");
175        let p = dir.join("SKILL.md");
176        let mut f = std::fs::File::create(&p).unwrap();
177        f.write_all(format!("{frontmatter}{body}").as_bytes()).unwrap();
178        p
179    }
180
181    #[test]
182    fn parses_frontmatter_and_body() {
183        let dir = tempfile::tempdir().unwrap();
184        write_skill(dir.path(), "web_search", "Searches the web", "Do a search then summarize.");
185        let skill = Skill::load(dir.path()).unwrap();
186        assert_eq!(skill.name(), "web_search");
187        assert_eq!(skill.description(), "Searches the web");
188        assert_eq!(skill.full_text().contains("Do a search then summarize."), true);
189    }
190
191    #[test]
192    fn disclosure_view_excludes_body() {
193        let dir = tempfile::tempdir().unwrap();
194        write_skill(dir.path(), "secret", "Just a name", "<!-- SECRET BODY -->");
195        let skill = Skill::load(dir.path()).unwrap();
196        assert_eq!(skill.disclosure_view(), "secret: Just a name");
197        assert_eq!(skill.disclosure_view().contains("SECRET BODY"), false);
198        assert_eq!(skill.full_text().contains("SECRET BODY"), true);
199    }
200
201    #[test]
202    fn missing_skill_file_errors() {
203        let dir = tempfile::tempdir().unwrap();
204        let err = Skill::load(dir.path()).unwrap_err();
205        assert!(matches!(err, SkillError::MissingSkillFile(_)));
206    }
207
208    #[test]
209    fn missing_required_field_errors() {
210        let dir = tempfile::tempdir().unwrap();
211        let p = dir.path().join("SKILL.md");
212        std::fs::write(&p, "---\ndescription: no name here\n---\n\nbody").unwrap();
213        let err = Skill::load(dir.path()).unwrap_err();
214        assert!(matches!(err, SkillError::MissingField(_)));
215    }
216
217    #[test]
218    fn scan_finds_only_skill_dirs() {
219        let root = tempfile::tempdir().unwrap();
220        write_skill(&root.path().join("a"), "a", "skill A", "body a");
221        write_skill(&root.path().join("b"), "b", "skill B", "body b");
222        std::fs::create_dir(root.path().join("plain")).unwrap();
223        let skills = Skill::scan(root.path()).unwrap();
224        assert_eq!(skills.len(), 2);
225    }
226}