Skip to main content

agent_skill_installer/
skill.rs

1// SPDX-License-Identifier: LGPL-3.0-or-later
2// Copyright (C) 2026 Fabian Schmieder
3
4//! The skill a program ships, held in memory until it is installed.
5//!
6//! The crate writes the frontmatter itself rather than editing YAML a program
7//! hands over. That keeps the ownership record in one known shape, which is
8//! what makes it readable again later without a YAML parser, and it rules out
9//! a program's own `metadata` block colliding with the record.
10
11use std::collections::BTreeMap;
12use std::fmt::{self, Write as _};
13use std::path::{Component, Path, PathBuf};
14
15use crate::name::{NameError, validate_name};
16
17/// The file every skill has, at the top of its directory.
18pub const SKILL_FILE: &str = "SKILL.md";
19
20/// The longest description the specification allows.
21const MAX_DESCRIPTION: usize = 1024;
22
23/// Frontmatter keys the crate writes itself.
24const RESERVED_FIELDS: [&str; 3] = ["name", "description", "metadata"];
25
26/// `metadata` keys that make up the ownership record.
27pub const RECORD_KEYS: [&str; 3] = ["installed-by", "installed-version", "content-digest"];
28
29/// A skill: its frontmatter, its instructions and any further files.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Skill {
32    name: String,
33    description: String,
34    body: String,
35    fields: Vec<(String, String)>,
36    metadata: Vec<(String, String)>,
37    files: BTreeMap<PathBuf, Vec<u8>>,
38}
39
40/// Why a skill was refused.
41#[derive(Debug, Clone, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum SkillError {
44    /// The name breaks the specification.
45    Name(NameError),
46    /// The description is empty or longer than 1024 characters.
47    Description(usize),
48    /// A frontmatter or metadata key is not a plain lowercase word, or is one
49    /// the crate writes itself.
50    Key(String),
51    /// A file path is absolute, leaves the skill directory, or is `SKILL.md`.
52    Path(PathBuf),
53    /// A frontmatter key or metadata key given twice. YAML forbids it, and
54    /// tools disagree on which value wins.
55    Duplicate(String),
56    /// A file path that differs from another, or from `SKILL.md`, only in
57    /// letter case: one file on the case-insensitive file systems of macOS
58    /// and Windows.
59    CaseCollision(PathBuf),
60}
61
62impl fmt::Display for SkillError {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            Self::Name(error) => error.fmt(f),
66            Self::Description(len) => write!(
67                f,
68                "a description has 1 to {MAX_DESCRIPTION} characters, this one has {len}"
69            ),
70            Self::Key(key) => write!(f, "'{key}' cannot be used as a frontmatter key here"),
71            Self::Duplicate(key) => write!(f, "'{key}' is given twice"),
72            Self::CaseCollision(path) => write!(
73                f,
74                "{} differs from another file only in letter case",
75                path.display()
76            ),
77            Self::Path(path) => write!(
78                f,
79                "{} is not a relative path inside the skill",
80                path.display()
81            ),
82        }
83    }
84}
85
86impl std::error::Error for SkillError {}
87
88impl From<NameError> for SkillError {
89    fn from(error: NameError) -> Self {
90        Self::Name(error)
91    }
92}
93
94impl Skill {
95    /// A skill with a name, a description and the instructions below the
96    /// frontmatter.
97    ///
98    /// # Errors
99    /// Returns an error for a name the specification refuses or a description
100    /// that is empty or too long.
101    pub fn new(
102        name: impl Into<String>,
103        description: impl Into<String>,
104        body: impl Into<String>,
105    ) -> Result<Self, SkillError> {
106        let name = name.into();
107        validate_name(&name)?;
108        let description = description.into();
109        let len = description.chars().count();
110        if description.trim().is_empty() || len > MAX_DESCRIPTION {
111            return Err(SkillError::Description(len));
112        }
113        Ok(Self {
114            name,
115            description,
116            body: body.into(),
117            fields: Vec::new(),
118            metadata: Vec::new(),
119            files: BTreeMap::new(),
120        })
121    }
122
123    /// Add a top-level frontmatter field, such as `license`.
124    ///
125    /// # Errors
126    /// Returns an error for a key that is not a plain lowercase word or that
127    /// the crate writes itself.
128    pub fn with_field(
129        mut self,
130        key: impl Into<String>,
131        value: impl Into<String>,
132    ) -> Result<Self, SkillError> {
133        let key = key.into();
134        if !plain_key(&key) || RESERVED_FIELDS.contains(&key.as_str()) {
135            return Err(SkillError::Key(key));
136        }
137        if self.fields.iter().any(|(known, _)| *known == key) {
138            return Err(SkillError::Duplicate(key));
139        }
140        self.fields.push((key, value.into()));
141        Ok(self)
142    }
143
144    /// Add an entry to the `metadata` map, next to the ownership record.
145    ///
146    /// # Errors
147    /// Returns an error for a key that is not a plain word or that belongs to
148    /// the ownership record.
149    pub fn with_metadata(
150        mut self,
151        key: impl Into<String>,
152        value: impl Into<String>,
153    ) -> Result<Self, SkillError> {
154        let key = key.into();
155        // Tools namespace their own keys, as OpenCode's `opencode/slash` does.
156        let plain = key.split('/').all(plain_key);
157        if !plain || RECORD_KEYS.contains(&key.as_str()) {
158            return Err(SkillError::Key(key));
159        }
160        if self.metadata.iter().any(|(known, _)| *known == key) {
161            return Err(SkillError::Duplicate(key));
162        }
163        self.metadata.push((key, value.into()));
164        Ok(self)
165    }
166
167    /// Add a further file, such as `references/api.md`.
168    ///
169    /// # Errors
170    /// Returns an error for a path that is absolute, contains `..` or `.`,
171    /// is empty, or names `SKILL.md` itself.
172    pub fn with_file(
173        mut self,
174        path: impl Into<PathBuf>,
175        content: impl Into<Vec<u8>>,
176    ) -> Result<Self, SkillError> {
177        let path = path.into();
178        if !contained(&path) || path == Path::new(SKILL_FILE) {
179            return Err(SkillError::Path(path));
180        }
181        let wanted = folded(&path);
182        if wanted == folded_str(SKILL_FILE)
183            || self.files.keys().any(|known| folded(known) == wanted)
184        {
185            return Err(SkillError::CaseCollision(path));
186        }
187        self.files.insert(path, content.into());
188        Ok(self)
189    }
190
191    /// The skill's name, which is also its directory's name.
192    #[must_use]
193    pub fn name(&self) -> &str {
194        &self.name
195    }
196
197    /// Every file of the skill with the record of who installed it, the
198    /// digest left empty. [`crate::record`] fills it in.
199    pub(crate) fn files_with_record(
200        &self,
201        program: &str,
202        version: &str,
203    ) -> BTreeMap<PathBuf, Vec<u8>> {
204        let mut out = self.files.clone();
205        out.insert(
206            PathBuf::from(SKILL_FILE),
207            self.render(program, version).into_bytes(),
208        );
209        out
210    }
211
212    fn render(&self, program: &str, version: &str) -> String {
213        // Writing into a String cannot fail.
214        let mut text = String::from("---\n");
215        let _ = writeln!(text, "name: {}", quote(&self.name));
216        let _ = writeln!(text, "description: {}", quote(&self.description));
217        for (key, value) in &self.fields {
218            let _ = writeln!(text, "{key}: {}", quote(value));
219        }
220        text.push_str("metadata:\n");
221        for (key, value) in &self.metadata {
222            let _ = writeln!(text, "  {key}: {}", quote(value));
223        }
224        let _ = writeln!(text, "  installed-by: {}", quote(program));
225        let _ = writeln!(text, "  installed-version: {}", quote(version));
226        text.push_str("  content-digest: \"\"\n---\n");
227        text.push_str(&self.body);
228        if !self.body.ends_with('\n') {
229            text.push('\n');
230        }
231        text
232    }
233}
234
235/// A lowercase word with hyphens, the shape every frontmatter key in the
236/// specification and the tools' documentation has.
237fn plain_key(key: &str) -> bool {
238    key.chars().next().is_some_and(|c| c.is_ascii_lowercase())
239        && key
240            .chars()
241            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
242}
243
244/// A relative path that stays inside the directory it is joined to.
245/// A path as a case-insensitive file system compares it, parts joined by
246/// `/` whatever the system's separator.
247fn folded(path: &Path) -> String {
248    folded_str(
249        &path
250            .components()
251            .map(|part| part.as_os_str().to_string_lossy())
252            .collect::<Vec<_>>()
253            .join("/"),
254    )
255}
256
257fn folded_str(text: &str) -> String {
258    text.to_lowercase()
259}
260
261pub fn contained(path: &Path) -> bool {
262    !path.as_os_str().is_empty()
263        && path
264            .components()
265            .all(|part| matches!(part, Component::Normal(name) if !name.is_empty()))
266        // A backslash is a separator on Windows and a file name character
267        // elsewhere; refused everywhere so the same skill means the same files.
268        && !path.to_string_lossy().contains('\\')
269}
270
271/// A YAML double-quoted string.
272///
273/// Every value goes through this, so no value can end the frontmatter, start
274/// a new key or carry a newline into the record.
275pub fn quote(value: &str) -> String {
276    let mut out = String::with_capacity(value.len() + 2);
277    out.push('"');
278    for c in value.chars() {
279        match c {
280            '"' => out.push_str("\\\""),
281            '\\' => out.push_str("\\\\"),
282            '\n' => out.push_str("\\n"),
283            '\r' => out.push_str("\\r"),
284            '\t' => out.push_str("\\t"),
285            c if c.is_control() => {
286                let _ = write!(out, "\\u{:04x}", u32::from(c));
287            }
288            c => out.push(c),
289        }
290    }
291    out.push('"');
292    out
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn base() -> Skill {
300        Skill::new("devserial", "Serial terminal.", "body\n").expect("skill")
301    }
302
303    /// A key given twice is refused; YAML forbids it and tools disagree on
304    /// which value wins.
305    #[test]
306    fn a_key_given_twice_is_refused() {
307        let once = base().with_field("license", "MIT").expect("first");
308        assert_eq!(
309            once.with_field("license", "GPL-3.0").err(),
310            Some(SkillError::Duplicate("license".into()))
311        );
312        let once = base()
313            .with_metadata("opencode/slash", "true")
314            .expect("first");
315        assert_eq!(
316            once.with_metadata("opencode/slash", "false").err(),
317            Some(SkillError::Duplicate("opencode/slash".into()))
318        );
319    }
320
321    /// Paths that differ only in letter case are one file on macOS and
322    /// Windows, so the second is refused, and so is a `skill.md`.
323    #[test]
324    fn paths_differing_only_in_case_are_refused() {
325        let one = base().with_file("references/cli.md", "a").expect("first");
326        assert_eq!(
327            one.clone().with_file("References/CLI.md", "b").err(),
328            Some(SkillError::CaseCollision(PathBuf::from(
329                "References/CLI.md"
330            )))
331        );
332        assert_eq!(
333            one.clone().with_file("skill.md", "c").err(),
334            Some(SkillError::CaseCollision(PathBuf::from("skill.md")))
335        );
336        assert!(one.with_file("references/api.md", "d").is_ok());
337    }
338}