1pub 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
27pub const CLAUDE_ROOT: &str = ".claude/skills";
29
30pub const AGENTS_ROOT: &str = ".agents/skills";
33
34pub const LEGACY_SHARED_ROOT: &str = ".local/state/release-kit/skills/shared";
41
42pub const REFERENCES_DIR: &str = "references";
45
46pub fn home() -> Result<Utf8PathBuf, RkError> {
57 let raw = std::env::var_os("HOME")
58 .or_else(|| std::env::var_os("USERPROFILE"))
59 .ok_or_else(|| RkError::Refused("neither HOME nor USERPROFILE is set".into()))?;
60 Utf8PathBuf::from_path_buf(PathBuf::from(raw)).map_err(|path| {
61 RkError::Refused(format!(
62 "the home directory is not UTF-8: {}",
63 path.display()
64 ))
65 })
66}
67
68#[derive(Debug)]
70pub struct Skill {
71 pub name: String,
73 pub text: &'static str,
75}
76
77pub fn all() -> Result<Vec<Skill>, RkError> {
85 let mut out = Vec::new();
86 for dir in embedded::SKILLS.dirs() {
87 let name = dir.path().to_string_lossy().into_owned();
88 let text = dir
89 .get_file(format!("{name}/SKILL.md"))
90 .and_then(include_dir::File::contents_utf8)
91 .ok_or_else(|| anyhow::anyhow!("embedded skill carries no UTF-8 SKILL.md: {name}"))?;
92 out.push(Skill { name, text });
93 }
94 out.sort_by(|a, b| a.name.cmp(&b.name));
95 Ok(out)
96}
97
98#[derive(Debug)]
100pub struct SharedArtifact {
101 pub path: String,
104 pub bytes: &'static [u8],
106}
107
108#[must_use]
114pub fn shared() -> Vec<SharedArtifact> {
115 embedded::walk(&embedded::SKILL_SHARED)
116 .into_iter()
117 .map(|(path, bytes)| SharedArtifact { path, bytes })
118 .collect()
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct PackageFile {
124 pub path: String,
126 pub bytes: &'static [u8],
128 pub shared: bool,
131}
132
133#[derive(Debug)]
135pub struct Package {
136 pub name: String,
138 pub files: Vec<PackageFile>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
144pub enum PackageError {
145 #[error("{skill}: the skill carries no SKILL.md")]
147 NoManual {
148 skill: String,
150 },
151 #[error("{skill}: {path} is neither SKILL.md nor a Markdown file directly under references/")]
154 Unlisted {
155 skill: String,
157 path: String,
159 },
160 #[error("{skill}: {path} takes the name of a shared artifact under skill-shared/")]
163 Collision {
164 skill: String,
166 path: String,
168 },
169}
170
171pub fn compose(
183 skill: &str,
184 own: &[(String, &'static [u8])],
185 shared: &[SharedArtifact],
186) -> Result<Vec<PackageFile>, PackageError> {
187 let mut manual = None;
188 let mut references = Vec::new();
189 for (path, bytes) in own {
190 if path == "SKILL.md" {
191 manual = Some(PackageFile {
192 path: path.clone(),
193 bytes,
194 shared: false,
195 });
196 continue;
197 }
198 let listed = path
199 .strip_prefix(&format!("{REFERENCES_DIR}/"))
200 .is_some_and(|name| {
201 !name.contains('/')
202 && std::path::Path::new(name)
203 .extension()
204 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
205 });
206 if !listed {
207 return Err(PackageError::Unlisted {
208 skill: skill.to_owned(),
209 path: path.clone(),
210 });
211 }
212 references.push(PackageFile {
213 path: path.clone(),
214 bytes,
215 shared: false,
216 });
217 }
218 let manual = manual.ok_or_else(|| PackageError::NoManual {
219 skill: skill.to_owned(),
220 })?;
221 for artifact in shared {
222 let path = format!("{REFERENCES_DIR}/{}", artifact.path);
223 if references.iter().any(|file| file.path == path) {
224 return Err(PackageError::Collision {
225 skill: skill.to_owned(),
226 path,
227 });
228 }
229 references.push(PackageFile {
230 path,
231 bytes: artifact.bytes,
232 shared: true,
233 });
234 }
235 references.sort_by(|a, b| a.path.cmp(&b.path));
236 let mut files = vec![manual];
237 files.extend(references);
238 Ok(files)
239}
240
241pub fn packages() -> Result<Vec<Package>, RkError> {
249 let shared = shared();
250 let mut out = Vec::new();
251 for skill in all()? {
252 let prefix = format!("{}/", skill.name);
253 let own: Vec<(String, &'static [u8])> = embedded::walk(&embedded::SKILLS)
254 .into_iter()
255 .filter_map(|(path, bytes)| {
256 path.strip_prefix(&prefix)
257 .map(|relative| (relative.to_owned(), bytes))
258 })
259 .collect();
260 let files =
261 compose(&skill.name, &own, &shared).map_err(|error| anyhow::anyhow!("{error}"))?;
262 out.push(Package {
263 name: skill.name,
264 files,
265 });
266 }
267 Ok(out)
268}
269
270#[cfg(test)]
271mod tests {
272 use super::{PackageError, SharedArtifact, all, compose, packages, shared};
273
274 #[test]
275 fn the_binary_carries_the_shared_plan_gate() {
276 let shared = shared();
277 assert!(
278 shared
279 .iter()
280 .any(|artifact| artifact.path == "plan-gate.md"),
281 "the binary carries no shared plan gate"
282 );
283 }
284
285 #[test]
286 fn the_binary_carries_every_authored_skill() {
287 let skills = all().expect("the embedded skills read");
288 assert!(!skills.is_empty(), "the binary carries no skills");
289 for skill in &skills {
290 assert!(
291 skill.text.contains(&format!("name: {}", skill.name)),
292 "{}: the frontmatter name differs from the directory",
293 skill.name
294 );
295 }
296 }
297
298 #[test]
301 fn every_skill_package_composes() {
302 let packages = packages().expect("every embedded skill composes");
303 for package in &packages {
304 assert_eq!(package.files[0].path, "SKILL.md", "{}", package.name);
305 for artifact in shared() {
306 let path = format!("references/{}", artifact.path);
307 assert!(
308 package.files.iter().any(|file| file.path == path
309 && file.shared
310 && file.bytes == artifact.bytes),
311 "{}: {path} is not carried",
312 package.name
313 );
314 }
315 }
316 }
317
318 fn gate() -> Vec<SharedArtifact> {
319 vec![SharedArtifact {
320 path: "plan-gate.md".to_owned(),
321 bytes: b"the shared gate\n",
322 }]
323 }
324
325 #[test]
326 fn a_name_both_sources_claim_fails_the_package() {
327 let own = vec![
328 ("SKILL.md".to_owned(), b"manual\n".as_slice()),
329 ("references/plan-gate.md".to_owned(), b"mine\n".as_slice()),
330 ];
331 assert_eq!(
332 compose("rk-demo", &own, &gate()),
333 Err(PackageError::Collision {
334 skill: "rk-demo".to_owned(),
335 path: "references/plan-gate.md".to_owned(),
336 })
337 );
338 }
339
340 #[test]
341 fn a_file_outside_the_package_shape_fails_the_package() {
342 for path in ["scripts/check.sh", "references/deep/task.md", "notes.md"] {
343 let own = vec![
344 ("SKILL.md".to_owned(), b"manual\n".as_slice()),
345 (path.to_owned(), b"x\n".as_slice()),
346 ];
347 assert_eq!(
348 compose("rk-demo", &own, &gate()),
349 Err(PackageError::Unlisted {
350 skill: "rk-demo".to_owned(),
351 path: path.to_owned(),
352 }),
353 "{path}"
354 );
355 }
356 }
357
358 #[test]
359 fn a_package_carries_its_references_and_the_shared_copies() {
360 let own = vec![
361 ("SKILL.md".to_owned(), b"manual\n".as_slice()),
362 ("references/task.md".to_owned(), b"task\n".as_slice()),
363 ];
364 let files = compose("rk-demo", &own, &gate()).expect("composes");
365 let paths: Vec<(&str, bool)> = files
366 .iter()
367 .map(|file| (file.path.as_str(), file.shared))
368 .collect();
369 assert_eq!(
370 paths,
371 [
372 ("SKILL.md", false),
373 ("references/plan-gate.md", true),
374 ("references/task.md", false),
375 ]
376 );
377 }
378}