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