Skip to main content

spec_driven_docs/services/
skill_installer.rs

1//! Install the embedded skills into agent skill directories, as packages.
2//!
3//! The destinations live outside any instance — under the invoking user's
4//! home — so nothing here touches an instance manifest. The unit is the
5//! package: one directory per skill, holding `SKILL.md` and every shared
6//! artifact under `references/`, which is what the Agent Skills format and
7//! every documented host resolve against.
8//!
9//! Three references decide every destination: the payload, the receipt, and
10//! the disk. Bytes matching the payload are current. Bytes the receipt
11//! vouches for are this tool's and may be replaced or taken back. Every
12//! other byte is the user's: an install refuses it without `--force`, and
13//! an uninstall leaves it and names it.
14//!
15//! Every apply holds the user-scope lock for its whole run, stages each
16//! write beside its destination, replaces one file at a time, and writes the
17//! receipt last. A run the process does not finish leaves whole files and
18//! the previous receipt, reports what it wrote, and is safe to run again.
19
20use std::collections::BTreeSet;
21
22use camino::{Utf8Path, Utf8PathBuf};
23
24use crate::domain::ownership::Sha256;
25use crate::domain::paths::HOME_VAR;
26use crate::domain::skill_record::SkillRecord;
27use crate::error::AppError;
28use crate::transaction::lock::Lock;
29use crate::transaction::stage::Stage;
30
31// The roots are declared in `domain::paths` and reach their callers from
32// here, so that what a destination is stays one statement and what an
33// install does with it stays another.
34pub use crate::domain::paths::{AGENTS_ROOT, CLAUDE_ROOT, LEGACY_SHARED_ROOT};
35
36/// The invoking user's home directory.
37///
38/// # Errors
39///
40/// [`AppError::Usage`] when `HOME` is unset or empty.
41pub fn home() -> Result<Utf8PathBuf, AppError> {
42    crate::domain::paths::UserEnv::from_process()
43        .home
44        .ok_or_else(|| AppError::Usage(format!("{HOME_VAR} is not set")))
45}
46
47/// Where one run writes: the agent roots, the state root, and the receipt.
48#[derive(Debug, Clone)]
49pub struct Layout {
50    /// The agent skill roots this run was asked to touch, deduplicated.
51    pub roots: Vec<Utf8PathBuf>,
52    /// Where this tool keeps state that outlives a command.
53    pub state_root: Utf8PathBuf,
54    /// The receipt vouching for every user-scope file this tool wrote.
55    pub receipt: Utf8PathBuf,
56    /// The receipt at the home-relative path, read once as a fallback.
57    pub legacy_receipt: Utf8PathBuf,
58    /// The retired shared root, swept rather than written.
59    pub legacy_shared: Utf8PathBuf,
60}
61
62impl Layout {
63    /// The lock every apply holds for its whole run.
64    #[must_use]
65    pub fn lock_path(&self) -> Utf8PathBuf {
66        self.state_root.join(crate::domain::paths::SKILL_LOCK_FILE)
67    }
68
69    /// Every location a sweep reads: the selected roots and the retired one.
70    fn scanned(&self) -> Vec<Utf8PathBuf> {
71        let mut scanned = self.roots.clone();
72        scanned.push(self.legacy_shared.clone());
73        scanned
74    }
75}
76
77/// One planned file: where, which bytes, and their digest.
78#[derive(Debug, Clone)]
79struct Planned {
80    destination: Utf8PathBuf,
81    bytes: &'static [u8],
82    digest: Sha256,
83}
84
85/// A destination this tool refuses to write through, whatever `--force` says.
86#[derive(Debug, Clone, PartialEq, Eq)]
87struct Blocked {
88    path: Utf8PathBuf,
89    kind: &'static str,
90}
91
92impl std::fmt::Display for Blocked {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        write!(f, "{} is {}", self.path, self.kind)
95    }
96}
97
98/// How a run ends when a test interrupts it.
99///
100/// `Abandoned` stands in for the process dying partway, which leaves the
101/// files written so far and the previous receipt.
102enum Failure {
103    Error(AppError),
104    Abandoned,
105}
106
107impl From<AppError> for Failure {
108    fn from(error: AppError) -> Self {
109        Self::Error(error)
110    }
111}
112
113impl From<std::io::Error> for Failure {
114    fn from(error: std::io::Error) -> Self {
115        Self::Error(AppError::Io(error))
116    }
117}
118
119/// Where a run stops, counted in boundaries of the persistence order.
120///
121/// Production never sets it. The recovery tests walk it from one upward and
122/// assert that the next invocation puts every destination back before it
123/// writes anything new.
124#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
125struct Interrupt {
126    /// Stop after this many boundaries, without rolling back.
127    after: Option<usize>,
128}
129
130fn reached(passed: &mut usize, interrupt: Interrupt) -> Result<(), Failure> {
131    *passed += 1;
132    if interrupt.after == Some(*passed) {
133        return Err(Failure::Abandoned);
134    }
135    Ok(())
136}
137
138/// Every file of every package under `roots`, root by root and skill by skill.
139fn plan(roots: &[Utf8PathBuf]) -> Result<Vec<Planned>, AppError> {
140    let mut planned = Vec::new();
141    for root in roots {
142        for name in crate::embedded::skill_names() {
143            let package = crate::embedded::skill_package(name)
144                .ok_or_else(|| anyhow::anyhow!("payload skill missing: {name}"))?;
145            for (relative, bytes) in package {
146                planned.push(Planned {
147                    destination: root.join(name).join(relative),
148                    bytes,
149                    digest: Sha256::of(bytes),
150                });
151            }
152        }
153    }
154    Ok(planned)
155}
156
157/// The first component of `destination` under `root` this tool will not
158/// write through.
159///
160/// Every component is inspected without following links, from the agent
161/// root down. Directories above the root are the user's layout and stay
162/// unjudged: this tool did not create them and does not police them.
163fn blocked_by(root: &Utf8Path, destination: &Utf8Path) -> Option<Blocked> {
164    let relative = destination.strip_prefix(root).ok()?;
165    let mut current = root.to_owned();
166    if let Ok(meta) = std::fs::symlink_metadata(&current) {
167        if meta.file_type().is_symlink() {
168            return Some(Blocked {
169                path: current,
170                kind: "a symlink",
171            });
172        }
173        if !meta.is_dir() {
174            return Some(Blocked {
175                path: current,
176                kind: "a file where a directory is needed",
177            });
178        }
179    }
180    let components: Vec<&str> = relative.as_str().split('/').collect();
181    let last = components.len().saturating_sub(1);
182    for (index, part) in components.iter().enumerate() {
183        current = current.join(part);
184        let Ok(meta) = std::fs::symlink_metadata(&current) else {
185            // Absent, so nothing below it exists either.
186            return None;
187        };
188        if meta.file_type().is_symlink() {
189            return Some(Blocked {
190                path: current,
191                kind: "a symlink",
192            });
193        }
194        if index == last {
195            if !meta.is_file() {
196                return Some(Blocked {
197                    path: current,
198                    kind: if meta.is_dir() {
199                        "a directory"
200                    } else {
201                        "not a regular file"
202                    },
203                });
204            }
205        } else if !meta.is_dir() {
206            return Some(Blocked {
207                path: current,
208                kind: "a file where a directory is needed",
209            });
210        }
211    }
212    None
213}
214
215/// Which root a destination sits under, for the chain check.
216fn owning_root<'a>(layout: &'a Layout, destination: &Utf8Path) -> Option<&'a Utf8Path> {
217    layout
218        .roots
219        .iter()
220        .chain(std::iter::once(&layout.state_root))
221        // The retired root is swept rather than written, and a sweep
222        // unlinks. A destination under it that no root claimed would reach
223        // the removal with no component checked at all.
224        .chain(std::iter::once(&layout.legacy_shared))
225        .map(Utf8PathBuf::as_path)
226        .find(|root| destination.starts_with(root))
227}
228
229/// Every destination this run will not write through.
230fn blocked(layout: &Layout, destinations: &[Utf8PathBuf]) -> Vec<Blocked> {
231    let mut found = Vec::new();
232    for destination in destinations {
233        let Some(root) = owning_root(layout, destination) else {
234            continue;
235        };
236        if let Some(one) = blocked_by(root, destination)
237            && !found.contains(&one)
238        {
239            found.push(one);
240        }
241    }
242    found
243}
244
245/// What a destination currently holds, judged against the two references.
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247enum Standing {
248    /// Nothing is there.
249    Absent,
250    /// The payload's own bytes.
251    Current,
252    /// Bytes the receipt vouches for, from an earlier release.
253    Recorded,
254    /// Bytes neither reference accounts for.
255    Foreign,
256}
257
258fn standing(
259    destination: &Utf8Path,
260    intended: &Sha256,
261    record: &SkillRecord,
262) -> Result<Standing, AppError> {
263    if !destination.is_file() {
264        return Ok(Standing::Absent);
265    }
266    // An unreadable destination raises instead of passing as clean: a
267    // comparison that cannot run must never license an overwrite.
268    let found = Sha256::of(&std::fs::read(destination)?);
269    if &found == intended {
270        return Ok(Standing::Current);
271    }
272    if record.wrote(destination, &found) {
273        return Ok(Standing::Recorded);
274    }
275    Ok(Standing::Foreign)
276}
277
278/// Recorded destinations under a scanned root that this run no longer plans.
279///
280/// Only bytes the receipt still vouches for are swept: a leftover the user
281/// edited is theirs. This is also what sweeps the retired shared root, whose
282/// two files an earlier release wrote and recorded.
283fn leftovers(
284    scanned: &[Utf8PathBuf],
285    record: &SkillRecord,
286    keep: &BTreeSet<Utf8PathBuf>,
287) -> Vec<(Utf8PathBuf, Sha256, bool)> {
288    record
289        .written
290        .iter()
291        .filter(|(destination, _)| {
292            !keep.contains(*destination) && scanned.iter().any(|root| destination.starts_with(root))
293        })
294        .map(|(destination, digest)| {
295            let ours = !destination.is_symlink()
296                && destination.is_file()
297                && std::fs::read(destination).is_ok_and(|found| Sha256::of(&found) == *digest);
298            (destination.clone(), digest.clone(), ours)
299        })
300        .collect()
301}
302
303/// Remove the directories a removal emptied, deepest first, up to `stop`.
304fn prune_empty(directories: &BTreeSet<Utf8PathBuf>, stop: &[Utf8PathBuf], lines: &mut Vec<String>) {
305    let mut deepest: Vec<&Utf8PathBuf> = directories.iter().collect();
306    deepest.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
307    for directory in deepest {
308        if stop.iter().any(|root| root == directory) {
309            continue;
310        }
311        let Ok(mut entries) = std::fs::read_dir(directory) else {
312            continue;
313        };
314        if entries.next().is_none() {
315            let _ = std::fs::remove_dir(directory);
316        } else {
317            lines.push(format!("kept (not empty): {directory}"));
318        }
319    }
320}
321
322/// The receipt this run intends to leave.
323fn next_receipt(record: &SkillRecord, written: &[Planned], removed: &[Utf8PathBuf]) -> SkillRecord {
324    let mut next = record.clone();
325    next.schema_version = crate::domain::skill_record::SCHEMA_VERSION;
326    next.engine_version = env!("CARGO_PKG_VERSION").to_string();
327    for destination in removed {
328        next.written.remove(destination);
329    }
330    for entry in written {
331        next.written
332            .insert(entry.destination.clone(), entry.digest.clone());
333    }
334    // A run that changed nothing leaves the receipt alone, timestamp
335    // included. A record rewritten on every invocation would make a second
336    // install write a file, and idempotence is what tells an operator that
337    // the home is already current.
338    if next.written == record.written
339        && next.engine_version == record.engine_version
340        && record.schema_version == crate::domain::skill_record::SCHEMA_VERSION
341    {
342        return record.clone();
343    }
344    next.installed_at = jiff::Timestamp::now().to_string();
345    next
346}
347
348/// Read the receipt, falling back to the home-relative path once.
349fn load_receipt(layout: &Layout) -> SkillRecord {
350    SkillRecord::load_with_fallback(&layout.receipt, &layout.legacy_receipt)
351}
352
353/// Install every embedded skill package under each root, previewing by default.
354///
355/// # Errors
356///
357/// [`AppError::Refused`] when a destination is reached through a link or
358/// holds bytes neither reference accounts for without `--force`,
359/// [`AppError::Busy`] when another process holds the lock,
360/// [`AppError::Unrecovered`] when an unfinished run cannot be put back, and
361/// [`AppError::Receipt`] when the apply cannot record what it wrote.
362pub fn install(layout: &Layout, apply: bool, force: bool) -> Result<Vec<String>, AppError> {
363    settle(install_with(layout, apply, force, Interrupt::default()))
364}
365
366/// Remove every installed skill package under each root, previewing by default.
367///
368/// # Errors
369///
370/// As [`install`], except that nothing here refuses on foreign bytes: a
371/// file the receipt cannot vouch for stays and is named.
372pub fn uninstall(layout: &Layout, apply: bool) -> Result<Vec<String>, AppError> {
373    settle(uninstall_with(layout, apply, Interrupt::default()))
374}
375
376fn settle(result: Result<Vec<String>, Failure>) -> Result<Vec<String>, AppError> {
377    result.map_err(|failure| match failure {
378        Failure::Error(error) => error,
379        Failure::Abandoned => AppError::Other(anyhow::anyhow!(
380            "the run was interrupted; the next invocation recovers it"
381        )),
382    })
383}
384
385/// The lock an applied run holds for the whole of its own reasoning.
386///
387/// A preview takes none: it writes nothing, and a preview that refused
388/// because somebody else was installing would fail for a reason the
389/// operator cannot act on. An applied run takes it before it reads a
390/// receipt or scans a destination, so every decision it makes is one the
391/// world still agrees with when it executes. A run that derived its
392/// removals before the lock could remove files a newer install had just
393/// written.
394///
395/// # Errors
396///
397/// [`AppError::Busy`] when another process holds it.
398fn held(layout: &Layout, apply: bool, purpose: &str) -> Result<Option<Lock>, Failure> {
399    if !apply {
400        return Ok(None);
401    }
402    let lock = Lock::exclusive(&layout.lock_path(), purpose)?;
403    Ok(Some(lock))
404}
405
406/// The install, with the recovery tests' interruption point.
407fn install_with(
408    layout: &Layout,
409    apply: bool,
410    force: bool,
411    interrupt: Interrupt,
412) -> Result<Vec<String>, Failure> {
413    let _lock = held(layout, apply, "skill install")?;
414    let planned = plan(&layout.roots)?;
415    let mut lines: Vec<String> = planned
416        .iter()
417        .map(|entry| entry.destination.to_string())
418        .collect();
419
420    let record = load_receipt(layout);
421    let kept: BTreeSet<Utf8PathBuf> = planned
422        .iter()
423        .map(|entry| entry.destination.clone())
424        .collect();
425    let stale = leftovers(&layout.scanned(), &record, &kept);
426    for (destination, _, ours) in &stale {
427        if *ours {
428            lines.push(format!(
429                "to sweep (no longer in the payload): {destination}"
430            ));
431        } else {
432            lines.push(format!("kept (edited): {destination}"));
433        }
434    }
435
436    let mut destinations: Vec<Utf8PathBuf> = planned
437        .iter()
438        .map(|entry| entry.destination.clone())
439        .collect();
440    destinations.push(layout.receipt.clone());
441    let refused = blocked(layout, &destinations);
442    for one in &refused {
443        lines.push(format!("conflict: {one}"));
444    }
445
446    let mut foreign: Vec<Utf8PathBuf> = Vec::new();
447    let mut writes: Vec<Planned> = Vec::new();
448    // What the receipt will vouch for is every destination that holds the
449    // payload's bytes once this run ends, not only the ones this run wrote.
450    // A previous run that stopped partway left files written and unrecorded,
451    // and a record that skipped them would never take them back.
452    let mut vouched: Vec<Planned> = Vec::new();
453    for entry in &planned {
454        if refused
455            .iter()
456            .any(|one| entry.destination.starts_with(&one.path))
457        {
458            continue;
459        }
460        vouched.push(entry.clone());
461        match standing(&entry.destination, &entry.digest, &record)? {
462            Standing::Current => {}
463            Standing::Foreign => {
464                foreign.push(entry.destination.clone());
465                writes.push(entry.clone());
466            }
467            Standing::Absent | Standing::Recorded => writes.push(entry.clone()),
468        }
469    }
470    for destination in &foreign {
471        lines.push(format!(
472            "conflict: {destination} holds bytes this tool did not write"
473        ));
474    }
475
476    if !apply {
477        lines.push("DRY RUN: no files written".to_string());
478        return Ok(lines);
479    }
480    if !refused.is_empty() {
481        return Err(refuse_blocked(&refused).into());
482    }
483    if !force && !foreign.is_empty() {
484        let paths: Vec<&str> = foreign.iter().map(|path| path.as_str()).collect();
485        return Err(AppError::Refused(format!(
486            "destinations hold bytes this tool did not write: {}; re-run with --force to overwrite",
487            paths.join(", ")
488        ))
489        .into());
490    }
491
492    let swept: Vec<Utf8PathBuf> = stale
493        .iter()
494        .filter(|(_, _, ours)| *ours)
495        .map(|(destination, _, _)| destination.clone())
496        .collect();
497    let receipt = next_receipt(&record, &vouched, &swept);
498    run_transaction(
499        layout,
500        &writes,
501        &stale
502            .into_iter()
503            .filter(|(_, _, ours)| *ours)
504            .map(|(destination, digest, _)| (destination, digest))
505            .collect::<Vec<_>>(),
506        &receipt,
507        &mut lines,
508        interrupt,
509    )?;
510    Ok(lines)
511}
512
513/// The uninstall, with the recovery tests' interruption point.
514fn uninstall_with(
515    layout: &Layout,
516    apply: bool,
517    interrupt: Interrupt,
518) -> Result<Vec<String>, Failure> {
519    // A home with no receipt has nothing this tool wrote, so an uninstall
520    // there removes nothing and takes no lock: creating the lock file
521    // would itself be the write that home was promised it would not get.
522    let _lock = held(layout, apply && layout.receipt.exists(), "skill uninstall")?;
523    let planned = plan(&layout.roots)?;
524    let record = load_receipt(layout);
525    let mut lines: Vec<String> = Vec::new();
526    let mut removals: Vec<(Utf8PathBuf, Sha256)> = Vec::new();
527
528    let refused = blocked(
529        layout,
530        &planned
531            .iter()
532            .map(|entry| entry.destination.clone())
533            .collect::<Vec<_>>(),
534    );
535    for one in &refused {
536        lines.push(format!("conflict: {one}"));
537    }
538
539    for entry in &planned {
540        if refused
541            .iter()
542            .any(|one| entry.destination.starts_with(&one.path))
543        {
544            continue;
545        }
546        if !entry.destination.is_file() {
547            continue;
548        }
549        let found = Sha256::of(&std::fs::read(&entry.destination)?);
550        if record.wrote(&entry.destination, &found) {
551            lines.push(entry.destination.to_string());
552            removals.push((entry.destination.clone(), found));
553        } else if found == entry.digest {
554            // The payload's own bytes with no receipt behind them: this
555            // tool cannot tell its copy from one the user placed there.
556            lines.push(format!("kept (not this tool's): {}", entry.destination));
557        } else {
558            lines.push(format!("kept (edited): {}", entry.destination));
559        }
560    }
561
562    let keep: BTreeSet<Utf8PathBuf> = removals
563        .iter()
564        .map(|(destination, _)| destination.clone())
565        .collect();
566    for (destination, digest, ours) in leftovers(&layout.scanned(), &record, &keep) {
567        if ours {
568            lines.push(format!("sweep (no longer in the payload): {destination}"));
569            removals.push((destination, digest));
570        } else if destination.exists() {
571            lines.push(format!("kept (edited): {destination}"));
572        }
573    }
574
575    if !apply {
576        lines.push("DRY RUN: no files removed".to_string());
577        return Ok(lines);
578    }
579    if !refused.is_empty() {
580        return Err(refuse_blocked(&refused).into());
581    }
582    // A home this tool never wrote into has nothing to take back, and an
583    // uninstall there must leave it exactly as it found it — no state root,
584    // no lock file, nothing.
585    if removals.is_empty() && !layout.receipt.exists() {
586        return Ok(lines);
587    }
588
589    let gone: Vec<Utf8PathBuf> = removals
590        .iter()
591        .map(|(destination, _)| destination.clone())
592        .collect();
593    let receipt = next_receipt(&record, &[], &gone);
594    run_transaction(layout, &[], &removals, &receipt, &mut lines, interrupt)?;
595    Ok(lines)
596}
597
598fn refuse_blocked(refused: &[Blocked]) -> AppError {
599    let named: Vec<String> = refused.iter().map(Blocked::to_string).collect();
600    AppError::Refused(format!(
601        "destinations cannot be written through: {}; move them aside and run this again",
602        named.join("; ")
603    ))
604}
605
606/// Replace each file, remove what is gone, and record it last.
607///
608/// Each destination is replaced in its own directory, one rename at a time,
609/// and the receipt is written after all of them. There is no journal and no
610/// backup store: a run that stops partway leaves whole files, the previous
611/// receipt, and a report naming what it finished, and running it again
612/// finishes the rest.
613fn run_transaction(
614    layout: &Layout,
615    writes: &[Planned],
616    removals: &[(Utf8PathBuf, Sha256)],
617    receipt: &SkillRecord,
618    lines: &mut Vec<String>,
619    interrupt: Interrupt,
620) -> Result<(), Failure> {
621    // A receipt that vouches for nothing is removed rather than written:
622    // an empty record is a file that says the tool wrote something here,
623    // and it did not.
624    let empty = receipt.written.is_empty();
625    let receipt_bytes = receipt.to_json().into_bytes();
626    let receipt_current = if empty {
627        !layout.receipt.exists()
628    } else {
629        std::fs::read(&layout.receipt).is_ok_and(|held| held == receipt_bytes)
630    };
631    if writes.is_empty() && removals.is_empty() && receipt_current {
632        return Ok(());
633    }
634
635    let mut passed = 0usize;
636    let mut completed: Vec<Utf8PathBuf> = Vec::new();
637    // What the run meant to remove and did not, because the bytes there
638    // are no longer the ones the receipt vouches for. The record keeps
639    // them, so the doctor can still name what is left behind.
640    let mut kept: Vec<Utf8PathBuf> = Vec::new();
641
642    for entry in writes {
643        replace_one(layout, &entry.destination, entry.bytes)
644            .map_err(|failure| stopped(failure, &completed))?;
645        completed.push(entry.destination.clone());
646        reached(&mut passed, interrupt)?;
647    }
648    for (destination, vouched) in removals {
649        // A component swapped since the run was planned is refused rather
650        // than followed: a removal through a link would unlink somebody
651        // else's file.
652        //
653        if let Some(root) = owning_root(layout, destination)
654            && let Some(one) = blocked_by(root, destination)
655        {
656            return Err(stopped(
657                Failure::Error(refuse_blocked(std::slice::from_ref(&one))),
658                &completed,
659            ));
660        }
661        // The receipt authorizes these bytes, not this path. Re-read them
662        // here rather than trusting the read that planned the run: a file
663        // edited since then is the user's, and a removal would be a silent
664        // loss.
665        match std::fs::read(destination) {
666            Ok(held) if &Sha256::of(&held) == vouched => {}
667            _ => {
668                kept.push(destination.clone());
669                continue;
670            }
671        }
672        match std::fs::remove_file(destination) {
673            Ok(()) => crate::transaction::sync_parent(destination)?,
674            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
675            Err(source) => {
676                return Err(stopped(Failure::Error(AppError::Io(source)), &completed));
677            }
678        }
679        lines.push(format!("swept: {destination}"));
680        completed.push(destination.clone());
681        reached(&mut passed, interrupt)?;
682    }
683
684    let mut directories: BTreeSet<Utf8PathBuf> = BTreeSet::new();
685    for (destination, _) in removals {
686        let mut parent = destination.parent();
687        while let Some(directory) = parent {
688            if !layout.roots.iter().any(|root| directory.starts_with(root))
689                && !directory.starts_with(&layout.state_root)
690                && !directory.starts_with(&layout.legacy_shared)
691            {
692                break;
693            }
694            directories.insert(directory.to_owned());
695            parent = directory.parent();
696        }
697    }
698    let mut stop = layout.roots.clone();
699    stop.push(layout.state_root.clone());
700    prune_empty(&directories, &stop, lines);
701
702    // A removal the byte check skipped leaves a file behind, so the record
703    // keeps vouching for it. Dropping it would make the leftover
704    // unrecorded, and the doctor could no longer name what is there.
705    let mut receipt = receipt.clone();
706    for destination in &kept {
707        if let Some((_, digest)) = removals.iter().find(|(path, _)| path == destination) {
708            receipt.written.insert(destination.clone(), digest.clone());
709        }
710        lines.push(format!("kept (changed since the record): {destination}"));
711    }
712    let empty = receipt.written.is_empty();
713    let receipt_bytes = receipt.to_json().into_bytes();
714
715    // The receipt is last. Until it lands, the previous one still describes
716    // the home, and what this run wrote is reported rather than vouched for.
717    if empty {
718        match std::fs::remove_file(&layout.receipt) {
719            Ok(()) => crate::transaction::sync_parent(&layout.receipt)?,
720            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
721            Err(source) => {
722                return Err(Failure::Error(AppError::Receipt(format!(
723                    "{}: {source}",
724                    layout.receipt
725                ))));
726            }
727        }
728    } else {
729        replace_one(layout, &layout.receipt, &receipt_bytes).map_err(|failure| match failure {
730            Failure::Error(cause) => Failure::Error(AppError::Receipt(format!(
731                "{}: {cause}; the files above are written and the previous receipt still stands, so run this again",
732                layout.receipt
733            ))),
734            abandoned @ Failure::Abandoned => abandoned,
735        })?;
736    }
737    reached(&mut passed, interrupt)?;
738    Ok(())
739}
740
741/// The refusal a run that stopped partway carries.
742fn stopped(failure: Failure, completed: &[Utf8PathBuf]) -> Failure {
743    let Failure::Error(cause) = failure else {
744        return failure;
745    };
746    let done: Vec<String> = completed.iter().map(ToString::to_string).collect();
747    let finished = if done.is_empty() {
748        "no destination was written".to_string()
749    } else {
750        format!("these destinations are written: {}", done.join(", "))
751    };
752    Failure::Error(AppError::Refused(format!(
753        "skill install stopped: {cause}; {finished}, the previous receipt still stands, and running this again finishes the rest"
754    )))
755}
756
757/// Stage one destination and rename it into place.
758///
759/// The chain is inspected again immediately before the rename, so a link
760/// substituted between the plan and the write is refused rather than
761/// followed.
762fn replace_one(layout: &Layout, destination: &Utf8Path, bytes: &[u8]) -> Result<(), Failure> {
763    let refuse = || {
764        owning_root(layout, destination)
765            .and_then(|root| blocked_by(root, destination))
766            .map(|one| Failure::Error(refuse_blocked(std::slice::from_ref(&one))))
767    };
768    // Before the scratch file is created, so nothing is written through a
769    // component that already refuses.
770    if let Some(refusal) = refuse() {
771        return Err(refusal);
772    }
773    let scratch = Stage::write(destination, bytes)?;
774    // And again before the rename, so a component swapped in between is
775    // refused rather than followed.
776    if let Some(refusal) = refuse() {
777        Stage::discard(&scratch);
778        return Err(refusal);
779    }
780    Stage::replace(&scratch, destination)?;
781    Ok(())
782}
783
784#[cfg(test)]
785mod tests {
786    #![allow(
787        clippy::unwrap_used,
788        reason = "a test panics as its failure signal, not as control flow"
789    )]
790
791    use std::collections::BTreeMap;
792
793    use super::*;
794
795    fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
796        Utf8PathBuf::from(dir.path().to_str().unwrap())
797    }
798
799    /// A digest of every file a run is answerable for, under one scratch
800    /// home.
801    ///
802    /// The lock and the holder note are the run's own workings rather than
803    /// destinations, so a comparison that counted them would report a run as
804    /// having changed the home when it changed only its own scaffolding.
805    fn tree(dir: &tempfile::TempDir) -> BTreeMap<String, Sha256> {
806        walkdir::WalkDir::new(dir.path())
807            .into_iter()
808            .filter_map(Result::ok)
809            .filter(|entry| entry.file_type().is_file())
810            .filter_map(|entry| {
811                let path = entry.path().to_str()?.to_string();
812                let bytes = std::fs::read(entry.path()).ok()?;
813                Some((path, Sha256::of(&bytes)))
814            })
815            .filter(|(path, _)| {
816                !path.contains("/backups/")
817                    && !["lock", "holder"].iter().any(|suffix| {
818                        std::path::Path::new(path)
819                            .extension()
820                            .is_some_and(|found| found == *suffix)
821                    })
822            })
823            .collect()
824    }
825
826    /// The layout a home directory implies, selecting both agent roots.
827    fn home(dir: &tempfile::TempDir) -> Layout {
828        let home = root(dir);
829        let state = home.join(crate::domain::paths::STATE_ROOT);
830        Layout {
831            roots: vec![home.join(AGENTS_ROOT), home.join(CLAUDE_ROOT)],
832            receipt: state.join(crate::domain::paths::SKILL_RECEIPT_FILE),
833            legacy_receipt: home.join(crate::domain::paths::LEGACY_SKILL_RECEIPT_PATH),
834            legacy_shared: home.join(LEGACY_SHARED_ROOT),
835            state_root: state,
836        }
837    }
838
839    /// The same layout narrowed to one selected root.
840    fn select(layout: &Layout, index: usize) -> Layout {
841        Layout {
842            roots: vec![layout.roots[index].clone()],
843            ..layout.clone()
844        }
845    }
846
847    fn package_files(root: &Utf8Path, name: &str) -> Vec<Utf8PathBuf> {
848        crate::embedded::skill_package(name)
849            .unwrap()
850            .into_iter()
851            .map(|(relative, _)| root.join(name).join(relative))
852            .collect()
853    }
854
855    #[test]
856    fn a_package_is_skill_md_plus_every_shared_artifact_under_references() {
857        let package = crate::embedded::skill_package("sdd-setup").unwrap();
858        let names: Vec<&str> = package.iter().map(|(path, _)| path.as_str()).collect();
859        assert!(names.contains(&"SKILL.md"));
860        assert!(names.contains(&"references/plan-gate.md"));
861        assert!(names.contains(&"references/pre-flight-gate.md"));
862        assert_eq!(package.len(), 1 + crate::embedded::shared_artifacts().len());
863        assert!(crate::embedded::skill_package("no-such-skill").is_none());
864    }
865
866    #[test]
867    fn an_install_lands_every_package_file_and_records_each_digest() {
868        let dir = tempfile::tempdir().unwrap();
869        let layout = home(&dir);
870        install(&layout, true, false).unwrap();
871        let record = SkillRecord::load(&layout.receipt);
872        assert_eq!(record.schema_version, 2);
873        assert_eq!(record.engine_version, env!("CARGO_PKG_VERSION"));
874        for root in &layout.roots {
875            for name in crate::embedded::skill_names() {
876                for path in package_files(root, name) {
877                    assert!(path.is_file(), "{path} did not land");
878                    let digest = Sha256::of(&std::fs::read(&path).unwrap());
879                    assert!(record.wrote(&path, &digest), "{path} was not recorded");
880                }
881            }
882        }
883    }
884
885    #[test]
886    fn a_preview_lists_every_destination_and_writes_nothing() {
887        let dir = tempfile::tempdir().unwrap();
888        let layout = home(&dir);
889        let lines = install(&layout, false, false).unwrap();
890        assert_eq!(lines.last().unwrap(), "DRY RUN: no files written");
891        let package = crate::embedded::skill_package("sdd-setup").unwrap().len();
892        assert_eq!(
893            lines.len(),
894            crate::embedded::skill_names().len() * package * 2 + 1
895        );
896        assert!(!layout.roots[0].exists());
897        assert!(!layout.receipt.exists());
898    }
899
900    #[test]
901    fn a_second_install_is_idempotent_and_writes_nothing() {
902        let dir = tempfile::tempdir().unwrap();
903        let layout = home(&dir);
904        install(&layout, true, false).unwrap();
905        let before = std::fs::metadata(layout.roots[0].join("sdd-setup/SKILL.md"))
906            .unwrap()
907            .modified()
908            .unwrap();
909        let receipt_before = std::fs::read(&layout.receipt).unwrap();
910        install(&layout, true, false).unwrap();
911        assert_eq!(
912            std::fs::metadata(layout.roots[0].join("sdd-setup/SKILL.md"))
913                .unwrap()
914                .modified()
915                .unwrap(),
916            before
917        );
918        assert_eq!(std::fs::read(&layout.receipt).unwrap(), receipt_before);
919    }
920
921    #[test]
922    fn the_retired_root_is_a_root_the_component_check_knows() {
923        let dir = tempfile::tempdir().unwrap();
924        let mut layout = home(&dir);
925        // The retired root is home-relative and the state root follows
926        // `XDG_STATE_HOME`, so a moved state root leaves the retired root
927        // outside it. A sweep there would otherwise unlink through
928        // components nothing inspected.
929        layout.state_root = root(&dir).join("moved/state");
930        let leftover = layout.legacy_shared.join("plan-gate.md");
931        assert_eq!(
932            owning_root(&layout, &leftover),
933            Some(layout.legacy_shared.as_path())
934        );
935    }
936
937    #[test]
938    fn a_stale_package_file_the_receipt_vouches_for_is_replaced_without_force() {
939        let dir = tempfile::tempdir().unwrap();
940        let layout = home(&dir);
941        install(&layout, true, false).unwrap();
942
943        let mut stale = SkillRecord::load(&layout.receipt);
944        let destination = layout.roots[0].join("sdd-setup/references/plan-gate.md");
945        std::fs::write(&destination, "older canon bytes\n").unwrap();
946        stale
947            .written
948            .insert(destination.clone(), Sha256::of(b"older canon bytes\n"));
949        crate::adapters::fs::write_file(&layout.receipt, stale.to_json().as_bytes()).unwrap();
950
951        install(&layout, true, false).unwrap();
952        assert!(
953            std::fs::read_to_string(&destination)
954                .unwrap()
955                .contains("# The plan gate")
956        );
957    }
958
959    #[test]
960    fn an_edited_reference_refuses_the_install_naming_every_conflict() {
961        let dir = tempfile::tempdir().unwrap();
962        let layout = home(&dir);
963        install(&layout, true, false).unwrap();
964        let edited = layout.roots[1].join("sdd-setup/references/plan-gate.md");
965        std::fs::write(&edited, "mine\n").unwrap();
966        let message = install(&layout, true, false).unwrap_err().to_string();
967        assert!(message.contains(edited.as_str()), "{message}");
968        assert_eq!(std::fs::read_to_string(&edited).unwrap(), "mine\n");
969    }
970
971    #[test]
972    fn force_replaces_an_edited_file_and_records_the_new_digest() {
973        let dir = tempfile::tempdir().unwrap();
974        let layout = home(&dir);
975        install(&layout, true, false).unwrap();
976        let edited = layout.roots[1].join("sdd-setup/SKILL.md");
977        std::fs::write(&edited, "mine\n").unwrap();
978        install(&layout, true, true).unwrap();
979        let held = std::fs::read(&edited).unwrap();
980        assert!(String::from_utf8_lossy(&held).contains("name: sdd-setup"));
981        assert!(SkillRecord::load(&layout.receipt).wrote(&edited, &Sha256::of(&held)));
982    }
983
984    /// The defect this phase closes: an uninstall that consulted the payload
985    /// and not the receipt deleted a skill the operator had edited.
986    #[test]
987    fn an_edited_skill_md_survives_uninstall_and_is_named_as_kept() {
988        let dir = tempfile::tempdir().unwrap();
989        let layout = home(&dir);
990        install(&layout, true, false).unwrap();
991        let edited = layout.roots[0].join("sdd-setup/SKILL.md");
992        std::fs::write(&edited, "mine\n").unwrap();
993
994        let lines = uninstall(&layout, true).unwrap();
995        assert_eq!(std::fs::read_to_string(&edited).unwrap(), "mine\n");
996        assert!(
997            lines
998                .iter()
999                .any(|line| line == &format!("kept (edited): {edited}")),
1000            "{lines:?}"
1001        );
1002    }
1003
1004    #[test]
1005    fn an_edited_reference_survives_uninstall_and_its_directory_stays() {
1006        let dir = tempfile::tempdir().unwrap();
1007        let layout = home(&dir);
1008        install(&layout, true, false).unwrap();
1009        let edited = layout.roots[0].join("sdd-setup/references/plan-gate.md");
1010        std::fs::write(&edited, "mine\n").unwrap();
1011        uninstall(&layout, true).unwrap();
1012        assert_eq!(std::fs::read_to_string(&edited).unwrap(), "mine\n");
1013        assert!(layout.roots[0].join("sdd-setup/references").is_dir());
1014    }
1015
1016    #[test]
1017    fn an_uninstall_removes_a_directory_only_when_nothing_recorded_or_foreign_remains() {
1018        let dir = tempfile::tempdir().unwrap();
1019        let layout = home(&dir);
1020        install(&layout, true, false).unwrap();
1021        let mine = layout.roots[1].join("sdd-setup/notes.md");
1022        std::fs::write(&mine, "mine").unwrap();
1023
1024        let lines = uninstall(&layout, true).unwrap();
1025        assert!(!layout.roots[1].join("sdd-write-docs").exists());
1026        assert!(!layout.roots[1].join("sdd-setup/SKILL.md").exists());
1027        assert!(!layout.roots[1].join("sdd-setup/references").exists());
1028        assert_eq!(std::fs::read_to_string(&mine).unwrap(), "mine");
1029        assert!(
1030            lines
1031                .iter()
1032                .any(|line| line.starts_with("kept (not empty):"))
1033        );
1034        // A re-run on the emptied roots is a no-op, not an error.
1035        uninstall(&layout, true).unwrap();
1036    }
1037
1038    #[test]
1039    fn the_retired_shared_root_is_swept_when_the_receipt_vouches_for_it() {
1040        let dir = tempfile::tempdir().unwrap();
1041        let layout = home(&dir);
1042        // Stand in for a 0.8.0 home: two gate files under the retired root,
1043        // each vouched for by the receipt that release wrote.
1044        let mut older = SkillRecord::new();
1045        for (name, bytes) in crate::embedded::shared_artifacts() {
1046            let destination = layout.legacy_shared.join(&name);
1047            crate::adapters::fs::write_file(&destination, bytes).unwrap();
1048            older.written.insert(destination, Sha256::of(bytes));
1049        }
1050        crate::adapters::fs::write_file(&layout.receipt, older.to_json().as_bytes()).unwrap();
1051
1052        install(&layout, true, false).unwrap();
1053        assert!(
1054            !layout.legacy_shared.exists(),
1055            "the retired shared root survived"
1056        );
1057        assert!(
1058            layout.roots[0]
1059                .join("sdd-setup/references/plan-gate.md")
1060                .is_file()
1061        );
1062    }
1063
1064    #[test]
1065    fn an_unrecorded_file_at_the_retired_shared_root_is_kept() {
1066        let dir = tempfile::tempdir().unwrap();
1067        let layout = home(&dir);
1068        let leftover = layout.legacy_shared.join("plan-gate.md");
1069        crate::adapters::fs::write_file(&leftover, b"mine\n").unwrap();
1070        install(&layout, true, false).unwrap();
1071        assert_eq!(std::fs::read(&leftover).unwrap(), b"mine\n");
1072    }
1073
1074    #[test]
1075    fn a_symlinked_package_directory_skill_md_reference_or_parent_is_a_typed_conflict() {
1076        for linked in ["sdd-setup", "sdd-setup/SKILL.md", "sdd-setup/references"] {
1077            let dir = tempfile::tempdir().unwrap();
1078            let layout = home(&dir);
1079            let elsewhere = root(&dir).join("elsewhere");
1080            std::fs::create_dir_all(&elsewhere).unwrap();
1081            let target = layout.roots[0].join(linked);
1082            std::fs::create_dir_all(target.parent().unwrap()).unwrap();
1083            std::os::unix::fs::symlink(&elsewhere, target.as_std_path()).unwrap();
1084
1085            for force in [false, true] {
1086                let message = install(&layout, true, force).unwrap_err().to_string();
1087                assert!(
1088                    message.contains("symlink"),
1089                    "{linked} with force {force}: {message}"
1090                );
1091                assert!(message.contains(target.as_str()), "{message}");
1092            }
1093            assert!(!elsewhere.join("SKILL.md").exists());
1094            assert!(target.is_symlink(), "the link itself was replaced");
1095        }
1096    }
1097
1098    #[test]
1099    fn a_symlink_whose_target_matches_the_recorded_digest_is_still_a_conflict() {
1100        let dir = tempfile::tempdir().unwrap();
1101        let layout = home(&dir);
1102        install(&layout, true, false).unwrap();
1103        let destination = layout.roots[0].join("sdd-setup/SKILL.md");
1104        let elsewhere = root(&dir).join("copy.md");
1105        std::fs::copy(&destination, &elsewhere).unwrap();
1106        std::fs::remove_file(&destination).unwrap();
1107        std::os::unix::fs::symlink(&elsewhere, destination.as_std_path()).unwrap();
1108
1109        let message = install(&layout, true, true).unwrap_err().to_string();
1110        assert!(message.contains("symlink"), "{message}");
1111        let message = uninstall(&layout, true).unwrap_err().to_string();
1112        assert!(message.contains("symlink"), "{message}");
1113        assert!(destination.is_symlink());
1114    }
1115
1116    #[test]
1117    fn two_roots_resolving_to_one_path_are_written_once() {
1118        let dir = tempfile::tempdir().unwrap();
1119        let mut layout = home(&dir);
1120        layout.roots = vec![layout.roots[0].clone()];
1121        let lines = install(&layout, false, false).unwrap();
1122        let landed = lines
1123            .iter()
1124            .filter(|line| line.ends_with("sdd-setup/SKILL.md"))
1125            .count();
1126        assert_eq!(landed, 1);
1127    }
1128
1129    #[test]
1130    fn a_second_installer_refuses_while_the_lock_is_held_naming_the_holder() {
1131        let dir = tempfile::tempdir().unwrap();
1132        let layout = home(&dir);
1133        // The home holds packages first, so the uninstall below has work to
1134        // do and reaches the lock rather than returning early.
1135        install(&layout, true, false).unwrap();
1136        age(&layout);
1137        let _held = Lock::exclusive(&layout.lock_path(), "skill install").unwrap();
1138        let error = install(&layout, true, false).unwrap_err();
1139        assert_eq!(error.kind(), "Busy");
1140        assert_eq!(error.exit_code(), 73);
1141        assert!(error.to_string().contains("skill install"));
1142        let error = uninstall(&layout, true).unwrap_err();
1143        assert_eq!(error.kind(), "Busy");
1144    }
1145
1146    /// A run the process did not finish is put back before the next one
1147    /// writes anything, at every boundary of the persistence order.
1148    /// Stand in for a home an older release left: every package file holds
1149    /// its bytes, and the receipt vouches for each, so the next install has
1150    /// a replacement at every boundary.
1151    fn age(layout: &Layout) {
1152        let mut older = SkillRecord::new();
1153        for root in &layout.roots {
1154            for name in crate::embedded::skill_names() {
1155                for path in package_files(root, name) {
1156                    crate::adapters::fs::write_file(&path, b"older\n").unwrap();
1157                    older.written.insert(path, Sha256::of(b"older\n"));
1158                }
1159            }
1160        }
1161        crate::adapters::fs::write_file(&layout.receipt, older.to_json().as_bytes()).unwrap();
1162    }
1163
1164    /// A run the process did not finish leaves whole files, the previous
1165    /// receipt, and a home the next run finishes, at every boundary of the
1166    /// persistence order.
1167    #[test]
1168    fn an_interrupted_install_leaves_whole_files_and_a_rerun_finishes() {
1169        let mut boundaries = 0usize;
1170        for after in 1..200 {
1171            let dir = tempfile::tempdir().unwrap();
1172            // One root, because the matrix walks every boundary and the
1173            // second root doubles the walk without adding a kind of
1174            // boundary to it.
1175            let layout = select(&home(&dir), 0);
1176            age(&layout);
1177            let aged = std::fs::read(&layout.receipt).unwrap();
1178
1179            let outcome = install_with(&layout, true, false, Interrupt { after: Some(after) });
1180            let Err(Failure::Abandoned) = outcome else {
1181                // The run finished before this boundary, so every earlier
1182                // boundary has already been walked.
1183                break;
1184            };
1185            boundaries = after;
1186
1187            // Every file the interrupted run left is whole: it holds either
1188            // the bytes it had before or the bytes the payload carries, and
1189            // never a fragment of the replacement.
1190            for name in crate::embedded::skill_names() {
1191                for path in package_files(&layout.roots[0], name) {
1192                    let held = std::fs::read(&path).unwrap();
1193                    assert!(
1194                        held == b"older\n" || !held.is_empty(),
1195                        "after {after}: {path} is not a whole file"
1196                    );
1197                }
1198            }
1199            // Until the run reaches its last write, the receipt the previous
1200            // run left still describes the home.
1201            let receipt = std::fs::read(&layout.receipt).unwrap();
1202            assert!(
1203                receipt == aged || !SkillRecord::load(&layout.receipt).written.is_empty(),
1204                "after {after}: the receipt is neither the old one nor a new one"
1205            );
1206
1207            // Every few boundaries, take the home all the way, so the matrix
1208            // proves an interrupted run leaves one an install still lands
1209            // into rather than only one that reads back whole.
1210            if after % 3 == 0 {
1211                install(&layout, true, false).unwrap();
1212                let record = SkillRecord::load(&layout.receipt);
1213                for path in package_files(&layout.roots[0], "sdd-setup") {
1214                    let digest = Sha256::of(&std::fs::read(&path).unwrap());
1215                    assert!(record.wrote(&path, &digest), "after {after}: {path}");
1216                }
1217            }
1218        }
1219        assert!(boundaries > 5, "only {boundaries} boundaries were walked");
1220    }
1221
1222    #[test]
1223    fn a_second_interruption_still_leaves_a_home_a_rerun_finishes() {
1224        let dir = tempfile::tempdir().unwrap();
1225        let layout = home(&dir);
1226        age(&layout);
1227        for after in [3, 5] {
1228            let Err(Failure::Abandoned) =
1229                install_with(&layout, true, false, Interrupt { after: Some(after) })
1230            else {
1231                panic!("the run was not interrupted after {after}");
1232            };
1233        }
1234        install(&layout, true, false).unwrap();
1235        let record = SkillRecord::load(&layout.receipt);
1236        for path in package_files(&layout.roots[0], "sdd-setup") {
1237            let digest = Sha256::of(&std::fs::read(&path).unwrap());
1238            assert!(record.wrote(&path, &digest), "{path}");
1239        }
1240    }
1241
1242    #[test]
1243    fn a_receipt_write_failure_reports_what_the_run_wrote() {
1244        let dir = tempfile::tempdir().unwrap();
1245        // The receipt sits in its own directory, so denying writes there
1246        // stops the receipt alone and leaves the lock beside it reachable.
1247        let mut layout = home(&dir);
1248        let vault = layout.state_root.join("receipt");
1249        layout.receipt = vault.join("skills.json");
1250        age(&layout);
1251        let aged = std::fs::read(&layout.receipt).unwrap();
1252
1253        let mut permissions = std::fs::metadata(&vault).unwrap().permissions();
1254        std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o500);
1255        std::fs::set_permissions(&vault, permissions.clone()).unwrap();
1256
1257        let error = install(&layout, true, false).unwrap_err();
1258
1259        std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o700);
1260        std::fs::set_permissions(&vault, permissions).unwrap();
1261
1262        let message = error.to_string();
1263        assert!(message.contains(layout.receipt.as_str()), "{message}");
1264        assert!(message.contains("run this again"), "{message}");
1265
1266        // The package files are written and the previous receipt still
1267        // stands, which is what the next run reads.
1268        assert_eq!(std::fs::read(&layout.receipt).unwrap(), aged);
1269        assert_ne!(
1270            std::fs::read(layout.roots[0].join("sdd-setup/SKILL.md")).unwrap(),
1271            b"older\n"
1272        );
1273
1274        // Running it again finishes the work the failure left.
1275        install(&layout, true, false).unwrap();
1276        let record = SkillRecord::load(&layout.receipt);
1277        for path in package_files(&layout.roots[0], "sdd-setup") {
1278            let digest = Sha256::of(&std::fs::read(&path).unwrap());
1279            assert!(record.wrote(&path, &digest), "{path}");
1280        }
1281    }
1282
1283    #[test]
1284    fn a_schema_one_receipt_reads_through_the_adapter() {
1285        let dir = tempfile::tempdir().unwrap();
1286        let layout = home(&dir);
1287        let destination = layout.roots[0].join("sdd-setup/SKILL.md");
1288        crate::adapters::fs::write_file(&destination, b"older\n").unwrap();
1289        crate::adapters::fs::write_file(
1290            &layout.receipt,
1291            format!(
1292                "{{\"schema_version\":1,\"written\":{{\"{destination}\":\"{}\"}}}}",
1293                Sha256::of(b"older\n")
1294            )
1295            .as_bytes(),
1296        )
1297        .unwrap();
1298        // The stale copy is the tool's, so the install replaces it without
1299        // asking, which only happens if the adapter read the old shape.
1300        install(&layout, true, false).unwrap();
1301        assert!(
1302            String::from_utf8_lossy(&std::fs::read(&destination).unwrap())
1303                .contains("name: sdd-setup")
1304        );
1305        assert_eq!(SkillRecord::load(&layout.receipt).schema_version, 2);
1306    }
1307
1308    #[test]
1309    fn a_receipt_at_the_legacy_path_is_read_once_and_rewritten_at_the_resolved_one() {
1310        let dir = tempfile::tempdir().unwrap();
1311        let mut layout = home(&dir);
1312        layout.state_root = root(&dir).join("xdg/spec-driven-docs");
1313        layout.receipt = layout
1314            .state_root
1315            .join(crate::domain::paths::SKILL_RECEIPT_FILE);
1316
1317        let destination = layout.roots[0].join("sdd-setup/SKILL.md");
1318        crate::adapters::fs::write_file(&destination, b"older\n").unwrap();
1319        let mut legacy = SkillRecord::new();
1320        legacy
1321            .written
1322            .insert(destination.clone(), Sha256::of(b"older\n"));
1323        crate::adapters::fs::write_file(&layout.legacy_receipt, legacy.to_json().as_bytes())
1324            .unwrap();
1325
1326        install(&layout, true, false).unwrap();
1327        assert!(layout.receipt.is_file());
1328        assert!(
1329            String::from_utf8_lossy(&std::fs::read(&destination).unwrap())
1330                .contains("name: sdd-setup")
1331        );
1332    }
1333
1334    #[test]
1335    fn a_write_that_fails_partway_reports_what_it_finished() {
1336        let dir = tempfile::tempdir().unwrap();
1337        let layout = home(&dir);
1338        install(&layout, true, false).unwrap();
1339
1340        // Every destination carries recognisable bytes, then one package
1341        // directory in the second root denies writes, so the failure lands
1342        // after the first root is already rewritten.
1343        for root in &layout.roots {
1344            for name in crate::embedded::skill_names() {
1345                for path in package_files(root, name) {
1346                    std::fs::write(&path, format!("previous {name}\n")).unwrap();
1347                }
1348            }
1349        }
1350        let blocked = layout.roots[1].join("sdd-write-docs");
1351        let mut permissions = std::fs::metadata(&blocked).unwrap().permissions();
1352        std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o500);
1353        std::fs::set_permissions(&blocked, permissions.clone()).unwrap();
1354
1355        let message = install(&layout, true, true).unwrap_err().to_string();
1356
1357        std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o700);
1358        std::fs::set_permissions(&blocked, permissions).unwrap();
1359
1360        assert!(message.contains("skill install stopped"), "{message}");
1361        assert!(
1362            message.contains("these destinations are written"),
1363            "{message}"
1364        );
1365        assert!(message.contains("running this again"), "{message}");
1366        // The first root is written and the second is not, which is what the
1367        // report says and what the rerun finishes.
1368        assert_ne!(
1369            std::fs::read(layout.roots[0].join("sdd-setup/SKILL.md")).unwrap(),
1370            b"previous sdd-setup\n"
1371        );
1372        assert_eq!(
1373            std::fs::read(layout.roots[1].join("sdd-write-docs/SKILL.md")).unwrap(),
1374            b"previous sdd-write-docs\n"
1375        );
1376
1377        install(&layout, true, true).unwrap();
1378        let record = SkillRecord::load(&layout.receipt);
1379        for root in &layout.roots {
1380            for path in package_files(root, "sdd-setup") {
1381                let digest = Sha256::of(&std::fs::read(&path).unwrap());
1382                assert!(record.wrote(&path, &digest), "{path}");
1383            }
1384        }
1385    }
1386
1387    #[test]
1388    fn an_uninstall_of_one_root_keeps_the_others_entries() {
1389        let dir = tempfile::tempdir().unwrap();
1390        let layout = home(&dir);
1391        install(&layout, true, false).unwrap();
1392        uninstall(&select(&layout, 1), true).unwrap();
1393        let kept = SkillRecord::load(&layout.receipt);
1394        assert!(
1395            kept.written
1396                .keys()
1397                .all(|path| path.starts_with(&layout.roots[0]))
1398        );
1399        assert!(!kept.written.is_empty());
1400        assert!(
1401            layout.roots[0]
1402                .join("sdd-setup/references/plan-gate.md")
1403                .is_file()
1404        );
1405    }
1406
1407    #[test]
1408    fn either_agent_alone_lands_a_whole_package() {
1409        for index in 0..2 {
1410            let dir = tempfile::tempdir().unwrap();
1411            let layout = home(&dir);
1412            let narrowed = select(&layout, index);
1413            install(&narrowed, true, false).unwrap();
1414            for path in package_files(&layout.roots[index], "sdd-setup") {
1415                assert!(path.is_file(), "{path} did not land");
1416            }
1417            assert!(!layout.roots[1 - index].exists());
1418        }
1419    }
1420
1421    #[test]
1422    fn the_last_uninstall_takes_the_receipt_with_it() {
1423        let dir = tempfile::tempdir().unwrap();
1424        let layout = home(&dir);
1425        install(&layout, true, false).unwrap();
1426        uninstall(&layout, true).unwrap();
1427        assert!(
1428            !layout.receipt.exists(),
1429            "the receipt outlived every file it vouched for"
1430        );
1431        for root in &layout.roots {
1432            assert!(!root.join("sdd-setup").exists());
1433        }
1434    }
1435
1436    #[test]
1437    fn an_uninstall_on_a_home_this_tool_never_wrote_into_touches_nothing() {
1438        let dir = tempfile::tempdir().unwrap();
1439        let layout = home(&dir);
1440        let before = tree(&dir);
1441        uninstall(&layout, true).unwrap();
1442        assert_eq!(tree(&dir), before);
1443        assert!(!layout.state_root.exists());
1444    }
1445}