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