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.
- boost_cli/__init__.py +5 -0
- boost_cli/__main__.py +8 -0
- boost_cli/cli.py +213 -0
- boost_cli/commands/__init__.py +0 -0
- boost_cli/commands/configuration.py +1087 -0
- boost_cli/commands/discovery.py +650 -0
- boost_cli/commands/info.py +595 -0
- boost_cli/commands/intelligence.py +1048 -0
- boost_cli/commands/pkg.py +828 -0
- boost_cli/commands/quality.py +1041 -0
- boost_cli/commands/taps.py +203 -0
- boost_cli/commands/team.py +679 -0
- boost_cli/core/__init__.py +0 -0
- boost_cli/core/agents.py +33 -0
- boost_cli/core/ai.py +115 -0
- boost_cli/core/catalog.py +144 -0
- boost_cli/core/config.py +112 -0
- boost_cli/core/frontmatter.py +160 -0
- boost_cli/core/gitutil.py +69 -0
- boost_cli/core/journal.py +70 -0
- boost_cli/core/lockfile.py +116 -0
- boost_cli/core/output.py +99 -0
- boost_cli/core/paths.py +111 -0
- boost_cli/core/policy.py +63 -0
- boost_cli/core/registry.py +112 -0
- boost_cli/core/store.py +247 -0
- boost_cli/core/util.py +139 -0
- boost_cli/errors.py +11 -0
- boost_skill_cli-1.0.1.dist-info/METADATA +831 -0
- boost_skill_cli-1.0.1.dist-info/RECORD +34 -0
- boost_skill_cli-1.0.1.dist-info/WHEEL +5 -0
- boost_skill_cli-1.0.1.dist-info/entry_points.txt +2 -0
- boost_skill_cli-1.0.1.dist-info/licenses/LICENSE +675 -0
- boost_skill_cli-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -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
|