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
19pub use crate::digest::Digest;
20use crate::embedded;
21use crate::error::RkError;
22
23/// One embedded skill: its directory name and its `SKILL.md` text.
24#[derive(Debug)]
25pub struct Skill {
26 /// The directory name, which is also the skill's `name` frontmatter.
27 pub name: String,
28 /// The authored `SKILL.md`, byte-identical to the file under `skills/`.
29 pub text: &'static str,
30}
31
32/// Every embedded skill, sorted by name.
33///
34/// # Errors
35///
36/// Returns [`RkError::Other`] when a skill directory carries no readable
37/// UTF-8 `SKILL.md`. That is a defect in the payload this binary was built
38/// from, not something a caller can correct.
39pub fn all() -> Result<Vec<Skill>, RkError> {
40 let mut out = Vec::new();
41 for dir in embedded::SKILLS.dirs() {
42 let name = dir.path().to_string_lossy().into_owned();
43 let text = dir
44 .get_file(format!("{name}/SKILL.md"))
45 .and_then(include_dir::File::contents_utf8)
46 .ok_or_else(|| anyhow::anyhow!("payload skill carries no UTF-8 SKILL.md: {name}"))?;
47 out.push(Skill { name, text });
48 }
49 out.sort_by(|a, b| a.name.cmp(&b.name));
50 Ok(out)
51}
52
53/// One shared artifact: its path under the shared root, and its bytes.
54#[derive(Debug)]
55pub struct SharedArtifact {
56 /// The path relative to the shared root, as it lands.
57 pub path: String,
58 /// The authored bytes, byte-identical to the file under `skill-shared/`.
59 pub bytes: &'static [u8],
60}
61
62/// Every artifact the skills share, sorted by path.
63///
64/// These land once, outside the agent skill roots, because every skill names
65/// the same absolute path for them. A copy per skill would be one file to
66/// correct per agent root per skill; one copy is one.
67#[must_use]
68pub fn shared() -> Vec<SharedArtifact> {
69 embedded::walk(&embedded::SKILL_SHARED)
70 .into_iter()
71 .map(|(path, bytes)| SharedArtifact { path, bytes })
72 .collect()
73}
74
75#[cfg(test)]
76mod tests {
77 #![allow(clippy::expect_used)]
78
79 use super::{all, shared};
80
81 #[test]
82 fn the_payload_carries_the_shared_plan_gate() {
83 let shared = shared();
84 assert!(
85 shared
86 .iter()
87 .any(|artifact| artifact.path == "plan-gate.md"),
88 "the payload carries no shared plan gate"
89 );
90 }
91
92 #[test]
93 fn the_payload_carries_every_authored_skill() {
94 let skills = all().expect("the embedded skills read");
95 assert!(!skills.is_empty(), "the payload carries no skills");
96 for skill in &skills {
97 assert!(
98 skill.text.contains(&format!("name: {}", skill.name)),
99 "{}: the frontmatter name differs from the directory",
100 skill.name
101 );
102 }
103 }
104}