Skip to main content

release_kit/
skills.rs

1//! The agent skills: the payload, 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 holding what the skills share, relative to the home directory.
35///
36/// Home-relative rather than `XDG_STATE_HOME`-relative for the reason the
37/// record states: the skills naming these artifacts live under `$HOME/.claude`
38/// and `$HOME/.agents`, which no XDG variable moves, and a shared file
39/// reachable under a different home than the skills reading it would be worse
40/// than no shared file at all.
41pub const SHARED_ROOT: &str = ".local/state/release-kit/skills/shared";
42
43/// The home directory, from the environment.
44///
45/// A home that is not UTF-8 refuses rather than proceeding: the record names
46/// its destinations as text, so a path it cannot write down is a path it
47/// cannot later vouch for.
48///
49/// # Errors
50///
51/// Returns [`RkError::Refused`] when no home variable is set, and when the
52/// home directory is not UTF-8.
53pub fn home() -> Result<Utf8PathBuf, RkError> {
54    let raw = std::env::var_os("HOME")
55        .or_else(|| std::env::var_os("USERPROFILE"))
56        .ok_or_else(|| RkError::Refused("neither HOME nor USERPROFILE is set".into()))?;
57    Utf8PathBuf::from_path_buf(PathBuf::from(raw)).map_err(|path| {
58        RkError::Refused(format!(
59            "the home directory is not UTF-8: {}",
60            path.display()
61        ))
62    })
63}
64
65/// One embedded skill: its directory name and its `SKILL.md` text.
66#[derive(Debug)]
67pub struct Skill {
68    /// The directory name, which is also the skill's `name` frontmatter.
69    pub name: String,
70    /// The authored `SKILL.md`, byte-identical to the file under `skills/`.
71    pub text: &'static str,
72}
73
74/// Every embedded skill, sorted by name.
75///
76/// # Errors
77///
78/// Returns [`RkError::Other`] when a skill directory carries no readable
79/// UTF-8 `SKILL.md`. That is a defect in the payload this binary was built
80/// from, not something a caller can correct.
81pub fn all() -> Result<Vec<Skill>, RkError> {
82    let mut out = Vec::new();
83    for dir in embedded::SKILLS.dirs() {
84        let name = dir.path().to_string_lossy().into_owned();
85        let text = dir
86            .get_file(format!("{name}/SKILL.md"))
87            .and_then(include_dir::File::contents_utf8)
88            .ok_or_else(|| anyhow::anyhow!("payload skill carries no UTF-8 SKILL.md: {name}"))?;
89        out.push(Skill { name, text });
90    }
91    out.sort_by(|a, b| a.name.cmp(&b.name));
92    Ok(out)
93}
94
95/// One shared artifact: its path under the shared root, and its bytes.
96#[derive(Debug)]
97pub struct SharedArtifact {
98    /// The path relative to the shared root, as it lands.
99    pub path: String,
100    /// The authored bytes, byte-identical to the file under `skill-shared/`.
101    pub bytes: &'static [u8],
102}
103
104/// Every artifact the skills share, sorted by path.
105///
106/// These land once, outside the agent skill roots, because every skill names
107/// the same absolute path for them. A copy per skill would be one file to
108/// correct per agent root per skill; one copy is one.
109#[must_use]
110pub fn shared() -> Vec<SharedArtifact> {
111    embedded::walk(&embedded::SKILL_SHARED)
112        .into_iter()
113        .map(|(path, bytes)| SharedArtifact { path, bytes })
114        .collect()
115}
116
117#[cfg(test)]
118mod tests {
119    #![allow(clippy::expect_used)]
120
121    use super::{all, shared};
122
123    #[test]
124    fn the_payload_carries_the_shared_plan_gate() {
125        let shared = shared();
126        assert!(
127            shared
128                .iter()
129                .any(|artifact| artifact.path == "plan-gate.md"),
130            "the payload carries no shared plan gate"
131        );
132    }
133
134    #[test]
135    fn the_payload_carries_every_authored_skill() {
136        let skills = all().expect("the embedded skills read");
137        assert!(!skills.is_empty(), "the payload carries no skills");
138        for skill in &skills {
139            assert!(
140                skill.text.contains(&format!("name: {}", skill.name)),
141                "{}: the frontmatter name differs from the directory",
142                skill.name
143            );
144        }
145    }
146}