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; the embedded
5//! payload is the reference every destination is compared against. The
6//! preview-by-default and list-every-conflict conventions mirror the
7//! instance installer.
8
9use camino::{Utf8Path, Utf8PathBuf};
10
11use crate::error::AppError;
12
13/// One planned write: where, and which bytes.
14struct Planned {
15    destination: Utf8PathBuf,
16    bytes: &'static [u8],
17}
18
19fn plan(roots: &[Utf8PathBuf]) -> Result<Vec<Planned>, AppError> {
20    let mut planned = Vec::new();
21    for root in roots {
22        for name in crate::embedded::skill_names() {
23            let text = crate::embedded::skill(name)
24                .ok_or_else(|| anyhow::anyhow!("payload skill missing: {name}"))?;
25            planned.push(Planned {
26                destination: root.join(name).join("SKILL.md"),
27                bytes: text.as_bytes(),
28            });
29        }
30    }
31    Ok(planned)
32}
33
34fn check_destination(destination: &Utf8Path) -> Result<(), AppError> {
35    if destination.is_symlink() {
36        return Err(AppError::Refused(format!(
37            "destination is a symlink: {destination}"
38        )));
39    }
40    if destination.exists() && !destination.is_file() {
41        return Err(AppError::Refused(format!(
42            "destination exists and is not a regular file: {destination}"
43        )));
44    }
45    Ok(())
46}
47
48/// Install every embedded skill under each root, previewing by default.
49///
50/// # Errors
51///
52/// [`AppError::Refused`] when a destination cannot be touched or holds
53/// bytes that differ from the payload and `force` is not set, and I/O
54/// errors when a write fails.
55pub fn install(roots: &[Utf8PathBuf], apply: bool, force: bool) -> Result<Vec<String>, AppError> {
56    let planned = plan(roots)?;
57    let mut lines: Vec<String> = Vec::new();
58    for entry in &planned {
59        check_destination(&entry.destination)?;
60        lines.push(entry.destination.to_string());
61    }
62    if !apply {
63        lines.push("DRY RUN: no files written".to_string());
64        return Ok(lines);
65    }
66    if !force {
67        let mut conflicts: Vec<String> = Vec::new();
68        for entry in &planned {
69            // An unreadable destination raises instead of passing as clean:
70            // a comparison that cannot run must never license an overwrite.
71            if entry.destination.is_file() && std::fs::read(&entry.destination)? != entry.bytes {
72                conflicts.push(entry.destination.to_string());
73            }
74        }
75        if !conflicts.is_empty() {
76            return Err(AppError::Refused(format!(
77                "destinations hold locally changed bytes: {}; re-run with --force to overwrite",
78                conflicts.join(", ")
79            )));
80        }
81    }
82    for entry in &planned {
83        crate::adapters::fs::write_file(&entry.destination, entry.bytes)?;
84    }
85    Ok(lines)
86}
87
88/// Remove every installed skill under each root, previewing by default.
89///
90/// Only the payload's own files go: each skill's `SKILL.md`, and its
91/// directory when nothing else lives there. An absent destination is a
92/// no-op, so a re-run succeeds.
93///
94/// # Errors
95///
96/// [`AppError::Refused`] when a destination is a symlink or not a regular
97/// file, and I/O errors when a removal fails.
98pub fn uninstall(roots: &[Utf8PathBuf], apply: bool) -> Result<Vec<String>, AppError> {
99    let mut lines: Vec<String> = Vec::new();
100    let mut removable: Vec<Utf8PathBuf> = Vec::new();
101    for root in roots {
102        for name in crate::embedded::skill_names() {
103            let destination = root.join(name).join("SKILL.md");
104            check_destination(&destination)?;
105            if destination.is_file() {
106                lines.push(destination.to_string());
107                removable.push(destination);
108            }
109        }
110    }
111    if !apply {
112        lines.push("DRY RUN: no files removed".to_string());
113        return Ok(lines);
114    }
115    for destination in &removable {
116        std::fs::remove_file(destination)?;
117        let directory = destination
118            .parent()
119            .ok_or_else(|| anyhow::anyhow!("destination has no parent: {destination}"))?;
120        if std::fs::read_dir(directory)?.next().is_none() {
121            std::fs::remove_dir(directory)?;
122        } else {
123            lines.push(format!("kept (not empty): {directory}"));
124        }
125    }
126    Ok(lines)
127}
128
129#[cfg(test)]
130mod tests {
131    #![allow(clippy::unwrap_used)]
132
133    use super::*;
134
135    fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
136        Utf8PathBuf::from(dir.path().to_str().unwrap())
137    }
138
139    #[test]
140    fn a_preview_lists_every_destination_and_writes_nothing() {
141        let dir = tempfile::tempdir().unwrap();
142        let skills = root(&dir).join(".claude/skills");
143        let lines = install(std::slice::from_ref(&skills), false, false).unwrap();
144        assert_eq!(lines.last().unwrap(), "DRY RUN: no files written");
145        assert_eq!(lines.len(), crate::embedded::skill_names().len() + 1);
146        assert!(!skills.exists());
147    }
148
149    #[test]
150    fn an_apply_is_idempotent_and_a_conflict_refuses_with_every_path() {
151        let dir = tempfile::tempdir().unwrap();
152        let skills = root(&dir).join(".agents/skills");
153        install(std::slice::from_ref(&skills), true, false).unwrap();
154        install(std::slice::from_ref(&skills), true, false).unwrap();
155        for name in crate::embedded::skill_names() {
156            std::fs::write(skills.join(name).join("SKILL.md"), "edited").unwrap();
157        }
158        let error = install(std::slice::from_ref(&skills), true, false).unwrap_err();
159        let message = error.to_string();
160        for name in crate::embedded::skill_names() {
161            assert!(message.contains(name), "{message} misses {name}");
162        }
163        install(std::slice::from_ref(&skills), true, true).unwrap();
164        let text = std::fs::read_to_string(skills.join("sdd-setup/SKILL.md")).unwrap();
165        assert!(text.contains("name: sdd-setup"));
166    }
167
168    #[test]
169    fn an_uninstall_removes_only_payload_files_and_keeps_foreign_ones() {
170        let dir = tempfile::tempdir().unwrap();
171        let skills = root(&dir).join(".claude/skills");
172        install(std::slice::from_ref(&skills), true, false).unwrap();
173        std::fs::write(skills.join("sdd-setup/notes.md"), "mine").unwrap();
174
175        let preview = uninstall(std::slice::from_ref(&skills), false).unwrap();
176        assert_eq!(preview.last().unwrap(), "DRY RUN: no files removed");
177        assert!(skills.join("sdd-setup/SKILL.md").is_file());
178
179        let lines = uninstall(std::slice::from_ref(&skills), true).unwrap();
180        assert!(!skills.join("sdd-setup/SKILL.md").exists());
181        assert!(!skills.join("sdd-write-docs").exists());
182        assert_eq!(
183            std::fs::read_to_string(skills.join("sdd-setup/notes.md")).unwrap(),
184            "mine"
185        );
186        assert!(
187            lines
188                .iter()
189                .any(|line| line.starts_with("kept (not empty):"))
190        );
191
192        // A re-run on the emptied roots is a no-op, not an error.
193        uninstall(std::slice::from_ref(&skills), true).unwrap();
194    }
195}