boost-skill-cli 1.0.4__tar.gz → 1.0.6__tar.gz

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.
Files changed (87) hide show
  1. boost_skill_cli-1.0.6/CLAUDE.md +58 -0
  2. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/PKG-INFO +13 -3
  3. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/README.md +12 -2
  4. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/_version.py +2 -2
  5. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/cli.py +59 -4
  6. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/info.py +40 -1
  7. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/quality.py +12 -2
  8. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/config.py +9 -0
  9. boost_skill_cli-1.0.6/boost_cli/core/logs.py +250 -0
  10. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/data/registries.json +80 -3
  11. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_skill_cli.egg-info/PKG-INFO +13 -3
  12. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_skill_cli.egg-info/SOURCES.txt +5 -0
  13. boost_skill_cli-1.0.6/docs/DEBUGGING.md +183 -0
  14. boost_skill_cli-1.0.6/docs/rag-architecture.md +250 -0
  15. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/scripts/build_registries.py +8 -0
  16. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/conftest.py +12 -0
  17. boost_skill_cli-1.0.6/tests/unit/test_gitutil.py +282 -0
  18. boost_skill_cli-1.0.6/tests/unit/test_logs.py +234 -0
  19. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_store.py +59 -5
  20. boost_skill_cli-1.0.4/tests/unit/test_gitutil.py +0 -155
  21. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/.gitignore +0 -0
  22. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/CONTRIBUTING.md +0 -0
  23. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/LICENSE +0 -0
  24. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/MANIFEST.in +0 -0
  25. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/Makefile +0 -0
  26. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/SECURITY.md +0 -0
  27. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost +0 -0
  28. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/__init__.py +0 -0
  29. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/__main__.py +0 -0
  30. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/__init__.py +0 -0
  31. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/configuration.py +0 -0
  32. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/discovery.py +0 -0
  33. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/intelligence.py +0 -0
  34. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/pkg.py +0 -0
  35. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/taps.py +0 -0
  36. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/team.py +0 -0
  37. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/__init__.py +0 -0
  38. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/agents.py +0 -0
  39. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/ai.py +0 -0
  40. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/catalog.py +0 -0
  41. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/frontmatter.py +0 -0
  42. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/gitutil.py +0 -0
  43. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/journal.py +0 -0
  44. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/lockfile.py +0 -0
  45. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/output.py +0 -0
  46. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/paths.py +0 -0
  47. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/policy.py +0 -0
  48. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/registry.py +0 -0
  49. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/store.py +0 -0
  50. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/util.py +0 -0
  51. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/errors.py +0 -0
  52. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_skill_cli.egg-info/dependency_links.txt +0 -0
  53. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_skill_cli.egg-info/entry_points.txt +0 -0
  54. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_skill_cli.egg-info/top_level.txt +0 -0
  55. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/docs/.claude/settings.local.json +0 -0
  56. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/docs/demo.gif +0 -0
  57. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/docs/demo.tape +0 -0
  58. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/docs/overview.html +0 -0
  59. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/pyproject.toml +0 -0
  60. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/scripts/mutation_gate.py +0 -0
  61. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/setup.cfg +0 -0
  62. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_cli_configuration.py +0 -0
  63. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_cli_discovery.py +0 -0
  64. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_cli_info.py +0 -0
  65. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_cli_intelligence.py +0 -0
  66. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_cli_pkg.py +0 -0
  67. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_cli_quality.py +0 -0
  68. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_cli_taps.py +0 -0
  69. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_cli_team.py +0 -0
  70. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_everyday_loop.py +0 -0
  71. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/make_fixture.py +0 -0
  72. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/smoke.sh +0 -0
  73. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_agents.py +0 -0
  74. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_ai.py +0 -0
  75. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_catalog.py +0 -0
  76. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_config.py +0 -0
  77. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_errors_and_cli_table.py +0 -0
  78. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_frontmatter.py +0 -0
  79. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_journal.py +0 -0
  80. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_lockfile.py +0 -0
  81. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_mutation_hardening.py +0 -0
  82. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_output.py +0 -0
  83. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_paths.py +0 -0
  84. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_policy.py +0 -0
  85. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_registry.py +0 -0
  86. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_util.py +0 -0
  87. {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_version.py +0 -0
@@ -0,0 +1,58 @@
1
+ # CLAUDE.md — working rules for the boost workspace
2
+
3
+ boost is a "Homebrew for AI coding skills": a Python CLI (`boost_cli`) that finds,
4
+ installs, and governs the skill/rule/workflow files that AI coding agents run on.
5
+ Package name on PyPI is `boost-skill-cli`; the command is `boost`.
6
+
7
+ ## The one gate that matters
8
+
9
+ Before calling any change done, run the full gate:
10
+
11
+ ```bash
12
+ make check # == lint test smoke mutation
13
+ ```
14
+
15
+ It is four gates and **all must pass**:
16
+
17
+ | Gate | Command | Threshold |
18
+ |------------|------------------------------------------------|-----------|
19
+ | `lint` | `ruff check boost_cli tests` + `mypy` | zero errors |
20
+ | `test` | `pytest tests/unit tests/functional --cov` | **80%** coverage (`fail_under = 80`) |
21
+ | `smoke` | `bash tests/smoke.sh` | 0 failed |
22
+ | `mutation` | `python3 scripts/mutation_gate.py --run --min 80` | **80%** of `boost_cli/core` mutants killed |
23
+
24
+ New/changed core logic needs tests that both cover it *and* kill mutants —
25
+ untested code counts as unkilled mutants, so the mutation gate fails even at 80%
26
+ line coverage. Target `boost_cli/core` behavior with assertions, not just imports.
27
+
28
+ ## Non-obvious rules
29
+
30
+ - **`boost_cli/data/registries.json` is GENERATED — never hand-edit it.** The
31
+ source of truth is `scripts/build_registries.py` (the `SKILLS` / `RULES` /
32
+ `WORKFLOWS` tuples). Edit those, then regenerate:
33
+ `python3 scripts/build_registries.py`. Each row is
34
+ `(owner/repo, category, focus, est_items, confidence)`. Add awesome-list repos
35
+ to `LIST_ONLY` so item-count math stays honest. Verify a repo is real
36
+ (`gh api repos/<owner/repo>`) before adding it.
37
+ - **Sandbox tests via env, and export separately.** boost state lives under
38
+ `HOME`/`BOOST_HOME`; tests point them at a tempdir. In zsh, one-line chains
39
+ like `export A=$(...) B=$A/x` leave `B` broken — use separate `export`
40
+ statements.
41
+ - **Versioning is setuptools-scm from git tags.** There is no `__version__`
42
+ constant and `boost_cli/_version.py` is generated + gitignored. Don't hardcode
43
+ or assert exact versions; version tests are shape-only (`^boost \S+$`). The
44
+ publish workflow filename must stay `publish.yml` (PyPI Trusted Publisher
45
+ matches on it).
46
+ - **Target Python ≥ 3.9** (`requires-python = ">=3.9"`). Avoid 3.10+-only syntax
47
+ (structural pattern matching, `X | Y` runtime unions in non-annotation context).
48
+ - **Three item kinds, one scanner.** `core/catalog.scan_dir` indexes `skill`
49
+ (SKILL.md), `rule` (.mdc/.cursorrules/.windsurfrules/.clinerules), and
50
+ `workflow` (commands/agents/workflows Markdown). **Only `skill` installs** —
51
+ `store.install` refuses non-skill kinds; rules/workflows are search/tap-only.
52
+
53
+ ## Layout
54
+
55
+ - `boost_cli/commands/` — CLI command groups · `boost_cli/core/` — engine (the mutation-gated code)
56
+ - `boost_cli/data/` — shipped catalog data (generated) · `scripts/` — build/gate tooling
57
+ - `tests/unit`, `tests/functional`, `tests/smoke.sh` — the three test tiers
58
+ - `docs/` — `overview.html` (visual guide), `DEBUGGING.md`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boost-skill-cli
3
- Version: 1.0.4
3
+ Version: 1.0.6
4
4
  Summary: boost — Homebrew for AI coding skills
5
5
  Author: Jonathan Reyes
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -744,14 +744,17 @@ boost tap --defaults # pull in the 5 starter registries
744
744
  ```
745
745
 
746
746
  Want the whole ecosystem instead of the starter set? boost ships a **curated
747
- registry catalog** — 90+ classified GitHub registries of skills, Cursor/Windsurf
747
+ registry catalog** — 100+ classified GitHub registries of skills, Cursor/Windsurf
748
748
  **rules**, and Claude Code **workflows** (slash commands & subagents),
749
- collectively indexing thousands of items:
749
+ collectively indexing thousands of items. Categories include a curated
750
+ **`rag`** set — official Weaviate, Pinecone, and DSPy skill libraries plus
751
+ Graph-RAG and agentic-RAG toolkits for building retrieval pipelines:
750
752
 
751
753
  ```bash
752
754
  boost tap --catalog --dry-run # browse the classified catalog
753
755
  boost tap --catalog --type skill --limit 20 # tap the 20 biggest skill packs
754
756
  boost tap --catalog --type rule # every rules registry
757
+ boost tap --catalog --category rag # RAG / vector-search skill packs
755
758
  boost tap --catalog --category security # filter by category
756
759
  ```
757
760
 
@@ -831,6 +834,13 @@ Standard-library Python only — no third-party runtime dependencies. Every
831
834
  path under `~/.boost` and `~/.agents/skills` is resolved from `$HOME` at
832
835
  call time, which is what makes the sandboxing above possible.
833
836
 
837
+ When something misbehaves, boost keeps a rotating diagnostic log at
838
+ `~/.boost/logs/boost.log` and writes a full crash report on any unexpected
839
+ error. Turn up detail with `boost --verbose <cmd>` or `boost --debug <cmd>`,
840
+ read the trail with `boost log --diagnostics`, and see
841
+ [`docs/DEBUGGING.md`](docs/DEBUGGING.md) for log levels, env vars, crash
842
+ reports, and the free services that monitor the project.
843
+
834
844
  ## Test suite
835
845
 
836
846
  Three layers, all enforced (`make check` runs the full set; CI runs the same thing):
@@ -40,14 +40,17 @@ boost tap --defaults # pull in the 5 starter registries
40
40
  ```
41
41
 
42
42
  Want the whole ecosystem instead of the starter set? boost ships a **curated
43
- registry catalog** — 90+ classified GitHub registries of skills, Cursor/Windsurf
43
+ registry catalog** — 100+ classified GitHub registries of skills, Cursor/Windsurf
44
44
  **rules**, and Claude Code **workflows** (slash commands & subagents),
45
- collectively indexing thousands of items:
45
+ collectively indexing thousands of items. Categories include a curated
46
+ **`rag`** set — official Weaviate, Pinecone, and DSPy skill libraries plus
47
+ Graph-RAG and agentic-RAG toolkits for building retrieval pipelines:
46
48
 
47
49
  ```bash
48
50
  boost tap --catalog --dry-run # browse the classified catalog
49
51
  boost tap --catalog --type skill --limit 20 # tap the 20 biggest skill packs
50
52
  boost tap --catalog --type rule # every rules registry
53
+ boost tap --catalog --category rag # RAG / vector-search skill packs
51
54
  boost tap --catalog --category security # filter by category
52
55
  ```
53
56
 
@@ -127,6 +130,13 @@ Standard-library Python only — no third-party runtime dependencies. Every
127
130
  path under `~/.boost` and `~/.agents/skills` is resolved from `$HOME` at
128
131
  call time, which is what makes the sandboxing above possible.
129
132
 
133
+ When something misbehaves, boost keeps a rotating diagnostic log at
134
+ `~/.boost/logs/boost.log` and writes a full crash report on any unexpected
135
+ error. Turn up detail with `boost --verbose <cmd>` or `boost --debug <cmd>`,
136
+ read the trail with `boost log --diagnostics`, and see
137
+ [`docs/DEBUGGING.md`](docs/DEBUGGING.md) for log levels, env vars, crash
138
+ reports, and the free services that monitor the project.
139
+
130
140
  ## Test suite
131
141
 
132
142
  Three layers, all enforced (`make check` runs the full set; CI runs the same thing):
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.0.4'
22
- __version_tuple__ = version_tuple = (1, 0, 4)
21
+ __version__ = version = '1.0.6'
22
+ __version_tuple__ = version_tuple = (1, 0, 6)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -9,12 +9,21 @@ from __future__ import annotations
9
9
  import difflib
10
10
  import importlib
11
11
  import sys
12
+ import time
12
13
  from typing import List
13
14
 
14
15
  from . import PRODUCT, TAGLINE, __version__
16
+ from .core import logs
15
17
  from .core import output as out
16
18
  from .errors import BoostError
17
19
 
20
+ # Global flags handled before dispatch, stripped from the command's own argv.
21
+ _GLOBAL_FLAGS = {
22
+ "--verbose": "verbose", "-v": "verbose",
23
+ "--debug": "debug",
24
+ "--quiet": "quiet", "-q": "quiet",
25
+ }
26
+
18
27
  # group key -> (icon token, title, description)
19
28
  GROUPS = {
20
29
  "pkg": ("pkg", "Package Management",
@@ -160,6 +169,13 @@ def print_command_help(name: str) -> int:
160
169
  return _dispatch(name, ["--help"], soft=True)
161
170
 
162
171
 
172
+ def _crash_hint(report) -> str:
173
+ where = ("a crash report was written to %s" % report if report
174
+ else "set BOOST_DEBUG=1 to see the full traceback")
175
+ return "%s — re-run with --debug for the traceback, or file it at %s" % (
176
+ where, "https://github.com/jonnyeclectic/boost/issues")
177
+
178
+
163
179
  def _unknown(name: str) -> int:
164
180
  close = difflib.get_close_matches(name, list(_BY_NAME), n=3)
165
181
  out.err("unknown command: %s" % name,
@@ -181,8 +197,25 @@ def _dispatch(name: str, argv: List[str], soft: bool = False) -> int:
181
197
  return int(rc or 0)
182
198
 
183
199
 
200
+ def _extract_globals(argv: List[str]) -> tuple[dict, List[str]]:
201
+ """Peel leading global flags (`boost --debug install …`) off argv.
202
+
203
+ Only flags *before* the command name are treated as global, so a
204
+ subcommand keeps its own `--verbose`/`-q` (e.g. `boost lint --verbose`).
205
+ """
206
+ opts = {"verbose": False, "debug": False, "quiet": False}
207
+ i = 0
208
+ while i < len(argv) and argv[i] in _GLOBAL_FLAGS:
209
+ opts[_GLOBAL_FLAGS[argv[i]]] = True
210
+ i += 1
211
+ return opts, argv[i:]
212
+
213
+
184
214
  def main(argv: List[str] | None = None) -> int:
185
215
  argv = list(sys.argv[1:] if argv is None else argv)
216
+ opts, argv = _extract_globals(argv)
217
+ logs.configure(verbose=opts["verbose"], debug=opts["debug"],
218
+ quiet=opts["quiet"])
186
219
  if not argv or argv[0] in ("-h", "--help"):
187
220
  print_help()
188
221
  return 0
@@ -197,17 +230,39 @@ def main(argv: List[str] | None = None) -> int:
197
230
  name, rest = argv[0], argv[1:]
198
231
  if name not in _BY_NAME:
199
232
  return _unknown(name)
233
+ logs.log_invocation([name, *rest])
234
+ start = time.perf_counter()
235
+ rc = 70 # assume the worst until a handler proves otherwise
200
236
  try:
201
- return _dispatch(name, rest)
237
+ rc = _dispatch(name, rest)
238
+ return rc
202
239
  except BoostError as e:
240
+ logs.get_logger().info("BoostError: %s", e.message)
241
+ rc = 1
203
242
  out.err(e.message, hint=e.hint)
204
- return 1
243
+ return rc
205
244
  except KeyboardInterrupt:
245
+ logs.get_logger().debug("interrupted by user")
246
+ rc = 130
206
247
  print()
207
- return 130
248
+ return rc
208
249
  except BrokenPipeError:
209
250
  try:
210
251
  sys.stdout.close()
211
252
  except Exception:
212
253
  pass
213
- return 0
254
+ rc = 0
255
+ return rc
256
+ except Exception as e: # noqa: BLE001 — top-level safety net
257
+ report = logs.write_crash_report(e, [name, *rest])
258
+ if logs.is_debug():
259
+ raise
260
+ out.err("boost hit an unexpected error: %s: %s"
261
+ % (type(e).__name__, e),
262
+ hint=_crash_hint(report))
263
+ return 70 # EX_SOFTWARE
264
+ finally:
265
+ # Bookend every invocation with its exit code + duration, even when the
266
+ # --debug path re-raises the traceback above.
267
+ logs.log_completion([name, *rest], rc,
268
+ (time.perf_counter() - start) * 1000)
@@ -16,7 +16,7 @@ import textwrap
16
16
  import webbrowser
17
17
  from pathlib import Path
18
18
 
19
- from ..core import ai, catalog, frontmatter, gitutil, journal, lockfile, paths, registry, store, util
19
+ from ..core import ai, catalog, frontmatter, gitutil, journal, lockfile, logs, paths, registry, store, util
20
20
  from ..core import output as out
21
21
  from ..errors import BoostError
22
22
 
@@ -378,13 +378,52 @@ def cmd_explain(argv):
378
378
  return 0
379
379
 
380
380
 
381
+ def _show_diagnostics(limit):
382
+ lp = logs.log_path()
383
+ if not lp.exists():
384
+ out.info("no diagnostic log yet at %s" % lp)
385
+ return 0
386
+ lines = lp.read_text(encoding="utf-8", errors="replace").splitlines()
387
+ out.heading("diagnostic log — %s" % lp)
388
+ for line in lines[-limit:]:
389
+ out.info(line)
390
+ return 0
391
+
392
+
393
+ def _show_crashes(limit):
394
+ ldir = paths.logs_dir()
395
+ reports = sorted(ldir.glob("crash-*.log"), reverse=True) if ldir.is_dir() else []
396
+ if not reports:
397
+ out.info("no crash reports — nothing has blown up (that boost noticed)")
398
+ return 0
399
+ out.heading("crash reports in %s" % ldir)
400
+ for r in reports[:limit]:
401
+ try:
402
+ first = r.read_text(encoding="utf-8", errors="replace").splitlines()
403
+ summary = next((ln for ln in first if ln.startswith("command:")), "")
404
+ except OSError:
405
+ summary = ""
406
+ out.info("%s %s" % (r.name, summary))
407
+ out.info("")
408
+ out.dim(" view one with: cat %s/<name>" % ldir)
409
+ return 0
410
+
411
+
381
412
  def cmd_log(argv):
382
413
  ap = argparse.ArgumentParser(prog="boost log",
383
414
  description="Git log for a skill, or boost's activity log")
384
415
  ap.add_argument("name", nargs="?", help="skill to show upstream history for")
385
416
  ap.add_argument("-n", "--limit", type=int, default=20, metavar="N",
386
417
  help="max entries (default 20)")
418
+ ap.add_argument("--diagnostics", action="store_true",
419
+ help="show boost's diagnostic log trail (not skill history)")
420
+ ap.add_argument("--crashes", action="store_true",
421
+ help="list recent crash reports")
387
422
  args = ap.parse_args(argv)
423
+ if args.crashes:
424
+ return _show_crashes(args.limit)
425
+ if args.diagnostics:
426
+ return _show_diagnostics(args.limit)
388
427
  if args.name:
389
428
  lock = lockfile.get_skill(args.name)
390
429
  if lock:
@@ -17,8 +17,8 @@ from pathlib import Path
17
17
  from typing import List, Optional, Tuple
18
18
 
19
19
  from ..core import (agents, ai, catalog, frontmatter, gitutil, journal,
20
- lockfile, output as out, paths, policy, registry, store,
21
- util)
20
+ lockfile, logs, output as out, paths, policy, registry,
21
+ store, util)
22
22
  from ..errors import BoostError
23
23
 
24
24
  # --- audit: dangerous-content patterns ------------------------------------
@@ -323,6 +323,16 @@ def cmd_doctor(argv):
323
323
  if not rotation:
324
324
  bad("journal is overdue for rotation — run `boost heal`")
325
325
 
326
+ lp = logs.log_path()
327
+ if lp.exists():
328
+ out.ok("diagnostic log at %s" % _tilde(lp))
329
+ crashes = sorted(paths.logs_dir().glob("crash-*.log")) \
330
+ if paths.logs_dir().is_dir() else []
331
+ if crashes:
332
+ out.warn("%d crash report%s in %s (newest: %s) — see `boost log --crashes`"
333
+ % (len(crashes), _s(len(crashes)), _tilde(paths.logs_dir()),
334
+ crashes[-1].name))
335
+
326
336
  line1 = ("%d skill%s installed · %d tap%s synced · %d broken link%s"
327
337
  % (len(skills), _s(len(skills)), tap_ok, _s(tap_ok),
328
338
  len(broken), _s(len(broken))))
@@ -22,6 +22,15 @@ DEFAULTS = {
22
22
  "serve": {"port": 8787},
23
23
  "policy_enforce": True,
24
24
  "telemetry": False,
25
+ "logging": {
26
+ # Console verbosity for the diagnostic log on stderr. "OFF" keeps
27
+ # stderr clean (the default); set DEBUG/INFO/WARNING/ERROR to always
28
+ # surface the trail. The rotating file always records at DEBUG.
29
+ # Overridden by --verbose/--debug/--quiet and BOOST_LOG_LEVEL.
30
+ # See core/logs.py and docs/DEBUGGING.md.
31
+ "level": "OFF",
32
+ "file": True, # set false (or BOOST_NO_LOG=1) to disable the log file
33
+ },
25
34
  }
26
35
 
27
36
  # Recommended public registries, added via `boost tap --defaults`.
@@ -0,0 +1,250 @@
1
+ """Diagnostic logging & crash reporting — boost's black box recorder.
2
+
3
+ This is separate from two neighbouring concerns:
4
+
5
+ * ``core.output`` is the *human* channel — the pretty stdout a user reads.
6
+ * ``core.journal`` is the *activity* feed — semantic events (`install`,
7
+ `uninstall`) that power `boost pulse`/`trending`/`who`.
8
+
9
+ ``core.logs`` is the *diagnostic* channel: a rotating, machine-greppable trail
10
+ of what boost did and why, written to ``~/.boost/logs/boost.log``, plus full
11
+ crash reports when an unexpected exception escapes. Nothing here is meant for
12
+ normal reading — it exists so that when something breaks, there is a trail.
13
+
14
+ Verbosity is resolved once, in this order (first wins):
15
+
16
+ 1. ``--debug`` flag / ``BOOST_DEBUG=1`` -> console shows DEBUG + tracebacks
17
+ 2. ``--verbose`` / ``-v`` flag -> console shows INFO
18
+ 3. ``--quiet`` / ``-q`` flag -> console stays silent
19
+ 4. ``BOOST_LOG_LEVEL=DEBUG|INFO|WARNING|…`` -> explicit console level
20
+ 5. config ``logging.level`` (default ``OFF`` -> console silent)
21
+
22
+ The console diagnostic channel is *off by default* — user-facing messages
23
+ already go through ``core.output``. The *file* handler always records at DEBUG
24
+ regardless of console verbosity, so a plain run still leaves a complete trail to
25
+ inspect after the fact. Set ``BOOST_NO_LOG=1`` (or config ``logging.file=false``)
26
+ to disable the file.
27
+
28
+ Each invocation bookends the trail with an ``invoke:`` line and a ``done:`` line
29
+ that carries the exit code and wall-clock duration, so the log doubles as a
30
+ lightweight timing record for spotting slow commands after the fact.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import logging
35
+ import logging.handlers
36
+ import os
37
+ import platform
38
+ import sys
39
+ import traceback
40
+ from datetime import datetime, timezone
41
+ from pathlib import Path
42
+ from typing import List, Optional
43
+
44
+ from . import paths
45
+
46
+ LOGGER_NAME = "boost"
47
+ MAX_BYTES = 1_000_000 # ~1 MB per file …
48
+ BACKUP_COUNT = 3 # … times (N+1) files kept = ~4 MB ceiling
49
+ KEEP_CRASH_REPORTS = 20
50
+
51
+ _LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
52
+
53
+ # Set by configure(); read by main()'s exception handler to decide whether to
54
+ # print a full traceback or a friendly one-liner.
55
+ _debug_console = False
56
+ _configured = False
57
+
58
+
59
+ def _stamp() -> str:
60
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
61
+
62
+
63
+ def _file_stamp() -> str:
64
+ return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
65
+
66
+
67
+ def log_path() -> Path:
68
+ return paths.logs_dir() / "boost.log"
69
+
70
+
71
+ def is_debug() -> bool:
72
+ """True when the user asked for debug output (flag or env)."""
73
+ return _debug_console
74
+
75
+
76
+ def _console_level(verbose: bool, debug: bool, quiet: bool) -> Optional[int]:
77
+ """Resolve the stderr diagnostic-handler level, or None to suppress it.
78
+
79
+ The console diagnostic channel is *off by default* — user-facing messages
80
+ already go through ``core.output``. It turns on only when explicitly asked
81
+ for, so a normal run leaves stderr clean while the file keeps the full
82
+ DEBUG trail.
83
+ """
84
+ if debug or os.environ.get("BOOST_DEBUG"):
85
+ return logging.DEBUG
86
+ if verbose:
87
+ return logging.INFO
88
+ if quiet:
89
+ return None
90
+ env = (os.environ.get("BOOST_LOG_LEVEL") or "").strip().upper()
91
+ if env in _LEVELS:
92
+ return getattr(logging, env)
93
+ from . import config
94
+ cfg = str(config.get("logging.level", "OFF") or "OFF").upper()
95
+ return getattr(logging, cfg) if cfg in _LEVELS else None
96
+
97
+
98
+ def _file_enabled() -> bool:
99
+ if os.environ.get("BOOST_NO_LOG"):
100
+ return False
101
+ from . import config
102
+ return bool(config.get("logging.file", True))
103
+
104
+
105
+ def get_logger() -> logging.Logger:
106
+ return logging.getLogger(LOGGER_NAME)
107
+
108
+
109
+ def reset() -> None:
110
+ """Detach all handlers and forget configuration.
111
+
112
+ Real runs configure logging exactly once, but an in-process test suite
113
+ reconfigures against a fresh sandbox $HOME each test; without this the
114
+ first test's file handler would linger and write to a stale path.
115
+ """
116
+ global _configured, _debug_console
117
+ logger = logging.getLogger(LOGGER_NAME)
118
+ for h in list(logger.handlers):
119
+ try:
120
+ h.close()
121
+ except Exception:
122
+ pass
123
+ logger.removeHandler(h)
124
+ _configured = False
125
+ _debug_console = False
126
+
127
+
128
+ def configure(verbose: bool = False, debug: bool = False,
129
+ quiet: bool = False) -> logging.Logger:
130
+ """Install handlers on the ``boost`` logger. Idempotent within a process."""
131
+ global _debug_console, _configured
132
+ logger = logging.getLogger(LOGGER_NAME)
133
+ logger.setLevel(logging.DEBUG) # handlers do the real filtering
134
+ logger.propagate = False
135
+
136
+ _debug_console = bool(debug or os.environ.get("BOOST_DEBUG"))
137
+
138
+ if _configured:
139
+ return logger
140
+ _configured = True
141
+
142
+ fmt = logging.Formatter(
143
+ "%(asctime)s %(levelname)-7s %(name)s: %(message)s",
144
+ datefmt="%Y-%m-%dT%H:%M:%SZ",
145
+ )
146
+
147
+ # File handler — always DEBUG, best-effort (never break the CLI over a log).
148
+ if _file_enabled():
149
+ try:
150
+ paths.logs_dir().mkdir(parents=True, exist_ok=True)
151
+ fh = logging.handlers.RotatingFileHandler(
152
+ log_path(), maxBytes=MAX_BYTES, backupCount=BACKUP_COUNT,
153
+ encoding="utf-8", delay=True,
154
+ )
155
+ fh.setLevel(logging.DEBUG)
156
+ fh.setFormatter(fmt)
157
+ logger.addHandler(fh)
158
+ except OSError:
159
+ pass
160
+
161
+ # Console handler — only attached when something should surface on stderr.
162
+ level = _console_level(verbose, debug, quiet)
163
+ if level is not None:
164
+ ch = logging.StreamHandler(sys.stderr)
165
+ ch.setLevel(level)
166
+ ch.setFormatter(fmt)
167
+ logger.addHandler(ch)
168
+
169
+ return logger
170
+
171
+
172
+ def log_invocation(argv: List[str]) -> None:
173
+ """Record a command invocation at the head of the trail."""
174
+ get_logger().info("invoke: boost %s", " ".join(argv))
175
+
176
+
177
+ def log_completion(argv: List[str], rc: int, elapsed_ms: float) -> None:
178
+ """Close out an invocation with its exit code and wall-clock duration.
179
+
180
+ A clean exit logs at INFO; any non-zero code logs at WARNING so a failing
181
+ run stands out when scanning the trail (and surfaces with ``--verbose``).
182
+ """
183
+ level = logging.INFO if rc == 0 else logging.WARNING
184
+ get_logger().log(level, "done: boost %s -> rc=%d in %dms",
185
+ " ".join(argv), rc, round(elapsed_ms))
186
+
187
+
188
+ def _boost_version() -> str:
189
+ try:
190
+ from .. import __version__
191
+ return str(__version__)
192
+ except Exception:
193
+ return "unknown"
194
+
195
+
196
+ def _env_snapshot() -> List[str]:
197
+ keys = sorted(k for k in os.environ
198
+ if k.startswith("BOOST_") or k in ("NO_COLOR", "CLICOLOR_FORCE"))
199
+ return ["%s=%s" % (k, os.environ[k]) for k in keys]
200
+
201
+
202
+ def write_crash_report(exc: BaseException, argv: List[str]) -> Optional[Path]:
203
+ """Dump a full crash report and return its path (or None if it can't).
204
+
205
+ Captures the traceback, invocation, versions and boost-relevant env so a
206
+ user can attach one file to a bug report instead of reproducing by hand.
207
+ """
208
+ tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
209
+ body = "\n".join([
210
+ "boost crash report",
211
+ "==================",
212
+ "time: %s" % _stamp(),
213
+ "version: %s" % _boost_version(),
214
+ "python: %s" % sys.version.split()[0],
215
+ "platform: %s" % platform.platform(),
216
+ "command: boost %s" % " ".join(argv),
217
+ "",
218
+ "environment:",
219
+ *(" " + line for line in (_env_snapshot() or [" (none)"])),
220
+ "",
221
+ "traceback:",
222
+ tb.rstrip(),
223
+ "",
224
+ ])
225
+ # Always try to get it into the rotating trail, even if the file write fails.
226
+ try:
227
+ get_logger().error("crash: %s: %s", type(exc).__name__, exc)
228
+ except Exception:
229
+ pass
230
+ try:
231
+ paths.logs_dir().mkdir(parents=True, exist_ok=True)
232
+ report = paths.logs_dir() / ("crash-%s.log" % _file_stamp())
233
+ report.write_text(body, encoding="utf-8")
234
+ _prune_crash_reports()
235
+ return report
236
+ except OSError:
237
+ return None
238
+
239
+
240
+ def _prune_crash_reports() -> None:
241
+ """Keep only the most recent KEEP_CRASH_REPORTS crash files."""
242
+ try:
243
+ reports = sorted(paths.logs_dir().glob("crash-*.log"))
244
+ except OSError:
245
+ return
246
+ for stale in reports[:-KEEP_CRASH_REPORTS]:
247
+ try:
248
+ stale.unlink()
249
+ except OSError:
250
+ pass