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.
- boost_skill_cli-1.0.6/CLAUDE.md +58 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/PKG-INFO +13 -3
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/README.md +12 -2
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/_version.py +2 -2
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/cli.py +59 -4
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/info.py +40 -1
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/quality.py +12 -2
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/config.py +9 -0
- boost_skill_cli-1.0.6/boost_cli/core/logs.py +250 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/data/registries.json +80 -3
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_skill_cli.egg-info/PKG-INFO +13 -3
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_skill_cli.egg-info/SOURCES.txt +5 -0
- boost_skill_cli-1.0.6/docs/DEBUGGING.md +183 -0
- boost_skill_cli-1.0.6/docs/rag-architecture.md +250 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/scripts/build_registries.py +8 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/conftest.py +12 -0
- boost_skill_cli-1.0.6/tests/unit/test_gitutil.py +282 -0
- boost_skill_cli-1.0.6/tests/unit/test_logs.py +234 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_store.py +59 -5
- boost_skill_cli-1.0.4/tests/unit/test_gitutil.py +0 -155
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/.gitignore +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/CONTRIBUTING.md +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/LICENSE +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/MANIFEST.in +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/Makefile +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/SECURITY.md +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/__init__.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/__main__.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/__init__.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/configuration.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/discovery.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/intelligence.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/pkg.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/taps.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/commands/team.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/__init__.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/agents.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/ai.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/catalog.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/frontmatter.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/gitutil.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/journal.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/lockfile.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/output.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/paths.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/policy.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/registry.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/store.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/core/util.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_cli/errors.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_skill_cli.egg-info/dependency_links.txt +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_skill_cli.egg-info/entry_points.txt +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/boost_skill_cli.egg-info/top_level.txt +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/docs/.claude/settings.local.json +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/docs/demo.gif +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/docs/demo.tape +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/docs/overview.html +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/pyproject.toml +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/scripts/mutation_gate.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/setup.cfg +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_cli_configuration.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_cli_discovery.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_cli_info.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_cli_intelligence.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_cli_pkg.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_cli_quality.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_cli_taps.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_cli_team.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/functional/test_everyday_loop.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/make_fixture.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/smoke.sh +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_agents.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_ai.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_catalog.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_config.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_errors_and_cli_table.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_frontmatter.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_journal.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_lockfile.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_mutation_hardening.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_output.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_paths.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_policy.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_registry.py +0 -0
- {boost_skill_cli-1.0.4 → boost_skill_cli-1.0.6}/tests/unit/test_util.py +0 -0
- {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.
|
|
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** —
|
|
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** —
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 0,
|
|
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
|
-
|
|
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
|
|
243
|
+
return rc
|
|
205
244
|
except KeyboardInterrupt:
|
|
245
|
+
logs.get_logger().debug("interrupted by user")
|
|
246
|
+
rc = 130
|
|
206
247
|
print()
|
|
207
|
-
return
|
|
248
|
+
return rc
|
|
208
249
|
except BrokenPipeError:
|
|
209
250
|
try:
|
|
210
251
|
sys.stdout.close()
|
|
211
252
|
except Exception:
|
|
212
253
|
pass
|
|
213
|
-
|
|
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,
|
|
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
|