Skip to main content

borg/
install.rs

1//! Wiring borg into a harness, and seeing whether it works:
2//! `borg install|uninstall <harness|all> [--apply] [--home DIR]`, `borg status`, `borg doctor`.
3//!
4//! The wiring is exactly what each adapter README describes, written into the engineer's *user* config,
5//! as each harness's module says (harness/): command hooks merged into a JSON file (Claude Code's
6//! `~/.claude/settings.json`, each running `<this borg> hook claude <event>`), or a compiled-in adapter
7//! copied whole (omp's extension, opencode's plugin), plus the `borg` skill wherever the harness loads
8//! skills (`install/skill.rs`). An installed borg needs no checkout.
9//!
10//! Someone else's config is precious, so: the default is a dry run that prints the exact diff;
11//! `--apply` first writes a timestamped backup next to every file it changes; merging keeps every
12//! other key and hook as it was; re-running changes nothing; uninstall removes only what install adds
13//! (borg's own hook commands, and adapter and skill files whose content is this borg's). `--home`
14//! replaces HOME *and ignores every config-dir variable* (the harnesses' and `$BORG_HOME`/`$BORG_CONFIG`,
15//! see [`home_overrides`]), so a test can never reach a real config or store.
16
17pub mod change;
18mod orphans;
19pub mod setup;
20pub mod skill;
21
22use std::collections::{BTreeMap, BTreeSet, HashMap};
23use std::path::{Path, PathBuf};
24use std::time::Duration;
25
26use serde_json::Value;
27
28use crate::collective::{self, Route, Share};
29pub use crate::harness::Level;
30use crate::harness::wiring::Hooks;
31use crate::harness::{self, Harness, Wiring};
32use crate::{learn, outcome};
33use change::read;
34pub use change::{Action, Change, apply, diff};
35
36/// `all` (every visible harness) or one harness by name.
37fn parse(s: &str) -> Option<Vec<&'static dyn Harness>> {
38    match s {
39        "all" => Some(harness::visible().collect()),
40        _ => harness::by_name(s).map(|h| vec![h]),
41    }
42}
43
44/// Where the harnesses read their user config.
45#[derive(Debug, Clone)]
46pub struct Targets {
47    home: PathBuf,
48    /// Whether each harness's own config-dir variables count (not under `--home`).
49    env: bool,
50}
51
52fn var(k: &str) -> Option<PathBuf> {
53    std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from)
54}
55
56impl Targets {
57    /// Everything under `home`, no environment consulted (tests, `--home`).
58    pub fn under(home: &Path) -> Self {
59        Targets { home: home.to_path_buf(), env: false }
60    }
61
62    /// The real locations, honouring each harness's own config-dir variables.
63    pub fn from_env() -> Self {
64        Targets { home: var("HOME").unwrap_or_default(), env: true }
65    }
66
67    pub fn path(&self, h: &dyn Harness) -> PathBuf {
68        h.target(&self.home, &|k| if self.env { var(k) } else { None })
69    }
70
71    /// Where `h` loads the `borg` skill from; None for a harness without skills.
72    pub fn skill_dir(&self, h: &dyn Harness) -> Option<PathBuf> {
73        skill::dir(h, &self.home, &|k| if self.env { var(k) } else { None })
74    }
75}
76
77// ---------------------------------------------------------------- plans
78
79/// What `action` would change for `h`: its hook config or adapter first, then each skill file
80/// (`install/skill.rs`); plus notes: things deliberately left alone, and on install the steps the harness
81/// itself asks of the engineer (`Harness::install_notes`).
82pub fn plan(action: Action, h: &dyn Harness, t: &Targets, bin: &Path) -> Result<(Vec<Change>, Vec<String>), String> {
83    let path = t.path(h);
84    let before = read(&path).map_err(|e| format!("{}: cannot read ({e}); nothing was changed", path.display()))?;
85    let mut notes = Vec::new();
86    let after = match (Hooks::of(h), action) {
87        (Some(k), Action::Install) => k.install(before.as_deref(), bin)?.map(Some),
88        (Some(k), Action::Uninstall) => k.uninstall(before.as_deref())?,
89        (None, Action::Install) => (before.as_deref() != Some(h.wiring().adapter())).then(|| Some(h.wiring().adapter().to_string())),
90        (None, Action::Uninstall) => match before.as_deref() {
91            Some(b) if b == h.wiring().adapter() => Some(None),
92            Some(_) => {
93                notes.push(format!("{}: left alone, its content is not this borg's adapter (yours, or an older borg's)", path.display()));
94                None
95            }
96            None => None,
97        },
98    };
99    let through_links = matches!(h.wiring(), Wiring::CommandHooks { through_links: true, .. });
100    let mut changes: Vec<Change> = after.map(|after| Change { path, before, after, through_links, backup: true }).into_iter().collect();
101    let (skill, left) = skill::plan(action, t.skill_dir(h).as_deref())?;
102    changes.extend(skill);
103    notes.extend(left);
104    if action == Action::Install {
105        notes.extend(h.install_notes());
106    }
107    Ok((changes, notes))
108}
109
110/// UTC `YYYYMMDDTHHMMSS` for an epoch-ms time (no time crate for one format).
111pub fn utc(ms: i64) -> String {
112    let secs = ms.div_euclid(1000);
113    let day_s = crate::clock::DAY_MS / 1000;
114    let (days, rem) = (secs.div_euclid(day_s), secs.rem_euclid(day_s));
115    let (y, m, d) = crate::clock::civil_from_days(days);
116    format!("{y:04}{m:02}{d:02}T{:02}{:02}{:02}", rem / 3600, rem % 3600 / 60, rem % 60)
117}
118
119fn human(ms: i64) -> String {
120    let s = utc(ms);
121    format!("{}-{}-{} {}:{}:{} UTC", &s[0..4], &s[4..6], &s[6..8], &s[9..11], &s[11..13], &s[13..15])
122}
123
124// ---------------------------------------------------------------- status
125
126#[derive(Debug, Default, PartialEq)]
127pub struct Stats {
128    pub lessons: usize,
129    pub episodes: usize,
130    pub outcomes: usize,
131    pub last_capture_ms: Option<i64>,
132    /// When, and which lessons (`id: question`), the conscience last asked.
133    pub last_asked: Option<(i64, Vec<String>)>,
134}
135
136pub fn stats(home: &Path) -> Stats {
137    let lessons = learn::lessons(&home.join("lessons.jsonl"));
138    let episodes: Vec<Value> =
139        read(&home.join("episodes.jsonl")).ok().flatten().unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect();
140    let outcomes = outcome::read(&home.join("outcomes.jsonl"));
141    let last_asked = outcomes.iter().filter(|o| o.kind == outcome::ASKED).max_by_key(|o| o.ms).map(|o| {
142        let named = o.lessons.iter().map(|id| match lessons.iter().find(|l| &l.id == id) {
143            Some(l) => format!("{id}: {}", l.question.chars().take(100).collect::<String>()),
144            None => id.clone(),
145        });
146        (o.ms, named.collect())
147    });
148    Stats {
149        lessons: lessons.len(),
150        episodes: episodes.len(),
151        outcomes: outcomes.len(),
152        last_capture_ms: episodes.iter().filter_map(|e| e.get("ms").and_then(Value::as_i64)).max(),
153        last_asked,
154    }
155}
156
157/// Every collective with its store: those in the config, or the one "default" without a config.
158pub fn collectives(cfg: Option<&collective::Config>, base: &Path) -> Vec<Route> {
159    match cfg {
160        None => vec![Route::Collective { name: "default".into(), home: base.to_path_buf(), share: Share::Team }],
161        Some(c) => c.collectives.iter().map(|e| Route::Collective { name: e.name.clone(), home: collective::store(base, &e.name), share: e.share }).collect(),
162    }
163}
164
165/// How `h` is wired under `t`, in one line: its hooks or adapter, then its skill.
166pub fn wiring(h: &dyn Harness, t: &Targets) -> String {
167    let line = hooks_line(h, t);
168    match skill::status(h.name(), t.skill_dir(h).as_deref()) {
169        Some(skill) => format!("{line}; {skill}"),
170        None => line,
171    }
172}
173
174/// How `h`'s hooks or adapter are wired under `t`.
175fn hooks_line(h: &dyn Harness, t: &Targets) -> String {
176    let path = t.path(h);
177    let text = match read(&path) {
178        Ok(t) => t,
179        Err(e) => return format!("UNREADABLE: {} ({e})", path.display()),
180    };
181    let Some(k) = Hooks::of(h) else {
182        return match text {
183            None => format!("not wired ({})", path.display()),
184            Some(t) if t == h.wiring().adapter() => format!("wired: {}", path.display()),
185            Some(_) => format!("wired, but {} differs from this borg's adapter (older or edited)", path.display()),
186        };
187    };
188    let w = k.wired(text.as_deref());
189    let missing = k.missing(&w);
190    let events: Vec<&str> = k.events.iter().map(|(_, e)| *e).filter(|e| !missing.contains(e)).collect();
191    let mut bins: Vec<String> = w.iter().map(|(_, b)| b.display().to_string()).collect();
192    bins.dedup();
193    match events.len() {
194        0 => format!("not wired ({})", path.display()),
195        n if n == k.events.len() => format!("wired: {} -> {}", path.display(), bins.join(", ")),
196        _ => format!("PARTIAL ({} only): {} -> {}", events.join(","), path.display(), bins.join(", ")),
197    }
198}
199
200fn print_status(t: &Targets) {
201    let base = learn::home();
202    let cfg = collective::load();
203    match &cfg {
204        Err(e) => println!("COLLECTIVES CONFIG INVALID: {e}\n  every repo routes OFF: borg captures and asks nothing until it is fixed (borg doctor)"),
205        Ok(None) => println!("config: {} (absent: every repo is in collective \"default\")", collective::config_path().display()),
206        Ok(Some(c)) => println!("config: {} (default: {}, off: {:?})", collective::config_path().display(), c.default, c.off),
207    }
208    let routes = match &cfg {
209        Ok(c) => collectives(c.as_ref(), &base),
210        Err(_) => Vec::new(), // nothing is routed anywhere; listing stores would suggest otherwise
211    };
212    for r in &routes {
213        let Route::Collective { name, home, share } = r else { continue };
214        let s = stats(home);
215        println!("\ncollective {name} ({share:?}, may sync: {})", collective::may_sync(r));
216        println!("  store        {}", home.display());
217        println!("  lessons      {}", s.lessons);
218        // Held outside borg (absorb.rs), and of them the ones a correction merged into since.
219        let (absorbed, recurred) = crate::memory_export::summary(home);
220        println!("  absorbed     {absorbed}, recurred {recurred}");
221        println!("  episodes     {}", s.episodes);
222        println!("  outcomes     {}", s.outcomes);
223        println!("  queen open   {}", crate::queen::open(home).len());
224        println!("  trust        {}", crate::trust::summary(home));
225        println!("  last capture {}", s.last_capture_ms.map(human).unwrap_or_else(|| "never".into()));
226        // Corrections and answers per source: the hooks' live capture and each harness's transcript (sweep.rs).
227        let sources: Vec<String> = crate::sweep::sources(home).into_iter().map(|(k, (n, ms))| format!("{k} {n} (last {})", human(ms))).collect();
228        println!("  captures     {}", if sources.is_empty() { "none yet".to_string() } else { sources.join("; ") });
229        let g = crate::prefilter::tally(&home.join("state"));
230        println!("  stop gate    {} turn ends skipped at 0 tokens, {} asked the model (${:.2} so far)", g.skipped, g.asked, g.cost);
231        // The turn-end check: detached or at the stop, and what the detached ones did (check_async.rs).
232        let model = std::env::var("BORG_PREDICT_MODEL").unwrap_or_default();
233        let line = crate::top::async_line(
234            &crate::stats::async_tally(&crate::stats::live(home)),
235            crate::check_async::on(),
236            crate::check_async::call_limit_in(home, &model),
237            crate::whole::line_format().name(),
238        );
239        println!("  turn check   {line}");
240        // Live mode's checks during the turn, before it was removed in 0.7.6: history only.
241        if g.live_checks > 0 {
242            println!("  live checks  {} during the turn before 0.7.6, {} lessons read (${:.2})", g.live_checks, g.live_lessons, g.live_cost);
243        }
244        println!(
245            "  cost         {} extra rounds (Stop blocks, {} questions, ~{} tokens); {} free deliveries ({} lessons, ~{} tokens, no extra round)",
246            g.rounds,
247            g.questions,
248            g.block_chars / crate::whole::CHARS_PER_TOKEN,
249            g.free,
250            g.free_lessons,
251            g.free_chars / crate::whole::CHARS_PER_TOKEN
252        );
253        // A detached check's questions, delivered after the turn they judged (no extra round).
254        if g.late > 0 {
255            println!(
256                "  questions    {} asked after their turn ({} in a wake, ~{} tokens, no extra round)",
257                g.late_questions,
258                g.late_woken,
259                g.late_chars / crate::whole::CHARS_PER_TOKEN
260            );
261        }
262        println!(
263            "  judge        {} model calls also judged {} asked lessons ({} settled; ~{} prompt tokens added, no extra call)",
264            g.judge_calls,
265            g.judged,
266            g.judge_resolved,
267            g.judge_chars / crate::whole::CHARS_PER_TOKEN
268        );
269        // The store's lesson selection (top.rs `selection`, tested there).
270        let selection = crate::top::selection(&home.join("lessons.jsonl"), crate::select::policy());
271        // The judge's effective length per model: the session-end judge's budget (judge_length.rs).
272        for (i, l) in selection.judge.iter().enumerate() {
273            println!("  {:<12} {}", if i == 0 { "judge length" } else { "" }, crate::top::judge_length_line(l));
274        }
275        println!("  selection    {}", crate::top::selection_line(&selection));
276        match s.last_asked {
277            None => println!("  last asked   never"),
278            Some((ms, ids)) => {
279                println!("  last asked   {}", human(ms));
280                for l in ids {
281                    println!("               {l}");
282                }
283            }
284        }
285    }
286    // This borg, the latest release, and the members of each synced collective by version (update.rs).
287    println!("\nversion");
288    println!("  this borg    {}", crate::update::VERSION);
289    println!("  latest       {}", crate::update::release_line(crate::update::checked().as_ref(), learn::now_ms()));
290    for r in routes.iter().filter(|r| collective::may_sync(r)) {
291        let Route::Collective { name, home, .. } = r else { continue };
292        for l in crate::update::member_lines(home) {
293            println!("  {name:<12} {l}");
294        }
295    }
296    println!("\nharnesses");
297    for &h in harness::ALL {
298        let line = wiring(h, t);
299        // A hidden harness (the simulated one) only once someone wired it.
300        if !h.hidden() || !line.starts_with("not wired") {
301            println!("  {:<9} {line}", h.name());
302        }
303    }
304}
305
306// ---------------------------------------------------------------- doctor
307
308#[cfg(unix)]
309fn executable(p: &Path) -> bool {
310    use std::os::unix::fs::PermissionsExt;
311    std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
312}
313
314#[cfg(not(unix))]
315fn executable(p: &Path) -> bool {
316    p.is_file()
317}
318
319pub fn which(name: &str) -> Option<PathBuf> {
320    std::env::split_paths(&std::env::var_os("PATH")?).map(|d| d.join(name)).find(|p| executable(p))
321}
322
323/// `borg-<part>` beside this borg, else on PATH: main.rs runs it, `borg setup` runs borg-mesh.
324#[must_use]
325pub fn sibling(part: &str) -> Option<PathBuf> {
326    let bin = format!("borg-{part}");
327    let beside = std::env::current_exe().ok().map(|e| e.with_file_name(&bin)).filter(|p| p.exists());
328    beside.or_else(|| which(&bin))
329}
330
331/// First line of `<cli> --version`, if it answers within 5 s.
332pub(crate) fn version(cli: &Path) -> Option<String> {
333    let mut child = std::process::Command::new(cli)
334        .arg("--version")
335        .stdin(std::process::Stdio::null())
336        .stdout(std::process::Stdio::piped())
337        .stderr(std::process::Stdio::null())
338        .spawn()
339        .ok()?;
340    for _ in 0..100 {
341        if child.try_wait().ok()?.is_some() {
342            let out = child.wait_with_output().ok()?;
343            return String::from_utf8_lossy(&out.stdout).lines().next().map(|l| l.trim().to_string());
344        }
345        std::thread::sleep(Duration::from_millis(50));
346    }
347    let _ = child.kill();
348    None
349}
350
351/// Checks on the wiring alone (pure: no PATH, no processes), so they can be tested on fixtures.
352/// `borg_found`: whether the adapters can find a borg (`$BORG_BIN` or PATH).
353pub fn wiring_checks(t: &Targets, borg_found: bool) -> Vec<(Level, String)> {
354    let mut out = Vec::new();
355    // Every harness: an unwired one adds nothing, so a hidden one shows only once someone wired it.
356    for &h in harness::ALL {
357        let found = match Hooks::of(h) {
358            Some(k) => hook_checks(h, &k, t),
359            None => adapter_checks(h, t, borg_found),
360        };
361        let skill = skill::checks(h.name(), t.skill_dir(h).as_deref(), !found.is_empty());
362        out.extend(found.into_iter().chain(skill));
363    }
364    if out.is_empty() {
365        out.push((Level::Warn, "no harness is wired to borg (borg install all)".into()));
366    }
367    out
368}
369
370/// A command-hook harness: its config readable and valid, every event wired, to a binary that runs.
371fn hook_checks(h: &dyn Harness, k: &Hooks, t: &Targets) -> Vec<(Level, String)> {
372    let (name, path) = (h.name(), t.path(h));
373    let mut out = Vec::new();
374    let text = read(&path).unwrap_or_else(|e| {
375        out.push((Level::Fail, format!("{name}: {}: cannot read ({e}); borg install will not touch it", path.display())));
376        None
377    });
378    if text.is_some()
379        && let Err(e) = k.parse(text.as_deref())
380    {
381        out.push((Level::Fail, format!("{name}: {}: {e}", path.display())));
382    }
383    let wired = k.wired(text.as_deref());
384    if wired.is_empty() {
385        return out;
386    }
387    let missing = k.missing(&wired);
388    if !missing.is_empty() {
389        out.push((Level::Warn, format!("{name}: hooks missing for {} (borg install {name})", missing.join(", "))));
390    }
391    let leftover = k.leftover(text.as_deref());
392    if !leftover.is_empty() {
393        out.push((
394            Level::Warn,
395            format!("{name}: an older borg's hooks for {} are still wired (borg install {name} --apply removes them)", leftover.join(", ")),
396        ));
397    }
398    for (ev, bin) in &wired {
399        if !executable(bin) {
400            out.push((
401                Level::Fail,
402                format!("{name}: hook {ev} runs {}, which does not exist or is not executable (borg install {name} --apply)", bin.display()),
403            ));
404        }
405    }
406    if out.iter().all(|(l, _)| *l == Level::Ok) {
407        out.push((Level::Ok, format!("{name}: hooks wired to {}", wired[0].1.display())));
408    }
409    out
410}
411
412/// An adapter harness: the installed file is this borg's adapter, and the adapter can find borg.
413fn adapter_checks(h: &dyn Harness, t: &Targets, borg_found: bool) -> Vec<(Level, String)> {
414    let (name, path) = (h.name(), t.path(h));
415    let text = match read(&path) {
416        Ok(Some(text)) => text,
417        Ok(None) => return Vec::new(),
418        Err(e) => return vec![(Level::Fail, format!("{name}: {}: cannot read ({e})", path.display()))],
419    };
420    let mut out = Vec::new();
421    if text != h.wiring().adapter() {
422        out.push((Level::Warn, format!("{name}: {} differs from this borg's adapter (borg install {name})", path.display())));
423    } else {
424        out.push((Level::Ok, format!("{name}: adapter installed at {}", path.display())));
425    }
426    if !borg_found {
427        out.push((Level::Fail, format!("{name}: the adapter finds borg via $BORG_BIN or PATH, and neither has one: it silently does nothing")));
428    }
429    out
430}
431
432/// Checks on the collectives config and stores.
433pub fn collective_checks(cfg: &Result<Option<collective::Config>, String>, base: &Path) -> Vec<(Level, String)> {
434    let mut out = Vec::new();
435    let cfg = match cfg {
436        Err(e) => {
437            out.push((
438                Level::Fail,
439                format!("COLLECTIVES CONFIG INVALID ({e}): EVERY repo routes OFF, borg captures and asks NOTHING anywhere until it is fixed"),
440            ));
441            return out;
442        }
443        Ok(c) => c.as_ref(),
444    };
445    match cfg {
446        None => out.push((Level::Ok, format!("collectives: no config at {}, every repo is in \"default\"", collective::config_path().display()))),
447        Some(c) => {
448            if c.default != "off" && !c.collectives.iter().any(|e| e.name == c.default) {
449                out.push((Level::Warn, format!("collectives: default \"{}\" names no collective, so unmatched repos route off", c.default)));
450            }
451            let mut names: Vec<&str> = c.collectives.iter().map(|e| e.name.as_str()).collect();
452            names.sort();
453            if names.windows(2).any(|w| w[0] == w[1]) {
454                out.push((Level::Warn, "collectives: a name appears twice; their stores are shared".into()));
455            }
456            out.push((Level::Ok, format!("collectives: {} configured ({})", c.collectives.len(), names.join(", "))));
457        }
458    }
459    if let Some(set) = collective::mode_gone(cfg, std::env::var("BORG_MODE").ok().as_deref()) {
460        out.push((Level::Ok, format!("delivery mode: live mode is gone since 0.7.6; {set} is ignored, every check runs at the turn end")));
461    }
462    for r in collectives(cfg, base) {
463        let Route::Collective { name, home, .. } = &r else { continue };
464        out.extend(orphans::store_checks(name, home));
465        let probe = home.join(".borg-doctor-probe");
466        let ok = std::fs::create_dir_all(home).and_then(|_| std::fs::write(&probe, b"ok")).and_then(|_| std::fs::remove_file(&probe));
467        match ok {
468            Ok(()) => out.push((Level::Ok, format!("store {name}: {} writable", home.display()))),
469            Err(e) => out.push((Level::Fail, format!("store {name}: {} not writable ({e})", home.display()))),
470        }
471    }
472    out
473}
474
475/// Every engineer email a store records (lessons' authors and joiners, episodes), as written.
476pub fn store_emails(home: &Path) -> BTreeSet<String> {
477    let mut out = BTreeSet::new();
478    for l in learn::lessons(&home.join("lessons.jsonl")) {
479        out.extend(l.engineers.into_iter().chain(l.joined.into_keys()));
480    }
481    let episodes = read(&home.join("episodes.jsonl")).ok().flatten().unwrap_or_default();
482    for v in episodes.lines().filter_map(|l| serde_json::from_str::<Value>(l).ok()) {
483        out.extend(v.get("engineer").and_then(Value::as_str).map(str::to_string));
484    }
485    out.retain(|e| !e.trim().is_empty());
486    out
487}
488
489/// Hints for emails in the stores that share a git user.name (`identities`: name, email pairs) but
490/// are not yet one engineer under the config's `engineers` (collective.rs "Engineers").
491pub fn alias_hints(emails: &BTreeSet<String>, identities: &[(String, String)], aliases: &collective::Aliases) -> Vec<String> {
492    let stored: HashMap<String, &String> = emails.iter().map(|e| (e.trim().to_lowercase(), e)).collect();
493    // name (lowercased) -> (name as written, canonical engineer -> an email as the store has it)
494    let mut by_name: BTreeMap<String, (&str, BTreeMap<String, &str>)> = BTreeMap::new();
495    for (name, email) in identities {
496        let Some(raw) = stored.get(&email.trim().to_lowercase()) else { continue };
497        if name.trim().is_empty() {
498            continue;
499        }
500        let entry = by_name.entry(name.trim().to_lowercase()).or_insert((name.trim(), BTreeMap::new()));
501        entry.1.entry(aliases.canon(raw)).or_insert(raw.as_str());
502    }
503    by_name
504        .into_values()
505        .filter(|(_, ids)| ids.len() > 1)
506        .map(|(name, ids)| {
507            let listed: Vec<String> = ids.values().map(|e| format!("{e:?}")).collect();
508            format!(
509                "engineers: git user {name:?} appears in the stores as {}; if that is one engineer, add {{\"engineers\": [{{\"name\": \"...\", \"emails\": [{}]}}]}} to {} so trust and status count them once",
510                listed.join(" and "),
511                listed.join(", "),
512                collective::config_path().display()
513            )
514        })
515        .collect()
516}
517
518/// (user.name, user.email) pairs git knows here: the configured identity (global and the current
519/// repo's) and the authors of the current repo's recent history. Best effort: no git, no pairs.
520fn git_identities() -> Vec<(String, String)> {
521    let git = |args: &[&str]| {
522        std::process::Command::new("git")
523            .args(args)
524            .stdin(std::process::Stdio::null())
525            .stderr(std::process::Stdio::null())
526            .output()
527            .ok()
528            .filter(|o| o.status.success())
529            .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
530            .unwrap_or_default()
531    };
532    let mut out: Vec<(String, String)> =
533        git(&["log", "-n", "2000", "--format=%aN%x09%aE"]).lines().filter_map(|l| l.split_once('\t')).map(|(n, e)| (n.to_string(), e.to_string())).collect();
534    for scope in [&["config", "--global"][..], &["config"][..]] {
535        let get = |k: &str| git(&[scope, &[k]].concat()).trim().to_string();
536        out.push((get("user.name"), get("user.email")));
537    }
538    out.sort();
539    out.dedup();
540    out
541}
542
543/// Each harness's CLI (its version, as its [`harness::Probe`] says) and, once the CLI answered, its own
544/// checks (`Harness::checks`: they may ask the CLI, which a missing or hung one cannot answer).
545fn harness_checks<'a>(hs: impl Iterator<Item = &'a dyn Harness>, t: &Targets) -> Vec<(Level, String)> {
546    let mut checks = Vec::new();
547    for h in hs {
548        let harness::Probe { cli, tested } = h.probe();
549        match which(cli) {
550            None => checks.push((Level::Ok, format!("{cli}: not installed"))),
551            Some(p) => match version(&p) {
552                Some(v) => {
553                    checks.push((Level::Ok, format!("{cli}: {v}{tested}")));
554                    checks.extend(h.checks(&t.path(h)));
555                }
556                None => checks.push((Level::Warn, format!("{cli}: {} did not answer --version", p.display()))),
557            },
558        }
559    }
560    checks
561}
562
563fn doctor(t: &Targets) -> i32 {
564    let mut checks = Vec::new();
565    let env_bin = std::env::var_os("BORG_BIN").filter(|v| !v.is_empty()).map(PathBuf::from);
566    let on_path = which("borg");
567    match (&env_bin, &on_path) {
568        (Some(b), _) if !executable(b) => checks.push((Level::Fail, format!("BORG_BIN={} does not exist or is not executable", b.display()))),
569        (Some(b), _) => checks.push((Level::Ok, format!("borg: BORG_BIN={}", b.display()))),
570        (None, Some(p)) => checks.push((Level::Ok, format!("borg: on PATH at {}", p.display()))),
571        (None, None) => {
572            let adapters: Vec<&str> = harness::visible().filter(|h| matches!(h.wiring(), Wiring::AdapterFile { .. })).map(|h| h.name()).collect();
573            checks.push((Level::Warn, format!("borg: not on PATH and BORG_BIN unset ({} adapters need one)", adapters.join(" and "))));
574        }
575    }
576    let borg_found = env_bin.as_deref().is_some_and(executable) || on_path.is_some();
577    // A newer release, or a release check that keeps failing (update.rs).
578    checks.push(crate::update::doctor_check(learn::now_ms()));
579    checks.extend(harness_checks(harness::visible(), t));
580    checks.extend(wiring_checks(t, borg_found));
581    let cfg = collective::load();
582    checks.extend(collective_checks(&cfg, &learn::home()));
583    if let Ok(cwd) = std::env::current_dir() {
584        checks.extend(orphans::hive_worktree_checks(&cwd));
585    }
586    if let Ok(c) = &cfg {
587        let routes = collectives(c.as_ref(), &learn::home());
588        checks.extend(crate::vectors::doctor::checks(&routes));
589        // What the harnesses' transcripts name against what Borg captured (sweep.rs).
590        checks.extend(crate::sweep::doctor(c.as_ref(), &learn::home(), &t.home, learn::now_ms()));
591        checks.extend(crate::presence::mesh_checks(&routes));
592        // Lessons and evidence outside the collective their repo routes to, with the `borg move` that
593        // fixes each (moves.rs): shown, never acted on.
594        let cwd = std::env::current_dir().map(|d| d.display().to_string()).unwrap_or_default();
595        checks.extend(crate::moves::doctor(c.as_ref(), &collective::root_of(&learn::home()), &learn::engineer(&cwd)));
596        let emails: BTreeSet<String> = routes
597            .iter()
598            .filter_map(|r| match r {
599                Route::Collective { home, .. } => Some(store_emails(home)),
600                Route::Off => None,
601            })
602            .flatten()
603            .collect();
604        let aliases = c.as_ref().map(collective::Config::aliases).unwrap_or_default();
605        checks.extend(alias_hints(&emails, &git_identities(), &aliases).into_iter().map(|h| (Level::Warn, h)));
606    }
607    let spec = std::env::var("BORG_PREDICT_MODEL").ok();
608    checks.push(match crate::runner::availability(spec.as_deref(), &crate::runner::on_path) {
609        Ok(m) => (Level::Ok, m),
610        Err(m) => (Level::Warn, m),
611    });
612    for (l, msg) in &checks {
613        let tag = match l {
614            Level::Ok => "ok  ",
615            Level::Warn => "WARN",
616            Level::Fail => "FAIL",
617        };
618        println!("{tag} {msg}");
619    }
620    i32::from(checks.iter().any(|(l, _)| *l == Level::Fail))
621}
622
623// ---------------------------------------------------------------- CLI
624
625fn usage_line() -> String {
626    format!("usage: borg install|uninstall <{}|all> [--apply] [--home DIR] | borg status [--home DIR] | borg doctor [--home DIR]", harness::names("|"))
627}
628
629/// `borg install|uninstall|status|doctor`; returns the exit code.
630pub fn main(cmd: &str, args: &[&str]) -> i32 {
631    let mut apply_flag = false;
632    let mut home: Option<PathBuf> = None;
633    let mut which_h: Option<&str> = None;
634    let mut it = args.iter();
635    while let Some(a) = it.next() {
636        match *a {
637            "--apply" => apply_flag = true,
638            "--home" => match it.next() {
639                Some(d) => home = Some(PathBuf::from(d)),
640                None => return usage(),
641            },
642            h if which_h.is_none() && !h.starts_with('-') => which_h = Some(h),
643            _ => return usage(),
644        }
645    }
646    let targets = match &home {
647        Some(h) => {
648            isolate(cmd, h);
649            Targets::under(h)
650        }
651        None => Targets::from_env(),
652    };
653    match cmd {
654        "status" if which_h.is_none() && !apply_flag => {
655            print_status(&targets);
656            0
657        }
658        "doctor" if which_h.is_none() && !apply_flag => doctor(&targets),
659        "install" | "uninstall" => {
660            let Some(hs) = which_h.and_then(parse) else { return usage() };
661            let action = if cmd == "install" { Action::Install } else { Action::Uninstall };
662            run(action, &hs, &targets, &bin(), apply_flag)
663        }
664        _ => usage(),
665    }
666}
667
668/// The borg the hooks run: `$BORG_BIN`, else this binary.
669pub(crate) fn bin() -> PathBuf {
670    match std::env::var_os("BORG_BIN").filter(|v| !v.is_empty()) {
671        Some(b) => PathBuf::from(b),
672        None => std::env::current_exe().and_then(std::fs::canonicalize).unwrap_or_else(|_| PathBuf::from("borg")),
673    }
674}
675
676/// Variables `--home` removes, so every path borg resolves falls under the given HOME: the store
677/// (`$BORG_HOME`, default `HOME/.local/share/borg`), the collectives config (`$BORG_CONFIG`, default
678/// `HOME/.config/borg/collectives.json`) and the vector model (`$BORG_VECTOR_MODEL`, default under
679/// `HOME/.local/share/borg/models`). For status and doctor `$BORG_BIN` goes too (a real binary to
680/// check); install (and `borg setup`, which installs) keeps it, since there it only names the binary to
681/// wire, not a place to write.
682/// Harness config-dir variables need no removing: `Targets::under` never reads them.
683pub fn home_overrides(cmd: &str) -> &'static [&'static str] {
684    match cmd {
685        "install" | "uninstall" | "setup" => &["BORG_HOME", "BORG_CONFIG", "BORG_VECTOR_MODEL"],
686        _ => &["BORG_HOME", "BORG_CONFIG", "BORG_VECTOR_MODEL", "BORG_BIN"],
687    }
688}
689
690pub(crate) fn isolate(cmd: &str, home: &Path) {
691    // SAFETY: single-threaded CLI start, before anything reads the environment.
692    unsafe {
693        std::env::set_var("HOME", home);
694        for k in home_overrides(cmd) {
695            std::env::remove_var(k);
696        }
697    }
698}
699
700fn usage() -> i32 {
701    eprintln!("{}", usage_line());
702    2
703}
704
705fn run(action: Action, hs: &[&dyn Harness], t: &Targets, bin: &Path, apply_flag: bool) -> i32 {
706    let stamp = format!("{}Z", utc(learn::now_ms()));
707    let mut code = 0;
708    for &h in hs {
709        let changes = match plan(action, h, t, bin) {
710            Err(e) => {
711                eprintln!("{}: {e}", h.name());
712                code = 1;
713                continue;
714            }
715            Ok((changes, notes)) => {
716                for n in notes {
717                    println!("{}: {n}", h.name());
718                }
719                changes
720            }
721        };
722        if changes.is_empty() {
723            println!("{}: nothing to change ({})", h.name(), t.path(h).display());
724        } else if apply_flag {
725            code = code.max(write(h, &changes, &stamp));
726            t.skill_dir(h).inspect(|d| skill::tidy(d));
727        } else {
728            for c in &changes {
729                print!("{}", diff(&c.path, c.before.as_deref(), c.after.as_deref()));
730            }
731        }
732    }
733    footer(action, apply_flag, code);
734    code
735}
736
737/// After a clean run: the dry-run note, or after an install what is left that install never does.
738fn footer(action: Action, apply_flag: bool, code: i32) {
739    if code != 0 {
740        return;
741    }
742    if !apply_flag {
743        println!("(dry run: nothing written; add --apply to write, with a backup of every changed file)");
744    } else if action == Action::Install
745        && let Some(hint) = crate::vectors::doctor::hint()
746    {
747        println!("{hint}");
748    }
749}
750
751/// Writes `h`'s changes in order, each reported on its own line; 1 if any failed.
752pub(crate) fn write(h: &dyn Harness, changes: &[Change], stamp: &str) -> i32 {
753    let mut code = 0;
754    for c in changes {
755        match apply(c, stamp) {
756            Ok(backup) => {
757                let b = backup.map(|b| format!(" (backup {})", b.display())).unwrap_or_default();
758                let what = if c.after.is_some() { "wrote" } else { "removed" };
759                println!("{}: {what} {}{b}", h.name(), c.path.display());
760            }
761            Err(e) => {
762                eprintln!("{}: {}: {e}", h.name(), c.path.display());
763                code = 1;
764            }
765        }
766    }
767    code
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773    use crate::harness::{claude, omp, opencode};
774    use serde_json::json;
775
776    const CLAUDE: &dyn Harness = &claude::Claude;
777    const OMP: &dyn Harness = &omp::Omp;
778    const OPENCODE: &dyn Harness = &opencode::Opencode;
779
780    fn claude_hooks() -> Hooks {
781        Hooks::of(CLAUDE).unwrap()
782    }
783
784    fn tmp(name: &str) -> PathBuf {
785        let d = std::env::temp_dir().join(format!("borg-install-{name}-{}", std::process::id()));
786        let _ = std::fs::remove_dir_all(&d);
787        std::fs::create_dir_all(&d).unwrap();
788        d
789    }
790
791    /// A settings.json as Claude Code writes it, with someone else's hooks and settings.
792    const EXISTING: &str = r#"{
793  "model": "opus",
794  "permissions": {
795    "allow": [
796      "Bash(git status)"
797    ]
798  },
799  "hooks": {
800    "Stop": [
801      {
802        "hooks": [
803          {
804            "type": "command",
805            "command": "notify-send done"
806          }
807        ]
808      }
809    ],
810    "PreToolUse": [
811      {
812        "matcher": "Bash",
813        "hooks": [
814          {
815            "type": "command",
816            "command": "/usr/local/bin/guard"
817          }
818        ]
819      }
820    ]
821  },
822  "zz_last": true
823}"#;
824
825    fn all_files(home: &Path) -> Vec<(PathBuf, Vec<u8>)> {
826        fn walk(d: &Path, out: &mut Vec<(PathBuf, Vec<u8>)>) {
827            for e in std::fs::read_dir(d).into_iter().flatten().flatten() {
828                let p = e.path();
829                if p.is_dir() { walk(&p, out) } else { out.push((p.clone(), std::fs::read(&p).unwrap())) }
830            }
831        }
832        let mut out = Vec::new();
833        walk(home, &mut out);
834        out.sort();
835        out
836    }
837
838    /// Applies every visible harness's plan as `run` does; the number of files changed.
839    fn install_all(t: &Targets, bin: &Path, action: Action, stamp: &str) -> usize {
840        let mut n = 0;
841        for h in harness::visible() {
842            for c in plan(action, h, t, bin).unwrap().0 {
843                apply(&c, stamp).unwrap();
844                n += 1;
845            }
846            t.skill_dir(h).inspect(|d| skill::tidy(d));
847        }
848        n
849    }
850
851    /// The files a full install writes: one config or adapter per harness, and the skill's files
852    /// wherever the harness loads skills.
853    fn files_per_install(t: &Targets) -> usize {
854        harness::visible().map(|h| 1 + t.skill_dir(h).map_or(0, |_| skill::FILES.len())).sum()
855    }
856
857    #[test]
858    fn install_preserves_existing_hooks_and_settings() {
859        let home = tmp("preserve");
860        let t = Targets::under(&home);
861        std::fs::create_dir_all(t.path(CLAUDE).parent().unwrap()).unwrap();
862        std::fs::write(t.path(CLAUDE), EXISTING).unwrap();
863        let bin = Path::new("/opt/borg dir/borg");
864        assert_eq!(install_all(&t, bin, Action::Install, "s1"), files_per_install(&t), "one config per harness, and the skill");
865
866        let v: Value = serde_json::from_str(&std::fs::read_to_string(t.path(CLAUDE)).unwrap()).unwrap();
867        assert_eq!(v["model"], "opus");
868        assert_eq!(v["zz_last"], true);
869        assert_eq!(v["permissions"]["allow"][0], "Bash(git status)");
870        assert_eq!(v["hooks"]["PreToolUse"][0]["hooks"][0]["command"], "/usr/local/bin/guard");
871        assert_eq!(v["hooks"]["Stop"][0]["hooks"][0]["command"], "notify-send done", "their Stop hook stays first");
872        assert_eq!(v["hooks"]["Stop"][1]["hooks"][0]["command"], "'/opt/borg dir/borg' hook claude stop");
873        assert_eq!(v["hooks"]["Stop"][1]["hooks"][0]["timeout"], harness::STOP_TIMEOUT_S, "the stop hook's budget is explicit");
874        assert!(v["hooks"]["PostToolUse"][0]["hooks"][0].get("timeout").is_none());
875        // An install from before the timeout is brought up to it.
876        let old = r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"/b/borg hook claude stop"}]}]}}"#;
877        let up: Value = serde_json::from_str(&claude_hooks().install(Some(old), Path::new("/b/borg")).unwrap().unwrap()).unwrap();
878        assert_eq!(up["hooks"]["Stop"].as_array().unwrap().len(), 1);
879        assert_eq!(up["hooks"]["Stop"][0]["hooks"][0]["timeout"], harness::STOP_TIMEOUT_S);
880        let keys: Vec<&String> = v.as_object().unwrap().keys().collect();
881        assert_eq!(keys, ["model", "permissions", "hooks", "zz_last"], "key order kept");
882        let wired = claude_hooks().wired(Some(&std::fs::read_to_string(t.path(CLAUDE)).unwrap()));
883        assert_eq!(wired.len(), claude::EVENTS.len());
884        assert!(wired.iter().all(|(_, b)| b == bin), "quoted path parsed back");
885        assert_eq!(std::fs::read_to_string(t.path(OMP)).unwrap(), omp::ADAPTER);
886        assert_eq!(std::fs::read_to_string(t.path(OPENCODE)).unwrap(), opencode::ADAPTER);
887        let backup = t.path(CLAUDE).with_file_name("settings.json.borg-backup-s1");
888        assert_eq!(std::fs::read_to_string(backup).unwrap(), EXISTING, "backup of the original");
889    }
890
891    #[test]
892    fn install_is_idempotent_and_replaces_a_stale_binary() {
893        let home = tmp("idem");
894        let t = Targets::under(&home);
895        let bin = Path::new("/usr/bin/borg");
896        assert_eq!(install_all(&t, bin, Action::Install, "a"), files_per_install(&t), "one config per harness, and the skill");
897        let snapshot = all_files(&home);
898        assert_eq!(install_all(&t, bin, Action::Install, "b"), 0, "second install changes nothing");
899        assert_eq!(all_files(&home), snapshot, "and writes nothing, not even a backup");
900
901        // A moved binary: each event points at the new one, never twice.
902        let text = claude_hooks().install(read(&t.path(CLAUDE)).unwrap().as_deref(), Path::new("/new/borg")).unwrap().unwrap();
903        let wired = claude_hooks().wired(Some(&text));
904        assert_eq!(wired.len(), claude::EVENTS.len());
905        assert!(wired.iter().all(|(_, b)| b == Path::new("/new/borg")));
906    }
907
908    #[test]
909    fn uninstall_restores_the_original_exactly() {
910        for (name, original) in [("existing", Some(EXISTING)), ("existing-nl", Some(&*format!("{EXISTING}\n"))), ("absent", None)] {
911            let home = tmp(&format!("restore-{name}"));
912            let t = Targets::under(&home);
913            if let Some(o) = original {
914                std::fs::create_dir_all(t.path(CLAUDE).parent().unwrap()).unwrap();
915                std::fs::write(t.path(CLAUDE), o).unwrap();
916            }
917            install_all(&t, Path::new("/usr/bin/borg"), Action::Install, "i");
918            assert_eq!(install_all(&t, Path::new("/elsewhere/borg"), Action::Uninstall, "u"), files_per_install(&t), "everything install wrote");
919            assert_eq!(read(&t.path(CLAUDE)).unwrap().as_deref(), original, "{name}: byte for byte");
920            assert!(!t.path(OMP).exists() && !t.path(OPENCODE).exists() && !t.path(&crate::harness::codex::Codex).exists());
921            for h in harness::visible() {
922                assert!(t.skill_dir(h).is_none_or(|d| !d.exists()), "{name}: {}'s skill directory removed", h.name());
923            }
924            assert_eq!(install_all(&t, Path::new("/usr/bin/borg"), Action::Uninstall, "u2"), 0, "nothing left to remove");
925        }
926    }
927
928    #[test]
929    fn uninstall_leaves_foreign_files_alone() {
930        let home = tmp("foreign");
931        let t = Targets::under(&home);
932        std::fs::create_dir_all(t.path(OMP).parent().unwrap()).unwrap();
933        std::fs::write(t.path(OMP), "// my own borg.ts").unwrap();
934        let (change, notes) = plan(Action::Uninstall, OMP, &t, Path::new("/b/borg")).unwrap();
935        assert!(change.is_empty());
936        assert_eq!(notes.len(), 1);
937        // The engineer's own file in borg's skill directory: left alone, and so is the directory.
938        let skill = t.skill_dir(CLAUDE).unwrap();
939        std::fs::create_dir_all(&skill).unwrap();
940        std::fs::write(skill.join("SKILL.md"), "my own skill").unwrap();
941        std::fs::write(skill.join("notes.md"), "mine").unwrap();
942        install_all(&t, Path::new("/b/borg"), Action::Install, "i");
943        assert_eq!(install_all(&t, Path::new("/b/borg"), Action::Uninstall, "u"), files_per_install(&t));
944        assert_eq!(std::fs::read_to_string(skill.join("notes.md")).unwrap(), "mine");
945        assert_eq!(std::fs::read_to_string(skill.join("SKILL.md.borg-backup-i")).unwrap(), "my own skill", "replaced with a backup");
946        // A hook that merely mentions borg but is not borg's own command stays.
947        let s = r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"/x/notborg hook claude stop"}]}]}}"#;
948        assert_eq!(claude_hooks().uninstall(Some(s)).unwrap(), None);
949    }
950
951    #[test]
952    fn dry_run_writes_nothing() {
953        let home = tmp("dry");
954        let t = Targets::under(&home);
955        std::fs::create_dir_all(t.path(CLAUDE).parent().unwrap()).unwrap();
956        std::fs::write(t.path(CLAUDE), EXISTING).unwrap();
957        let before = all_files(&home);
958        assert_eq!(run(Action::Install, &harness::visible().collect::<Vec<_>>(), &t, Path::new("/usr/bin/borg"), false), 0);
959        assert_eq!(all_files(&home), before);
960        let (c, _) = plan(Action::Install, CLAUDE, &t, Path::new("/usr/bin/borg")).unwrap();
961        let c = &c[0];
962        let d = diff(&c.path, c.before.as_deref(), c.after.as_deref());
963        assert!(d.contains("+            \"command\": \"/usr/bin/borg hook claude stop\""), "{d}");
964        assert!(!d.lines().any(|l| l.starts_with('-') && !l.starts_with("---")), "pure addition:\n{d}");
965    }
966
967    #[test]
968    fn invalid_settings_are_never_overwritten() {
969        assert!(claude_hooks().install(Some("{ not json"), Path::new("/b/borg")).is_err());
970        assert!(claude_hooks().install(Some(r#"{"hooks": []}"#), Path::new("/b/borg")).is_err());
971    }
972
973    #[cfg(unix)]
974    #[test]
975    fn adapter_symlink_is_replaced_not_written_through() {
976        let home = tmp("link");
977        let t = Targets::under(&home);
978        let src = home.join("checkout-borg.ts");
979        std::fs::write(&src, "// older adapter in a checkout").unwrap();
980        std::fs::create_dir_all(t.path(OMP).parent().unwrap()).unwrap();
981        std::os::unix::fs::symlink(&src, t.path(OMP)).unwrap();
982        let (c, _) = plan(Action::Install, OMP, &t, Path::new("/b/borg")).unwrap();
983        apply(&c[0], "x").unwrap();
984        assert_eq!(std::fs::read_to_string(&src).unwrap(), "// older adapter in a checkout");
985        assert_eq!(std::fs::read_to_string(t.path(OMP)).unwrap(), omp::ADAPTER);
986    }
987
988    #[test]
989    fn status_counts_a_store() {
990        let home = tmp("stats");
991        let w = |f: &str, s: &str| std::fs::write(home.join(f), s).unwrap();
992        w("episodes.jsonl", "{\"ms\":5}\n{\"ms\":9}\n");
993        w("lessons.jsonl", "");
994        w(
995            "outcomes.jsonl",
996            "{\"kind\":\"asked\",\"session\":\"s\",\"lessons\":[\"L1\"],\"ms\":3,\"repo\":\"r\"}\n{\"kind\":\"asked\",\"session\":\"s\",\"lessons\":[\"L2\"],\"ms\":7,\"repo\":\"r\"}\n",
997        );
998        let s = stats(&home);
999        assert_eq!((s.lessons, s.episodes, s.outcomes, s.last_capture_ms), (0, 2, 2, Some(9)));
1000        assert_eq!(s.last_asked, Some((7, vec!["L2".to_string()])));
1001        assert_eq!(stats(&home.join("absent")), Stats::default());
1002        let cfg: collective::Config =
1003            serde_json::from_str(r#"{"collectives":[{"name":"corp","paths":["/w"]},{"name":"mine","paths":["/p"],"share":"local"}]}"#).unwrap();
1004        let rs = collectives(Some(&cfg), Path::new("/b"));
1005        assert_eq!(rs.iter().map(collective::may_sync).collect::<Vec<_>>(), [true, false]);
1006        assert_eq!(rs[1], Route::Collective { name: "mine".into(), home: "/b/collectives/mine".into(), share: Share::Local });
1007        assert_eq!(utc(0), "19700101T000000");
1008        assert_eq!(utc(1_791_590_400_000), "20261010T000000");
1009    }
1010
1011    #[test]
1012    fn doctor_hints_at_emails_sharing_a_git_name_until_they_are_one_engineer() {
1013        let home = tmp("aliases");
1014        let line = |v: Value| format!("{v}\n");
1015        std::fs::write(
1016            home.join("lessons.jsonl"),
1017            line(json!({"id": "a", "origin_ms": 1, "repo": "r", "question": "q", "situations": [], "consequence": "", "support": 1,
1018                "episodes": [], "history": [], "version": 2, "engineers": ["Dana.Example@corp.example", "ann@x"], "joined": {"ann@x": 5}})),
1019        )
1020        .unwrap();
1021        std::fs::write(home.join("episodes.jsonl"), line(json!({"ms": 1, "engineer": "da@home.example"})) + &line(json!({"ms": 2, "engineer": ""}))).unwrap();
1022        let emails = store_emails(&home);
1023        assert_eq!(emails.iter().map(String::as_str).collect::<Vec<_>>(), ["Dana.Example@corp.example", "ann@x", "da@home.example"]);
1024        let ids = |v: &[(&str, &str)]| v.iter().map(|(n, e)| (n.to_string(), e.to_string())).collect::<Vec<_>>();
1025        let known =
1026            ids(&[("Dana Example", "dana.example@corp.example"), ("Dana Example", "da@home.example"), ("Ann", "ann@x"), ("Dana Example", "elsewhere@x")]);
1027        let hints = alias_hints(&emails, &known, &collective::Aliases::default());
1028        assert_eq!(hints.len(), 1, "{hints:?}");
1029        assert!(
1030            hints[0].contains("\"Dana Example\"") && hints[0].contains("Dana.Example@corp.example") && hints[0].contains("da@home.example"),
1031            "{}",
1032            hints[0]
1033        );
1034        assert!(!hints[0].contains("elsewhere@x"), "only emails the stores have");
1035        let cfg: collective::Config =
1036            serde_json::from_str(r#"{"engineers": [{"name": "dana", "emails": ["da@home.example", "Dana.Example@corp.example"]}]}"#).unwrap();
1037        assert!(alias_hints(&emails, &known, &cfg.aliases()).is_empty(), "already one engineer");
1038        assert!(alias_hints(&emails, &ids(&[("Ann", "ann@x"), ("Dana Example", "da@home.example")]), &collective::Aliases::default()).is_empty());
1039    }
1040
1041    #[test]
1042    fn doctor_flags_a_missing_binary_and_an_invalid_config() {
1043        let home = tmp("doctor");
1044        let t = Targets::under(&home);
1045        plan(Action::Install, CLAUDE, &t, Path::new("/nonexistent/borg")).unwrap().0.iter().for_each(|c| drop(apply(c, "d").unwrap()));
1046        let checks = wiring_checks(&t, true);
1047        assert!(checks.iter().any(|(l, m)| *l == Level::Fail && m.contains("/nonexistent/borg")), "{checks:?}");
1048        let bad = collective_checks(&Err("x.json: expected value".into()), &home);
1049        assert_eq!(bad.len(), 1);
1050        assert!(bad[0].0 == Level::Fail && bad[0].1.contains("EVERY repo routes OFF"));
1051        assert!(collective_checks(&Ok(None), &home.join("store")).iter().all(|(l, _)| *l == Level::Ok));
1052    }
1053
1054    #[test]
1055    fn doctor_names_a_leftover_hive_and_its_removal_but_leaves_it() {
1056        let base = tmp("hive-orphan");
1057        let store = collectives(None, &base)
1058            .into_iter()
1059            .find_map(|r| match r {
1060                Route::Collective { home, .. } => Some(home),
1061                Route::Off => None,
1062            })
1063            .unwrap();
1064        std::fs::create_dir_all(store.join("hive/g1")).unwrap();
1065        let warn: Vec<String> = collective_checks(&Ok(None), &base).into_iter().filter(|(l, _)| *l == Level::Warn).map(|(_, m)| m).collect();
1066        assert!(
1067            matches!(warn.as_slice(), [m] if m.contains("removed feature") && m.contains(&format!("rm -r '{}'", store.join("hive").display()))),
1068            "{warn:?}"
1069        );
1070        assert!(store.join("hive/g1").is_dir(), "never deleted by doctor");
1071    }
1072
1073    #[cfg(unix)]
1074    fn chmod(p: &Path, mode: u32) {
1075        use std::os::unix::fs::PermissionsExt;
1076        std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode)).unwrap();
1077    }
1078
1079    #[test]
1080    fn full_claude_install_reports_wired_not_partial() {
1081        let home = tmp("wired-status");
1082        let t = Targets::under(&home);
1083        assert_eq!(run(Action::Install, &[CLAUDE], &t, Path::new("/b/borg"), true), 0);
1084        assert!(wiring(CLAUDE, &t).starts_with("wired: "), "{}", wiring(CLAUDE, &t));
1085    }
1086
1087    #[cfg(unix)]
1088    #[test]
1089    fn unreadable_files_are_errors_not_absent() {
1090        let home = tmp("unreadable");
1091        let t = Targets::under(&home);
1092        std::fs::create_dir_all(t.path(CLAUDE).parent().unwrap()).unwrap();
1093        let precious = r#"{"model":"precious"}"#;
1094        std::fs::write(t.path(CLAUDE), precious).unwrap();
1095        chmod(&t.path(CLAUDE), 0o000);
1096        if std::fs::read(t.path(CLAUDE)).is_ok() {
1097            chmod(&t.path(CLAUDE), 0o600);
1098            return; // running as root: permissions do not apply
1099        }
1100        let before = std::fs::read_dir(t.path(CLAUDE).parent().unwrap()).unwrap().count();
1101        for action in [Action::Install, Action::Uninstall] {
1102            assert!(plan(action, CLAUDE, &t, Path::new("/b/borg")).is_err());
1103        }
1104        assert_eq!(run(Action::Install, &[CLAUDE], &t, Path::new("/b/borg"), true), 1);
1105        assert!(wiring(CLAUDE, &t).starts_with("UNREADABLE"));
1106        assert!(wiring_checks(&t, true).iter().any(|(l, m)| *l == Level::Fail && m.contains("cannot read")));
1107        chmod(&t.path(CLAUDE), 0o600);
1108        assert_eq!(std::fs::read_to_string(t.path(CLAUDE)).unwrap(), precious, "untouched");
1109        assert_eq!(std::fs::read_dir(t.path(CLAUDE).parent().unwrap()).unwrap().count(), before, "no new files");
1110
1111        // Not UTF-8: an adapter file someone else wrote, likewise never replaced.
1112        std::fs::create_dir_all(t.path(OMP).parent().unwrap()).unwrap();
1113        std::fs::write(t.path(OMP), [0xff, 0xfe, 0x00]).unwrap();
1114        assert!(plan(Action::Install, OMP, &t, Path::new("/b/borg")).is_err());
1115        assert_eq!(std::fs::read(t.path(OMP)).unwrap(), [0xff, 0xfe, 0x00]);
1116    }
1117
1118    #[test]
1119    fn a_binary_not_named_borg_is_refused() {
1120        let home = tmp("borg-dev");
1121        let t = Targets::under(&home);
1122        for bin in ["/x/borg-dev", "/x/borg-ai", "/x/notborg"] {
1123            let e = plan(Action::Install, CLAUDE, &t, Path::new(bin)).unwrap_err();
1124            assert!(e.contains("BORG_BIN"), "{e}");
1125        }
1126        assert!(!t.path(CLAUDE).exists());
1127        // Named borg: installed once, however often it runs.
1128        for stamp in ["1", "2", "3"] {
1129            install_all(&t, Path::new("/x/dev/borg"), Action::Install, stamp);
1130        }
1131        let text = std::fs::read_to_string(t.path(CLAUDE)).unwrap();
1132        assert_eq!(text.matches("hook claude stop").count(), 1);
1133        assert_eq!(claude_hooks().wired(Some(&text)).len(), claude::EVENTS.len());
1134    }
1135
1136    #[cfg(unix)]
1137    #[test]
1138    fn permissions_are_kept_and_new_files_are_private() {
1139        use std::os::unix::fs::PermissionsExt;
1140        let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
1141        let home = tmp("perms");
1142        let t = Targets::under(&home);
1143        std::fs::create_dir_all(t.path(CLAUDE).parent().unwrap()).unwrap();
1144        std::fs::write(t.path(CLAUDE), r#"{"env":{"SECRET":"x"}}"#).unwrap();
1145        chmod(&t.path(CLAUDE), 0o600);
1146        let (c, _) = plan(Action::Install, CLAUDE, &t, Path::new("/usr/bin/borg")).unwrap();
1147        let backup = apply(&c[0], "p").unwrap().unwrap();
1148        assert_eq!(mode(&t.path(CLAUDE)), 0o600, "settings stay 0600");
1149        assert_eq!(mode(&backup), 0o600, "backup is 0600");
1150
1151        chmod(&t.path(CLAUDE), 0o640);
1152        let (c, _) = plan(Action::Uninstall, CLAUDE, &t, Path::new("/usr/bin/borg")).unwrap();
1153        apply(&c[0], "q").unwrap();
1154        assert_eq!(mode(&t.path(CLAUDE)), 0o640, "whatever the original had");
1155
1156        let fresh = tmp("perms-new");
1157        let t = Targets::under(&fresh);
1158        let (c, _) = plan(Action::Install, CLAUDE, &t, Path::new("/usr/bin/borg")).unwrap();
1159        apply(&c[0], "n").unwrap();
1160        assert_eq!(mode(&t.path(CLAUDE)), 0o600, "a new settings.json is private");
1161    }
1162
1163    #[test]
1164    fn a_backup_is_never_overwritten() {
1165        let home = tmp("backups");
1166        let t = Targets::under(&home);
1167        std::fs::create_dir_all(t.path(CLAUDE).parent().unwrap()).unwrap();
1168        let orig = r#"{"model":"orig"}"#;
1169        std::fs::write(t.path(CLAUDE), orig).unwrap();
1170        // Install then uninstall within one stamp (the same second).
1171        let (c, _) = plan(Action::Install, CLAUDE, &t, Path::new("/usr/bin/borg")).unwrap();
1172        let b1 = apply(&c[0], "same").unwrap().unwrap();
1173        let (c, _) = plan(Action::Uninstall, CLAUDE, &t, Path::new("/usr/bin/borg")).unwrap();
1174        let b2 = apply(&c[0], "same").unwrap().unwrap();
1175        assert_ne!(b1, b2);
1176        assert_eq!(std::fs::read_to_string(&b1).unwrap(), orig, "the original survives");
1177        assert!(std::fs::read_to_string(&b2).unwrap().contains("hook claude stop"));
1178        assert!(b2.to_string_lossy().ends_with("borg-backup-same-1"));
1179    }
1180
1181    #[test]
1182    fn home_overrides_cover_store_and_config() {
1183        for cmd in ["install", "uninstall", "setup", "status", "doctor"] {
1184            let v = home_overrides(cmd);
1185            assert!(v.contains(&"BORG_HOME") && v.contains(&"BORG_CONFIG") && v.contains(&"BORG_VECTOR_MODEL"), "{cmd}");
1186        }
1187        assert!(home_overrides("doctor").contains(&"BORG_BIN"));
1188        assert!(!home_overrides("install").contains(&"BORG_BIN"), "install still wires $BORG_BIN");
1189    }
1190
1191    /// A harness with a step of its own in the harness (as Codex's hook trust will be).
1192    /// A harness with its own install notes and doctor check; its CLI is `.0`.
1193    struct Noted(&'static str);
1194
1195    impl Harness for Noted {
1196        fn name(&self) -> &'static str {
1197            "noted"
1198        }
1199        fn caps(&self) -> harness::Caps {
1200            CLAUDE.caps()
1201        }
1202        fn wiring(&self) -> Wiring {
1203            CLAUDE.wiring()
1204        }
1205        fn target(&self, home: &Path, _var: &dyn Fn(&str) -> Option<PathBuf>) -> PathBuf {
1206            home.join(".noted/hooks.json")
1207        }
1208        fn probe(&self) -> harness::Probe {
1209            harness::Probe { cli: self.0, tested: "" }
1210        }
1211        fn checks(&self, target: &Path) -> Vec<(Level, String)> {
1212            vec![(Level::Warn, format!("noted: {} is not trusted", target.display()))]
1213        }
1214        fn install_notes(&self) -> Vec<String> {
1215            vec!["run /hooks in noted and trust borg".into()]
1216        }
1217    }
1218
1219    #[test]
1220    fn a_harness_adds_its_own_install_notes_and_doctor_checks() {
1221        let home = tmp("noted");
1222        let t = Targets::under(&home);
1223        let (change, notes) = plan(Action::Install, &Noted("git"), &t, Path::new("/b/borg")).unwrap();
1224        assert_eq!(change.len(), 1 + skill::FILES.len(), "the hook config and the skill");
1225        assert_eq!(notes, ["run /hooks in noted and trust borg"]);
1226        assert_eq!(plan(Action::Uninstall, &Noted("git"), &t, Path::new("/b/borg")).unwrap().1, Vec::<String>::new());
1227        let checks = harness_checks([&Noted("git") as &dyn Harness].into_iter(), &t);
1228        assert!(checks[0].0 == Level::Ok && checks[0].1.starts_with("git: git version"), "{checks:?}");
1229        assert_eq!(checks[1], (Level::Warn, format!("noted: {} is not trusted", home.join(".noted/hooks.json").display())));
1230        let missing = harness_checks([&Noted("borg-test-no-such-cli") as &dyn Harness].into_iter(), &t);
1231        assert_eq!(missing, [(Level::Ok, "borg-test-no-such-cli: not installed".into())], "no CLI to ask: its own checks wait");
1232    }
1233}