Skip to main content

spec_driven_docs/services/
skill_installer.rs

1//! Install the embedded skills into agent skill directories.
2//!
3//! The destinations live outside any instance — under the invoking user's
4//! home — so nothing here touches an instance manifest. Two references
5//! decide what a destination holds: the embedded payload, and the record of
6//! what a previous apply wrote there. Bytes matching either are the tool's
7//! own and may be replaced; anything else is the user's and refuses.
8//!
9//! The preview-by-default, list-every-conflict, and restore-on-failure
10//! conventions mirror the instance installer, and for the same reason: a
11//! partial apply across two roots leaves an agent reading one version of a
12//! skill and another agent reading a different one.
13
14use std::collections::{BTreeMap, BTreeSet};
15
16use camino::{Utf8Path, Utf8PathBuf};
17
18use crate::domain::ownership::Sha256;
19use crate::domain::skill_record::SkillRecord;
20use crate::error::AppError;
21
22/// The root holding what the skills share, relative to the home directory.
23///
24/// Home-relative rather than `XDG_STATE_HOME`-relative for the reason the
25/// record states: the skills naming these artifacts live under
26/// `$HOME/.agents` and `$HOME/.claude`, which no XDG variable moves, and a
27/// shared file reachable under a different home than the skills reading it
28/// would be worse than no shared file at all.
29pub const SHARED_ROOT: &str = ".local/state/spec-driven-docs/skills/shared";
30
31/// The skill root Claude Code reads, relative to the home directory.
32pub const CLAUDE_ROOT: &str = ".claude/skills";
33
34/// The skill root Codex, Gemini CLI, and Copilot read, relative to the home
35/// directory.
36pub const AGENTS_ROOT: &str = ".agents/skills";
37
38/// The invoking user's home directory.
39///
40/// # Errors
41///
42/// [`AppError::Usage`] when `HOME` is unset or empty.
43pub fn home() -> Result<Utf8PathBuf, AppError> {
44    std::env::var("HOME")
45        .ok()
46        .filter(|home| !home.is_empty())
47        .map(Utf8PathBuf::from)
48        .ok_or_else(|| AppError::Usage("HOME is not set".to_string()))
49}
50
51/// One planned write: where, and which bytes.
52struct Planned {
53    destination: Utf8PathBuf,
54    bytes: &'static [u8],
55}
56
57/// Where one run writes: the agent roots, the shared root, and the record.
58///
59/// The shared root is not an agent root and no `--agent` selects it. Every
60/// skill names its shared artifacts by one absolute path, so one copy serves
61/// both agent families, and an install writes it whichever family it was
62/// asked for.
63#[derive(Debug, Clone)]
64pub struct Layout {
65    /// The agent skill roots this run was asked to touch.
66    pub roots: Vec<Utf8PathBuf>,
67    /// Every agent skill root, whichever this run selected.
68    pub every_root: Vec<Utf8PathBuf>,
69    /// The root holding what the skills share.
70    pub shared: Utf8PathBuf,
71    /// The user-scope digest record.
72    pub record: Utf8PathBuf,
73}
74
75/// Every skill destination under `roots`, root by root and skill by skill.
76fn plan_roots(roots: &[Utf8PathBuf]) -> Result<Vec<Planned>, AppError> {
77    let mut planned = Vec::new();
78    for root in roots {
79        for name in crate::embedded::skill_names() {
80            let text = crate::embedded::skill(name)
81                .ok_or_else(|| anyhow::anyhow!("payload skill missing: {name}"))?;
82            planned.push(Planned {
83                destination: root.join(name).join("SKILL.md"),
84                bytes: text.as_bytes(),
85            });
86        }
87    }
88    Ok(planned)
89}
90
91/// Every shared destination under `shared`.
92fn plan_shared(shared: &Utf8Path) -> Vec<Planned> {
93    crate::embedded::shared_artifacts()
94        .into_iter()
95        .map(|(path, bytes)| Planned {
96            destination: shared.join(path),
97            bytes,
98        })
99        .collect()
100}
101
102/// Refuse a shared root reached through a symlink this tool would follow.
103///
104/// The chain from the state directory down to the shared root is this
105/// tool's own — nothing here ever creates a symlink in it, so one found
106/// there redirects every shared write and removal somewhere else, and
107/// `check_destination` cannot see it: the final component it checks is not
108/// itself a link. The directories above the state directory are the user's
109/// layout and stay unjudged.
110fn check_shared_root(shared: &Utf8Path, record: &Utf8Path) -> Result<(), AppError> {
111    let Some(state_dir) = record.parent() else {
112        return Ok(());
113    };
114    let mut current = Some(shared);
115    while let Some(dir) = current {
116        if !dir.starts_with(state_dir) {
117            break;
118        }
119        if dir.is_symlink() {
120            return Err(AppError::Refused(format!(
121                "the shared root is reached through a symlink: {dir}"
122            )));
123        }
124        current = dir.parent();
125    }
126    Ok(())
127}
128
129fn check_destination(destination: &Utf8Path) -> Result<(), AppError> {
130    if destination.is_symlink() {
131        return Err(AppError::Refused(format!(
132            "destination is a symlink: {destination}"
133        )));
134    }
135    if destination.exists() && !destination.is_file() {
136        return Err(AppError::Refused(format!(
137            "destination exists and is not a regular file: {destination}"
138        )));
139    }
140    Ok(())
141}
142
143/// Destinations holding bytes neither the payload nor the record accounts for.
144///
145/// A destination the record vouches for carries a copy this tool wrote and a
146/// later release has since changed. That is an upgrade, not a conflict, and
147/// naming it one would make every skill-touching release refuse on files
148/// nobody edited.
149fn conflicts(planned: &[Planned], record: &SkillRecord) -> Result<Vec<String>, AppError> {
150    let mut conflicts = Vec::new();
151    for entry in planned {
152        if !entry.destination.is_file() {
153            continue;
154        }
155        // An unreadable destination raises instead of passing as clean: a
156        // comparison that cannot run must never license an overwrite.
157        let found = std::fs::read(&entry.destination)?;
158        if found == entry.bytes {
159            continue;
160        }
161        if record.wrote(&entry.destination, &Sha256::of(&found)) {
162            continue;
163        }
164        conflicts.push(entry.destination.to_string());
165    }
166    Ok(conflicts)
167}
168
169/// Recorded destinations under `roots` that this install no longer plans.
170///
171/// A skill the canon renamed, dropped, or moved to another root leaves its
172/// file behind otherwise, and a stale name is not inert: an agent's picker
173/// keys on the name, so the leftover shows up beside the skill that replaced
174/// it. Only bytes the record still vouches for are swept — a leftover the
175/// user has since edited is theirs, and a symlink is never followed.
176fn leftovers(
177    roots: &[Utf8PathBuf],
178    record: &SkillRecord,
179    keep: &[Utf8PathBuf],
180) -> Vec<Utf8PathBuf> {
181    let kept: BTreeSet<&Utf8Path> = keep.iter().map(Utf8PathBuf::as_path).collect();
182    record
183        .written
184        .iter()
185        .filter(|(destination, digest)| {
186            !kept.contains(destination.as_path())
187                && roots.iter().any(|root| destination.starts_with(root))
188                && !destination.is_symlink()
189                && destination.is_file()
190                && std::fs::read(destination).is_ok_and(|found| Sha256::of(&found) == **digest)
191        })
192        .map(|(destination, _)| destination.clone())
193        .collect()
194}
195
196/// Remove one installed destination, and its directory when nothing else is
197/// left there.
198fn remove_installed(destination: &Utf8Path, lines: &mut Vec<String>) -> Result<(), AppError> {
199    std::fs::remove_file(destination)?;
200    let directory = destination
201        .parent()
202        .ok_or_else(|| anyhow::anyhow!("destination has no parent: {destination}"))?;
203    if std::fs::read_dir(directory)?.next().is_none() {
204        std::fs::remove_dir(directory)?;
205    } else {
206        lines.push(format!("kept (not empty): {directory}"));
207    }
208    Ok(())
209}
210
211/// Restore every backed-up destination, returning those that would not go back.
212///
213/// A destination already holding what it held is restored, whatever a write
214/// to it would do. Nothing else distinguishes the two ways an apply reaches
215/// here — the write loop stopped before this destination, or a read-only
216/// root refused every write including this one — and reporting the second as
217/// unrestored sends the operator to verify files no write ever reached.
218fn rollback(backups: &BTreeMap<Utf8PathBuf, Option<Vec<u8>>>) -> Vec<Utf8PathBuf> {
219    let mut unrestored = Vec::new();
220    for (destination, previous) in backups {
221        let restored = previous.as_ref().map_or_else(
222            || !destination.exists() || std::fs::remove_file(destination).is_ok(),
223            |bytes| {
224                std::fs::read(destination).is_ok_and(|found| &found == bytes)
225                    || crate::adapters::fs::write_file(destination, bytes).is_ok()
226            },
227        );
228        if !restored {
229            unrestored.push(destination.clone());
230        }
231    }
232    unrestored
233}
234
235/// The refusal a failed apply carries, naming the cause and what it restored.
236fn abort(unrestored: &[Utf8PathBuf], cause: &str) -> AppError {
237    if unrestored.is_empty() {
238        AppError::Refused(format!(
239            "skill install aborted; the destinations were restored: {cause}"
240        ))
241    } else {
242        let paths: Vec<&str> = unrestored.iter().map(|p| p.as_str()).collect();
243        AppError::Refused(format!(
244            "skill install aborted and restoration is incomplete; verify by hand: {}: {cause}",
245            paths.join(" ")
246        ))
247    }
248}
249
250/// Install every embedded skill under each root, previewing by default.
251///
252/// `record` is the user-scope digest record: read to tell a stale copy this
253/// tool wrote from a file the user edited, and rewritten after a successful
254/// apply. A failure to write the record is not a failure of the install —
255/// the files landed — so it costs only the benefit of the doubt next time.
256///
257/// # Errors
258///
259/// [`AppError::Refused`] when a destination cannot be touched, when one
260/// holds bytes neither reference accounts for and `force` is not set, or
261/// when a write fails partway; the destinations are restored before that
262/// last one returns. I/O errors when a destination cannot be read.
263pub fn install(layout: &Layout, apply: bool, force: bool) -> Result<Vec<String>, AppError> {
264    let record_path = layout.record.as_path();
265    check_shared_root(&layout.shared, &layout.record)?;
266    let mut planned = plan_roots(&layout.roots)?;
267    planned.extend(plan_shared(&layout.shared));
268    let mut lines: Vec<String> = Vec::new();
269    for entry in &planned {
270        check_destination(&entry.destination)?;
271        lines.push(entry.destination.to_string());
272    }
273    let mut record = SkillRecord::load(record_path);
274    let kept: Vec<Utf8PathBuf> = planned
275        .iter()
276        .map(|entry| entry.destination.clone())
277        .collect();
278    let mut scanned = layout.roots.clone();
279    scanned.push(layout.shared.clone());
280    let stale = leftovers(&scanned, &record, &kept);
281    for destination in &stale {
282        lines.push(format!("sweep (no longer in the payload): {destination}"));
283    }
284    if !apply {
285        lines.push("DRY RUN: no files written".to_string());
286        return Ok(lines);
287    }
288
289    if !force {
290        let conflicts = conflicts(&planned, &record)?;
291        if !conflicts.is_empty() {
292            return Err(AppError::Refused(format!(
293                "destinations hold bytes this tool did not write: {}; re-run with --force to overwrite",
294                conflicts.join(", ")
295            )));
296        }
297    }
298
299    // Back up every destination before the first write, so a failure on the
300    // second root cannot leave the first one upgraded.
301    let mut backups: BTreeMap<Utf8PathBuf, Option<Vec<u8>>> = BTreeMap::new();
302    for entry in &planned {
303        let previous = if entry.destination.is_file() {
304            Some(std::fs::read(&entry.destination).map_err(|source| {
305                AppError::Refused(format!("cannot back up {}: {source}", entry.destination))
306            })?)
307        } else {
308            None
309        };
310        backups.insert(entry.destination.clone(), previous);
311    }
312
313    for entry in &planned {
314        if let Err(source) = crate::adapters::fs::write_file(&entry.destination, entry.bytes) {
315            return Err(abort(
316                &rollback(&backups),
317                &format!("writing {} failed: {source}", entry.destination),
318            ));
319        }
320    }
321
322    // Sweep after the writes, never before: a refusal must leave the home
323    // exactly as it found it, and a leftover is harmless until the install
324    // that supersedes it has actually landed.
325    for destination in &stale {
326        if let Err(source) = remove_installed(destination, &mut lines) {
327            lines.push(format!(
328                "could not remove {destination}; remove it by hand: {source}"
329            ));
330            continue;
331        }
332        record.written.remove(destination);
333    }
334
335    for entry in &planned {
336        record
337            .written
338            .insert(entry.destination.clone(), Sha256::of(entry.bytes));
339    }
340    if crate::adapters::fs::write_file(record_path, record.to_json().as_bytes()).is_err() {
341        lines.push(format!(
342            "note: could not record the installed digests at {record_path}; a later install may ask for --force"
343        ));
344    }
345    Ok(lines)
346}
347
348/// Remove every installed skill under each root, previewing by default.
349///
350/// Only the payload's own files go: each skill's `SKILL.md`, and its
351/// directory when nothing else lives there. An absent destination is a
352/// no-op, so a re-run succeeds. Removed destinations leave the record too;
353/// what is gone cannot be vouched for.
354///
355/// # Errors
356///
357/// [`AppError::Refused`] when a destination is a symlink or not a regular
358/// file, and I/O errors when a removal fails.
359pub fn uninstall(layout: &Layout, apply: bool) -> Result<Vec<String>, AppError> {
360    let record_path = layout.record.as_path();
361    let mut lines: Vec<String> = Vec::new();
362    let mut removable: Vec<Utf8PathBuf> = Vec::new();
363    for root in &layout.roots {
364        for name in crate::embedded::skill_names() {
365            let destination = root.join(name).join("SKILL.md");
366            check_destination(&destination)?;
367            if destination.is_file() {
368                lines.push(destination.to_string());
369                removable.push(destination);
370            }
371        }
372    }
373    // The shared artifacts serve every agent root, so they go only once no
374    // root still holds an installed skill that reads them. Taking them
375    // during a one-agent uninstall would leave the other family's skills
376    // naming a file that is no longer there.
377    let going: BTreeSet<&Utf8Path> = removable.iter().map(Utf8PathBuf::as_path).collect();
378    let retained = plan_roots(&layout.every_root)?
379        .iter()
380        .any(|entry| !going.contains(entry.destination.as_path()) && entry.destination.is_file());
381    let mut scanned = layout.roots.clone();
382    if !retained {
383        check_shared_root(&layout.shared, &layout.record)?;
384        for entry in plan_shared(&layout.shared) {
385            check_destination(&entry.destination)?;
386            if entry.destination.is_file() {
387                lines.push(entry.destination.to_string());
388                removable.push(entry.destination);
389            }
390        }
391        scanned.push(layout.shared.clone());
392    }
393    let mut record = SkillRecord::load(record_path);
394    // A skill the payload has since dropped is still ours to take back, and
395    // an uninstall that leaves it behind is the leftover an agent's picker
396    // keeps offering. The record is what names it; the payload cannot.
397    for destination in leftovers(&scanned, &record, &removable) {
398        lines.push(format!("sweep (no longer in the payload): {destination}"));
399        removable.push(destination);
400    }
401    if !apply {
402        lines.push("DRY RUN: no files removed".to_string());
403        return Ok(lines);
404    }
405    for destination in &removable {
406        remove_installed(destination, &mut lines)?;
407    }
408
409    for destination in &removable {
410        record.written.remove(destination);
411    }
412    let _ = if record.written.is_empty() {
413        std::fs::remove_file(record_path).map_err(|_| ())
414    } else {
415        crate::adapters::fs::write_file(record_path, record.to_json().as_bytes()).map_err(|_| ())
416    };
417    Ok(lines)
418}
419
420#[cfg(test)]
421mod tests {
422    #![allow(
423        clippy::unwrap_used,
424        reason = "a test panics as its failure signal, not as control flow"
425    )]
426
427    use super::*;
428
429    fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
430        Utf8PathBuf::from(dir.path().to_str().unwrap())
431    }
432
433    /// The layout a home directory implies, selecting both agent roots.
434    fn home(dir: &tempfile::TempDir) -> Layout {
435        let home = root(dir);
436        let roots = vec![home.join(".agents/skills"), home.join(".claude/skills")];
437        Layout {
438            roots: roots.clone(),
439            every_root: roots,
440            shared: home.join(".local/state/spec-driven-docs/skills/shared"),
441            record: home.join(crate::domain::skill_record::RECORD_PATH),
442        }
443    }
444
445    /// The same layout narrowed to one selected root.
446    fn select(layout: &Layout, index: usize) -> Layout {
447        Layout {
448            roots: vec![layout.roots[index].clone()],
449            ..layout.clone()
450        }
451    }
452
453    #[test]
454    fn a_preview_lists_every_destination_and_writes_nothing() {
455        let dir = tempfile::tempdir().unwrap();
456        let layout = home(&dir);
457        let (roots, record) = (layout.roots.clone(), layout.record.clone());
458        let lines = install(&layout, false, false).unwrap();
459        assert_eq!(lines.last().unwrap(), "DRY RUN: no files written");
460        assert_eq!(
461            lines.len(),
462            crate::embedded::skill_names().len() * 2
463                + crate::embedded::shared_artifacts().len()
464                + 1
465        );
466        assert!(!layout.shared.exists());
467        assert!(!roots[0].exists());
468        assert!(!record.exists());
469    }
470
471    #[test]
472    fn an_apply_is_idempotent_and_a_conflict_refuses_with_every_path() {
473        let dir = tempfile::tempdir().unwrap();
474        let layout = home(&dir);
475        let roots = layout.roots.clone();
476        install(&layout, true, false).unwrap();
477        install(&layout, true, false).unwrap();
478        for name in crate::embedded::skill_names() {
479            std::fs::write(roots[0].join(name).join("SKILL.md"), "edited").unwrap();
480        }
481        let error = install(&layout, true, false).unwrap_err();
482        let message = error.to_string();
483        for name in crate::embedded::skill_names() {
484            assert!(message.contains(name), "{message} misses {name}");
485        }
486        install(&layout, true, true).unwrap();
487        let text = std::fs::read_to_string(roots[0].join("sdd-setup/SKILL.md")).unwrap();
488        assert!(text.contains("name: sdd-setup"));
489    }
490
491    /// The defect this record exists for: bytes a previous release wrote are
492    /// not the user's, and refusing on them makes every skill-touching
493    /// release break the install recipe.
494    #[test]
495    fn a_copy_a_previous_release_wrote_is_replaced_without_force() {
496        let dir = tempfile::tempdir().unwrap();
497        let layout = home(&dir);
498        let (roots, record) = (layout.roots.clone(), layout.record.clone());
499        install(&layout, true, false).unwrap();
500
501        // Stand in for an older release: rewrite each destination and record
502        // the digest, exactly as that release's apply would have left it.
503        let mut stale = SkillRecord::load(&record);
504        for root in &roots {
505            for name in crate::embedded::skill_names() {
506                let destination = root.join(name).join("SKILL.md");
507                std::fs::write(&destination, "older canon bytes\n").unwrap();
508                stale
509                    .written
510                    .insert(destination, Sha256::of(b"older canon bytes\n"));
511            }
512        }
513        crate::adapters::fs::write_file(&record, stale.to_json().as_bytes()).unwrap();
514
515        install(&layout, true, false).unwrap();
516        let text = std::fs::read_to_string(roots[1].join("sdd-setup/SKILL.md")).unwrap();
517        assert!(text.contains("name: sdd-setup"));
518    }
519
520    /// A record vouching for one destination says nothing about the others.
521    #[test]
522    fn an_edit_still_refuses_when_a_sibling_is_merely_stale() {
523        let dir = tempfile::tempdir().unwrap();
524        let layout = home(&dir);
525        let (roots, record) = (layout.roots.clone(), layout.record.clone());
526        install(&layout, true, false).unwrap();
527
528        let stale_path = roots[0].join("sdd-setup/SKILL.md");
529        let edited_path = roots[1].join("sdd-setup/SKILL.md");
530        let mut stale = SkillRecord::load(&record);
531        std::fs::write(&stale_path, "older canon bytes\n").unwrap();
532        stale
533            .written
534            .insert(stale_path, Sha256::of(b"older canon bytes\n"));
535        crate::adapters::fs::write_file(&record, stale.to_json().as_bytes()).unwrap();
536        std::fs::write(&edited_path, "mine\n").unwrap();
537
538        let message = install(&layout, true, false).unwrap_err().to_string();
539        assert!(message.contains(edited_path.as_str()), "{message}");
540        assert!(!message.contains("older canon"), "{message}");
541        assert_eq!(std::fs::read_to_string(&edited_path).unwrap(), "mine\n");
542    }
543
544    /// Without a readable record every destination is the user's, which is
545    /// the conservative half of the old behaviour and must survive.
546    #[test]
547    fn a_missing_record_treats_unknown_bytes_as_the_users() {
548        let dir = tempfile::tempdir().unwrap();
549        let layout = home(&dir);
550        let (roots, record) = (layout.roots.clone(), layout.record.clone());
551        install(&layout, true, false).unwrap();
552        std::fs::remove_file(&record).unwrap();
553        std::fs::write(roots[0].join("sdd-setup/SKILL.md"), "older canon bytes\n").unwrap();
554        let error = install(&layout, true, false).unwrap_err();
555        assert!(error.to_string().contains("sdd-setup"));
556    }
557
558    /// A destination that cannot be written must not leave the roots split.
559    ///
560    /// This is the shape the defect took in the field: two roots, the first
561    /// written, the second refused, and no rollback — leaving one agent on
562    /// the new skill and the other on the old one.
563    #[test]
564    fn a_write_that_fails_partway_restores_every_destination() {
565        let dir = tempfile::tempdir().unwrap();
566        let layout = home(&dir);
567        let roots = layout.roots.clone();
568        install(&layout, true, false).unwrap();
569
570        // Every destination carries recognisable bytes, then the second
571        // root's first destination is made uncreatable: its file is gone and
572        // its directory denies writes, so the failure lands after the first
573        // root is already rewritten.
574        let mut before: Vec<(Utf8PathBuf, Vec<u8>)> = Vec::new();
575        for root in &roots {
576            for name in crate::embedded::skill_names() {
577                let path = root.join(name).join("SKILL.md");
578                std::fs::write(&path, format!("previous {name}\n")).unwrap();
579                before.push((path.clone(), std::fs::read(&path).unwrap()));
580            }
581        }
582        let blocked = roots[1].join("sdd-setup");
583        std::fs::remove_file(blocked.join("SKILL.md")).unwrap();
584        before.retain(|(path, _)| path.parent() != Some(blocked.as_path()));
585        let mut permissions = std::fs::metadata(&blocked).unwrap().permissions();
586        std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o500);
587        std::fs::set_permissions(&blocked, permissions.clone()).unwrap();
588
589        let message = install(&layout, true, true).unwrap_err().to_string();
590        assert!(message.contains("skill install aborted"), "{message}");
591        assert!(
592            message.contains("the destinations were restored"),
593            "{message}"
594        );
595        assert!(message.contains(blocked.as_str()), "{message}");
596
597        std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o700);
598        std::fs::set_permissions(&blocked, permissions).unwrap();
599        for (path, bytes) in &before {
600            assert_eq!(
601                &std::fs::read(path).unwrap(),
602                bytes,
603                "{path} was not restored"
604            );
605        }
606        assert!(
607            !blocked.join("SKILL.md").exists(),
608            "a destination that did not exist before was left behind"
609        );
610    }
611
612    /// A destination the record vouches for but the payload dropped is a
613    /// leftover, not a skill: an agent still offers it under the name the
614    /// canon renamed away from.
615    #[test]
616    fn a_recorded_destination_the_payload_dropped_is_swept_by_both_verbs() {
617        for sweep_with_uninstall in [false, true] {
618            let dir = tempfile::tempdir().unwrap();
619            let layout = home(&dir);
620            let (roots, record_path) = (layout.roots.clone(), layout.record.clone());
621            install(&layout, true, false).unwrap();
622
623            let dropped = roots[0].join("sdd-old-name/SKILL.md");
624            crate::adapters::fs::write_file(&dropped, b"older\n").unwrap();
625            let mut record = SkillRecord::load(&record_path);
626            record
627                .written
628                .insert(dropped.clone(), Sha256::of(b"older\n"));
629            crate::adapters::fs::write_file(&record_path, record.to_json().as_bytes()).unwrap();
630
631            if sweep_with_uninstall {
632                uninstall(&layout, true).unwrap();
633            } else {
634                install(&layout, true, false).unwrap();
635            }
636            assert!(!dropped.exists(), "the leftover file survived");
637            assert!(
638                !roots[0].join("sdd-old-name").exists(),
639                "the leftover directory survived"
640            );
641            assert!(
642                !SkillRecord::load(&record_path)
643                    .written
644                    .contains_key(&dropped)
645            );
646        }
647    }
648
649    /// The record vouches for bytes, so a leftover the user rewrote is
650    /// theirs and no sweep may take it.
651    #[test]
652    fn an_edited_leftover_is_left_where_it_is() {
653        let dir = tempfile::tempdir().unwrap();
654        let layout = home(&dir);
655        let (roots, record_path) = (layout.roots.clone(), layout.record.clone());
656        install(&layout, true, false).unwrap();
657
658        let dropped = roots[0].join("sdd-old-name/SKILL.md");
659        crate::adapters::fs::write_file(&dropped, b"older\n").unwrap();
660        let mut record = SkillRecord::load(&record_path);
661        record
662            .written
663            .insert(dropped.clone(), Sha256::of(b"older\n"));
664        crate::adapters::fs::write_file(&record_path, record.to_json().as_bytes()).unwrap();
665        crate::adapters::fs::write_file(&dropped, b"mine\n").unwrap();
666
667        install(&layout, true, false).unwrap();
668        assert_eq!(std::fs::read(&dropped).unwrap(), b"mine\n");
669        uninstall(&layout, true).unwrap();
670        assert_eq!(std::fs::read(&dropped).unwrap(), b"mine\n");
671    }
672
673    /// A root no write can reach leaves every destination as it was, so the
674    /// refusal must say the destinations were restored rather than sending
675    /// the operator to verify files nothing touched.
676    #[test]
677    fn a_root_that_refuses_every_write_reports_a_clean_restore() {
678        let dir = tempfile::tempdir().unwrap();
679        let layout = home(&dir);
680        let roots = layout.roots.clone();
681        install(&layout, true, false).unwrap();
682
683        // `.agents` is the first root, so its refusal lands before any
684        // destination has been rewritten.
685        let mut locked = Vec::new();
686        for name in crate::embedded::skill_names() {
687            let destination = roots[0].join(name).join("SKILL.md");
688            let mut permissions = std::fs::metadata(&destination).unwrap().permissions();
689            std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o444);
690            std::fs::set_permissions(&destination, permissions).unwrap();
691            locked.push(destination);
692        }
693
694        let message = install(&layout, true, true).unwrap_err().to_string();
695
696        for destination in &locked {
697            let mut permissions = std::fs::metadata(destination).unwrap().permissions();
698            std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o644);
699            std::fs::set_permissions(destination, permissions).unwrap();
700        }
701
702        assert!(
703            message.contains("the destinations were restored"),
704            "{message}"
705        );
706        assert!(!message.contains("restoration is incomplete"), "{message}");
707    }
708
709    #[test]
710    fn an_uninstall_removes_only_payload_files_and_keeps_foreign_ones() {
711        let dir = tempfile::tempdir().unwrap();
712        let layout = home(&dir);
713        let (roots, record) = (layout.roots.clone(), layout.record.clone());
714        install(&layout, true, false).unwrap();
715        std::fs::write(roots[1].join("sdd-setup/notes.md"), "mine").unwrap();
716
717        let preview = uninstall(&layout, false).unwrap();
718        assert_eq!(preview.last().unwrap(), "DRY RUN: no files removed");
719        assert!(roots[1].join("sdd-setup/SKILL.md").is_file());
720
721        let lines = uninstall(&layout, true).unwrap();
722        assert!(!roots[1].join("sdd-setup/SKILL.md").exists());
723        assert!(!roots[1].join("sdd-write-docs").exists());
724        assert_eq!(
725            std::fs::read_to_string(roots[1].join("sdd-setup/notes.md")).unwrap(),
726            "mine"
727        );
728        assert!(
729            lines
730                .iter()
731                .any(|line| line.starts_with("kept (not empty):"))
732        );
733        assert!(
734            !record.exists(),
735            "the record outlived every file it vouched for"
736        );
737
738        // A re-run on the emptied roots is a no-op, not an error.
739        uninstall(&layout, true).unwrap();
740    }
741
742    /// Uninstalling one root leaves the other's entries intact, so the next
743    /// install still recognises what it wrote there.
744    #[test]
745    fn an_uninstall_of_one_root_keeps_the_others_entries() {
746        let dir = tempfile::tempdir().unwrap();
747        let layout = home(&dir);
748        let (roots, record) = (layout.roots.clone(), layout.record.clone());
749        install(&layout, true, false).unwrap();
750        uninstall(&select(&layout, 1), true).unwrap();
751        let kept = SkillRecord::load(&record);
752        assert!(
753            kept.written
754                .keys()
755                .all(|path| path.starts_with(&roots[0]) || path.starts_with(&layout.shared))
756        );
757        assert!(kept.written.keys().any(|path| path.starts_with(&roots[0])));
758        assert!(
759            kept.written
760                .keys()
761                .any(|path| path.starts_with(&layout.shared))
762        );
763    }
764
765    /// The shared artifacts serve both agent families, so either family's
766    /// install lands them alone.
767    #[test]
768    fn either_agent_alone_still_lands_the_shared_artifacts() {
769        for index in 0..2 {
770            let dir = tempfile::tempdir().unwrap();
771            let layout = home(&dir);
772            install(&select(&layout, index), true, false).unwrap();
773            assert!(layout.shared.join("plan-gate.md").is_file());
774            assert!(layout.shared.join("pre-flight-gate.md").is_file());
775        }
776    }
777
778    /// A one-family uninstall keeps the shared artifacts while the other
779    /// family's skills still name them; the last one takes them along.
780    #[test]
781    fn the_shared_artifacts_stay_while_another_root_still_holds_skills() {
782        let dir = tempfile::tempdir().unwrap();
783        let layout = home(&dir);
784        install(&layout, true, false).unwrap();
785        uninstall(&select(&layout, 1), true).unwrap();
786        assert!(layout.shared.join("plan-gate.md").is_file());
787        assert!(layout.shared.join("pre-flight-gate.md").is_file());
788        uninstall(&select(&layout, 0), true).unwrap();
789        assert!(!layout.shared.exists());
790        assert!(!layout.record.exists());
791    }
792
793    /// The uninstall that takes the last skills previews the shared
794    /// artifacts with them, and removes nothing.
795    #[test]
796    fn the_last_uninstall_previews_the_shared_artifacts() {
797        let dir = tempfile::tempdir().unwrap();
798        let layout = home(&dir);
799        install(&layout, true, false).unwrap();
800        let lines = uninstall(&layout, false).unwrap();
801        for artifact in ["plan-gate.md", "pre-flight-gate.md"] {
802            let shared = layout.shared.join(artifact);
803            assert!(lines.iter().any(|line| line == shared.as_str()));
804            assert!(shared.is_file());
805        }
806    }
807
808    /// An edited shared artifact is a conflict like an edited skill: the
809    /// install refuses naming it, and `--force` is the override.
810    #[test]
811    fn an_edited_shared_artifact_refuses_an_install() {
812        let dir = tempfile::tempdir().unwrap();
813        let layout = home(&dir);
814        install(&layout, true, false).unwrap();
815        let shared = layout.shared.join("plan-gate.md");
816        std::fs::write(&shared, "mine\n").unwrap();
817        let message = install(&layout, true, false).unwrap_err().to_string();
818        assert!(message.contains(shared.as_str()), "{message}");
819        install(&layout, true, true).unwrap();
820        assert!(
821            std::fs::read_to_string(&shared)
822                .unwrap()
823                .contains("# The plan gate")
824        );
825    }
826
827    /// A shared root reached through a symlinked directory refuses both
828    /// verbs: the write would land, and the removal would delete, wherever
829    /// the link points.
830    #[test]
831    fn a_symlinked_shared_root_refuses_install_and_uninstall() {
832        for symlinked in ["skills", "skills/shared"] {
833            let dir = tempfile::tempdir().unwrap();
834            let layout = home(&dir);
835            let elsewhere = root(&dir).join("elsewhere");
836            std::fs::create_dir_all(&elsewhere).unwrap();
837            let state_dir = layout.record.parent().unwrap();
838            let linked = state_dir.join(symlinked);
839            std::fs::create_dir_all(linked.parent().unwrap()).unwrap();
840            std::os::unix::fs::symlink(&elsewhere, &linked).unwrap();
841
842            let message = install(&layout, true, false).unwrap_err().to_string();
843            assert!(message.contains("symlink"), "{message}");
844            assert!(!elsewhere.join("plan-gate.md").exists());
845            assert!(!layout.roots[0].exists());
846
847            std::fs::write(elsewhere.join("plan-gate.md"), "theirs\n").unwrap();
848            let message = uninstall(&layout, true).unwrap_err().to_string();
849            assert!(message.contains("symlink"), "{message}");
850            assert!(elsewhere.join("plan-gate.md").exists());
851        }
852    }
853
854    /// A symlinked shared destination refuses before anything is written.
855    #[test]
856    fn a_symlinked_shared_destination_refuses_before_writing() {
857        let dir = tempfile::tempdir().unwrap();
858        let layout = home(&dir);
859        std::fs::create_dir_all(&layout.shared).unwrap();
860        let target = root(&dir).join("elsewhere.md");
861        std::fs::write(&target, "x").unwrap();
862        std::os::unix::fs::symlink(&target, layout.shared.join("plan-gate.md")).unwrap();
863        let message = install(&layout, true, false).unwrap_err().to_string();
864        assert!(message.contains("symlink"), "{message}");
865        assert!(!layout.roots[0].exists());
866    }
867}