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,650 @@
1
+ """Discovery & Search commands: search, discover, recommend, browse,
2
+ index, trending, stats, count.
3
+
4
+ Also exports detect_stack(), shared with the Quality commands.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from pathlib import Path
17
+
18
+ from ..core import agents, ai, catalog, gitutil, journal, lockfile, paths, registry, store, util
19
+ from ..core import output as out
20
+ from ..errors import BoostError
21
+
22
+ _SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv",
23
+ "dist", "build", "target", "vendor"}
24
+
25
+ _EXT_LANGS = {".py": "python", ".ts": "typescript", ".tsx": "typescript",
26
+ ".js": "javascript", ".jsx": "javascript", ".go": "go",
27
+ ".rs": "rust", ".java": "java", ".rb": "ruby", ".kt": "kotlin",
28
+ ".swift": "swift", ".php": "php", ".cs": "csharp"}
29
+
30
+
31
+ def _positive_int(s: str) -> int:
32
+ """argparse type for --limit flags: an int that must be >= 1."""
33
+ try:
34
+ v = int(s)
35
+ except ValueError:
36
+ raise argparse.ArgumentTypeError("invalid int value: %r" % s)
37
+ if v < 1:
38
+ raise argparse.ArgumentTypeError("must be >= 1")
39
+ return v
40
+
41
+
42
+ def _tilde(p) -> str:
43
+ """Show a path with $HOME contracted to ~."""
44
+ s = str(p)
45
+ for h in (str(paths.home()), str(paths.home().resolve())):
46
+ if s == h or s.startswith(h + os.sep):
47
+ return "~" + s[len(h):]
48
+ return s
49
+
50
+
51
+ def _read_text(p: Path) -> str:
52
+ try:
53
+ return p.read_text(encoding="utf-8", errors="replace")
54
+ except OSError:
55
+ return ""
56
+
57
+
58
+ def _json_array(text):
59
+ """Best-effort extraction of the first JSON array in an AI reply."""
60
+ m = re.search(r"\[.*\]", text or "", re.S)
61
+ if not m:
62
+ return None
63
+ try:
64
+ data = json.loads(m.group(0))
65
+ except json.JSONDecodeError:
66
+ return None
67
+ return data if isinstance(data, list) else None
68
+
69
+
70
+ def _discovery_path() -> Path:
71
+ return paths.cache_dir() / "discovery.json"
72
+
73
+
74
+ def detect_stack(path) -> dict:
75
+ """Detect a project's tech stack from files on disk.
76
+
77
+ Walks at most two directory levels (skipping .git/node_modules etc.) and
78
+ returns {"languages": [...], "frameworks": [...], "keywords": [...]}.
79
+ Shared with the Quality commands.
80
+ """
81
+ root = Path(path)
82
+ langs, frameworks, extras = set(), set(), set()
83
+ markers: dict = {}
84
+ ext_counts: dict = {}
85
+ for dirpath, dirnames, filenames in os.walk(str(root)):
86
+ rel = os.path.relpath(dirpath, str(root))
87
+ depth = 0 if rel == "." else rel.count(os.sep) + 1
88
+ dirnames[:] = [] if depth >= 2 else [d for d in dirnames
89
+ if d not in _SKIP_DIRS]
90
+ for fn in filenames:
91
+ markers.setdefault(fn.lower(), Path(dirpath) / fn)
92
+ ext = os.path.splitext(fn)[1].lower()
93
+ if ext:
94
+ ext_counts[ext] = ext_counts.get(ext, 0) + 1
95
+
96
+ def read(*names) -> str:
97
+ return "\n".join(_read_text(markers[n]) for n in names
98
+ if n in markers).lower()
99
+
100
+ if "package.json" in markers:
101
+ langs.add("javascript")
102
+ try:
103
+ pkg = json.loads(_read_text(markers["package.json"]))
104
+ except json.JSONDecodeError:
105
+ pkg = {}
106
+ if not isinstance(pkg, dict):
107
+ pkg = {}
108
+ deps: set[str] = set()
109
+ for section in ("dependencies", "devDependencies", "peerDependencies"):
110
+ deps.update(pkg.get(section) or {})
111
+ for dep in ("react", "vue", "next", "express"):
112
+ if any(d == dep or d.startswith((dep + "/", "@" + dep + "/"))
113
+ for d in deps):
114
+ frameworks.add(dep)
115
+ if "typescript" in deps:
116
+ langs.add("typescript")
117
+ if "pyproject.toml" in markers or "requirements.txt" in markers:
118
+ langs.add("python")
119
+ blob = read("pyproject.toml", "requirements.txt")
120
+ for fw in ("django", "flask", "fastapi", "pytest"):
121
+ if fw in blob:
122
+ frameworks.add(fw)
123
+ if "go.mod" in markers:
124
+ langs.add("go")
125
+ if "cargo.toml" in markers:
126
+ langs.add("rust")
127
+ if any(n in markers for n in ("pom.xml", "build.gradle", "build.gradle.kts")):
128
+ langs.add("java")
129
+ if "spring" in read("pom.xml", "build.gradle", "build.gradle.kts"):
130
+ frameworks.add("spring")
131
+ if "gemfile" in markers:
132
+ langs.add("ruby")
133
+ if "rails" in read("gemfile"):
134
+ frameworks.add("rails")
135
+ if "tsconfig.json" in markers:
136
+ langs.add("typescript")
137
+ if "dockerfile" in markers:
138
+ extras.add("docker")
139
+ if (root / ".github" / "workflows").is_dir():
140
+ extras.add("ci")
141
+ if ext_counts.get(".tf"):
142
+ extras.add("terraform")
143
+ for ext, n in ext_counts.items():
144
+ if n >= 2 and ext in _EXT_LANGS:
145
+ langs.add(_EXT_LANGS[ext])
146
+ return {"languages": sorted(langs), "frameworks": sorted(frameworks),
147
+ "keywords": sorted(langs | frameworks | extras)}
148
+
149
+
150
+ def _ai_rank(query: str, scored):
151
+ """Ask Claude to reorder the top hits. Returns a new scored list or None."""
152
+ top = scored[:15]
153
+ listing = "\n".join("- %s: %s" % (e["name"], e["description"])
154
+ for e, _ in top)
155
+ reply = ai.ask(
156
+ "Query: %s\n\nSkills:\n%s\n\nOrder these skill names best-match-first "
157
+ "for the query. Reply with ONLY a JSON array of names."
158
+ % (query, listing),
159
+ system="You rank AI coding skills by relevance to a search query.",
160
+ max_tokens=400)
161
+ order = _json_array(reply)
162
+ if not order:
163
+ return None
164
+ by_name = {e["name"]: (e, s) for e, s in scored}
165
+ names = [str(n) for n in order if str(n) in by_name]
166
+ rest = [t for t in scored if t[0]["name"] not in set(names)]
167
+ return [by_name[n] for n in names] + rest
168
+
169
+
170
+ def cmd_search(argv):
171
+ """Search the tap catalogs, optionally AI-reranked with --smart."""
172
+ p = argparse.ArgumentParser(
173
+ prog="boost search",
174
+ description="Search skills across tap registries (AI-ranked)")
175
+ p.add_argument("query", nargs="+", help="search terms")
176
+ p.add_argument("--smart", action="store_true",
177
+ help="rerank the top hits with Claude")
178
+ p.add_argument("--limit", type=_positive_int, default=15,
179
+ help="max results (default 15)")
180
+ p.add_argument("--json", action="store_true", dest="as_json",
181
+ help="machine-readable output")
182
+ args = p.parse_args(argv)
183
+ query = " ".join(args.query)
184
+ if not registry.list_taps():
185
+ raise BoostError("no taps configured — nothing to search",
186
+ hint="add the recommended registries with `boost tap --defaults`")
187
+ scored = catalog.search(query)
188
+ if args.as_json:
189
+ print(json.dumps([dict(e, score=s) for e, s in scored[:args.limit]]))
190
+ return 0
191
+ if not scored:
192
+ out.info("no matches for %r" % query)
193
+ out.info(out.c("try `boost discover %s` to search all of GitHub" % query,
194
+ out.DIM))
195
+ return 0
196
+ ranker = "heuristic relevance"
197
+ if args.smart:
198
+ if ai.available():
199
+ reranked = _ai_rank(query, scored)
200
+ if reranked:
201
+ scored, ranker = reranked, "Claude Haiku relevance"
202
+ else:
203
+ out.warn(ai.fallback_note())
204
+ shown = scored[:args.limit]
205
+ width = max(len(e["name"]) for e, _ in shown)
206
+ for e, _s in shown:
207
+ line = out.c(e["name"].ljust(width + 3), out.CYAN) + (e["description"] or "")
208
+ if e.get("curated"):
209
+ line += " " + out.c("★ curated", out.YELLOW)
210
+ out.info(line)
211
+ out.info(out.c("%d match%s · ranked by %s"
212
+ % (len(scored), "" if len(scored) == 1 else "es", ranker),
213
+ out.DIM))
214
+ return 0
215
+
216
+
217
+ def cmd_index(argv):
218
+ """Build ~/.boost/cache/discovery.json via `gh api` code search."""
219
+ p = argparse.ArgumentParser(
220
+ prog="boost index",
221
+ description="Build the discovery registry via GitHub Code Search")
222
+ p.add_argument("--limit", type=_positive_int, default=300,
223
+ help="max skill files to index (default 300)")
224
+ args = p.parse_args(argv)
225
+ if not shutil.which("gh"):
226
+ raise BoostError("the GitHub CLI (gh) is required to build the index",
227
+ hint="brew install gh && gh auth login")
228
+ items, total = [], 0
229
+ pages = min((max(1, args.limit) + 99) // 100, 10) # code search caps at 1000
230
+ for page in range(1, pages + 1):
231
+ if page > 1:
232
+ time.sleep(1) # stay under the code-search rate limit
233
+ try:
234
+ proc = subprocess.run(
235
+ ["gh", "api", "-H", "Accept: application/vnd.github+json",
236
+ "search/code?q=filename:SKILL.md&per_page=100&page=%d" % page],
237
+ capture_output=True, text=True, timeout=120)
238
+ except (subprocess.TimeoutExpired, OSError) as e:
239
+ raise BoostError("gh api timed out on page %d" % page, hint=str(e))
240
+ if proc.returncode != 0:
241
+ tail = "\n".join((proc.stderr or proc.stdout or "").strip()
242
+ .splitlines()[-3:])
243
+ if not items:
244
+ raise BoostError("GitHub code search failed",
245
+ hint=tail or "check `gh auth status`")
246
+ out.warn("page %d failed — keeping the %d items fetched so far"
247
+ % (page, len(items)))
248
+ break
249
+ try:
250
+ data = json.loads(proc.stdout)
251
+ except json.JSONDecodeError:
252
+ raise BoostError("gh api returned unparseable JSON",
253
+ hint="try `boost index` again, or `gh auth status`")
254
+ if page == 1:
255
+ total = int(data.get("total_count") or 0)
256
+ batch = data.get("items") or []
257
+ for it in batch:
258
+ repo = it.get("repository") or {}
259
+ items.append({
260
+ "repo": repo.get("full_name", "?"),
261
+ "path": it.get("path", ""),
262
+ "url": it.get("html_url", ""),
263
+ "description": repo.get("description") or "",
264
+ })
265
+ if len(items) >= args.limit:
266
+ break
267
+ if len(items) >= args.limit or len(batch) < 100:
268
+ break
269
+ paths.ensure_dirs()
270
+ _discovery_path().write_text(json.dumps(
271
+ {"generated": util.now_iso(), "github_total": total, "items": items},
272
+ indent=1))
273
+ repos = len({it["repo"] for it in items})
274
+ out.ok("indexed %d skill files across %d repos (GitHub reports %d total)"
275
+ % (len(items), repos, total))
276
+ journal.log("index", "%d skill files" % len(items), total=total)
277
+ return 0
278
+
279
+
280
+ def cmd_discover(argv):
281
+ """Browse/filter the GitHub-wide index built by `boost index`."""
282
+ p = argparse.ArgumentParser(
283
+ prog="boost discover",
284
+ description="Browse & search the GitHub-wide skill discovery index")
285
+ p.add_argument("query", nargs="*", help="filter terms (repo/path substring)")
286
+ p.add_argument("--limit", type=_positive_int, default=25,
287
+ help="max rows (default 25)")
288
+ p.add_argument("--json", action="store_true", dest="as_json",
289
+ help="machine-readable output")
290
+ args = p.parse_args(argv)
291
+ dpath = _discovery_path()
292
+ if not dpath.exists():
293
+ if args.as_json:
294
+ print(json.dumps([]))
295
+ return 0
296
+ out.info("the discovery index has not been built yet")
297
+ if shutil.which("gh"):
298
+ out.info("build it with `boost index` (GitHub Code Search)")
299
+ else:
300
+ out.info("install the GitHub CLI first (`brew install gh && "
301
+ "gh auth login`), then run `boost index`")
302
+ return 0
303
+ try:
304
+ data = json.loads(dpath.read_text())
305
+ except (json.JSONDecodeError, OSError):
306
+ raise BoostError("the discovery index is corrupt",
307
+ hint="rebuild it with `boost index`")
308
+ all_items = data.get("items") or []
309
+ tokens = [t.lower() for t in args.query if t.strip()]
310
+ items = [it for it in all_items
311
+ if all(t in ("%s %s %s" % (it.get("repo", ""), it.get("path", ""),
312
+ it.get("description", ""))).lower()
313
+ for t in tokens)]
314
+ shown = items[:args.limit]
315
+ if args.as_json:
316
+ print(json.dumps(shown))
317
+ return 0
318
+ if not shown:
319
+ out.info("no indexed skills match %r" % " ".join(args.query))
320
+ out.info(out.c("the index holds %d entries — rebuild with `boost index`"
321
+ % len(all_items), out.DIM))
322
+ return 0
323
+ out.table([(it.get("repo", "?"), it.get("path", ""),
324
+ out.c(it.get("url", ""), out.DIM)) for it in shown],
325
+ headers=("repo", "path", "url"))
326
+ out.info(out.c("%d of %d indexed skills · GitHub reports ~%d total"
327
+ % (len(shown), len(all_items),
328
+ int(data.get("github_total") or 0)), out.DIM))
329
+ return 0
330
+
331
+
332
+ def _ai_picks(stack: dict, cands):
333
+ """Ask Claude for its top-5 picks with reasons. Returns list or None."""
334
+ listing = "\n".join("- %s: %s" % (e["name"], e["description"])
335
+ for e in cands)
336
+ reply = ai.ask(
337
+ "Project stack: %s\n\nCandidate skills:\n%s\n\nPick the 5 best skills "
338
+ "for this project. Reply with ONLY a JSON array like "
339
+ '[{"name": "...", "reason": "one short line"}].'
340
+ % (json.dumps(stack), listing),
341
+ system="You recommend AI coding skills for a software project.",
342
+ max_tokens=500)
343
+ picks = _json_array(reply)
344
+ if not picks:
345
+ return None
346
+ return [pk for pk in picks if isinstance(pk, dict) and pk.get("name")][:5]
347
+
348
+
349
+ def cmd_recommend(argv):
350
+ """Match the detected tech stack against the catalog."""
351
+ p = argparse.ArgumentParser(
352
+ prog="boost recommend",
353
+ description="Suggest skills based on your project's tech stack")
354
+ p.add_argument("--path", default=".", help="project directory (default: cwd)")
355
+ p.add_argument("--limit", type=_positive_int, default=8,
356
+ help="max suggestions (default 8)")
357
+ p.add_argument("--json", action="store_true", dest="as_json",
358
+ help="machine-readable output")
359
+ args = p.parse_args(argv)
360
+ target = paths.expand(args.path).resolve()
361
+ if not target.is_dir():
362
+ raise BoostError("no such directory: %s" % args.path)
363
+ entries = catalog.all_entries()
364
+ if not entries:
365
+ raise BoostError("no skills in any tap to recommend from",
366
+ hint="add registries with `boost tap --defaults`")
367
+ stack = detect_stack(target)
368
+ agg = {}
369
+ for kw in stack["keywords"]:
370
+ for e, s in catalog.search(kw, entries):
371
+ rec = agg.setdefault(e["name"], {"entry": e, "score": 0,
372
+ "because": set()})
373
+ rec["score"] += s
374
+ rec["because"].add(kw)
375
+ for rec in agg.values():
376
+ if rec["entry"].get("curated"):
377
+ rec["score"] += 10
378
+ ranked = sorted(agg.values(),
379
+ key=lambda r: (-r["score"], r["entry"]["name"]))
380
+ if args.as_json:
381
+ print(json.dumps({"stack": stack, "recommendations": [
382
+ dict(r["entry"], score=r["score"], because=sorted(r["because"]))
383
+ for r in ranked[:args.limit]]}))
384
+ return 0
385
+ line = "stack: " + (", ".join(stack["languages"]) or "unknown")
386
+ if stack["frameworks"]:
387
+ line += " · frameworks: " + ", ".join(stack["frameworks"])
388
+ out.info(out.c("%s (%s)" % (line, _tilde(target)), out.DIM))
389
+ shown = ranked[:args.limit]
390
+ if not shown:
391
+ shown = [{"entry": e, "score": 0, "because": {"curated"}}
392
+ for e in entries if e.get("curated")][:args.limit]
393
+ if not shown:
394
+ out.info("no recommendations for this stack — try `boost search <keyword>`")
395
+ return 0
396
+ out.info("no stack-specific matches — curated picks instead:")
397
+ width = max(len(r["entry"]["name"]) for r in shown)
398
+ for r in shown:
399
+ e = r["entry"]
400
+ out.info(out.c(e["name"].ljust(width + 3), out.CYAN)
401
+ + (e["description"] or "") + " "
402
+ + out.c("because: %s" % ", ".join(sorted(r["because"])), out.DIM))
403
+ if ai.available():
404
+ picks = _ai_picks(stack, [r["entry"] for r in ranked[:20]])
405
+ if picks:
406
+ out.heading("AI picks")
407
+ for pk in picks:
408
+ out.info(out.c(str(pk.get("name", "?")), out.CYAN) + " "
409
+ + str(pk.get("reason", "")))
410
+ return 0
411
+
412
+
413
+ def _subseq(needle: str, hay: str) -> bool:
414
+ """True when needle is a subsequence of hay (fuzzy filter)."""
415
+ it = iter(hay)
416
+ return all(ch in it for ch in needle)
417
+
418
+
419
+ def _browse_plain(entries, why: str):
420
+ out.warn(why + " — showing the full catalog")
421
+ out.table([(e["name"], "v" + e["version"], e["tap"],
422
+ "★" if e.get("curated") else "") for e in entries],
423
+ headers=("name", "version", "tap", ""))
424
+ out.info(out.c("%d skills · install with `boost install <name>`"
425
+ % len(entries), out.DIM))
426
+ return 0
427
+
428
+
429
+ def _browse_tui(curses, entries):
430
+ """Run the curses UI. Returns the entry picked for install, or None."""
431
+ state = {"pick": None}
432
+
433
+ def ui(scr):
434
+ try:
435
+ curses.curs_set(0)
436
+ except curses.error:
437
+ pass
438
+ filt, sel, detail = "", 0, False
439
+ while True:
440
+ q = filt.lower()
441
+ matches = [e for e in entries
442
+ if _subseq(q, (e["name"] + " " + e["tap"]).lower())]
443
+ sel = max(0, min(sel, len(matches) - 1))
444
+ h, w = scr.getmaxyx()
445
+ pane = 6 if detail and matches else 0
446
+ try:
447
+ scr.erase()
448
+ scr.addnstr(0, 0, "filter: " + filt, w - 1, curses.A_BOLD)
449
+ scr.addnstr(1, 0, "up/down move · enter detail · i install · "
450
+ "q quit · %d/%d" % (len(matches), len(entries)),
451
+ w - 1, curses.A_DIM)
452
+ rows = max(1, h - 3 - pane)
453
+ top = max(0, sel - rows + 1)
454
+ for i, e in enumerate(matches[top:top + rows]):
455
+ line = "%s %s v%s %s" % ("★" if e.get("curated") else " ",
456
+ e["name"], e["version"], e["tap"])
457
+ attr = curses.A_REVERSE if top + i == sel else curses.A_NORMAL
458
+ scr.addnstr(2 + i, 0, line.ljust(w - 1), w - 1, attr)
459
+ if pane and matches:
460
+ e = matches[sel]
461
+ y0 = h - pane
462
+ scr.hline(y0, 0, curses.ACS_HLINE, w - 1)
463
+ tags = ", ".join(str(t) for t in
464
+ (e.get("meta", {}).get("tags") or []))
465
+ for j, ln in enumerate((
466
+ "%s v%s" % (e["name"], e["version"]),
467
+ (e["description"] or "")[:w - 3],
468
+ "tap: %s dir: %s" % (e["tap"], e["rel_dir"]),
469
+ "tags: %s" % (tags or "-"))):
470
+ if y0 + 1 + j < h:
471
+ scr.addnstr(y0 + 1 + j, 1, ln, w - 2)
472
+ scr.refresh()
473
+ except curses.error:
474
+ pass # terminal too small mid-draw; retry on next key
475
+ key = scr.getch()
476
+ if key in (ord("q"), 27): # q / ESC
477
+ return
478
+ if key == ord("i") and matches:
479
+ state["pick"] = matches[sel]
480
+ return
481
+ if key == curses.KEY_UP:
482
+ sel = max(0, sel - 1)
483
+ elif key == curses.KEY_DOWN:
484
+ sel = min(sel + 1, max(0, len(matches) - 1))
485
+ elif key in (10, 13, curses.KEY_ENTER):
486
+ detail = not detail
487
+ elif key in (curses.KEY_BACKSPACE, 127, 8):
488
+ filt = filt[:-1]
489
+ elif 32 <= key <= 126:
490
+ filt += chr(key)
491
+
492
+ curses.wrapper(ui) # wrapper guards drawing with try/finally endwin()
493
+ return state["pick"]
494
+
495
+
496
+ def cmd_browse(argv):
497
+ """Full-screen fuzzy browser over every catalog entry."""
498
+ p = argparse.ArgumentParser(
499
+ prog="boost browse",
500
+ description="Interactive full-screen TUI with fuzzy search")
501
+ p.parse_args(argv)
502
+ entries = sorted(catalog.all_entries(), key=lambda e: e["name"])
503
+ if not entries:
504
+ raise BoostError("no skills available to browse",
505
+ hint="add registries with `boost tap --defaults`")
506
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
507
+ return _browse_plain(entries, "interactive mode needs a TTY")
508
+ try:
509
+ import curses
510
+ except ImportError:
511
+ return _browse_plain(entries, "curses is unavailable on this Python")
512
+ picked = _browse_tui(curses, entries)
513
+ if picked is None:
514
+ return 0
515
+ res = store.install(picked)
516
+ out.ok("installed %s v%s → %s"
517
+ % (picked["name"], picked["version"], _tilde(res.dest)))
518
+ if res.linked:
519
+ out.info("linked into: "
520
+ + ", ".join(agents.display_name(a) for a in res.linked))
521
+ for conflict in res.conflicts:
522
+ out.warn("conflict: %s exists and is not a symlink" % _tilde(conflict))
523
+ return 0
524
+
525
+
526
+ def cmd_trending(argv):
527
+ """Rank skills by local install events from the journal."""
528
+ p = argparse.ArgumentParser(
529
+ prog="boost trending",
530
+ description="Show trending skills by install count")
531
+ p.add_argument("--limit", type=_positive_int, default=10,
532
+ help="max rows (default 10)")
533
+ args = p.parse_args(argv)
534
+ evs = journal.events(action="install")
535
+ by_name = {e["name"]: e for e in catalog.all_entries()}
536
+ if not evs:
537
+ out.heading("curated picks (no local install data yet)")
538
+ curated = [e for e in by_name.values() if e.get("curated")]
539
+ if not curated:
540
+ out.info("no curated skills available — add taps with `boost tap --defaults`")
541
+ return 0
542
+ out.table([(e["name"], "v" + e["version"], e["description"])
543
+ for e in sorted(curated, key=lambda e: e["name"])[:args.limit]])
544
+ return 0
545
+ agg = {}
546
+ for ev in evs: # most-recent-first, so first ts per subject is the latest
547
+ name = ev.get("subject") or "?"
548
+ rec = agg.setdefault(name, {"count": 0, "last": ev.get("ts", "")})
549
+ rec["count"] += 1
550
+ ranked = sorted(agg.items(), key=lambda kv: (-kv[1]["count"], kv[0]))
551
+ out.table([(name, str(rec["count"]), util.rel_time(rec["last"]),
552
+ by_name.get(name, {}).get("description", ""))
553
+ for name, rec in ranked[:args.limit]],
554
+ headers=("name", "installs", "last", "description"))
555
+ out.info(out.c("based on local install activity", out.DIM))
556
+ return 0
557
+
558
+
559
+ def cmd_stats(argv):
560
+ """Lock-file, journal, and upstream stats for one skill."""
561
+ p = argparse.ArgumentParser(
562
+ prog="boost stats",
563
+ description="Install statistics & trend for a single skill")
564
+ p.add_argument("name", help="skill name")
565
+ p.add_argument("--json", action="store_true", dest="as_json",
566
+ help="machine-readable output")
567
+ args = p.parse_args(argv)
568
+ name = args.name
569
+ lock = lockfile.get_skill(name)
570
+ matches = catalog.find(name)
571
+ cat = matches[0] if matches else None
572
+ if not lock and not cat:
573
+ raise BoostError("no skill named %r installed or in any tap" % name,
574
+ hint="try `boost search %s`" % name)
575
+ acts = {a: len(journal.events(action=a, subject=name))
576
+ for a in ("install", "update", "uninstall")}
577
+ latest = cat["version"] if cat else None
578
+ upstream = None
579
+ if cat:
580
+ try:
581
+ tap = registry.get(cat["tap"])
582
+ if tap.is_cloned:
583
+ lines = gitutil.log_for_path(tap.path, cat["rel_dir"], n=1)
584
+ upstream = lines[0] if lines else None
585
+ except BoostError:
586
+ pass
587
+ sdir = store.skill_store_dir(name)
588
+ size = util.dir_size(sdir) if lock and sdir.is_dir() else None
589
+ if args.as_json:
590
+ print(json.dumps({
591
+ "name": name, "installed": bool(lock), "lock": lock, "size": size,
592
+ "activity": acts,
593
+ "catalog": ({"latest": latest, "tap": cat["tap"],
594
+ "description": cat["description"],
595
+ "upstream": upstream} if cat else None)}))
596
+ return 0
597
+ out.heading(name)
598
+ if lock:
599
+ out.kv("version", lock.get("version", "?"))
600
+ out.kv("tap", lock.get("tap", "?"))
601
+ out.kv("installed", util.rel_time(lock.get("installed_at", "")))
602
+ out.kv("updated", util.rel_time(lock.get("updated_at", "")))
603
+ out.kv("agents", ", ".join(lock.get("agents") or []) or "none")
604
+ out.kv("pinned", "yes" if lock.get("pinned") else "no")
605
+ out.kv("sha256", str(lock.get("sha256", ""))[:12])
606
+ if size is not None:
607
+ out.kv("size", util.human_size(size))
608
+ else:
609
+ out.kv("version", cat["version"])
610
+ out.kv("tap", cat["tap"])
611
+ out.kv("description", cat["description"])
612
+ out.info(out.c("not installed — `boost install %s`" % name, out.DIM))
613
+ out.kv("activity", "%d installs · %d updates · %d uninstalls"
614
+ % (acts["install"], acts["update"], acts["uninstall"]))
615
+ if lock and latest:
616
+ if util.semver_gt(latest, lock.get("version", "0")):
617
+ out.kv("latest", "%s %s" % (latest, out.c("(update available)", out.YELLOW)))
618
+ else:
619
+ out.kv("latest", "%s %s" % (latest, out.c("(up to date)", out.DIM)))
620
+ if upstream:
621
+ out.kv("upstream", upstream)
622
+ return 0
623
+
624
+
625
+ def cmd_count(argv):
626
+ """One-line inventory: installed / available / taps / discovery index."""
627
+ p = argparse.ArgumentParser(
628
+ prog="boost count",
629
+ description="Quick summary of installed / available / taps")
630
+ p.add_argument("--json", action="store_true", dest="as_json",
631
+ help="machine-readable output")
632
+ args = p.parse_args(argv)
633
+ installed_n = len(lockfile.installed())
634
+ taps_n = len(registry.list_taps())
635
+ available_n = len(catalog.all_entries())
636
+ discovery = None
637
+ dpath = _discovery_path()
638
+ if dpath.exists():
639
+ try:
640
+ discovery = len(json.loads(dpath.read_text()).get("items") or [])
641
+ except (json.JSONDecodeError, OSError):
642
+ discovery = None
643
+ if args.as_json:
644
+ print(json.dumps({"installed": installed_n, "available": available_n,
645
+ "taps": taps_n, "discovery": discovery}))
646
+ return 0
647
+ out.info("installed %d · available %d (across %d tap%s) · discovery index %s"
648
+ % (installed_n, available_n, taps_n, "" if taps_n == 1 else "s",
649
+ discovery if discovery is not None else "not built"))
650
+ return 0