1pub 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
36fn 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#[derive(Debug, Clone)]
46pub struct Targets {
47 home: PathBuf,
48 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 pub fn under(home: &Path) -> Self {
59 Targets { home: home.to_path_buf(), env: false }
60 }
61
62 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 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
77pub 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
110pub 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#[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 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
157pub 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
165pub 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
174fn 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(), };
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 let m = crate::live::of(cfg.as_ref().ok().and_then(Option::as_ref), name);
218 let off = (m.mode == crate::live::Mode::Live).then(crate::live::unsupported).flatten();
219 let note = match (&m.warning, off) {
220 (Some(_), _) => "; unknown value, see borg doctor".to_string(),
221 (None, Some(why)) => format!("; off here: {why} replaces the consequence model, so checks run at the turn end only"),
222 (None, None) => String::new(),
223 };
224 println!(" mode {} ({}){note}", m.mode.name(), m.source);
225 println!(" lessons {}", s.lessons);
226 let (absorbed, recurred) = crate::memory_export::summary(home);
228 println!(" absorbed {absorbed}, recurred {recurred}");
229 println!(" episodes {}", s.episodes);
230 println!(" outcomes {}", s.outcomes);
231 println!(" queen open {}", crate::queen::open(home).len());
232 println!(" trust {}", crate::trust::summary(home));
233 println!(" last capture {}", s.last_capture_ms.map(human).unwrap_or_else(|| "never".into()));
234 let sources: Vec<String> = crate::sweep::sources(home).into_iter().map(|(k, (n, ms))| format!("{k} {n} (last {})", human(ms))).collect();
236 println!(" captures {}", if sources.is_empty() { "none yet".to_string() } else { sources.join("; ") });
237 let g = crate::prefilter::tally(&home.join("state"));
238 println!(" stop gate {} turn ends skipped at 0 tokens, {} asked the model (${:.2} so far)", g.skipped, g.asked, g.cost);
239 if g.live_checks > 0 {
240 println!(" live checks {} during the turn, {} lessons read (${:.2} so far)", g.live_checks, g.live_lessons, g.live_cost);
241 }
242 println!(
243 " cost {} extra rounds (Stop blocks, {} questions, ~{} tokens); {} free deliveries ({} lessons, ~{} tokens, no extra round)",
244 g.rounds,
245 g.questions,
246 g.block_chars / crate::whole::CHARS_PER_TOKEN,
247 g.free,
248 g.free_lessons,
249 g.free_chars / crate::whole::CHARS_PER_TOKEN
250 );
251 println!(
252 " judge {} model calls also judged {} asked lessons ({} settled; ~{} prompt tokens added, no extra call)",
253 g.judge_calls,
254 g.judged,
255 g.judge_resolved,
256 g.judge_chars / crate::whole::CHARS_PER_TOKEN
257 );
258 let selection = crate::top::selection(&home.join("lessons.jsonl"), crate::select::policy());
260 println!(" selection {}", crate::top::selection_line(&selection));
261 match s.last_asked {
262 None => println!(" last asked never"),
263 Some((ms, ids)) => {
264 println!(" last asked {}", human(ms));
265 for l in ids {
266 println!(" {l}");
267 }
268 }
269 }
270 }
271 println!("\nversion");
273 println!(" this borg {}", crate::update::VERSION);
274 println!(" latest {}", crate::update::release_line(crate::update::checked().as_ref(), learn::now_ms()));
275 for r in routes.iter().filter(|r| collective::may_sync(r)) {
276 let Route::Collective { name, home, .. } = r else { continue };
277 for l in crate::update::member_lines(home) {
278 println!(" {name:<12} {l}");
279 }
280 }
281 println!("\nharnesses");
282 for &h in harness::ALL {
283 let line = wiring(h, t);
284 if !h.hidden() || !line.starts_with("not wired") {
286 println!(" {:<9} {line}", h.name());
287 }
288 }
289}
290
291#[cfg(unix)]
294fn executable(p: &Path) -> bool {
295 use std::os::unix::fs::PermissionsExt;
296 std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
297}
298
299#[cfg(not(unix))]
300fn executable(p: &Path) -> bool {
301 p.is_file()
302}
303
304pub fn which(name: &str) -> Option<PathBuf> {
305 std::env::split_paths(&std::env::var_os("PATH")?).map(|d| d.join(name)).find(|p| executable(p))
306}
307
308#[must_use]
310pub fn sibling(part: &str) -> Option<PathBuf> {
311 let bin = format!("borg-{part}");
312 let beside = std::env::current_exe().ok().map(|e| e.with_file_name(&bin)).filter(|p| p.exists());
313 beside.or_else(|| which(&bin))
314}
315
316pub(crate) fn version(cli: &Path) -> Option<String> {
318 let mut child = std::process::Command::new(cli)
319 .arg("--version")
320 .stdin(std::process::Stdio::null())
321 .stdout(std::process::Stdio::piped())
322 .stderr(std::process::Stdio::null())
323 .spawn()
324 .ok()?;
325 for _ in 0..100 {
326 if child.try_wait().ok()?.is_some() {
327 let out = child.wait_with_output().ok()?;
328 return String::from_utf8_lossy(&out.stdout).lines().next().map(|l| l.trim().to_string());
329 }
330 std::thread::sleep(Duration::from_millis(50));
331 }
332 let _ = child.kill();
333 None
334}
335
336pub fn wiring_checks(t: &Targets, borg_found: bool) -> Vec<(Level, String)> {
339 let mut out = Vec::new();
340 for &h in harness::ALL {
342 let found = match Hooks::of(h) {
343 Some(k) => hook_checks(h, &k, t),
344 None => adapter_checks(h, t, borg_found),
345 };
346 let skill = skill::checks(h.name(), t.skill_dir(h).as_deref(), !found.is_empty());
347 out.extend(found.into_iter().chain(skill));
348 }
349 if out.is_empty() {
350 out.push((Level::Warn, "no harness is wired to borg (borg install all)".into()));
351 }
352 out
353}
354
355fn hook_checks(h: &dyn Harness, k: &Hooks, t: &Targets) -> Vec<(Level, String)> {
357 let (name, path) = (h.name(), t.path(h));
358 let mut out = Vec::new();
359 let text = read(&path).unwrap_or_else(|e| {
360 out.push((Level::Fail, format!("{name}: {}: cannot read ({e}); borg install will not touch it", path.display())));
361 None
362 });
363 if text.is_some()
364 && let Err(e) = k.parse(text.as_deref())
365 {
366 out.push((Level::Fail, format!("{name}: {}: {e}", path.display())));
367 }
368 let wired = k.wired(text.as_deref());
369 if wired.is_empty() {
370 return out;
371 }
372 let missing = k.missing(&wired);
373 if !missing.is_empty() {
374 out.push((Level::Warn, format!("{name}: hooks missing for {} (borg install {name})", missing.join(", "))));
375 }
376 let leftover = k.leftover(text.as_deref());
377 if !leftover.is_empty() {
378 out.push((
379 Level::Warn,
380 format!("{name}: an older borg's hooks for {} are still wired (borg install {name} --apply removes them)", leftover.join(", ")),
381 ));
382 }
383 for (ev, bin) in &wired {
384 if !executable(bin) {
385 out.push((
386 Level::Fail,
387 format!("{name}: hook {ev} runs {}, which does not exist or is not executable (borg install {name} --apply)", bin.display()),
388 ));
389 }
390 }
391 if out.iter().all(|(l, _)| *l == Level::Ok) {
392 out.push((Level::Ok, format!("{name}: hooks wired to {}", wired[0].1.display())));
393 }
394 out
395}
396
397fn adapter_checks(h: &dyn Harness, t: &Targets, borg_found: bool) -> Vec<(Level, String)> {
399 let (name, path) = (h.name(), t.path(h));
400 let text = match read(&path) {
401 Ok(Some(text)) => text,
402 Ok(None) => return Vec::new(),
403 Err(e) => return vec![(Level::Fail, format!("{name}: {}: cannot read ({e})", path.display()))],
404 };
405 let mut out = Vec::new();
406 if text != h.wiring().adapter() {
407 out.push((Level::Warn, format!("{name}: {} differs from this borg's adapter (borg install {name})", path.display())));
408 } else {
409 out.push((Level::Ok, format!("{name}: adapter installed at {}", path.display())));
410 }
411 if !borg_found {
412 out.push((Level::Fail, format!("{name}: the adapter finds borg via $BORG_BIN or PATH, and neither has one: it silently does nothing")));
413 }
414 out
415}
416
417pub fn collective_checks(cfg: &Result<Option<collective::Config>, String>, base: &Path) -> Vec<(Level, String)> {
419 let mut out = Vec::new();
420 let cfg = match cfg {
421 Err(e) => {
422 out.push((
423 Level::Fail,
424 format!("COLLECTIVES CONFIG INVALID ({e}): EVERY repo routes OFF, borg captures and asks NOTHING anywhere until it is fixed"),
425 ));
426 return out;
427 }
428 Ok(c) => c.as_ref(),
429 };
430 match cfg {
431 None => out.push((Level::Ok, format!("collectives: no config at {}, every repo is in \"default\"", collective::config_path().display()))),
432 Some(c) => {
433 if c.default != "off" && !c.collectives.iter().any(|e| e.name == c.default) {
434 out.push((Level::Warn, format!("collectives: default \"{}\" names no collective, so unmatched repos route off", c.default)));
435 }
436 let mut names: Vec<&str> = c.collectives.iter().map(|e| e.name.as_str()).collect();
437 names.sort();
438 if names.windows(2).any(|w| w[0] == w[1]) {
439 out.push((Level::Warn, "collectives: a name appears twice; their stores are shared".into()));
440 }
441 out.push((Level::Ok, format!("collectives: {} configured ({})", c.collectives.len(), names.join(", "))));
442 }
443 }
444 for r in collectives(cfg, base) {
445 let Route::Collective { name, home, .. } = &r else { continue };
446 let m = crate::live::of(cfg, name);
447 match m.warning {
448 Some(w) => out.push((Level::Warn, format!("collective {name}: {w}"))),
449 None => out.push((Level::Ok, format!("collective {name}: delivery mode {} ({})", m.mode.name(), m.source))),
450 }
451 if m.mode == crate::live::Mode::Live
454 && let Some(why) = crate::live::unsupported()
455 {
456 out.push((Level::Warn, format!("collective {name}: live mode is off while {why} is set; its drones check at the turn end only (hook mode)")));
457 }
458 out.extend(orphans::store_checks(name, home));
459 let probe = home.join(".borg-doctor-probe");
460 let ok = std::fs::create_dir_all(home).and_then(|_| std::fs::write(&probe, b"ok")).and_then(|_| std::fs::remove_file(&probe));
461 match ok {
462 Ok(()) => out.push((Level::Ok, format!("store {name}: {} writable", home.display()))),
463 Err(e) => out.push((Level::Fail, format!("store {name}: {} not writable ({e})", home.display()))),
464 }
465 }
466 out
467}
468
469pub fn store_emails(home: &Path) -> BTreeSet<String> {
471 let mut out = BTreeSet::new();
472 for l in learn::lessons(&home.join("lessons.jsonl")) {
473 out.extend(l.engineers.into_iter().chain(l.joined.into_keys()));
474 }
475 let episodes = read(&home.join("episodes.jsonl")).ok().flatten().unwrap_or_default();
476 for v in episodes.lines().filter_map(|l| serde_json::from_str::<Value>(l).ok()) {
477 out.extend(v.get("engineer").and_then(Value::as_str).map(str::to_string));
478 }
479 out.retain(|e| !e.trim().is_empty());
480 out
481}
482
483pub fn alias_hints(emails: &BTreeSet<String>, identities: &[(String, String)], aliases: &collective::Aliases) -> Vec<String> {
486 let stored: HashMap<String, &String> = emails.iter().map(|e| (e.trim().to_lowercase(), e)).collect();
487 let mut by_name: BTreeMap<String, (&str, BTreeMap<String, &str>)> = BTreeMap::new();
489 for (name, email) in identities {
490 let Some(raw) = stored.get(&email.trim().to_lowercase()) else { continue };
491 if name.trim().is_empty() {
492 continue;
493 }
494 let entry = by_name.entry(name.trim().to_lowercase()).or_insert((name.trim(), BTreeMap::new()));
495 entry.1.entry(aliases.canon(raw)).or_insert(raw.as_str());
496 }
497 by_name
498 .into_values()
499 .filter(|(_, ids)| ids.len() > 1)
500 .map(|(name, ids)| {
501 let listed: Vec<String> = ids.values().map(|e| format!("{e:?}")).collect();
502 format!(
503 "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",
504 listed.join(" and "),
505 listed.join(", "),
506 collective::config_path().display()
507 )
508 })
509 .collect()
510}
511
512fn git_identities() -> Vec<(String, String)> {
515 let git = |args: &[&str]| {
516 std::process::Command::new("git")
517 .args(args)
518 .stdin(std::process::Stdio::null())
519 .stderr(std::process::Stdio::null())
520 .output()
521 .ok()
522 .filter(|o| o.status.success())
523 .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
524 .unwrap_or_default()
525 };
526 let mut out: Vec<(String, String)> =
527 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();
528 for scope in [&["config", "--global"][..], &["config"][..]] {
529 let get = |k: &str| git(&[scope, &[k]].concat()).trim().to_string();
530 out.push((get("user.name"), get("user.email")));
531 }
532 out.sort();
533 out.dedup();
534 out
535}
536
537fn harness_checks<'a>(hs: impl Iterator<Item = &'a dyn Harness>, t: &Targets) -> Vec<(Level, String)> {
540 let mut checks = Vec::new();
541 for h in hs {
542 let harness::Probe { cli, tested } = h.probe();
543 match which(cli) {
544 None => checks.push((Level::Ok, format!("{cli}: not installed"))),
545 Some(p) => match version(&p) {
546 Some(v) => {
547 checks.push((Level::Ok, format!("{cli}: {v}{tested}")));
548 checks.extend(h.checks(&t.path(h)));
549 }
550 None => checks.push((Level::Warn, format!("{cli}: {} did not answer --version", p.display()))),
551 },
552 }
553 }
554 checks
555}
556
557fn doctor(t: &Targets) -> i32 {
558 let mut checks = Vec::new();
559 let env_bin = std::env::var_os("BORG_BIN").filter(|v| !v.is_empty()).map(PathBuf::from);
560 let on_path = which("borg");
561 match (&env_bin, &on_path) {
562 (Some(b), _) if !executable(b) => checks.push((Level::Fail, format!("BORG_BIN={} does not exist or is not executable", b.display()))),
563 (Some(b), _) => checks.push((Level::Ok, format!("borg: BORG_BIN={}", b.display()))),
564 (None, Some(p)) => checks.push((Level::Ok, format!("borg: on PATH at {}", p.display()))),
565 (None, None) => {
566 let adapters: Vec<&str> = harness::visible().filter(|h| matches!(h.wiring(), Wiring::AdapterFile { .. })).map(|h| h.name()).collect();
567 checks.push((Level::Warn, format!("borg: not on PATH and BORG_BIN unset ({} adapters need one)", adapters.join(" and "))));
568 }
569 }
570 let borg_found = env_bin.as_deref().is_some_and(executable) || on_path.is_some();
571 checks.push(crate::update::doctor_check(learn::now_ms()));
573 checks.extend(harness_checks(harness::visible(), t));
574 checks.extend(wiring_checks(t, borg_found));
575 let cfg = collective::load();
576 checks.extend(collective_checks(&cfg, &learn::home()));
577 if let Ok(cwd) = std::env::current_dir() {
578 checks.extend(orphans::hive_worktree_checks(&cwd));
579 }
580 if let Ok(c) = &cfg {
581 let routes = collectives(c.as_ref(), &learn::home());
582 checks.extend(crate::vectors::doctor::checks(&routes));
583 checks.extend(crate::sweep::doctor(c.as_ref(), &learn::home(), &t.home, learn::now_ms()));
585 checks.extend(crate::presence::mesh_checks(&routes));
586 let cwd = std::env::current_dir().map(|d| d.display().to_string()).unwrap_or_default();
589 checks.extend(crate::moves::doctor(c.as_ref(), &collective::root_of(&learn::home()), &learn::engineer(&cwd)));
590 let emails: BTreeSet<String> = routes
591 .iter()
592 .filter_map(|r| match r {
593 Route::Collective { home, .. } => Some(store_emails(home)),
594 Route::Off => None,
595 })
596 .flatten()
597 .collect();
598 let aliases = c.as_ref().map(collective::Config::aliases).unwrap_or_default();
599 checks.extend(alias_hints(&emails, &git_identities(), &aliases).into_iter().map(|h| (Level::Warn, h)));
600 }
601 let spec = std::env::var("BORG_PREDICT_MODEL").ok();
602 checks.push(match crate::runner::availability(spec.as_deref(), &crate::runner::on_path) {
603 Ok(m) => (Level::Ok, m),
604 Err(m) => (Level::Warn, m),
605 });
606 for (l, msg) in &checks {
607 let tag = match l {
608 Level::Ok => "ok ",
609 Level::Warn => "WARN",
610 Level::Fail => "FAIL",
611 };
612 println!("{tag} {msg}");
613 }
614 i32::from(checks.iter().any(|(l, _)| *l == Level::Fail))
615}
616
617fn usage_line() -> String {
620 format!("usage: borg install|uninstall <{}|all> [--apply] [--home DIR] | borg status [--home DIR] | borg doctor [--home DIR]", harness::names("|"))
621}
622
623pub fn main(cmd: &str, args: &[&str]) -> i32 {
625 let mut apply_flag = false;
626 let mut home: Option<PathBuf> = None;
627 let mut which_h: Option<&str> = None;
628 let mut it = args.iter();
629 while let Some(a) = it.next() {
630 match *a {
631 "--apply" => apply_flag = true,
632 "--home" => match it.next() {
633 Some(d) => home = Some(PathBuf::from(d)),
634 None => return usage(),
635 },
636 h if which_h.is_none() && !h.starts_with('-') => which_h = Some(h),
637 _ => return usage(),
638 }
639 }
640 let targets = match &home {
641 Some(h) => {
642 isolate(cmd, h);
643 Targets::under(h)
644 }
645 None => Targets::from_env(),
646 };
647 match cmd {
648 "status" if which_h.is_none() && !apply_flag => {
649 print_status(&targets);
650 0
651 }
652 "doctor" if which_h.is_none() && !apply_flag => doctor(&targets),
653 "install" | "uninstall" => {
654 let Some(hs) = which_h.and_then(parse) else { return usage() };
655 let action = if cmd == "install" { Action::Install } else { Action::Uninstall };
656 run(action, &hs, &targets, &bin(), apply_flag)
657 }
658 _ => usage(),
659 }
660}
661
662pub(crate) fn bin() -> PathBuf {
664 match std::env::var_os("BORG_BIN").filter(|v| !v.is_empty()) {
665 Some(b) => PathBuf::from(b),
666 None => std::env::current_exe().and_then(std::fs::canonicalize).unwrap_or_else(|_| PathBuf::from("borg")),
667 }
668}
669
670pub fn home_overrides(cmd: &str) -> &'static [&'static str] {
678 match cmd {
679 "install" | "uninstall" | "setup" => &["BORG_HOME", "BORG_CONFIG", "BORG_VECTOR_MODEL"],
680 _ => &["BORG_HOME", "BORG_CONFIG", "BORG_VECTOR_MODEL", "BORG_BIN"],
681 }
682}
683
684pub(crate) fn isolate(cmd: &str, home: &Path) {
685 unsafe {
687 std::env::set_var("HOME", home);
688 for k in home_overrides(cmd) {
689 std::env::remove_var(k);
690 }
691 }
692}
693
694fn usage() -> i32 {
695 eprintln!("{}", usage_line());
696 2
697}
698
699fn run(action: Action, hs: &[&dyn Harness], t: &Targets, bin: &Path, apply_flag: bool) -> i32 {
700 let stamp = format!("{}Z", utc(learn::now_ms()));
701 let mut code = 0;
702 for &h in hs {
703 let changes = match plan(action, h, t, bin) {
704 Err(e) => {
705 eprintln!("{}: {e}", h.name());
706 code = 1;
707 continue;
708 }
709 Ok((changes, notes)) => {
710 for n in notes {
711 println!("{}: {n}", h.name());
712 }
713 changes
714 }
715 };
716 if changes.is_empty() {
717 println!("{}: nothing to change ({})", h.name(), t.path(h).display());
718 } else if apply_flag {
719 code = code.max(write(h, &changes, &stamp));
720 t.skill_dir(h).inspect(|d| skill::tidy(d));
721 } else {
722 for c in &changes {
723 print!("{}", diff(&c.path, c.before.as_deref(), c.after.as_deref()));
724 }
725 }
726 }
727 footer(action, apply_flag, code);
728 code
729}
730
731fn footer(action: Action, apply_flag: bool, code: i32) {
733 if code != 0 {
734 return;
735 }
736 if !apply_flag {
737 println!("(dry run: nothing written; add --apply to write, with a backup of every changed file)");
738 } else if action == Action::Install
739 && let Some(hint) = crate::vectors::doctor::hint()
740 {
741 println!("{hint}");
742 }
743}
744
745pub(crate) fn write(h: &dyn Harness, changes: &[Change], stamp: &str) -> i32 {
747 let mut code = 0;
748 for c in changes {
749 match apply(c, stamp) {
750 Ok(backup) => {
751 let b = backup.map(|b| format!(" (backup {})", b.display())).unwrap_or_default();
752 let what = if c.after.is_some() { "wrote" } else { "removed" };
753 println!("{}: {what} {}{b}", h.name(), c.path.display());
754 }
755 Err(e) => {
756 eprintln!("{}: {}: {e}", h.name(), c.path.display());
757 code = 1;
758 }
759 }
760 }
761 code
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767 use crate::harness::{claude, omp, opencode};
768 use serde_json::json;
769
770 const CLAUDE: &dyn Harness = &claude::Claude;
771 const OMP: &dyn Harness = &omp::Omp;
772 const OPENCODE: &dyn Harness = &opencode::Opencode;
773
774 fn claude_hooks() -> Hooks {
775 Hooks::of(CLAUDE).unwrap()
776 }
777
778 fn tmp(name: &str) -> PathBuf {
779 let d = std::env::temp_dir().join(format!("borg-install-{name}-{}", std::process::id()));
780 let _ = std::fs::remove_dir_all(&d);
781 std::fs::create_dir_all(&d).unwrap();
782 d
783 }
784
785 const EXISTING: &str = r#"{
787 "model": "opus",
788 "permissions": {
789 "allow": [
790 "Bash(git status)"
791 ]
792 },
793 "hooks": {
794 "Stop": [
795 {
796 "hooks": [
797 {
798 "type": "command",
799 "command": "notify-send done"
800 }
801 ]
802 }
803 ],
804 "PreToolUse": [
805 {
806 "matcher": "Bash",
807 "hooks": [
808 {
809 "type": "command",
810 "command": "/usr/local/bin/guard"
811 }
812 ]
813 }
814 ]
815 },
816 "zz_last": true
817}"#;
818
819 fn all_files(home: &Path) -> Vec<(PathBuf, Vec<u8>)> {
820 fn walk(d: &Path, out: &mut Vec<(PathBuf, Vec<u8>)>) {
821 for e in std::fs::read_dir(d).into_iter().flatten().flatten() {
822 let p = e.path();
823 if p.is_dir() { walk(&p, out) } else { out.push((p.clone(), std::fs::read(&p).unwrap())) }
824 }
825 }
826 let mut out = Vec::new();
827 walk(home, &mut out);
828 out.sort();
829 out
830 }
831
832 fn install_all(t: &Targets, bin: &Path, action: Action, stamp: &str) -> usize {
834 let mut n = 0;
835 for h in harness::visible() {
836 for c in plan(action, h, t, bin).unwrap().0 {
837 apply(&c, stamp).unwrap();
838 n += 1;
839 }
840 t.skill_dir(h).inspect(|d| skill::tidy(d));
841 }
842 n
843 }
844
845 fn files_per_install(t: &Targets) -> usize {
848 harness::visible().map(|h| 1 + t.skill_dir(h).map_or(0, |_| skill::FILES.len())).sum()
849 }
850
851 #[test]
852 fn install_preserves_existing_hooks_and_settings() {
853 let home = tmp("preserve");
854 let t = Targets::under(&home);
855 std::fs::create_dir_all(t.path(CLAUDE).parent().unwrap()).unwrap();
856 std::fs::write(t.path(CLAUDE), EXISTING).unwrap();
857 let bin = Path::new("/opt/borg dir/borg");
858 assert_eq!(install_all(&t, bin, Action::Install, "s1"), files_per_install(&t), "one config per harness, and the skill");
859
860 let v: Value = serde_json::from_str(&std::fs::read_to_string(t.path(CLAUDE)).unwrap()).unwrap();
861 assert_eq!(v["model"], "opus");
862 assert_eq!(v["zz_last"], true);
863 assert_eq!(v["permissions"]["allow"][0], "Bash(git status)");
864 assert_eq!(v["hooks"]["PreToolUse"][0]["hooks"][0]["command"], "/usr/local/bin/guard");
865 assert_eq!(v["hooks"]["Stop"][0]["hooks"][0]["command"], "notify-send done", "their Stop hook stays first");
866 assert_eq!(v["hooks"]["Stop"][1]["hooks"][0]["command"], "'/opt/borg dir/borg' hook claude stop");
867 assert_eq!(v["hooks"]["Stop"][1]["hooks"][0]["timeout"], harness::STOP_TIMEOUT_S, "the stop hook's budget is explicit");
868 assert!(v["hooks"]["PostToolUse"][0]["hooks"][0].get("timeout").is_none());
869 let old = r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"/b/borg hook claude stop"}]}]}}"#;
871 let up: Value = serde_json::from_str(&claude_hooks().install(Some(old), Path::new("/b/borg")).unwrap().unwrap()).unwrap();
872 assert_eq!(up["hooks"]["Stop"].as_array().unwrap().len(), 1);
873 assert_eq!(up["hooks"]["Stop"][0]["hooks"][0]["timeout"], harness::STOP_TIMEOUT_S);
874 let keys: Vec<&String> = v.as_object().unwrap().keys().collect();
875 assert_eq!(keys, ["model", "permissions", "hooks", "zz_last"], "key order kept");
876 let wired = claude_hooks().wired(Some(&std::fs::read_to_string(t.path(CLAUDE)).unwrap()));
877 assert_eq!(wired.len(), claude::EVENTS.len());
878 assert!(wired.iter().all(|(_, b)| b == bin), "quoted path parsed back");
879 assert_eq!(std::fs::read_to_string(t.path(OMP)).unwrap(), omp::ADAPTER);
880 assert_eq!(std::fs::read_to_string(t.path(OPENCODE)).unwrap(), opencode::ADAPTER);
881 let backup = t.path(CLAUDE).with_file_name("settings.json.borg-backup-s1");
882 assert_eq!(std::fs::read_to_string(backup).unwrap(), EXISTING, "backup of the original");
883 }
884
885 #[test]
886 fn install_is_idempotent_and_replaces_a_stale_binary() {
887 let home = tmp("idem");
888 let t = Targets::under(&home);
889 let bin = Path::new("/usr/bin/borg");
890 assert_eq!(install_all(&t, bin, Action::Install, "a"), files_per_install(&t), "one config per harness, and the skill");
891 let snapshot = all_files(&home);
892 assert_eq!(install_all(&t, bin, Action::Install, "b"), 0, "second install changes nothing");
893 assert_eq!(all_files(&home), snapshot, "and writes nothing, not even a backup");
894
895 let text = claude_hooks().install(read(&t.path(CLAUDE)).unwrap().as_deref(), Path::new("/new/borg")).unwrap().unwrap();
897 let wired = claude_hooks().wired(Some(&text));
898 assert_eq!(wired.len(), claude::EVENTS.len());
899 assert!(wired.iter().all(|(_, b)| b == Path::new("/new/borg")));
900 }
901
902 #[test]
903 fn uninstall_restores_the_original_exactly() {
904 for (name, original) in [("existing", Some(EXISTING)), ("existing-nl", Some(&*format!("{EXISTING}\n"))), ("absent", None)] {
905 let home = tmp(&format!("restore-{name}"));
906 let t = Targets::under(&home);
907 if let Some(o) = original {
908 std::fs::create_dir_all(t.path(CLAUDE).parent().unwrap()).unwrap();
909 std::fs::write(t.path(CLAUDE), o).unwrap();
910 }
911 install_all(&t, Path::new("/usr/bin/borg"), Action::Install, "i");
912 assert_eq!(install_all(&t, Path::new("/elsewhere/borg"), Action::Uninstall, "u"), files_per_install(&t), "everything install wrote");
913 assert_eq!(read(&t.path(CLAUDE)).unwrap().as_deref(), original, "{name}: byte for byte");
914 assert!(!t.path(OMP).exists() && !t.path(OPENCODE).exists() && !t.path(&crate::harness::codex::Codex).exists());
915 for h in harness::visible() {
916 assert!(t.skill_dir(h).is_none_or(|d| !d.exists()), "{name}: {}'s skill directory removed", h.name());
917 }
918 assert_eq!(install_all(&t, Path::new("/usr/bin/borg"), Action::Uninstall, "u2"), 0, "nothing left to remove");
919 }
920 }
921
922 #[test]
923 fn uninstall_leaves_foreign_files_alone() {
924 let home = tmp("foreign");
925 let t = Targets::under(&home);
926 std::fs::create_dir_all(t.path(OMP).parent().unwrap()).unwrap();
927 std::fs::write(t.path(OMP), "// my own borg.ts").unwrap();
928 let (change, notes) = plan(Action::Uninstall, OMP, &t, Path::new("/b/borg")).unwrap();
929 assert!(change.is_empty());
930 assert_eq!(notes.len(), 1);
931 let skill = t.skill_dir(CLAUDE).unwrap();
933 std::fs::create_dir_all(&skill).unwrap();
934 std::fs::write(skill.join("SKILL.md"), "my own skill").unwrap();
935 std::fs::write(skill.join("notes.md"), "mine").unwrap();
936 install_all(&t, Path::new("/b/borg"), Action::Install, "i");
937 assert_eq!(install_all(&t, Path::new("/b/borg"), Action::Uninstall, "u"), files_per_install(&t));
938 assert_eq!(std::fs::read_to_string(skill.join("notes.md")).unwrap(), "mine");
939 assert_eq!(std::fs::read_to_string(skill.join("SKILL.md.borg-backup-i")).unwrap(), "my own skill", "replaced with a backup");
940 let s = r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"/x/notborg hook claude stop"}]}]}}"#;
942 assert_eq!(claude_hooks().uninstall(Some(s)).unwrap(), None);
943 }
944
945 #[test]
946 fn dry_run_writes_nothing() {
947 let home = tmp("dry");
948 let t = Targets::under(&home);
949 std::fs::create_dir_all(t.path(CLAUDE).parent().unwrap()).unwrap();
950 std::fs::write(t.path(CLAUDE), EXISTING).unwrap();
951 let before = all_files(&home);
952 assert_eq!(run(Action::Install, &harness::visible().collect::<Vec<_>>(), &t, Path::new("/usr/bin/borg"), false), 0);
953 assert_eq!(all_files(&home), before);
954 let (c, _) = plan(Action::Install, CLAUDE, &t, Path::new("/usr/bin/borg")).unwrap();
955 let c = &c[0];
956 let d = diff(&c.path, c.before.as_deref(), c.after.as_deref());
957 assert!(d.contains("+ \"command\": \"/usr/bin/borg hook claude stop\""), "{d}");
958 assert!(!d.lines().any(|l| l.starts_with('-') && !l.starts_with("---")), "pure addition:\n{d}");
959 }
960
961 #[test]
962 fn invalid_settings_are_never_overwritten() {
963 assert!(claude_hooks().install(Some("{ not json"), Path::new("/b/borg")).is_err());
964 assert!(claude_hooks().install(Some(r#"{"hooks": []}"#), Path::new("/b/borg")).is_err());
965 }
966
967 #[cfg(unix)]
968 #[test]
969 fn adapter_symlink_is_replaced_not_written_through() {
970 let home = tmp("link");
971 let t = Targets::under(&home);
972 let src = home.join("checkout-borg.ts");
973 std::fs::write(&src, "// older adapter in a checkout").unwrap();
974 std::fs::create_dir_all(t.path(OMP).parent().unwrap()).unwrap();
975 std::os::unix::fs::symlink(&src, t.path(OMP)).unwrap();
976 let (c, _) = plan(Action::Install, OMP, &t, Path::new("/b/borg")).unwrap();
977 apply(&c[0], "x").unwrap();
978 assert_eq!(std::fs::read_to_string(&src).unwrap(), "// older adapter in a checkout");
979 assert_eq!(std::fs::read_to_string(t.path(OMP)).unwrap(), omp::ADAPTER);
980 }
981
982 #[test]
983 fn status_counts_a_store() {
984 let home = tmp("stats");
985 let w = |f: &str, s: &str| std::fs::write(home.join(f), s).unwrap();
986 w("episodes.jsonl", "{\"ms\":5}\n{\"ms\":9}\n");
987 w("lessons.jsonl", "");
988 w(
989 "outcomes.jsonl",
990 "{\"kind\":\"asked\",\"session\":\"s\",\"lessons\":[\"L1\"],\"ms\":3,\"repo\":\"r\"}\n{\"kind\":\"asked\",\"session\":\"s\",\"lessons\":[\"L2\"],\"ms\":7,\"repo\":\"r\"}\n",
991 );
992 let s = stats(&home);
993 assert_eq!((s.lessons, s.episodes, s.outcomes, s.last_capture_ms), (0, 2, 2, Some(9)));
994 assert_eq!(s.last_asked, Some((7, vec!["L2".to_string()])));
995 assert_eq!(stats(&home.join("absent")), Stats::default());
996 let cfg: collective::Config =
997 serde_json::from_str(r#"{"collectives":[{"name":"corp","paths":["/w"]},{"name":"mine","paths":["/p"],"share":"local"}]}"#).unwrap();
998 let rs = collectives(Some(&cfg), Path::new("/b"));
999 assert_eq!(rs.iter().map(collective::may_sync).collect::<Vec<_>>(), [true, false]);
1000 assert_eq!(rs[1], Route::Collective { name: "mine".into(), home: "/b/collectives/mine".into(), share: Share::Local });
1001 assert_eq!(utc(0), "19700101T000000");
1002 assert_eq!(utc(1_791_590_400_000), "20261010T000000");
1003 }
1004
1005 #[test]
1006 fn doctor_hints_at_emails_sharing_a_git_name_until_they_are_one_engineer() {
1007 let home = tmp("aliases");
1008 let line = |v: Value| format!("{v}\n");
1009 std::fs::write(
1010 home.join("lessons.jsonl"),
1011 line(json!({"id": "a", "origin_ms": 1, "repo": "r", "question": "q", "situations": [], "consequence": "", "support": 1,
1012 "episodes": [], "history": [], "version": 2, "engineers": ["Dana.Example@corp.example", "ann@x"], "joined": {"ann@x": 5}})),
1013 )
1014 .unwrap();
1015 std::fs::write(home.join("episodes.jsonl"), line(json!({"ms": 1, "engineer": "da@home.example"})) + &line(json!({"ms": 2, "engineer": ""}))).unwrap();
1016 let emails = store_emails(&home);
1017 assert_eq!(emails.iter().map(String::as_str).collect::<Vec<_>>(), ["Dana.Example@corp.example", "ann@x", "da@home.example"]);
1018 let ids = |v: &[(&str, &str)]| v.iter().map(|(n, e)| (n.to_string(), e.to_string())).collect::<Vec<_>>();
1019 let known =
1020 ids(&[("Dana Example", "dana.example@corp.example"), ("Dana Example", "da@home.example"), ("Ann", "ann@x"), ("Dana Example", "elsewhere@x")]);
1021 let hints = alias_hints(&emails, &known, &collective::Aliases::default());
1022 assert_eq!(hints.len(), 1, "{hints:?}");
1023 assert!(
1024 hints[0].contains("\"Dana Example\"") && hints[0].contains("Dana.Example@corp.example") && hints[0].contains("da@home.example"),
1025 "{}",
1026 hints[0]
1027 );
1028 assert!(!hints[0].contains("elsewhere@x"), "only emails the stores have");
1029 let cfg: collective::Config =
1030 serde_json::from_str(r#"{"engineers": [{"name": "dana", "emails": ["da@home.example", "Dana.Example@corp.example"]}]}"#).unwrap();
1031 assert!(alias_hints(&emails, &known, &cfg.aliases()).is_empty(), "already one engineer");
1032 assert!(alias_hints(&emails, &ids(&[("Ann", "ann@x"), ("Dana Example", "da@home.example")]), &collective::Aliases::default()).is_empty());
1033 }
1034
1035 #[test]
1036 fn doctor_flags_a_missing_binary_and_an_invalid_config() {
1037 let home = tmp("doctor");
1038 let t = Targets::under(&home);
1039 plan(Action::Install, CLAUDE, &t, Path::new("/nonexistent/borg")).unwrap().0.iter().for_each(|c| drop(apply(c, "d").unwrap()));
1040 let checks = wiring_checks(&t, true);
1041 assert!(checks.iter().any(|(l, m)| *l == Level::Fail && m.contains("/nonexistent/borg")), "{checks:?}");
1042 let bad = collective_checks(&Err("x.json: expected value".into()), &home);
1043 assert_eq!(bad.len(), 1);
1044 assert!(bad[0].0 == Level::Fail && bad[0].1.contains("EVERY repo routes OFF"));
1045 assert!(collective_checks(&Ok(None), &home.join("store")).iter().all(|(l, _)| *l == Level::Ok));
1046 }
1047
1048 #[test]
1049 fn doctor_names_a_leftover_hive_and_its_removal_but_leaves_it() {
1050 let base = tmp("hive-orphan");
1051 let store = collectives(None, &base)
1052 .into_iter()
1053 .find_map(|r| match r {
1054 Route::Collective { home, .. } => Some(home),
1055 Route::Off => None,
1056 })
1057 .unwrap();
1058 std::fs::create_dir_all(store.join("hive/g1")).unwrap();
1059 let warn: Vec<String> = collective_checks(&Ok(None), &base).into_iter().filter(|(l, _)| *l == Level::Warn).map(|(_, m)| m).collect();
1060 assert!(
1061 matches!(warn.as_slice(), [m] if m.contains("removed feature") && m.contains(&format!("rm -r '{}'", store.join("hive").display()))),
1062 "{warn:?}"
1063 );
1064 assert!(store.join("hive/g1").is_dir(), "never deleted by doctor");
1065 }
1066
1067 #[cfg(unix)]
1068 fn chmod(p: &Path, mode: u32) {
1069 use std::os::unix::fs::PermissionsExt;
1070 std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode)).unwrap();
1071 }
1072
1073 #[test]
1074 fn full_claude_install_reports_wired_not_partial() {
1075 let home = tmp("wired-status");
1076 let t = Targets::under(&home);
1077 assert_eq!(run(Action::Install, &[CLAUDE], &t, Path::new("/b/borg"), true), 0);
1078 assert!(wiring(CLAUDE, &t).starts_with("wired: "), "{}", wiring(CLAUDE, &t));
1079 }
1080
1081 #[cfg(unix)]
1082 #[test]
1083 fn unreadable_files_are_errors_not_absent() {
1084 let home = tmp("unreadable");
1085 let t = Targets::under(&home);
1086 std::fs::create_dir_all(t.path(CLAUDE).parent().unwrap()).unwrap();
1087 let precious = r#"{"model":"precious"}"#;
1088 std::fs::write(t.path(CLAUDE), precious).unwrap();
1089 chmod(&t.path(CLAUDE), 0o000);
1090 if std::fs::read(t.path(CLAUDE)).is_ok() {
1091 chmod(&t.path(CLAUDE), 0o600);
1092 return; }
1094 let before = std::fs::read_dir(t.path(CLAUDE).parent().unwrap()).unwrap().count();
1095 for action in [Action::Install, Action::Uninstall] {
1096 assert!(plan(action, CLAUDE, &t, Path::new("/b/borg")).is_err());
1097 }
1098 assert_eq!(run(Action::Install, &[CLAUDE], &t, Path::new("/b/borg"), true), 1);
1099 assert!(wiring(CLAUDE, &t).starts_with("UNREADABLE"));
1100 assert!(wiring_checks(&t, true).iter().any(|(l, m)| *l == Level::Fail && m.contains("cannot read")));
1101 chmod(&t.path(CLAUDE), 0o600);
1102 assert_eq!(std::fs::read_to_string(t.path(CLAUDE)).unwrap(), precious, "untouched");
1103 assert_eq!(std::fs::read_dir(t.path(CLAUDE).parent().unwrap()).unwrap().count(), before, "no new files");
1104
1105 std::fs::create_dir_all(t.path(OMP).parent().unwrap()).unwrap();
1107 std::fs::write(t.path(OMP), [0xff, 0xfe, 0x00]).unwrap();
1108 assert!(plan(Action::Install, OMP, &t, Path::new("/b/borg")).is_err());
1109 assert_eq!(std::fs::read(t.path(OMP)).unwrap(), [0xff, 0xfe, 0x00]);
1110 }
1111
1112 #[test]
1113 fn a_binary_not_named_borg_is_refused() {
1114 let home = tmp("borg-dev");
1115 let t = Targets::under(&home);
1116 for bin in ["/x/borg-dev", "/x/borg-ai", "/x/notborg"] {
1117 let e = plan(Action::Install, CLAUDE, &t, Path::new(bin)).unwrap_err();
1118 assert!(e.contains("BORG_BIN"), "{e}");
1119 }
1120 assert!(!t.path(CLAUDE).exists());
1121 for stamp in ["1", "2", "3"] {
1123 install_all(&t, Path::new("/x/dev/borg"), Action::Install, stamp);
1124 }
1125 let text = std::fs::read_to_string(t.path(CLAUDE)).unwrap();
1126 assert_eq!(text.matches("hook claude stop").count(), 1);
1127 assert_eq!(claude_hooks().wired(Some(&text)).len(), claude::EVENTS.len());
1128 }
1129
1130 #[cfg(unix)]
1131 #[test]
1132 fn permissions_are_kept_and_new_files_are_private() {
1133 use std::os::unix::fs::PermissionsExt;
1134 let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
1135 let home = tmp("perms");
1136 let t = Targets::under(&home);
1137 std::fs::create_dir_all(t.path(CLAUDE).parent().unwrap()).unwrap();
1138 std::fs::write(t.path(CLAUDE), r#"{"env":{"SECRET":"x"}}"#).unwrap();
1139 chmod(&t.path(CLAUDE), 0o600);
1140 let (c, _) = plan(Action::Install, CLAUDE, &t, Path::new("/usr/bin/borg")).unwrap();
1141 let backup = apply(&c[0], "p").unwrap().unwrap();
1142 assert_eq!(mode(&t.path(CLAUDE)), 0o600, "settings stay 0600");
1143 assert_eq!(mode(&backup), 0o600, "backup is 0600");
1144
1145 chmod(&t.path(CLAUDE), 0o640);
1146 let (c, _) = plan(Action::Uninstall, CLAUDE, &t, Path::new("/usr/bin/borg")).unwrap();
1147 apply(&c[0], "q").unwrap();
1148 assert_eq!(mode(&t.path(CLAUDE)), 0o640, "whatever the original had");
1149
1150 let fresh = tmp("perms-new");
1151 let t = Targets::under(&fresh);
1152 let (c, _) = plan(Action::Install, CLAUDE, &t, Path::new("/usr/bin/borg")).unwrap();
1153 apply(&c[0], "n").unwrap();
1154 assert_eq!(mode(&t.path(CLAUDE)), 0o600, "a new settings.json is private");
1155 }
1156
1157 #[test]
1158 fn a_backup_is_never_overwritten() {
1159 let home = tmp("backups");
1160 let t = Targets::under(&home);
1161 std::fs::create_dir_all(t.path(CLAUDE).parent().unwrap()).unwrap();
1162 let orig = r#"{"model":"orig"}"#;
1163 std::fs::write(t.path(CLAUDE), orig).unwrap();
1164 let (c, _) = plan(Action::Install, CLAUDE, &t, Path::new("/usr/bin/borg")).unwrap();
1166 let b1 = apply(&c[0], "same").unwrap().unwrap();
1167 let (c, _) = plan(Action::Uninstall, CLAUDE, &t, Path::new("/usr/bin/borg")).unwrap();
1168 let b2 = apply(&c[0], "same").unwrap().unwrap();
1169 assert_ne!(b1, b2);
1170 assert_eq!(std::fs::read_to_string(&b1).unwrap(), orig, "the original survives");
1171 assert!(std::fs::read_to_string(&b2).unwrap().contains("hook claude stop"));
1172 assert!(b2.to_string_lossy().ends_with("borg-backup-same-1"));
1173 }
1174
1175 #[test]
1176 fn home_overrides_cover_store_and_config() {
1177 for cmd in ["install", "uninstall", "setup", "status", "doctor"] {
1178 let v = home_overrides(cmd);
1179 assert!(v.contains(&"BORG_HOME") && v.contains(&"BORG_CONFIG") && v.contains(&"BORG_VECTOR_MODEL"), "{cmd}");
1180 }
1181 assert!(home_overrides("doctor").contains(&"BORG_BIN"));
1182 assert!(!home_overrides("install").contains(&"BORG_BIN"), "install still wires $BORG_BIN");
1183 }
1184
1185 struct Noted(&'static str);
1188
1189 impl Harness for Noted {
1190 fn name(&self) -> &'static str {
1191 "noted"
1192 }
1193 fn caps(&self) -> harness::Caps {
1194 CLAUDE.caps()
1195 }
1196 fn wiring(&self) -> Wiring {
1197 CLAUDE.wiring()
1198 }
1199 fn target(&self, home: &Path, _var: &dyn Fn(&str) -> Option<PathBuf>) -> PathBuf {
1200 home.join(".noted/hooks.json")
1201 }
1202 fn probe(&self) -> harness::Probe {
1203 harness::Probe { cli: self.0, tested: "" }
1204 }
1205 fn checks(&self, target: &Path) -> Vec<(Level, String)> {
1206 vec![(Level::Warn, format!("noted: {} is not trusted", target.display()))]
1207 }
1208 fn install_notes(&self) -> Vec<String> {
1209 vec!["run /hooks in noted and trust borg".into()]
1210 }
1211 }
1212
1213 #[test]
1214 fn a_harness_adds_its_own_install_notes_and_doctor_checks() {
1215 let home = tmp("noted");
1216 let t = Targets::under(&home);
1217 let (change, notes) = plan(Action::Install, &Noted("git"), &t, Path::new("/b/borg")).unwrap();
1218 assert_eq!(change.len(), 1 + skill::FILES.len(), "the hook config and the skill");
1219 assert_eq!(notes, ["run /hooks in noted and trust borg"]);
1220 assert_eq!(plan(Action::Uninstall, &Noted("git"), &t, Path::new("/b/borg")).unwrap().1, Vec::<String>::new());
1221 let checks = harness_checks([&Noted("git") as &dyn Harness].into_iter(), &t);
1222 assert!(checks[0].0 == Level::Ok && checks[0].1.starts_with("git: git version"), "{checks:?}");
1223 assert_eq!(checks[1], (Level::Warn, format!("noted: {} is not trusted", home.join(".noted/hooks.json").display())));
1224 let missing = harness_checks([&Noted("borg-test-no-such-cli") as &dyn Harness].into_iter(), &t);
1225 assert_eq!(missing, [(Level::Ok, "borg-test-no-such-cli: not installed".into())], "no CLI to ask: its own checks wait");
1226 }
1227}