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,1087 @@
|
|
|
1
|
+
"""Configuration commands: config, clean, create, policy, onboard,
|
|
2
|
+
completions, schedule, serve, mcp, self-update."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import errno
|
|
7
|
+
import getpass
|
|
8
|
+
import html
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
import urllib.parse
|
|
17
|
+
from datetime import datetime, timedelta
|
|
18
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .. import __version__
|
|
22
|
+
from ..core import agents, catalog, config, frontmatter, gitutil, journal
|
|
23
|
+
from ..core import lockfile, paths, policy, registry, store, util
|
|
24
|
+
from ..core import output as out
|
|
25
|
+
from ..errors import BoostError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _tilde(p) -> str:
|
|
29
|
+
"""Contract $HOME to ~ in a path-ish string for display."""
|
|
30
|
+
s = str(p)
|
|
31
|
+
for h in {str(paths.home()), str(paths.home().resolve())}:
|
|
32
|
+
if s == h:
|
|
33
|
+
return "~"
|
|
34
|
+
if s.startswith(h + os.sep):
|
|
35
|
+
return "~" + s[len(h):]
|
|
36
|
+
return s
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _user() -> str:
|
|
40
|
+
try:
|
|
41
|
+
return getpass.getuser()
|
|
42
|
+
except Exception:
|
|
43
|
+
return "unknown"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------- config
|
|
47
|
+
|
|
48
|
+
def cmd_config(argv) -> int:
|
|
49
|
+
"""boost config [list|get KEY|set KEY VALUE|unset KEY] [--json]"""
|
|
50
|
+
p = argparse.ArgumentParser(
|
|
51
|
+
prog="boost config",
|
|
52
|
+
description="Display or modify boost configuration")
|
|
53
|
+
p.add_argument("action", nargs="?", default="list",
|
|
54
|
+
choices=("list", "get", "set", "unset"),
|
|
55
|
+
help="what to do (default: list)")
|
|
56
|
+
p.add_argument("key", nargs="?", help="dotted key, e.g. ai.enabled")
|
|
57
|
+
p.add_argument("value", nargs="?", help="new value (JSON or string)")
|
|
58
|
+
p.add_argument("--json", action="store_true",
|
|
59
|
+
help="machine-readable output")
|
|
60
|
+
args = p.parse_args(argv)
|
|
61
|
+
if args.action in ("get", "set", "unset") and not args.key:
|
|
62
|
+
raise BoostError("config %s requires a KEY" % args.action,
|
|
63
|
+
hint="e.g. `boost config %s ai.enabled`" % args.action)
|
|
64
|
+
if args.action == "set" and args.value is None:
|
|
65
|
+
raise BoostError("config set requires a VALUE",
|
|
66
|
+
hint="e.g. `boost config set ai.enabled false`")
|
|
67
|
+
|
|
68
|
+
if args.action == "list":
|
|
69
|
+
cfg = config.load()
|
|
70
|
+
print(json.dumps(cfg, indent=2))
|
|
71
|
+
if not args.json:
|
|
72
|
+
out.dim(" " + _tilde(paths.config_path()))
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
if args.action == "get":
|
|
76
|
+
missing = object()
|
|
77
|
+
val = config.get(args.key, missing)
|
|
78
|
+
if val is missing:
|
|
79
|
+
raise BoostError("no config key %r" % args.key,
|
|
80
|
+
hint="see `boost config list`")
|
|
81
|
+
if args.json:
|
|
82
|
+
print(json.dumps(val))
|
|
83
|
+
elif isinstance(val, str):
|
|
84
|
+
print(val)
|
|
85
|
+
else:
|
|
86
|
+
print(json.dumps(val, indent=2))
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
if args.action == "set":
|
|
90
|
+
try:
|
|
91
|
+
config.set_value(args.key, args.value)
|
|
92
|
+
except TypeError as e:
|
|
93
|
+
raise BoostError(str(e),
|
|
94
|
+
hint="the parent key holds a plain value — "
|
|
95
|
+
"`boost config unset` it first")
|
|
96
|
+
val = config.get(args.key)
|
|
97
|
+
journal.log("config", args.key, op="set")
|
|
98
|
+
out.ok("set %s = %s"
|
|
99
|
+
% (args.key, val if isinstance(val, str) else json.dumps(val)))
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
# unset
|
|
103
|
+
if config.unset(args.key):
|
|
104
|
+
journal.log("config", args.key, op="unset")
|
|
105
|
+
out.ok("unset %s" % args.key)
|
|
106
|
+
else:
|
|
107
|
+
out.info("%s not set" % args.key)
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------- clean
|
|
112
|
+
|
|
113
|
+
def cmd_clean(argv) -> int:
|
|
114
|
+
"""boost clean [--dry-run] [--deep]"""
|
|
115
|
+
p = argparse.ArgumentParser(
|
|
116
|
+
prog="boost clean",
|
|
117
|
+
description="Clear stale caches & broken symlinks")
|
|
118
|
+
p.add_argument("--dry-run", action="store_true",
|
|
119
|
+
help="show what would be removed without touching anything")
|
|
120
|
+
p.add_argument("--deep", action="store_true",
|
|
121
|
+
help="also remove snapshots older than 90 days")
|
|
122
|
+
args = p.parse_args(argv)
|
|
123
|
+
|
|
124
|
+
items = [] # (path, kind, bytes)
|
|
125
|
+
for _agent, spec in agents.known_agents().items():
|
|
126
|
+
adir = spec["dir"]
|
|
127
|
+
if not adir.is_dir():
|
|
128
|
+
continue
|
|
129
|
+
for link in sorted(adir.iterdir()):
|
|
130
|
+
if link.is_symlink() and not link.exists():
|
|
131
|
+
items.append((link, "broken symlink", 0))
|
|
132
|
+
|
|
133
|
+
configured = {t.safe_name for t in registry.list_taps()}
|
|
134
|
+
if paths.cache_dir().is_dir():
|
|
135
|
+
for f in sorted(paths.cache_dir().glob("*.json")):
|
|
136
|
+
if f.stem not in configured:
|
|
137
|
+
items.append((f, "stale tap cache", f.stat().st_size))
|
|
138
|
+
|
|
139
|
+
if paths.lock_history_dir().is_dir():
|
|
140
|
+
snaps = sorted(paths.lock_history_dir().glob("lock-*.json"))
|
|
141
|
+
for old in snaps[:-50]:
|
|
142
|
+
items.append((old, "old lock history", old.stat().st_size))
|
|
143
|
+
|
|
144
|
+
if paths.store_dir().is_dir():
|
|
145
|
+
for pth in sorted(paths.store_dir().rglob("*")):
|
|
146
|
+
if pth.name == "__pycache__" and pth.is_dir():
|
|
147
|
+
items.append((pth, "__pycache__", util.dir_size(pth)))
|
|
148
|
+
elif pth.name == ".DS_Store" and pth.is_file():
|
|
149
|
+
items.append((pth, ".DS_Store", pth.stat().st_size))
|
|
150
|
+
|
|
151
|
+
if args.deep and paths.snapshots_dir().is_dir():
|
|
152
|
+
cutoff = time.time() - 90 * 86400
|
|
153
|
+
old_snaps = [s for s in sorted(paths.snapshots_dir().iterdir())
|
|
154
|
+
if s.lstat().st_mtime < cutoff]
|
|
155
|
+
if old_snaps and not args.dry_run and not out.confirm(
|
|
156
|
+
"remove %d snapshot(s) older than 90 days?" % len(old_snaps)):
|
|
157
|
+
out.info("keeping old snapshots")
|
|
158
|
+
old_snaps = []
|
|
159
|
+
for s in old_snaps:
|
|
160
|
+
size = util.dir_size(s) if s.is_dir() else s.lstat().st_size
|
|
161
|
+
items.append((s, "old snapshot", size))
|
|
162
|
+
|
|
163
|
+
if not items:
|
|
164
|
+
out.ok("nothing to clean")
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
verb = "would remove" if args.dry_run else "removed"
|
|
168
|
+
freed = 0
|
|
169
|
+
for pth, kind, size in items:
|
|
170
|
+
if not args.dry_run:
|
|
171
|
+
try:
|
|
172
|
+
if pth.is_symlink() or pth.is_file():
|
|
173
|
+
pth.unlink()
|
|
174
|
+
elif pth.is_dir():
|
|
175
|
+
shutil.rmtree(pth)
|
|
176
|
+
except OSError as e:
|
|
177
|
+
out.warn("could not remove %s: %s" % (_tilde(pth), e))
|
|
178
|
+
continue
|
|
179
|
+
freed += size
|
|
180
|
+
out.info("%s %s %s" % (verb, _tilde(pth), out.c("(%s)" % kind, out.DIM)))
|
|
181
|
+
if args.dry_run:
|
|
182
|
+
out.dim(" %d item(s) · %s would be freed" % (len(items), util.human_size(freed)))
|
|
183
|
+
else:
|
|
184
|
+
journal.log("clean", "%d items" % len(items), freed=util.human_size(freed))
|
|
185
|
+
out.ok("cleaned %d item(s) · %s freed" % (len(items), util.human_size(freed)))
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------- create
|
|
190
|
+
|
|
191
|
+
_CREATE_BODY = """# %(title)s
|
|
192
|
+
|
|
193
|
+
## When to use
|
|
194
|
+
|
|
195
|
+
TODO: describe the situations where this skill should activate.
|
|
196
|
+
|
|
197
|
+
## Instructions
|
|
198
|
+
|
|
199
|
+
1. TODO: first step the assistant should take
|
|
200
|
+
2. TODO: second step
|
|
201
|
+
3. TODO: third step
|
|
202
|
+
|
|
203
|
+
## Rules
|
|
204
|
+
|
|
205
|
+
- TODO: something the assistant must always do
|
|
206
|
+
- TODO: something the assistant must never do
|
|
207
|
+
|
|
208
|
+
## Examples
|
|
209
|
+
|
|
210
|
+
```text
|
|
211
|
+
TODO: a concrete input/output example
|
|
212
|
+
```
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def cmd_create(argv) -> int:
|
|
217
|
+
"""boost create NAME [--description D] [--dir DIR] [--install]"""
|
|
218
|
+
p = argparse.ArgumentParser(
|
|
219
|
+
prog="boost create",
|
|
220
|
+
description="Scaffold a new skill from a template")
|
|
221
|
+
p.add_argument("name", help="skill name (slugified)")
|
|
222
|
+
p.add_argument("--description", default=None,
|
|
223
|
+
help="one-line trigger description for the frontmatter")
|
|
224
|
+
p.add_argument("--dir", default=None,
|
|
225
|
+
help="parent directory (default: current directory)")
|
|
226
|
+
p.add_argument("--install", action="store_true",
|
|
227
|
+
help="install the new skill immediately")
|
|
228
|
+
args = p.parse_args(argv)
|
|
229
|
+
|
|
230
|
+
name = util.slugify(args.name)
|
|
231
|
+
parent = paths.expand(args.dir) if args.dir else Path.cwd()
|
|
232
|
+
target = parent / name
|
|
233
|
+
skill_md = target / "SKILL.md"
|
|
234
|
+
if skill_md.exists():
|
|
235
|
+
raise BoostError("%s already exists" % _tilde(skill_md),
|
|
236
|
+
hint="pick another name or --dir, or edit the existing file")
|
|
237
|
+
|
|
238
|
+
meta = {
|
|
239
|
+
"name": name,
|
|
240
|
+
"description": args.description
|
|
241
|
+
or "TODO: describe when this skill should trigger",
|
|
242
|
+
"version": "0.1.0",
|
|
243
|
+
}
|
|
244
|
+
title = name.replace("-", " ").title()
|
|
245
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
skill_md.write_text(frontmatter.dump(meta) + "\n\n"
|
|
247
|
+
+ _CREATE_BODY % {"title": title})
|
|
248
|
+
journal.log("create", name, path=str(target))
|
|
249
|
+
out.ok("created %s" % _tilde(skill_md))
|
|
250
|
+
if args.install:
|
|
251
|
+
res = store.install_from_path(target, name=name)
|
|
252
|
+
out.ok("installed %s → %s" % (name, _tilde(res.dest)))
|
|
253
|
+
if res.linked:
|
|
254
|
+
out.info("linked: " + ", ".join(agents.display_name(a) for a in res.linked))
|
|
255
|
+
else:
|
|
256
|
+
out.dim(" next: edit it, then `boost import %s`" % _tilde(target))
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------- policy
|
|
261
|
+
|
|
262
|
+
def _parse_policy_value(key: str, raw: str):
|
|
263
|
+
try:
|
|
264
|
+
return json.loads(raw)
|
|
265
|
+
except (json.JSONDecodeError, TypeError):
|
|
266
|
+
pass
|
|
267
|
+
if isinstance(policy.DEFAULTS.get(key), list):
|
|
268
|
+
return [s.strip() for s in raw.split(",") if s.strip()]
|
|
269
|
+
return raw
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def cmd_policy(argv) -> int:
|
|
273
|
+
"""boost policy [list|set KEY VALUE|unset KEY|check] [--json]"""
|
|
274
|
+
p = argparse.ArgumentParser(
|
|
275
|
+
prog="boost policy",
|
|
276
|
+
description="Manage & enforce skill governance policies")
|
|
277
|
+
p.add_argument("action", nargs="?", default="list",
|
|
278
|
+
choices=("list", "set", "unset", "check"),
|
|
279
|
+
help="what to do (default: list)")
|
|
280
|
+
p.add_argument("key", nargs="?", help="policy key, e.g. min_quality_score")
|
|
281
|
+
p.add_argument("value", nargs="?", help="new value (JSON, comma list, or string)")
|
|
282
|
+
p.add_argument("--json", action="store_true",
|
|
283
|
+
help="machine-readable output")
|
|
284
|
+
args = p.parse_args(argv)
|
|
285
|
+
|
|
286
|
+
if args.action in ("set", "unset"):
|
|
287
|
+
if not args.key:
|
|
288
|
+
raise BoostError("policy %s requires a KEY" % args.action,
|
|
289
|
+
hint="keys: " + ", ".join(sorted(policy.DEFAULTS)))
|
|
290
|
+
if args.key not in policy.DEFAULTS:
|
|
291
|
+
raise BoostError("unknown policy key %r" % args.key,
|
|
292
|
+
hint="keys: " + ", ".join(sorted(policy.DEFAULTS)))
|
|
293
|
+
|
|
294
|
+
if args.action == "list":
|
|
295
|
+
pol = policy.load()
|
|
296
|
+
print(json.dumps(pol, indent=2))
|
|
297
|
+
if not args.json:
|
|
298
|
+
diff = sorted(k for k in policy.DEFAULTS
|
|
299
|
+
if pol.get(k) != policy.DEFAULTS[k])
|
|
300
|
+
out.dim(" modified from defaults: %s" % ", ".join(diff)
|
|
301
|
+
if diff else " all values at defaults")
|
|
302
|
+
return 0
|
|
303
|
+
|
|
304
|
+
if args.action == "set":
|
|
305
|
+
if args.value is None:
|
|
306
|
+
raise BoostError("policy set requires a VALUE",
|
|
307
|
+
hint="e.g. `boost policy set min_quality_score 60`")
|
|
308
|
+
pol = policy.load()
|
|
309
|
+
pol[args.key] = _parse_policy_value(args.key, args.value)
|
|
310
|
+
policy.save(pol)
|
|
311
|
+
journal.log("policy", args.key, op="set")
|
|
312
|
+
out.ok("set %s = %s" % (args.key, json.dumps(pol[args.key])))
|
|
313
|
+
return 0
|
|
314
|
+
|
|
315
|
+
if args.action == "unset":
|
|
316
|
+
pol = policy.load()
|
|
317
|
+
pol[args.key] = policy.DEFAULTS[args.key]
|
|
318
|
+
policy.save(pol)
|
|
319
|
+
journal.log("policy", args.key, op="unset")
|
|
320
|
+
out.ok("reset %s to default (%s)"
|
|
321
|
+
% (args.key, json.dumps(policy.DEFAULTS[args.key])))
|
|
322
|
+
return 0
|
|
323
|
+
|
|
324
|
+
# check
|
|
325
|
+
pol = policy.load()
|
|
326
|
+
installed = lockfile.installed()
|
|
327
|
+
min_score = int(pol.get("min_quality_score") or 0)
|
|
328
|
+
violations = [] # (skill, problem)
|
|
329
|
+
for name, entry in sorted(installed.items()):
|
|
330
|
+
tap = entry.get("tap", "local")
|
|
331
|
+
if name in pol["blocked_skills"]:
|
|
332
|
+
violations.append((name, "on the blocklist"))
|
|
333
|
+
if tap in pol["blocked_taps"]:
|
|
334
|
+
violations.append((name, "tap %s is blocked" % tap))
|
|
335
|
+
if pol["allowed_taps"] and tap not in pol["allowed_taps"] and tap != "local":
|
|
336
|
+
violations.append((name, "tap %s is not on the allowlist" % tap))
|
|
337
|
+
if min_score:
|
|
338
|
+
score, _notes = util.score_skill(store.skill_store_dir(name))
|
|
339
|
+
if score < min_score:
|
|
340
|
+
violations.append(
|
|
341
|
+
(name, "quality score %d < required %d" % (score, min_score)))
|
|
342
|
+
unpinned = sorted(n for n, e in installed.items() if not e.get("pinned"))
|
|
343
|
+
|
|
344
|
+
if args.json:
|
|
345
|
+
print(json.dumps({
|
|
346
|
+
"skills": len(installed),
|
|
347
|
+
"violations": [{"skill": s, "violation": v} for s, v in violations],
|
|
348
|
+
"pin_only": bool(pol["pin_only"]),
|
|
349
|
+
"unpinned": unpinned if pol["pin_only"] else [],
|
|
350
|
+
}, indent=2))
|
|
351
|
+
return 1 if violations else 0
|
|
352
|
+
|
|
353
|
+
if pol["pin_only"]:
|
|
354
|
+
out.info("pin-only mode is on — installs/updates are frozen"
|
|
355
|
+
+ (" (%d unpinned skill(s): %s)"
|
|
356
|
+
% (len(unpinned), ", ".join(unpinned)) if unpinned else ""))
|
|
357
|
+
if violations:
|
|
358
|
+
out.table(violations, headers=("SKILL", "VIOLATION"))
|
|
359
|
+
print()
|
|
360
|
+
out.err("%d policy violation(s) across %d installed skill(s)"
|
|
361
|
+
% (len(violations), len(installed)),
|
|
362
|
+
hint="adjust with `boost policy set` or remove the offenders")
|
|
363
|
+
return 1
|
|
364
|
+
out.ok("policy check passed (%d skills)" % len(installed))
|
|
365
|
+
return 0
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------- onboard
|
|
369
|
+
|
|
370
|
+
_WORKFLOW_REL = ".github/workflows/boost-skill-inventory.yml"
|
|
371
|
+
_TELEMETRY_REL = ".boost/telemetry.json"
|
|
372
|
+
|
|
373
|
+
_WORKFLOW_YML = """\
|
|
374
|
+
# generated by `boost onboard` — publishes this repo's AI-skill inventory
|
|
375
|
+
name: boost skill inventory
|
|
376
|
+
|
|
377
|
+
on:
|
|
378
|
+
push:
|
|
379
|
+
branches: [main]
|
|
380
|
+
workflow_dispatch: {}
|
|
381
|
+
|
|
382
|
+
jobs:
|
|
383
|
+
inventory:
|
|
384
|
+
runs-on: ubuntu-latest
|
|
385
|
+
steps:
|
|
386
|
+
- uses: actions/checkout@v4
|
|
387
|
+
- name: Report skill count
|
|
388
|
+
if: ${{ hashFiles('.skill-lock.json') != '' }}
|
|
389
|
+
run: |
|
|
390
|
+
python3 -c "import json; d = json.load(open('.skill-lock.json')); print(len(d.get('skills', {})), 'skills tracked')"
|
|
391
|
+
- name: Upload skill inventory
|
|
392
|
+
if: ${{ hashFiles('.skill-lock.json') != '' }}
|
|
393
|
+
uses: actions/upload-artifact@v4
|
|
394
|
+
with:
|
|
395
|
+
name: skill-lock
|
|
396
|
+
path: .skill-lock.json
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def cmd_onboard(argv) -> int:
|
|
401
|
+
"""boost onboard [--repo DIR] [--pr] [--dry-run]"""
|
|
402
|
+
p = argparse.ArgumentParser(
|
|
403
|
+
prog="boost onboard",
|
|
404
|
+
description="Add skill-tracker telemetry to a repo & open a PR")
|
|
405
|
+
p.add_argument("--repo", default=".", help="repository directory (default: .)")
|
|
406
|
+
p.add_argument("--pr", action="store_true",
|
|
407
|
+
help="commit on a branch and open a PR with `gh`")
|
|
408
|
+
p.add_argument("--dry-run", action="store_true",
|
|
409
|
+
help="preview the files without writing anything")
|
|
410
|
+
args = p.parse_args(argv)
|
|
411
|
+
|
|
412
|
+
repo = paths.expand(args.repo).resolve()
|
|
413
|
+
if not repo.is_dir():
|
|
414
|
+
raise BoostError("%s is not a directory" % _tilde(repo),
|
|
415
|
+
hint="point --repo at a checked-out repository")
|
|
416
|
+
|
|
417
|
+
telemetry = json.dumps({
|
|
418
|
+
"enabled": True,
|
|
419
|
+
"share_pulse": True,
|
|
420
|
+
"created": util.now_iso(),
|
|
421
|
+
"by": _user(),
|
|
422
|
+
}, indent=2) + "\n"
|
|
423
|
+
files = [(_TELEMETRY_REL, telemetry), (_WORKFLOW_REL, _WORKFLOW_YML)]
|
|
424
|
+
if repo != paths.store_dir().resolve():
|
|
425
|
+
files.append((".skill-lock.json",
|
|
426
|
+
json.dumps(lockfile.read(), indent=2, sort_keys=True) + "\n"))
|
|
427
|
+
|
|
428
|
+
if args.dry_run:
|
|
429
|
+
for rel, content in files:
|
|
430
|
+
out.heading("would write %s" % _tilde(repo / rel))
|
|
431
|
+
for line in content.splitlines()[:24]:
|
|
432
|
+
out.dim(" " + line)
|
|
433
|
+
return 0
|
|
434
|
+
|
|
435
|
+
if args.pr: # check preconditions FIRST so we never leave the repo mid-state
|
|
436
|
+
if not gitutil.is_repo(repo):
|
|
437
|
+
raise BoostError("%s is not a git repository" % _tilde(repo),
|
|
438
|
+
hint="--pr needs a git checkout with a GitHub remote")
|
|
439
|
+
if gitutil.run(["-C", str(repo), "status", "--porcelain"]).stdout.strip():
|
|
440
|
+
raise BoostError("working tree at %s is not clean" % _tilde(repo),
|
|
441
|
+
hint="commit or stash your changes first")
|
|
442
|
+
if not shutil.which("gh"):
|
|
443
|
+
raise BoostError("the `gh` CLI is required for --pr",
|
|
444
|
+
hint="brew install gh, or rerun without --pr")
|
|
445
|
+
|
|
446
|
+
for rel, content in files:
|
|
447
|
+
fp = repo / rel
|
|
448
|
+
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
449
|
+
fp.write_text(content)
|
|
450
|
+
out.ok("created %s" % _tilde(fp))
|
|
451
|
+
journal.log("onboard", _tilde(repo), pr=args.pr or None)
|
|
452
|
+
|
|
453
|
+
if args.pr:
|
|
454
|
+
branch = "boost/onboard-skill-tracker"
|
|
455
|
+
gitutil.run(["-C", str(repo), "checkout", "-b", branch])
|
|
456
|
+
gitutil.run(["-C", str(repo), "add"] + [rel for rel, _ in files])
|
|
457
|
+
gitutil.run(["-C", str(repo), "commit", "-m",
|
|
458
|
+
"chore: add boost skill tracking (boost onboard)"])
|
|
459
|
+
try:
|
|
460
|
+
proc = subprocess.run(["gh", "pr", "create", "--fill"],
|
|
461
|
+
cwd=str(repo), capture_output=True,
|
|
462
|
+
text=True, timeout=120)
|
|
463
|
+
except (subprocess.TimeoutExpired, OSError) as e:
|
|
464
|
+
raise BoostError("gh pr create failed: %s" % e,
|
|
465
|
+
hint="branch %s is committed — push it and open the PR manually" % branch)
|
|
466
|
+
if proc.returncode != 0:
|
|
467
|
+
tail = (proc.stderr or proc.stdout or "").strip().splitlines()
|
|
468
|
+
raise BoostError("gh pr create failed: %s" % (tail[-1] if tail else "unknown error"),
|
|
469
|
+
hint="branch %s is committed — push it and run `gh pr create --fill`" % branch)
|
|
470
|
+
url = (proc.stdout or "").strip().splitlines()
|
|
471
|
+
out.ok("opened PR%s" % ((" " + url[-1]) if url else ""))
|
|
472
|
+
return 0
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# ---------------------------------------------------------------- completions
|
|
476
|
+
|
|
477
|
+
def _sq(s: str) -> str:
|
|
478
|
+
"""Escape a string for a POSIX/zsh single-quoted context."""
|
|
479
|
+
return s.replace("'", "'\\''")
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def cmd_completions(argv) -> int:
|
|
483
|
+
"""boost completions [bash|zsh|fish]"""
|
|
484
|
+
p = argparse.ArgumentParser(
|
|
485
|
+
prog="boost completions",
|
|
486
|
+
description="Generate shell tab-completion scripts")
|
|
487
|
+
p.add_argument("shell", nargs="?", choices=("bash", "zsh", "fish"),
|
|
488
|
+
default=None, help="target shell (default: from $SHELL)")
|
|
489
|
+
args = p.parse_args(argv)
|
|
490
|
+
from ..cli import COMMANDS
|
|
491
|
+
|
|
492
|
+
shell = args.shell or Path(os.environ.get("SHELL", "")).name
|
|
493
|
+
if shell not in ("bash", "zsh", "fish"):
|
|
494
|
+
shell = "bash"
|
|
495
|
+
|
|
496
|
+
names = [n for n, _g, _m, _s in COMMANDS]
|
|
497
|
+
if shell == "bash":
|
|
498
|
+
lines = ["# boost bash completion",
|
|
499
|
+
'complete -W "%s" boost' % " ".join(names)]
|
|
500
|
+
hint = "boost completions bash >> ~/.bashrc"
|
|
501
|
+
elif shell == "zsh":
|
|
502
|
+
lines = ["#compdef boost", "", "_boost() {", " local -a _boost_commands",
|
|
503
|
+
" _boost_commands=("]
|
|
504
|
+
lines += [" '%s:%s'" % (n, _sq(s)) for n, _g, _m, s in COMMANDS]
|
|
505
|
+
lines += [" )", " if (( CURRENT == 2 )); then",
|
|
506
|
+
" _describe -t commands 'boost command' _boost_commands",
|
|
507
|
+
" else", " _files", " fi", "}", "", '_boost "$@"']
|
|
508
|
+
hint = "boost completions zsh > ~/.zfunc/_boost (with fpath+=~/.zfunc before compinit)"
|
|
509
|
+
else:
|
|
510
|
+
lines = ["# boost fish completion"]
|
|
511
|
+
lines += ["complete -c boost -n __fish_use_subcommand -a %s -d '%s'"
|
|
512
|
+
% (n, s.replace("\\", "\\\\").replace("'", "\\'"))
|
|
513
|
+
for n, _g, _m, s in COMMANDS]
|
|
514
|
+
hint = "boost completions fish > ~/.config/fish/completions/boost.fish"
|
|
515
|
+
print("\n".join(lines))
|
|
516
|
+
out.dim("# install: " + hint)
|
|
517
|
+
return 0
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# ---------------------------------------------------------------- schedule
|
|
521
|
+
|
|
522
|
+
_INTERVALS = {"6h": 21600, "12h": 43200, "daily": 86400}
|
|
523
|
+
_CRON_SPECS = {"6h": "0 */6 * * *", "12h": "0 */12 * * *", "daily": "0 6 * * *"}
|
|
524
|
+
_PLIST_LABEL = "com.boost.sync"
|
|
525
|
+
_CRON_MARK = "# boost-sync"
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _plist_path() -> Path:
|
|
529
|
+
return paths.home() / "Library" / "LaunchAgents" / (_PLIST_LABEL + ".plist")
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _plist_body(shim: Path, seconds: int) -> str:
|
|
533
|
+
log = paths.logs_dir() / "schedule.log"
|
|
534
|
+
return """<?xml version="1.0" encoding="UTF-8"?>
|
|
535
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
536
|
+
<plist version="1.0">
|
|
537
|
+
<dict>
|
|
538
|
+
<key>Label</key><string>%s</string>
|
|
539
|
+
<key>ProgramArguments</key>
|
|
540
|
+
<array>
|
|
541
|
+
<string>%s</string>
|
|
542
|
+
<string>update</string>
|
|
543
|
+
</array>
|
|
544
|
+
<key>StartInterval</key><integer>%d</integer>
|
|
545
|
+
<key>StandardOutPath</key><string>%s</string>
|
|
546
|
+
<key>StandardErrorPath</key><string>%s</string>
|
|
547
|
+
</dict>
|
|
548
|
+
</plist>
|
|
549
|
+
""" % (_PLIST_LABEL, shim, seconds, log, log)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _crontab_lines():
|
|
553
|
+
"""Current crontab lines, [] when empty, None when crontab is unusable."""
|
|
554
|
+
try:
|
|
555
|
+
proc = subprocess.run(["crontab", "-l"], capture_output=True, text=True,
|
|
556
|
+
timeout=30)
|
|
557
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
558
|
+
return None
|
|
559
|
+
if proc.returncode != 0:
|
|
560
|
+
return [] # "no crontab for user"
|
|
561
|
+
return proc.stdout.splitlines()
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _cron_field_ok(field: str, val: int) -> bool:
|
|
565
|
+
if field == "*":
|
|
566
|
+
return True
|
|
567
|
+
if field.startswith("*/"):
|
|
568
|
+
try:
|
|
569
|
+
return val % int(field[2:]) == 0
|
|
570
|
+
except (ValueError, ZeroDivisionError):
|
|
571
|
+
return False
|
|
572
|
+
try:
|
|
573
|
+
return val == int(field)
|
|
574
|
+
except ValueError:
|
|
575
|
+
return False
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _cron_next_run(spec: str):
|
|
579
|
+
"""Best-effort next fire time for a `M H * * *`-shaped cron spec."""
|
|
580
|
+
parts = spec.split()
|
|
581
|
+
if len(parts) < 2:
|
|
582
|
+
return None
|
|
583
|
+
t = datetime.now().replace(second=0, microsecond=0) + timedelta(minutes=1)
|
|
584
|
+
for _ in range(2 * 24 * 60):
|
|
585
|
+
if _cron_field_ok(parts[0], t.minute) and _cron_field_ok(parts[1], t.hour):
|
|
586
|
+
return t
|
|
587
|
+
t += timedelta(minutes=1)
|
|
588
|
+
return None
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _interval_label(seconds) -> str:
|
|
592
|
+
for label, secs in _INTERVALS.items():
|
|
593
|
+
if secs == seconds:
|
|
594
|
+
return label
|
|
595
|
+
return "%ss" % seconds
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def cmd_schedule(argv) -> int:
|
|
599
|
+
"""boost schedule [status|enable [--interval 6h|12h|daily]|disable]"""
|
|
600
|
+
p = argparse.ArgumentParser(
|
|
601
|
+
prog="boost schedule",
|
|
602
|
+
description="Manage automatic skill-sync scheduling")
|
|
603
|
+
p.add_argument("action", nargs="?", default="status",
|
|
604
|
+
choices=("status", "enable", "disable"),
|
|
605
|
+
help="what to do (default: status)")
|
|
606
|
+
p.add_argument("--interval", choices=tuple(_INTERVALS), default="6h",
|
|
607
|
+
help="how often to run `boost update` (default: 6h)")
|
|
608
|
+
p.add_argument("--json", action="store_true",
|
|
609
|
+
help="machine-readable output (status only)")
|
|
610
|
+
args = p.parse_args(argv)
|
|
611
|
+
|
|
612
|
+
darwin = sys.platform == "darwin"
|
|
613
|
+
shim = paths.launcher()
|
|
614
|
+
|
|
615
|
+
if args.action == "status":
|
|
616
|
+
present, interval, next_run = False, None, None
|
|
617
|
+
if darwin:
|
|
618
|
+
plist = _plist_path()
|
|
619
|
+
if plist.exists():
|
|
620
|
+
present = True
|
|
621
|
+
m = re.search(r"<key>StartInterval</key>\s*<integer>(\d+)</integer>",
|
|
622
|
+
plist.read_text())
|
|
623
|
+
if m:
|
|
624
|
+
secs = int(m.group(1))
|
|
625
|
+
interval = _interval_label(secs)
|
|
626
|
+
nxt = datetime.fromtimestamp(plist.stat().st_mtime + secs)
|
|
627
|
+
while nxt < datetime.now():
|
|
628
|
+
nxt += timedelta(seconds=secs)
|
|
629
|
+
next_run = nxt
|
|
630
|
+
else:
|
|
631
|
+
lines = _crontab_lines() or []
|
|
632
|
+
job = next((ln for ln in lines if ln.rstrip().endswith(_CRON_MARK)), None)
|
|
633
|
+
if job:
|
|
634
|
+
present = True
|
|
635
|
+
spec = " ".join(job.split()[:5])
|
|
636
|
+
interval = next((lbl for lbl, s in _CRON_SPECS.items() if s == spec),
|
|
637
|
+
spec)
|
|
638
|
+
next_run = _cron_next_run(spec)
|
|
639
|
+
if args.json:
|
|
640
|
+
print(json.dumps({
|
|
641
|
+
"platform": sys.platform,
|
|
642
|
+
"backend": "launchd" if darwin else "cron",
|
|
643
|
+
"scheduled": present,
|
|
644
|
+
"interval": interval,
|
|
645
|
+
"next_run": next_run.strftime("%Y-%m-%d %H:%M") if next_run else None,
|
|
646
|
+
}, indent=2))
|
|
647
|
+
return 0
|
|
648
|
+
out.kv("platform", "%s (%s)" % (sys.platform, "launchd" if darwin else "cron"))
|
|
649
|
+
out.kv("scheduled", "yes" if present else "no")
|
|
650
|
+
if present:
|
|
651
|
+
out.kv("interval", "every %s" % interval)
|
|
652
|
+
out.kv("next run", next_run.strftime("%Y-%m-%d %H:%M (approx)")
|
|
653
|
+
if next_run else "unknown")
|
|
654
|
+
else:
|
|
655
|
+
out.dim(" enable with `boost schedule enable --interval 6h|12h|daily`")
|
|
656
|
+
return 0
|
|
657
|
+
|
|
658
|
+
if args.action == "enable":
|
|
659
|
+
seconds = _INTERVALS[args.interval]
|
|
660
|
+
paths.ensure_dirs()
|
|
661
|
+
if darwin:
|
|
662
|
+
plist = _plist_path()
|
|
663
|
+
plist.parent.mkdir(parents=True, exist_ok=True)
|
|
664
|
+
plist.write_text(_plist_body(shim, seconds))
|
|
665
|
+
out.ok("wrote %s" % _tilde(plist))
|
|
666
|
+
try:
|
|
667
|
+
subprocess.run(["launchctl", "unload", str(plist)],
|
|
668
|
+
capture_output=True, text=True, timeout=30)
|
|
669
|
+
proc = subprocess.run(["launchctl", "load", "-w", str(plist)],
|
|
670
|
+
capture_output=True, text=True, timeout=30)
|
|
671
|
+
if proc.returncode != 0:
|
|
672
|
+
tail = (proc.stderr or "").strip().splitlines()
|
|
673
|
+
out.warn("launchctl load failed: %s"
|
|
674
|
+
% (tail[-1] if tail else "unknown error"))
|
|
675
|
+
out.dim(" load it manually: launchctl load -w %s" % _tilde(plist))
|
|
676
|
+
else:
|
|
677
|
+
out.ok("`boost update` scheduled every %s" % args.interval)
|
|
678
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
679
|
+
out.warn("launchctl unavailable — the agent loads at next login")
|
|
680
|
+
else:
|
|
681
|
+
lines = _crontab_lines()
|
|
682
|
+
entry = "%s %s update >> %s 2>&1 %s" % (
|
|
683
|
+
_CRON_SPECS[args.interval], shim,
|
|
684
|
+
paths.logs_dir() / "schedule.log", _CRON_MARK)
|
|
685
|
+
if lines is None:
|
|
686
|
+
out.warn("crontab is not available — add this line yourself:")
|
|
687
|
+
out.info(entry)
|
|
688
|
+
else:
|
|
689
|
+
kept = [ln for ln in lines if not ln.rstrip().endswith(_CRON_MARK)]
|
|
690
|
+
try:
|
|
691
|
+
proc = subprocess.run(["crontab", "-"],
|
|
692
|
+
input="\n".join(kept + [entry]) + "\n",
|
|
693
|
+
capture_output=True, text=True, timeout=30)
|
|
694
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
695
|
+
proc = None
|
|
696
|
+
if proc is None or proc.returncode != 0:
|
|
697
|
+
out.warn("could not write crontab — add this line yourself:")
|
|
698
|
+
out.info(entry)
|
|
699
|
+
else:
|
|
700
|
+
out.ok("`boost update` scheduled every %s via cron" % args.interval)
|
|
701
|
+
journal.log("schedule", "enable", interval=args.interval)
|
|
702
|
+
return 0
|
|
703
|
+
|
|
704
|
+
# disable
|
|
705
|
+
removed = False
|
|
706
|
+
if darwin:
|
|
707
|
+
plist = _plist_path()
|
|
708
|
+
if plist.exists():
|
|
709
|
+
try:
|
|
710
|
+
subprocess.run(["launchctl", "unload", "-w", str(plist)],
|
|
711
|
+
capture_output=True, text=True, timeout=30)
|
|
712
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
713
|
+
out.warn("launchctl unavailable — removed the plist only")
|
|
714
|
+
plist.unlink()
|
|
715
|
+
removed = True
|
|
716
|
+
else:
|
|
717
|
+
lines = _crontab_lines()
|
|
718
|
+
if lines:
|
|
719
|
+
kept = [ln for ln in lines if not ln.rstrip().endswith(_CRON_MARK)]
|
|
720
|
+
if len(kept) != len(lines):
|
|
721
|
+
try:
|
|
722
|
+
proc = subprocess.run(["crontab", "-"],
|
|
723
|
+
input="\n".join(kept) + ("\n" if kept else ""),
|
|
724
|
+
capture_output=True, text=True, timeout=30)
|
|
725
|
+
removed = proc.returncode == 0
|
|
726
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
727
|
+
pass
|
|
728
|
+
if not removed:
|
|
729
|
+
out.warn("could not rewrite crontab — remove the %s line yourself"
|
|
730
|
+
% _CRON_MARK)
|
|
731
|
+
if removed:
|
|
732
|
+
journal.log("schedule", "disable")
|
|
733
|
+
out.ok("automatic sync disabled")
|
|
734
|
+
else:
|
|
735
|
+
out.info("no schedule was configured")
|
|
736
|
+
return 0
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
# ---------------------------------------------------------------- serve
|
|
740
|
+
|
|
741
|
+
_PAGE_CSS = ("body{background:#111;color:#ddd;font-family:ui-monospace,SFMono-Regular,"
|
|
742
|
+
"Menlo,monospace;max-width:720px;margin:2rem auto;padding:0 1rem}"
|
|
743
|
+
"h1{color:#fff;font-size:1.3rem}a{color:#7dc4ff;text-decoration:none}"
|
|
744
|
+
"a:hover{text-decoration:underline}table{border-collapse:collapse;width:100%}"
|
|
745
|
+
"th,td{text-align:left;padding:.3rem .8rem .3rem 0;"
|
|
746
|
+
"border-bottom:1px solid #2a2a2a}.dim{color:#777}")
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _serve_page() -> str:
|
|
750
|
+
installed = lockfile.installed()
|
|
751
|
+
entries = catalog.all_entries()
|
|
752
|
+
taps = registry.list_taps()
|
|
753
|
+
rows = "".join(
|
|
754
|
+
'<tr><td><a href="/skill/%s">%s</a></td><td>%s</td><td>%s</td></tr>'
|
|
755
|
+
% (urllib.parse.quote(n), html.escape(n),
|
|
756
|
+
html.escape(str(e.get("version", "?"))),
|
|
757
|
+
html.escape(str(e.get("tap", "?"))))
|
|
758
|
+
for n, e in sorted(installed.items()))
|
|
759
|
+
return ("<!doctype html><html><head><meta charset='utf-8'>"
|
|
760
|
+
"<title>boost — skill catalog</title><style>%s</style></head><body>"
|
|
761
|
+
"<h1>⚡ boost <span class='dim'>v%s</span></h1>"
|
|
762
|
+
"<p class='dim'>%d installed · %d available across %d taps</p>"
|
|
763
|
+
"<table><tr><th>skill</th><th>version</th><th>tap</th></tr>%s</table>"
|
|
764
|
+
"<p><a href='/installed.json'>installed.json</a> · "
|
|
765
|
+
"<a href='/catalog.json'>catalog.json</a></p></body></html>"
|
|
766
|
+
% (_PAGE_CSS, __version__, len(installed), len(entries), len(taps),
|
|
767
|
+
rows or "<tr><td colspan='3' class='dim'>nothing installed</td></tr>"))
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _skill_text(name: str):
|
|
771
|
+
"""SKILL.md text for an installed skill, else from a tap. None if unknown."""
|
|
772
|
+
if not re.match(r"^[A-Za-z0-9._-]+$", name):
|
|
773
|
+
return None
|
|
774
|
+
fp = store.skill_store_dir(name) / "SKILL.md"
|
|
775
|
+
if fp.is_file():
|
|
776
|
+
return fp.read_text(encoding="utf-8", errors="replace")
|
|
777
|
+
for e in catalog.find(name):
|
|
778
|
+
try:
|
|
779
|
+
fp = registry.get(e["tap"]).path / e["skill_md"]
|
|
780
|
+
except BoostError:
|
|
781
|
+
continue
|
|
782
|
+
if fp.is_file():
|
|
783
|
+
return fp.read_text(encoding="utf-8", errors="replace")
|
|
784
|
+
return None
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
class _CatalogHandler(BaseHTTPRequestHandler):
|
|
788
|
+
server_version = "boost/" + __version__
|
|
789
|
+
|
|
790
|
+
def log_message(self, fmt, *args): # logged in _send instead
|
|
791
|
+
pass
|
|
792
|
+
|
|
793
|
+
def _send(self, status: int, ctype: str, body: bytes) -> None:
|
|
794
|
+
self.send_response(status)
|
|
795
|
+
self.send_header("Content-Type", ctype)
|
|
796
|
+
self.send_header("Content-Length", str(len(body)))
|
|
797
|
+
self.end_headers()
|
|
798
|
+
self.wfile.write(body)
|
|
799
|
+
out.dim(" %s %s → %d" % (self.command, self.path, status))
|
|
800
|
+
|
|
801
|
+
def do_GET(self):
|
|
802
|
+
path = urllib.parse.unquote(self.path.split("?", 1)[0])
|
|
803
|
+
try:
|
|
804
|
+
if path in ("/", "/index.html"):
|
|
805
|
+
self._send(200, "text/html; charset=utf-8", _serve_page().encode())
|
|
806
|
+
elif path == "/catalog.json":
|
|
807
|
+
self._send(200, "application/json",
|
|
808
|
+
json.dumps(catalog.all_entries(), indent=2).encode())
|
|
809
|
+
elif path == "/installed.json":
|
|
810
|
+
self._send(200, "application/json",
|
|
811
|
+
json.dumps(lockfile.read(), indent=2).encode())
|
|
812
|
+
elif path.startswith("/skill/"):
|
|
813
|
+
name = path[len("/skill/"):].strip("/")
|
|
814
|
+
text = _skill_text(name)
|
|
815
|
+
if text is None:
|
|
816
|
+
self._send(404, "application/json",
|
|
817
|
+
json.dumps({"error": "no skill named %r" % name}).encode())
|
|
818
|
+
else:
|
|
819
|
+
self._send(200, "text/plain; charset=utf-8", text.encode())
|
|
820
|
+
else:
|
|
821
|
+
self._send(404, "application/json",
|
|
822
|
+
json.dumps({"error": "not found"}).encode())
|
|
823
|
+
except BrokenPipeError:
|
|
824
|
+
pass
|
|
825
|
+
except Exception as e: # noqa: BLE001 — keep the server alive
|
|
826
|
+
try:
|
|
827
|
+
self._send(500, "application/json",
|
|
828
|
+
json.dumps({"error": str(e)}).encode())
|
|
829
|
+
except Exception:
|
|
830
|
+
pass
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def cmd_serve(argv) -> int:
|
|
834
|
+
"""boost serve [--port N] [--host H]"""
|
|
835
|
+
p = argparse.ArgumentParser(
|
|
836
|
+
prog="boost serve",
|
|
837
|
+
description="Serve the skill catalog over HTTP (port 8787)")
|
|
838
|
+
p.add_argument("--port", type=int,
|
|
839
|
+
default=int(config.get("serve.port", 8787) or 8787),
|
|
840
|
+
help="port to listen on (default: config serve.port)")
|
|
841
|
+
p.add_argument("--host", default="127.0.0.1",
|
|
842
|
+
help="address to bind (default: 127.0.0.1)")
|
|
843
|
+
args = p.parse_args(argv)
|
|
844
|
+
|
|
845
|
+
try:
|
|
846
|
+
httpd = ThreadingHTTPServer((args.host, args.port), _CatalogHandler)
|
|
847
|
+
except OSError as e:
|
|
848
|
+
if e.errno == errno.EADDRINUSE:
|
|
849
|
+
raise BoostError("port %d is already in use" % args.port,
|
|
850
|
+
hint="pick another with --port")
|
|
851
|
+
raise BoostError("cannot bind %s:%d — %s" % (args.host, args.port, e),
|
|
852
|
+
hint="check --host and --port")
|
|
853
|
+
out.info("⚡ serving skill catalog on http://%s:%d %s"
|
|
854
|
+
% (args.host, args.port, out.c("(ctrl-c to stop)", out.DIM)))
|
|
855
|
+
try:
|
|
856
|
+
httpd.serve_forever()
|
|
857
|
+
except KeyboardInterrupt:
|
|
858
|
+
print()
|
|
859
|
+
out.ok("server stopped")
|
|
860
|
+
finally:
|
|
861
|
+
httpd.server_close()
|
|
862
|
+
return 0
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
# ---------------------------------------------------------------- mcp
|
|
866
|
+
|
|
867
|
+
_MCP_TOOLS = [
|
|
868
|
+
{"name": "boost_search",
|
|
869
|
+
"description": "Search AI coding skills across the configured tap registries",
|
|
870
|
+
"inputSchema": {"type": "object",
|
|
871
|
+
"properties": {"query": {"type": "string",
|
|
872
|
+
"description": "search terms"}},
|
|
873
|
+
"required": ["query"]}},
|
|
874
|
+
{"name": "boost_list",
|
|
875
|
+
"description": "List the skills currently installed by boost",
|
|
876
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
877
|
+
{"name": "boost_info",
|
|
878
|
+
"description": "Show detailed information about one skill",
|
|
879
|
+
"inputSchema": {"type": "object",
|
|
880
|
+
"properties": {"name": {"type": "string",
|
|
881
|
+
"description": "skill name"}},
|
|
882
|
+
"required": ["name"]}},
|
|
883
|
+
{"name": "boost_install",
|
|
884
|
+
"description": "Install a skill from a configured tap registry",
|
|
885
|
+
"inputSchema": {"type": "object",
|
|
886
|
+
"properties": {"name": {"type": "string",
|
|
887
|
+
"description": "skill name"}},
|
|
888
|
+
"required": ["name"]}},
|
|
889
|
+
{"name": "boost_doctor",
|
|
890
|
+
"description": "Health summary of the boost skill environment",
|
|
891
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
892
|
+
]
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _mcp_tool(tool: str, args: dict):
|
|
896
|
+
"""Run one MCP tool -> (text, is_error). (None, _) for unknown tools."""
|
|
897
|
+
if tool == "boost_search":
|
|
898
|
+
hits = catalog.search(str(args.get("query", "")))[:10]
|
|
899
|
+
if not hits:
|
|
900
|
+
return "no skills match %r" % args.get("query", ""), False
|
|
901
|
+
return "\n".join("%s — %s (%s)" % (e["name"], e["description"], e["tap"])
|
|
902
|
+
for e, _score in hits), False
|
|
903
|
+
if tool == "boost_list":
|
|
904
|
+
skills = lockfile.installed()
|
|
905
|
+
if not skills:
|
|
906
|
+
return "no skills installed", False
|
|
907
|
+
return "\n".join("%s v%s (%s)%s"
|
|
908
|
+
% (n, e.get("version", "?"), e.get("tap", "?"),
|
|
909
|
+
" [pinned]" if e.get("pinned") else "")
|
|
910
|
+
for n, e in sorted(skills.items())), False
|
|
911
|
+
if tool == "boost_info":
|
|
912
|
+
name = str(args.get("name", ""))
|
|
913
|
+
entry = lockfile.get_skill(name)
|
|
914
|
+
matches = catalog.find(name)
|
|
915
|
+
if not entry and not matches:
|
|
916
|
+
return "no skill named %r (installed or in any tap)" % name, True
|
|
917
|
+
src = matches[0] if matches else {}
|
|
918
|
+
lines = ["name: " + name,
|
|
919
|
+
"version: %s" % (entry or src).get("version", "?"),
|
|
920
|
+
"tap: %s" % (entry or src).get("tap", "?")]
|
|
921
|
+
if src.get("description"):
|
|
922
|
+
lines.append("description: %s" % src["description"])
|
|
923
|
+
if entry:
|
|
924
|
+
lines.append("installed: yes (%s)" % entry.get("installed_at", "?"))
|
|
925
|
+
lines.append("agents: %s" % (", ".join(entry.get("agents") or []) or "none"))
|
|
926
|
+
if entry.get("pinned"):
|
|
927
|
+
lines.append("pinned: yes")
|
|
928
|
+
else:
|
|
929
|
+
lines.append("installed: no")
|
|
930
|
+
return "\n".join(lines), False
|
|
931
|
+
if tool == "boost_install":
|
|
932
|
+
entry = catalog.resolve_one(str(args.get("name", "")))
|
|
933
|
+
res = store.install(entry)
|
|
934
|
+
lines = ["installed %s v%s from %s → %s"
|
|
935
|
+
% (res.name, entry.get("version", "?"), entry["tap"], res.dest),
|
|
936
|
+
"linked agents: %s" % (", ".join(res.linked) or "none"),
|
|
937
|
+
"quality score: %d/100" % res.score]
|
|
938
|
+
if res.conflicts:
|
|
939
|
+
lines.append("conflicts (left in place): %s" % ", ".join(res.conflicts))
|
|
940
|
+
return "\n".join(lines), False
|
|
941
|
+
if tool == "boost_doctor":
|
|
942
|
+
plan = store.sync_plan()
|
|
943
|
+
issues = sum(len(v) for v in plan.values())
|
|
944
|
+
taps = registry.list_taps()
|
|
945
|
+
lines = ["installed skills: %d" % len(lockfile.installed()),
|
|
946
|
+
"taps: %d (%d skills available)" % (len(taps), len(catalog.all_entries()))]
|
|
947
|
+
for key, vals in plan.items():
|
|
948
|
+
if vals:
|
|
949
|
+
lines.append("%s: %s" % (key, ", ".join(str(v) for v in vals)))
|
|
950
|
+
lines.append("healthy — no issues found" if issues == 0
|
|
951
|
+
else "%d issue(s) — run `boost sync` to fix" % issues)
|
|
952
|
+
return "\n".join(lines), issues > 0
|
|
953
|
+
return None, False
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _mcp_send(msg: dict) -> bool:
|
|
957
|
+
try:
|
|
958
|
+
sys.stdout.write(json.dumps(msg) + "\n")
|
|
959
|
+
sys.stdout.flush()
|
|
960
|
+
return True
|
|
961
|
+
except (BrokenPipeError, OSError):
|
|
962
|
+
return False
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _mcp_serve_stdio() -> int:
|
|
966
|
+
"""Newline-delimited JSON-RPC 2.0 MCP server on stdin/stdout."""
|
|
967
|
+
while True:
|
|
968
|
+
try:
|
|
969
|
+
line = sys.stdin.readline()
|
|
970
|
+
except (KeyboardInterrupt, OSError):
|
|
971
|
+
return 0
|
|
972
|
+
if not line: # EOF
|
|
973
|
+
return 0
|
|
974
|
+
line = line.strip()
|
|
975
|
+
if not line:
|
|
976
|
+
continue
|
|
977
|
+
try:
|
|
978
|
+
req = json.loads(line)
|
|
979
|
+
except json.JSONDecodeError:
|
|
980
|
+
if not _mcp_send({"jsonrpc": "2.0", "id": None,
|
|
981
|
+
"error": {"code": -32700, "message": "parse error"}}):
|
|
982
|
+
return 0
|
|
983
|
+
continue
|
|
984
|
+
method = str(req.get("method", ""))
|
|
985
|
+
if "id" not in req: # notification (e.g. notifications/initialized)
|
|
986
|
+
continue
|
|
987
|
+
resp = {"jsonrpc": "2.0", "id": req.get("id")}
|
|
988
|
+
if method == "initialize":
|
|
989
|
+
resp["result"] = {"protocolVersion": "2024-11-05",
|
|
990
|
+
"capabilities": {"tools": {}},
|
|
991
|
+
"serverInfo": {"name": "boost", "version": __version__}}
|
|
992
|
+
elif method == "ping":
|
|
993
|
+
resp["result"] = {}
|
|
994
|
+
elif method == "tools/list":
|
|
995
|
+
resp["result"] = {"tools": _MCP_TOOLS}
|
|
996
|
+
elif method == "tools/call":
|
|
997
|
+
params = req.get("params") or {}
|
|
998
|
+
tool = str(params.get("name", ""))
|
|
999
|
+
try:
|
|
1000
|
+
text, is_err = _mcp_tool(tool, params.get("arguments") or {})
|
|
1001
|
+
except BoostError as e:
|
|
1002
|
+
text = "Error: %s" % e.message + ("\nhint: %s" % e.hint if e.hint else "")
|
|
1003
|
+
is_err = True
|
|
1004
|
+
except Exception as e: # noqa: BLE001 — server must not die
|
|
1005
|
+
text, is_err = "Error: %s" % e, True
|
|
1006
|
+
if text is None:
|
|
1007
|
+
resp["error"] = {"code": -32602, "message": "unknown tool %r" % tool}
|
|
1008
|
+
else:
|
|
1009
|
+
resp["result"] = {"content": [{"type": "text", "text": text}]}
|
|
1010
|
+
if is_err:
|
|
1011
|
+
resp["result"]["isError"] = True
|
|
1012
|
+
else:
|
|
1013
|
+
resp["error"] = {"code": -32601, "message": "method not found: %s" % method}
|
|
1014
|
+
if not _mcp_send(resp):
|
|
1015
|
+
return 0
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def cmd_mcp(argv) -> int:
|
|
1019
|
+
"""boost mcp [register|unregister] [--stdio]"""
|
|
1020
|
+
p = argparse.ArgumentParser(
|
|
1021
|
+
prog="boost mcp",
|
|
1022
|
+
description="Register boost as an MCP server for Claude Code")
|
|
1023
|
+
p.add_argument("action", nargs="?", default="register",
|
|
1024
|
+
choices=("register", "unregister"),
|
|
1025
|
+
help="what to do (default: register)")
|
|
1026
|
+
p.add_argument("--stdio", action="store_true",
|
|
1027
|
+
help="run the MCP server on stdin/stdout (used by Claude Code)")
|
|
1028
|
+
args = p.parse_args(argv)
|
|
1029
|
+
|
|
1030
|
+
if args.stdio:
|
|
1031
|
+
return _mcp_serve_stdio()
|
|
1032
|
+
|
|
1033
|
+
shim = paths.launcher()
|
|
1034
|
+
if args.action == "register":
|
|
1035
|
+
cmd = ["claude", "mcp", "add", "--scope", "user", "boost", "--",
|
|
1036
|
+
str(shim), "mcp", "--stdio"]
|
|
1037
|
+
else:
|
|
1038
|
+
cmd = ["claude", "mcp", "remove", "boost"]
|
|
1039
|
+
|
|
1040
|
+
if shutil.which("claude"):
|
|
1041
|
+
try:
|
|
1042
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
1043
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
1044
|
+
raise BoostError("claude mcp %s failed: %s" % (args.action, e),
|
|
1045
|
+
hint="run it yourself: " + " ".join(cmd))
|
|
1046
|
+
for ln in (proc.stdout or "").strip().splitlines():
|
|
1047
|
+
out.info(ln)
|
|
1048
|
+
if proc.returncode != 0:
|
|
1049
|
+
tail = (proc.stderr or "").strip().splitlines()
|
|
1050
|
+
raise BoostError("claude mcp %s failed: %s"
|
|
1051
|
+
% (args.action, tail[-1] if tail else "unknown error"),
|
|
1052
|
+
hint="run it yourself: " + " ".join(cmd))
|
|
1053
|
+
out.ok("%sed boost as an MCP server (scope: user)"
|
|
1054
|
+
% ("register" if args.action == "register" else "unregister"))
|
|
1055
|
+
else:
|
|
1056
|
+
out.warn("`claude` CLI not found — run this yourself:")
|
|
1057
|
+
out.info(" ".join(cmd))
|
|
1058
|
+
journal.log("mcp", args.action)
|
|
1059
|
+
return 0
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
# ---------------------------------------------------------------- self-update
|
|
1063
|
+
|
|
1064
|
+
def cmd_self_update(argv) -> int:
|
|
1065
|
+
"""boost self-update"""
|
|
1066
|
+
p = argparse.ArgumentParser(
|
|
1067
|
+
prog="boost self-update",
|
|
1068
|
+
description="Update boost itself to the latest version")
|
|
1069
|
+
p.parse_args(argv)
|
|
1070
|
+
|
|
1071
|
+
root = paths.repo_root()
|
|
1072
|
+
if not gitutil.is_repo(root):
|
|
1073
|
+
raise BoostError("boost is not running from a git checkout",
|
|
1074
|
+
hint="git clone the boost repo and symlink bin")
|
|
1075
|
+
old = __version__
|
|
1076
|
+
gitutil.run(["-C", str(root), "pull", "--ff-only"], timeout=120)
|
|
1077
|
+
new = old
|
|
1078
|
+
m = re.search(r"__version__\s*=\s*[\"']([^\"']+)[\"']",
|
|
1079
|
+
(root / "boost_cli" / "__init__.py").read_text())
|
|
1080
|
+
if m:
|
|
1081
|
+
new = m.group(1)
|
|
1082
|
+
journal.log("self-update", new, previous=old)
|
|
1083
|
+
if new != old:
|
|
1084
|
+
out.ok("boost v%s → v%s" % (old, new))
|
|
1085
|
+
else:
|
|
1086
|
+
out.ok("already up to date (v%s)" % old)
|
|
1087
|
+
return 0
|