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,595 @@
|
|
|
1
|
+
"""Skill Information commands — list, info, cat, edit, preview, explain,
|
|
2
|
+
log, home, deps, tag.
|
|
3
|
+
|
|
4
|
+
Read-mostly views over the lock file, the canonical store, and tap catalogs.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import textwrap
|
|
16
|
+
import webbrowser
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ..core import ai, catalog, frontmatter, gitutil, journal, lockfile, paths, registry, store, util
|
|
20
|
+
from ..core import output as out
|
|
21
|
+
from ..errors import BoostError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------- helpers
|
|
25
|
+
|
|
26
|
+
def _tilde(p) -> str:
|
|
27
|
+
"""Show a path with $HOME contracted to ~."""
|
|
28
|
+
s, h = str(p), str(paths.home())
|
|
29
|
+
if s == h:
|
|
30
|
+
return "~"
|
|
31
|
+
if s.startswith(h + os.sep):
|
|
32
|
+
return "~" + s[len(h):]
|
|
33
|
+
return s
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _read(p: Path) -> str:
|
|
37
|
+
try:
|
|
38
|
+
return Path(p).read_text(encoding="utf-8", errors="replace")
|
|
39
|
+
except OSError as e:
|
|
40
|
+
raise BoostError("cannot read %s: %s" % (_tilde(p), e))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_skill_md(name: str):
|
|
44
|
+
"""Locate a skill's SKILL.md — installed store first, then tap clones.
|
|
45
|
+
|
|
46
|
+
Returns (path, lock_entry_or_None, catalog_entry_or_None).
|
|
47
|
+
"""
|
|
48
|
+
lock = lockfile.get_skill(name)
|
|
49
|
+
if lock:
|
|
50
|
+
p = store.skill_store_dir(name) / "SKILL.md"
|
|
51
|
+
if p.exists():
|
|
52
|
+
return p, lock, None
|
|
53
|
+
entry = catalog.resolve_one(name)
|
|
54
|
+
return registry.get(entry["tap"]).path / entry["skill_md"], lock, entry
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _as_list(v) -> list:
|
|
58
|
+
"""Normalize a frontmatter value to a list of non-empty strings."""
|
|
59
|
+
if v in (None, "", False):
|
|
60
|
+
return []
|
|
61
|
+
if isinstance(v, list):
|
|
62
|
+
return [str(x).strip() for x in v if str(x).strip()]
|
|
63
|
+
return [s.strip() for s in str(v).split(",") if s.strip()]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _skill_meta(name: str):
|
|
67
|
+
"""Frontmatter for a named skill — installed store preferred, else tap."""
|
|
68
|
+
p = store.skill_store_dir(name) / "SKILL.md"
|
|
69
|
+
if not p.exists():
|
|
70
|
+
matches = catalog.find(name)
|
|
71
|
+
if not matches:
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
p = registry.get(matches[0]["tap"]).path / matches[0]["skill_md"]
|
|
75
|
+
except BoostError:
|
|
76
|
+
return None
|
|
77
|
+
if not p.exists():
|
|
78
|
+
return None
|
|
79
|
+
return frontmatter.parse(_read(p))[0]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _file_count(d: Path) -> int:
|
|
83
|
+
return sum(1 for p in Path(d).rglob("*")
|
|
84
|
+
if p.is_file() and not any(part in util.IGNORED for part in p.parts))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _mark(installed: bool) -> str:
|
|
88
|
+
return (out.c("✓ installed", out.GREEN) if installed
|
|
89
|
+
else out.c("✗ not installed", out.RED))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _print_wrapped(text: str) -> None:
|
|
93
|
+
paras = [p for p in re.split(r"\n\s*\n", text.strip()) if p.strip()]
|
|
94
|
+
for i, para in enumerate(paras):
|
|
95
|
+
for line in textwrap.wrap(" ".join(para.split()), width=76):
|
|
96
|
+
out.info(line)
|
|
97
|
+
if i < len(paras) - 1:
|
|
98
|
+
print()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------- commands
|
|
102
|
+
|
|
103
|
+
def cmd_list(argv):
|
|
104
|
+
ap = argparse.ArgumentParser(prog="boost list",
|
|
105
|
+
description="List installed skills")
|
|
106
|
+
ap.add_argument("--tag", help="only show skills carrying this tag")
|
|
107
|
+
ap.add_argument("--json", action="store_true", help="machine-readable output")
|
|
108
|
+
args = ap.parse_args(argv)
|
|
109
|
+
skills = lockfile.installed()
|
|
110
|
+
if args.tag:
|
|
111
|
+
want = args.tag.lstrip("#")
|
|
112
|
+
skills = {n: e for n, e in skills.items() if want in (e.get("tags") or [])}
|
|
113
|
+
if args.json:
|
|
114
|
+
print(json.dumps(skills, indent=2, sort_keys=True))
|
|
115
|
+
return 0
|
|
116
|
+
if not skills:
|
|
117
|
+
out.info("no skills installed" +
|
|
118
|
+
(" with tag #%s" % args.tag.lstrip("#") if args.tag else ""))
|
|
119
|
+
out.info(out.c("hint: boost tap --defaults && boost search <topic>", out.DIM))
|
|
120
|
+
return 0
|
|
121
|
+
rows = []
|
|
122
|
+
for name in sorted(skills):
|
|
123
|
+
e = skills[name]
|
|
124
|
+
flags = (["pinned"] if e.get("pinned") else []) + \
|
|
125
|
+
(["quarantined"] if e.get("quarantined") else []) + \
|
|
126
|
+
["#" + t for t in e.get("tags") or []]
|
|
127
|
+
rows.append((name, e.get("version", "?"), e.get("tap", "?"),
|
|
128
|
+
"·".join(a.split("-")[0] for a in e.get("agents") or []),
|
|
129
|
+
" ".join(flags)))
|
|
130
|
+
out.table(rows, headers=("NAME", "VERSION", "TAP", "AGENTS", "FLAGS"))
|
|
131
|
+
print(out.c(" %d skill%s" % (len(rows), "" if len(rows) == 1 else "s"), out.DIM))
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_info(argv):
|
|
136
|
+
ap = argparse.ArgumentParser(prog="boost info",
|
|
137
|
+
description="Show detailed info about a skill")
|
|
138
|
+
ap.add_argument("name")
|
|
139
|
+
ap.add_argument("--json", action="store_true", help="machine-readable output")
|
|
140
|
+
args = ap.parse_args(argv)
|
|
141
|
+
name = args.name
|
|
142
|
+
lock = lockfile.get_skill(name)
|
|
143
|
+
if lock:
|
|
144
|
+
matches = catalog.find(name)
|
|
145
|
+
same_tap = [e for e in matches if e["tap"] == lock.get("tap")]
|
|
146
|
+
cat = (same_tap or matches or [None])[0]
|
|
147
|
+
else:
|
|
148
|
+
cat = catalog.resolve_one(name) # raises if unknown anywhere
|
|
149
|
+
|
|
150
|
+
sdir = store.skill_store_dir(name)
|
|
151
|
+
skill_dir = sdir if lock and sdir.is_dir() else None
|
|
152
|
+
if skill_dir is None and cat:
|
|
153
|
+
try:
|
|
154
|
+
skill_dir = store.source_dir_for(cat)
|
|
155
|
+
except BoostError:
|
|
156
|
+
skill_dir = None
|
|
157
|
+
desc = str((cat or {}).get("description") or "")
|
|
158
|
+
meta = {}
|
|
159
|
+
if skill_dir and (skill_dir / "SKILL.md").exists():
|
|
160
|
+
meta, _body = frontmatter.parse(_read(skill_dir / "SKILL.md"))
|
|
161
|
+
desc = desc or str(meta.get("description") or "")
|
|
162
|
+
score = size = files = None
|
|
163
|
+
if skill_dir:
|
|
164
|
+
score, _notes = util.score_skill(skill_dir)
|
|
165
|
+
size, files = util.dir_size(skill_dir), _file_count(skill_dir)
|
|
166
|
+
|
|
167
|
+
if args.json:
|
|
168
|
+
print(json.dumps({
|
|
169
|
+
"name": name, "description": desc,
|
|
170
|
+
"installed": lock, "latest": (cat or {}).get("version"),
|
|
171
|
+
"tap": (lock or cat or {}).get("tap"),
|
|
172
|
+
"store": str(sdir) if lock and sdir.is_dir() else None,
|
|
173
|
+
"quality": score, "size": size, "files": files,
|
|
174
|
+
}, indent=2))
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
out.heading(name)
|
|
178
|
+
if desc:
|
|
179
|
+
lines = textwrap.wrap(desc, width=62)
|
|
180
|
+
out.kv("description", lines[0])
|
|
181
|
+
for ln in lines[1:]:
|
|
182
|
+
print(" " * 16 + ln)
|
|
183
|
+
if lock:
|
|
184
|
+
inst_v = str(lock.get("version", "?"))
|
|
185
|
+
out.kv("version", inst_v)
|
|
186
|
+
latest = str((cat or {}).get("version") or "")
|
|
187
|
+
if cat and latest != inst_v:
|
|
188
|
+
out.kv("latest", out.c(latest, out.YELLOW, out.BOLD)
|
|
189
|
+
+ out.c(" (update available)", out.DIM))
|
|
190
|
+
else:
|
|
191
|
+
out.kv("latest", str(cat.get("version", "?")))
|
|
192
|
+
out.kv("tap", (lock or cat).get("tap", "?"))
|
|
193
|
+
if lock and sdir.is_dir():
|
|
194
|
+
out.kv("store", _tilde(sdir))
|
|
195
|
+
src = lock.get("source_dir") if lock else cat.get("rel_dir")
|
|
196
|
+
if src:
|
|
197
|
+
out.kv("source", _tilde(src))
|
|
198
|
+
if lock:
|
|
199
|
+
if lock.get("commit"):
|
|
200
|
+
out.kv("commit", str(lock["commit"])[:9])
|
|
201
|
+
if lock.get("sha256"):
|
|
202
|
+
out.kv("sha256", str(lock["sha256"])[:12])
|
|
203
|
+
ia, ua = lock.get("installed_at"), lock.get("updated_at")
|
|
204
|
+
if ia:
|
|
205
|
+
out.kv("installed", "%s (%s)" % (ia, util.rel_time(ia)))
|
|
206
|
+
if ua and ua != ia:
|
|
207
|
+
out.kv("updated", "%s (%s)" % (ua, util.rel_time(ua)))
|
|
208
|
+
out.kv("agents", ", ".join(lock.get("agents") or []) or "(none)")
|
|
209
|
+
out.kv("pinned", "yes" if lock.get("pinned") else "no")
|
|
210
|
+
out.kv("quarantined", "yes" if lock.get("quarantined") else "no")
|
|
211
|
+
if lock.get("tags"):
|
|
212
|
+
out.kv("tags", " ".join("#" + t for t in lock["tags"]))
|
|
213
|
+
elif _as_list(meta.get("tags")):
|
|
214
|
+
out.kv("tags", " ".join("#" + t for t in _as_list(meta.get("tags"))))
|
|
215
|
+
if score is not None:
|
|
216
|
+
out.kv("quality", "%d/100" % score)
|
|
217
|
+
out.kv("size", util.human_size(size))
|
|
218
|
+
out.kv("files", str(files))
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def cmd_cat(argv):
|
|
223
|
+
ap = argparse.ArgumentParser(prog="boost cat",
|
|
224
|
+
description="Print a skill or rule's contents")
|
|
225
|
+
ap.add_argument("name")
|
|
226
|
+
ap.add_argument("--raw", action="store_true", help="no styling even on a TTY")
|
|
227
|
+
args = ap.parse_args(argv)
|
|
228
|
+
path, _lock, _cat = _resolve_skill_md(args.name)
|
|
229
|
+
text = _read(path)
|
|
230
|
+
if args.raw or not sys.stdout.isatty():
|
|
231
|
+
sys.stdout.write(text if text.endswith("\n") else text + "\n")
|
|
232
|
+
return 0
|
|
233
|
+
block, body = frontmatter.split(text)
|
|
234
|
+
if block:
|
|
235
|
+
print(out.c("---", out.DIM))
|
|
236
|
+
for line in block.splitlines():
|
|
237
|
+
print(out.c(line, out.DIM))
|
|
238
|
+
print(out.c("---", out.DIM))
|
|
239
|
+
print()
|
|
240
|
+
for line in body.splitlines():
|
|
241
|
+
print(out.c(line, out.BOLD) if re.match(r"^#{1,6} ", line) else line)
|
|
242
|
+
return 0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def cmd_edit(argv):
|
|
246
|
+
ap = argparse.ArgumentParser(prog="boost edit",
|
|
247
|
+
description="Open a skill's SKILL.md in your editor")
|
|
248
|
+
ap.add_argument("name")
|
|
249
|
+
args = ap.parse_args(argv)
|
|
250
|
+
lock = lockfile.get_skill(args.name)
|
|
251
|
+
if not lock:
|
|
252
|
+
raise BoostError("%s is not installed" % args.name,
|
|
253
|
+
hint="install it first, or `boost cat %s` to read the tap copy"
|
|
254
|
+
% args.name)
|
|
255
|
+
sdir = store.skill_store_dir(args.name)
|
|
256
|
+
path = sdir / "SKILL.md"
|
|
257
|
+
if not path.exists():
|
|
258
|
+
raise BoostError("SKILL.md missing from %s" % _tilde(sdir),
|
|
259
|
+
hint="repair the store with `boost sync`")
|
|
260
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "vi"
|
|
261
|
+
cmd = shlex.split(editor) or ["vi"] # support EDITOR="code -w" etc.
|
|
262
|
+
try:
|
|
263
|
+
rc = subprocess.call(cmd + [str(path)])
|
|
264
|
+
except OSError as e:
|
|
265
|
+
raise BoostError("cannot launch editor %r: %s" % (editor, e),
|
|
266
|
+
hint="set $VISUAL or $EDITOR to a valid command")
|
|
267
|
+
if rc != 0:
|
|
268
|
+
out.warn("editor exited with status %d" % rc)
|
|
269
|
+
sha = util.sha256_dir(sdir)
|
|
270
|
+
if sha != lock.get("sha256"):
|
|
271
|
+
lock["sha256"], lock["updated_at"] = sha, util.now_iso()
|
|
272
|
+
lockfile.set_skill(args.name, lock)
|
|
273
|
+
journal.log("edit", args.name)
|
|
274
|
+
out.warn("local edits diverge from the tap source — boost drift will flag this")
|
|
275
|
+
else:
|
|
276
|
+
out.ok("no changes")
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _inline(s: str) -> str:
|
|
281
|
+
s = re.sub(r"`([^`]+)`", lambda m: out.c(m.group(1), out.CYAN), s)
|
|
282
|
+
return re.sub(r"\*\*([^*]+)\*\*", lambda m: out.c(m.group(1), out.BOLD), s)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _render_markdown(body: str) -> None:
|
|
286
|
+
"""Modest ANSI renderer: headings, fences, lists, inline code/bold."""
|
|
287
|
+
in_fence = prev_blank = False
|
|
288
|
+
for raw in body.splitlines():
|
|
289
|
+
line = raw.rstrip()
|
|
290
|
+
if line.lstrip().startswith("```"):
|
|
291
|
+
in_fence = not in_fence
|
|
292
|
+
continue
|
|
293
|
+
if in_fence:
|
|
294
|
+
print(out.c(" " + line, out.DIM))
|
|
295
|
+
prev_blank = False
|
|
296
|
+
continue
|
|
297
|
+
if not line:
|
|
298
|
+
if not prev_blank:
|
|
299
|
+
print()
|
|
300
|
+
prev_blank = True
|
|
301
|
+
continue
|
|
302
|
+
prev_blank = False
|
|
303
|
+
m = re.match(r"^(#{1,6})\s+(.*)$", line)
|
|
304
|
+
if m:
|
|
305
|
+
level, txt = len(m.group(1)), m.group(2)
|
|
306
|
+
if level == 1:
|
|
307
|
+
print(out.c(txt, out.BOLD, out.YELLOW))
|
|
308
|
+
print(out.c("─" * min(len(txt), 60), out.DIM))
|
|
309
|
+
elif level == 2:
|
|
310
|
+
print(out.c(txt, out.BOLD))
|
|
311
|
+
else:
|
|
312
|
+
print(out.c(txt, out.BOLD, out.DIM))
|
|
313
|
+
continue
|
|
314
|
+
m = re.match(r"^(\s*)[-*]\s+(.*)$", line)
|
|
315
|
+
if m:
|
|
316
|
+
print("%s • %s" % (m.group(1), _inline(m.group(2))))
|
|
317
|
+
continue
|
|
318
|
+
print(_inline(line))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def cmd_preview(argv):
|
|
322
|
+
ap = argparse.ArgumentParser(prog="boost preview",
|
|
323
|
+
description="Render a SKILL.md with rich formatting")
|
|
324
|
+
ap.add_argument("name")
|
|
325
|
+
args = ap.parse_args(argv)
|
|
326
|
+
path, lock, cat = _resolve_skill_md(args.name)
|
|
327
|
+
meta, body = frontmatter.parse(_read(path))
|
|
328
|
+
print(out.c("%s · v%s · %s" % (meta.get("name") or args.name,
|
|
329
|
+
meta.get("version") or "?",
|
|
330
|
+
(lock or cat or {}).get("tap", "local")), out.DIM))
|
|
331
|
+
print()
|
|
332
|
+
_render_markdown(body)
|
|
333
|
+
return 0
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def cmd_explain(argv):
|
|
337
|
+
ap = argparse.ArgumentParser(prog="boost explain",
|
|
338
|
+
description="Explain what a skill does in plain English")
|
|
339
|
+
ap.add_argument("name")
|
|
340
|
+
args = ap.parse_args(argv)
|
|
341
|
+
path, _lock, _cat = _resolve_skill_md(args.name)
|
|
342
|
+
text = _read(path)
|
|
343
|
+
if ai.available():
|
|
344
|
+
reply = ai.ask(
|
|
345
|
+
"Explain in plain English (4-6 sentences, no markdown) what this "
|
|
346
|
+
"AI coding-agent skill makes the agent do differently and when it "
|
|
347
|
+
"triggers:\n\n" + text,
|
|
348
|
+
system="You summarize agent skills for developers. Be concrete and brief.")
|
|
349
|
+
if reply:
|
|
350
|
+
_print_wrapped(reply)
|
|
351
|
+
return 0
|
|
352
|
+
out.warn(ai.fallback_note())
|
|
353
|
+
meta, body = frontmatter.parse(text)
|
|
354
|
+
desc = str(meta.get("description") or "").strip()
|
|
355
|
+
if desc:
|
|
356
|
+
_print_wrapped(desc)
|
|
357
|
+
headings = re.findall(r"^(#{1,6})\s+(.*)$", body, re.M)
|
|
358
|
+
if headings:
|
|
359
|
+
print()
|
|
360
|
+
out.info(out.c("Outline:", out.BOLD))
|
|
361
|
+
for hashes, title in headings:
|
|
362
|
+
out.info(" " * len(hashes) + title)
|
|
363
|
+
rules, seen = [], set()
|
|
364
|
+
for line in body.splitlines():
|
|
365
|
+
stripped = re.sub(r"^\s*(?:[-*]|\d+\.)\s+", "", line).strip()
|
|
366
|
+
if not stripped or stripped in seen:
|
|
367
|
+
continue
|
|
368
|
+
is_bullet = bool(re.match(r"^\s*(?:[-*]|\d+\.)\s", line))
|
|
369
|
+
if (re.match(r"(?i)^(always|never|must|do not)\b", stripped)
|
|
370
|
+
or (is_bullet and re.search(r"(?i)\b(always|never)\b", stripped))):
|
|
371
|
+
seen.add(stripped)
|
|
372
|
+
rules.append(stripped)
|
|
373
|
+
if rules:
|
|
374
|
+
print()
|
|
375
|
+
out.info(out.c("Key rules:", out.BOLD))
|
|
376
|
+
for rule in rules[:12]:
|
|
377
|
+
out.info(" • " + rule)
|
|
378
|
+
return 0
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def cmd_log(argv):
|
|
382
|
+
ap = argparse.ArgumentParser(prog="boost log",
|
|
383
|
+
description="Git log for a skill, or boost's activity log")
|
|
384
|
+
ap.add_argument("name", nargs="?", help="skill to show upstream history for")
|
|
385
|
+
ap.add_argument("-n", "--limit", type=int, default=20, metavar="N",
|
|
386
|
+
help="max entries (default 20)")
|
|
387
|
+
args = ap.parse_args(argv)
|
|
388
|
+
if args.name:
|
|
389
|
+
lock = lockfile.get_skill(args.name)
|
|
390
|
+
if lock:
|
|
391
|
+
tap_name, rel = lock.get("tap", "local"), lock.get("source_dir", ".")
|
|
392
|
+
else:
|
|
393
|
+
entry = catalog.resolve_one(args.name)
|
|
394
|
+
tap_name, rel = entry["tap"], entry["rel_dir"]
|
|
395
|
+
try:
|
|
396
|
+
tap = registry.get(tap_name)
|
|
397
|
+
except BoostError:
|
|
398
|
+
out.info("no upstream history (imported locally)")
|
|
399
|
+
return 0
|
|
400
|
+
if not tap.is_cloned:
|
|
401
|
+
raise BoostError("tap %s is not cloned" % tap.name,
|
|
402
|
+
hint="run `boost update %s`" % tap.name)
|
|
403
|
+
lines = gitutil.log_for_path(tap.path, rel, args.limit)
|
|
404
|
+
if not lines:
|
|
405
|
+
out.info("no commits touch %s in %s" % (args.name, tap.name))
|
|
406
|
+
return 0
|
|
407
|
+
out.heading("%s — history in %s" % (args.name, tap.name))
|
|
408
|
+
for line in lines:
|
|
409
|
+
out.info(line)
|
|
410
|
+
return 0
|
|
411
|
+
events = journal.events(args.limit)
|
|
412
|
+
if not events:
|
|
413
|
+
out.info("no activity yet")
|
|
414
|
+
return 0
|
|
415
|
+
colors = {"install": out.GREEN, "uninstall": out.RED}
|
|
416
|
+
w_time = max(len(util.rel_time(e.get("ts", ""))) for e in events)
|
|
417
|
+
w_user = max(len(e.get("user", "?")) for e in events)
|
|
418
|
+
for e in events:
|
|
419
|
+
action = e.get("action", "?")
|
|
420
|
+
out.info(("%s %s %s %s" % (
|
|
421
|
+
util.rel_time(e.get("ts", "")).ljust(w_time),
|
|
422
|
+
e.get("user", "?").ljust(w_user),
|
|
423
|
+
out.c(action, colors.get(action, out.CYAN)),
|
|
424
|
+
e.get("subject", ""))).rstrip())
|
|
425
|
+
return 0
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def cmd_home(argv):
|
|
429
|
+
ap = argparse.ArgumentParser(prog="boost home",
|
|
430
|
+
description="Open a skill's GitHub page in the browser")
|
|
431
|
+
ap.add_argument("name")
|
|
432
|
+
ap.add_argument("--print", dest="print_only", action="store_true",
|
|
433
|
+
help="print the URL without opening a browser")
|
|
434
|
+
args = ap.parse_args(argv)
|
|
435
|
+
lock = lockfile.get_skill(args.name)
|
|
436
|
+
try:
|
|
437
|
+
entry = catalog.resolve_one(args.name)
|
|
438
|
+
tap_name, rel = entry["tap"], entry["rel_dir"]
|
|
439
|
+
except BoostError:
|
|
440
|
+
if not lock:
|
|
441
|
+
raise
|
|
442
|
+
tap_name, rel = lock.get("tap", "local"), lock.get("source_dir", ".")
|
|
443
|
+
try:
|
|
444
|
+
tap = registry.get(tap_name)
|
|
445
|
+
except BoostError:
|
|
446
|
+
out.info(_tilde(rel)) # local import — only a path to show
|
|
447
|
+
return 0
|
|
448
|
+
if not tap.url.startswith(("http://", "https://")):
|
|
449
|
+
out.info(_tilde(Path(tap.url) if rel == "." else Path(tap.url) / rel))
|
|
450
|
+
return 0
|
|
451
|
+
url = tap.url.rstrip("/") + ("" if rel == "." else "/tree/HEAD/" + rel)
|
|
452
|
+
out.info(url)
|
|
453
|
+
if not args.print_only and sys.stdout.isatty():
|
|
454
|
+
webbrowser.open(url)
|
|
455
|
+
return 0
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def cmd_deps(argv):
|
|
459
|
+
ap = argparse.ArgumentParser(prog="boost deps",
|
|
460
|
+
description="Show dependency & conflict relationships")
|
|
461
|
+
ap.add_argument("name", nargs="?", help="skill to inspect (default: check all installed)")
|
|
462
|
+
ap.add_argument("--json", action="store_true", help="machine-readable output")
|
|
463
|
+
args = ap.parse_args(argv)
|
|
464
|
+
inst = lockfile.installed()
|
|
465
|
+
|
|
466
|
+
if args.name:
|
|
467
|
+
path, _lock, _cat = _resolve_skill_md(args.name)
|
|
468
|
+
meta = frontmatter.parse(_read(path))[0]
|
|
469
|
+
requires = _as_list(meta.get("requires"))
|
|
470
|
+
conflicts = _as_list(meta.get("conflicts"))
|
|
471
|
+
problems = (any(r not in inst for r in requires)
|
|
472
|
+
or any(c in inst for c in conflicts))
|
|
473
|
+
if args.json:
|
|
474
|
+
print(json.dumps({
|
|
475
|
+
"name": args.name,
|
|
476
|
+
"requires": [{"name": r, "installed": r in inst,
|
|
477
|
+
"requires": _as_list((_skill_meta(r) or {}).get("requires"))}
|
|
478
|
+
for r in requires],
|
|
479
|
+
"conflicts": [{"name": c, "installed": c in inst} for c in conflicts],
|
|
480
|
+
}, indent=2))
|
|
481
|
+
return 1 if problems else 0
|
|
482
|
+
out.info(out.c(args.name, out.BOLD))
|
|
483
|
+
if not requires:
|
|
484
|
+
out.info(" requires: " + out.c("(none)", out.DIM))
|
|
485
|
+
for r in requires:
|
|
486
|
+
out.info(" requires: %s %s" % (r, _mark(r in inst)))
|
|
487
|
+
for sub in _as_list((_skill_meta(r) or {}).get("requires")):
|
|
488
|
+
out.info(" ↳ %s %s" % (sub, _mark(sub in inst)))
|
|
489
|
+
if not conflicts:
|
|
490
|
+
out.info(" conflicts: " + out.c("(none)", out.DIM))
|
|
491
|
+
for c_name in conflicts:
|
|
492
|
+
state = (out.c("✗ installed (conflict!)", out.RED) if c_name in inst
|
|
493
|
+
else out.c("not installed", out.DIM))
|
|
494
|
+
out.info(" conflicts: %s %s" % (c_name, state))
|
|
495
|
+
return 1 if problems else 0
|
|
496
|
+
|
|
497
|
+
unmet, pairs, seen = [], [], set()
|
|
498
|
+
for name in sorted(inst):
|
|
499
|
+
meta = _skill_meta(name) or {}
|
|
500
|
+
for r in _as_list(meta.get("requires")):
|
|
501
|
+
if r not in inst:
|
|
502
|
+
unmet.append({"skill": name, "requires": r})
|
|
503
|
+
for c_name in _as_list(meta.get("conflicts")):
|
|
504
|
+
if c_name in inst:
|
|
505
|
+
key = tuple(sorted((name, c_name)))
|
|
506
|
+
if key not in seen:
|
|
507
|
+
seen.add(key)
|
|
508
|
+
pairs.append(list(key))
|
|
509
|
+
if args.json:
|
|
510
|
+
print(json.dumps({"unmet": unmet, "conflicts": pairs}, indent=2))
|
|
511
|
+
return 1 if unmet or pairs else 0
|
|
512
|
+
if not inst:
|
|
513
|
+
out.info("no skills installed")
|
|
514
|
+
return 0
|
|
515
|
+
for u in unmet:
|
|
516
|
+
out.info("%s requires %s %s"
|
|
517
|
+
% (out.c(u["skill"], out.BOLD), u["requires"], _mark(False)))
|
|
518
|
+
for a, b in pairs:
|
|
519
|
+
out.info("%s %s %s" % (out.c(a, out.BOLD),
|
|
520
|
+
out.c("conflicts with", out.RED), out.c(b, out.BOLD)))
|
|
521
|
+
if not unmet and not pairs:
|
|
522
|
+
out.ok("no unmet requirements or conflicts across %d skill%s"
|
|
523
|
+
% (len(inst), "" if len(inst) == 1 else "s"))
|
|
524
|
+
return 0
|
|
525
|
+
return 1
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def cmd_tag(argv):
|
|
529
|
+
ap = argparse.ArgumentParser(prog="boost tag",
|
|
530
|
+
description="Custom labels for organizing skills")
|
|
531
|
+
ap.add_argument("--list", dest="list_all", action="store_true",
|
|
532
|
+
help="show every tag and the skills carrying it")
|
|
533
|
+
ap.add_argument("--json", action="store_true", help="machine-readable output")
|
|
534
|
+
ap.add_argument("name", nargs="?", help="installed skill")
|
|
535
|
+
ap.add_argument("mods", nargs="*", help="+tag to add, -tag to remove")
|
|
536
|
+
args, extras = ap.parse_known_args(argv)
|
|
537
|
+
# '-tag' tokens land in extras; re-order the combined pool to match the
|
|
538
|
+
# user's original argv so `-x +x` nets differently from `+x -x`.
|
|
539
|
+
remaining = list(args.mods) + extras
|
|
540
|
+
mods = []
|
|
541
|
+
for tok in argv:
|
|
542
|
+
if tok in remaining:
|
|
543
|
+
mods.append(tok)
|
|
544
|
+
remaining.remove(tok)
|
|
545
|
+
mods += remaining # anything unmatched keeps parse order (defensive)
|
|
546
|
+
|
|
547
|
+
if args.list_all:
|
|
548
|
+
mapping = {}
|
|
549
|
+
for name, e in sorted(lockfile.installed().items()):
|
|
550
|
+
for t in e.get("tags") or []:
|
|
551
|
+
mapping.setdefault(t, []).append(name)
|
|
552
|
+
if args.json:
|
|
553
|
+
print(json.dumps(mapping, indent=2, sort_keys=True))
|
|
554
|
+
return 0
|
|
555
|
+
if not mapping:
|
|
556
|
+
out.info("no tags yet")
|
|
557
|
+
out.info(out.c("hint: boost tag <skill> +mytag", out.DIM))
|
|
558
|
+
return 0
|
|
559
|
+
out.table([("#" + t, ", ".join(mapping[t])) for t in sorted(mapping)],
|
|
560
|
+
headers=("TAG", "SKILLS"))
|
|
561
|
+
return 0
|
|
562
|
+
|
|
563
|
+
if not args.name:
|
|
564
|
+
raise BoostError("skill name required",
|
|
565
|
+
hint="`boost tag NAME +tag -tag`, or `boost tag --list`")
|
|
566
|
+
entry = lockfile.get_skill(args.name)
|
|
567
|
+
if not entry:
|
|
568
|
+
raise BoostError("%s is not installed" % args.name,
|
|
569
|
+
hint="see what is with `boost list`")
|
|
570
|
+
tags = list(entry.get("tags") or [])
|
|
571
|
+
changed = False
|
|
572
|
+
for tok in mods:
|
|
573
|
+
if not tok or tok[0] not in "+-":
|
|
574
|
+
raise BoostError("cannot parse %r" % tok,
|
|
575
|
+
hint="prefix tags with + to add or - to remove")
|
|
576
|
+
t = tok[1:].lstrip("#").strip()
|
|
577
|
+
if not t:
|
|
578
|
+
raise BoostError("empty tag in %r" % tok)
|
|
579
|
+
if tok[0] == "+" and t not in tags:
|
|
580
|
+
tags.append(t)
|
|
581
|
+
changed = True
|
|
582
|
+
elif tok[0] == "-" and t in tags:
|
|
583
|
+
tags.remove(t)
|
|
584
|
+
changed = True
|
|
585
|
+
if changed:
|
|
586
|
+
entry["tags"] = sorted(tags)
|
|
587
|
+
tags = entry["tags"]
|
|
588
|
+
lockfile.set_skill(args.name, entry)
|
|
589
|
+
journal.log("tag", args.name, tags=tags)
|
|
590
|
+
if args.json:
|
|
591
|
+
print(json.dumps({"name": args.name, "tags": tags}, indent=2))
|
|
592
|
+
return 0
|
|
593
|
+
shown = " ".join(out.c("#" + t, out.CYAN) for t in tags) or out.c("(no tags)", out.DIM)
|
|
594
|
+
(out.ok if changed else out.info)("%s %s" % (args.name, shown))
|
|
595
|
+
return 0
|