1use std::collections::{BTreeMap, BTreeSet};
17use std::fs;
18
19use camino::{Utf8Path, Utf8PathBuf};
20use serde::Serialize;
21
22use crate::atomic;
23use crate::error::RkError;
24use crate::skills::record::Record;
25use crate::skills::{Digest, Package};
26
27#[derive(Debug, Serialize, PartialEq, Eq)]
29#[serde(tag = "action", rename_all = "kebab-case")]
30pub enum Action {
31 Write {
33 destination: Utf8PathBuf,
35 },
36 Unchanged {
38 destination: Utf8PathBuf,
40 },
41 Sweep {
43 destination: Utf8PathBuf,
45 },
46 SweepFailed {
48 destination: Utf8PathBuf,
50 error: String,
52 },
53 Remove {
55 destination: Utf8PathBuf,
57 },
58 KeptEdited {
62 destination: Utf8PathBuf,
64 },
65 KeptDirectory {
67 directory: Utf8PathBuf,
69 },
70 RecordUnwritten {
73 record: Utf8PathBuf,
75 },
76}
77
78struct Planned {
80 destination: Utf8PathBuf,
82 bytes: &'static [u8],
84}
85
86#[derive(Debug, Clone)]
95pub struct Layout {
96 pub roots: Vec<Utf8PathBuf>,
98 pub legacy_shared: Option<Utf8PathBuf>,
101 pub record: Utf8PathBuf,
103}
104
105fn plan_roots(roots: &[Utf8PathBuf], packages: &[Package]) -> Vec<Planned> {
108 let mut planned = Vec::new();
109 for root in roots {
110 for package in packages {
111 for file in &package.files {
112 planned.push(Planned {
113 destination: root.join(&package.name).join(&file.path),
114 bytes: file.bytes,
115 });
116 }
117 }
118 }
119 planned
120}
121
122fn check_legacy_root(legacy: Option<&Utf8Path>, record: &Utf8Path) -> Result<(), RkError> {
132 let (Some(legacy), Some(state_dir)) = (legacy, record.parent()) else {
133 return Ok(());
134 };
135 let mut current = Some(legacy);
136 while let Some(dir) = current {
137 if !dir.starts_with(state_dir) {
138 break;
139 }
140 if dir.is_symlink() {
141 return Err(RkError::Refused(format!(
142 "the shared root is reached through a symlink, and nothing was written: {dir}"
143 )));
144 }
145 current = dir.parent();
146 }
147 Ok(())
148}
149
150fn legacy_kept(legacy: Option<&Utf8Path>, swept: &[Utf8PathBuf]) -> Vec<Utf8PathBuf> {
156 let Some(legacy) = legacy else {
157 return Vec::new();
158 };
159 crate::skills::shared()
160 .into_iter()
161 .map(|artifact| legacy.join(&artifact.path))
162 .filter(|path| path.is_file() && !path.is_symlink() && !swept.contains(path))
163 .collect()
164}
165
166fn check_destination(destination: &Utf8Path) -> Result<(), RkError> {
171 if destination.is_symlink() {
172 return Err(RkError::Refused(format!(
173 "destination is a symlink, and nothing was written: {destination}"
174 )));
175 }
176 if destination.exists() && !destination.is_file() {
177 return Err(RkError::Refused(format!(
178 "destination is not a regular file, and nothing was written: {destination}"
179 )));
180 }
181 Ok(())
182}
183
184fn conflicts(planned: &[Planned], record: &Record) -> Result<Vec<String>, RkError> {
191 let mut conflicts = Vec::new();
192 for entry in planned {
193 if !entry.destination.is_file() {
194 continue;
195 }
196 let found = fs::read(&entry.destination)?;
199 if found == entry.bytes || record.wrote(&entry.destination, &Digest::of(&found)) {
200 continue;
201 }
202 conflicts.push(entry.destination.to_string());
203 }
204 Ok(conflicts)
205}
206
207fn leftovers(roots: &[Utf8PathBuf], record: &Record, keep: &[Utf8PathBuf]) -> Vec<Utf8PathBuf> {
215 let kept: BTreeSet<&Utf8Path> = keep.iter().map(Utf8PathBuf::as_path).collect();
216 record
217 .written
218 .iter()
219 .filter(|(destination, digest)| {
220 !kept.contains(destination.as_path())
221 && roots.iter().any(|root| destination.starts_with(root))
222 && !destination.is_symlink()
223 && destination.is_file()
224 && fs::read(destination).is_ok_and(|found| Digest::of(&found) == **digest)
225 })
226 .map(|(destination, _)| destination.clone())
227 .collect()
228}
229
230fn write_file(path: &Utf8Path, bytes: &[u8]) -> std::io::Result<()> {
233 atomic::write(path.as_std_path(), bytes)
234}
235
236fn remove_installed(
244 destinations: &[Utf8PathBuf],
245 stops: &[Utf8PathBuf],
246) -> Result<Vec<Utf8PathBuf>, (Utf8PathBuf, std::io::Error)> {
247 let mut parents: BTreeSet<Utf8PathBuf> = BTreeSet::new();
248 for destination in destinations {
249 fs::remove_file(destination).map_err(|source| (destination.clone(), source))?;
250 if let Some(parent) = destination.parent() {
251 parents.insert(parent.to_owned());
252 }
253 }
254 let mut candidates: Vec<Utf8PathBuf> = Vec::new();
257 for parent in &parents {
258 let mut current = Some(parent.as_path());
259 while let Some(dir) = current {
260 if stops.iter().any(|stop| stop == dir)
261 || !stops.iter().any(|stop| dir.starts_with(stop))
262 {
263 break;
264 }
265 if !candidates.iter().any(|seen| seen == dir) {
266 candidates.push(dir.to_owned());
267 }
268 current = dir.parent();
269 }
270 }
271 candidates.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
272 for dir in &candidates {
273 if fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
274 let _ = fs::remove_dir(dir);
275 }
276 }
277 Ok(parents.into_iter().filter(|dir| dir.is_dir()).collect())
278}
279
280fn rollback(backups: &BTreeMap<Utf8PathBuf, Option<Vec<u8>>>) -> Vec<Utf8PathBuf> {
288 let mut unrestored = Vec::new();
289 for (destination, previous) in backups {
290 let restored = previous.as_ref().map_or_else(
291 || !destination.exists() || fs::remove_file(destination).is_ok(),
292 |bytes| {
293 fs::read(destination).is_ok_and(|found| &found == bytes)
294 || write_file(destination, bytes).is_ok()
295 },
296 );
297 if !restored {
298 unrestored.push(destination.clone());
299 }
300 }
301 unrestored
302}
303
304fn abort(unrestored: &[Utf8PathBuf], cause: &str) -> RkError {
306 if unrestored.is_empty() {
307 return RkError::Refused(format!(
308 "the install was aborted and the destinations were restored: {cause}"
309 ));
310 }
311 let paths: Vec<&str> = unrestored.iter().map(|p| p.as_str()).collect();
312 RkError::Refused(format!(
313 "the install was aborted and restoration is incomplete; verify these by hand: {}: {cause}",
314 paths.join(", ")
315 ))
316}
317
318pub fn install(layout: &Layout, apply: bool, force: bool) -> Result<Vec<Action>, RkError> {
338 check_legacy_root(layout.legacy_shared.as_deref(), &layout.record)?;
339 let record_path = layout.record.as_path();
340 let packages = crate::skills::packages()?;
341 let planned = plan_roots(&layout.roots, &packages);
342 for entry in &planned {
343 check_destination(&entry.destination)?;
344 }
345
346 let mut record = Record::load(record_path);
347 let covered: Vec<Utf8PathBuf> = planned
348 .iter()
349 .map(|entry| entry.destination.clone())
350 .collect();
351 let mut scanned = layout.roots.clone();
352 scanned.extend(layout.legacy_shared.clone());
353 let stale = leftovers(&scanned, &record, &covered);
354 let kept = legacy_kept(layout.legacy_shared.as_deref(), &stale);
355
356 if !apply {
357 let mut actions: Vec<Action> = covered
358 .into_iter()
359 .map(|destination| Action::Write { destination })
360 .collect();
361 actions.extend(
362 stale
363 .into_iter()
364 .map(|destination| Action::Sweep { destination }),
365 );
366 actions.extend(
367 kept.into_iter()
368 .map(|destination| Action::KeptEdited { destination }),
369 );
370 return Ok(actions);
371 }
372
373 if !force {
374 let conflicts = conflicts(&planned, &record)?;
375 if !conflicts.is_empty() {
376 return Err(RkError::Refused(format!(
377 "these destinations hold bytes this tool did not write, and nothing was written: {}; re-run with --force to overwrite",
378 conflicts.join(", ")
379 )));
380 }
381 }
382
383 let backups = back_up(&planned)?;
386
387 let mut actions = Vec::new();
388 for entry in &planned {
389 let held = backups.get(&entry.destination).and_then(Option::as_ref);
390 if held.is_some_and(|previous| previous == entry.bytes) {
391 actions.push(Action::Unchanged {
392 destination: entry.destination.clone(),
393 });
394 continue;
395 }
396 if let Err(source) = write_file(&entry.destination, entry.bytes) {
397 return Err(abort(
398 &rollback(&backups),
399 &format!("writing {} failed: {source}", entry.destination),
400 ));
401 }
402 actions.push(Action::Write {
403 destination: entry.destination.clone(),
404 });
405 }
406
407 let stops = sweep_stops(layout);
411 for destination in &stale {
412 match remove_installed(std::slice::from_ref(destination), &stops) {
413 Ok(kept_dirs) => {
414 actions.push(Action::Sweep {
415 destination: destination.clone(),
416 });
417 actions.extend(
418 kept_dirs
419 .into_iter()
420 .map(|directory| Action::KeptDirectory { directory }),
421 );
422 record.written.remove(destination);
423 }
424 Err((_, source)) => actions.push(Action::SweepFailed {
425 destination: destination.clone(),
426 error: source.to_string(),
427 }),
428 }
429 }
430 actions.extend(
431 kept.into_iter()
432 .map(|destination| Action::KeptEdited { destination }),
433 );
434
435 for entry in &planned {
436 record
437 .written
438 .insert(entry.destination.clone(), Digest::of(entry.bytes));
439 }
440 if write_file(record_path, record.to_text().as_bytes()).is_err() {
441 actions.push(Action::RecordUnwritten {
442 record: record_path.to_owned(),
443 });
444 }
445 Ok(actions)
446}
447
448fn back_up(planned: &[Planned]) -> Result<BTreeMap<Utf8PathBuf, Option<Vec<u8>>>, RkError> {
451 let mut backups = BTreeMap::new();
452 for entry in planned {
453 let previous = if entry.destination.is_file() {
454 Some(fs::read(&entry.destination).map_err(|source| {
455 RkError::Refused(format!(
456 "cannot back up {}, and nothing was written: {source}",
457 entry.destination
458 ))
459 })?)
460 } else {
461 None
462 };
463 backups.insert(entry.destination.clone(), previous);
464 }
465 Ok(backups)
466}
467
468fn sweep_stops(layout: &Layout) -> Vec<Utf8PathBuf> {
471 let mut stops = layout.roots.clone();
472 if let Some(state_dir) = layout.record.parent() {
473 stops.push(state_dir.to_owned());
474 }
475 stops
476}
477
478pub fn uninstall(layout: &Layout, apply: bool) -> Result<Vec<Action>, RkError> {
497 check_legacy_root(layout.legacy_shared.as_deref(), &layout.record)?;
498 let record_path = layout.record.as_path();
499 let packages = crate::skills::packages()?;
500 let record_found = Record::load(record_path);
501 let mut removable: Vec<Utf8PathBuf> = Vec::new();
502 let mut edited: Vec<Utf8PathBuf> = Vec::new();
503 for entry in &plan_roots(&layout.roots, &packages) {
504 check_destination(&entry.destination)?;
505 if !entry.destination.is_file() {
506 continue;
507 }
508 let found = fs::read(&entry.destination)?;
513 if found == entry.bytes || record_found.wrote(&entry.destination, &Digest::of(&found)) {
514 removable.push(entry.destination.clone());
515 } else {
516 edited.push(entry.destination.clone());
517 }
518 }
519
520 let mut record = record_found;
521 let mut scanned = layout.roots.clone();
522 scanned.extend(layout.legacy_shared.clone());
523 let stale = leftovers(&scanned, &record, &removable);
527 edited.extend(legacy_kept(layout.legacy_shared.as_deref(), &stale));
528
529 if !apply {
530 let mut actions: Vec<Action> = removable
531 .into_iter()
532 .map(|destination| Action::Remove { destination })
533 .collect();
534 actions.extend(
535 stale
536 .into_iter()
537 .map(|destination| Action::Sweep { destination }),
538 );
539 actions.extend(
540 edited
541 .into_iter()
542 .map(|destination| Action::KeptEdited { destination }),
543 );
544 return Ok(actions);
545 }
546
547 removable.extend(stale);
548 let kept_dirs = remove_installed(&removable, &sweep_stops(layout))
549 .map_err(|(_, source)| RkError::Io(source))?;
550 let mut actions: Vec<Action> = Vec::new();
551 for destination in &removable {
552 actions.push(Action::Remove {
553 destination: destination.clone(),
554 });
555 record.written.remove(destination);
556 }
557 actions.extend(
558 kept_dirs
559 .into_iter()
560 .map(|directory| Action::KeptDirectory { directory }),
561 );
562 actions.extend(
563 edited
564 .into_iter()
565 .map(|destination| Action::KeptEdited { destination }),
566 );
567
568 let recorded = if record.written.is_empty() {
569 fs::remove_file(record_path).or_else(|source| {
570 if source.kind() == std::io::ErrorKind::NotFound {
571 Ok(())
572 } else {
573 Err(source)
574 }
575 })
576 } else {
577 write_file(record_path, record.to_text().as_bytes())
578 };
579 if recorded.is_err() {
580 actions.push(Action::RecordUnwritten {
581 record: record_path.to_owned(),
582 });
583 }
584 Ok(actions)
585}
586
587#[cfg(test)]
588mod tests {
589 use camino::Utf8PathBuf;
590
591 use super::{Action, Layout, install, leftovers, uninstall};
592 use crate::skills::record::{RECORD_PATH, Record};
593 use crate::skills::{Digest, all, packages};
594
595 struct Home {
597 dir: tempfile::TempDir,
598 }
599
600 impl Home {
601 fn new() -> Self {
602 Self {
603 dir: tempfile::tempdir().expect("a scratch home exists"),
604 }
605 }
606
607 fn path(&self) -> Utf8PathBuf {
608 Utf8PathBuf::from_path_buf(self.dir.path().to_path_buf())
609 .expect("the temp path is UTF-8")
610 }
611
612 fn roots(&self) -> Vec<Utf8PathBuf> {
613 let home = self.path();
614 vec![home.join(".claude/skills"), home.join(".agents/skills")]
615 }
616
617 fn record(&self) -> Utf8PathBuf {
618 self.path().join(RECORD_PATH)
619 }
620
621 fn destination(&self, root: &str, skill: &str) -> Utf8PathBuf {
622 self.path().join(root).join(skill).join("SKILL.md")
623 }
624
625 fn legacy_shared(&self) -> Utf8PathBuf {
626 self.path().join(".local/state/release-kit/skills/shared")
627 }
628
629 fn gate(&self, root: &str, skill: &str) -> Utf8PathBuf {
631 self.path()
632 .join(root)
633 .join(skill)
634 .join("references/plan-gate.md")
635 }
636
637 fn layout(&self) -> Layout {
639 self.layout_for(self.roots())
640 }
641
642 fn layout_for(&self, roots: Vec<Utf8PathBuf>) -> Layout {
645 let every = roots == self.roots();
646 Layout {
647 roots,
648 legacy_shared: every.then(|| self.legacy_shared()),
649 record: self.record(),
650 }
651 }
652 }
653
654 fn package_files() -> usize {
656 packages()
657 .expect("the packages compose")
658 .iter()
659 .map(|package| package.files.len())
660 .sum()
661 }
662
663 fn first_skill() -> String {
664 all().expect("the skills read").swap_remove(0).name
665 }
666
667 #[test]
668 fn a_preview_lists_every_destination_and_writes_nothing() {
669 let home = Home::new();
670 let actions = install(&home.layout(), false, false).unwrap();
671 assert_eq!(actions.len(), package_files() * 2, "{actions:?}");
672 assert!(
673 actions
674 .iter()
675 .all(|action| matches!(action, Action::Write { .. })),
676 "{actions:?}"
677 );
678 assert!(!home.path().join(".claude").exists());
679 assert!(!home.record().exists());
680 }
681
682 #[test]
683 fn an_apply_is_idempotent_and_records_what_it_wrote() {
684 let home = Home::new();
685 let first = install(&home.layout(), true, false).unwrap();
686 assert!(
687 first
688 .iter()
689 .all(|action| matches!(action, Action::Write { .. })),
690 "{first:?}"
691 );
692 let second = install(&home.layout(), true, false).unwrap();
693 assert!(
694 second
695 .iter()
696 .all(|action| matches!(action, Action::Unchanged { .. })),
697 "{second:?}"
698 );
699 let record = Record::load(&home.record());
700 assert_eq!(record.written.len(), package_files() * 2);
701 }
702
703 #[test]
707 fn a_copy_a_previous_release_wrote_is_replaced_without_force() {
708 let home = Home::new();
709 install(&home.layout(), true, false).unwrap();
710
711 let mut stale = Record::default();
714 for destination in Record::load(&home.record()).written.into_keys() {
715 std::fs::write(&destination, "older canon bytes\n").unwrap();
716 stale
717 .written
718 .insert(destination, Digest::of(b"older canon bytes\n"));
719 }
720 std::fs::write(home.record(), stale.to_text()).unwrap();
721
722 install(&home.layout(), true, false).unwrap();
723 let text =
724 std::fs::read_to_string(home.destination(".claude/skills", &first_skill())).unwrap();
725 assert!(text.contains(&format!("name: {}", first_skill())));
726 }
727
728 #[test]
730 fn an_edit_refuses_and_names_every_conflict() {
731 let home = Home::new();
732 install(&home.layout(), true, false).unwrap();
733 let edited: Vec<Utf8PathBuf> = all()
734 .unwrap()
735 .iter()
736 .map(|skill| home.destination(".claude/skills", &skill.name))
737 .collect();
738 for destination in &edited {
739 std::fs::write(destination, "the user wrote this").unwrap();
740 }
741
742 let message = install(&home.layout(), true, false)
743 .unwrap_err()
744 .to_string();
745 for destination in &edited {
746 assert!(message.contains(destination.as_str()), "{message}");
747 }
748 for destination in &edited {
749 assert_eq!(
750 std::fs::read_to_string(destination).unwrap(),
751 "the user wrote this",
752 "a refused install must not overwrite"
753 );
754 }
755 install(&home.layout(), true, true).unwrap();
756 assert!(
757 std::fs::read_to_string(&edited[0])
758 .unwrap()
759 .starts_with("---")
760 );
761 }
762
763 #[cfg(unix)]
764 #[test]
765 fn a_symlinked_destination_refuses_before_anything_is_written() {
766 let home = Home::new();
767 let skill = first_skill();
768 let destination = home.destination(".claude/skills", &skill);
769 std::fs::create_dir_all(destination.parent().unwrap()).unwrap();
770 let elsewhere = home.path().join("elsewhere");
771 std::fs::write(&elsewhere, "the user's file\n").unwrap();
772 std::os::unix::fs::symlink(&elsewhere, &destination).unwrap();
773
774 let message = install(&home.layout(), true, true).unwrap_err().to_string();
775 assert!(message.contains("symlink"), "{message}");
776 assert_eq!(
777 std::fs::read_to_string(&elsewhere).unwrap(),
778 "the user's file\n"
779 );
780 assert!(!home.path().join(".agents").exists());
781 }
782
783 #[test]
785 fn a_failed_write_restores_every_destination() {
786 let home = Home::new();
787 install(&home.layout(), true, false).unwrap();
788 let first = home.destination(".claude/skills", &first_skill());
789 std::fs::write(&first, "older canon bytes\n").unwrap();
790 let mut record = Record::load(&home.record());
791 record
792 .written
793 .insert(first.clone(), Digest::of(b"older canon bytes\n"));
794 std::fs::write(home.record(), record.to_text()).unwrap();
795
796 let blocked = home.path().join(".agents/skills").join(first_skill());
799 std::fs::remove_dir_all(&blocked).unwrap();
800 std::fs::write(&blocked, "in the way\n").unwrap();
801
802 let message = install(&home.layout(), true, false)
803 .unwrap_err()
804 .to_string();
805 assert!(message.contains("aborted"), "{message}");
806 assert_eq!(
807 std::fs::read_to_string(&first).unwrap(),
808 "older canon bytes\n",
809 "the first root must be restored"
810 );
811 }
812
813 #[test]
814 fn an_install_sweeps_a_destination_the_binary_dropped() {
815 let home = Home::new();
816 install(&home.layout(), true, false).unwrap();
817 let dropped = home.destination(".claude/skills", "rk-retired");
818 std::fs::create_dir_all(dropped.parent().unwrap()).unwrap();
819 std::fs::write(&dropped, "a skill a later release dropped\n").unwrap();
820 let mut record = Record::load(&home.record());
821 record.written.insert(
822 dropped.clone(),
823 Digest::of(b"a skill a later release dropped\n"),
824 );
825 std::fs::write(home.record(), record.to_text()).unwrap();
826
827 let actions = install(&home.layout(), true, false).unwrap();
828 assert!(
829 actions.contains(&Action::Sweep {
830 destination: dropped.clone()
831 }),
832 "{actions:?}"
833 );
834 assert!(!dropped.exists());
835 assert!(!dropped.parent().unwrap().exists());
836 assert!(!Record::load(&home.record()).written.contains_key(&dropped));
837 }
838
839 #[test]
841 fn a_sweep_leaves_an_edited_leftover_alone() {
842 let home = Home::new();
843 install(&home.layout(), true, false).unwrap();
844 let dropped = home.destination(".claude/skills", "rk-retired");
845 std::fs::create_dir_all(dropped.parent().unwrap()).unwrap();
846 std::fs::write(&dropped, "the user rewrote this\n").unwrap();
847 let mut record = Record::load(&home.record());
848 record
849 .written
850 .insert(dropped.clone(), Digest::of(b"what we wrote\n"));
851 std::fs::write(home.record(), record.to_text()).unwrap();
852
853 assert!(
854 !leftovers(&home.roots(), &record, &[]).contains(&dropped),
855 "a leftover whose bytes differ from the record is the user's"
856 );
857 install(&home.layout(), true, false).unwrap();
858 assert_eq!(
859 std::fs::read_to_string(&dropped).unwrap(),
860 "the user rewrote this\n"
861 );
862 }
863
864 #[test]
868 fn an_uninstall_keeps_an_edited_destination() {
869 let home = Home::new();
870 install(&home.layout(), true, false).unwrap();
871 let edited = home.destination(".claude/skills", &first_skill());
872 std::fs::write(&edited, "the user rewrote this\n").unwrap();
873
874 let preview = uninstall(&home.layout(), false).unwrap();
875 assert!(
876 preview.contains(&Action::KeptEdited {
877 destination: edited.clone()
878 }),
879 "{preview:?}"
880 );
881 assert!(
882 !preview.contains(&Action::Remove {
883 destination: edited.clone()
884 }),
885 "{preview:?}"
886 );
887
888 let actions = uninstall(&home.layout(), true).unwrap();
889 assert!(
890 actions.contains(&Action::KeptEdited {
891 destination: edited.clone()
892 }),
893 "{actions:?}"
894 );
895 assert_eq!(
896 std::fs::read_to_string(&edited).unwrap(),
897 "the user rewrote this\n",
898 "an uninstall must never delete a user's edit"
899 );
900 assert!(!home.destination(".agents/skills", &first_skill()).exists());
902 }
903
904 #[test]
905 fn an_uninstall_removes_what_it_wrote_and_keeps_the_rest() {
906 let home = Home::new();
907 install(&home.layout(), true, false).unwrap();
908 let skill = first_skill();
909 let beside = home
910 .destination(".claude/skills", &skill)
911 .parent()
912 .unwrap()
913 .join("notes.md");
914 std::fs::write(&beside, "the user's notes\n").unwrap();
915
916 let actions = uninstall(&home.layout(), true).unwrap();
917 assert!(
918 actions
919 .iter()
920 .any(|action| matches!(action, Action::KeptDirectory { .. })),
921 "{actions:?}"
922 );
923 assert!(!home.destination(".claude/skills", &skill).exists());
924 assert!(beside.is_file(), "a file beside a skill must survive");
925 assert!(!home.record().exists(), "an empty record is removed");
926 uninstall(&home.layout(), true).unwrap();
928 }
929
930 #[test]
931 fn one_root_installs_and_uninstalls_without_touching_the_other() {
932 let home = Home::new();
933 let claude = home.layout_for(vec![home.path().join(".claude/skills")]);
934 install(&claude, true, false).unwrap();
935 assert!(home.destination(".claude/skills", &first_skill()).is_file());
936 assert!(!home.path().join(".agents").exists());
937
938 uninstall(&claude, true).unwrap();
939 assert!(!home.destination(".claude/skills", &first_skill()).exists());
940 }
941
942 #[test]
945 fn either_agent_alone_lands_the_gates_in_its_packages() {
946 for (root, other) in [
947 (".claude/skills", ".agents/skills"),
948 (".agents/skills", ".claude/skills"),
949 ] {
950 let home = Home::new();
951 let one = home.layout_for(vec![home.path().join(root)]);
952 install(&one, true, false).unwrap();
953 for skill in all().unwrap() {
954 assert!(
955 home.gate(root, &skill.name).is_file(),
956 "{root}/{}: the gate did not land",
957 skill.name
958 );
959 assert!(!home.gate(other, &skill.name).exists());
960 }
961 assert!(!home.legacy_shared().exists());
962 }
963 }
964
965 #[test]
969 fn an_uninstall_of_one_family_leaves_the_other_family_its_gates() {
970 let home = Home::new();
971 install(&home.layout(), true, false).unwrap();
972 let codex = home.layout_for(vec![home.path().join(".agents/skills")]);
973 uninstall(&codex, true).unwrap();
974 assert!(
975 !home
976 .path()
977 .join(".agents/skills")
978 .join(first_skill())
979 .exists()
980 );
981 assert!(home.gate(".claude/skills", &first_skill()).is_file());
982 }
983
984 #[test]
988 fn an_install_sweeps_the_legacy_shared_root_it_vouches_for() {
989 let home = Home::new();
990 let legacy = home.legacy_shared().join("plan-gate.md");
991 std::fs::create_dir_all(home.legacy_shared()).unwrap();
992 std::fs::write(&legacy, "an earlier release's gate\n").unwrap();
993 let mut record = Record::default();
994 record
995 .written
996 .insert(legacy.clone(), Digest::of(b"an earlier release's gate\n"));
997 std::fs::create_dir_all(home.record().parent().unwrap()).unwrap();
998 std::fs::write(home.record(), record.to_text()).unwrap();
999
1000 let preview = install(&home.layout(), false, false).unwrap();
1001 assert!(
1002 preview.contains(&Action::Sweep {
1003 destination: legacy.clone()
1004 }),
1005 "{preview:?}"
1006 );
1007 install(&home.layout(), true, false).unwrap();
1008 assert!(!legacy.exists());
1009 assert!(!home.legacy_shared().exists());
1010 assert!(
1011 !home.legacy_shared().parent().unwrap().exists(),
1012 "the emptied skills directory goes too"
1013 );
1014 assert!(!Record::load(&home.record()).written.contains_key(&legacy));
1015 }
1016
1017 #[test]
1021 fn a_one_family_install_keeps_the_legacy_gates() {
1022 let home = Home::new();
1023 let legacy = home.legacy_shared().join("plan-gate.md");
1024 std::fs::create_dir_all(home.legacy_shared()).unwrap();
1025 std::fs::write(&legacy, "an earlier release's gate\n").unwrap();
1026 let mut record = Record::default();
1027 record
1028 .written
1029 .insert(legacy.clone(), Digest::of(b"an earlier release's gate\n"));
1030 std::fs::create_dir_all(home.record().parent().unwrap()).unwrap();
1031 std::fs::write(home.record(), record.to_text()).unwrap();
1032 let codex = home.layout_for(vec![home.path().join(".agents/skills")]);
1033
1034 let preview = install(&codex, false, false).unwrap();
1035 assert!(
1036 !preview.iter().any(|action| matches!(
1037 action,
1038 Action::Sweep { destination } if destination == &legacy
1039 )),
1040 "{preview:?}"
1041 );
1042 install(&codex, true, false).unwrap();
1043 assert!(legacy.is_file());
1044 uninstall(&codex, true).unwrap();
1045 assert!(legacy.is_file());
1046 assert!(Record::load(&home.record()).written.contains_key(&legacy));
1047 }
1048
1049 #[test]
1052 fn an_install_leaves_and_names_an_unvouched_legacy_gate() {
1053 let home = Home::new();
1054 let legacy = home.legacy_shared().join("plan-gate.md");
1055 std::fs::create_dir_all(home.legacy_shared()).unwrap();
1056 std::fs::write(&legacy, "the user's own gate\n").unwrap();
1057
1058 let actions = install(&home.layout(), true, false).unwrap();
1059 assert!(
1060 actions.contains(&Action::KeptEdited {
1061 destination: legacy.clone()
1062 }),
1063 "{actions:?}"
1064 );
1065 assert_eq!(
1066 std::fs::read_to_string(&legacy).unwrap(),
1067 "the user's own gate\n"
1068 );
1069 }
1070
1071 #[test]
1073 fn an_edited_gate_copy_refuses_an_install_and_survives_an_uninstall() {
1074 let home = Home::new();
1075 install(&home.layout(), true, false).unwrap();
1076 let gate = home.gate(".claude/skills", &first_skill());
1077 std::fs::write(&gate, b"mine now").unwrap();
1078
1079 let message = install(&home.layout(), true, false)
1080 .expect_err("an edited gate refuses")
1081 .to_string();
1082 assert!(message.contains("plan-gate.md"), "{message}");
1083
1084 let actions = uninstall(&home.layout(), true).unwrap();
1085 assert!(
1086 actions.iter().any(|action| matches!(
1087 action,
1088 Action::KeptEdited { destination } if destination == &gate
1089 )),
1090 "{actions:?}"
1091 );
1092 assert_eq!(std::fs::read(&gate).unwrap(), b"mine now");
1093 }
1094
1095 #[test]
1098 fn a_symlinked_gate_copy_refuses_before_writing() {
1099 let home = Home::new();
1100 let gate = home.gate(".agents/skills", &first_skill());
1101 std::fs::create_dir_all(gate.parent().unwrap()).unwrap();
1102 std::os::unix::fs::symlink("/etc/passwd", &gate).unwrap();
1103
1104 let message = install(&home.layout(), true, false)
1105 .expect_err("a symlink refuses")
1106 .to_string();
1107 assert!(message.contains("symlink"), "{message}");
1108 assert!(
1109 !home.destination(".claude/skills", &first_skill()).exists(),
1110 "the refusal must come before the first write"
1111 );
1112 }
1113
1114 #[test]
1118 fn a_symlinked_shared_root_refuses_install_and_uninstall() {
1119 for symlinked in ["skills", "skills/shared"] {
1120 let home = Home::new();
1121 let elsewhere = home.path().join("elsewhere");
1122 std::fs::create_dir_all(&elsewhere).unwrap();
1123 let state_dir = home.record().parent().unwrap().to_path_buf();
1124 let linked = state_dir.join(symlinked);
1125 std::fs::create_dir_all(linked.parent().unwrap()).unwrap();
1126 std::os::unix::fs::symlink(&elsewhere, &linked).unwrap();
1127
1128 let message = install(&home.layout(), true, false)
1129 .expect_err("a symlinked shared root refuses an install")
1130 .to_string();
1131 assert!(message.contains("symlink"), "{message}");
1132 assert!(
1133 !elsewhere.join("plan-gate.md").exists(),
1134 "an install must never write through a symlinked shared root"
1135 );
1136 assert!(!home.path().join(".claude").exists());
1137
1138 std::fs::write(elsewhere.join("plan-gate.md"), "theirs\n").unwrap();
1139 let message = uninstall(&home.layout(), true)
1140 .expect_err("a symlinked shared root refuses an uninstall")
1141 .to_string();
1142 assert!(message.contains("symlink"), "{message}");
1143 assert!(
1144 elsewhere.join("plan-gate.md").exists(),
1145 "an uninstall must never remove through a symlinked shared root"
1146 );
1147 }
1148 }
1149}