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,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