Skip to main content

release_kit/
skills.rs

1//! The agent skills: the embedded files, the user-scope record, and the installer.
2//!
3//! Skills land under the invoking user's home and never into a target
4//! repository. An agent resolves a skill by name across scopes, so a second
5//! copy under one name is a second entry offering the same skill, with no way
6//! for the operator to tell which one runs. One installed binary already
7//! serves every repository, and the skills routing into it belong at the same
8//! scope.
9//!
10//! That places every destination outside the reach of `rk init`, which has a
11//! target directory to compare against and a landing to refuse. Here there is
12//! no target and no manifest, so [`record`] stands in for one: it answers the
13//! single question the installer cannot otherwise answer — are these bytes
14//! ones we wrote?
15
16pub mod installer;
17pub mod record;
18
19use std::path::PathBuf;
20
21use camino::Utf8PathBuf;
22
23pub use crate::digest::Digest;
24use crate::embedded;
25use crate::error::RkError;
26
27/// The root Claude Code reads, relative to the home directory.
28pub const CLAUDE_ROOT: &str = ".claude/skills";
29
30/// The root Codex, Gemini CLI, and Copilot read, relative to the home
31/// directory.
32pub const AGENTS_ROOT: &str = ".agents/skills";
33
34/// The root an earlier release installed the shared gates to, relative to
35/// the home directory.
36///
37/// No install writes here any more: each package carries its own copy of
38/// the gates. An install and an uninstall still scan it, so the copies an
39/// earlier release wrote are swept where the record vouches for them.
40pub const LEGACY_SHARED_ROOT: &str = ".local/state/release-kit/skills/shared";
41
42/// The directory inside a package that holds its reference files, the shared
43/// gates' copies among them.
44pub const REFERENCES_DIR: &str = "references";
45
46/// The home directory, from the environment.
47///
48/// A home that is not UTF-8 refuses rather than proceeding: the record names
49/// its destinations as text, so a path it cannot write down is a path it
50/// cannot later vouch for.
51///
52/// # Errors
53///
54/// Returns [`RkError::Refused`] when no home variable is set, and when the
55/// home directory is not UTF-8.
56pub fn home() -> Result<Utf8PathBuf, RkError> {
57    let raw = std::env::var_os("HOME")
58        .or_else(|| std::env::var_os("USERPROFILE"))
59        .ok_or_else(|| RkError::Refused("neither HOME nor USERPROFILE is set".into()))?;
60    Utf8PathBuf::from_path_buf(PathBuf::from(raw)).map_err(|path| {
61        RkError::Refused(format!(
62            "the home directory is not UTF-8: {}",
63            path.display()
64        ))
65    })
66}
67
68/// One embedded skill: its directory name and its `SKILL.md` text.
69#[derive(Debug)]
70pub struct Skill {
71    /// The directory name, which is also the skill's `name` frontmatter.
72    pub name: String,
73    /// The authored `SKILL.md`, byte-identical to the file under `skills/`.
74    pub text: &'static str,
75}
76
77/// Every embedded skill, sorted by name.
78///
79/// # Errors
80///
81/// Returns [`RkError::Other`] when a skill directory carries no readable
82/// UTF-8 `SKILL.md`. That is a defect in the sources this binary was built
83/// from, not something a caller can correct.
84pub fn all() -> Result<Vec<Skill>, RkError> {
85    let mut out = Vec::new();
86    for dir in embedded::SKILLS.dirs() {
87        let name = dir.path().to_string_lossy().into_owned();
88        let text = dir
89            .get_file(format!("{name}/SKILL.md"))
90            .and_then(include_dir::File::contents_utf8)
91            .ok_or_else(|| anyhow::anyhow!("embedded skill carries no UTF-8 SKILL.md: {name}"))?;
92        out.push(Skill { name, text });
93    }
94    out.sort_by(|a, b| a.name.cmp(&b.name));
95    Ok(out)
96}
97
98/// One shared artifact: its file name, and its bytes.
99#[derive(Debug)]
100pub struct SharedArtifact {
101    /// The file name under `skill-shared/`, which is also its name under a
102    /// package's `references/`.
103    pub path: String,
104    /// The authored bytes, byte-identical to the file under `skill-shared/`.
105    pub bytes: &'static [u8],
106}
107
108/// Every artifact the skills share, sorted by path.
109///
110/// Authored once under `skill-shared/` and copied into every package, so a
111/// gate fix still lands in one file while each installed skill reads only
112/// inside its own directory.
113#[must_use]
114pub fn shared() -> Vec<SharedArtifact> {
115    embedded::walk(&embedded::SKILL_SHARED)
116        .into_iter()
117        .map(|(path, bytes)| SharedArtifact { path, bytes })
118        .collect()
119}
120
121/// One file of an installed skill package.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct PackageFile {
124    /// The path relative to the skill directory, as it lands.
125    pub path: String,
126    /// The embedded bytes that belong there.
127    pub bytes: &'static [u8],
128    /// Whether the file is a copy of a shared artifact rather than the
129    /// skill's own.
130    pub shared: bool,
131}
132
133/// One installed skill: its name and every file its directory carries.
134#[derive(Debug)]
135pub struct Package {
136    /// The skill's directory name.
137    pub name: String,
138    /// `SKILL.md` first, then the reference files, sorted by path.
139    pub files: Vec<PackageFile>,
140}
141
142/// Why a skill's authored files do not compose into a package.
143#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
144pub enum PackageError {
145    /// The skill directory carries no `SKILL.md`.
146    #[error("{skill}: the skill carries no SKILL.md")]
147    NoManual {
148        /// The skill directory.
149        skill: String,
150    },
151    /// A file sits where a package carries nothing: anything other than
152    /// `SKILL.md` and Markdown files directly under `references/`.
153    #[error("{skill}: {path} is neither SKILL.md nor a Markdown file directly under references/")]
154    Unlisted {
155        /// The skill directory.
156        skill: String,
157        /// The file, relative to the skill directory.
158        path: String,
159    },
160    /// A skill's own reference file takes the name of a shared artifact, so
161    /// two sources claim one destination.
162    #[error("{skill}: {path} takes the name of a shared artifact under skill-shared/")]
163    Collision {
164        /// The skill directory.
165        skill: String,
166        /// The file, relative to the skill directory.
167        path: String,
168    },
169}
170
171/// Compose one package from a skill's own files and the shared artifacts.
172///
173/// `own` holds paths relative to the skill directory. The package carries
174/// `SKILL.md`, each Markdown file directly under `references/`, and every
175/// shared artifact copied under `references/`. Anything else, and any name
176/// two sources claim, is an error: a package that silently kept one copy
177/// would ship a gate that no longer matches its source.
178///
179/// # Errors
180///
181/// Returns the first [`PackageError`] the files carry.
182pub fn compose(
183    skill: &str,
184    own: &[(String, &'static [u8])],
185    shared: &[SharedArtifact],
186) -> Result<Vec<PackageFile>, PackageError> {
187    let mut manual = None;
188    let mut references = Vec::new();
189    for (path, bytes) in own {
190        if path == "SKILL.md" {
191            manual = Some(PackageFile {
192                path: path.clone(),
193                bytes,
194                shared: false,
195            });
196            continue;
197        }
198        let listed = path
199            .strip_prefix(&format!("{REFERENCES_DIR}/"))
200            .is_some_and(|name| {
201                !name.contains('/')
202                    && std::path::Path::new(name)
203                        .extension()
204                        .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
205            });
206        if !listed {
207            return Err(PackageError::Unlisted {
208                skill: skill.to_owned(),
209                path: path.clone(),
210            });
211        }
212        references.push(PackageFile {
213            path: path.clone(),
214            bytes,
215            shared: false,
216        });
217    }
218    let manual = manual.ok_or_else(|| PackageError::NoManual {
219        skill: skill.to_owned(),
220    })?;
221    for artifact in shared {
222        let path = format!("{REFERENCES_DIR}/{}", artifact.path);
223        if references.iter().any(|file| file.path == path) {
224            return Err(PackageError::Collision {
225                skill: skill.to_owned(),
226                path,
227            });
228        }
229        references.push(PackageFile {
230            path,
231            bytes: artifact.bytes,
232            shared: true,
233        });
234    }
235    references.sort_by(|a, b| a.path.cmp(&b.path));
236    let mut files = vec![manual];
237    files.extend(references);
238    Ok(files)
239}
240
241/// Every embedded skill as the package an install writes, sorted by name.
242///
243/// # Errors
244///
245/// Returns [`RkError::Other`] when a skill's files do not compose. That is a
246/// defect in the sources this binary was built from, and a test holds every
247/// embedded skill to composing.
248pub fn packages() -> Result<Vec<Package>, RkError> {
249    let shared = shared();
250    let mut out = Vec::new();
251    for skill in all()? {
252        let prefix = format!("{}/", skill.name);
253        let own: Vec<(String, &'static [u8])> = embedded::walk(&embedded::SKILLS)
254            .into_iter()
255            .filter_map(|(path, bytes)| {
256                path.strip_prefix(&prefix)
257                    .map(|relative| (relative.to_owned(), bytes))
258            })
259            .collect();
260        let files =
261            compose(&skill.name, &own, &shared).map_err(|error| anyhow::anyhow!("{error}"))?;
262        out.push(Package {
263            name: skill.name,
264            files,
265        });
266    }
267    Ok(out)
268}
269
270#[cfg(test)]
271mod tests {
272    use super::{PackageError, SharedArtifact, all, compose, packages, shared};
273
274    #[test]
275    fn the_binary_carries_the_shared_plan_gate() {
276        let shared = shared();
277        assert!(
278            shared
279                .iter()
280                .any(|artifact| artifact.path == "plan-gate.md"),
281            "the binary carries no shared plan gate"
282        );
283    }
284
285    #[test]
286    fn the_binary_carries_every_authored_skill() {
287        let skills = all().expect("the embedded skills read");
288        assert!(!skills.is_empty(), "the binary carries no skills");
289        for skill in &skills {
290            assert!(
291                skill.text.contains(&format!("name: {}", skill.name)),
292                "{}: the frontmatter name differs from the directory",
293                skill.name
294            );
295        }
296    }
297
298    /// Every embedded skill composes, and each package carries every shared
299    /// artifact under `references/`.
300    #[test]
301    fn every_skill_package_composes() {
302        let packages = packages().expect("every embedded skill composes");
303        for package in &packages {
304            assert_eq!(package.files[0].path, "SKILL.md", "{}", package.name);
305            for artifact in shared() {
306                let path = format!("references/{}", artifact.path);
307                assert!(
308                    package.files.iter().any(|file| file.path == path
309                        && file.shared
310                        && file.bytes == artifact.bytes),
311                    "{}: {path} is not carried",
312                    package.name
313                );
314            }
315        }
316    }
317
318    fn gate() -> Vec<SharedArtifact> {
319        vec![SharedArtifact {
320            path: "plan-gate.md".to_owned(),
321            bytes: b"the shared gate\n",
322        }]
323    }
324
325    #[test]
326    fn a_name_both_sources_claim_fails_the_package() {
327        let own = vec![
328            ("SKILL.md".to_owned(), b"manual\n".as_slice()),
329            ("references/plan-gate.md".to_owned(), b"mine\n".as_slice()),
330        ];
331        assert_eq!(
332            compose("rk-demo", &own, &gate()),
333            Err(PackageError::Collision {
334                skill: "rk-demo".to_owned(),
335                path: "references/plan-gate.md".to_owned(),
336            })
337        );
338    }
339
340    #[test]
341    fn a_file_outside_the_package_shape_fails_the_package() {
342        for path in ["scripts/check.sh", "references/deep/task.md", "notes.md"] {
343            let own = vec![
344                ("SKILL.md".to_owned(), b"manual\n".as_slice()),
345                (path.to_owned(), b"x\n".as_slice()),
346            ];
347            assert_eq!(
348                compose("rk-demo", &own, &gate()),
349                Err(PackageError::Unlisted {
350                    skill: "rk-demo".to_owned(),
351                    path: path.to_owned(),
352                }),
353                "{path}"
354            );
355        }
356    }
357
358    #[test]
359    fn a_package_carries_its_references_and_the_shared_copies() {
360        let own = vec![
361            ("SKILL.md".to_owned(), b"manual\n".as_slice()),
362            ("references/task.md".to_owned(), b"task\n".as_slice()),
363        ];
364        let files = compose("rk-demo", &own, &gate()).expect("composes");
365        let paths: Vec<(&str, bool)> = files
366            .iter()
367            .map(|file| (file.path.as_str(), file.shared))
368            .collect();
369        assert_eq!(
370            paths,
371            [
372                ("SKILL.md", false),
373                ("references/plan-gate.md", true),
374                ("references/task.md", false),
375            ]
376        );
377    }
378}