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,679 @@
|
|
|
1
|
+
"""Team & Collaboration commands: cohort, profile, protocol, pulse, replay, who."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import getpass
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import shutil
|
|
11
|
+
import stat
|
|
12
|
+
import urllib.parse
|
|
13
|
+
|
|
14
|
+
from ..core import catalog, journal, lockfile, paths, registry, store, util
|
|
15
|
+
from ..core import output as out
|
|
16
|
+
from ..errors import BoostError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _tilde(p) -> str:
|
|
20
|
+
"""Contract $HOME to ~ in a path-ish string for display."""
|
|
21
|
+
s = str(p)
|
|
22
|
+
for h in {str(paths.home()), str(paths.home().resolve())}:
|
|
23
|
+
if s == h:
|
|
24
|
+
return "~"
|
|
25
|
+
if s.startswith(h + os.sep):
|
|
26
|
+
return "~" + s[len(h):]
|
|
27
|
+
return s
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _user() -> str:
|
|
31
|
+
try:
|
|
32
|
+
return getpass.getuser()
|
|
33
|
+
except Exception:
|
|
34
|
+
return "unknown"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _resolve_entry(name: str, prefer_tap: str | None = None):
|
|
38
|
+
"""Find a catalog entry by name, preferring a specific tap. None if absent."""
|
|
39
|
+
matches = catalog.find(name)
|
|
40
|
+
if not matches:
|
|
41
|
+
return None
|
|
42
|
+
if prefer_tap:
|
|
43
|
+
tapped = [e for e in matches if e["tap"] == prefer_tap]
|
|
44
|
+
if tapped:
|
|
45
|
+
return tapped[0]
|
|
46
|
+
return matches[0]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------- cohort
|
|
50
|
+
|
|
51
|
+
def _cohorts_path():
|
|
52
|
+
return paths.state_dir() / "cohorts.json"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load_cohorts() -> dict:
|
|
56
|
+
p = _cohorts_path()
|
|
57
|
+
if not p.exists():
|
|
58
|
+
return {}
|
|
59
|
+
try:
|
|
60
|
+
return json.loads(p.read_text())
|
|
61
|
+
except (json.JSONDecodeError, OSError):
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _save_cohorts(cohorts: dict) -> None:
|
|
66
|
+
paths.ensure_dirs()
|
|
67
|
+
_cohorts_path().write_text(json.dumps(cohorts, indent=2) + "\n")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_member(user: str, cohort_name: str, percent: int) -> bool:
|
|
71
|
+
"""Deterministic per user+cohort: stable across runs and machines."""
|
|
72
|
+
digest = hashlib.sha256(("%s:%s" % (user, cohort_name)).encode()).hexdigest()
|
|
73
|
+
return int(digest, 16) % 100 < percent
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_cohort(argv) -> int:
|
|
77
|
+
"""boost cohort [list|create NAME --skills a,b --percent N|delete NAME|status|apply [NAME]]"""
|
|
78
|
+
p = argparse.ArgumentParser(
|
|
79
|
+
prog="boost cohort",
|
|
80
|
+
description="Controlled skill rollouts & team A/B testing",
|
|
81
|
+
epilog="Membership is a deterministic hash of user+cohort, so a 50%% "
|
|
82
|
+
"rollout lands on the same half of the team every time. "
|
|
83
|
+
"This machine evaluates its own membership locally.")
|
|
84
|
+
p.add_argument("action", nargs="?", default="list",
|
|
85
|
+
choices=["list", "create", "delete", "status", "apply"])
|
|
86
|
+
p.add_argument("name", nargs="?", help="cohort name")
|
|
87
|
+
p.add_argument("--skills", default="", help="comma-separated skill names")
|
|
88
|
+
p.add_argument("--percent", type=int, default=100,
|
|
89
|
+
help="rollout percentage (default 100)")
|
|
90
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
91
|
+
args = p.parse_args(argv)
|
|
92
|
+
|
|
93
|
+
cohorts = _load_cohorts()
|
|
94
|
+
user = _user()
|
|
95
|
+
|
|
96
|
+
if args.action == "create":
|
|
97
|
+
if not args.name:
|
|
98
|
+
p.error("create needs a cohort NAME")
|
|
99
|
+
if not 0 <= args.percent <= 100:
|
|
100
|
+
p.error("--percent must be 0-100")
|
|
101
|
+
skills = [s.strip() for s in args.skills.split(",") if s.strip()]
|
|
102
|
+
if not skills:
|
|
103
|
+
p.error("create needs --skills a,b,...")
|
|
104
|
+
for s in skills:
|
|
105
|
+
if not catalog.find(s):
|
|
106
|
+
out.warn("skill %r not found in any tap (kept anyway)" % s)
|
|
107
|
+
cohorts[args.name] = {"skills": skills, "percent": args.percent,
|
|
108
|
+
"created": util.now_iso(), "creator": user}
|
|
109
|
+
_save_cohorts(cohorts)
|
|
110
|
+
journal.log("cohort", args.name, op="create", percent=args.percent)
|
|
111
|
+
member = _is_member(user, args.name, args.percent)
|
|
112
|
+
out.ok("created cohort %s (%d%% rollout, %d skills) — you are %s"
|
|
113
|
+
% (args.name, args.percent, len(skills),
|
|
114
|
+
"IN" if member else "OUT"))
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
if args.action == "delete":
|
|
118
|
+
if not args.name:
|
|
119
|
+
p.error("delete needs a cohort NAME")
|
|
120
|
+
if args.name not in cohorts:
|
|
121
|
+
raise BoostError("no cohort named %s" % args.name,
|
|
122
|
+
hint="list cohorts with `boost cohort list`")
|
|
123
|
+
if not out.confirm("delete cohort %s?" % args.name):
|
|
124
|
+
out.info("cancelled")
|
|
125
|
+
return 1
|
|
126
|
+
del cohorts[args.name]
|
|
127
|
+
_save_cohorts(cohorts)
|
|
128
|
+
journal.log("cohort", args.name, op="delete")
|
|
129
|
+
out.ok("deleted cohort %s" % args.name)
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
if args.action == "apply":
|
|
133
|
+
targets = [args.name] if args.name else sorted(cohorts)
|
|
134
|
+
if args.name and args.name not in cohorts:
|
|
135
|
+
raise BoostError("no cohort named %s" % args.name,
|
|
136
|
+
hint="list cohorts with `boost cohort list`")
|
|
137
|
+
if not targets:
|
|
138
|
+
out.info("no cohorts defined")
|
|
139
|
+
return 0
|
|
140
|
+
installed = lockfile.installed()
|
|
141
|
+
applied = skipped = 0
|
|
142
|
+
for cname in targets:
|
|
143
|
+
spec = cohorts[cname]
|
|
144
|
+
if not _is_member(user, cname, spec["percent"]):
|
|
145
|
+
out.info(out.c("%s: not in the %d%% rollout — skipping"
|
|
146
|
+
% (cname, spec["percent"]), out.DIM))
|
|
147
|
+
continue
|
|
148
|
+
out.heading("cohort %s" % cname)
|
|
149
|
+
for skill in spec["skills"]:
|
|
150
|
+
if skill in installed:
|
|
151
|
+
out.info(out.c("%s already installed" % skill, out.DIM))
|
|
152
|
+
skipped += 1
|
|
153
|
+
continue
|
|
154
|
+
entry = _resolve_entry(skill)
|
|
155
|
+
if entry is None:
|
|
156
|
+
out.warn("%s not found in any tap — skipped" % skill)
|
|
157
|
+
continue
|
|
158
|
+
res = store.install(entry)
|
|
159
|
+
out.ok("installed %s → %s" % (skill, " · ".join(res.linked)))
|
|
160
|
+
applied += 1
|
|
161
|
+
out.info("applied: %d installed, %d already present" % (applied, skipped))
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
# list / status
|
|
165
|
+
rows = []
|
|
166
|
+
data = []
|
|
167
|
+
for cname in sorted(cohorts):
|
|
168
|
+
spec = cohorts[cname]
|
|
169
|
+
member = _is_member(user, cname, spec["percent"])
|
|
170
|
+
data.append({"name": cname, "skills": spec["skills"],
|
|
171
|
+
"percent": spec["percent"], "member": member,
|
|
172
|
+
"created": spec.get("created", "")})
|
|
173
|
+
rows.append((cname, ", ".join(spec["skills"]),
|
|
174
|
+
"%d%%" % spec["percent"],
|
|
175
|
+
out.c("IN", out.GREEN) if member else out.c("out", out.DIM)))
|
|
176
|
+
if args.json:
|
|
177
|
+
print(json.dumps(data, indent=2))
|
|
178
|
+
return 0
|
|
179
|
+
if not rows:
|
|
180
|
+
out.info("no cohorts defined")
|
|
181
|
+
out.info(out.c("create one: `boost cohort create pilot --skills tdd-workflow --percent 50`",
|
|
182
|
+
out.DIM))
|
|
183
|
+
return 0
|
|
184
|
+
out.table(rows, headers=("COHORT", "SKILLS", "ROLLOUT", "YOU"))
|
|
185
|
+
print()
|
|
186
|
+
out.dim("membership = sha256(user:cohort) %% 100 < rollout · apply with `boost cohort apply`")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------- profile
|
|
191
|
+
|
|
192
|
+
def _profile_path(name: str):
|
|
193
|
+
return paths.profiles_dir() / (util.slugify(name) + ".json")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _load_profile(name: str) -> dict:
|
|
197
|
+
p = _profile_path(name)
|
|
198
|
+
if not p.exists():
|
|
199
|
+
raise BoostError("no profile named %s" % name,
|
|
200
|
+
hint="list profiles with `boost profile list`")
|
|
201
|
+
try:
|
|
202
|
+
return json.loads(p.read_text())
|
|
203
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
204
|
+
raise BoostError("profile %s is unreadable: %s" % (name, e))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _profile_diff(profile: dict):
|
|
208
|
+
"""-> (missing, extras, changed): profile vs currently installed."""
|
|
209
|
+
current = lockfile.installed()
|
|
210
|
+
want = profile.get("skills", {})
|
|
211
|
+
missing = sorted(n for n in want if n not in current)
|
|
212
|
+
extras = sorted(n for n in current if n not in want)
|
|
213
|
+
changed = sorted(n for n in want if n in current and
|
|
214
|
+
str(want[n].get("version")) != str(current[n].get("version")))
|
|
215
|
+
return missing, extras, changed
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def cmd_profile(argv) -> int:
|
|
219
|
+
"""boost profile [list|save NAME|use NAME [--prune]|show NAME|diff NAME|delete NAME]"""
|
|
220
|
+
p = argparse.ArgumentParser(
|
|
221
|
+
prog="boost profile",
|
|
222
|
+
description="Named skill profiles for context switching")
|
|
223
|
+
p.add_argument("action", nargs="?", default="list",
|
|
224
|
+
choices=["list", "save", "use", "show", "diff", "delete"])
|
|
225
|
+
p.add_argument("name", nargs="?", help="profile name")
|
|
226
|
+
p.add_argument("--prune", action="store_true",
|
|
227
|
+
help="with `use`: fully uninstall skills not in the profile")
|
|
228
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
229
|
+
args = p.parse_args(argv)
|
|
230
|
+
|
|
231
|
+
if args.action != "list" and not args.name:
|
|
232
|
+
p.error("%s needs a profile NAME" % args.action)
|
|
233
|
+
|
|
234
|
+
if args.action == "list":
|
|
235
|
+
profiles = []
|
|
236
|
+
for f in sorted(paths.profiles_dir().glob("*.json")):
|
|
237
|
+
try:
|
|
238
|
+
data = json.loads(f.read_text())
|
|
239
|
+
except (json.JSONDecodeError, OSError):
|
|
240
|
+
continue
|
|
241
|
+
profiles.append({"name": data.get("name", f.stem),
|
|
242
|
+
"skills": len(data.get("skills", {})),
|
|
243
|
+
"saved": data.get("saved", "?")})
|
|
244
|
+
if args.json:
|
|
245
|
+
print(json.dumps(profiles, indent=2))
|
|
246
|
+
return 0
|
|
247
|
+
if not profiles:
|
|
248
|
+
out.info("no profiles saved")
|
|
249
|
+
out.info(out.c("snapshot the current setup: `boost profile save daily`", out.DIM))
|
|
250
|
+
return 0
|
|
251
|
+
rows = [(pr["name"], str(pr["skills"]), util.rel_time(pr["saved"]))
|
|
252
|
+
for pr in profiles]
|
|
253
|
+
out.table(rows, headers=("PROFILE", "SKILLS", "SAVED"))
|
|
254
|
+
return 0
|
|
255
|
+
|
|
256
|
+
if args.action == "save":
|
|
257
|
+
installed = lockfile.installed()
|
|
258
|
+
profile = {"name": args.name, "saved": util.now_iso(), "user": _user(),
|
|
259
|
+
"skills": {n: {"tap": e.get("tap", "local"),
|
|
260
|
+
"version": e.get("version", "0.0.0")}
|
|
261
|
+
for n, e in installed.items()}}
|
|
262
|
+
paths.ensure_dirs()
|
|
263
|
+
_profile_path(args.name).write_text(json.dumps(profile, indent=2) + "\n")
|
|
264
|
+
journal.log("profile", args.name, op="save", skills=len(installed))
|
|
265
|
+
out.ok("saved profile %s (%d skills)" % (args.name, len(installed)))
|
|
266
|
+
return 0
|
|
267
|
+
|
|
268
|
+
if args.action == "show":
|
|
269
|
+
profile = _load_profile(args.name)
|
|
270
|
+
if args.json:
|
|
271
|
+
print(json.dumps(profile, indent=2))
|
|
272
|
+
return 0
|
|
273
|
+
out.heading("profile %s" % profile.get("name", args.name))
|
|
274
|
+
out.kv("saved", "%s by %s" % (util.rel_time(profile.get("saved", "")),
|
|
275
|
+
profile.get("user", "?")))
|
|
276
|
+
rows = [(n, s.get("version", "?"), s.get("tap", "?"))
|
|
277
|
+
for n, s in sorted(profile.get("skills", {}).items())]
|
|
278
|
+
if rows:
|
|
279
|
+
print()
|
|
280
|
+
out.table(rows, headers=("SKILL", "VERSION", "TAP"))
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
if args.action == "diff":
|
|
284
|
+
profile = _load_profile(args.name)
|
|
285
|
+
missing, extras, changed = _profile_diff(profile)
|
|
286
|
+
if args.json:
|
|
287
|
+
print(json.dumps({"missing": missing, "extras": extras,
|
|
288
|
+
"changed": changed}, indent=2))
|
|
289
|
+
return 0
|
|
290
|
+
if not (missing or extras or changed):
|
|
291
|
+
out.ok("current setup matches profile %s" % args.name)
|
|
292
|
+
return 0
|
|
293
|
+
for n in missing:
|
|
294
|
+
out.info(out.c("+ %s" % n, out.GREEN) + out.c(" (in profile, not installed)", out.DIM))
|
|
295
|
+
for n in extras:
|
|
296
|
+
out.info(out.c("- %s" % n, out.RED) + out.c(" (installed, not in profile)", out.DIM))
|
|
297
|
+
for n in changed:
|
|
298
|
+
out.info(out.c("~ %s" % n, out.YELLOW) + out.c(" (version differs)", out.DIM))
|
|
299
|
+
return 0
|
|
300
|
+
|
|
301
|
+
if args.action == "delete":
|
|
302
|
+
_load_profile(args.name) # existence check
|
|
303
|
+
if not out.confirm("delete profile %s?" % args.name):
|
|
304
|
+
out.info("cancelled")
|
|
305
|
+
return 1
|
|
306
|
+
_profile_path(args.name).unlink()
|
|
307
|
+
journal.log("profile", args.name, op="delete")
|
|
308
|
+
out.ok("deleted profile %s" % args.name)
|
|
309
|
+
return 0
|
|
310
|
+
|
|
311
|
+
# use
|
|
312
|
+
profile = _load_profile(args.name)
|
|
313
|
+
missing, extras, _changed = _profile_diff(profile)
|
|
314
|
+
want = profile.get("skills", {})
|
|
315
|
+
for n in missing:
|
|
316
|
+
entry = _resolve_entry(n, prefer_tap=want[n].get("tap"))
|
|
317
|
+
if entry is None:
|
|
318
|
+
out.warn("%s is in the profile but not in any tap — skipped" % n)
|
|
319
|
+
continue
|
|
320
|
+
res = store.install(entry)
|
|
321
|
+
out.ok("installed %s → %s" % (n, " · ".join(res.linked)))
|
|
322
|
+
for n in sorted(want):
|
|
323
|
+
if lockfile.get_skill(n) and not (lockfile.get_skill(n) or {}).get("quarantined"):
|
|
324
|
+
store.link_agents(n)
|
|
325
|
+
if extras:
|
|
326
|
+
if args.prune:
|
|
327
|
+
if out.confirm("uninstall %d skill(s) not in the profile (%s)?"
|
|
328
|
+
% (len(extras), ", ".join(extras))):
|
|
329
|
+
for n in extras:
|
|
330
|
+
store.uninstall(n)
|
|
331
|
+
out.ok("uninstalled %s" % n)
|
|
332
|
+
else:
|
|
333
|
+
out.info("kept extras installed")
|
|
334
|
+
else:
|
|
335
|
+
for n in extras:
|
|
336
|
+
store.unlink_agents(n)
|
|
337
|
+
out.info("sidelined %d skill(s) not in the profile (unlinked, still installed): %s"
|
|
338
|
+
% (len(extras), ", ".join(extras)))
|
|
339
|
+
journal.log("profile", args.name, op="use")
|
|
340
|
+
out.ok("switched to profile %s" % args.name)
|
|
341
|
+
return 0
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ---------------------------------------------------------------- protocol
|
|
345
|
+
|
|
346
|
+
def _handler_script():
|
|
347
|
+
return paths.state_dir() / "boost-protocol-handler.sh"
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _desktop_file():
|
|
351
|
+
return paths.home() / ".local" / "share" / "applications" / "boost-protocol.desktop"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _parse_boost_url(url: str):
|
|
355
|
+
"""boost://install/<skill> | boost://install/<tap>:<skill> | boost://tap/<owner>/<repo>
|
|
356
|
+
-> (verb, argument)"""
|
|
357
|
+
parsed = urllib.parse.urlparse(url)
|
|
358
|
+
if parsed.scheme != "boost":
|
|
359
|
+
raise BoostError("not a boost:// URL: %s" % url,
|
|
360
|
+
hint="expected boost://install/<skill> or boost://tap/<owner>/<repo>")
|
|
361
|
+
verb = parsed.netloc
|
|
362
|
+
arg = urllib.parse.unquote(parsed.path.lstrip("/"))
|
|
363
|
+
if verb not in ("install", "tap") or not arg:
|
|
364
|
+
raise BoostError("cannot parse %s" % url,
|
|
365
|
+
hint="supported: boost://install/<skill>, "
|
|
366
|
+
"boost://install/<tap>:<skill>, boost://tap/<owner>/<repo>")
|
|
367
|
+
return verb, arg
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def cmd_protocol(argv) -> int:
|
|
371
|
+
"""boost protocol [status|register|unregister|open URL]"""
|
|
372
|
+
p = argparse.ArgumentParser(
|
|
373
|
+
prog="boost protocol",
|
|
374
|
+
description="Manage the boost:// one-click-install handler")
|
|
375
|
+
p.add_argument("action", nargs="?", default="status",
|
|
376
|
+
choices=["status", "register", "unregister", "open"])
|
|
377
|
+
p.add_argument("url", nargs="?", help="a boost:// URL (for `open`)")
|
|
378
|
+
args = p.parse_args(argv)
|
|
379
|
+
system = platform.system()
|
|
380
|
+
|
|
381
|
+
if args.action == "open":
|
|
382
|
+
if not args.url:
|
|
383
|
+
p.error("open needs a boost:// URL")
|
|
384
|
+
verb, arg = _parse_boost_url(args.url)
|
|
385
|
+
if verb == "install":
|
|
386
|
+
entry = catalog.resolve_one(arg)
|
|
387
|
+
if not out.confirm("install %s from %s?" % (entry["name"], entry["tap"])):
|
|
388
|
+
out.info("cancelled")
|
|
389
|
+
return 1
|
|
390
|
+
res = store.install(entry)
|
|
391
|
+
out.ok("copied to %s" % _tilde(res.dest))
|
|
392
|
+
out.ok("linked → %s" % " · ".join(res.linked))
|
|
393
|
+
out.ok("lock updated (.skill-lock.json)")
|
|
394
|
+
return 0
|
|
395
|
+
# tap
|
|
396
|
+
if not out.confirm("tap %s?" % arg):
|
|
397
|
+
out.info("cancelled")
|
|
398
|
+
return 1
|
|
399
|
+
tap = registry.add(arg)
|
|
400
|
+
entries = catalog.rebuild_tap(tap)
|
|
401
|
+
journal.log("tap", tap.name, via="protocol")
|
|
402
|
+
out.ok("tapped %s (%d skills)" % (tap.name, len(entries)))
|
|
403
|
+
return 0
|
|
404
|
+
|
|
405
|
+
if args.action == "register":
|
|
406
|
+
paths.ensure_dirs()
|
|
407
|
+
shim = paths.launcher()
|
|
408
|
+
script = _handler_script()
|
|
409
|
+
script.write_text("#!/usr/bin/env bash\n"
|
|
410
|
+
"# boost:// URL handler — invoked with the URL as $1\n"
|
|
411
|
+
'exec "%s" protocol open "$1"\n' % shim)
|
|
412
|
+
script.chmod(script.stat().st_mode | stat.S_IEXEC)
|
|
413
|
+
out.ok("wrote handler script %s" % _tilde(script))
|
|
414
|
+
if system == "Darwin":
|
|
415
|
+
out.info("macOS routes URL schemes through app bundles, so one manual step remains:")
|
|
416
|
+
out.info(" 1. Automator → New → Application → 'Run Shell Script'")
|
|
417
|
+
out.info(' 2. script: %s "$1" (pass input: as arguments)' % _tilde(script))
|
|
418
|
+
out.info(" 3. save as Boost.app, then add CFBundleURLTypes for 'boost' to its Info.plist")
|
|
419
|
+
out.info(out.c(" (or: brew install duti && duti -s <bundle-id> boost)", out.DIM))
|
|
420
|
+
elif system == "Linux":
|
|
421
|
+
desktop = _desktop_file()
|
|
422
|
+
desktop.parent.mkdir(parents=True, exist_ok=True)
|
|
423
|
+
desktop.write_text("[Desktop Entry]\nType=Application\nName=boost protocol handler\n"
|
|
424
|
+
"Exec=%s %%u\nMimeType=x-scheme-handler/boost;\nNoDisplay=true\n"
|
|
425
|
+
% script)
|
|
426
|
+
out.ok("wrote %s" % _tilde(desktop))
|
|
427
|
+
if shutil.which("xdg-mime"):
|
|
428
|
+
import subprocess
|
|
429
|
+
proc = subprocess.run(["xdg-mime", "default", "boost-protocol.desktop",
|
|
430
|
+
"x-scheme-handler/boost"], capture_output=True)
|
|
431
|
+
if proc.returncode == 0:
|
|
432
|
+
out.ok("registered x-scheme-handler/boost via xdg-mime")
|
|
433
|
+
else:
|
|
434
|
+
out.warn("xdg-mime registration failed — run it manually")
|
|
435
|
+
else:
|
|
436
|
+
out.warn("xdg-mime not found — handler written but not registered")
|
|
437
|
+
else:
|
|
438
|
+
out.warn("no automatic registration on %s — use the handler script directly" % system)
|
|
439
|
+
journal.log("protocol", "register")
|
|
440
|
+
return 0
|
|
441
|
+
|
|
442
|
+
if args.action == "unregister":
|
|
443
|
+
removed = 0
|
|
444
|
+
for artifact in (_handler_script(), _desktop_file()):
|
|
445
|
+
if artifact.exists():
|
|
446
|
+
artifact.unlink()
|
|
447
|
+
out.ok("removed %s" % _tilde(artifact))
|
|
448
|
+
removed += 1
|
|
449
|
+
if not removed:
|
|
450
|
+
out.info("nothing registered")
|
|
451
|
+
journal.log("protocol", "unregister")
|
|
452
|
+
return 0
|
|
453
|
+
|
|
454
|
+
# status
|
|
455
|
+
out.kv("platform", system)
|
|
456
|
+
out.kv("handler", _tilde(_handler_script())
|
|
457
|
+
if _handler_script().exists() else "not registered")
|
|
458
|
+
if system == "Linux":
|
|
459
|
+
out.kv("desktop", _tilde(_desktop_file())
|
|
460
|
+
if _desktop_file().exists() else "not registered")
|
|
461
|
+
out.kv("URL forms", "boost://install/<skill> · boost://install/<tap>:<skill> "
|
|
462
|
+
"· boost://tap/<owner>/<repo>")
|
|
463
|
+
out.dim(" try it: boost protocol open boost://install/brainstorming")
|
|
464
|
+
return 0
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
# ---------------------------------------------------------------- pulse
|
|
468
|
+
|
|
469
|
+
_ACTION_COLOR = {"install": out.GREEN, "uninstall": out.RED,
|
|
470
|
+
"evolve": out.YELLOW, "edit": out.YELLOW}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def cmd_pulse(argv) -> int:
|
|
474
|
+
"""boost pulse [-n N] [--all] [--action A] [--json]"""
|
|
475
|
+
p = argparse.ArgumentParser(
|
|
476
|
+
prog="boost pulse",
|
|
477
|
+
description="Team activity feed of skill-management events")
|
|
478
|
+
p.add_argument("-n", type=int, default=20, help="events to show (default 20)")
|
|
479
|
+
p.add_argument("--all", action="store_true", help="show the whole journal")
|
|
480
|
+
p.add_argument("--action", help="filter by action (install, tap, ...)")
|
|
481
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
482
|
+
args = p.parse_args(argv)
|
|
483
|
+
|
|
484
|
+
events = journal.events(None if args.all else args.n, action=args.action)
|
|
485
|
+
if args.json:
|
|
486
|
+
print(json.dumps(events, indent=2))
|
|
487
|
+
return 0
|
|
488
|
+
if not events:
|
|
489
|
+
out.info("no activity yet — events appear as you install and manage skills")
|
|
490
|
+
return 0
|
|
491
|
+
for e in events:
|
|
492
|
+
action = e.get("action", "?")
|
|
493
|
+
color = _ACTION_COLOR.get(action, out.CYAN)
|
|
494
|
+
extras = {k: v for k, v in e.items()
|
|
495
|
+
if k not in ("ts", "user", "action", "subject")}
|
|
496
|
+
extra_s = (" " + " ".join("%s=%s" % kv for kv in sorted(extras.items()))
|
|
497
|
+
if extras else "")
|
|
498
|
+
print(" %s %s %s %s%s" % (
|
|
499
|
+
util.rel_time(e.get("ts", "")).rjust(7),
|
|
500
|
+
out.c(e.get("user", "?").ljust(10), out.CYAN),
|
|
501
|
+
out.c(action.ljust(11), color),
|
|
502
|
+
out.c(e.get("subject", ""), out.BOLD),
|
|
503
|
+
out.c(extra_s, out.DIM)))
|
|
504
|
+
print()
|
|
505
|
+
out.dim("local journal · share it with your team via `boost onboard`")
|
|
506
|
+
return 0
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# ---------------------------------------------------------------- replay
|
|
510
|
+
|
|
511
|
+
def cmd_replay(argv) -> int:
|
|
512
|
+
"""boost replay [list|show ID|rollback ID]"""
|
|
513
|
+
p = argparse.ArgumentParser(
|
|
514
|
+
prog="boost replay",
|
|
515
|
+
description="View version history & roll back skills")
|
|
516
|
+
p.add_argument("action", nargs="?", default="list",
|
|
517
|
+
choices=["list", "show", "rollback"])
|
|
518
|
+
p.add_argument("id", nargs="?", help="history entry id (from `boost replay list`)")
|
|
519
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
520
|
+
args = p.parse_args(argv)
|
|
521
|
+
|
|
522
|
+
if args.action == "list":
|
|
523
|
+
history = lockfile.history_list()
|
|
524
|
+
if args.json:
|
|
525
|
+
print(json.dumps(history, indent=2))
|
|
526
|
+
return 0
|
|
527
|
+
if not history:
|
|
528
|
+
out.info("no lock history yet — every install/uninstall snapshots the lock file")
|
|
529
|
+
return 0
|
|
530
|
+
rows = []
|
|
531
|
+
prev_skills = None
|
|
532
|
+
annotated = []
|
|
533
|
+
for h in history: # oldest -> newest
|
|
534
|
+
try:
|
|
535
|
+
skills = set(lockfile.history_read(h["id"]).get("skills", {}))
|
|
536
|
+
except BoostError:
|
|
537
|
+
skills = set()
|
|
538
|
+
if prev_skills is None:
|
|
539
|
+
delta = ""
|
|
540
|
+
else:
|
|
541
|
+
n_added, n_removed = (len(skills - prev_skills),
|
|
542
|
+
len(prev_skills - skills))
|
|
543
|
+
parts = ([("+%d" % n_added)] if n_added else []) + \
|
|
544
|
+
([("-%d" % n_removed)] if n_removed else [])
|
|
545
|
+
delta = " ".join(parts)
|
|
546
|
+
annotated.append((h, delta))
|
|
547
|
+
prev_skills = skills
|
|
548
|
+
for h, delta in reversed(annotated): # newest first
|
|
549
|
+
rows.append((h["id"], util.rel_time(h["updated"]),
|
|
550
|
+
str(h["count"]), delta))
|
|
551
|
+
out.table(rows, headers=("ID", "WHEN", "SKILLS", "Δ"))
|
|
552
|
+
print()
|
|
553
|
+
out.dim("inspect with `boost replay show <id>` · restore with `boost replay rollback <id>`")
|
|
554
|
+
return 0
|
|
555
|
+
|
|
556
|
+
if not args.id:
|
|
557
|
+
p.error("%s needs a history ID" % args.action)
|
|
558
|
+
snapshot = lockfile.history_read(args.id)
|
|
559
|
+
snap_skills = snapshot.get("skills", {})
|
|
560
|
+
current = lockfile.installed()
|
|
561
|
+
added = sorted(n for n in current if n not in snap_skills)
|
|
562
|
+
removed = sorted(n for n in snap_skills if n not in current)
|
|
563
|
+
changed = sorted(n for n in current if n in snap_skills and
|
|
564
|
+
str(current[n].get("version")) != str(snap_skills[n].get("version")))
|
|
565
|
+
|
|
566
|
+
if args.action == "show":
|
|
567
|
+
if args.json:
|
|
568
|
+
print(json.dumps({"id": args.id, "since_snapshot": {
|
|
569
|
+
"added": added, "removed": removed, "changed": changed}}, indent=2))
|
|
570
|
+
return 0
|
|
571
|
+
out.heading("since %s (%s)" % (args.id,
|
|
572
|
+
util.rel_time(snapshot.get("updated", ""))))
|
|
573
|
+
if not (added or removed or changed):
|
|
574
|
+
out.ok("current state matches this snapshot")
|
|
575
|
+
return 0
|
|
576
|
+
for n in added:
|
|
577
|
+
out.info(out.c("+ %s" % n, out.GREEN) + out.c(" added since", out.DIM))
|
|
578
|
+
for n in removed:
|
|
579
|
+
out.info(out.c("- %s" % n, out.RED) + out.c(" removed since", out.DIM))
|
|
580
|
+
for n in changed:
|
|
581
|
+
out.info(out.c("~ %s %s → %s" % (n, snap_skills[n].get("version"),
|
|
582
|
+
current[n].get("version")), out.YELLOW))
|
|
583
|
+
return 0
|
|
584
|
+
|
|
585
|
+
# rollback
|
|
586
|
+
if not (added or removed or changed):
|
|
587
|
+
out.ok("already at this snapshot — nothing to do")
|
|
588
|
+
return 0
|
|
589
|
+
out.info("rollback to %s will: uninstall %d, install %d, revisit %d version change(s)"
|
|
590
|
+
% (args.id, len(added), len(removed), len(changed)))
|
|
591
|
+
if not out.confirm("proceed?"):
|
|
592
|
+
out.info("cancelled")
|
|
593
|
+
return 1
|
|
594
|
+
for n in added: # in current, not in snapshot
|
|
595
|
+
store.uninstall(n)
|
|
596
|
+
out.ok("uninstalled %s" % n)
|
|
597
|
+
for n in removed: # in snapshot, missing now
|
|
598
|
+
want = snap_skills[n]
|
|
599
|
+
entry = _resolve_entry(n, prefer_tap=want.get("tap"))
|
|
600
|
+
if entry is None:
|
|
601
|
+
out.warn("%s is gone from every tap — cannot restore" % n)
|
|
602
|
+
continue
|
|
603
|
+
res = store.install(entry, force=True)
|
|
604
|
+
if str(entry.get("version")) != str(want.get("version")):
|
|
605
|
+
out.warn("restored %s v%s from current tap state (snapshot had v%s)"
|
|
606
|
+
% (n, entry.get("version"), want.get("version")))
|
|
607
|
+
else:
|
|
608
|
+
out.ok("restored %s → %s" % (n, " · ".join(res.linked)))
|
|
609
|
+
for n in changed:
|
|
610
|
+
out.warn("%s version differs from snapshot (%s → %s) — taps only carry "
|
|
611
|
+
"their current state; `boost pin` prevents future drift"
|
|
612
|
+
% (n, snap_skills[n].get("version"), current[n].get("version")))
|
|
613
|
+
journal.log("replay", args.id, op="rollback")
|
|
614
|
+
out.ok("rollback to %s complete" % args.id)
|
|
615
|
+
return 0
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
# ---------------------------------------------------------------- who
|
|
619
|
+
|
|
620
|
+
def cmd_who(argv) -> int:
|
|
621
|
+
"""boost who [SKILL] [--json]"""
|
|
622
|
+
p = argparse.ArgumentParser(
|
|
623
|
+
prog="boost who",
|
|
624
|
+
description="Discover who on the team has skill expertise")
|
|
625
|
+
p.add_argument("skill", nargs="?", help="focus on one skill")
|
|
626
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
627
|
+
args = p.parse_args(argv)
|
|
628
|
+
|
|
629
|
+
events = journal.events(subject=args.skill) if args.skill else journal.events()
|
|
630
|
+
if not events:
|
|
631
|
+
out.info("no journal activity yet — expertise builds as people install, "
|
|
632
|
+
"edit, and evolve skills")
|
|
633
|
+
return 0
|
|
634
|
+
|
|
635
|
+
if args.skill:
|
|
636
|
+
lk = lockfile.get_skill(args.skill)
|
|
637
|
+
expertise = ("install", "edit", "evolve", "distill", "tag")
|
|
638
|
+
rows = [(util.rel_time(e.get("ts", "")), e.get("user", "?"),
|
|
639
|
+
e.get("action", "?"))
|
|
640
|
+
for e in events if e.get("action") in expertise] or \
|
|
641
|
+
[(util.rel_time(e.get("ts", "")), e.get("user", "?"),
|
|
642
|
+
e.get("action", "?")) for e in events]
|
|
643
|
+
if args.json:
|
|
644
|
+
print(json.dumps({"skill": args.skill, "installed": bool(lk),
|
|
645
|
+
"events": events}, indent=2))
|
|
646
|
+
return 0
|
|
647
|
+
out.heading(args.skill)
|
|
648
|
+
if lk:
|
|
649
|
+
out.kv("installed", "v%s from %s" % (lk.get("version"), lk.get("tap")))
|
|
650
|
+
out.table(rows[:20], headers=("WHEN", "USER", "ACTION"))
|
|
651
|
+
print()
|
|
652
|
+
out.dim("based on the local journal — in a team setup, pulse feeds "
|
|
653
|
+
"aggregate via `boost onboard`")
|
|
654
|
+
return 0
|
|
655
|
+
|
|
656
|
+
users: dict[str, dict] = {}
|
|
657
|
+
for e in events:
|
|
658
|
+
u = users.setdefault(e.get("user", "?"), {
|
|
659
|
+
"events": 0, "skills": set(), "installs": 0, "last": e.get("ts", "")})
|
|
660
|
+
u["events"] += 1
|
|
661
|
+
if e.get("subject"):
|
|
662
|
+
u["skills"].add(e["subject"])
|
|
663
|
+
if e.get("action") == "install":
|
|
664
|
+
u["installs"] += 1
|
|
665
|
+
u["last"] = max(u["last"], e.get("ts", ""))
|
|
666
|
+
if args.json:
|
|
667
|
+
print(json.dumps({u: {"events": d["events"], "installs": d["installs"],
|
|
668
|
+
"skills": sorted(d["skills"]),
|
|
669
|
+
"last_active": d["last"]}
|
|
670
|
+
for u, d in users.items()}, indent=2))
|
|
671
|
+
return 0
|
|
672
|
+
board = [(u, str(d["events"]), str(len(d["skills"])), str(d["installs"]),
|
|
673
|
+
util.rel_time(d["last"]))
|
|
674
|
+
for u, d in sorted(users.items(), key=lambda kv: -kv[1]["events"])]
|
|
675
|
+
out.table(board, headers=("USER", "EVENTS", "SKILLS", "INSTALLS", "LAST ACTIVE"))
|
|
676
|
+
print()
|
|
677
|
+
out.dim("based on the local journal — in a team setup, pulse feeds aggregate "
|
|
678
|
+
"via `boost onboard`")
|
|
679
|
+
return 0
|
|
File without changes
|