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