use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use camino::{Utf8Path, Utf8PathBuf};
use serde::Serialize;
use crate::atomic;
use crate::error::RkError;
use crate::skills::record::Record;
use crate::skills::{Digest, Skill};
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(tag = "action", rename_all = "kebab-case")]
pub enum Action {
Write {
destination: Utf8PathBuf,
},
Unchanged {
destination: Utf8PathBuf,
},
Sweep {
destination: Utf8PathBuf,
},
SweepFailed {
destination: Utf8PathBuf,
error: String,
},
Remove {
destination: Utf8PathBuf,
},
KeptEdited {
destination: Utf8PathBuf,
},
KeptDirectory {
directory: Utf8PathBuf,
},
RecordUnwritten {
record: Utf8PathBuf,
},
}
struct Planned {
destination: Utf8PathBuf,
bytes: &'static [u8],
}
#[derive(Debug, Clone)]
pub struct Layout {
pub roots: Vec<Utf8PathBuf>,
pub every_root: Vec<Utf8PathBuf>,
pub shared: Utf8PathBuf,
pub record: Utf8PathBuf,
}
fn plan_roots(roots: &[Utf8PathBuf], skills: &[Skill]) -> Vec<Planned> {
let mut planned = Vec::new();
for root in roots {
for skill in skills {
planned.push(Planned {
destination: root.join(&skill.name).join("SKILL.md"),
bytes: skill.text.as_bytes(),
});
}
}
planned
}
fn plan_shared(shared: &Utf8Path) -> Vec<Planned> {
crate::skills::shared()
.into_iter()
.map(|artifact| Planned {
destination: shared.join(&artifact.path),
bytes: artifact.bytes,
})
.collect()
}
fn check_shared_root(shared: &Utf8Path, record: &Utf8Path) -> Result<(), RkError> {
let Some(state_dir) = record.parent() else {
return Ok(());
};
let mut current = Some(shared);
while let Some(dir) = current {
if !dir.starts_with(state_dir) {
break;
}
if dir.is_symlink() {
return Err(RkError::Refused(format!(
"the shared root is reached through a symlink, and nothing was written: {dir}"
)));
}
current = dir.parent();
}
Ok(())
}
fn check_destination(destination: &Utf8Path) -> Result<(), RkError> {
if destination.is_symlink() {
return Err(RkError::Refused(format!(
"destination is a symlink, and nothing was written: {destination}"
)));
}
if destination.exists() && !destination.is_file() {
return Err(RkError::Refused(format!(
"destination is not a regular file, and nothing was written: {destination}"
)));
}
Ok(())
}
fn conflicts(planned: &[Planned], record: &Record) -> Result<Vec<String>, RkError> {
let mut conflicts = Vec::new();
for entry in planned {
if !entry.destination.is_file() {
continue;
}
let found = fs::read(&entry.destination)?;
if found == entry.bytes || record.wrote(&entry.destination, &Digest::of(&found)) {
continue;
}
conflicts.push(entry.destination.to_string());
}
Ok(conflicts)
}
fn leftovers(roots: &[Utf8PathBuf], record: &Record, keep: &[Utf8PathBuf]) -> Vec<Utf8PathBuf> {
let kept: BTreeSet<&Utf8Path> = keep.iter().map(Utf8PathBuf::as_path).collect();
record
.written
.iter()
.filter(|(destination, digest)| {
!kept.contains(destination.as_path())
&& roots.iter().any(|root| destination.starts_with(root))
&& !destination.is_symlink()
&& destination.is_file()
&& fs::read(destination).is_ok_and(|found| Digest::of(&found) == **digest)
})
.map(|(destination, _)| destination.clone())
.collect()
}
fn write_file(path: &Utf8Path, bytes: &[u8]) -> std::io::Result<()> {
atomic::write(path.as_std_path(), bytes)
}
fn remove_installed(destination: &Utf8Path) -> Result<Option<Utf8PathBuf>, RkError> {
fs::remove_file(destination)?;
let Some(directory) = destination.parent() else {
return Ok(None);
};
if fs::read_dir(directory)?.next().is_none() {
fs::remove_dir(directory)?;
return Ok(None);
}
Ok(Some(directory.to_owned()))
}
fn rollback(backups: &BTreeMap<Utf8PathBuf, Option<Vec<u8>>>) -> Vec<Utf8PathBuf> {
let mut unrestored = Vec::new();
for (destination, previous) in backups {
let restored = previous.as_ref().map_or_else(
|| !destination.exists() || fs::remove_file(destination).is_ok(),
|bytes| {
fs::read(destination).is_ok_and(|found| &found == bytes)
|| write_file(destination, bytes).is_ok()
},
);
if !restored {
unrestored.push(destination.clone());
}
}
unrestored
}
fn abort(unrestored: &[Utf8PathBuf], cause: &str) -> RkError {
if unrestored.is_empty() {
return RkError::Refused(format!(
"the install was aborted and the destinations were restored: {cause}"
));
}
let paths: Vec<&str> = unrestored.iter().map(|p| p.as_str()).collect();
RkError::Refused(format!(
"the install was aborted and restoration is incomplete; verify these by hand: {}: {cause}",
paths.join(", ")
))
}
pub fn install(layout: &Layout, apply: bool, force: bool) -> Result<Vec<Action>, RkError> {
check_shared_root(&layout.shared, &layout.record)?;
let record_path = layout.record.as_path();
let skills = crate::skills::all()?;
let mut planned = plan_roots(&layout.roots, &skills);
planned.extend(plan_shared(&layout.shared));
for entry in &planned {
check_destination(&entry.destination)?;
}
let mut record = Record::load(record_path);
let covered: Vec<Utf8PathBuf> = planned
.iter()
.map(|entry| entry.destination.clone())
.collect();
let mut scanned = layout.roots.clone();
scanned.push(layout.shared.clone());
let stale = leftovers(&scanned, &record, &covered);
if !apply {
let mut actions: Vec<Action> = covered
.into_iter()
.map(|destination| Action::Write { destination })
.collect();
actions.extend(
stale
.into_iter()
.map(|destination| Action::Sweep { destination }),
);
return Ok(actions);
}
if !force {
let conflicts = conflicts(&planned, &record)?;
if !conflicts.is_empty() {
return Err(RkError::Refused(format!(
"these destinations hold bytes this tool did not write, and nothing was written: {}; re-run with --force to overwrite",
conflicts.join(", ")
)));
}
}
let mut backups: BTreeMap<Utf8PathBuf, Option<Vec<u8>>> = BTreeMap::new();
for entry in &planned {
let previous = if entry.destination.is_file() {
Some(fs::read(&entry.destination).map_err(|source| {
RkError::Refused(format!(
"cannot back up {}, and nothing was written: {source}",
entry.destination
))
})?)
} else {
None
};
backups.insert(entry.destination.clone(), previous);
}
let mut actions = Vec::new();
for entry in &planned {
let held = backups.get(&entry.destination).and_then(Option::as_ref);
if held.is_some_and(|previous| previous == entry.bytes) {
actions.push(Action::Unchanged {
destination: entry.destination.clone(),
});
continue;
}
if let Err(source) = write_file(&entry.destination, entry.bytes) {
return Err(abort(
&rollback(&backups),
&format!("writing {} failed: {source}", entry.destination),
));
}
actions.push(Action::Write {
destination: entry.destination.clone(),
});
}
for destination in &stale {
match remove_installed(destination) {
Ok(kept) => {
actions.push(Action::Sweep {
destination: destination.clone(),
});
actions.extend(kept.map(|directory| Action::KeptDirectory { directory }));
record.written.remove(destination);
}
Err(source) => actions.push(Action::SweepFailed {
destination: destination.clone(),
error: source.to_string(),
}),
}
}
for entry in &planned {
record
.written
.insert(entry.destination.clone(), Digest::of(entry.bytes));
}
if write_file(record_path, record.to_text().as_bytes()).is_err() {
actions.push(Action::RecordUnwritten {
record: record_path.to_owned(),
});
}
Ok(actions)
}
pub fn uninstall(layout: &Layout, apply: bool) -> Result<Vec<Action>, RkError> {
check_shared_root(&layout.shared, &layout.record)?;
let record_path = layout.record.as_path();
let skills = crate::skills::all()?;
let record_found = Record::load(record_path);
let mut removable: Vec<Utf8PathBuf> = Vec::new();
let mut edited: Vec<Utf8PathBuf> = Vec::new();
let classify = |entry: &Planned,
removable: &mut Vec<Utf8PathBuf>,
edited: &mut Vec<Utf8PathBuf>|
-> Result<(), RkError> {
check_destination(&entry.destination)?;
if !entry.destination.is_file() {
return Ok(());
}
let found = fs::read(&entry.destination)?;
if found == entry.bytes || record_found.wrote(&entry.destination, &Digest::of(&found)) {
removable.push(entry.destination.clone());
} else {
edited.push(entry.destination.clone());
}
Ok(())
};
let selected = plan_roots(&layout.roots, &skills);
for entry in &selected {
classify(entry, &mut removable, &mut edited)?;
}
let going: BTreeSet<&Utf8Path> = removable.iter().map(Utf8PathBuf::as_path).collect();
let retained = plan_roots(&layout.every_root, &skills)
.iter()
.any(|entry| !going.contains(entry.destination.as_path()) && entry.destination.is_file());
let mut scanned = layout.roots.clone();
if !retained {
for entry in plan_shared(&layout.shared) {
classify(&entry, &mut removable, &mut edited)?;
}
scanned.push(layout.shared.clone());
}
let mut record = record_found;
let stale = leftovers(&scanned, &record, &removable);
if !apply {
let mut actions: Vec<Action> = removable
.into_iter()
.map(|destination| Action::Remove { destination })
.collect();
actions.extend(
stale
.into_iter()
.map(|destination| Action::Sweep { destination }),
);
actions.extend(
edited
.into_iter()
.map(|destination| Action::KeptEdited { destination }),
);
return Ok(actions);
}
removable.extend(stale);
let mut actions = Vec::new();
for destination in &removable {
let kept = remove_installed(destination)?;
actions.push(Action::Remove {
destination: destination.clone(),
});
actions.extend(kept.map(|directory| Action::KeptDirectory { directory }));
record.written.remove(destination);
}
actions.extend(
edited
.into_iter()
.map(|destination| Action::KeptEdited { destination }),
);
let recorded = if record.written.is_empty() {
fs::remove_file(record_path).or_else(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
Ok(())
} else {
Err(source)
}
})
} else {
write_file(record_path, record.to_text().as_bytes())
};
if recorded.is_err() {
actions.push(Action::RecordUnwritten {
record: record_path.to_owned(),
});
}
Ok(actions)
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use camino::Utf8PathBuf;
use super::{Action, Layout, install, leftovers, uninstall};
use crate::skills::record::{RECORD_PATH, Record};
use crate::skills::{Digest, all};
struct Home {
dir: tempfile::TempDir,
}
impl Home {
fn new() -> Self {
Self {
dir: tempfile::tempdir().expect("a scratch home exists"),
}
}
fn path(&self) -> Utf8PathBuf {
Utf8PathBuf::from_path_buf(self.dir.path().to_path_buf())
.expect("the temp path is UTF-8")
}
fn roots(&self) -> Vec<Utf8PathBuf> {
let home = self.path();
vec![home.join(".claude/skills"), home.join(".agents/skills")]
}
fn record(&self) -> Utf8PathBuf {
self.path().join(RECORD_PATH)
}
fn destination(&self, root: &str, skill: &str) -> Utf8PathBuf {
self.path().join(root).join(skill).join("SKILL.md")
}
fn shared(&self) -> Utf8PathBuf {
self.path().join(".local/state/release-kit/skills/shared")
}
fn layout(&self) -> Layout {
self.layout_for(self.roots())
}
fn layout_for(&self, roots: Vec<Utf8PathBuf>) -> Layout {
Layout {
roots,
every_root: self.roots(),
shared: self.shared(),
record: self.record(),
}
}
}
fn shared_count() -> usize {
crate::skills::shared().len()
}
fn first_skill() -> String {
all().expect("the skills read").swap_remove(0).name
}
#[test]
fn a_preview_lists_every_destination_and_writes_nothing() {
let home = Home::new();
let actions = install(&home.layout(), false, false).unwrap();
let count = all().unwrap().len();
assert_eq!(actions.len(), count * 2 + shared_count(), "{actions:?}");
assert!(
actions
.iter()
.all(|action| matches!(action, Action::Write { .. })),
"{actions:?}"
);
assert!(!home.path().join(".claude").exists());
assert!(!home.record().exists());
}
#[test]
fn an_apply_is_idempotent_and_records_what_it_wrote() {
let home = Home::new();
let first = install(&home.layout(), true, false).unwrap();
assert!(
first
.iter()
.all(|action| matches!(action, Action::Write { .. })),
"{first:?}"
);
let second = install(&home.layout(), true, false).unwrap();
assert!(
second
.iter()
.all(|action| matches!(action, Action::Unchanged { .. })),
"{second:?}"
);
let record = Record::load(&home.record());
assert_eq!(
record.written.len(),
all().unwrap().len() * 2 + shared_count()
);
}
#[test]
fn a_copy_a_previous_release_wrote_is_replaced_without_force() {
let home = Home::new();
install(&home.layout(), true, false).unwrap();
let mut stale = Record::default();
for destination in Record::load(&home.record()).written.into_keys() {
std::fs::write(&destination, "older canon bytes\n").unwrap();
stale
.written
.insert(destination, Digest::of(b"older canon bytes\n"));
}
std::fs::write(home.record(), stale.to_text()).unwrap();
install(&home.layout(), true, false).unwrap();
let text =
std::fs::read_to_string(home.destination(".claude/skills", &first_skill())).unwrap();
assert!(text.contains(&format!("name: {}", first_skill())));
}
#[test]
fn an_edit_refuses_and_names_every_conflict() {
let home = Home::new();
install(&home.layout(), true, false).unwrap();
let edited: Vec<Utf8PathBuf> = all()
.unwrap()
.iter()
.map(|skill| home.destination(".claude/skills", &skill.name))
.collect();
for destination in &edited {
std::fs::write(destination, "the user wrote this").unwrap();
}
let message = install(&home.layout(), true, false)
.unwrap_err()
.to_string();
for destination in &edited {
assert!(message.contains(destination.as_str()), "{message}");
}
for destination in &edited {
assert_eq!(
std::fs::read_to_string(destination).unwrap(),
"the user wrote this",
"a refused install must not overwrite"
);
}
install(&home.layout(), true, true).unwrap();
assert!(
std::fs::read_to_string(&edited[0])
.unwrap()
.starts_with("---")
);
}
#[cfg(unix)]
#[test]
fn a_symlinked_destination_refuses_before_anything_is_written() {
let home = Home::new();
let skill = first_skill();
let destination = home.destination(".claude/skills", &skill);
std::fs::create_dir_all(destination.parent().unwrap()).unwrap();
let elsewhere = home.path().join("elsewhere");
std::fs::write(&elsewhere, "the user's file\n").unwrap();
std::os::unix::fs::symlink(&elsewhere, &destination).unwrap();
let message = install(&home.layout(), true, true).unwrap_err().to_string();
assert!(message.contains("symlink"), "{message}");
assert_eq!(
std::fs::read_to_string(&elsewhere).unwrap(),
"the user's file\n"
);
assert!(!home.path().join(".agents").exists());
}
#[test]
fn a_failed_write_restores_every_destination() {
let home = Home::new();
install(&home.layout(), true, false).unwrap();
let first = home.destination(".claude/skills", &first_skill());
std::fs::write(&first, "older canon bytes\n").unwrap();
let mut record = Record::load(&home.record());
record
.written
.insert(first.clone(), Digest::of(b"older canon bytes\n"));
std::fs::write(home.record(), record.to_text()).unwrap();
let blocked = home.path().join(".agents/skills").join(first_skill());
std::fs::remove_file(blocked.join("SKILL.md")).unwrap();
std::fs::remove_dir(&blocked).unwrap();
std::fs::write(&blocked, "in the way\n").unwrap();
let message = install(&home.layout(), true, false)
.unwrap_err()
.to_string();
assert!(message.contains("aborted"), "{message}");
assert_eq!(
std::fs::read_to_string(&first).unwrap(),
"older canon bytes\n",
"the first root must be restored"
);
}
#[test]
fn an_install_sweeps_a_destination_the_payload_dropped() {
let home = Home::new();
install(&home.layout(), true, false).unwrap();
let dropped = home.destination(".claude/skills", "rk-retired");
std::fs::create_dir_all(dropped.parent().unwrap()).unwrap();
std::fs::write(&dropped, "a skill a later release dropped\n").unwrap();
let mut record = Record::load(&home.record());
record.written.insert(
dropped.clone(),
Digest::of(b"a skill a later release dropped\n"),
);
std::fs::write(home.record(), record.to_text()).unwrap();
let actions = install(&home.layout(), true, false).unwrap();
assert!(
actions.contains(&Action::Sweep {
destination: dropped.clone()
}),
"{actions:?}"
);
assert!(!dropped.exists());
assert!(!dropped.parent().unwrap().exists());
assert!(!Record::load(&home.record()).written.contains_key(&dropped));
}
#[test]
fn a_sweep_leaves_an_edited_leftover_alone() {
let home = Home::new();
install(&home.layout(), true, false).unwrap();
let dropped = home.destination(".claude/skills", "rk-retired");
std::fs::create_dir_all(dropped.parent().unwrap()).unwrap();
std::fs::write(&dropped, "the user rewrote this\n").unwrap();
let mut record = Record::load(&home.record());
record
.written
.insert(dropped.clone(), Digest::of(b"what we wrote\n"));
std::fs::write(home.record(), record.to_text()).unwrap();
assert!(
!leftovers(&home.roots(), &record, &[]).contains(&dropped),
"a leftover whose bytes differ from the record is the user's"
);
install(&home.layout(), true, false).unwrap();
assert_eq!(
std::fs::read_to_string(&dropped).unwrap(),
"the user rewrote this\n"
);
}
#[test]
fn an_uninstall_keeps_an_edited_destination() {
let home = Home::new();
install(&home.layout(), true, false).unwrap();
let edited = home.destination(".claude/skills", &first_skill());
std::fs::write(&edited, "the user rewrote this\n").unwrap();
let preview = uninstall(&home.layout(), false).unwrap();
assert!(
preview.contains(&Action::KeptEdited {
destination: edited.clone()
}),
"{preview:?}"
);
assert!(
!preview.contains(&Action::Remove {
destination: edited.clone()
}),
"{preview:?}"
);
let actions = uninstall(&home.layout(), true).unwrap();
assert!(
actions.contains(&Action::KeptEdited {
destination: edited.clone()
}),
"{actions:?}"
);
assert_eq!(
std::fs::read_to_string(&edited).unwrap(),
"the user rewrote this\n",
"an uninstall must never delete a user's edit"
);
assert!(!home.destination(".agents/skills", &first_skill()).exists());
}
#[test]
fn an_uninstall_removes_what_it_wrote_and_keeps_the_rest() {
let home = Home::new();
install(&home.layout(), true, false).unwrap();
let skill = first_skill();
let beside = home
.destination(".claude/skills", &skill)
.parent()
.unwrap()
.join("notes.md");
std::fs::write(&beside, "the user's notes\n").unwrap();
let actions = uninstall(&home.layout(), true).unwrap();
assert!(
actions
.iter()
.any(|action| matches!(action, Action::KeptDirectory { .. })),
"{actions:?}"
);
assert!(!home.destination(".claude/skills", &skill).exists());
assert!(beside.is_file(), "a file beside a skill must survive");
assert!(!home.record().exists(), "an empty record is removed");
uninstall(&home.layout(), true).unwrap();
}
#[test]
fn one_root_installs_and_uninstalls_without_touching_the_other() {
let home = Home::new();
let claude = home.layout_for(vec![home.path().join(".claude/skills")]);
install(&claude, true, false).unwrap();
assert!(home.destination(".claude/skills", &first_skill()).is_file());
assert!(!home.path().join(".agents").exists());
uninstall(&claude, true).unwrap();
assert!(!home.destination(".claude/skills", &first_skill()).exists());
}
#[test]
fn either_agent_alone_still_lands_the_shared_artifacts() {
for root in [".claude/skills", ".agents/skills"] {
let home = Home::new();
let one = home.layout_for(vec![home.path().join(root)]);
install(&one, true, false).unwrap();
assert!(
home.shared().join("plan-gate.md").is_file(),
"{root}: the shared gate did not land"
);
}
}
#[test]
fn the_shared_artifacts_stay_while_another_root_still_holds_skills() {
let home = Home::new();
install(&home.layout(), true, false).unwrap();
let gate = home.shared().join("plan-gate.md");
assert!(gate.is_file());
let codex = home.layout_for(vec![home.path().join(".agents/skills")]);
uninstall(&codex, true).unwrap();
assert!(!home.destination(".agents/skills", &first_skill()).exists());
assert!(
gate.is_file(),
"the Claude skills still read the gate, so it must stay"
);
let claude = home.layout_for(vec![home.path().join(".claude/skills")]);
let actions = uninstall(&claude, true).unwrap();
assert!(
!gate.exists(),
"the last uninstall takes the gate: {actions:?}"
);
}
#[test]
fn the_last_uninstall_previews_the_shared_artifacts() {
let home = Home::new();
install(&home.layout(), true, false).unwrap();
let actions = uninstall(&home.layout(), false).unwrap();
assert!(
actions.iter().any(|action| matches!(
action,
Action::Remove { destination } if destination.file_name() == Some("plan-gate.md")
)),
"{actions:?}"
);
assert!(
home.shared().join("plan-gate.md").is_file(),
"a preview writes nothing"
);
}
#[test]
fn an_edited_shared_artifact_refuses_an_install_and_survives_an_uninstall() {
let home = Home::new();
install(&home.layout(), true, false).unwrap();
let gate = home.shared().join("plan-gate.md");
std::fs::write(&gate, b"mine now").unwrap();
let message = install(&home.layout(), true, false)
.expect_err("an edited gate refuses")
.to_string();
assert!(message.contains("plan-gate.md"), "{message}");
let actions = uninstall(&home.layout(), true).unwrap();
assert!(
actions.iter().any(|action| matches!(
action,
Action::KeptEdited { destination } if destination == &gate
)),
"{actions:?}"
);
assert_eq!(std::fs::read(&gate).unwrap(), b"mine now");
}
#[test]
fn a_symlinked_shared_destination_refuses_before_writing() {
let home = Home::new();
let gate = home.shared().join("plan-gate.md");
std::fs::create_dir_all(home.shared()).unwrap();
std::os::unix::fs::symlink("/etc/passwd", &gate).unwrap();
let message = install(&home.layout(), true, false)
.expect_err("a symlink refuses")
.to_string();
assert!(message.contains("symlink"), "{message}");
assert!(
!home.destination(".claude/skills", &first_skill()).exists(),
"the refusal must come before the first write"
);
}
#[test]
fn a_symlinked_shared_root_refuses_install_and_uninstall() {
for symlinked in ["skills", "skills/shared"] {
let home = Home::new();
let elsewhere = home.path().join("elsewhere");
std::fs::create_dir_all(&elsewhere).unwrap();
let state_dir = home.record().parent().unwrap().to_path_buf();
let linked = state_dir.join(symlinked);
std::fs::create_dir_all(linked.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(&elsewhere, &linked).unwrap();
let message = install(&home.layout(), true, false)
.expect_err("a symlinked shared root refuses an install")
.to_string();
assert!(message.contains("symlink"), "{message}");
assert!(
!elsewhere.join("plan-gate.md").exists(),
"an install must never write through a symlinked shared root"
);
assert!(!home.path().join(".claude").exists());
std::fs::write(elsewhere.join("plan-gate.md"), "theirs\n").unwrap();
let message = uninstall(&home.layout(), true)
.expect_err("a symlinked shared root refuses an uninstall")
.to_string();
assert!(message.contains("symlink"), "{message}");
assert!(
elsewhere.join("plan-gate.md").exists(),
"an uninstall must never remove through a symlinked shared root"
);
}
}
}