Skip to main content

skill_embed/
skill.rs

1use std::borrow::Cow;
2use std::path::Path;
3
4use crate::tree::{self, Tree};
5use crate::{Error, Result, manifest};
6
7/// The manifest every skill directory must contain, as defined by the
8/// [Agent Skills specification](https://agentskills.io/specification).
9pub const SKILL_FILE: &str = manifest::FILE_NAME;
10
11/// One embedded skill directory.
12#[derive(Clone, Debug)]
13pub struct Skill {
14    name: String,
15    description: String,
16    dir: String,
17    digest: String,
18    tree: Tree,
19}
20
21impl Skill {
22    /// The directory name the skill is installed under.
23    ///
24    /// It comes from the `name` frontmatter field, falling back to the source
25    /// directory name.
26    #[must_use]
27    pub fn name(&self) -> &str {
28        &self.name
29    }
30
31    /// The `description` frontmatter field, if any.
32    #[must_use]
33    pub fn description(&self) -> &str {
34        &self.description
35    }
36
37    /// The skill's path inside the source tree.
38    #[must_use]
39    pub fn dir(&self) -> &str {
40        &self.dir
41    }
42
43    /// The SHA-256 of the skill's contents, recorded in the installed
44    /// `SKILL.md`.
45    #[must_use]
46    pub fn digest(&self) -> &str {
47        &self.digest
48    }
49
50    pub(crate) fn tree(&self) -> &Tree {
51        &self.tree
52    }
53}
54
55/// A collection of embedded skills, in name order.
56#[derive(Clone, Debug, Default)]
57pub struct SkillSet {
58    skills: Vec<Skill>,
59}
60
61impl SkillSet {
62    /// Reads skills from an [`include_dir`] tree.
63    ///
64    /// The macro's root is the skills directory itself, which holds one
65    /// directory per skill:
66    ///
67    /// ```no_run
68    /// use include_dir::{Dir, include_dir};
69    /// use skill_embed::SkillSet;
70    ///
71    /// static SKILLS_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/skills");
72    ///
73    /// let skills = SkillSet::from_include_dir(&SKILLS_DIR).expect("the embedded skills are valid");
74    /// ```
75    ///
76    /// # Errors
77    ///
78    /// Fails when a skill carries no [`SKILL_FILE`], when two skills claim one
79    /// name, or when a file an operating system left behind was embedded with
80    /// them.
81    #[cfg(feature = "include_dir")]
82    pub fn from_include_dir(dir: &'static include_dir::Dir<'static>) -> Result<Self> {
83        fn walk(dir: &'static include_dir::Dir<'static>, out: &mut Vec<tree::File>) {
84            for file in dir.files() {
85                out.push(tree::File {
86                    path: file.path().to_string_lossy().replace('\\', "/"),
87                    data: Cow::Borrowed(file.contents()),
88                    executable: false,
89                });
90            }
91            for sub in dir.dirs() {
92                walk(sub, out);
93            }
94        }
95        let mut files = Vec::new();
96        walk(dir, &mut files);
97        Self::from_tree_files(files)
98    }
99
100    /// Reads skills from a directory on disk.
101    ///
102    /// This is what a tool that does not embed its skills uses, and what a test
103    /// uses to build the set a fixture describes.
104    ///
105    /// # Errors
106    ///
107    /// Fails for the reasons [`SkillSet::from_include_dir`] does, and when the
108    /// directory cannot be read.
109    pub fn read_dir(root: &Path) -> Result<Self> {
110        let files = tree::read_dir_raw(root)
111            .map_err(|e| Error::io(format!("read {}", root.display()), e))?;
112        Self::from_tree_files(files)
113    }
114
115    /// Reads skills from an arbitrary set of files, each path separated with
116    /// `/` and relative to the skills directory.
117    ///
118    /// It is what [`SkillSet::from_include_dir`] and [`SkillSet::read_dir`] are
119    /// written in terms of, and what a tool embedding its skills some other way
120    /// uses.
121    ///
122    /// # Errors
123    ///
124    /// Fails when a skill carries no [`SKILL_FILE`], when two skills claim one
125    /// name, or when a file an operating system left behind was embedded with
126    /// them.
127    pub fn from_files(files: impl IntoIterator<Item = File>) -> Result<Self> {
128        Self::from_tree_files(files.into_iter().map(|f| f.0).collect())
129    }
130
131    fn from_tree_files(files: Vec<tree::File>) -> Result<Self> {
132        // Two files at one path is a caller's mistake. Caught here, where the
133        // message can name the path: the write stages into a fresh directory
134        // and creates each file new, so the second one would otherwise fail
135        // half way through a run with nothing but "file exists".
136        let mut paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
137        paths.sort_unstable();
138        if let Some([path, _]) = paths.windows(2).find(|w| w[0] == w[1]) {
139            return Err(Error::Skills(format!("{path} was given twice")));
140        }
141
142        let junk: Vec<String> =
143            files.iter().filter(|f| tree::is_junk(&f.path)).map(|f| f.path.clone()).collect();
144        let whole = Tree::from_files(files);
145        let mut skills = if whole.get(SKILL_FILE).is_some() {
146            vec![read_skill(&whole, "")?]
147        } else {
148            let mut dirs: Vec<&str> = whole
149                .files()
150                .iter()
151                .filter_map(|f| f.path.split_once('/').map(|(d, _)| d))
152                .collect();
153            dirs.dedup();
154            dirs.iter()
155                .filter(|d| whole.get(&format!("{d}/{SKILL_FILE}")).is_some())
156                .map(|d| read_skill(&whole, d))
157                .collect::<Result<Vec<_>>>()?
158        };
159        if skills.is_empty() {
160            return Err(Error::Skills(format!("no {SKILL_FILE} found among the embedded files")));
161        }
162        // Refused rather than skipped, but only inside a skill. An installed
163        // skill sits where a file browser can reach it, and one of these
164        // appearing beside it is not the user editing the skill. An embedded
165        // one is different: it was committed, and it ships to everyone.
166        //
167        // One that is not inside any skill ships too, and is never installed.
168        // Failing the whole set over it would crash a user's binary over a file
169        // the skills do not contain.
170        let inside = |p: &String| {
171            skills
172                .iter()
173                // The separator matters: `demo-other/x` is not inside `demo`.
174                .any(|sk| sk.dir.is_empty() || p.starts_with(&format!("{}/", sk.dir)))
175        };
176        if let Some(path) = junk.iter().find(|p| inside(p)) {
177            return Err(Error::Skills(format!(
178                "{path} was left behind by an operating system and was embedded with a skill. \
179                 Remove the file"
180            )));
181        }
182        skills.sort_by(|a, b| a.name.cmp(&b.name));
183        check_names(&skills)?;
184        Ok(Self { skills })
185    }
186
187    /// The skills in the set.
188    #[must_use]
189    pub fn skills(&self) -> &[Skill] {
190        &self.skills
191    }
192
193    /// Finds a skill by name.
194    #[must_use]
195    pub fn get(&self, name: &str) -> Option<&Skill> {
196        self.skills.iter().find(|s| s.name == name)
197    }
198
199    /// The skill names, in the same order as [`SkillSet::skills`].
200    pub fn names(&self) -> impl Iterator<Item = &str> {
201        self.skills.iter().map(Skill::name)
202    }
203
204    /// How many skills the set holds.
205    #[must_use]
206    pub fn len(&self) -> usize {
207        self.skills.len()
208    }
209
210    /// Whether the set holds no skills.
211    #[must_use]
212    pub fn is_empty(&self) -> bool {
213        self.skills.is_empty()
214    }
215}
216
217/// One file on its way into a [`SkillSet`].
218#[derive(Clone, Debug)]
219pub struct File(tree::File);
220
221impl File {
222    /// Names a file by its path relative to the skills directory, separated
223    /// with `/`.
224    #[must_use]
225    pub fn new(path: impl Into<String>, data: impl Into<Cow<'static, [u8]>>) -> Self {
226        Self(tree::File { path: path.into(), data: data.into(), executable: false })
227    }
228}
229
230fn read_skill(whole: &Tree, dir: &str) -> Result<Skill> {
231    let path = if dir.is_empty() { SKILL_FILE.to_owned() } else { format!("{dir}/{SKILL_FILE}") };
232    let src = whole.get(&path).ok_or_else(|| Error::Skills(format!("{path} is missing")))?;
233    let fields = manifest::fields(src);
234    let name = match fields.get("name") {
235        Some(name) if !name.is_empty() => name.clone(),
236        // A skill in a directory takes that directory's name. One at the root
237        // of the tree has no directory to take a name from, so it has to carry
238        // one.
239        _ if dir.is_empty() => {
240            return Err(Error::Skills(format!(
241                "the {SKILL_FILE} at the root of the tree has no `name` field, \
242                 and there is no directory to take one from"
243            )));
244        }
245        _ => dir.rsplit('/').next().unwrap_or(dir).to_owned(),
246    };
247    check_name(&name).map_err(|e| Error::Skills(format!("{dir}: {e}")))?;
248
249    let tree = if dir.is_empty() { whole.clone() } else { whole.subtree(dir) };
250    Ok(Skill {
251        digest: tree.digest(),
252        description: fields.get("description").cloned().unwrap_or_default(),
253        dir: dir.to_owned(),
254        name,
255        tree,
256    })
257}
258
259/// Rejects anything that could escape the destination directory or be hidden
260/// from the agent that reads it.
261fn check_name(name: &str) -> Result<(), String> {
262    if name.is_empty() || name == "." || name == ".." {
263        return Err(format!("invalid skill name {name:?}"));
264    }
265    if name.contains(['/', '\\']) {
266        return Err(format!("skill name {name:?} must not contain a path separator"));
267    }
268    if name.starts_with('.') {
269        return Err(format!("skill name {name:?} must not start with a dot"));
270    }
271    Ok(())
272}
273
274/// Names are compared without case, because the file system they are installed
275/// on usually is. Two skills differing only in case would install over each
276/// other, and every run would flip the directory's contents while reporting
277/// success.
278fn check_names(skills: &[Skill]) -> Result<()> {
279    let mut seen: Vec<(String, &Skill)> = Vec::new();
280    for sk in skills {
281        let key = sk.name.to_lowercase();
282        if let Some((_, prev)) = seen.iter().find(|(k, _)| *k == key) {
283            return Err(Error::Skills(if prev.name == sk.name {
284                format!("{} and {} both declare the skill name {:?}", prev.dir, sk.dir, sk.name)
285            } else {
286                format!(
287                    "{} and {} declare {:?} and {:?}, which differ only in case \
288                     and install over each other on a case insensitive file system",
289                    prev.dir, sk.dir, prev.name, sk.name
290                )
291            }));
292        }
293        seen.push((key, sk));
294    }
295    Ok(())
296}