use std::{
fs,
path::{Path, PathBuf},
};
use serde::Serialize;
use crate::{Error, Host, refusal, unsafe_failure};
struct Asset {
relative_path: &'static str,
template: &'static str,
}
macro_rules! skill_asset {
($path:literal) => {
Asset {
relative_path: concat!(".claude/skills/", $path),
template: include_str!(concat!("../assets/skills/", $path)),
}
};
}
macro_rules! schema_asset {
($path:literal) => {
Asset {
relative_path: concat!("schemas/skill-evidence/", $path),
template: include_str!(concat!("../assets/schemas/", $path)),
}
};
}
const ASSETS: &[Asset] = &[
skill_asset!("skill-evidence-capture/SKILL.md"),
skill_asset!("skill-evidence-capture/agents/openai.yaml"),
skill_asset!("skill-evolution/SKILL.md"),
skill_asset!("skill-evolution/agents/openai.yaml"),
skill_asset!("skill-evolution/references/authorized-review.md"),
skill_asset!("skill-evolution-status/SKILL.md"),
skill_asset!("skill-evolution-status/agents/openai.yaml"),
skill_asset!("method-gap-research-status/SKILL.md"),
skill_asset!("method-gap-research-status/agents/openai.yaml"),
skill_asset!("method-gap-research-status/references/selection-rules.md"),
schema_asset!("event.v1.schema.json"),
schema_asset!("gate-status.v1.schema.json"),
];
fn skill_package_names() -> Vec<&'static str> {
let mut names = Vec::new();
for asset in ASSETS {
let Some(rest) = asset.relative_path.strip_prefix(".claude/skills/") else {
continue;
};
let name = rest.split('/').next().expect("a split always yields one");
if !names.contains(&name) {
names.push(name);
}
}
names
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct InstallReceipt {
pub schema_version: u8,
pub written: Vec<String>,
pub unchanged: Vec<String>,
pub replaced: Vec<String>,
pub linked: Vec<String>,
}
pub fn install(root: &Path, host: &Host, force: bool) -> Result<InstallReceipt, Error> {
let rendered: Vec<(PathBuf, String, &str)> = ASSETS
.iter()
.map(|asset| {
(
root.join(asset.relative_path),
render(asset.template, host),
asset.relative_path,
)
})
.collect();
let mut conflicts = Vec::new();
let mut written = Vec::new();
let mut unchanged = Vec::new();
let mut replaced = Vec::new();
for (destination, contents, relative_path) in &rendered {
match read_existing(destination)? {
None => written.push((*relative_path).to_owned()),
Some(existing) if &existing == contents => unchanged.push((*relative_path).to_owned()),
Some(_) => {
replaced.push((*relative_path).to_owned());
conflicts.push((*relative_path).to_owned());
}
}
}
if !conflicts.is_empty() && !force {
return Err(refusal(format!(
"{} installed file(s) differ from the ones this crate ships: {}. Re-run with --force to replace them.",
conflicts.len(),
conflicts.join(", ")
)));
}
for (destination, contents, relative_path) in &rendered {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(|error| {
unsafe_failure(format!(
"Could not create {} for {relative_path}: {error}",
parent.display()
))
})?;
}
fs::write(destination, contents)
.map_err(|error| unsafe_failure(format!("Could not write {relative_path}: {error}")))?;
}
let linked = install_discovery_links(root)?;
Ok(InstallReceipt {
schema_version: 1,
written,
unchanged,
replaced,
linked,
})
}
fn install_discovery_links(root: &Path) -> Result<Vec<String>, Error> {
let mut created = Vec::new();
for name in skill_package_names() {
let link = root.join(".agents/skills").join(name);
let destination = PathBuf::from("../../.claude/skills").join(name);
match fs::read_link(&link) {
Ok(existing) if existing == destination => continue,
Ok(_) => {
fs::remove_file(&link).map_err(|error| {
unsafe_failure(format!(
"Could not replace the discovery link {}: {error}",
link.display()
))
})?;
}
Err(_) => {}
}
if let Some(parent) = link.parent() {
fs::create_dir_all(parent).map_err(|error| {
unsafe_failure(format!(
"Could not create {} for discovery links: {error}",
parent.display()
))
})?;
}
match symlink(&destination, &link) {
Ok(()) => created.push(format!(".agents/skills/{name}")),
Err(error) if error.kind() == std::io::ErrorKind::Unsupported => return Ok(Vec::new()),
Err(error) => {
return Err(unsafe_failure(format!(
"Could not create the discovery link {}: {error}",
link.display()
)));
}
}
}
Ok(created)
}
#[cfg(unix)]
fn symlink(destination: &Path, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(destination, link)
}
#[cfg(not(unix))]
fn symlink(_destination: &Path, _link: &Path) -> std::io::Result<()> {
Err(std::io::Error::from(std::io::ErrorKind::Unsupported))
}
fn render(template: &str, host: &Host) -> String {
template
.replace("{{cargo_package}}", &host.cargo_package)
.replace("{{command}}", &host.command)
.replace("{{namespace}}", &host.namespace)
}
fn read_existing(path: &Path) -> Result<Option<String>, Error> {
match fs::read_to_string(path) {
Ok(contents) => Ok(Some(contents)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(unsafe_failure(format!(
"Could not read {} to compare it: {error}",
path.display()
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn host() -> Host {
Host {
namespace: "demo".to_owned(),
command: "demo".to_owned(),
cargo_package: "demo-cli".to_owned(),
skills_directory: PathBuf::from("/nonexistent/.claude/skills"),
}
}
#[test]
fn every_shipped_file_renders_without_leaving_a_placeholder() {
for asset in ASSETS {
let rendered = render(asset.template, &host());
assert!(
!rendered.contains("{{"),
"{} still holds a placeholder after rendering",
asset.relative_path
);
}
}
#[test]
fn install_writes_every_asset_and_reports_them() {
let root = tempfile::tempdir().expect("temporary repository root");
let receipt = install(root.path(), &host(), false).expect("install into an empty root");
assert_eq!(receipt.written.len(), ASSETS.len());
assert!(receipt.unchanged.is_empty());
assert!(receipt.replaced.is_empty());
for asset in ASSETS {
assert!(
root.path().join(asset.relative_path).is_file(),
"{} was not written",
asset.relative_path
);
}
}
#[test]
fn every_skill_package_gets_a_relative_discovery_link() {
let root = tempfile::tempdir().expect("temporary repository root");
let receipt = install(root.path(), &host(), false).expect("install into an empty root");
let names = skill_package_names();
assert_eq!(names.len(), 4, "the four packages this crate ships");
#[cfg(unix)]
{
assert_eq!(receipt.linked.len(), names.len());
for name in names {
assert_eq!(
fs::read_link(root.path().join(".agents/skills").join(name))
.expect("read discovery link"),
PathBuf::from("../../.claude/skills").join(name)
);
}
}
#[cfg(not(unix))]
assert!(receipt.linked.is_empty());
}
#[test]
fn reinstalling_leaves_a_correct_discovery_link_alone() {
let root = tempfile::tempdir().expect("temporary repository root");
install(root.path(), &host(), false).expect("first install");
let receipt = install(root.path(), &host(), false).expect("second install");
assert!(
receipt.linked.is_empty(),
"a link that already points where it should is not re-created"
);
}
#[cfg(unix)]
#[test]
fn a_discovery_link_pointing_somewhere_else_is_repaired() {
let root = tempfile::tempdir().expect("temporary repository root");
install(root.path(), &host(), false).expect("first install");
let link = root.path().join(".agents/skills/skill-evolution");
fs::remove_file(&link).expect("remove the correct link");
std::os::unix::fs::symlink("../../elsewhere", &link).expect("point it elsewhere");
let receipt = install(root.path(), &host(), false).expect("second install");
assert_eq!(
receipt.linked,
vec![".agents/skills/skill-evolution".to_owned()]
);
assert_eq!(
fs::read_link(&link).expect("read repaired link"),
PathBuf::from("../../.claude/skills/skill-evolution")
);
}
#[test]
fn reinstalling_the_same_tree_reports_every_file_unchanged() {
let root = tempfile::tempdir().expect("temporary repository root");
install(root.path(), &host(), false).expect("first install");
let receipt = install(root.path(), &host(), false).expect("second install");
assert_eq!(receipt.unchanged.len(), ASSETS.len());
assert!(receipt.written.is_empty());
}
#[test]
fn install_refuses_an_edited_file_and_leaves_it_alone() {
let root = tempfile::tempdir().expect("temporary repository root");
install(root.path(), &host(), false).expect("first install");
let edited = root.path().join(".claude/skills/skill-evolution/SKILL.md");
fs::write(&edited, "Locally edited.\n").expect("edit an installed file");
let error = install(root.path(), &host(), false).expect_err("refuse the edited file");
assert_eq!(error.class(), crate::ErrorClass::Refusal);
assert!(error.to_string().contains("--force"));
assert_eq!(
fs::read_to_string(&edited).expect("read back"),
"Locally edited.\n",
"a refusal must not have written anything"
);
}
#[test]
fn force_replaces_an_edited_file_and_names_it() {
let root = tempfile::tempdir().expect("temporary repository root");
install(root.path(), &host(), false).expect("first install");
let edited = root.path().join(".claude/skills/skill-evolution/SKILL.md");
fs::write(&edited, "Locally edited.\n").expect("edit an installed file");
let receipt = install(root.path(), &host(), true).expect("force past the edit");
assert_eq!(
receipt.replaced,
vec![".claude/skills/skill-evolution/SKILL.md".to_owned()]
);
assert_ne!(
fs::read_to_string(&edited).expect("read back"),
"Locally edited.\n"
);
}
#[test]
fn a_host_installs_its_own_names_not_the_authoring_repositorys() {
let root = tempfile::tempdir().expect("temporary repository root");
install(root.path(), &host(), false).expect("install");
let skill = fs::read_to_string(
root.path()
.join(".claude/skills/skill-evidence-capture/SKILL.md"),
)
.expect("read the installed skill");
assert!(skill.contains("`demo skills evidence`"));
assert!(skill.contains("cargo run --locked -p demo-cli"));
assert!(!skill.contains("playbench"));
}
}