1pub 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
22pub const CLAUDE_ROOT: &str = ".claude/skills";
24
25pub const AGENTS_ROOT: &str = ".agents/skills";
28
29pub const LEGACY_SHARED_ROOT: &str = ".local/state/release-kit/skills/shared";
36
37pub const REFERENCES_DIR: &str = "references";
40
41pub 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#[derive(Debug)]
65pub struct Skill {
66 pub name: String,
68 pub text: &'static str,
70}
71
72pub 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#[derive(Debug)]
95pub struct SharedArtifact {
96 pub path: String,
99 pub bytes: &'static [u8],
101}
102
103#[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#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct PackageFile {
119 pub path: String,
121 pub bytes: &'static [u8],
123 pub shared: bool,
126}
127
128#[derive(Debug)]
130pub struct Package {
131 pub name: String,
133 pub files: Vec<PackageFile>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
139pub enum PackageError {
140 #[error("{skill}: the skill carries no SKILL.md")]
142 NoManual {
143 skill: String,
145 },
146 #[error("{skill}: {path} is neither SKILL.md nor a Markdown file directly under references/")]
149 Unlisted {
150 skill: String,
152 path: String,
154 },
155 #[error("{skill}: {path} takes the name of a shared artifact under skill-shared/")]
158 Collision {
159 skill: String,
161 path: String,
163 },
164}
165
166pub 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
236pub 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 #[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}