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}