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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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