pub mod installer;
pub mod record;
use std::path::PathBuf;
use camino::Utf8PathBuf;
pub use crate::digest::Digest;
use crate::embedded;
use crate::error::RkError;
pub const CLAUDE_ROOT: &str = ".claude/skills";
pub const AGENTS_ROOT: &str = ".agents/skills";
pub const LEGACY_SHARED_ROOT: &str = ".local/state/release-kit/skills/shared";
pub const REFERENCES_DIR: &str = "references";
pub fn home() -> Result<Utf8PathBuf, RkError> {
let raw = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.ok_or_else(|| RkError::Refused("neither HOME nor USERPROFILE is set".into()))?;
Utf8PathBuf::from_path_buf(PathBuf::from(raw)).map_err(|path| {
RkError::Refused(format!(
"the home directory is not UTF-8: {}",
path.display()
))
})
}
#[derive(Debug)]
pub struct Skill {
pub name: String,
pub text: &'static str,
}
pub fn all() -> Result<Vec<Skill>, RkError> {
let mut out = Vec::new();
for dir in embedded::SKILLS.dirs() {
let name = dir.path().to_string_lossy().into_owned();
let text = dir
.get_file(format!("{name}/SKILL.md"))
.and_then(include_dir::File::contents_utf8)
.ok_or_else(|| anyhow::anyhow!("embedded skill carries no UTF-8 SKILL.md: {name}"))?;
out.push(Skill { name, text });
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
#[derive(Debug)]
pub struct SharedArtifact {
pub path: String,
pub bytes: &'static [u8],
}
#[must_use]
pub fn shared() -> Vec<SharedArtifact> {
embedded::walk(&embedded::SKILL_SHARED)
.into_iter()
.map(|(path, bytes)| SharedArtifact { path, bytes })
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageFile {
pub path: String,
pub bytes: &'static [u8],
pub shared: bool,
}
#[derive(Debug)]
pub struct Package {
pub name: String,
pub files: Vec<PackageFile>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PackageError {
#[error("{skill}: the skill carries no SKILL.md")]
NoManual {
skill: String,
},
#[error("{skill}: {path} is neither SKILL.md nor a Markdown file directly under references/")]
Unlisted {
skill: String,
path: String,
},
#[error("{skill}: {path} takes the name of a shared artifact under skill-shared/")]
Collision {
skill: String,
path: String,
},
}
pub fn compose(
skill: &str,
own: &[(String, &'static [u8])],
shared: &[SharedArtifact],
) -> Result<Vec<PackageFile>, PackageError> {
let mut manual = None;
let mut references = Vec::new();
for (path, bytes) in own {
if path == "SKILL.md" {
manual = Some(PackageFile {
path: path.clone(),
bytes,
shared: false,
});
continue;
}
let listed = path
.strip_prefix(&format!("{REFERENCES_DIR}/"))
.is_some_and(|name| {
!name.contains('/')
&& std::path::Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
});
if !listed {
return Err(PackageError::Unlisted {
skill: skill.to_owned(),
path: path.clone(),
});
}
references.push(PackageFile {
path: path.clone(),
bytes,
shared: false,
});
}
let manual = manual.ok_or_else(|| PackageError::NoManual {
skill: skill.to_owned(),
})?;
for artifact in shared {
let path = format!("{REFERENCES_DIR}/{}", artifact.path);
if references.iter().any(|file| file.path == path) {
return Err(PackageError::Collision {
skill: skill.to_owned(),
path,
});
}
references.push(PackageFile {
path,
bytes: artifact.bytes,
shared: true,
});
}
references.sort_by(|a, b| a.path.cmp(&b.path));
let mut files = vec![manual];
files.extend(references);
Ok(files)
}
pub fn packages() -> Result<Vec<Package>, RkError> {
let shared = shared();
let mut out = Vec::new();
for skill in all()? {
let prefix = format!("{}/", skill.name);
let own: Vec<(String, &'static [u8])> = embedded::walk(&embedded::SKILLS)
.into_iter()
.filter_map(|(path, bytes)| {
path.strip_prefix(&prefix)
.map(|relative| (relative.to_owned(), bytes))
})
.collect();
let files =
compose(&skill.name, &own, &shared).map_err(|error| anyhow::anyhow!("{error}"))?;
out.push(Package {
name: skill.name,
files,
});
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::{PackageError, SharedArtifact, all, compose, packages, shared};
#[test]
fn the_binary_carries_the_shared_plan_gate() {
let shared = shared();
assert!(
shared
.iter()
.any(|artifact| artifact.path == "plan-gate.md"),
"the binary carries no shared plan gate"
);
}
#[test]
fn the_binary_carries_every_authored_skill() {
let skills = all().expect("the embedded skills read");
assert!(!skills.is_empty(), "the binary carries no skills");
for skill in &skills {
assert!(
skill.text.contains(&format!("name: {}", skill.name)),
"{}: the frontmatter name differs from the directory",
skill.name
);
}
}
#[test]
fn every_skill_package_composes() {
let packages = packages().expect("every embedded skill composes");
for package in &packages {
assert_eq!(package.files[0].path, "SKILL.md", "{}", package.name);
for artifact in shared() {
let path = format!("references/{}", artifact.path);
assert!(
package.files.iter().any(|file| file.path == path
&& file.shared
&& file.bytes == artifact.bytes),
"{}: {path} is not carried",
package.name
);
}
}
}
fn gate() -> Vec<SharedArtifact> {
vec![SharedArtifact {
path: "plan-gate.md".to_owned(),
bytes: b"the shared gate\n",
}]
}
#[test]
fn a_name_both_sources_claim_fails_the_package() {
let own = vec![
("SKILL.md".to_owned(), b"manual\n".as_slice()),
("references/plan-gate.md".to_owned(), b"mine\n".as_slice()),
];
assert_eq!(
compose("rk-demo", &own, &gate()),
Err(PackageError::Collision {
skill: "rk-demo".to_owned(),
path: "references/plan-gate.md".to_owned(),
})
);
}
#[test]
fn a_file_outside_the_package_shape_fails_the_package() {
for path in ["scripts/check.sh", "references/deep/task.md", "notes.md"] {
let own = vec![
("SKILL.md".to_owned(), b"manual\n".as_slice()),
(path.to_owned(), b"x\n".as_slice()),
];
assert_eq!(
compose("rk-demo", &own, &gate()),
Err(PackageError::Unlisted {
skill: "rk-demo".to_owned(),
path: path.to_owned(),
}),
"{path}"
);
}
}
#[test]
fn a_package_carries_its_references_and_the_shared_copies() {
let own = vec![
("SKILL.md".to_owned(), b"manual\n".as_slice()),
("references/task.md".to_owned(), b"task\n".as_slice()),
];
let files = compose("rk-demo", &own, &gate()).expect("composes");
let paths: Vec<(&str, bool)> = files
.iter()
.map(|file| (file.path.as_str(), file.shared))
.collect();
assert_eq!(
paths,
[
("SKILL.md", false),
("references/plan-gate.md", true),
("references/task.md", false),
]
);
}
}