Skip to main content

release_kit/
skills.rs

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