Skip to main content

release_kit/skills/
installer.rs

1//! Install the embedded skills into the agent skill directories.
2//!
3//! Two references decide what a destination holds: the skills this binary
4//! carries, and the record of what a previous apply wrote there. Bytes
5//! matching either are the tool's own and may be replaced; anything else is
6//! the user's and refuses.
7//!
8//! Preview by default, list every conflict at once, and restore on failure —
9//! the same conventions `rk init` follows, and for a sharper reason: an apply
10//! crosses two roots, so a failure partway leaves one agent reading this
11//! version of a skill and another agent reading the last one.
12//!
13//! The installer reports what it did as typed [`Action`]s and renders
14//! nothing; the handler in `commands::skill` owns both renderings.
15
16use 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/// One thing an install or uninstall did, or — in a preview — would do.
28#[derive(Debug, Serialize, PartialEq, Eq)]
29#[serde(tag = "action", rename_all = "kebab-case")]
30pub enum Action {
31    /// The embedded bytes land at this destination.
32    Write {
33        /// The package file written.
34        destination: Utf8PathBuf,
35    },
36    /// The destination already holds the embedded bytes.
37    Unchanged {
38        /// The package file left alone.
39        destination: Utf8PathBuf,
40    },
41    /// A recorded leftover the embedded skills no longer name is removed.
42    Sweep {
43        /// The leftover taken back.
44        destination: Utf8PathBuf,
45    },
46    /// A leftover could not be removed; it stays for the operator.
47    SweepFailed {
48        /// The leftover still in place.
49        destination: Utf8PathBuf,
50        /// Why the removal failed.
51        error: String,
52    },
53    /// An installed destination is removed.
54    Remove {
55        /// The package file removed.
56        destination: Utf8PathBuf,
57    },
58    /// A destination holding bytes neither the embedded skills nor the record
59    /// accounts for is the user's now: an uninstall leaves it, and so does
60    /// the sweep of the root an earlier release shared the gates from.
61    KeptEdited {
62        /// The edited file left in place.
63        destination: Utf8PathBuf,
64    },
65    /// A directory survives a removal because something else lives in it.
66    KeptDirectory {
67        /// The directory kept.
68        directory: Utf8PathBuf,
69    },
70    /// The record could not be written; a later install may ask for
71    /// `--force` it should not need.
72    RecordUnwritten {
73        /// The record path that did not write.
74        record: Utf8PathBuf,
75    },
76}
77
78/// One planned write: where, and which bytes.
79struct Planned {
80    /// The path this run writes.
81    destination: Utf8PathBuf,
82    /// The embedded bytes that belong there.
83    bytes: &'static [u8],
84}
85
86/// Where one run writes: the agent roots and the record, plus the root an
87/// earlier release shared the gates from.
88///
89/// Each package carries its own copy of the gates, so no install writes
90/// outside the agent roots. The legacy root is scanned only so the copies an
91/// earlier release wrote there are swept, and only by a run that covers
92/// every agent root: an older manual under a root the run leaves alone
93/// still reads the gates there.
94#[derive(Debug, Clone)]
95pub struct Layout {
96    /// The agent skill roots this run was asked to touch.
97    pub roots: Vec<Utf8PathBuf>,
98    /// The root an earlier release installed the shared gates to, where
99    /// this run sweeps it.
100    pub legacy_shared: Option<Utf8PathBuf>,
101    /// The user-scope digest record.
102    pub record: Utf8PathBuf,
103}
104
105/// Every package file under `roots`, root by root, skill by skill, and
106/// file by file.
107fn 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
122/// Refuse a legacy shared root reached through a symlink this installer
123/// would follow.
124///
125/// The chain from the state directory down to that root is this tool's own:
126/// nothing here ever creates a symlink in it, so one found there redirects
127/// every sweep somewhere else, and [`check_destination`] cannot see it,
128/// because the final component it checks is not itself a link. The
129/// directories above the state directory are the user's layout and stay
130/// unjudged.
131fn 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
150/// Files under the legacy shared root that the record cannot vouch for.
151///
152/// The sweep takes back only bytes the record says this tool wrote. Anything
153/// else there is the user's, so it stays, and the run names it rather than
154/// leaving it unexplained.
155fn 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
166/// Refuse a destination this installer must not write through or replace.
167///
168/// A symlink is never followed: the skill would land wherever it points,
169/// which is outside the home this command was asked to touch.
170fn 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
184/// Destinations holding bytes neither the embedded skills nor the record account for.
185///
186/// A destination the record vouches for carries a copy this tool wrote and a
187/// later release has since changed. That is an upgrade, not a conflict, and
188/// naming it one would make every skill-touching release refuse on files
189/// nobody edited.
190fn 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        // An unreadable destination raises instead of passing as clean: a
197        // comparison that cannot run must never license an overwrite.
198        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
207/// Recorded destinations under `roots` that this run no longer covers.
208///
209/// A skill the canon renamed or dropped leaves its file behind otherwise, and
210/// a stale name is not inert: an agent keys its picker on the name, so the
211/// leftover keeps showing up beside the skill that replaced it. Only bytes the
212/// record still vouches for are swept — a leftover the user has since edited
213/// is theirs, and a symlink is never followed.
214fn 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
230/// Write `bytes` at `path` through the temp-plus-rename writer, creating
231/// the directories it needs.
232fn write_file(path: &Utf8Path, bytes: &[u8]) -> std::io::Result<()> {
233    atomic::write(path.as_std_path(), bytes)
234}
235
236/// Remove installed files, then every directory they leave empty.
237///
238/// A directory is pruned up to, and never including, the root that holds
239/// it: an agent root or the state directory. Returns the parent directories
240/// kept because something else lives in them, so whatever a user put beside
241/// a skill survives its removal. A removal that fails stops the run at that
242/// file.
243fn 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    // Deepest first, so a package's `references/` empties before the skill
255    // directory holding it is judged.
256    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
280/// Restore every backed-up destination, returning those that would not go back.
281///
282/// A destination already holding what it held counts as restored, whatever a
283/// write to it would do. Nothing else distinguishes the two ways an apply
284/// reaches here — the write loop stopped before this destination, or a
285/// read-only root refused every write including this one — and reporting the
286/// second as unrestored sends the operator to verify files no write reached.
287fn 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
304/// The refusal a failed apply carries, naming the cause and what it restored.
305fn 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
318/// Install every embedded skill package under each root, previewing by
319/// default.
320///
321/// `record_path` names the user-scope digest record: read to tell a stale copy
322/// this tool wrote from a file the user edited, and rewritten after a
323/// successful apply. Failing to write it is not a failure of the install — the
324/// files landed — so it costs only the benefit of the doubt next time.
325///
326/// The first apply after the shared gates moved into each package takes back
327/// the copies an earlier release wrote to the legacy shared root, where the
328/// record vouches for them, and names any it cannot vouch for.
329///
330/// # Errors
331///
332/// Returns [`RkError::Refused`] when the legacy shared root is reached
333/// through a symlink, when a destination cannot be touched, when one holds
334/// bytes neither reference accounts for and `force` is unset, or when a write
335/// fails partway, in which case the destinations are restored first.
336/// Returns [`RkError::Io`] when a destination exists but cannot be read.
337pub 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    // Back up every destination before the first write, so a failure on the
384    // second root cannot leave the first one upgraded.
385    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    // Sweep after the writes, never before: a refusal must leave the home
408    // exactly as it found it, and a leftover is harmless until the install
409    // superseding it has actually landed.
410    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
448/// The bytes each planned destination holds before an apply, or `None` for
449/// one that does not exist yet.
450fn 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
468/// The directories a removal never prunes: every agent root this run
469/// touches, and the state directory above the legacy shared root.
470fn 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
478/// Remove every installed skill package under each root, previewing by
479/// default.
480///
481/// Only bytes this tool can vouch for go: a destination holding the embedded
482/// bytes or bytes the record says it wrote, plus the recorded leftovers, and a
483/// directory only once nothing else lives in it. A destination the user has
484/// edited is theirs now and stays, reported rather than removed. An absent
485/// destination is a no-op, so a re-run succeeds. Removed destinations leave
486/// the record too; what is gone cannot be vouched for.
487///
488/// Each package carries its own gates, so an uninstall of one agent family
489/// takes that family's copies and leaves the other's.
490///
491/// # Errors
492///
493/// Returns [`RkError::Refused`] when the legacy shared root is reached
494/// through a symlink, or when a destination is a symlink or not a regular
495/// file, and [`RkError::Io`] when a removal fails.
496pub 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        // The same two references an install trusts decide what goes: the
509        // bytes, or bytes the record vouches this tool wrote.
510        // Anything else is the user's edit, and removing it would destroy
511        // work an install refuses to even overwrite.
512        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    // A skill this binary has since dropped is still ours to take back, and an
524    // uninstall leaving it behind is the leftover an agent keeps offering. The
525    // record is what names it; the embedded skills no longer can.
526    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    /// A scratch home, plus the roots and record path it implies.
596    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        /// A gate copy inside one installed package.
630        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        /// The layout for every agent root.
638        fn layout(&self) -> Layout {
639            self.layout_for(self.roots())
640        }
641
642        /// The layout for a subset of the agent roots, which sweeps the
643        /// legacy root only when the subset is every root.
644        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    /// How many files every package carries together, per agent root.
655    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    /// The defect the record exists for: bytes a previous release wrote are
704    /// not the user's, and refusing on them makes every skill-touching release
705    /// break the install recipe.
706    #[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        // Stand in for an older release: rewrite each destination and record
712        // its digest, exactly as that release's apply would have left it.
713        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    /// A record vouching for one destination says nothing about another.
729    #[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    /// A failure on the second root must not leave the first one upgraded.
784    #[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        // A regular file where the second root's skill directory belongs: the
797        // directory cannot be created, so that root's write fails.
798        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    /// A leftover the user has since edited is theirs, not ours to remove.
840    #[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    /// A destination the user edited after installing is theirs: an
865    /// uninstall reports it and leaves it, exactly as an install refuses
866    /// to overwrite it.
867    #[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        // Everything the tool can vouch for is still removed.
901        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        // A re-run over an emptied home is a no-op, not a failure.
927        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    /// Each package carries its own gates, so an install for either agent
943    /// family lands them inside that family's packages and nowhere else.
944    #[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    /// The defect a shared root had: one family's uninstall had to keep the
966    /// gates for the other. A package owns its copies, so each family's
967    /// uninstall takes its own and leaves the other's.
968    #[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    /// The first install after the gates moved into the packages takes back
985    /// the copies an earlier release wrote to the shared root, and the
986    /// directories they leave empty.
987    #[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    /// A run limited to one agent family leaves the legacy root alone,
1018    /// because an older manual under the other family's root still reads
1019    /// the gates there; the uninstall of that family leaves it too.
1020    #[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    /// Bytes under the legacy root the record cannot vouch for are the
1050    /// user's: the sweep leaves them and names them.
1051    #[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    /// A gate copy the user edited is theirs, exactly as a skill is.
1072    #[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    /// A symlinked gate copy refuses before anything is written, the same as
1096    /// a symlinked skill.
1097    #[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    /// A legacy shared root reached through a symlinked directory refuses
1115    /// both verbs: the sweep would delete wherever the link points, and no
1116    /// per-destination check can see it.
1117    #[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}