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(
74                skill_path.display().to_string(),
75            ));
76        }
77        let raw = fs::read_to_string(&skill_path)?;
78        let (frontmatter, body) = parse_frontmatter(&raw, &skill_path)?;
79        Ok(Self {
80            frontmatter,
81            body,
82            dir,
83        })
84    }
85
86    /// Scan a root directory for all skill bundle directories (each containing `SKILL.md`).
87    pub fn scan(root: impl AsRef<Path>) -> Result<Vec<Self>, SkillError> {
88        let mut skills = Vec::new();
89        for entry in fs::read_dir(root)? {
90            let entry = entry?;
91            let path = entry.path();
92            if path.is_dir() && path.join("SKILL.md").is_file() {
93                skills.push(Self::load(&path)?);
94            }
95        }
96        Ok(skills)
97    }
98
99    /// Skill name (unique identifier).
100    ///
101    /// Safe because [`Skill::load`] rejects a missing/empty `name`; [`Skill`] can therefore
102    /// never be constructed with `None` here.
103    pub fn name(&self) -> &str {
104        self.frontmatter.name.as_deref().unwrap_or_default()
105    }
106
107    /// One-line description, safe for up-front listing.
108    ///
109    /// Safe for the same invariant as [`Skill::name`].
110    pub fn description(&self) -> &str {
111        self.frontmatter.description.as_deref().unwrap_or_default()
112    }
113
114    /// Progressive disclosure: name + description only. Low token cost, no body leakage.
115    ///
116    /// This is what the agent should see in its system prompt *before* selecting a skill.
117    pub fn disclosure_view(&self) -> String {
118        format!("{}: {}", self.name(), self.description())
119    }
120
121    /// Full skill body, loaded only after the model selects this skill.
122    pub fn full_text(&self) -> String {
123        format!(
124            "# Skill: {}\n\n## Description\n{}\n\n## Instructions\n\n{}",
125            self.name(),
126            self.description(),
127            self.body
128        )
129    }
130
131    /// The bundle directory (for resolving auxiliary files/scripts).
132    pub fn directory(&self) -> &Path {
133        &self.dir
134    }
135}
136
137/// Splits a SKILL.md file into (frontmatter, body). Frontmatter is delimited by `---` lines.
138fn parse_frontmatter(raw: &str, path: &Path) -> Result<(SkillFrontmatter, String), SkillError> {
139    let stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw); // tolerate a leading BOM
140    if !stripped.trim_start().starts_with("---") {
141        return Err(SkillError::MissingField(
142            "frontmatter block (---...) missing".into(),
143        ));
144    }
145    // Find the closing `---`.
146    let after_open = stripped.find('\n').ok_or_else(|| {
147        SkillError::Frontmatter(format!("{}: no newline after opening ---", path.display()))
148    })?;
149    let rest = &stripped[after_open..];
150    let close = rest.find("\n---").ok_or_else(|| {
151        SkillError::Frontmatter(format!(
152            "{}: no closing --- for frontmatter",
153            path.display()
154        ))
155    })?;
156    let yaml = &rest[..close];
157    let body = &rest[close + 4..];
158
159    let frontmatter: SkillFrontmatter = serde_yaml::from_str(yaml)
160        .map_err(|e| SkillError::Frontmatter(format!("{}: {}", path.display(), e)))?;
161    match &frontmatter.name {
162        Some(n) if !n.trim().is_empty() => {}
163        _ => return Err(SkillError::MissingField("name".into())),
164    }
165    match &frontmatter.description {
166        Some(d) if !d.trim().is_empty() => {}
167        _ => return Err(SkillError::MissingField("description".into())),
168    }
169    Ok((frontmatter, body.trim_matches('\n').to_string()))
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use std::io::Write;
176
177    fn write_skill(dir: &Path, name: &str, description: &str, body: &str) -> PathBuf {
178        std::fs::create_dir_all(dir).unwrap();
179        let frontmatter = format!("---\nname: {name}\ndescription: {description}\n---\n\n");
180        let p = dir.join("SKILL.md");
181        let mut f = std::fs::File::create(&p).unwrap();
182        f.write_all(format!("{frontmatter}{body}").as_bytes())
183            .unwrap();
184        p
185    }
186
187    #[test]
188    fn parses_frontmatter_and_body() {
189        let dir = tempfile::tempdir().unwrap();
190        write_skill(
191            dir.path(),
192            "web_search",
193            "Searches the web",
194            "Do a search then summarize.",
195        );
196        let skill = Skill::load(dir.path()).unwrap();
197        assert_eq!(skill.name(), "web_search");
198        assert_eq!(skill.description(), "Searches the web");
199        assert!(skill.full_text().contains("Do a search then summarize."));
200    }
201
202    #[test]
203    fn disclosure_view_excludes_body() {
204        let dir = tempfile::tempdir().unwrap();
205        write_skill(dir.path(), "secret", "Just a name", "<!-- SECRET BODY -->");
206        let skill = Skill::load(dir.path()).unwrap();
207        assert_eq!(skill.disclosure_view(), "secret: Just a name");
208        assert!(!skill.disclosure_view().contains("SECRET BODY"));
209        assert!(skill.full_text().contains("SECRET BODY"));
210    }
211
212    #[test]
213    fn missing_skill_file_errors() {
214        let dir = tempfile::tempdir().unwrap();
215        let err = Skill::load(dir.path()).unwrap_err();
216        assert!(matches!(err, SkillError::MissingSkillFile(_)));
217    }
218
219    #[test]
220    fn missing_required_field_errors() {
221        let dir = tempfile::tempdir().unwrap();
222        let p = dir.path().join("SKILL.md");
223        std::fs::write(&p, "---\ndescription: no name here\n---\n\nbody").unwrap();
224        let err = Skill::load(dir.path()).unwrap_err();
225        assert!(matches!(err, SkillError::MissingField(_)));
226    }
227
228    #[test]
229    fn scan_finds_only_skill_dirs() {
230        let root = tempfile::tempdir().unwrap();
231        write_skill(&root.path().join("a"), "a", "skill A", "body a");
232        write_skill(&root.path().join("b"), "b", "skill B", "body b");
233        std::fs::create_dir(root.path().join("plain")).unwrap();
234        let skills = Skill::scan(root.path()).unwrap();
235        assert_eq!(skills.len(), 2);
236    }
237}