Skip to main content

agent_plugins/
skill.rs

1//! Agent Skills as discovered inside a plugin (§7.1).
2//!
3//! The plugin spec defers the skill *format* to the Agent Skills
4//! specification (agentskills.io); this module implements that format's
5//! validation so a discovered skill can be accepted or skipped with a precise
6//! reason. Skipping is per-skill and non-fatal (§7.1) — the reason travels in
7//! a diagnostic, and loading continues.
8
9use std::collections::BTreeMap;
10use std::fmt;
11
12use yaml_rust2::{Yaml, YamlLoader};
13
14use crate::markdown::Document;
15use crate::path::PackagePath;
16
17/// One valid skill discovered under `skills/`.
18#[derive(Debug, Clone, PartialEq, Eq)]
19#[non_exhaustive]
20pub struct Skill {
21    /// The immediate child directory of `skills/` this skill lives in.
22    /// Always equal to `meta.name` — the Agent Skills spec requires the
23    /// frontmatter name to match the directory name.
24    pub directory: String,
25    /// Package path of the skill directory (`skills/<directory>`).
26    pub path: PackagePath,
27    /// Validated frontmatter.
28    pub meta: SkillMeta,
29    /// The instructions after the frontmatter, parsed into a navigable
30    /// [`Document`] so a client can address one section instead of the whole
31    /// body. [`Document::source`] returns the original Markdown.
32    pub body: Document,
33}
34
35/// Validated `SKILL.md` frontmatter per the Agent Skills specification.
36#[derive(Debug, Clone, PartialEq, Eq)]
37#[non_exhaustive]
38pub struct SkillMeta {
39    /// Skill name: 1–64 lowercase alphanumeric characters and hyphens,
40    /// matching the parent directory name.
41    pub name: String,
42    /// What the skill does and when to use it (1–1024 characters).
43    pub description: String,
44    /// License name or reference to a bundled license file.
45    pub license: Option<String>,
46    /// Environment requirements (1–500 characters).
47    pub compatibility: Option<String>,
48    /// Space-separated pre-approved tools (experimental).
49    pub allowed_tools: Option<String>,
50    /// Arbitrary string-to-string metadata.
51    pub metadata: BTreeMap<String, String>,
52}
53
54/// Why a discovered skill was skipped (§7.1).
55#[derive(Debug, Clone, PartialEq, Eq)]
56#[non_exhaustive]
57pub enum SkillInvalid {
58    /// `SKILL.md` is not UTF-8 text.
59    NotUtf8,
60    /// The file does not begin with a `---` frontmatter fence.
61    NoFrontmatter,
62    /// The opening fence is never closed by a `---` line.
63    UnterminatedFrontmatter,
64    /// The frontmatter is not parseable YAML.
65    BadYaml {
66        /// The YAML parser's explanation.
67        detail: String,
68    },
69    /// The frontmatter is valid YAML but not a mapping.
70    NotAMapping,
71    /// A frontmatter key outside the Agent Skills specification.
72    UnknownField {
73        /// The unexpected key.
74        field: String,
75    },
76    /// A required field is missing.
77    MissingField {
78        /// The missing key: `name` or `description`.
79        field: &'static str,
80    },
81    /// A field has the wrong YAML type.
82    WrongType {
83        /// The offending key.
84        field: String,
85        /// What the Agent Skills specification requires there.
86        expected: &'static str,
87    },
88    /// The `name` field breaks a naming constraint.
89    BadName {
90        /// Which constraint broke.
91        reason: String,
92    },
93    /// The frontmatter name does not match the directory name.
94    NameMismatch {
95        /// The name declared in frontmatter.
96        name: String,
97        /// The directory the skill was discovered in.
98        directory: String,
99    },
100    /// The `description` is empty or over 1024 characters.
101    BadDescription {
102        /// Which constraint broke.
103        reason: String,
104    },
105    /// The `compatibility` field is empty or over 500 characters.
106    BadCompatibility {
107        /// Which constraint broke.
108        reason: String,
109    },
110}
111
112impl fmt::Display for SkillInvalid {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        match self {
115            Self::NotUtf8 => f.write_str("SKILL.md is not UTF-8 text"),
116            Self::NoFrontmatter => f.write_str("SKILL.md must begin with YAML frontmatter (`---`)"),
117            Self::UnterminatedFrontmatter => {
118                f.write_str("SKILL.md frontmatter is not closed with `---`")
119            }
120            Self::BadYaml { detail } => write!(f, "invalid YAML in frontmatter: {detail}"),
121            Self::NotAMapping => f.write_str("frontmatter must be a YAML mapping"),
122            Self::UnknownField { field } => {
123                write!(f, "unexpected frontmatter field `{field}`")
124            }
125            Self::MissingField { field } => {
126                write!(f, "missing required frontmatter field `{field}`")
127            }
128            Self::WrongType { field, expected } => {
129                write!(f, "frontmatter field `{field}` must be {expected}")
130            }
131            Self::BadName { reason } => write!(f, "invalid skill name: {reason}"),
132            Self::NameMismatch { name, directory } => {
133                write!(f, "frontmatter name {name:?} must match directory name {directory:?}")
134            }
135            Self::BadDescription { reason } => write!(f, "invalid description: {reason}"),
136            Self::BadCompatibility { reason } => write!(f, "invalid compatibility: {reason}"),
137        }
138    }
139}
140
141impl std::error::Error for SkillInvalid {}
142
143/// Parse and validate the contents of a `SKILL.md` found at
144/// `skills/<directory>/SKILL.md`.
145pub fn parse_skill_md(
146    directory: &str,
147    bytes: &[u8],
148) -> Result<(SkillMeta, Document), SkillInvalid> {
149    let text = std::str::from_utf8(bytes).map_err(|_| SkillInvalid::NotUtf8)?;
150    let (frontmatter, body) = split_frontmatter(text)?;
151
152    let docs = YamlLoader::load_from_str(frontmatter)
153        .map_err(|e| SkillInvalid::BadYaml { detail: e.to_string() })?;
154    let mapping = match docs.first() {
155        Some(Yaml::Hash(mapping)) => mapping,
156        Some(_) => return Err(SkillInvalid::NotAMapping),
157        None => return Err(SkillInvalid::NotAMapping),
158    };
159
160    let mut name = None;
161    let mut description = None;
162    let mut license = None;
163    let mut compatibility = None;
164    let mut allowed_tools = None;
165    let mut metadata = BTreeMap::new();
166
167    for (key, value) in mapping {
168        let Yaml::String(key) = key else {
169            return Err(SkillInvalid::NotAMapping);
170        };
171        match key.as_str() {
172            "name" => name = Some(expect_str(key, value)?),
173            "description" => description = Some(expect_str(key, value)?),
174            "license" => license = Some(expect_str(key, value)?),
175            "compatibility" => compatibility = Some(expect_str(key, value)?),
176            "allowed-tools" => allowed_tools = Some(expect_str(key, value)?),
177            "metadata" => {
178                let Yaml::Hash(entries) = value else {
179                    return Err(SkillInvalid::WrongType {
180                        field: "metadata".to_owned(),
181                        expected: "a mapping of strings to strings",
182                    });
183                };
184                for (k, v) in entries {
185                    let (Some(k), Some(v)) = (scalar_to_string(k), scalar_to_string(v)) else {
186                        return Err(SkillInvalid::WrongType {
187                            field: "metadata".to_owned(),
188                            expected: "a mapping of strings to strings",
189                        });
190                    };
191                    metadata.insert(k, v);
192                }
193            }
194            unknown => return Err(SkillInvalid::UnknownField { field: unknown.to_owned() }),
195        }
196    }
197
198    let name = name.ok_or(SkillInvalid::MissingField { field: "name" })?;
199    let description = description.ok_or(SkillInvalid::MissingField { field: "description" })?;
200
201    validate_name(&name)?;
202    if name != directory {
203        return Err(SkillInvalid::NameMismatch { name, directory: directory.to_owned() });
204    }
205    validate_description(&description)?;
206    if let Some(compat) = &compatibility {
207        let count = compat.chars().count();
208        if compat.trim().is_empty() {
209            return Err(SkillInvalid::BadCompatibility { reason: "empty".to_owned() });
210        }
211        if count > 500 {
212            return Err(SkillInvalid::BadCompatibility {
213                reason: format!("{count} characters; the limit is 500"),
214            });
215        }
216    }
217
218    Ok((
219        SkillMeta { name, description, license, compatibility, allowed_tools, metadata },
220        Document::parse(body),
221    ))
222}
223
224/// Split `---`-fenced YAML frontmatter from the Markdown body.
225fn split_frontmatter(text: &str) -> Result<(&str, &str), SkillInvalid> {
226    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
227    let mut lines = text.split_inclusive('\n');
228    let first = lines.next().unwrap_or("");
229    if first.trim_end() != "---" {
230        return Err(SkillInvalid::NoFrontmatter);
231    }
232    let after_open = &text[first.len()..];
233    let mut offset = 0;
234    for line in after_open.split_inclusive('\n') {
235        if line.trim_end() == "---" {
236            let frontmatter = &after_open[..offset];
237            let body = &after_open[offset + line.len()..];
238            return Ok((frontmatter, body.trim_start_matches('\n')));
239        }
240        offset += line.len();
241    }
242    Err(SkillInvalid::UnterminatedFrontmatter)
243}
244
245fn expect_str(field: &str, value: &Yaml) -> Result<String, SkillInvalid> {
246    match value {
247        Yaml::String(s) => Ok(s.clone()),
248        _ => Err(SkillInvalid::WrongType { field: field.to_owned(), expected: "a string" }),
249    }
250}
251
252/// Frontmatter `metadata` values are coerced from scalars, mirroring the
253/// reference implementation's tolerance for `version: "1.0"` vs `version: 1.0`.
254fn scalar_to_string(value: &Yaml) -> Option<String> {
255    match value {
256        Yaml::String(s) => Some(s.clone()),
257        Yaml::Integer(i) => Some(i.to_string()),
258        Yaml::Real(r) => Some(r.clone()),
259        Yaml::Boolean(b) => Some(b.to_string()),
260        _ => None,
261    }
262}
263
264/// Agent Skills name rules: 1–64 characters, lowercase letters/digits/hyphens,
265/// alphanumeric at the edges, no `--`.
266fn validate_name(name: &str) -> Result<(), SkillInvalid> {
267    let bad = |reason: &str| SkillInvalid::BadName { reason: reason.to_owned() };
268    if name.trim().is_empty() {
269        return Err(bad("empty"));
270    }
271    let count = name.chars().count();
272    if count > 64 {
273        return Err(bad("longer than 64 characters"));
274    }
275    if name.chars().any(|c| c.is_uppercase()) {
276        return Err(bad("must be lowercase"));
277    }
278    if !name.chars().all(|c| c.is_alphanumeric() || c == '-') {
279        return Err(bad("only letters, digits, and hyphens are allowed"));
280    }
281    if name.starts_with('-') || name.ends_with('-') {
282        return Err(bad("must not start or end with a hyphen"));
283    }
284    if name.contains("--") {
285        return Err(bad("must not contain consecutive hyphens"));
286    }
287    Ok(())
288}
289
290fn validate_description(description: &str) -> Result<(), SkillInvalid> {
291    if description.trim().is_empty() {
292        return Err(SkillInvalid::BadDescription { reason: "empty".to_owned() });
293    }
294    let count = description.chars().count();
295    if count > 1024 {
296        return Err(SkillInvalid::BadDescription {
297            reason: format!("{count} characters; the limit is 1024"),
298        });
299    }
300    Ok(())
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    const VALID: &str =
308        "---\nname: deploy\ndescription: Deploys things. Use when deploying.\n---\n\nBody here.\n";
309
310    #[test]
311    fn valid_skill() {
312        let (meta, body) = parse_skill_md("deploy", VALID.as_bytes()).unwrap();
313        assert_eq!(meta.name, "deploy");
314        assert_eq!(body.source(), "Body here.\n");
315    }
316
317    #[test]
318    fn name_must_match_directory() {
319        let err = parse_skill_md("other", VALID.as_bytes()).unwrap_err();
320        assert!(matches!(err, SkillInvalid::NameMismatch { .. }));
321    }
322
323    #[test]
324    fn frontmatter_is_required() {
325        assert_eq!(parse_skill_md("x", b"# Just markdown"), Err(SkillInvalid::NoFrontmatter));
326        assert_eq!(
327            parse_skill_md("x", b"---\nname: x\n"),
328            Err(SkillInvalid::UnterminatedFrontmatter)
329        );
330    }
331
332    #[test]
333    fn unknown_fields_are_rejected() {
334        let text = "---\nname: x\ndescription: d\nversion: 1.0\n---\n";
335        assert_eq!(
336            parse_skill_md("x", text.as_bytes()),
337            Err(SkillInvalid::UnknownField { field: "version".to_owned() })
338        );
339    }
340
341    #[test]
342    fn metadata_scalars_coerce() {
343        let text = "---\nname: x\ndescription: d\nmetadata:\n  version: 1.0\n  pinned: true\n---\n";
344        let (meta, _) = parse_skill_md("x", text.as_bytes()).unwrap();
345        assert_eq!(meta.metadata.get("version").map(String::as_str), Some("1.0"));
346        assert_eq!(meta.metadata.get("pinned").map(String::as_str), Some("true"));
347    }
348
349    #[test]
350    fn description_length_limit() {
351        let text = format!("---\nname: x\ndescription: {}\n---\n", "d".repeat(1025));
352        assert!(matches!(
353            parse_skill_md("x", text.as_bytes()),
354            Err(SkillInvalid::BadDescription { .. })
355        ));
356    }
357
358    #[test]
359    fn triple_dash_inside_body_is_fine() {
360        let text = "---\nname: x\ndescription: d\n---\n\nA body with --- a horizontal rule.\n";
361        let (_, body) = parse_skill_md("x", text.as_bytes()).unwrap();
362        assert!(body.source().contains("horizontal rule"));
363    }
364}