boost-skill-cli 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1041 @@
1
+ """Quality & Health commands — doctor, lint, audit, verify, drift, test,
2
+ fingerprint, quarantine, decay, heal, conflict, changelog, attest, health.
3
+
4
+ Shared helpers at the top keep the fourteen commands small: installed-skill
5
+ iteration, broken-symlink discovery, drift classification, and the
6
+ deterministic environment fingerprint.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import re
15
+ from datetime import datetime, timedelta, timezone
16
+ from pathlib import Path
17
+ from typing import List, Optional, Tuple
18
+
19
+ from ..core import (agents, ai, catalog, frontmatter, gitutil, journal,
20
+ lockfile, output as out, paths, policy, registry, store,
21
+ util)
22
+ from ..errors import BoostError
23
+
24
+ # --- audit: dangerous-content patterns ------------------------------------
25
+
26
+ _AUDIT_PATTERNS = [
27
+ (re.compile(r"(?:curl|wget)[^|\n]*\|\s*(?:sudo\s+)?(?:ba|z|da)?sh\b"),
28
+ "HIGH", "remote-exec"),
29
+ (re.compile(r"rm\s+-(?:rf|fr)\s+(?:/|~)(?=[\s'\";`)]|$)", re.M),
30
+ "HIGH", "destructive"),
31
+ (re.compile(r"base64\s+(?:-d|-D|--decode)\b[^\n]*\|\s*(?:ba|z)?sh\b"),
32
+ "HIGH", "obfuscated-exec"),
33
+ (re.compile(r"(?i)ignore\s+(?:all\s+|any\s+)?(?:previous|prior)\s+instructions"),
34
+ "HIGH", "prompt-injection"),
35
+ (re.compile(r"(?i)exfiltrat"), "MED", "exfiltration"),
36
+ (re.compile(r"\bsudo\s"), "LOW", "privilege-escalation"),
37
+ ]
38
+ _CRED_POST = re.compile(r"(?i)(?:curl\b[^\n]*\s(?:-d|--data)\b|POST\s+[^\n]*https?://)")
39
+ _CRED_HINT = re.compile(r"(?i)secret|token|api[-_]?key|password|credential")
40
+ _SEV_STYLE = {"HIGH": out.RED, "MED": out.YELLOW, "LOW": out.DIM}
41
+
42
+ # --- conflict: normative-rule extraction -----------------------------------
43
+
44
+ _RULE_RE = re.compile(
45
+ r"(?i)^\s*(?:[-*+>]|\d+[.)])?\s*(never|always|must\s+not|must|do\s+not|don'?t)\b(.*)$")
46
+ _NEG_MODALS = {"never", "must not", "do not", "don't", "dont"}
47
+ _STOPWORDS = {"the", "a", "an", "to", "of", "and", "in", "for", "with",
48
+ "before", "after", "is", "are", "be", "that", "this", "it",
49
+ "on", "at"}
50
+ _NEGATORS = {"without", "not", "no", "unless"}
51
+ _CONFLICT_OVERLAP = 0.4 # tuned so the fixture's tdd vs cowboy pair is caught
52
+
53
+ # --- decay: fallback stack markers when commands/discovery is unavailable --
54
+
55
+ _STACK_MARKERS = [
56
+ ("package.json", ["javascript", "node", "npm", "frontend", "web"]),
57
+ ("tsconfig.json", ["typescript"]),
58
+ ("pyproject.toml", ["python"]),
59
+ ("requirements.txt", ["python"]),
60
+ ("setup.py", ["python"]),
61
+ ("Cargo.toml", ["rust", "cargo"]),
62
+ ("go.mod", ["go", "golang"]),
63
+ ("pom.xml", ["java", "maven"]),
64
+ ("build.gradle", ["java", "gradle"]),
65
+ ("Gemfile", ["ruby", "rails"]),
66
+ ("Dockerfile", ["docker", "container"]),
67
+ ("docker-compose.yml", ["docker"]),
68
+ (".git", ["git", "commit", "workflow"]),
69
+ ("tests", ["testing", "test"]),
70
+ ("test", ["testing", "test"]),
71
+ ]
72
+
73
+
74
+ # --- shared helpers ---------------------------------------------------------
75
+
76
+ def _tilde(p) -> str:
77
+ s, h = str(p), str(paths.home())
78
+ return "~" + s[len(h):] if s.startswith(h) else s
79
+
80
+
81
+ def _s(n: int) -> str:
82
+ return "" if n == 1 else "s"
83
+
84
+
85
+ def _iter_installed(names: Optional[List[str]] = None) -> List[Tuple[str, dict]]:
86
+ """[(name, lock_entry)] — all installed, or the given names (validated)."""
87
+ skills = lockfile.installed()
88
+ if names:
89
+ missing = [n for n in names if n not in skills]
90
+ if missing:
91
+ raise BoostError("not installed: %s" % ", ".join(missing),
92
+ hint="see what is with `boost list`")
93
+ return [(n, skills[n]) for n in names]
94
+ return sorted(skills.items())
95
+
96
+
97
+ def _broken_links() -> List[Path]:
98
+ """Broken (dangling) symlinks across every enabled agent dir."""
99
+ broken: List[Path] = []
100
+ for adir in agents.enabled_agents().values():
101
+ if not adir.is_dir():
102
+ continue
103
+ for link in sorted(adir.iterdir()):
104
+ if link.is_symlink() and not link.exists():
105
+ broken.append(link)
106
+ return broken
107
+
108
+
109
+ def _read_skill(skill_dir: Path) -> Tuple[dict, str]:
110
+ """(frontmatter, body) for a skill dir's SKILL.md; ({}, "") if unreadable."""
111
+ md = Path(skill_dir) / "SKILL.md"
112
+ if not md.exists():
113
+ return {}, ""
114
+ try:
115
+ return frontmatter.parse(md.read_text(encoding="utf-8", errors="replace"))
116
+ except OSError:
117
+ return {}, ""
118
+
119
+
120
+ def _drift_status(name: str, entry: dict) -> str:
121
+ """'in-sync' | 'local-edits' | 'upstream-moved' | 'source-missing'
122
+ | 'store-missing' | 'n/a' (local imports with no tap source)."""
123
+ sdir = store.skill_store_dir(name)
124
+ if not sdir.is_dir():
125
+ return "store-missing"
126
+ if util.sha256_dir(sdir) != entry.get("sha256"):
127
+ return "local-edits"
128
+ if entry.get("tap") == "local":
129
+ return "n/a"
130
+ try:
131
+ src = store.source_dir_for({"name": name, "tap": entry.get("tap", ""),
132
+ "rel_dir": entry.get("source_dir", ".")})
133
+ except BoostError:
134
+ return "source-missing"
135
+ return "in-sync" if util.sha256_dir(src) == entry.get("sha256") else "upstream-moved"
136
+
137
+
138
+ _DRIFT_STYLE = {"in-sync": out.GREEN, "local-edits": out.YELLOW,
139
+ "upstream-moved": out.CYAN, "source-missing": out.RED,
140
+ "store-missing": out.RED, "n/a": out.DIM}
141
+
142
+
143
+ def _drift_hint(name: str, status: str) -> str:
144
+ if status == "upstream-moved":
145
+ return "boost update"
146
+ if status == "local-edits":
147
+ return "boost reinstall %s to discard local edits" % name
148
+ if status == "source-missing":
149
+ return "boost update"
150
+ if status == "store-missing":
151
+ return "boost heal"
152
+ return ""
153
+
154
+
155
+ def _parse_ts(iso: str) -> Optional[datetime]:
156
+ try:
157
+ return datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
158
+ except (ValueError, TypeError):
159
+ return None
160
+
161
+
162
+ def _fingerprint() -> Tuple[str, List[str]]:
163
+ """(sha256 hexdigest, component lines). Deterministic: the same lock file
164
+ and tap commits always produce the same hash."""
165
+ comps = sorted("%s:%s" % (n, e.get("sha256", ""))
166
+ for n, e in lockfile.installed().items())
167
+ comps += sorted("%s:%s" % (t.name,
168
+ gitutil.head_commit(t.path)
169
+ if t.is_cloned and gitutil.has_git() else "")
170
+ for t in registry.list_taps())
171
+ digest = hashlib.sha256("\n".join(comps).encode()).hexdigest()
172
+ return digest, comps
173
+
174
+
175
+ def _norm_token(tok: str) -> str:
176
+ t = re.sub(r"[^a-z0-9]", "", tok.lower())
177
+ if len(t) > 3 and t.endswith("s") and not t.endswith("ss"):
178
+ t = t[:-1]
179
+ return t
180
+
181
+
182
+ def _stack_keywords(cwd: Path) -> set:
183
+ """Tech-stack keywords for the working directory: the discovery module's
184
+ detect_stack keywords, enriched with coarse filesystem markers (so tags
185
+ like `testing` or `git` can match even when detect_stack is language-only)."""
186
+ kws: List[str] = []
187
+ try:
188
+ from .discovery import detect_stack
189
+ stack = detect_stack(cwd)
190
+ if isinstance(stack, dict):
191
+ kws.extend(str(k) for k in (stack.get("keywords") or []))
192
+ except Exception:
193
+ pass
194
+ for marker, words in _STACK_MARKERS:
195
+ if (Path(cwd) / marker).exists():
196
+ kws.extend(words)
197
+ return {_norm_token(k) for k in kws if _norm_token(k)}
198
+
199
+
200
+ def _decay_rows(cwd: Path) -> List[dict]:
201
+ """Relevance/recency verdict per installed skill (shared by decay/health)."""
202
+ kws = _stack_keywords(cwd)
203
+ last_by: dict = {}
204
+ for e in journal.events():
205
+ subj, ts = e.get("subject"), _parse_ts(e.get("ts", ""))
206
+ if subj and ts and (subj not in last_by or ts > last_by[subj]):
207
+ last_by[subj] = ts
208
+ cutoff = datetime.now(timezone.utc) - timedelta(days=30)
209
+ rows = []
210
+ for name, _entry in _iter_installed():
211
+ meta, _ = _read_skill(store.skill_store_dir(name))
212
+ toks = {_norm_token(t) for t in re.split(r"[-_/\s]+", name)}
213
+ toks |= {_norm_token(w) for w in
214
+ re.findall(r"[A-Za-z0-9]+", str(meta.get("description") or ""))}
215
+ tags = meta.get("tags") or []
216
+ toks |= {_norm_token(str(t)) for t in (tags if isinstance(tags, list) else [tags])}
217
+ toks.discard("")
218
+ overlap = len(kws & toks)
219
+ relevance = "ok" if overlap >= 2 else ("low" if overlap == 1 else "none")
220
+ ts = last_by.get(name)
221
+ recent = ts is not None and ts >= cutoff
222
+ last = util.rel_time(ts.strftime("%Y-%m-%dT%H:%M:%SZ")) if ts else "never"
223
+ if relevance == "none" and not recent:
224
+ verdict = "decay"
225
+ elif relevance in ("none", "low"):
226
+ verdict = "review"
227
+ else:
228
+ verdict = "ok"
229
+ rows.append({"name": name, "relevance": relevance,
230
+ "last_activity": last, "verdict": verdict})
231
+ return rows
232
+
233
+
234
+ # --- commands ---------------------------------------------------------------
235
+
236
+ def cmd_doctor(argv):
237
+ ap = argparse.ArgumentParser(
238
+ prog="boost doctor", description="Check installation health & report issues")
239
+ ap.parse_args(argv)
240
+ issues = 0
241
+
242
+ def bad(msg):
243
+ nonlocal issues
244
+ issues += 1
245
+ out.warn(msg)
246
+
247
+ if gitutil.has_git():
248
+ out.ok("git on PATH")
249
+ else:
250
+ bad("git not found on PATH — install git")
251
+ paths.ensure_dirs() # create silently; never a failure
252
+
253
+ taps = registry.list_taps()
254
+ tap_ok = 0
255
+ for tap in taps:
256
+ if not tap.is_cloned:
257
+ bad("tap %s not cloned — run `boost update`" % tap.name)
258
+ elif not tap.cache_file.exists():
259
+ bad("tap %s has no catalog cache — run `boost update %s`"
260
+ % (tap.name, tap.name))
261
+ else:
262
+ tap_ok += 1
263
+ if taps and tap_ok == len(taps):
264
+ out.ok("%d tap%s cloned & cached" % (len(taps), _s(len(taps))))
265
+ elif not taps:
266
+ out.info("no taps configured — add one with `boost tap owner/repo`")
267
+
268
+ lock_ok = True
269
+ lp = paths.lockfile_path()
270
+ if lp.exists():
271
+ try:
272
+ raw = json.loads(lp.read_text())
273
+ if raw.get("version") != lockfile.SCHEMA_VERSION:
274
+ bad("lock file schema is v%s, expected v%d"
275
+ % (raw.get("version"), lockfile.SCHEMA_VERSION))
276
+ lock_ok = False
277
+ except (json.JSONDecodeError, OSError):
278
+ bad("lock file is corrupt — restore with `boost replay`")
279
+ lock_ok = False
280
+ if lock_ok:
281
+ out.ok("lock file parses (v%d)" % lockfile.SCHEMA_VERSION)
282
+
283
+ skills = lockfile.installed()
284
+ enabled = agents.enabled_agents()
285
+ skill_issues = 0
286
+ for name, entry in sorted(skills.items()):
287
+ if not store.skill_store_dir(name).is_dir():
288
+ bad("skill %s missing from store — run `boost heal`" % name)
289
+ skill_issues += 1
290
+ continue
291
+ if entry.get("quarantined"):
292
+ continue
293
+ for agent in entry.get("agents", []):
294
+ adir = enabled.get(agent)
295
+ if adir is None:
296
+ continue
297
+ link = adir / name
298
+ if not link.is_symlink() or not link.exists():
299
+ bad("skill %s not linked for %s — run `boost sync`" % (name, agent))
300
+ skill_issues += 1
301
+ if skills and not skill_issues:
302
+ out.ok("%d skill%s present in store with agent links"
303
+ % (len(skills), _s(len(skills))))
304
+
305
+ root = paths.store_dir()
306
+ orphans = [c.name for c in sorted(root.iterdir())
307
+ if c.is_dir() and not c.name.startswith(".") and c.name not in skills
308
+ ] if root.is_dir() else []
309
+ if orphans:
310
+ bad("%d orphaned store dir%s (%s) — run `boost sync`"
311
+ % (len(orphans), _s(len(orphans)), ", ".join(orphans[:5])))
312
+
313
+ broken = _broken_links()
314
+ if broken:
315
+ bad("%d broken symlink%s in agent dirs — run `boost heal`"
316
+ % (len(broken), _s(len(broken))))
317
+
318
+ for agent, adir in enabled.items():
319
+ if adir.is_dir() and not os.access(str(adir), os.W_OK):
320
+ bad("agent dir %s is not writable" % _tilde(adir))
321
+
322
+ rotation = journal.rotation_healthy()
323
+ if not rotation:
324
+ bad("journal is overdue for rotation — run `boost heal`")
325
+
326
+ line1 = ("%d skill%s installed · %d tap%s synced · %d broken link%s"
327
+ % (len(skills), _s(len(skills)), tap_ok, _s(tap_ok),
328
+ len(broken), _s(len(broken))))
329
+ (out.ok if not broken else out.warn)(line1)
330
+ if lock_ok and rotation:
331
+ out.ok("lock file integrity OK · log rotation healthy")
332
+ else:
333
+ out.warn("lock file integrity or log rotation needs attention")
334
+ return 1 if issues else 0
335
+
336
+
337
+ def cmd_lint(argv):
338
+ ap = argparse.ArgumentParser(
339
+ prog="boost lint", description="Validate SKILL.md frontmatter & quality")
340
+ ap.add_argument("names", nargs="*", metavar="NAME")
341
+ ap.add_argument("--tap", metavar="TAP", help="lint every skill in a tap's clone")
342
+ ap.add_argument("--min", type=int, default=40, dest="min_score", metavar="N",
343
+ help="minimum passing score (default 40)")
344
+ ap.add_argument("--json", action="store_true")
345
+ args = ap.parse_args(argv)
346
+
347
+ targets: List[Tuple[str, Path]] = []
348
+ if args.tap:
349
+ tap = registry.get(args.tap)
350
+ if not tap.is_cloned:
351
+ raise BoostError("tap %s is not cloned" % tap.name,
352
+ hint="run `boost update %s`" % tap.name)
353
+ entries = catalog.load_tap(tap)
354
+ if args.names:
355
+ wanted = set(args.names)
356
+ entries = [e for e in entries if e["name"] in wanted]
357
+ targets = [(e["name"],
358
+ tap.path if e["rel_dir"] == "." else tap.path / e["rel_dir"])
359
+ for e in entries]
360
+ else:
361
+ targets = [(n, store.skill_store_dir(n))
362
+ for n, _e in _iter_installed(args.names or None)]
363
+ if not targets:
364
+ if args.json:
365
+ print(json.dumps([]))
366
+ else:
367
+ out.info("nothing to lint")
368
+ return 0
369
+
370
+ results = []
371
+ for name, sdir in targets:
372
+ score, notes = util.score_skill(sdir)
373
+ meta, _ = _read_skill(sdir)
374
+ errors = []
375
+ if not (sdir / "SKILL.md").exists():
376
+ errors.append("missing SKILL.md")
377
+ else:
378
+ if not meta.get("name"):
379
+ errors.append("missing required field: name")
380
+ if not meta.get("description"):
381
+ errors.append("missing required field: description")
382
+ notes = [n for n in notes
383
+ if "missing `name`" not in n and "missing `description`" not in n
384
+ and n != "missing SKILL.md"]
385
+ results.append({"name": name, "score": score, "notes": notes,
386
+ "errors": errors, "path": str(sdir)})
387
+
388
+ failed = [r for r in results if r["score"] < args.min_score or r["errors"]]
389
+ if args.json:
390
+ print(json.dumps({"min": args.min_score, "skills": results,
391
+ "failed": len(failed)}))
392
+ return 1 if failed else 0
393
+
394
+ width = max(len(r["name"]) for r in results)
395
+ for r in results:
396
+ style = (out.GREEN if r["score"] >= 80
397
+ else out.YELLOW if r["score"] >= args.min_score else out.RED)
398
+ print(" %s %s" % (r["name"].ljust(width),
399
+ out.c("%d/100" % r["score"], style)))
400
+ for e in r["errors"]:
401
+ print(" " + out.c("error: " + e, out.RED))
402
+ for n in r["notes"]:
403
+ print(" " + out.c(n, out.DIM))
404
+ if failed:
405
+ out.warn("%d of %d skill%s below %d or with errors"
406
+ % (len(failed), len(results), _s(len(results)), args.min_score))
407
+ return 1
408
+ out.ok("%d skill%s pass lint (min %d)" % (len(results), _s(len(results)),
409
+ args.min_score))
410
+ return 0
411
+
412
+
413
+ def cmd_audit(argv):
414
+ ap = argparse.ArgumentParser(
415
+ prog="boost audit",
416
+ description="Check installed skills against a safety blocklist")
417
+ ap.add_argument("--json", action="store_true")
418
+ args = ap.parse_args(argv)
419
+
420
+ pol = policy.load()
421
+ installed = _iter_installed()
422
+ findings: dict = {}
423
+
424
+ def add(name, severity, label, where, snippet):
425
+ findings.setdefault(name, []).append({
426
+ "severity": severity, "label": label, "file": where,
427
+ "snippet": snippet})
428
+
429
+ min_score = int(pol.get("min_quality_score") or 0)
430
+ for name, _entry in installed:
431
+ if name in pol.get("blocked_skills", []):
432
+ add(name, "HIGH", "policy-blocked", "policy.json",
433
+ "skill is on the policy blocklist")
434
+ sdir = store.skill_store_dir(name)
435
+ if min_score > 0:
436
+ score, _n = util.score_skill(sdir)
437
+ if score < min_score:
438
+ add(name, "MED", "quality-below-policy", "SKILL.md",
439
+ "score %d < policy minimum %d" % (score, min_score))
440
+ files = [sdir / "SKILL.md"] if (sdir / "SKILL.md").exists() else []
441
+ if sdir.is_dir():
442
+ files += [p for p in sorted(sdir.rglob("*"))
443
+ if p.is_file() and p.suffix in (".sh", ".py")]
444
+ for f in files:
445
+ try:
446
+ text = f.read_text(encoding="utf-8", errors="replace")
447
+ except OSError:
448
+ continue
449
+ rel = str(f.relative_to(sdir))
450
+ for pat, severity, label in _AUDIT_PATTERNS:
451
+ for m in pat.finditer(text):
452
+ line_no = text.count("\n", 0, m.start()) + 1
453
+ line = text.splitlines()[line_no - 1].strip()
454
+ add(name, severity, label, "%s:%d" % (rel, line_no), line[:90])
455
+ for i, line in enumerate(text.splitlines(), 1):
456
+ if _CRED_POST.search(line) and _CRED_HINT.search(line):
457
+ add(name, "MED", "credential-exfil",
458
+ "%s:%d" % (rel, i), line.strip()[:90])
459
+
460
+ counts = {"HIGH": 0, "MED": 0, "LOW": 0}
461
+ for fs in findings.values():
462
+ for f in fs:
463
+ counts[f["severity"]] += 1
464
+
465
+ if args.json:
466
+ print(json.dumps({"skills_scanned": len(installed),
467
+ "findings": findings, "counts": counts}))
468
+ return 1 if counts["HIGH"] or counts["MED"] else 0
469
+
470
+ out.heading("safety audit — %d skill%s" % (len(installed), _s(len(installed))))
471
+ if not findings:
472
+ out.ok("no safety findings across %d skills" % len(installed))
473
+ return 0
474
+ for name in sorted(findings):
475
+ print(" " + out.c(name, out.BOLD))
476
+ for f in findings[name]:
477
+ print(" %s %s %s %s"
478
+ % (out.c(f["severity"].ljust(4), _SEV_STYLE[f["severity"]]),
479
+ f["label"], out.c(f["file"], out.DIM), f["snippet"]))
480
+ out.info("%d high · %d medium · %d low across %d skill%s"
481
+ % (counts["HIGH"], counts["MED"], counts["LOW"],
482
+ len(installed), _s(len(installed))))
483
+ return 1 if counts["HIGH"] or counts["MED"] else 0
484
+
485
+
486
+ def cmd_verify(argv):
487
+ ap = argparse.ArgumentParser(
488
+ prog="boost verify",
489
+ description="Validate skill quality & lock-file integrity")
490
+ ap.add_argument("names", nargs="*", metavar="NAME")
491
+ ap.add_argument("--json", action="store_true")
492
+ args = ap.parse_args(argv)
493
+
494
+ results = []
495
+ for name, entry in _iter_installed(args.names or None):
496
+ missing_fields = [f for f in ("version", "tap", "sha256", "installed_at")
497
+ if not entry.get(f)]
498
+ sdir = store.skill_store_dir(name)
499
+ if not sdir.is_dir():
500
+ status = "missing"
501
+ elif util.sha256_dir(sdir) != entry.get("sha256"):
502
+ status = "modified"
503
+ else:
504
+ status = "ok"
505
+ results.append({"name": name, "status": status,
506
+ "missing_fields": missing_fields})
507
+
508
+ bad = [r for r in results if r["status"] != "ok" or r["missing_fields"]]
509
+ if args.json:
510
+ print(json.dumps({"skills": results, "failed": len(bad)}))
511
+ return 1 if bad else 0
512
+
513
+ if not results:
514
+ out.info("no skills installed")
515
+ return 0
516
+ width = max(len(r["name"]) for r in results)
517
+ style = {"ok": out.GREEN, "modified": out.YELLOW, "missing": out.RED}
518
+ for r in results:
519
+ note = (" missing lock fields: " + ", ".join(r["missing_fields"])
520
+ if r["missing_fields"] else "")
521
+ print(" %s %s%s" % (r["name"].ljust(width),
522
+ out.c(r["status"], style[r["status"]]),
523
+ out.c(note, out.DIM)))
524
+ if bad:
525
+ out.warn("%d of %d skill%s failed verification"
526
+ % (len(bad), len(results), _s(len(results))))
527
+ return 1
528
+ out.ok("lock file integrity OK")
529
+ return 0
530
+
531
+
532
+ def cmd_drift(argv):
533
+ ap = argparse.ArgumentParser(
534
+ prog="boost drift",
535
+ description="Detect installed skills diverging from source")
536
+ ap.add_argument("names", nargs="*", metavar="NAME")
537
+ ap.add_argument("--json", action="store_true")
538
+ args = ap.parse_args(argv)
539
+
540
+ rows = []
541
+ for name, entry in _iter_installed(args.names or None):
542
+ status = _drift_status(name, entry)
543
+ rows.append({"name": name, "status": status,
544
+ "hint": _drift_hint(name, status)})
545
+ if args.json:
546
+ print(json.dumps({"skills": rows}))
547
+ return 0
548
+ if not rows:
549
+ out.info("no skills installed")
550
+ return 0
551
+ out.table([(r["name"], out.c(r["status"], _DRIFT_STYLE[r["status"]]),
552
+ out.c(r["hint"], out.DIM)) for r in rows],
553
+ headers=("SKILL", "STATUS", "HINT"))
554
+ counts: dict = {}
555
+ for r in rows:
556
+ counts[r["status"]] = counts.get(r["status"], 0) + 1
557
+ out.info(" · ".join("%d %s" % (n, s) for s, n in sorted(counts.items())))
558
+ return 0
559
+
560
+
561
+ def cmd_test(argv):
562
+ ap = argparse.ArgumentParser(
563
+ prog="boost test",
564
+ description="Validate installed skills against quality checks")
565
+ ap.add_argument("names", nargs="*", metavar="NAME")
566
+ args = ap.parse_args(argv)
567
+
568
+ rows, failed_count = [], 0
569
+ for name, entry in _iter_installed(args.names or None):
570
+ sdir = store.skill_store_dir(name)
571
+ md = sdir / "SKILL.md"
572
+ meta, body = _read_skill(sdir)
573
+ failed = []
574
+ if not (md.exists() and meta.get("name")):
575
+ failed.append("parses")
576
+ score, _notes = util.score_skill(sdir)
577
+ if score < 40:
578
+ failed.append("lint")
579
+ if not sdir.is_dir() or util.sha256_dir(sdir) != entry.get("sha256"):
580
+ failed.append("verify")
581
+ if len(body.encode("utf-8")) > 64 * 1024:
582
+ failed.append("size")
583
+ if not md.exists():
584
+ failed.append("layout")
585
+ if failed:
586
+ failed_count += 1
587
+ rows.append((name,
588
+ out.c("FAIL", out.RED) if failed else out.c("PASS", out.GREEN),
589
+ out.c(", ".join(failed), out.DIM)))
590
+ if not rows:
591
+ out.info("no skills installed")
592
+ return 0
593
+ out.table(rows, headers=("SKILL", "RESULT", "FAILED CHECKS"))
594
+ out.info("%d passed, %d failed" % (len(rows) - failed_count, failed_count))
595
+ return 1 if failed_count else 0
596
+
597
+
598
+ def cmd_fingerprint(argv):
599
+ ap = argparse.ArgumentParser(
600
+ prog="boost fingerprint",
601
+ description="Deterministic hash of the skill environment")
602
+ ap.add_argument("--verbose", action="store_true",
603
+ help="show the hashed components")
604
+ ap.add_argument("--json", action="store_true")
605
+ args = ap.parse_args(argv)
606
+
607
+ digest, comps = _fingerprint()
608
+ if args.json:
609
+ print(json.dumps({"fingerprint": digest, "short": digest[:16],
610
+ "components": comps}))
611
+ return 0
612
+ out.heading("environment fingerprint")
613
+ print(" " + out.c(digest[:16], out.BOLD, out.CYAN)
614
+ + " " + out.c(digest, out.DIM))
615
+ if args.verbose:
616
+ out.table([tuple(line.split(":", 1)) for line in comps],
617
+ headers=("COMPONENT", "DIGEST/COMMIT"))
618
+ return 0
619
+
620
+
621
+ def cmd_quarantine(argv):
622
+ ap = argparse.ArgumentParser(
623
+ prog="boost quarantine",
624
+ description="Isolate a problematic skill without uninstalling")
625
+ ap.add_argument("name", nargs="?", metavar="NAME")
626
+ ap.add_argument("--release", metavar="NAME",
627
+ help="re-link a quarantined skill")
628
+ ap.add_argument("--list", action="store_true", dest="list_mode",
629
+ help="list quarantined skills")
630
+ args = ap.parse_args(argv)
631
+
632
+ modes = sum(1 for m in (args.name, args.release, args.list_mode) if m)
633
+ if modes != 1:
634
+ raise BoostError("specify a skill to quarantine, --release NAME, or --list",
635
+ hint="e.g. `boost quarantine cowboy-coding`")
636
+
637
+ if args.list_mode:
638
+ rows = []
639
+ for name, entry in _iter_installed():
640
+ if not entry.get("quarantined"):
641
+ continue
642
+ evs = journal.events(action="quarantine", subject=name)
643
+ since = util.rel_time(evs[0].get("ts", "")) if evs else "?"
644
+ rows.append((name, entry.get("version", "?"),
645
+ entry.get("tap", "?"), since))
646
+ if not rows:
647
+ out.info("no skills in quarantine")
648
+ return 0
649
+ out.table(rows, headers=("SKILL", "VERSION", "TAP", "SINCE"))
650
+ return 0
651
+
652
+ if args.release:
653
+ name = args.release
654
+ entry = lockfile.get_skill(name)
655
+ if not entry:
656
+ raise BoostError("%s is not installed" % name,
657
+ hint="see what is with `boost list`")
658
+ if not entry.get("quarantined"):
659
+ out.warn("%s is not quarantined" % name)
660
+ return 0
661
+ res = store.link_agents(name)
662
+ entry["quarantined"] = False
663
+ entry["agents"] = res.linked
664
+ lockfile.set_skill(name, entry)
665
+ journal.log("release", name)
666
+ out.ok("released %s (linked: %s)" % (name, ", ".join(res.linked) or "none"))
667
+ return 0
668
+
669
+ name = args.name
670
+ entry = lockfile.get_skill(name)
671
+ if not entry:
672
+ raise BoostError("%s is not installed" % name,
673
+ hint="see what is with `boost list`")
674
+ if entry.get("quarantined"):
675
+ out.warn("%s is already quarantined" % name)
676
+ return 0
677
+ store.unlink_agents(name)
678
+ entry["quarantined"] = True
679
+ lockfile.set_skill(name, entry)
680
+ journal.log("quarantine", name)
681
+ out.ok("quarantined %s (store intact, links removed)" % name)
682
+ return 0
683
+
684
+
685
+ def cmd_decay(argv):
686
+ ap = argparse.ArgumentParser(
687
+ prog="boost decay",
688
+ description="Flag skills irrelevant to your current stack")
689
+ ap.add_argument("--json", action="store_true")
690
+ args = ap.parse_args(argv)
691
+
692
+ rows = _decay_rows(Path.cwd())
693
+ if args.json:
694
+ print(json.dumps({"skills": rows}))
695
+ return 0
696
+ if not rows:
697
+ out.info("no skills installed")
698
+ return 0
699
+ rel_style = {"none": out.RED, "low": out.YELLOW, "ok": out.GREEN}
700
+ verdicts = {"decay": out.c("decay candidate", out.RED),
701
+ "review": out.c("review", out.YELLOW),
702
+ "ok": out.c("ok", out.GREEN)}
703
+ out.table([(r["name"], out.c(r["relevance"], rel_style[r["relevance"]]),
704
+ r["last_activity"], verdicts[r["verdict"]]) for r in rows],
705
+ headers=("SKILL", "RELEVANCE", "LAST ACTIVITY", "VERDICT"))
706
+ n_decay = sum(1 for r in rows if r["verdict"] == "decay")
707
+ n_review = sum(1 for r in rows if r["verdict"] == "review")
708
+ out.info("%d decay candidate%s · %d to review · %d ok"
709
+ % (n_decay, _s(n_decay), n_review,
710
+ len(rows) - n_decay - n_review))
711
+ if n_decay:
712
+ print(out.c(" isolate one with `boost quarantine <name>`", out.DIM))
713
+ return 0
714
+
715
+
716
+ def cmd_heal(argv):
717
+ ap = argparse.ArgumentParser(
718
+ prog="boost heal",
719
+ description="Self-diagnose & repair the boost environment")
720
+ ap.add_argument("--dry-run", action="store_true",
721
+ help="show repairs without applying them")
722
+ args = ap.parse_args(argv)
723
+ dry = args.dry_run
724
+ actions: List[str] = []
725
+
726
+ wanted = [paths.boost_home(), paths.repos_dir(), paths.cache_dir(),
727
+ paths.logs_dir(), paths.state_dir(), paths.snapshots_dir(),
728
+ paths.lock_history_dir(), paths.profiles_dir(),
729
+ paths.store_dir()] + list(agents.enabled_agents().values())
730
+ missing = [d for d in wanted if not d.is_dir()]
731
+ if missing:
732
+ if dry:
733
+ out.info("would create %d missing director%s"
734
+ % (len(missing), "y" if len(missing) == 1 else "ies"))
735
+ else:
736
+ paths.ensure_dirs()
737
+ agents.ensure_agent_dirs()
738
+ out.ok("created %d missing director%s"
739
+ % (len(missing), "y" if len(missing) == 1 else "ies"))
740
+ actions.append("mkdir %d" % len(missing))
741
+
742
+ for link in _broken_links():
743
+ if dry:
744
+ out.info("would remove broken link %s" % _tilde(link))
745
+ else:
746
+ link.unlink()
747
+ out.ok("removed broken link %s" % _tilde(link))
748
+ actions.append("unlink %s" % link.name)
749
+
750
+ plan = store.sync_plan()
751
+ if dry:
752
+ for name, agent in plan["missing_links"]:
753
+ out.info("would link %s → %s" % (name, agent))
754
+ actions.append("link %s" % name)
755
+ for p in plan["stale_links"]:
756
+ out.info("would remove stale link %s" % _tilde(p))
757
+ actions.append("stale %s" % p)
758
+ for name in plan["missing_store"]:
759
+ out.info("would restore %s from its tap (or drop it from the lock)" % name)
760
+ actions.append("restore %s" % name)
761
+ else:
762
+ for msg in store.sync_apply(plan):
763
+ out.ok(msg.replace(str(paths.home()), "~"))
764
+ actions.append(msg)
765
+
766
+ for tap in registry.list_taps():
767
+ if not tap.is_cloned:
768
+ out.warn("tap %s not cloned — skipped (run `boost update`)" % tap.name)
769
+ continue
770
+ had_cache = tap.cache_file.exists()
771
+ if dry:
772
+ if not had_cache:
773
+ out.info("would rebuild catalog cache for %s" % tap.name)
774
+ actions.append("cache %s" % tap.name)
775
+ else:
776
+ catalog.rebuild_tap(tap)
777
+ if not had_cache:
778
+ out.ok("rebuilt catalog cache for %s" % tap.name)
779
+ actions.append("cache %s" % tap.name)
780
+
781
+ if not journal.rotation_healthy():
782
+ if dry:
783
+ out.info("would rotate the journal")
784
+ else:
785
+ out.ok("journal rotation scheduled (next write rotates)")
786
+ actions.append("rotate")
787
+
788
+ if not actions:
789
+ out.ok("nothing to heal")
790
+ elif not dry:
791
+ journal.log("heal", "%d actions" % len(actions))
792
+ return 0
793
+
794
+
795
+ def cmd_conflict(argv):
796
+ ap = argparse.ArgumentParser(
797
+ prog="boost conflict",
798
+ description="Detect contradictory rules between skills")
799
+ ap.add_argument("--json", action="store_true")
800
+ args = ap.parse_args(argv)
801
+
802
+ installed = _iter_installed()
803
+ rules = [] # (skill, line, polarity, stem set)
804
+ declared = []
805
+ installed_names = {n for n, _e in installed}
806
+ for name, _entry in installed:
807
+ meta, body = _read_skill(store.skill_store_dir(name))
808
+ conflicts = meta.get("conflicts") or []
809
+ for other in (conflicts if isinstance(conflicts, list) else [conflicts]):
810
+ if str(other) in installed_names and str(other) != name:
811
+ declared.append((name, str(other)))
812
+ for raw in body.splitlines():
813
+ m = _RULE_RE.match(raw)
814
+ if not m:
815
+ continue
816
+ modal = re.sub(r"\s+", " ", m.group(1).lower())
817
+ polarity = "neg" if modal in _NEG_MODALS else "pos"
818
+ toks = [_norm_token(t) for t in re.findall(r"[a-z0-9']+",
819
+ m.group(2).lower())]
820
+ if any(t in _NEGATORS for t in toks):
821
+ polarity = "neg" if polarity == "pos" else "pos"
822
+ stem = {t for t in toks
823
+ if t and t not in _STOPWORDS and t not in _NEGATORS}
824
+ if stem:
825
+ rules.append((name, raw.strip(), polarity, stem))
826
+
827
+ pairs, seen = [], set()
828
+ for da, db in declared:
829
+ key = tuple(sorted((da, db))) + ("declared",)
830
+ if key in seen:
831
+ continue
832
+ seen.add(key)
833
+ pairs.append({"kind": "declared", "a": da, "b": db,
834
+ "a_line": "frontmatter declares conflicts: %s" % db,
835
+ "b_line": ""})
836
+ for a_skill, a_line, a_pol, a_stem in rules:
837
+ for b_skill, b_line, b_pol, b_stem in rules:
838
+ if a_skill == b_skill or not (a_pol == "pos" and b_pol == "neg"):
839
+ continue
840
+ small = min(len(a_stem), len(b_stem))
841
+ if small and len(a_stem & b_stem) / small >= _CONFLICT_OVERLAP:
842
+ key = tuple(sorted(((a_skill, a_line), (b_skill, b_line))))
843
+ if key in seen:
844
+ continue
845
+ seen.add(key)
846
+ pairs.append({"kind": "heuristic", "a": a_skill, "b": b_skill,
847
+ "a_line": a_line, "b_line": b_line})
848
+
849
+ heuristic = [p for p in pairs if p["kind"] == "heuristic"]
850
+ if heuristic and ai.available():
851
+ listing = "\n".join("%d. %s: %r vs %s: %r"
852
+ % (i, p["a"], p["a_line"], p["b"], p["b_line"])
853
+ for i, p in enumerate(heuristic, 1))
854
+ reply = ai.ask(
855
+ "These pairs of coding-skill rules were flagged as possibly "
856
+ "contradictory:\n%s\nWhich numbered pairs are genuine "
857
+ "contradictions? Reply with the numbers only, comma-separated, "
858
+ "or 'none'." % listing, max_tokens=100)
859
+ if reply:
860
+ confirmed = {int(x) for x in re.findall(r"\d+", reply)}
861
+ for i, p in enumerate(heuristic, 1):
862
+ if i in confirmed:
863
+ p["kind"] = "ai-confirmed"
864
+ elif heuristic and not args.json:
865
+ out.warn(ai.fallback_note())
866
+
867
+ if args.json:
868
+ print(json.dumps({"pairs": pairs}))
869
+ return 1 if pairs else 0
870
+ if not pairs:
871
+ out.ok("no contradictory rules across %d skill%s"
872
+ % (len(installed), _s(len(installed))))
873
+ return 0
874
+ out.heading("rule conflicts")
875
+ for p in pairs:
876
+ out.warn("%s ↔ %s (%s)" % (p["a"], p["b"], p["kind"]))
877
+ print(" " + out.c("%s: %s" % (p["a"], p["a_line"]), out.DIM))
878
+ if p["b_line"]:
879
+ print(" " + out.c("%s: %s" % (p["b"], p["b_line"]), out.DIM))
880
+ out.info("%d conflict pair%s found" % (len(pairs), _s(len(pairs))))
881
+ return 1
882
+
883
+
884
+ def cmd_changelog(argv):
885
+ ap = argparse.ArgumentParser(
886
+ prog="boost changelog",
887
+ description="Show a skill's upstream change history")
888
+ ap.add_argument("name", metavar="NAME")
889
+ ap.add_argument("-n", type=int, default=20, metavar="N",
890
+ help="number of entries (default 20)")
891
+ args = ap.parse_args(argv)
892
+
893
+ entry = lockfile.get_skill(args.name)
894
+ if entry:
895
+ tap_name, rel = entry.get("tap", ""), entry.get("source_dir", ".")
896
+ else:
897
+ e = catalog.resolve_one(args.name)
898
+ tap_name, rel = e["tap"], e["rel_dir"]
899
+ if tap_name == "local":
900
+ out.info("no upstream history — %s was imported locally" % args.name)
901
+ return 0
902
+ tap = registry.get(tap_name)
903
+ if not tap.is_cloned:
904
+ raise BoostError("tap %s is not cloned" % tap.name,
905
+ hint="run `boost update %s`" % tap.name)
906
+ lines = gitutil.log_for_path(tap.path, rel, args.n)
907
+ out.heading("changelog for %s (%s)" % (args.name, tap.name))
908
+ for line in lines:
909
+ out.info(line)
910
+ if not lines:
911
+ out.warn("no history found for %s in %s" % (rel, tap.name))
912
+ if len(lines) < 3:
913
+ print(out.c(" (shallow clone: run `git -C %s fetch --unshallow` "
914
+ "for full history)" % _tilde(tap.path), out.DIM))
915
+ return 0
916
+
917
+
918
+ def cmd_attest(argv):
919
+ ap = argparse.ArgumentParser(
920
+ prog="boost attest",
921
+ description="Display/verify the install record for skills")
922
+ ap.add_argument("name", nargs="?", metavar="NAME")
923
+ ap.add_argument("--verify", action="store_true",
924
+ help="check sha & journal record for each skill")
925
+ ap.add_argument("--json", action="store_true")
926
+ args = ap.parse_args(argv)
927
+
928
+ targets = _iter_installed([args.name] if args.name else None)
929
+ first_install: dict = {}
930
+ for e in journal.events(): # most-recent-first; oldest wins by overwrite
931
+ if e.get("action") in ("install", "import") and e.get("subject"):
932
+ first_install[e["subject"]] = e
933
+
934
+ records, failures = [], 0
935
+ for name, entry in targets:
936
+ ev = first_install.get(name)
937
+ rec = {"name": name,
938
+ "who": (ev or {}).get("user", "?"),
939
+ "when": entry.get("installed_at", "?"),
940
+ "tap": entry.get("tap", "?"),
941
+ "commit": (entry.get("commit") or "")[:9],
942
+ "sha256": (entry.get("sha256") or "")[:12]}
943
+ if args.verify:
944
+ sdir = store.skill_store_dir(name)
945
+ rec["sha_ok"] = (sdir.is_dir()
946
+ and util.sha256_dir(sdir) == entry.get("sha256"))
947
+ rec["journal"] = ev is not None
948
+ if not rec["sha_ok"]:
949
+ failures += 1
950
+ records.append(rec)
951
+
952
+ if args.json:
953
+ print(json.dumps({"skills": records, "failed": failures}))
954
+ return 1 if (args.verify and failures) else 0
955
+ if not records:
956
+ out.info("no skills installed")
957
+ return 0
958
+ out.table([(r["name"], r["who"], util.rel_time(r["when"]), r["tap"],
959
+ r["commit"] or "-", r["sha256"]) for r in records],
960
+ headers=("SKILL", "WHO", "WHEN", "TAP", "COMMIT", "SHA"))
961
+ if args.verify:
962
+ for r in records:
963
+ if not r["sha_ok"]:
964
+ out.warn("%s: store content no longer matches the lock sha" % r["name"])
965
+ elif not r["journal"]:
966
+ out.warn("%s: no journal record (installed before journaling?)" % r["name"])
967
+ else:
968
+ out.ok("%s attestation OK" % r["name"])
969
+ return 1 if failures else 0
970
+ return 0
971
+
972
+
973
+ def cmd_health(argv):
974
+ ap = argparse.ArgumentParser(
975
+ prog="boost health", description="Dashboard of skill-environment health")
976
+ ap.parse_args(argv)
977
+
978
+ installed = _iter_installed()
979
+ quarantined = sum(1 for _n, e in installed if e.get("quarantined"))
980
+ pinned = sum(1 for _n, e in installed if e.get("pinned"))
981
+ taps = registry.list_taps()
982
+ cloned = [t for t in taps if t.is_cloned]
983
+
984
+ out.heading("boost health")
985
+ out.kv("skills", "%d installed · %d quarantined · %d pinned"
986
+ % (len(installed), quarantined, pinned))
987
+ out.kv("taps", "%d configured · %d cloned" % (len(taps), len(cloned)))
988
+
989
+ expected = [n for n, e in installed if not e.get("quarantined")]
990
+ coverage_ok = True
991
+ for agent, adir in agents.enabled_agents().items():
992
+ linked = sum(1 for n in expected
993
+ if (adir / n).is_symlink() and (adir / n).exists())
994
+ full = linked == len(expected)
995
+ coverage_ok = coverage_ok and full
996
+ out.kv(agent, "%d/%d %s" % (linked, len(expected),
997
+ out.c("✓", out.GREEN) if full
998
+ else out.c("!", out.YELLOW)))
999
+
1000
+ drift_counts: dict = {}
1001
+ for name, entry in installed:
1002
+ st = _drift_status(name, entry)
1003
+ drift_counts[st] = drift_counts.get(st, 0) + 1
1004
+ out.kv("drift", " · ".join("%d %s" % (n, s)
1005
+ for s, n in sorted(drift_counts.items())) or "—")
1006
+
1007
+ decay_n = sum(1 for r in _decay_rows(Path.cwd()) if r["verdict"] == "decay")
1008
+ out.kv("decay", "%d candidate%s" % (decay_n, _s(decay_n)))
1009
+
1010
+ broken = _broken_links()
1011
+ out.kv("broken links", str(len(broken)))
1012
+
1013
+ last_sync = "never"
1014
+ if cloned and gitutil.has_git():
1015
+ stamps = []
1016
+ for tap in cloned:
1017
+ proc = gitutil.run(["-C", str(tap.path), "log", "-1", "--format=%ct"],
1018
+ check=False)
1019
+ if proc.returncode == 0 and proc.stdout.strip().isdigit():
1020
+ stamps.append(int(proc.stdout.strip()))
1021
+ if stamps:
1022
+ iso = datetime.fromtimestamp(max(stamps), tz=timezone.utc
1023
+ ).strftime("%Y-%m-%dT%H:%M:%SZ")
1024
+ last_sync = util.rel_time(iso)
1025
+ out.kv("last tap sync", last_sync)
1026
+
1027
+ week_ago = datetime.now(timezone.utc) - timedelta(days=7)
1028
+ recent = sum(1 for e in journal.events()
1029
+ if (_parse_ts(e.get("ts", "")) or week_ago) > week_ago)
1030
+ out.kv("journal (7d)", "%d event%s" % (recent, _s(recent)))
1031
+ out.kv("fingerprint", _fingerprint()[0][:16])
1032
+
1033
+ attention = (bool(broken) or not coverage_ok
1034
+ or drift_counts.get("store-missing", 0) > 0
1035
+ or drift_counts.get("source-missing", 0) > 0
1036
+ or not journal.rotation_healthy())
1037
+ if attention:
1038
+ print(" " + out.c("● needs attention (run boost doctor)", out.YELLOW))
1039
+ else:
1040
+ print(" " + out.c("● healthy", out.GREEN))
1041
+ return 0