Skip to main content

borg/install/
skill.rs

1//! The `borg` skill (`skills/borg/`): how borg works and how to set it up, for the drone itself. Compiled
2//! in and copied by `borg install` into every harness that loads skills ([`dir`]), one
3//! [`Change`] per file, with an adapter file's rules: each file is copied whole, a symlinked file is
4//! replaced (never written through), and uninstall removes only a file whose content is exactly this
5//! borg's, without a backup (borg recreates it exactly), then the skill's directories once empty. A
6//! skill directory that is itself a symlink is the engineer's (`ln -s` into a checkout): left alone.
7//! `borg status` and `borg doctor` report a missing or stale skill (an older borg's, or edited). borg
8//! cannot tell an older borg's text from an edit, so install backs up either one beside it: after an
9//! upgrade that changed the skill, uninstall leaves those backups and the directory holding them.
10
11use std::path::{Path, PathBuf};
12
13use super::change::{Action, Change, read};
14use crate::harness::{Harness, Level};
15
16/// The skill's files, relative to its directory, and their content.
17pub const FILES: [(&str, &str); 5] = [
18    ("SKILL.md", include_str!("../../assets/skills/borg/SKILL.md")),
19    ("references/collectives.md", include_str!("../../assets/skills/borg/references/collectives.md")),
20    ("references/team.md", include_str!("../../assets/skills/borg/references/team.md")),
21    ("references/vectors.md", include_str!("../../assets/skills/borg/references/vectors.md")),
22    ("references/troubleshooting.md", include_str!("../../assets/skills/borg/references/troubleshooting.md")),
23];
24
25/// Files an older borg's skill had and this one has not, with the blake3 of each text a release
26/// shipped: install and uninstall remove one only when it is exactly such a text; anything else is
27/// the engineer's, left alone.
28const RETIRED: [(&str, &[&str]); 1] = [(
29    "references/hive.md",
30    &[
31        "e489258ae65ce54c7b30b8d30e18754424946420a39cb03e45daa1cadb602380",
32        "e5f0eff3bddbfc883290fbefc781bcee3286562f06d95a64496514c97b686d94",
33        "c305e7b93c700d7f38c9b7968116fc4ece56a4f5b2f05f6bc47dadeecab84741",
34        "15f4dc8013f7584988d7ce035af1589917ad9e2b52f744792b5a42973f41c939",
35        "d11ef6672729f9d669a708e7439ef653da14eed88e908dcaed7d2dbb94e65fbb",
36        "23e607385e63f7b202e6bebf3dc8617023ec632f88958073354e9d6c0629be10",
37    ],
38)];
39
40/// The retired files in `dir` that are an older borg's exact text, with that text.
41fn retired(dir: &Path) -> Vec<(PathBuf, String)> {
42    RETIRED
43        .iter()
44        .filter_map(|(rel, known)| {
45            let path = dir.join(rel);
46            let text = read(&path).ok().flatten()?;
47            known.contains(&blake3::hash(text.as_bytes()).to_hex().as_str()).then_some((path, text))
48        })
49        .collect()
50}
51
52/// Where `h` loads the `borg` skill from: `skills/borg` in its config directory, the one holding its hook
53/// config or its adapter's directory (Claude Code, codex-cli 0.154.0, omp 17.3.5, opencode.ai/docs/skills);
54/// `home` and `var` as for `Harness::target`. None for the hidden harness, which loads none. Derived from
55/// the wiring, never the name: a harness that loads skills elsewhere needs a `Harness` method of its own.
56pub fn dir(h: &dyn Harness, home: &Path, var: &dyn Fn(&str) -> Option<PathBuf>) -> Option<PathBuf> {
57    let up = 1 + usize::from(!h.wiring().adapter().is_empty());
58    let dir = h.target(home, var).ancestors().nth(up)?.join("skills/borg");
59    (!h.hidden()).then_some(dir)
60}
61
62/// How the skill stands in one harness.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum State {
65    /// None of its files is there.
66    Absent,
67    /// Every file is this borg's.
68    Current,
69    /// Some file is missing or differs (an older borg's skill, or edited).
70    Stale,
71    /// The directory is a symlink: the engineer's, left alone.
72    Linked,
73    /// A file could not be read.
74    Unreadable(String),
75}
76
77fn linked(dir: &Path) -> bool {
78    std::fs::symlink_metadata(dir).is_ok_and(|m| m.file_type().is_symlink())
79}
80
81/// Each file's current text, or the first read error.
82fn current(dir: &Path) -> Result<Vec<Option<String>>, String> {
83    FILES.iter().map(|(rel, _)| read(&dir.join(rel)).map_err(|e| format!("{}: cannot read ({e}); nothing was changed", dir.join(rel).display()))).collect()
84}
85
86/// What `action` would change in the skill directory `dir` (None: the harness loads no skills), plus
87/// notes on what is left alone.
88///
89/// # Errors
90/// A skill file that exists but cannot be read: nothing is planned, as for any config.
91pub fn plan(action: Action, dir: Option<&Path>) -> Result<(Vec<Change>, Vec<String>), String> {
92    let Some(dir) = dir else { return Ok((Vec::new(), Vec::new())) };
93    if linked(dir) {
94        return Ok((Vec::new(), vec![format!("{}: left alone, a symlink (yours); the borg skill is not managed there", dir.display())]));
95    }
96    let befores = current(dir)?;
97    let mut changes = Vec::new();
98    let mut foreign = Vec::new();
99    for ((rel, text), before) in FILES.iter().zip(befores) {
100        let path = dir.join(rel);
101        let ours = before.as_deref() == Some(*text);
102        match action {
103            Action::Install if !ours => changes.push(Change { path, before, after: Some((*text).to_string()), through_links: false, backup: true }),
104            Action::Uninstall if ours => changes.push(Change { path, before, after: None, through_links: false, backup: false }),
105            Action::Uninstall if before.is_some() => foreign.push(path.display().to_string()),
106            _ => {}
107        }
108    }
109    for (path, text) in retired(dir) {
110        changes.push(Change { path, before: Some(text), after: None, through_links: false, backup: false });
111    }
112    let notes = foreign.into_iter().map(|p| format!("{p}: left alone, its content is not this borg's skill (yours, or an older borg's)")).collect();
113    Ok((changes, notes))
114}
115
116/// After an uninstall: removes the skill's directories that are now empty, innermost first. Anything
117/// still in them (a backup, the engineer's own file) keeps them.
118pub fn tidy(dir: &Path) {
119    if linked(dir) {
120        return;
121    }
122    let _ = std::fs::remove_dir(dir.join("references"));
123    let _ = std::fs::remove_dir(dir);
124}
125
126/// The skill's state in `dir`.
127#[must_use]
128pub fn state(dir: &Path) -> State {
129    if linked(dir) {
130        return State::Linked;
131    }
132    match current(dir) {
133        Err(e) => State::Unreadable(e),
134        Ok(found) if found.iter().all(Option::is_none) => State::Absent,
135        Ok(found) => {
136            let same = FILES.iter().zip(&found).all(|((_, text), f)| f.as_deref() == Some(*text));
137            if same && retired(dir).is_empty() { State::Current } else { State::Stale }
138        }
139    }
140}
141
142/// `borg status`: the skill's state for harness `name`, in a few words; None where it loads no skills.
143#[must_use]
144pub fn status(name: &str, dir: Option<&Path>) -> Option<String> {
145    let dir = dir?;
146    Some(match state(dir) {
147        State::Absent => "skill not installed".to_string(),
148        State::Current => format!("skill installed ({})", dir.display()),
149        State::Stale => format!("skill STALE: {} differs from this borg's (borg install {name})", dir.display()),
150        State::Linked => format!("skill: {} is a symlink (yours)", dir.display()),
151        State::Unreadable(e) => format!("skill UNREADABLE: {e}"),
152    })
153}
154
155/// `borg doctor`: the skill's state for harness `name`. A missing skill is a finding only where borg is
156/// wired (`wired`): an upgrade from a borg without the skill.
157#[must_use]
158pub fn checks(name: &str, dir: Option<&Path>, wired: bool) -> Vec<(Level, String)> {
159    let Some(dir) = dir else { return Vec::new() };
160    let fix = format!("borg install {name} --apply");
161    match state(dir) {
162        State::Absent if wired => vec![(Level::Warn, format!("{name}: the borg skill is missing at {} ({fix})", dir.display()))],
163        State::Absent => Vec::new(),
164        State::Current => vec![(Level::Ok, format!("{name}: borg skill installed at {}", dir.display()))],
165        State::Stale => vec![(Level::Warn, format!("{name}: the borg skill at {} differs from this borg's, older or edited ({fix})", dir.display()))],
166        State::Linked => vec![(Level::Ok, format!("{name}: the borg skill at {} is a symlink (yours); borg leaves it alone", dir.display()))],
167        State::Unreadable(e) => vec![(Level::Warn, format!("{name}: borg skill {e}"))],
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::install::change::apply;
175
176    fn tmp(name: &str) -> std::path::PathBuf {
177        let d = std::env::temp_dir().join(format!("borg-skill-{name}-{}", std::process::id()));
178        let _ = std::fs::remove_dir_all(&d);
179        std::fs::create_dir_all(&d).unwrap();
180        d
181    }
182
183    fn run(action: Action, dir: &Path, stamp: &str) -> (usize, Vec<String>) {
184        let (changes, notes) = plan(action, Some(dir)).unwrap();
185        for c in &changes {
186            apply(c, stamp).unwrap();
187        }
188        tidy(dir);
189        (changes.len(), notes)
190    }
191
192    #[test]
193    fn the_skill_lands_in_each_harness_own_config_directory() {
194        let home = Path::new("/h");
195        for h in crate::harness::visible() {
196            let d = dir(h, home, &|_| None).unwrap();
197            assert!(d.starts_with(home) && d.ends_with("skills/borg"), "{}", d.display());
198            // Its config-dir variable moves the skill along with the config.
199            assert_eq!(dir(h, home, &|_| Some(PathBuf::from("/v"))).unwrap(), Path::new("/v/skills/borg"), "{}", h.name());
200        }
201        assert!(crate::harness::ALL.iter().filter(|h| h.hidden()).all(|h| dir(*h, home, &|_| None).is_none()));
202    }
203
204    #[test]
205    fn the_skill_names_itself_and_says_when_to_load() {
206        let (name, skill) = FILES[0];
207        assert_eq!(name, "SKILL.md");
208        let front = skill.strip_prefix("---\n").and_then(|s| s.split_once("\n---\n")).map(|(f, _)| f).expect("frontmatter first");
209        // Strict YAML, as Codex and opencode parse it: an unquoted `: ` in a value breaks the skill there.
210        let front: std::collections::BTreeMap<String, String> = serde_norway::from_str(front).expect("the frontmatter is YAML");
211        assert_eq!(front.get("name").map(String::as_str), Some("borg"), "{front:?}");
212        let desc = front.get("description").expect("a description");
213        assert!(desc.len() <= 1024, "opencode's limit");
214        for word in ["borg", "collective", "lesson", "drone", "queen", "mesh", "vectors"] {
215            assert!(desc.contains(word), "the description triggers on {word}");
216        }
217        for (rel, _) in &FILES[1..] {
218            assert!(skill.contains(rel), "SKILL.md links {rel}");
219        }
220    }
221
222    #[test]
223    fn install_copies_every_file_once_and_uninstall_removes_exactly_them() {
224        let home = tmp("cycle");
225        let dir = home.join("skills/borg");
226        assert_eq!(state(&dir), State::Absent);
227        assert_eq!(run(Action::Install, &dir, "a").0, FILES.len());
228        assert_eq!(state(&dir), State::Current);
229        assert_eq!(run(Action::Install, &dir, "b").0, 0, "idempotent");
230        assert_eq!(run(Action::Uninstall, &dir, "c").0, FILES.len());
231        assert!(!dir.exists(), "the empty skill directory goes too");
232        assert!(home.join("skills").is_dir(), "the harness's skills directory stays");
233        assert_eq!(run(Action::Uninstall, &dir, "d").0, 0);
234    }
235
236    #[test]
237    fn an_edited_file_is_stale_replaced_with_a_backup_and_never_uninstalled() {
238        let dir = tmp("edited").join("borg");
239        run(Action::Install, &dir, "a");
240        std::fs::write(dir.join("references/team.md"), "my notes").unwrap();
241        assert_eq!(state(&dir), State::Stale);
242        assert!(checks("x", Some(&dir), true)[0].1.contains("borg install x --apply"), "the harness's own fix, not all");
243        let (n, notes) = run(Action::Uninstall, &dir, "u");
244        assert_eq!((n, notes.len()), (FILES.len() - 1, 1), "{notes:?}");
245        assert_eq!(std::fs::read_to_string(dir.join("references/team.md")).unwrap(), "my notes", "left alone");
246        assert_eq!(run(Action::Install, &dir, "i").0, FILES.len());
247        assert_eq!(std::fs::read_to_string(dir.join("references/team.md.borg-backup-i")).unwrap(), "my notes", "backed up");
248        assert_eq!(state(&dir), State::Current);
249    }
250
251    #[test]
252    fn an_older_borgs_retired_file_goes_and_an_edited_one_stays() {
253        let dir = tmp("retired").join("borg");
254        run(Action::Install, &dir, "a");
255        let old = dir.join("references/hive.md");
256        let shipped = std::process::Command::new("git")
257            .args(["show", "188c262:crates/borg/assets/skills/borg/references/hive.md"])
258            .current_dir(env!("CARGO_MANIFEST_DIR"))
259            .output()
260            .ok()
261            .filter(|o| o.status.success());
262        if let Some(o) = shipped {
263            std::fs::write(&old, &o.stdout).unwrap();
264            assert_eq!(state(&dir), State::Stale, "a leftover of an older borg asks for a reinstall");
265            assert_eq!(run(Action::Install, &dir, "b").0, 1);
266            assert!(!old.exists());
267        }
268        std::fs::write(&old, "my notes").unwrap();
269        assert_eq!(state(&dir), State::Current, "the engineer's own file is not borg's");
270        assert_eq!(run(Action::Install, &dir, "c").0, 0);
271        assert_eq!(std::fs::read_to_string(&old).unwrap(), "my notes");
272    }
273
274    #[cfg(unix)]
275    #[test]
276    fn symlinks_a_linked_file_is_replaced_and_a_linked_directory_left_alone() {
277        let home = tmp("links");
278        let outside = home.join("checkout.md");
279        std::fs::write(&outside, "checkout").unwrap();
280        let dir = home.join("borg");
281        std::fs::create_dir_all(&dir).unwrap();
282        std::os::unix::fs::symlink(&outside, dir.join("SKILL.md")).unwrap();
283        run(Action::Install, &dir, "a");
284        assert_eq!(std::fs::read_to_string(&outside).unwrap(), "checkout", "not written through");
285        assert_eq!(state(&dir), State::Current);
286
287        let linked_dir = home.join("linked");
288        std::os::unix::fs::symlink(home.join("elsewhere"), &linked_dir).unwrap();
289        let (n, notes) = run(Action::Install, &linked_dir, "b");
290        assert_eq!((n, notes.len()), (0, 1));
291        assert!(!home.join("elsewhere").exists());
292        assert_eq!(state(&linked_dir), State::Linked);
293    }
294
295    #[test]
296    fn doctor_warns_on_a_missing_skill_only_where_borg_is_wired() {
297        let dir = tmp("doctor").join("borg");
298        assert!(checks("x", Some(&dir), false).is_empty());
299        assert_eq!(checks("x", Some(&dir), true)[0].0, Level::Warn);
300        assert!(checks("x", None, true).is_empty(), "a harness without skills");
301        assert_eq!(status("x", None), None);
302        run(Action::Install, &dir, "a");
303        assert_eq!(checks("x", Some(&dir), true)[0].0, Level::Ok);
304        assert!(status("x", Some(&dir)).unwrap().starts_with("skill installed"));
305    }
306}