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, Package};
#[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 legacy_shared: Option<Utf8PathBuf>,
pub record: Utf8PathBuf,
}
fn plan_roots(roots: &[Utf8PathBuf], packages: &[Package]) -> Vec<Planned> {
let mut planned = Vec::new();
for root in roots {
for package in packages {
for file in &package.files {
planned.push(Planned {
destination: root.join(&package.name).join(&file.path),
bytes: file.bytes,
});
}
}
}
planned
}
fn check_legacy_root(legacy: Option<&Utf8Path>, record: &Utf8Path) -> Result<(), RkError> {
let (Some(legacy), Some(state_dir)) = (legacy, record.parent()) else {
return Ok(());
};
let mut current = Some(legacy);
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 legacy_kept(legacy: Option<&Utf8Path>, swept: &[Utf8PathBuf]) -> Vec<Utf8PathBuf> {
let Some(legacy) = legacy else {
return Vec::new();
};
crate::skills::shared()
.into_iter()
.map(|artifact| legacy.join(&artifact.path))
.filter(|path| path.is_file() && !path.is_symlink() && !swept.contains(path))
.collect()
}
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(
destinations: &[Utf8PathBuf],
stops: &[Utf8PathBuf],
) -> Result<Vec<Utf8PathBuf>, (Utf8PathBuf, std::io::Error)> {
let mut parents: BTreeSet<Utf8PathBuf> = BTreeSet::new();
for destination in destinations {
fs::remove_file(destination).map_err(|source| (destination.clone(), source))?;
if let Some(parent) = destination.parent() {
parents.insert(parent.to_owned());
}
}
let mut candidates: Vec<Utf8PathBuf> = Vec::new();
for parent in &parents {
let mut current = Some(parent.as_path());
while let Some(dir) = current {
if stops.iter().any(|stop| stop == dir)
|| !stops.iter().any(|stop| dir.starts_with(stop))
{
break;
}
if !candidates.iter().any(|seen| seen == dir) {
candidates.push(dir.to_owned());
}
current = dir.parent();
}
}
candidates.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
for dir in &candidates {
if fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
let _ = fs::remove_dir(dir);
}
}
Ok(parents.into_iter().filter(|dir| dir.is_dir()).collect())
}
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_legacy_root(layout.legacy_shared.as_deref(), &layout.record)?;
let record_path = layout.record.as_path();
let packages = crate::skills::packages()?;
let planned = plan_roots(&layout.roots, &packages);
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.extend(layout.legacy_shared.clone());
let stale = leftovers(&scanned, &record, &covered);
let kept = legacy_kept(layout.legacy_shared.as_deref(), &stale);
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 }),
);
actions.extend(
kept.into_iter()
.map(|destination| Action::KeptEdited { 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 backups = back_up(&planned)?;
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(),
});
}
let stops = sweep_stops(layout);
for destination in &stale {
match remove_installed(std::slice::from_ref(destination), &stops) {
Ok(kept_dirs) => {
actions.push(Action::Sweep {
destination: destination.clone(),
});
actions.extend(
kept_dirs
.into_iter()
.map(|directory| Action::KeptDirectory { directory }),
);
record.written.remove(destination);
}
Err((_, source)) => actions.push(Action::SweepFailed {
destination: destination.clone(),
error: source.to_string(),
}),
}
}
actions.extend(
kept.into_iter()
.map(|destination| Action::KeptEdited { destination }),
);
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)
}
fn back_up(planned: &[Planned]) -> Result<BTreeMap<Utf8PathBuf, Option<Vec<u8>>>, RkError> {
let mut backups = 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);
}
Ok(backups)
}
fn sweep_stops(layout: &Layout) -> Vec<Utf8PathBuf> {
let mut stops = layout.roots.clone();
if let Some(state_dir) = layout.record.parent() {
stops.push(state_dir.to_owned());
}
stops
}
pub fn uninstall(layout: &Layout, apply: bool) -> Result<Vec<Action>, RkError> {
check_legacy_root(layout.legacy_shared.as_deref(), &layout.record)?;
let record_path = layout.record.as_path();
let packages = crate::skills::packages()?;
let record_found = Record::load(record_path);
let mut removable: Vec<Utf8PathBuf> = Vec::new();
let mut edited: Vec<Utf8PathBuf> = Vec::new();
for entry in &plan_roots(&layout.roots, &packages) {
check_destination(&entry.destination)?;
if !entry.destination.is_file() {
continue;
}
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());
}
}
let mut record = record_found;
let mut scanned = layout.roots.clone();
scanned.extend(layout.legacy_shared.clone());
let stale = leftovers(&scanned, &record, &removable);
edited.extend(legacy_kept(layout.legacy_shared.as_deref(), &stale));
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 kept_dirs = remove_installed(&removable, &sweep_stops(layout))
.map_err(|(_, source)| RkError::Io(source))?;
let mut actions: Vec<Action> = Vec::new();
for destination in &removable {
actions.push(Action::Remove {
destination: destination.clone(),
});
record.written.remove(destination);
}
actions.extend(
kept_dirs
.into_iter()
.map(|directory| Action::KeptDirectory { directory }),
);
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 {
use camino::Utf8PathBuf;
use super::{Action, Layout, install, leftovers, uninstall};
use crate::skills::record::{RECORD_PATH, Record};
use crate::skills::{Digest, all, packages};
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 legacy_shared(&self) -> Utf8PathBuf {
self.path().join(".local/state/release-kit/skills/shared")
}
fn gate(&self, root: &str, skill: &str) -> Utf8PathBuf {
self.path()
.join(root)
.join(skill)
.join("references/plan-gate.md")
}
fn layout(&self) -> Layout {
self.layout_for(self.roots())
}
fn layout_for(&self, roots: Vec<Utf8PathBuf>) -> Layout {
let every = roots == self.roots();
Layout {
roots,
legacy_shared: every.then(|| self.legacy_shared()),
record: self.record(),
}
}
}
fn package_files() -> usize {
packages()
.expect("the packages compose")
.iter()
.map(|package| package.files.len())
.sum()
}
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();
assert_eq!(actions.len(), package_files() * 2, "{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(), package_files() * 2);
}
#[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_dir_all(&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_binary_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_lands_the_gates_in_its_packages() {
for (root, other) in [
(".claude/skills", ".agents/skills"),
(".agents/skills", ".claude/skills"),
] {
let home = Home::new();
let one = home.layout_for(vec![home.path().join(root)]);
install(&one, true, false).unwrap();
for skill in all().unwrap() {
assert!(
home.gate(root, &skill.name).is_file(),
"{root}/{}: the gate did not land",
skill.name
);
assert!(!home.gate(other, &skill.name).exists());
}
assert!(!home.legacy_shared().exists());
}
}
#[test]
fn an_uninstall_of_one_family_leaves_the_other_family_its_gates() {
let home = Home::new();
install(&home.layout(), true, false).unwrap();
let codex = home.layout_for(vec![home.path().join(".agents/skills")]);
uninstall(&codex, true).unwrap();
assert!(
!home
.path()
.join(".agents/skills")
.join(first_skill())
.exists()
);
assert!(home.gate(".claude/skills", &first_skill()).is_file());
}
#[test]
fn an_install_sweeps_the_legacy_shared_root_it_vouches_for() {
let home = Home::new();
let legacy = home.legacy_shared().join("plan-gate.md");
std::fs::create_dir_all(home.legacy_shared()).unwrap();
std::fs::write(&legacy, "an earlier release's gate\n").unwrap();
let mut record = Record::default();
record
.written
.insert(legacy.clone(), Digest::of(b"an earlier release's gate\n"));
std::fs::create_dir_all(home.record().parent().unwrap()).unwrap();
std::fs::write(home.record(), record.to_text()).unwrap();
let preview = install(&home.layout(), false, false).unwrap();
assert!(
preview.contains(&Action::Sweep {
destination: legacy.clone()
}),
"{preview:?}"
);
install(&home.layout(), true, false).unwrap();
assert!(!legacy.exists());
assert!(!home.legacy_shared().exists());
assert!(
!home.legacy_shared().parent().unwrap().exists(),
"the emptied skills directory goes too"
);
assert!(!Record::load(&home.record()).written.contains_key(&legacy));
}
#[test]
fn a_one_family_install_keeps_the_legacy_gates() {
let home = Home::new();
let legacy = home.legacy_shared().join("plan-gate.md");
std::fs::create_dir_all(home.legacy_shared()).unwrap();
std::fs::write(&legacy, "an earlier release's gate\n").unwrap();
let mut record = Record::default();
record
.written
.insert(legacy.clone(), Digest::of(b"an earlier release's gate\n"));
std::fs::create_dir_all(home.record().parent().unwrap()).unwrap();
std::fs::write(home.record(), record.to_text()).unwrap();
let codex = home.layout_for(vec![home.path().join(".agents/skills")]);
let preview = install(&codex, false, false).unwrap();
assert!(
!preview.iter().any(|action| matches!(
action,
Action::Sweep { destination } if destination == &legacy
)),
"{preview:?}"
);
install(&codex, true, false).unwrap();
assert!(legacy.is_file());
uninstall(&codex, true).unwrap();
assert!(legacy.is_file());
assert!(Record::load(&home.record()).written.contains_key(&legacy));
}
#[test]
fn an_install_leaves_and_names_an_unvouched_legacy_gate() {
let home = Home::new();
let legacy = home.legacy_shared().join("plan-gate.md");
std::fs::create_dir_all(home.legacy_shared()).unwrap();
std::fs::write(&legacy, "the user's own gate\n").unwrap();
let actions = install(&home.layout(), true, false).unwrap();
assert!(
actions.contains(&Action::KeptEdited {
destination: legacy.clone()
}),
"{actions:?}"
);
assert_eq!(
std::fs::read_to_string(&legacy).unwrap(),
"the user's own gate\n"
);
}
#[test]
fn an_edited_gate_copy_refuses_an_install_and_survives_an_uninstall() {
let home = Home::new();
install(&home.layout(), true, false).unwrap();
let gate = home.gate(".claude/skills", &first_skill());
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_gate_copy_refuses_before_writing() {
let home = Home::new();
let gate = home.gate(".agents/skills", &first_skill());
std::fs::create_dir_all(gate.parent().unwrap()).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"
);
}
}
}