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
16#[cfg(test)]
17use std::collections::BTreeMap;
18use std::collections::BTreeSet;
19use std::fs;
20
21use camino::{Utf8Path, Utf8PathBuf};
22use serde::Serialize;
23
24use crate::atomic;
25use crate::error::RkError;
26use crate::skills::record::Record;
27use crate::skills::{Digest, Package};
28
29/// One thing an install or uninstall did, or — in a preview — would do.
30#[derive(Debug, Serialize, PartialEq, Eq)]
31#[serde(tag = "action", rename_all = "kebab-case")]
32pub enum Action {
33    /// The embedded bytes land at this destination.
34    Write {
35        /// The package file written.
36        destination: Utf8PathBuf,
37    },
38    /// The destination already holds the embedded bytes.
39    Unchanged {
40        /// The package file left alone.
41        destination: Utf8PathBuf,
42    },
43    /// A recorded leftover the embedded skills no longer name is removed.
44    Sweep {
45        /// The leftover taken back.
46        destination: Utf8PathBuf,
47    },
48    /// A leftover could not be removed; it stays for the operator.
49    SweepFailed {
50        /// The leftover still in place.
51        destination: Utf8PathBuf,
52        /// Why the removal failed.
53        error: String,
54    },
55    /// An installed destination is removed.
56    Remove {
57        /// The package file removed.
58        destination: Utf8PathBuf,
59    },
60    /// A destination holding bytes neither the embedded skills nor the record
61    /// accounts for is the user's now: an uninstall leaves it, and so does
62    /// the sweep of the root an earlier release shared the gates from.
63    KeptEdited {
64        /// The edited file left in place.
65        destination: Utf8PathBuf,
66    },
67    /// A directory survives a removal because something else lives in it.
68    KeptDirectory {
69        /// The directory kept.
70        directory: Utf8PathBuf,
71    },
72    /// The record could not be written; a later install may ask for
73    /// `--force` it should not need.
74    RecordUnwritten {
75        /// The record path that did not write.
76        record: Utf8PathBuf,
77    },
78}
79
80/// One planned write: where, and which bytes.
81struct Planned {
82    /// The path this run writes.
83    destination: Utf8PathBuf,
84    /// The embedded bytes that belong there.
85    bytes: &'static [u8],
86}
87
88/// Where one run writes: the agent roots and the record, plus the root an
89/// earlier release shared the gates from.
90///
91/// Each package carries its own copy of the gates, so no install writes
92/// outside the agent roots. The legacy root is scanned only so the copies an
93/// earlier release wrote there are swept, and only by a run that covers
94/// every agent root: an older manual under a root the run leaves alone
95/// still reads the gates there.
96#[derive(Debug, Clone)]
97pub struct Layout {
98    /// The agent skill roots this run was asked to touch.
99    pub roots: Vec<Utf8PathBuf>,
100    /// The root an earlier release installed the shared gates to, where
101    /// this run sweeps it.
102    pub legacy_shared: Option<Utf8PathBuf>,
103    /// The user-scope digest record.
104    pub record: Utf8PathBuf,
105}
106
107/// Every package file under `roots`, root by root, skill by skill, and
108/// file by file.
109fn plan_roots(roots: &[Utf8PathBuf], packages: &[Package]) -> Vec<Planned> {
110    let mut planned = Vec::new();
111    for root in roots {
112        for package in packages {
113            for file in &package.files {
114                planned.push(Planned {
115                    destination: root.join(&package.name).join(&file.path),
116                    bytes: file.bytes,
117                });
118            }
119        }
120    }
121    planned
122}
123
124/// Refuse a legacy shared root reached through a symlink this installer
125/// would follow.
126///
127/// The chain from the state directory down to that root is this tool's own:
128/// nothing here ever creates a symlink in it, so one found there redirects
129/// every sweep somewhere else, and [`check_destination`] cannot see it,
130/// because the final component it checks is not itself a link. The
131/// directories above the state directory are the user's layout and stay
132/// unjudged.
133fn check_legacy_root(legacy: Option<&Utf8Path>, record: &Utf8Path) -> Result<(), RkError> {
134    let (Some(legacy), Some(state_dir)) = (legacy, record.parent()) else {
135        return Ok(());
136    };
137    let mut current = Some(legacy);
138    while let Some(dir) = current {
139        if !dir.starts_with(state_dir) {
140            break;
141        }
142        if dir.is_symlink() {
143            return Err(RkError::Refused(format!(
144                "the shared root is reached through a symlink, and nothing was written: {dir}"
145            )));
146        }
147        current = dir.parent();
148    }
149    Ok(())
150}
151
152/// Files under the legacy shared root that the record cannot vouch for.
153///
154/// The sweep takes back only bytes the record says this tool wrote. Anything
155/// else there is the user's, so it stays, and the run names it rather than
156/// leaving it unexplained.
157fn legacy_kept(legacy: Option<&Utf8Path>, swept: &[Utf8PathBuf]) -> Vec<Utf8PathBuf> {
158    let Some(legacy) = legacy else {
159        return Vec::new();
160    };
161    crate::skills::shared()
162        .into_iter()
163        .map(|artifact| legacy.join(&artifact.path))
164        .filter(|path| path.is_file() && !path.is_symlink() && !swept.contains(path))
165        .collect()
166}
167
168/// Refuse a destination this installer must not write through or replace.
169///
170/// A symlink is never followed: the skill would land wherever it points,
171/// which is outside the home this command was asked to touch.
172fn check_destination(destination: &Utf8Path) -> Result<(), RkError> {
173    if destination.is_symlink() {
174        return Err(RkError::Refused(format!(
175            "destination is a symlink, and nothing was written: {destination}"
176        )));
177    }
178    if destination.exists() && !destination.is_file() {
179        return Err(RkError::Refused(format!(
180            "destination is not a regular file, and nothing was written: {destination}"
181        )));
182    }
183    Ok(())
184}
185
186/// Destinations holding bytes neither the embedded skills nor the record account for.
187///
188/// A destination the record vouches for carries a copy this tool wrote and a
189/// later release has since changed. That is an upgrade, not a conflict, and
190/// naming it one would make every skill-touching release refuse on files
191/// nobody edited.
192#[cfg(test)]
193fn conflicts(planned: &[Planned], record: &Record) -> Result<Vec<String>, RkError> {
194    let mut conflicts = Vec::new();
195    for entry in planned {
196        if !entry.destination.is_file() {
197            continue;
198        }
199        // An unreadable destination raises instead of passing as clean: a
200        // comparison that cannot run must never license an overwrite.
201        let found = fs::read(&entry.destination)?;
202        if found == entry.bytes || record.wrote(&entry.destination, &Digest::of(&found)) {
203            continue;
204        }
205        conflicts.push(entry.destination.to_string());
206    }
207    Ok(conflicts)
208}
209
210/// Recorded destinations under `roots` that this run no longer covers.
211///
212/// A skill the canon renamed or dropped leaves its file behind otherwise, and
213/// a stale name is not inert: an agent keys its picker on the name, so the
214/// leftover keeps showing up beside the skill that replaced it. Only bytes the
215/// record still vouches for are swept — a leftover the user has since edited
216/// is theirs, and a symlink is never followed.
217fn leftovers(roots: &[Utf8PathBuf], record: &Record, keep: &[Utf8PathBuf]) -> Vec<Utf8PathBuf> {
218    let kept: BTreeSet<&Utf8Path> = keep.iter().map(Utf8PathBuf::as_path).collect();
219    record
220        .written
221        .iter()
222        .filter(|(destination, digest)| {
223            !kept.contains(destination.as_path())
224                && roots.iter().any(|root| destination.starts_with(root))
225                && !destination.is_symlink()
226                && destination.is_file()
227                && fs::read(destination).is_ok_and(|found| Digest::of(&found) == **digest)
228        })
229        .map(|(destination, _)| destination.clone())
230        .collect()
231}
232
233/// Write `bytes` at `path` through the temp-plus-rename writer, creating
234/// the directories it needs.
235fn write_file(path: &Utf8Path, bytes: &[u8]) -> std::io::Result<()> {
236    atomic::write(path.as_std_path(), bytes)
237}
238
239/// Remove installed files, then every directory they leave empty.
240///
241/// A directory is pruned up to, and never including, the root that holds
242/// it: an agent root or the state directory. Returns the parent directories
243/// kept because something else lives in them, so whatever a user put beside
244/// a skill survives its removal. A removal that fails stops the run at that
245/// file.
246fn remove_installed(
247    destinations: &[Utf8PathBuf],
248    stops: &[Utf8PathBuf],
249) -> Result<Vec<Utf8PathBuf>, (Utf8PathBuf, std::io::Error)> {
250    let mut parents: BTreeSet<Utf8PathBuf> = BTreeSet::new();
251    for destination in destinations {
252        fs::remove_file(destination).map_err(|source| (destination.clone(), source))?;
253        if let Some(parent) = destination.parent() {
254            parents.insert(parent.to_owned());
255        }
256    }
257    // Deepest first, so a package's `references/` empties before the skill
258    // directory holding it is judged.
259    let mut candidates: Vec<Utf8PathBuf> = Vec::new();
260    for parent in &parents {
261        let mut current = Some(parent.as_path());
262        while let Some(dir) = current {
263            if stops.iter().any(|stop| stop == dir)
264                || !stops.iter().any(|stop| dir.starts_with(stop))
265            {
266                break;
267            }
268            if !candidates.iter().any(|seen| seen == dir) {
269                candidates.push(dir.to_owned());
270            }
271            current = dir.parent();
272        }
273    }
274    candidates.sort_by_key(|dir| std::cmp::Reverse(dir.components().count()));
275    for dir in &candidates {
276        if fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
277            let _ = fs::remove_dir(dir);
278        }
279    }
280    Ok(parents.into_iter().filter(|dir| dir.is_dir()).collect())
281}
282
283/// Restore every backed-up destination, returning those that would not go back.
284///
285/// A destination already holding what it held counts as restored, whatever a
286/// write to it would do. Nothing else distinguishes the two ways an apply
287/// reaches here — the write loop stopped before this destination, or a
288/// read-only root refused every write including this one — and reporting the
289/// second as unrestored sends the operator to verify files no write reached.
290#[cfg(test)]
291fn rollback(backups: &BTreeMap<Utf8PathBuf, Option<Vec<u8>>>) -> Vec<Utf8PathBuf> {
292    let mut unrestored = Vec::new();
293    for (destination, previous) in backups {
294        let restored = previous.as_ref().map_or_else(
295            || !destination.exists() || fs::remove_file(destination).is_ok(),
296            |bytes| {
297                fs::read(destination).is_ok_and(|found| &found == bytes)
298                    || write_file(destination, bytes).is_ok()
299            },
300        );
301        if !restored {
302            unrestored.push(destination.clone());
303        }
304    }
305    unrestored
306}
307
308/// The refusal a failed apply carries, naming the cause and what it restored.
309#[cfg(test)]
310fn abort(unrestored: &[Utf8PathBuf], cause: &str) -> RkError {
311    if unrestored.is_empty() {
312        return RkError::Refused(format!(
313            "the install was aborted and the destinations were restored: {cause}"
314        ));
315    }
316    let paths: Vec<&str> = unrestored.iter().map(|p| p.as_str()).collect();
317    RkError::Refused(format!(
318        "the install was aborted and restoration is incomplete; verify these by hand: {}: {cause}",
319        paths.join(", ")
320    ))
321}
322
323/// Install every embedded skill package under each root, previewing by
324/// default: the writer that every 0.9.x release ran.
325///
326/// No `rk` verb runs it now, because every landing carries the skills. It
327/// stays for the tests, which build with it the home that an earlier release
328/// left, so the uninstall is proved against that home.
329///
330/// `record_path` names the user-scope digest record: read to tell a stale copy
331/// this tool wrote from a file the user edited, and rewritten after a
332/// successful apply. Failing to write it is not a failure of the install — the
333/// files landed — so it costs only the benefit of the doubt next time.
334///
335/// The first apply after the shared gates moved into each package takes back
336/// the copies an earlier release wrote to the legacy shared root, where the
337/// record vouches for them, and names any it cannot vouch for.
338///
339/// # Errors
340///
341/// Returns [`RkError::Refused`] when the legacy shared root is reached
342/// through a symlink, when a destination cannot be touched, when one holds
343/// bytes neither reference accounts for and `force` is unset, or when a write
344/// fails partway, in which case the destinations are restored first.
345/// Returns [`RkError::Io`] when a destination exists but cannot be read.
346#[cfg(test)]
347pub fn install(layout: &Layout, apply: bool, force: bool) -> Result<Vec<Action>, RkError> {
348    check_legacy_root(layout.legacy_shared.as_deref(), &layout.record)?;
349    let record_path = layout.record.as_path();
350    let packages = crate::skills::packages()?;
351    let planned = plan_roots(&layout.roots, &packages);
352    for entry in &planned {
353        check_destination(&entry.destination)?;
354    }
355
356    let mut record = Record::load(record_path);
357    let covered: Vec<Utf8PathBuf> = planned
358        .iter()
359        .map(|entry| entry.destination.clone())
360        .collect();
361    let mut scanned = layout.roots.clone();
362    scanned.extend(layout.legacy_shared.clone());
363    let stale = leftovers(&scanned, &record, &covered);
364    let kept = legacy_kept(layout.legacy_shared.as_deref(), &stale);
365
366    if !apply {
367        let mut actions: Vec<Action> = covered
368            .into_iter()
369            .map(|destination| Action::Write { destination })
370            .collect();
371        actions.extend(
372            stale
373                .into_iter()
374                .map(|destination| Action::Sweep { destination }),
375        );
376        actions.extend(
377            kept.into_iter()
378                .map(|destination| Action::KeptEdited { destination }),
379        );
380        return Ok(actions);
381    }
382
383    if !force {
384        let conflicts = conflicts(&planned, &record)?;
385        if !conflicts.is_empty() {
386            return Err(RkError::Refused(format!(
387                "these destinations hold bytes this tool did not write, and nothing was written: {}; re-run with --force to overwrite",
388                conflicts.join(", ")
389            )));
390        }
391    }
392
393    // Back up every destination before the first write, so a failure on the
394    // second root cannot leave the first one upgraded.
395    let backups = back_up(&planned)?;
396
397    let mut actions = Vec::new();
398    for entry in &planned {
399        let held = backups.get(&entry.destination).and_then(Option::as_ref);
400        if held.is_some_and(|previous| previous == entry.bytes) {
401            actions.push(Action::Unchanged {
402                destination: entry.destination.clone(),
403            });
404            continue;
405        }
406        if let Err(source) = write_file(&entry.destination, entry.bytes) {
407            return Err(abort(
408                &rollback(&backups),
409                &format!("writing {} failed: {source}", entry.destination),
410            ));
411        }
412        actions.push(Action::Write {
413            destination: entry.destination.clone(),
414        });
415    }
416
417    // Sweep after the writes, never before: a refusal must leave the home
418    // exactly as it found it, and a leftover is harmless until the install
419    // superseding it has actually landed.
420    let stops = sweep_stops(layout);
421    for destination in &stale {
422        match remove_installed(std::slice::from_ref(destination), &stops) {
423            Ok(kept_dirs) => {
424                actions.push(Action::Sweep {
425                    destination: destination.clone(),
426                });
427                actions.extend(
428                    kept_dirs
429                        .into_iter()
430                        .map(|directory| Action::KeptDirectory { directory }),
431                );
432                record.written.remove(destination);
433            }
434            Err((_, source)) => actions.push(Action::SweepFailed {
435                destination: destination.clone(),
436                error: source.to_string(),
437            }),
438        }
439    }
440    actions.extend(
441        kept.into_iter()
442            .map(|destination| Action::KeptEdited { destination }),
443    );
444
445    for entry in &planned {
446        record
447            .written
448            .insert(entry.destination.clone(), Digest::of(entry.bytes));
449    }
450    if write_file(record_path, record.to_text().as_bytes()).is_err() {
451        actions.push(Action::RecordUnwritten {
452            record: record_path.to_owned(),
453        });
454    }
455    Ok(actions)
456}
457
458/// The bytes each planned destination holds before an apply, or `None` for
459/// one that does not exist yet.
460#[cfg(test)]
461fn back_up(planned: &[Planned]) -> Result<BTreeMap<Utf8PathBuf, Option<Vec<u8>>>, RkError> {
462    let mut backups = BTreeMap::new();
463    for entry in planned {
464        let previous = if entry.destination.is_file() {
465            Some(fs::read(&entry.destination).map_err(|source| {
466                RkError::Refused(format!(
467                    "cannot back up {}, and nothing was written: {source}",
468                    entry.destination
469                ))
470            })?)
471        } else {
472            None
473        };
474        backups.insert(entry.destination.clone(), previous);
475    }
476    Ok(backups)
477}
478
479/// The directories a removal never prunes: every agent root this run
480/// touches, and the state directory above the legacy shared root.
481fn sweep_stops(layout: &Layout) -> Vec<Utf8PathBuf> {
482    let mut stops = layout.roots.clone();
483    if let Some(state_dir) = layout.record.parent() {
484        stops.push(state_dir.to_owned());
485    }
486    stops
487}
488
489/// Remove every installed skill package under each root, previewing by
490/// default.
491///
492/// Only bytes this tool can vouch for go: a destination holding the embedded
493/// bytes or bytes the record says it wrote, plus the recorded leftovers, and a
494/// directory only once nothing else lives in it. A destination the user has
495/// edited is theirs now and stays, reported rather than removed. An absent
496/// destination is a no-op, so a re-run succeeds. Removed destinations leave
497/// the record too; what is gone cannot be vouched for.
498///
499/// Each package carries its own gates, so an uninstall of one agent family
500/// takes that family's copies and leaves the other's.
501///
502/// # Errors
503///
504/// Returns [`RkError::Refused`] when the legacy shared root is reached
505/// through a symlink, or when a destination is a symlink or not a regular
506/// file, and [`RkError::Io`] when a removal fails.
507pub fn uninstall(layout: &Layout, apply: bool) -> Result<Vec<Action>, RkError> {
508    check_legacy_root(layout.legacy_shared.as_deref(), &layout.record)?;
509    let record_path = layout.record.as_path();
510    let packages = crate::skills::packages()?;
511    let record_found = Record::load(record_path);
512    let mut removable: Vec<Utf8PathBuf> = Vec::new();
513    let mut edited: Vec<Utf8PathBuf> = Vec::new();
514    for entry in &plan_roots(&layout.roots, &packages) {
515        check_destination(&entry.destination)?;
516        if !entry.destination.is_file() {
517            continue;
518        }
519        // The same two references an install trusts decide what goes: the
520        // bytes, or bytes the record vouches this tool wrote.
521        // Anything else is the user's edit, and removing it would destroy
522        // work an install refuses to even overwrite.
523        let found = fs::read(&entry.destination)?;
524        if found == entry.bytes || record_found.wrote(&entry.destination, &Digest::of(&found)) {
525            removable.push(entry.destination.clone());
526        } else {
527            edited.push(entry.destination.clone());
528        }
529    }
530
531    let mut record = record_found;
532    let mut scanned = layout.roots.clone();
533    scanned.extend(layout.legacy_shared.clone());
534    // A skill this binary has since dropped is still ours to take back, and an
535    // uninstall leaving it behind is the leftover an agent keeps offering. The
536    // record is what names it; the embedded skills no longer can.
537    let stale = leftovers(&scanned, &record, &removable);
538    edited.extend(legacy_kept(layout.legacy_shared.as_deref(), &stale));
539
540    if !apply {
541        let mut actions: Vec<Action> = removable
542            .into_iter()
543            .map(|destination| Action::Remove { destination })
544            .collect();
545        actions.extend(
546            stale
547                .into_iter()
548                .map(|destination| Action::Sweep { destination }),
549        );
550        actions.extend(
551            edited
552                .into_iter()
553                .map(|destination| Action::KeptEdited { destination }),
554        );
555        return Ok(actions);
556    }
557
558    removable.extend(stale);
559    let kept_dirs = remove_installed(&removable, &sweep_stops(layout))
560        .map_err(|(_, source)| RkError::Io(source))?;
561    let mut actions: Vec<Action> = Vec::new();
562    for destination in &removable {
563        actions.push(Action::Remove {
564            destination: destination.clone(),
565        });
566        record.written.remove(destination);
567    }
568    actions.extend(
569        kept_dirs
570            .into_iter()
571            .map(|directory| Action::KeptDirectory { directory }),
572    );
573    actions.extend(
574        edited
575            .into_iter()
576            .map(|destination| Action::KeptEdited { destination }),
577    );
578
579    let recorded = if record.written.is_empty() {
580        fs::remove_file(record_path).or_else(|source| {
581            if source.kind() == std::io::ErrorKind::NotFound {
582                Ok(())
583            } else {
584                Err(source)
585            }
586        })
587    } else {
588        write_file(record_path, record.to_text().as_bytes())
589    };
590    if recorded.is_err() {
591        actions.push(Action::RecordUnwritten {
592            record: record_path.to_owned(),
593        });
594    }
595    Ok(actions)
596}
597
598#[cfg(test)]
599mod tests {
600    use camino::Utf8PathBuf;
601
602    use super::{Action, Layout, install, leftovers, uninstall};
603    use crate::skills::record::{RECORD_PATH, Record};
604    use crate::skills::{Digest, all, packages};
605
606    /// A scratch home, plus the roots and record path it implies.
607    struct Home {
608        dir: tempfile::TempDir,
609    }
610
611    impl Home {
612        fn new() -> Self {
613            Self {
614                dir: tempfile::tempdir().expect("a scratch home exists"),
615            }
616        }
617
618        fn path(&self) -> Utf8PathBuf {
619            Utf8PathBuf::from_path_buf(self.dir.path().to_path_buf())
620                .expect("the temp path is UTF-8")
621        }
622
623        fn roots(&self) -> Vec<Utf8PathBuf> {
624            let home = self.path();
625            vec![home.join(".claude/skills"), home.join(".agents/skills")]
626        }
627
628        fn record(&self) -> Utf8PathBuf {
629            self.path().join(RECORD_PATH)
630        }
631
632        fn destination(&self, root: &str, skill: &str) -> Utf8PathBuf {
633            self.path().join(root).join(skill).join("SKILL.md")
634        }
635
636        fn legacy_shared(&self) -> Utf8PathBuf {
637            self.path().join(".local/state/release-kit/skills/shared")
638        }
639
640        /// A gate copy inside one installed package.
641        fn gate(&self, root: &str, skill: &str) -> Utf8PathBuf {
642            self.path()
643                .join(root)
644                .join(skill)
645                .join("references/plan-gate.md")
646        }
647
648        /// The layout for every agent root.
649        fn layout(&self) -> Layout {
650            self.layout_for(self.roots())
651        }
652
653        /// The layout for a subset of the agent roots, which sweeps the
654        /// legacy root only when the subset is every root.
655        fn layout_for(&self, roots: Vec<Utf8PathBuf>) -> Layout {
656            let every = roots == self.roots();
657            Layout {
658                roots,
659                legacy_shared: every.then(|| self.legacy_shared()),
660                record: self.record(),
661            }
662        }
663    }
664
665    /// How many files every package carries together, per agent root.
666    fn package_files() -> usize {
667        packages()
668            .expect("the packages compose")
669            .iter()
670            .map(|package| package.files.len())
671            .sum()
672    }
673
674    fn first_skill() -> String {
675        all().expect("the skills read").swap_remove(0).name
676    }
677
678    #[test]
679    fn a_preview_lists_every_destination_and_writes_nothing() {
680        let home = Home::new();
681        let actions = install(&home.layout(), false, false).unwrap();
682        assert_eq!(actions.len(), package_files() * 2, "{actions:?}");
683        assert!(
684            actions
685                .iter()
686                .all(|action| matches!(action, Action::Write { .. })),
687            "{actions:?}"
688        );
689        assert!(!home.path().join(".claude").exists());
690        assert!(!home.record().exists());
691    }
692
693    #[test]
694    fn an_apply_is_idempotent_and_records_what_it_wrote() {
695        let home = Home::new();
696        let first = install(&home.layout(), true, false).unwrap();
697        assert!(
698            first
699                .iter()
700                .all(|action| matches!(action, Action::Write { .. })),
701            "{first:?}"
702        );
703        let second = install(&home.layout(), true, false).unwrap();
704        assert!(
705            second
706                .iter()
707                .all(|action| matches!(action, Action::Unchanged { .. })),
708            "{second:?}"
709        );
710        let record = Record::load(&home.record());
711        assert_eq!(record.written.len(), package_files() * 2);
712    }
713
714    /// The defect the record exists for: bytes a previous release wrote are
715    /// not the user's, and refusing on them makes every skill-touching release
716    /// break the install recipe.
717    #[test]
718    fn a_copy_a_previous_release_wrote_is_replaced_without_force() {
719        let home = Home::new();
720        install(&home.layout(), true, false).unwrap();
721
722        // Stand in for an older release: rewrite each destination and record
723        // its digest, exactly as that release's apply would have left it.
724        let mut stale = Record::default();
725        for destination in Record::load(&home.record()).written.into_keys() {
726            std::fs::write(&destination, "older canon bytes\n").unwrap();
727            stale
728                .written
729                .insert(destination, Digest::of(b"older canon bytes\n"));
730        }
731        std::fs::write(home.record(), stale.to_text()).unwrap();
732
733        install(&home.layout(), true, false).unwrap();
734        let text =
735            std::fs::read_to_string(home.destination(".claude/skills", &first_skill())).unwrap();
736        assert!(text.contains(&format!("name: {}", first_skill())));
737    }
738
739    /// A record vouching for one destination says nothing about another.
740    #[test]
741    fn an_edit_refuses_and_names_every_conflict() {
742        let home = Home::new();
743        install(&home.layout(), true, false).unwrap();
744        let edited: Vec<Utf8PathBuf> = all()
745            .unwrap()
746            .iter()
747            .map(|skill| home.destination(".claude/skills", &skill.name))
748            .collect();
749        for destination in &edited {
750            std::fs::write(destination, "the user wrote this").unwrap();
751        }
752
753        let message = install(&home.layout(), true, false)
754            .unwrap_err()
755            .to_string();
756        for destination in &edited {
757            assert!(message.contains(destination.as_str()), "{message}");
758        }
759        for destination in &edited {
760            assert_eq!(
761                std::fs::read_to_string(destination).unwrap(),
762                "the user wrote this",
763                "a refused install must not overwrite"
764            );
765        }
766        install(&home.layout(), true, true).unwrap();
767        assert!(
768            std::fs::read_to_string(&edited[0])
769                .unwrap()
770                .starts_with("---")
771        );
772    }
773
774    #[cfg(unix)]
775    #[test]
776    fn a_symlinked_destination_refuses_before_anything_is_written() {
777        let home = Home::new();
778        let skill = first_skill();
779        let destination = home.destination(".claude/skills", &skill);
780        std::fs::create_dir_all(destination.parent().unwrap()).unwrap();
781        let elsewhere = home.path().join("elsewhere");
782        std::fs::write(&elsewhere, "the user's file\n").unwrap();
783        std::os::unix::fs::symlink(&elsewhere, &destination).unwrap();
784
785        let message = install(&home.layout(), true, true).unwrap_err().to_string();
786        assert!(message.contains("symlink"), "{message}");
787        assert_eq!(
788            std::fs::read_to_string(&elsewhere).unwrap(),
789            "the user's file\n"
790        );
791        assert!(!home.path().join(".agents").exists());
792    }
793
794    /// A failure on the second root must not leave the first one upgraded.
795    #[test]
796    fn a_failed_write_restores_every_destination() {
797        let home = Home::new();
798        install(&home.layout(), true, false).unwrap();
799        let first = home.destination(".claude/skills", &first_skill());
800        std::fs::write(&first, "older canon bytes\n").unwrap();
801        let mut record = Record::load(&home.record());
802        record
803            .written
804            .insert(first.clone(), Digest::of(b"older canon bytes\n"));
805        std::fs::write(home.record(), record.to_text()).unwrap();
806
807        // A regular file where the second root's skill directory belongs: the
808        // directory cannot be created, so that root's write fails.
809        let blocked = home.path().join(".agents/skills").join(first_skill());
810        std::fs::remove_dir_all(&blocked).unwrap();
811        std::fs::write(&blocked, "in the way\n").unwrap();
812
813        let message = install(&home.layout(), true, false)
814            .unwrap_err()
815            .to_string();
816        assert!(message.contains("aborted"), "{message}");
817        assert_eq!(
818            std::fs::read_to_string(&first).unwrap(),
819            "older canon bytes\n",
820            "the first root must be restored"
821        );
822    }
823
824    #[test]
825    fn an_install_sweeps_a_destination_the_binary_dropped() {
826        let home = Home::new();
827        install(&home.layout(), true, false).unwrap();
828        let dropped = home.destination(".claude/skills", "rk-retired");
829        std::fs::create_dir_all(dropped.parent().unwrap()).unwrap();
830        std::fs::write(&dropped, "a skill a later release dropped\n").unwrap();
831        let mut record = Record::load(&home.record());
832        record.written.insert(
833            dropped.clone(),
834            Digest::of(b"a skill a later release dropped\n"),
835        );
836        std::fs::write(home.record(), record.to_text()).unwrap();
837
838        let actions = install(&home.layout(), true, false).unwrap();
839        assert!(
840            actions.contains(&Action::Sweep {
841                destination: dropped.clone()
842            }),
843            "{actions:?}"
844        );
845        assert!(!dropped.exists());
846        assert!(!dropped.parent().unwrap().exists());
847        assert!(!Record::load(&home.record()).written.contains_key(&dropped));
848    }
849
850    /// A leftover the user has since edited is theirs, not ours to remove.
851    #[test]
852    fn a_sweep_leaves_an_edited_leftover_alone() {
853        let home = Home::new();
854        install(&home.layout(), true, false).unwrap();
855        let dropped = home.destination(".claude/skills", "rk-retired");
856        std::fs::create_dir_all(dropped.parent().unwrap()).unwrap();
857        std::fs::write(&dropped, "the user rewrote this\n").unwrap();
858        let mut record = Record::load(&home.record());
859        record
860            .written
861            .insert(dropped.clone(), Digest::of(b"what we wrote\n"));
862        std::fs::write(home.record(), record.to_text()).unwrap();
863
864        assert!(
865            !leftovers(&home.roots(), &record, &[]).contains(&dropped),
866            "a leftover whose bytes differ from the record is the user's"
867        );
868        install(&home.layout(), true, false).unwrap();
869        assert_eq!(
870            std::fs::read_to_string(&dropped).unwrap(),
871            "the user rewrote this\n"
872        );
873    }
874
875    /// A destination the user edited after installing is theirs: an
876    /// uninstall reports it and leaves it, exactly as an install refuses
877    /// to overwrite it.
878    #[test]
879    fn an_uninstall_keeps_an_edited_destination() {
880        let home = Home::new();
881        install(&home.layout(), true, false).unwrap();
882        let edited = home.destination(".claude/skills", &first_skill());
883        std::fs::write(&edited, "the user rewrote this\n").unwrap();
884
885        let preview = uninstall(&home.layout(), false).unwrap();
886        assert!(
887            preview.contains(&Action::KeptEdited {
888                destination: edited.clone()
889            }),
890            "{preview:?}"
891        );
892        assert!(
893            !preview.contains(&Action::Remove {
894                destination: edited.clone()
895            }),
896            "{preview:?}"
897        );
898
899        let actions = uninstall(&home.layout(), true).unwrap();
900        assert!(
901            actions.contains(&Action::KeptEdited {
902                destination: edited.clone()
903            }),
904            "{actions:?}"
905        );
906        assert_eq!(
907            std::fs::read_to_string(&edited).unwrap(),
908            "the user rewrote this\n",
909            "an uninstall must never delete a user's edit"
910        );
911        // Everything the tool can vouch for is still removed.
912        assert!(!home.destination(".agents/skills", &first_skill()).exists());
913    }
914
915    #[test]
916    fn an_uninstall_removes_what_it_wrote_and_keeps_the_rest() {
917        let home = Home::new();
918        install(&home.layout(), true, false).unwrap();
919        let skill = first_skill();
920        let beside = home
921            .destination(".claude/skills", &skill)
922            .parent()
923            .unwrap()
924            .join("notes.md");
925        std::fs::write(&beside, "the user's notes\n").unwrap();
926
927        let actions = uninstall(&home.layout(), true).unwrap();
928        assert!(
929            actions
930                .iter()
931                .any(|action| matches!(action, Action::KeptDirectory { .. })),
932            "{actions:?}"
933        );
934        assert!(!home.destination(".claude/skills", &skill).exists());
935        assert!(beside.is_file(), "a file beside a skill must survive");
936        assert!(!home.record().exists(), "an empty record is removed");
937        // A re-run over an emptied home is a no-op, not a failure.
938        uninstall(&home.layout(), true).unwrap();
939    }
940
941    #[test]
942    fn one_root_installs_and_uninstalls_without_touching_the_other() {
943        let home = Home::new();
944        let claude = home.layout_for(vec![home.path().join(".claude/skills")]);
945        install(&claude, true, false).unwrap();
946        assert!(home.destination(".claude/skills", &first_skill()).is_file());
947        assert!(!home.path().join(".agents").exists());
948
949        uninstall(&claude, true).unwrap();
950        assert!(!home.destination(".claude/skills", &first_skill()).exists());
951    }
952
953    /// Each package carries its own gates, so an install for either agent
954    /// family lands them inside that family's packages and nowhere else.
955    #[test]
956    fn either_agent_alone_lands_the_gates_in_its_packages() {
957        for (root, other) in [
958            (".claude/skills", ".agents/skills"),
959            (".agents/skills", ".claude/skills"),
960        ] {
961            let home = Home::new();
962            let one = home.layout_for(vec![home.path().join(root)]);
963            install(&one, true, false).unwrap();
964            for skill in all().unwrap() {
965                assert!(
966                    home.gate(root, &skill.name).is_file(),
967                    "{root}/{}: the gate did not land",
968                    skill.name
969                );
970                assert!(!home.gate(other, &skill.name).exists());
971            }
972            assert!(!home.legacy_shared().exists());
973        }
974    }
975
976    /// The defect a shared root had: one family's uninstall had to keep the
977    /// gates for the other. A package owns its copies, so each family's
978    /// uninstall takes its own and leaves the other's.
979    #[test]
980    fn an_uninstall_of_one_family_leaves_the_other_family_its_gates() {
981        let home = Home::new();
982        install(&home.layout(), true, false).unwrap();
983        let codex = home.layout_for(vec![home.path().join(".agents/skills")]);
984        uninstall(&codex, true).unwrap();
985        assert!(
986            !home
987                .path()
988                .join(".agents/skills")
989                .join(first_skill())
990                .exists()
991        );
992        assert!(home.gate(".claude/skills", &first_skill()).is_file());
993    }
994
995    /// The first install after the gates moved into the packages takes back
996    /// the copies an earlier release wrote to the shared root, and the
997    /// directories they leave empty.
998    #[test]
999    fn an_install_sweeps_the_legacy_shared_root_it_vouches_for() {
1000        let home = Home::new();
1001        let legacy = home.legacy_shared().join("plan-gate.md");
1002        std::fs::create_dir_all(home.legacy_shared()).unwrap();
1003        std::fs::write(&legacy, "an earlier release's gate\n").unwrap();
1004        let mut record = Record::default();
1005        record
1006            .written
1007            .insert(legacy.clone(), Digest::of(b"an earlier release's gate\n"));
1008        std::fs::create_dir_all(home.record().parent().unwrap()).unwrap();
1009        std::fs::write(home.record(), record.to_text()).unwrap();
1010
1011        let preview = install(&home.layout(), false, false).unwrap();
1012        assert!(
1013            preview.contains(&Action::Sweep {
1014                destination: legacy.clone()
1015            }),
1016            "{preview:?}"
1017        );
1018        install(&home.layout(), true, false).unwrap();
1019        assert!(!legacy.exists());
1020        assert!(!home.legacy_shared().exists());
1021        assert!(
1022            !home.legacy_shared().parent().unwrap().exists(),
1023            "the emptied skills directory goes too"
1024        );
1025        assert!(!Record::load(&home.record()).written.contains_key(&legacy));
1026    }
1027
1028    /// A run limited to one agent family leaves the legacy root alone,
1029    /// because an older manual under the other family's root still reads
1030    /// the gates there; the uninstall of that family leaves it too.
1031    #[test]
1032    fn a_one_family_install_keeps_the_legacy_gates() {
1033        let home = Home::new();
1034        let legacy = home.legacy_shared().join("plan-gate.md");
1035        std::fs::create_dir_all(home.legacy_shared()).unwrap();
1036        std::fs::write(&legacy, "an earlier release's gate\n").unwrap();
1037        let mut record = Record::default();
1038        record
1039            .written
1040            .insert(legacy.clone(), Digest::of(b"an earlier release's gate\n"));
1041        std::fs::create_dir_all(home.record().parent().unwrap()).unwrap();
1042        std::fs::write(home.record(), record.to_text()).unwrap();
1043        let codex = home.layout_for(vec![home.path().join(".agents/skills")]);
1044
1045        let preview = install(&codex, false, false).unwrap();
1046        assert!(
1047            !preview.iter().any(|action| matches!(
1048                action,
1049                Action::Sweep { destination } if destination == &legacy
1050            )),
1051            "{preview:?}"
1052        );
1053        install(&codex, true, false).unwrap();
1054        assert!(legacy.is_file());
1055        uninstall(&codex, true).unwrap();
1056        assert!(legacy.is_file());
1057        assert!(Record::load(&home.record()).written.contains_key(&legacy));
1058    }
1059
1060    /// Bytes under the legacy root the record cannot vouch for are the
1061    /// user's: the sweep leaves them and names them.
1062    #[test]
1063    fn an_install_leaves_and_names_an_unvouched_legacy_gate() {
1064        let home = Home::new();
1065        let legacy = home.legacy_shared().join("plan-gate.md");
1066        std::fs::create_dir_all(home.legacy_shared()).unwrap();
1067        std::fs::write(&legacy, "the user's own gate\n").unwrap();
1068
1069        let actions = install(&home.layout(), true, false).unwrap();
1070        assert!(
1071            actions.contains(&Action::KeptEdited {
1072                destination: legacy.clone()
1073            }),
1074            "{actions:?}"
1075        );
1076        assert_eq!(
1077            std::fs::read_to_string(&legacy).unwrap(),
1078            "the user's own gate\n"
1079        );
1080    }
1081
1082    /// A gate copy the user edited is theirs, exactly as a skill is.
1083    #[test]
1084    fn an_edited_gate_copy_refuses_an_install_and_survives_an_uninstall() {
1085        let home = Home::new();
1086        install(&home.layout(), true, false).unwrap();
1087        let gate = home.gate(".claude/skills", &first_skill());
1088        std::fs::write(&gate, b"mine now").unwrap();
1089
1090        let message = install(&home.layout(), true, false)
1091            .expect_err("an edited gate refuses")
1092            .to_string();
1093        assert!(message.contains("plan-gate.md"), "{message}");
1094
1095        let actions = uninstall(&home.layout(), true).unwrap();
1096        assert!(
1097            actions.iter().any(|action| matches!(
1098                action,
1099                Action::KeptEdited { destination } if destination == &gate
1100            )),
1101            "{actions:?}"
1102        );
1103        assert_eq!(std::fs::read(&gate).unwrap(), b"mine now");
1104    }
1105
1106    /// A symlinked gate copy refuses before anything is written, the same as
1107    /// a symlinked skill.
1108    #[test]
1109    fn a_symlinked_gate_copy_refuses_before_writing() {
1110        let home = Home::new();
1111        let gate = home.gate(".agents/skills", &first_skill());
1112        std::fs::create_dir_all(gate.parent().unwrap()).unwrap();
1113        std::os::unix::fs::symlink("/etc/passwd", &gate).unwrap();
1114
1115        let message = install(&home.layout(), true, false)
1116            .expect_err("a symlink refuses")
1117            .to_string();
1118        assert!(message.contains("symlink"), "{message}");
1119        assert!(
1120            !home.destination(".claude/skills", &first_skill()).exists(),
1121            "the refusal must come before the first write"
1122        );
1123    }
1124
1125    /// A legacy shared root reached through a symlinked directory refuses
1126    /// both verbs: the sweep would delete wherever the link points, and no
1127    /// per-destination check can see it.
1128    #[test]
1129    fn a_symlinked_shared_root_refuses_install_and_uninstall() {
1130        for symlinked in ["skills", "skills/shared"] {
1131            let home = Home::new();
1132            let elsewhere = home.path().join("elsewhere");
1133            std::fs::create_dir_all(&elsewhere).unwrap();
1134            let state_dir = home.record().parent().unwrap().to_path_buf();
1135            let linked = state_dir.join(symlinked);
1136            std::fs::create_dir_all(linked.parent().unwrap()).unwrap();
1137            std::os::unix::fs::symlink(&elsewhere, &linked).unwrap();
1138
1139            let message = install(&home.layout(), true, false)
1140                .expect_err("a symlinked shared root refuses an install")
1141                .to_string();
1142            assert!(message.contains("symlink"), "{message}");
1143            assert!(
1144                !elsewhere.join("plan-gate.md").exists(),
1145                "an install must never write through a symlinked shared root"
1146            );
1147            assert!(!home.path().join(".claude").exists());
1148
1149            std::fs::write(elsewhere.join("plan-gate.md"), "theirs\n").unwrap();
1150            let message = uninstall(&home.layout(), true)
1151                .expect_err("a symlinked shared root refuses an uninstall")
1152                .to_string();
1153            assert!(message.contains("symlink"), "{message}");
1154            assert!(
1155                elsewhere.join("plan-gate.md").exists(),
1156                "an uninstall must never remove through a symlinked shared root"
1157            );
1158        }
1159    }
1160}