bridle-audit 0.1.0__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.
- bridle_audit-0.1.0/LICENSE +21 -0
- bridle_audit-0.1.0/PKG-INFO +165 -0
- bridle_audit-0.1.0/README.md +134 -0
- bridle_audit-0.1.0/pyproject.toml +51 -0
- bridle_audit-0.1.0/setup.cfg +4 -0
- bridle_audit-0.1.0/src/bridle_audit/__init__.py +3 -0
- bridle_audit-0.1.0/src/bridle_audit/__main__.py +7 -0
- bridle_audit-0.1.0/src/bridle_audit/cli.py +212 -0
- bridle_audit-0.1.0/src/bridle_audit.egg-info/PKG-INFO +165 -0
- bridle_audit-0.1.0/src/bridle_audit.egg-info/SOURCES.txt +13 -0
- bridle_audit-0.1.0/src/bridle_audit.egg-info/dependency_links.txt +1 -0
- bridle_audit-0.1.0/src/bridle_audit.egg-info/entry_points.txt +2 -0
- bridle_audit-0.1.0/src/bridle_audit.egg-info/requires.txt +3 -0
- bridle_audit-0.1.0/src/bridle_audit.egg-info/top_level.txt +1 -0
- bridle_audit-0.1.0/tests/test_cli.py +321 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jeremy Renoult
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bridle-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Audit your Claude Code config: dead hooks, duplicate levers, boot-token cost.
|
|
5
|
+
Author-email: Jeremy Renoult <jeremy.renoult.pro@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/trinity-organism/bridle-audit
|
|
8
|
+
Project-URL: Repository, https://github.com/trinity-organism/bridle-audit
|
|
9
|
+
Project-URL: Changelog, https://github.com/trinity-organism/bridle-audit/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Issues, https://github.com/trinity-organism/bridle-audit/issues
|
|
11
|
+
Keywords: claude-code,claude,config,audit,hooks,cli
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# bridle-audit
|
|
33
|
+
|
|
34
|
+
[](https://github.com/trinity-organism/bridle-audit/actions/workflows/test.yml)
|
|
35
|
+
[](https://pypi.org/project/bridle-audit/)
|
|
36
|
+
[](https://pypi.org/project/bridle-audit/)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
|
|
39
|
+
Your Claude Code config grows in silence. Hooks pile up in `settings.json` —
|
|
40
|
+
some fire on **every single turn** — and keep firing long after the script
|
|
41
|
+
they point at was deleted. The same linter gets wired twice by two different
|
|
42
|
+
sessions. Skill descriptions are loaded into context at **every session
|
|
43
|
+
start**, and nobody counts what that boot costs. Configs only ever grow;
|
|
44
|
+
nothing prunes them.
|
|
45
|
+
|
|
46
|
+
`bridle-audit` is the pruner: **one command, zero dependencies, reads your
|
|
47
|
+
real config and tells you exactly what is dead, doubled, or dormant.**
|
|
48
|
+
|
|
49
|
+
- **Dead hooks** — commands wired to scripts that no longer exist (fired
|
|
50
|
+
every turn, for nothing).
|
|
51
|
+
- **Duplicate levers** — the same script wired several times inside one event.
|
|
52
|
+
- **Dormant cost** — how many characters of skill descriptions you pay at
|
|
53
|
+
every boot.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
pipx install bridle-audit
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
or
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
pip install bridle-audit
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
or straight from source:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
pipx install git+https://github.com/trinity-organism/bridle-audit
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Python >= 3.9. Zero dependencies — standard library only.
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
bridle-audit # audit $CLAUDE_CONFIG_DIR, falling back to ~/.claude
|
|
79
|
+
bridle-audit --config DIR # audit a specific config directory
|
|
80
|
+
bridle-audit --json # machine-readable output
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Exit codes: `0` clean (or no config found), `1` at least one dead hook or
|
|
84
|
+
duplication, `2` config unreadable — so it drops straight into CI or a shell
|
|
85
|
+
prompt.
|
|
86
|
+
|
|
87
|
+
## Before / after
|
|
88
|
+
|
|
89
|
+
Real output (username anonymized). Before — a config that grew for six months:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
$ bridle-audit
|
|
93
|
+
bridle-audit — installed levers vs the ladder (context < skill < tool < hook < daemon)
|
|
94
|
+
hooks source: /Users/you/.claude/settings.json
|
|
95
|
+
|
|
96
|
+
[per session] SessionStart (1 hook)
|
|
97
|
+
ok session-banner.sh — /Users/you/.claude/hooks/session-banner.sh
|
|
98
|
+
|
|
99
|
+
[per turn] PreToolUse (2 hooks)
|
|
100
|
+
ok format-check.sh — /Users/you/.claude/hooks/format-check.sh
|
|
101
|
+
ok format-check.sh — /Users/you/.claude/hooks/format-check.sh
|
|
102
|
+
DUP format-check.sh x2 — same script, 2 voices in one event (one behavior, one lever, one place)
|
|
103
|
+
|
|
104
|
+
[per turn] Stop (1 hook)
|
|
105
|
+
DEAD notify.sh — /Users/you/.claude/hooks/notify.sh
|
|
106
|
+
|
|
107
|
+
dormant cost — 3 skills, ~414 chars of descriptions loaded at every session start
|
|
108
|
+
|
|
109
|
+
verdict — dead hooks: 1 · duplicate levers: 1 · skills: 3 (~414 chars/boot)
|
|
110
|
+
$ echo $?
|
|
111
|
+
1
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
After pruning the dead `Stop` hook and merging the duplicate into one matcher:
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
$ bridle-audit
|
|
118
|
+
bridle-audit — installed levers vs the ladder (context < skill < tool < hook < daemon)
|
|
119
|
+
hooks source: /Users/you/.claude/settings.json
|
|
120
|
+
|
|
121
|
+
[per session] SessionStart (1 hook)
|
|
122
|
+
ok session-banner.sh — /Users/you/.claude/hooks/session-banner.sh
|
|
123
|
+
|
|
124
|
+
[per turn] PreToolUse (1 hook)
|
|
125
|
+
ok format-check.sh — /Users/you/.claude/hooks/format-check.sh
|
|
126
|
+
|
|
127
|
+
dormant cost — 3 skills, ~414 chars of descriptions loaded at every session start
|
|
128
|
+
|
|
129
|
+
verdict — dead hooks: 0 · duplicate levers: 0 · skills: 3 (~414 chars/boot)
|
|
130
|
+
$ echo $?
|
|
131
|
+
0
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
And when there is nothing to audit:
|
|
135
|
+
|
|
136
|
+
```text
|
|
137
|
+
$ bridle-audit --config /some/empty/dir
|
|
138
|
+
bridle-audit: no settings.json found in /some/empty/dir
|
|
139
|
+
Nothing to audit. If your Claude Code config lives elsewhere, point at it with --config DIR or $CLAUDE_CONFIG_DIR.
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Hook commands are resolved the way a shell would: quoting (paths with
|
|
143
|
+
spaces), wrappers (`nohup`, `bash -c`, `python3`, ...), `VAR=val` prefixes
|
|
144
|
+
and `$HOME`/`~` are all handled before the target is checked.
|
|
145
|
+
|
|
146
|
+
## Philosophy
|
|
147
|
+
|
|
148
|
+
A bridle, not a harness rack: one behavior, one lever, one place — and always
|
|
149
|
+
the **cheapest lever that actually steers**. A line of context beats a skill,
|
|
150
|
+
a skill beats a tool, a tool beats a hook, a hook beats a daemon. This tool
|
|
151
|
+
shows you where your config climbed that ladder without need.
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
pip install -e ".[dev]"
|
|
157
|
+
pytest
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The test suite fabricates synthetic configs in temp directories — it never
|
|
161
|
+
reads your real `~/.claude`.
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# bridle-audit
|
|
2
|
+
|
|
3
|
+
[](https://github.com/trinity-organism/bridle-audit/actions/workflows/test.yml)
|
|
4
|
+
[](https://pypi.org/project/bridle-audit/)
|
|
5
|
+
[](https://pypi.org/project/bridle-audit/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
Your Claude Code config grows in silence. Hooks pile up in `settings.json` —
|
|
9
|
+
some fire on **every single turn** — and keep firing long after the script
|
|
10
|
+
they point at was deleted. The same linter gets wired twice by two different
|
|
11
|
+
sessions. Skill descriptions are loaded into context at **every session
|
|
12
|
+
start**, and nobody counts what that boot costs. Configs only ever grow;
|
|
13
|
+
nothing prunes them.
|
|
14
|
+
|
|
15
|
+
`bridle-audit` is the pruner: **one command, zero dependencies, reads your
|
|
16
|
+
real config and tells you exactly what is dead, doubled, or dormant.**
|
|
17
|
+
|
|
18
|
+
- **Dead hooks** — commands wired to scripts that no longer exist (fired
|
|
19
|
+
every turn, for nothing).
|
|
20
|
+
- **Duplicate levers** — the same script wired several times inside one event.
|
|
21
|
+
- **Dormant cost** — how many characters of skill descriptions you pay at
|
|
22
|
+
every boot.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
pipx install bridle-audit
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
or
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pip install bridle-audit
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
or straight from source:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
pipx install git+https://github.com/trinity-organism/bridle-audit
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Python >= 3.9. Zero dependencies — standard library only.
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
bridle-audit # audit $CLAUDE_CONFIG_DIR, falling back to ~/.claude
|
|
48
|
+
bridle-audit --config DIR # audit a specific config directory
|
|
49
|
+
bridle-audit --json # machine-readable output
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Exit codes: `0` clean (or no config found), `1` at least one dead hook or
|
|
53
|
+
duplication, `2` config unreadable — so it drops straight into CI or a shell
|
|
54
|
+
prompt.
|
|
55
|
+
|
|
56
|
+
## Before / after
|
|
57
|
+
|
|
58
|
+
Real output (username anonymized). Before — a config that grew for six months:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
$ bridle-audit
|
|
62
|
+
bridle-audit — installed levers vs the ladder (context < skill < tool < hook < daemon)
|
|
63
|
+
hooks source: /Users/you/.claude/settings.json
|
|
64
|
+
|
|
65
|
+
[per session] SessionStart (1 hook)
|
|
66
|
+
ok session-banner.sh — /Users/you/.claude/hooks/session-banner.sh
|
|
67
|
+
|
|
68
|
+
[per turn] PreToolUse (2 hooks)
|
|
69
|
+
ok format-check.sh — /Users/you/.claude/hooks/format-check.sh
|
|
70
|
+
ok format-check.sh — /Users/you/.claude/hooks/format-check.sh
|
|
71
|
+
DUP format-check.sh x2 — same script, 2 voices in one event (one behavior, one lever, one place)
|
|
72
|
+
|
|
73
|
+
[per turn] Stop (1 hook)
|
|
74
|
+
DEAD notify.sh — /Users/you/.claude/hooks/notify.sh
|
|
75
|
+
|
|
76
|
+
dormant cost — 3 skills, ~414 chars of descriptions loaded at every session start
|
|
77
|
+
|
|
78
|
+
verdict — dead hooks: 1 · duplicate levers: 1 · skills: 3 (~414 chars/boot)
|
|
79
|
+
$ echo $?
|
|
80
|
+
1
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
After pruning the dead `Stop` hook and merging the duplicate into one matcher:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
$ bridle-audit
|
|
87
|
+
bridle-audit — installed levers vs the ladder (context < skill < tool < hook < daemon)
|
|
88
|
+
hooks source: /Users/you/.claude/settings.json
|
|
89
|
+
|
|
90
|
+
[per session] SessionStart (1 hook)
|
|
91
|
+
ok session-banner.sh — /Users/you/.claude/hooks/session-banner.sh
|
|
92
|
+
|
|
93
|
+
[per turn] PreToolUse (1 hook)
|
|
94
|
+
ok format-check.sh — /Users/you/.claude/hooks/format-check.sh
|
|
95
|
+
|
|
96
|
+
dormant cost — 3 skills, ~414 chars of descriptions loaded at every session start
|
|
97
|
+
|
|
98
|
+
verdict — dead hooks: 0 · duplicate levers: 0 · skills: 3 (~414 chars/boot)
|
|
99
|
+
$ echo $?
|
|
100
|
+
0
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
And when there is nothing to audit:
|
|
104
|
+
|
|
105
|
+
```text
|
|
106
|
+
$ bridle-audit --config /some/empty/dir
|
|
107
|
+
bridle-audit: no settings.json found in /some/empty/dir
|
|
108
|
+
Nothing to audit. If your Claude Code config lives elsewhere, point at it with --config DIR or $CLAUDE_CONFIG_DIR.
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Hook commands are resolved the way a shell would: quoting (paths with
|
|
112
|
+
spaces), wrappers (`nohup`, `bash -c`, `python3`, ...), `VAR=val` prefixes
|
|
113
|
+
and `$HOME`/`~` are all handled before the target is checked.
|
|
114
|
+
|
|
115
|
+
## Philosophy
|
|
116
|
+
|
|
117
|
+
A bridle, not a harness rack: one behavior, one lever, one place — and always
|
|
118
|
+
the **cheapest lever that actually steers**. A line of context beats a skill,
|
|
119
|
+
a skill beats a tool, a tool beats a hook, a hook beats a daemon. This tool
|
|
120
|
+
shows you where your config climbed that ladder without need.
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
pip install -e ".[dev]"
|
|
126
|
+
pytest
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The test suite fabricates synthetic configs in temp directories — it never
|
|
130
|
+
reads your real `~/.claude`.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bridle-audit"
|
|
7
|
+
description = "Audit your Claude Code config: dead hooks, duplicate levers, boot-token cost."
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Jeremy Renoult", email = "jeremy.renoult.pro@gmail.com" }]
|
|
13
|
+
keywords = ["claude-code", "claude", "config", "audit", "hooks", "cli"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
27
|
+
"Topic :: Utilities",
|
|
28
|
+
]
|
|
29
|
+
dependencies = []
|
|
30
|
+
dynamic = ["version"]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/trinity-organism/bridle-audit"
|
|
37
|
+
Repository = "https://github.com/trinity-organism/bridle-audit"
|
|
38
|
+
Changelog = "https://github.com/trinity-organism/bridle-audit/blob/main/CHANGELOG.md"
|
|
39
|
+
Issues = "https://github.com/trinity-organism/bridle-audit/issues"
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
bridle-audit = "bridle_audit.cli:main"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.dynamic]
|
|
45
|
+
version = { attr = "bridle_audit.__version__" }
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.packages.find]
|
|
48
|
+
where = ["src"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""bridle-audit — audit a Claude Code config for dead hooks, duplicate levers, boot-token cost.
|
|
2
|
+
|
|
3
|
+
The bridle ladder: no lever (0) < context line (1) < skill (2) < tool (3) < hook (4) < daemon (5).
|
|
4
|
+
This tool measures the heavy levers actually installed in a Claude Code config:
|
|
5
|
+
|
|
6
|
+
- DEAD hooks: commands wired in settings.json whose target script no longer exists.
|
|
7
|
+
- DUPLICATE levers: the same script wired more than once inside the same event
|
|
8
|
+
("one behavior, one lever, one place").
|
|
9
|
+
- Dormant cost: skill descriptions (SKILL.md frontmatter) loaded into context at
|
|
10
|
+
every session start.
|
|
11
|
+
|
|
12
|
+
Exit codes: 0 clean (or no config found), 1 at least one finding, 2 unreadable config.
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shlex
|
|
19
|
+
import shutil
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
|
|
25
|
+
HOME = str(Path.home())
|
|
26
|
+
PER_TURN = {"UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"}
|
|
27
|
+
PER_SESSION = {"SessionStart"}
|
|
28
|
+
WRAPPERS = {"nohup", "bash", "sh", "zsh", "env", "exec", "command",
|
|
29
|
+
"python", "python3", "caffeinate", "timeout"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_config_dir(cli_value=None):
|
|
33
|
+
"""Precedence: --config flag > $CLAUDE_CONFIG_DIR > ~/.claude."""
|
|
34
|
+
if cli_value:
|
|
35
|
+
return Path(cli_value).expanduser()
|
|
36
|
+
env = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
37
|
+
if env:
|
|
38
|
+
return Path(env).expanduser()
|
|
39
|
+
return Path.home() / ".claude"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def extract_target(command):
|
|
43
|
+
"""Best-effort path of the script a hook command actually runs.
|
|
44
|
+
|
|
45
|
+
Handles quoting (spaces in paths), wrapper commands (nohup/bash/python3...),
|
|
46
|
+
their flags, VAR=val environment prefixes, `bash -c '...'`, ~ and $HOME.
|
|
47
|
+
Never splits blindly on whitespace.
|
|
48
|
+
"""
|
|
49
|
+
txt = command.replace("${HOME}", HOME).replace("$HOME", HOME)
|
|
50
|
+
try:
|
|
51
|
+
toks = shlex.split(txt)
|
|
52
|
+
except ValueError:
|
|
53
|
+
toks = txt.split()
|
|
54
|
+
prev = None
|
|
55
|
+
for t in toks:
|
|
56
|
+
if prev == "-c": # bash -c '...': recurse into the string
|
|
57
|
+
return extract_target(t)
|
|
58
|
+
prev = t
|
|
59
|
+
if t in WRAPPERS:
|
|
60
|
+
continue
|
|
61
|
+
if t.startswith("-"): # flag of a wrapper (python3 -u)
|
|
62
|
+
continue
|
|
63
|
+
if "=" in t.split("/", 1)[0]: # VAR=val environment prefix
|
|
64
|
+
continue
|
|
65
|
+
return os.path.expanduser(t)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def alive(target):
|
|
70
|
+
if not target:
|
|
71
|
+
return False
|
|
72
|
+
if "/" in target:
|
|
73
|
+
return os.path.exists(target)
|
|
74
|
+
return shutil.which(target) is not None # bare command: resolve on PATH
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cadence(event):
|
|
78
|
+
if event in PER_TURN:
|
|
79
|
+
return "per turn"
|
|
80
|
+
if event in PER_SESSION:
|
|
81
|
+
return "per session"
|
|
82
|
+
return "on event"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def audit_hooks(data):
|
|
86
|
+
"""Walk settings.json hooks; flag dead targets and per-event duplications."""
|
|
87
|
+
events = {}
|
|
88
|
+
for event, groups in (data.get("hooks") or {}).items():
|
|
89
|
+
entries, counts = [], {}
|
|
90
|
+
for group in groups:
|
|
91
|
+
for h in group.get("hooks", []):
|
|
92
|
+
if h.get("type") != "command":
|
|
93
|
+
continue
|
|
94
|
+
cmd = h.get("command", "")
|
|
95
|
+
target = extract_target(cmd)
|
|
96
|
+
entries.append({"command": cmd, "target": target,
|
|
97
|
+
"name": os.path.basename(target) if target else "?",
|
|
98
|
+
"alive": alive(target),
|
|
99
|
+
"matcher": group.get("matcher", "")})
|
|
100
|
+
if target:
|
|
101
|
+
counts[target] = counts.get(target, 0) + 1
|
|
102
|
+
dups = [{"target": t, "count": n} for t, n in counts.items() if n > 1]
|
|
103
|
+
events[event] = {"cadence": cadence(event), "hooks": entries,
|
|
104
|
+
"duplications": dups}
|
|
105
|
+
return events
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def desc_len(md):
|
|
109
|
+
"""Character count of the frontmatter `description:` of one SKILL.md."""
|
|
110
|
+
try:
|
|
111
|
+
lines = md.read_text(errors="replace").splitlines()
|
|
112
|
+
except OSError:
|
|
113
|
+
return 0
|
|
114
|
+
if not lines or lines[0].strip() != "---":
|
|
115
|
+
return 0
|
|
116
|
+
desc, active = [], False
|
|
117
|
+
for ln in lines[1:]:
|
|
118
|
+
if ln.strip() == "---":
|
|
119
|
+
break
|
|
120
|
+
if re.match(r"^description\s*:", ln):
|
|
121
|
+
active = True
|
|
122
|
+
val = ln.split(":", 1)[1].strip()
|
|
123
|
+
if val not in (">", "|", ">-", "|-", ""):
|
|
124
|
+
desc.append(val)
|
|
125
|
+
elif active:
|
|
126
|
+
if re.match(r"^\S", ln): # next top-level key
|
|
127
|
+
active = False
|
|
128
|
+
else:
|
|
129
|
+
desc.append(ln.strip())
|
|
130
|
+
return len(" ".join(desc))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def audit_skills(skills_dir):
|
|
134
|
+
mds = sorted(skills_dir.glob("*/SKILL.md"))
|
|
135
|
+
return {"count": len(mds), "desc_chars": sum(desc_len(m) for m in mds)}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def main(argv=None):
|
|
139
|
+
parser = argparse.ArgumentParser(
|
|
140
|
+
prog="bridle-audit",
|
|
141
|
+
description="Audit a Claude Code config: dead hooks, duplicate levers, "
|
|
142
|
+
"boot-token cost.")
|
|
143
|
+
parser.add_argument("--config", metavar="DIR",
|
|
144
|
+
help="Claude Code config directory "
|
|
145
|
+
"(default: $CLAUDE_CONFIG_DIR, then ~/.claude)")
|
|
146
|
+
parser.add_argument("--json", action="store_true",
|
|
147
|
+
help="machine-readable output")
|
|
148
|
+
parser.add_argument("--version", action="version",
|
|
149
|
+
version="%(prog)s " + __version__)
|
|
150
|
+
args = parser.parse_args(argv)
|
|
151
|
+
|
|
152
|
+
config_dir = resolve_config_dir(args.config)
|
|
153
|
+
settings = config_dir / "settings.json"
|
|
154
|
+
|
|
155
|
+
if not settings.is_file():
|
|
156
|
+
if args.json:
|
|
157
|
+
print(json.dumps({"config_dir": str(config_dir),
|
|
158
|
+
"settings_found": False,
|
|
159
|
+
"verdict": {"dead_hooks": 0, "duplications": 0,
|
|
160
|
+
"skills": 0, "desc_chars": 0,
|
|
161
|
+
"exit": 0}}, indent=2))
|
|
162
|
+
else:
|
|
163
|
+
print(f"bridle-audit: no settings.json found in {config_dir}")
|
|
164
|
+
print("Nothing to audit. If your Claude Code config lives elsewhere, "
|
|
165
|
+
"point at it with --config DIR or $CLAUDE_CONFIG_DIR.")
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
data = json.loads(settings.read_text())
|
|
170
|
+
events = audit_hooks(data)
|
|
171
|
+
except (OSError, ValueError, AttributeError, TypeError) as exc:
|
|
172
|
+
print(f"bridle-audit: cannot audit {settings}: {exc}", file=sys.stderr)
|
|
173
|
+
return 2
|
|
174
|
+
skills = audit_skills(config_dir / "skills")
|
|
175
|
+
|
|
176
|
+
dead = sum(1 for e in events.values() for h in e["hooks"] if not h["alive"])
|
|
177
|
+
duplications = sum(len(e["duplications"]) for e in events.values())
|
|
178
|
+
code = 1 if (dead or duplications) else 0
|
|
179
|
+
|
|
180
|
+
if args.json:
|
|
181
|
+
print(json.dumps({"config_dir": str(config_dir), "settings_found": True,
|
|
182
|
+
"hooks": events, "skills": skills,
|
|
183
|
+
"verdict": {"dead_hooks": dead,
|
|
184
|
+
"duplications": duplications,
|
|
185
|
+
"skills": skills["count"],
|
|
186
|
+
"desc_chars": skills["desc_chars"],
|
|
187
|
+
"exit": code}},
|
|
188
|
+
ensure_ascii=False, indent=2))
|
|
189
|
+
return code
|
|
190
|
+
|
|
191
|
+
print("bridle-audit — installed levers vs the ladder "
|
|
192
|
+
"(context < skill < tool < hook < daemon)")
|
|
193
|
+
print(f"hooks source: {settings}")
|
|
194
|
+
for event, e in events.items():
|
|
195
|
+
n = len(e["hooks"])
|
|
196
|
+
print(f"\n[{e['cadence']}] {event} ({n} hook{'s' if n > 1 else ''})")
|
|
197
|
+
for h in e["hooks"]:
|
|
198
|
+
mark = "ok " if h["alive"] else "DEAD"
|
|
199
|
+
print(f" {mark} {h['name']} — {h['target']}")
|
|
200
|
+
for d in e["duplications"]:
|
|
201
|
+
print(f" DUP {os.path.basename(d['target'])} x{d['count']} — "
|
|
202
|
+
f"same script, {d['count']} voices in one event "
|
|
203
|
+
"(one behavior, one lever, one place)")
|
|
204
|
+
print(f"\ndormant cost — {skills['count']} skills, ~{skills['desc_chars']} "
|
|
205
|
+
"chars of descriptions loaded at every session start")
|
|
206
|
+
print(f"\nverdict — dead hooks: {dead} · duplicate levers: {duplications}"
|
|
207
|
+
f" · skills: {skills['count']} (~{skills['desc_chars']} chars/boot)")
|
|
208
|
+
return code
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
sys.exit(main())
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bridle-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Audit your Claude Code config: dead hooks, duplicate levers, boot-token cost.
|
|
5
|
+
Author-email: Jeremy Renoult <jeremy.renoult.pro@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/trinity-organism/bridle-audit
|
|
8
|
+
Project-URL: Repository, https://github.com/trinity-organism/bridle-audit
|
|
9
|
+
Project-URL: Changelog, https://github.com/trinity-organism/bridle-audit/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Issues, https://github.com/trinity-organism/bridle-audit/issues
|
|
11
|
+
Keywords: claude-code,claude,config,audit,hooks,cli
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# bridle-audit
|
|
33
|
+
|
|
34
|
+
[](https://github.com/trinity-organism/bridle-audit/actions/workflows/test.yml)
|
|
35
|
+
[](https://pypi.org/project/bridle-audit/)
|
|
36
|
+
[](https://pypi.org/project/bridle-audit/)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
|
|
39
|
+
Your Claude Code config grows in silence. Hooks pile up in `settings.json` —
|
|
40
|
+
some fire on **every single turn** — and keep firing long after the script
|
|
41
|
+
they point at was deleted. The same linter gets wired twice by two different
|
|
42
|
+
sessions. Skill descriptions are loaded into context at **every session
|
|
43
|
+
start**, and nobody counts what that boot costs. Configs only ever grow;
|
|
44
|
+
nothing prunes them.
|
|
45
|
+
|
|
46
|
+
`bridle-audit` is the pruner: **one command, zero dependencies, reads your
|
|
47
|
+
real config and tells you exactly what is dead, doubled, or dormant.**
|
|
48
|
+
|
|
49
|
+
- **Dead hooks** — commands wired to scripts that no longer exist (fired
|
|
50
|
+
every turn, for nothing).
|
|
51
|
+
- **Duplicate levers** — the same script wired several times inside one event.
|
|
52
|
+
- **Dormant cost** — how many characters of skill descriptions you pay at
|
|
53
|
+
every boot.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
pipx install bridle-audit
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
or
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
pip install bridle-audit
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
or straight from source:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
pipx install git+https://github.com/trinity-organism/bridle-audit
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Python >= 3.9. Zero dependencies — standard library only.
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
bridle-audit # audit $CLAUDE_CONFIG_DIR, falling back to ~/.claude
|
|
79
|
+
bridle-audit --config DIR # audit a specific config directory
|
|
80
|
+
bridle-audit --json # machine-readable output
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Exit codes: `0` clean (or no config found), `1` at least one dead hook or
|
|
84
|
+
duplication, `2` config unreadable — so it drops straight into CI or a shell
|
|
85
|
+
prompt.
|
|
86
|
+
|
|
87
|
+
## Before / after
|
|
88
|
+
|
|
89
|
+
Real output (username anonymized). Before — a config that grew for six months:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
$ bridle-audit
|
|
93
|
+
bridle-audit — installed levers vs the ladder (context < skill < tool < hook < daemon)
|
|
94
|
+
hooks source: /Users/you/.claude/settings.json
|
|
95
|
+
|
|
96
|
+
[per session] SessionStart (1 hook)
|
|
97
|
+
ok session-banner.sh — /Users/you/.claude/hooks/session-banner.sh
|
|
98
|
+
|
|
99
|
+
[per turn] PreToolUse (2 hooks)
|
|
100
|
+
ok format-check.sh — /Users/you/.claude/hooks/format-check.sh
|
|
101
|
+
ok format-check.sh — /Users/you/.claude/hooks/format-check.sh
|
|
102
|
+
DUP format-check.sh x2 — same script, 2 voices in one event (one behavior, one lever, one place)
|
|
103
|
+
|
|
104
|
+
[per turn] Stop (1 hook)
|
|
105
|
+
DEAD notify.sh — /Users/you/.claude/hooks/notify.sh
|
|
106
|
+
|
|
107
|
+
dormant cost — 3 skills, ~414 chars of descriptions loaded at every session start
|
|
108
|
+
|
|
109
|
+
verdict — dead hooks: 1 · duplicate levers: 1 · skills: 3 (~414 chars/boot)
|
|
110
|
+
$ echo $?
|
|
111
|
+
1
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
After pruning the dead `Stop` hook and merging the duplicate into one matcher:
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
$ bridle-audit
|
|
118
|
+
bridle-audit — installed levers vs the ladder (context < skill < tool < hook < daemon)
|
|
119
|
+
hooks source: /Users/you/.claude/settings.json
|
|
120
|
+
|
|
121
|
+
[per session] SessionStart (1 hook)
|
|
122
|
+
ok session-banner.sh — /Users/you/.claude/hooks/session-banner.sh
|
|
123
|
+
|
|
124
|
+
[per turn] PreToolUse (1 hook)
|
|
125
|
+
ok format-check.sh — /Users/you/.claude/hooks/format-check.sh
|
|
126
|
+
|
|
127
|
+
dormant cost — 3 skills, ~414 chars of descriptions loaded at every session start
|
|
128
|
+
|
|
129
|
+
verdict — dead hooks: 0 · duplicate levers: 0 · skills: 3 (~414 chars/boot)
|
|
130
|
+
$ echo $?
|
|
131
|
+
0
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
And when there is nothing to audit:
|
|
135
|
+
|
|
136
|
+
```text
|
|
137
|
+
$ bridle-audit --config /some/empty/dir
|
|
138
|
+
bridle-audit: no settings.json found in /some/empty/dir
|
|
139
|
+
Nothing to audit. If your Claude Code config lives elsewhere, point at it with --config DIR or $CLAUDE_CONFIG_DIR.
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Hook commands are resolved the way a shell would: quoting (paths with
|
|
143
|
+
spaces), wrappers (`nohup`, `bash -c`, `python3`, ...), `VAR=val` prefixes
|
|
144
|
+
and `$HOME`/`~` are all handled before the target is checked.
|
|
145
|
+
|
|
146
|
+
## Philosophy
|
|
147
|
+
|
|
148
|
+
A bridle, not a harness rack: one behavior, one lever, one place — and always
|
|
149
|
+
the **cheapest lever that actually steers**. A line of context beats a skill,
|
|
150
|
+
a skill beats a tool, a tool beats a hook, a hook beats a daemon. This tool
|
|
151
|
+
shows you where your config climbed that ladder without need.
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
pip install -e ".[dev]"
|
|
157
|
+
pytest
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The test suite fabricates synthetic configs in temp directories — it never
|
|
161
|
+
reads your real `~/.claude`.
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/bridle_audit/__init__.py
|
|
5
|
+
src/bridle_audit/__main__.py
|
|
6
|
+
src/bridle_audit/cli.py
|
|
7
|
+
src/bridle_audit.egg-info/PKG-INFO
|
|
8
|
+
src/bridle_audit.egg-info/SOURCES.txt
|
|
9
|
+
src/bridle_audit.egg-info/dependency_links.txt
|
|
10
|
+
src/bridle_audit.egg-info/entry_points.txt
|
|
11
|
+
src/bridle_audit.egg-info/requires.txt
|
|
12
|
+
src/bridle_audit.egg-info/top_level.txt
|
|
13
|
+
tests/test_cli.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bridle_audit
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Tests for bridle-audit.
|
|
2
|
+
|
|
3
|
+
Every test runs against a synthetic config fabricated in a pytest tmp_path.
|
|
4
|
+
Nothing reads the host machine's real ~/.claude: an autouse fixture strips
|
|
5
|
+
$CLAUDE_CONFIG_DIR and every invocation points at a fixture directory.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from bridle_audit import __version__
|
|
17
|
+
from bridle_audit.cli import extract_target, main, resolve_config_dir
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture(autouse=True)
|
|
21
|
+
def isolate_env(monkeypatch):
|
|
22
|
+
"""Never let the host's real config leak into a test."""
|
|
23
|
+
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# --- helpers -----------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def command_hook(command, matcher=""):
|
|
29
|
+
return {"matcher": matcher, "hooks": [{"type": "command", "command": command}]}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def write_settings(config_dir, hooks):
|
|
33
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
(config_dir / "settings.json").write_text(json.dumps({"hooks": hooks}))
|
|
35
|
+
return config_dir
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def write_script(path):
|
|
39
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
path.write_text("#!/bin/sh\nexit 0\n")
|
|
41
|
+
path.chmod(path.stat().st_mode | stat.S_IEXEC)
|
|
42
|
+
return path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write_skill(config_dir, name, content):
|
|
46
|
+
skill = config_dir / "skills" / name / "SKILL.md"
|
|
47
|
+
skill.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
skill.write_text(content)
|
|
49
|
+
return skill
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_json(capsys, argv):
|
|
53
|
+
code = main(argv)
|
|
54
|
+
return code, json.loads(capsys.readouterr().out)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- no config ---------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def test_no_config_is_clean(tmp_path, capsys):
|
|
60
|
+
code = main(["--config", str(tmp_path / "nowhere")])
|
|
61
|
+
out = capsys.readouterr().out
|
|
62
|
+
assert code == 0
|
|
63
|
+
assert "no settings.json found" in out
|
|
64
|
+
assert "--config DIR or $CLAUDE_CONFIG_DIR" in out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_no_config_json(tmp_path, capsys):
|
|
68
|
+
code, data = run_json(capsys, ["--config", str(tmp_path / "nowhere"), "--json"])
|
|
69
|
+
assert code == 0
|
|
70
|
+
assert data["settings_found"] is False
|
|
71
|
+
assert data["verdict"] == {"dead_hooks": 0, "duplications": 0,
|
|
72
|
+
"skills": 0, "desc_chars": 0, "exit": 0}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --- dead / alive hooks ------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def test_alive_hook_is_ok(tmp_path, capsys):
|
|
78
|
+
script = write_script(tmp_path / "hooks" / "guard.sh")
|
|
79
|
+
cfg = write_settings(tmp_path / "cfg",
|
|
80
|
+
{"SessionStart": [command_hook(str(script))]})
|
|
81
|
+
code = main(["--config", str(cfg)])
|
|
82
|
+
out = capsys.readouterr().out
|
|
83
|
+
assert code == 0
|
|
84
|
+
assert "ok" in out and "guard.sh" in out
|
|
85
|
+
assert "DEAD" not in out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_revenant_hook_detected(tmp_path, capsys):
|
|
89
|
+
"""A hook whose script existed, then was deleted — the revenant."""
|
|
90
|
+
script = write_script(tmp_path / "hooks" / "gone.sh")
|
|
91
|
+
cfg = write_settings(tmp_path / "cfg",
|
|
92
|
+
{"Stop": [command_hook(str(script))]})
|
|
93
|
+
script.unlink()
|
|
94
|
+
code = main(["--config", str(cfg)])
|
|
95
|
+
out = capsys.readouterr().out
|
|
96
|
+
assert code == 1
|
|
97
|
+
assert "DEAD" in out and "gone.sh" in out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_dead_hook_json_verdict(tmp_path, capsys):
|
|
101
|
+
cfg = write_settings(tmp_path / "cfg",
|
|
102
|
+
{"Stop": [command_hook(str(tmp_path / "never-existed.sh"))]})
|
|
103
|
+
code, data = run_json(capsys, ["--config", str(cfg), "--json"])
|
|
104
|
+
assert code == 1
|
|
105
|
+
assert data["verdict"]["dead_hooks"] == 1
|
|
106
|
+
assert data["verdict"]["exit"] == 1
|
|
107
|
+
(hook,) = data["hooks"]["Stop"]["hooks"]
|
|
108
|
+
assert hook["alive"] is False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_bare_command_resolved_on_path(tmp_path, monkeypatch, capsys):
|
|
112
|
+
bindir = tmp_path / "bin"
|
|
113
|
+
write_script(bindir / "mycheck")
|
|
114
|
+
monkeypatch.setenv("PATH", str(bindir) + os.pathsep + os.environ.get("PATH", ""))
|
|
115
|
+
cfg = write_settings(tmp_path / "cfg",
|
|
116
|
+
{"SessionStart": [command_hook("mycheck --fast")]})
|
|
117
|
+
assert main(["--config", str(cfg)]) == 0
|
|
118
|
+
assert "DEAD" not in capsys.readouterr().out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_bare_command_missing_is_dead(tmp_path, capsys):
|
|
122
|
+
cfg = write_settings(tmp_path / "cfg",
|
|
123
|
+
{"SessionStart": [command_hook("no-such-cmd-bridle-xyz")]})
|
|
124
|
+
assert main(["--config", str(cfg)]) == 1
|
|
125
|
+
assert "DEAD" in capsys.readouterr().out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_non_command_hooks_ignored(tmp_path, capsys):
|
|
129
|
+
hooks = {"SessionStart": [{"matcher": "",
|
|
130
|
+
"hooks": [{"type": "prompt", "prompt": "hi"}]}]}
|
|
131
|
+
cfg = write_settings(tmp_path / "cfg", hooks)
|
|
132
|
+
code, data = run_json(capsys, ["--config", str(cfg), "--json"])
|
|
133
|
+
assert code == 0
|
|
134
|
+
assert data["hooks"]["SessionStart"]["hooks"] == []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --- duplications ------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def test_duplicate_lever_counted(tmp_path, capsys):
|
|
140
|
+
script = write_script(tmp_path / "hooks" / "lint.sh")
|
|
141
|
+
hooks = {"PreToolUse": [command_hook(str(script), matcher="Bash"),
|
|
142
|
+
command_hook(str(script), matcher="Edit")]}
|
|
143
|
+
cfg = write_settings(tmp_path / "cfg", hooks)
|
|
144
|
+
code = main(["--config", str(cfg)])
|
|
145
|
+
out = capsys.readouterr().out
|
|
146
|
+
assert code == 1
|
|
147
|
+
assert "DUP" in out and "x2" in out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_duplicate_lever_json(tmp_path, capsys):
|
|
151
|
+
script = write_script(tmp_path / "hooks" / "lint.sh")
|
|
152
|
+
hooks = {"PreToolUse": [command_hook(str(script)), command_hook(str(script))]}
|
|
153
|
+
cfg = write_settings(tmp_path / "cfg", hooks)
|
|
154
|
+
code, data = run_json(capsys, ["--config", str(cfg), "--json"])
|
|
155
|
+
assert code == 1
|
|
156
|
+
(dup,) = data["hooks"]["PreToolUse"]["duplications"]
|
|
157
|
+
assert dup == {"target": str(script), "count": 2}
|
|
158
|
+
assert data["verdict"]["duplications"] == 1
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_same_script_across_events_is_not_duplicate(tmp_path, capsys):
|
|
162
|
+
script = write_script(tmp_path / "hooks" / "shared.sh")
|
|
163
|
+
hooks = {"SessionStart": [command_hook(str(script))],
|
|
164
|
+
"Stop": [command_hook(str(script))]}
|
|
165
|
+
cfg = write_settings(tmp_path / "cfg", hooks)
|
|
166
|
+
code, data = run_json(capsys, ["--config", str(cfg), "--json"])
|
|
167
|
+
assert code == 0
|
|
168
|
+
assert data["verdict"]["duplications"] == 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# --- dormant cost (skills) ---------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def test_dormant_cost_summed(tmp_path, capsys):
|
|
174
|
+
cfg = write_settings(tmp_path / "cfg", {})
|
|
175
|
+
write_skill(cfg, "alpha", "---\nname: alpha\ndescription: hello world\n---\n")
|
|
176
|
+
write_skill(cfg, "beta", "---\nname: beta\ndescription: >\n"
|
|
177
|
+
" line one\n line two\nlicense: MIT\n---\n")
|
|
178
|
+
write_skill(cfg, "gamma", "no frontmatter at all\n")
|
|
179
|
+
code, data = run_json(capsys, ["--config", str(cfg), "--json"])
|
|
180
|
+
assert code == 0
|
|
181
|
+
assert data["skills"]["count"] == 3
|
|
182
|
+
# "hello world" (11) + "line one line two" (17) + none (0)
|
|
183
|
+
assert data["skills"]["desc_chars"] == 28
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_no_skills_dir(tmp_path, capsys):
|
|
187
|
+
cfg = write_settings(tmp_path / "cfg", {})
|
|
188
|
+
code, data = run_json(capsys, ["--config", str(cfg), "--json"])
|
|
189
|
+
assert code == 0
|
|
190
|
+
assert data["skills"] == {"count": 0, "desc_chars": 0}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --- config resolution -------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def test_config_flag_honored(tmp_path, capsys):
|
|
196
|
+
cfg = write_settings(tmp_path / "picked", {})
|
|
197
|
+
write_settings(tmp_path / "other", {})
|
|
198
|
+
code, data = run_json(capsys, ["--config", str(cfg), "--json"])
|
|
199
|
+
assert code == 0
|
|
200
|
+
assert data["config_dir"] == str(cfg)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_env_var_honored(tmp_path, monkeypatch, capsys):
|
|
204
|
+
cfg = write_settings(tmp_path / "from-env", {})
|
|
205
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(cfg))
|
|
206
|
+
code, data = run_json(capsys, ["--json"])
|
|
207
|
+
assert code == 0
|
|
208
|
+
assert data["config_dir"] == str(cfg)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_config_flag_beats_env(tmp_path, monkeypatch, capsys):
|
|
212
|
+
flag_cfg = write_settings(tmp_path / "flag", {})
|
|
213
|
+
env_cfg = write_settings(tmp_path / "env", {})
|
|
214
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(env_cfg))
|
|
215
|
+
code, data = run_json(capsys, ["--config", str(flag_cfg), "--json"])
|
|
216
|
+
assert data["config_dir"] == str(flag_cfg)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_default_falls_back_to_home_dot_claude(tmp_path, monkeypatch):
|
|
220
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
221
|
+
monkeypatch.setenv("USERPROFILE", str(tmp_path)) # windows
|
|
222
|
+
assert resolve_config_dir(None) == tmp_path / ".claude"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# --- paths with spaces -------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def test_config_dir_with_space(tmp_path, capsys):
|
|
228
|
+
script = write_script(tmp_path / "hooks" / "ok.sh")
|
|
229
|
+
cfg = write_settings(tmp_path / "claude config",
|
|
230
|
+
{"SessionStart": [command_hook(str(script))]})
|
|
231
|
+
code, data = run_json(capsys, ["--config", str(cfg), "--json"])
|
|
232
|
+
assert code == 0
|
|
233
|
+
assert data["config_dir"] == str(cfg)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_hook_path_with_space(tmp_path, capsys):
|
|
237
|
+
script = write_script(tmp_path / "dir with space" / "hook.sh")
|
|
238
|
+
cfg = write_settings(tmp_path / "cfg",
|
|
239
|
+
{"SessionStart": [command_hook(f'"{script}" --fast')]})
|
|
240
|
+
code, data = run_json(capsys, ["--config", str(cfg), "--json"])
|
|
241
|
+
assert code == 0
|
|
242
|
+
(hook,) = data["hooks"]["SessionStart"]["hooks"]
|
|
243
|
+
assert hook["target"] == str(script)
|
|
244
|
+
assert hook["alive"] is True
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# --- unreadable config -------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def test_invalid_json_exits_2(tmp_path, capsys):
|
|
250
|
+
cfg = tmp_path / "cfg"
|
|
251
|
+
cfg.mkdir()
|
|
252
|
+
(cfg / "settings.json").write_text("{not json")
|
|
253
|
+
code = main(["--config", str(cfg)])
|
|
254
|
+
assert code == 2
|
|
255
|
+
assert "cannot audit" in capsys.readouterr().err
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_non_object_json_exits_2(tmp_path, capsys):
|
|
259
|
+
cfg = tmp_path / "cfg"
|
|
260
|
+
cfg.mkdir()
|
|
261
|
+
(cfg / "settings.json").write_text("[1, 2, 3]")
|
|
262
|
+
assert main(["--config", str(cfg)]) == 2
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# --- JSON output shape -------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
def test_json_output_is_valid_and_consistent(tmp_path, capsys):
|
|
268
|
+
alive = write_script(tmp_path / "hooks" / "alive.sh")
|
|
269
|
+
hooks = {"SessionStart": [command_hook(str(alive))],
|
|
270
|
+
"Stop": [command_hook(str(tmp_path / "dead.sh"))],
|
|
271
|
+
"PreCompact": [command_hook(str(alive))]}
|
|
272
|
+
cfg = write_settings(tmp_path / "cfg", hooks)
|
|
273
|
+
write_skill(cfg, "one", "---\ndescription: abc\n---\n")
|
|
274
|
+
code, data = run_json(capsys, ["--config", str(cfg), "--json"])
|
|
275
|
+
assert code == 1
|
|
276
|
+
assert data["settings_found"] is True
|
|
277
|
+
assert set(data) == {"config_dir", "settings_found", "hooks", "skills", "verdict"}
|
|
278
|
+
assert data["hooks"]["SessionStart"]["cadence"] == "per session"
|
|
279
|
+
assert data["hooks"]["Stop"]["cadence"] == "per turn"
|
|
280
|
+
assert data["hooks"]["PreCompact"]["cadence"] == "on event"
|
|
281
|
+
assert data["verdict"] == {"dead_hooks": 1, "duplications": 0, "skills": 1,
|
|
282
|
+
"desc_chars": 3, "exit": 1}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# --- extract_target unit -----------------------------------------------------
|
|
286
|
+
|
|
287
|
+
@pytest.mark.parametrize("command,expected", [
|
|
288
|
+
("nohup /opt/hooks/watch.sh", "/opt/hooks/watch.sh"),
|
|
289
|
+
("bash -c '/opt/hooks/guard.sh --strict'", "/opt/hooks/guard.sh"),
|
|
290
|
+
("RUST_LOG=debug python3 /opt/hooks/check.py -u", "/opt/hooks/check.py"),
|
|
291
|
+
('"/opt/dir with space/hook.sh" --fast', "/opt/dir with space/hook.sh"),
|
|
292
|
+
("mycheck --fast", "mycheck"),
|
|
293
|
+
])
|
|
294
|
+
def test_extract_target(command, expected):
|
|
295
|
+
assert extract_target(command) == expected
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_extract_target_expands_home():
|
|
299
|
+
home = str(Path.home())
|
|
300
|
+
assert extract_target("$HOME/hooks/x.sh") == home + "/hooks/x.sh"
|
|
301
|
+
assert extract_target("~/hooks/y.sh") == os.path.expanduser("~/hooks/y.sh")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# --- entry points ------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
def test_version_flag(capsys):
|
|
307
|
+
with pytest.raises(SystemExit) as exc:
|
|
308
|
+
main(["--version"])
|
|
309
|
+
assert exc.value.code == 0
|
|
310
|
+
assert __version__ in capsys.readouterr().out
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_python_dash_m_entrypoint(tmp_path):
|
|
314
|
+
cfg = write_settings(tmp_path / "cfg", {})
|
|
315
|
+
env = {**os.environ, "CLAUDE_CONFIG_DIR": str(cfg)}
|
|
316
|
+
proc = subprocess.run([sys.executable, "-m", "bridle_audit", "--json"],
|
|
317
|
+
capture_output=True, text=True, env=env)
|
|
318
|
+
assert proc.returncode == 0
|
|
319
|
+
data = json.loads(proc.stdout)
|
|
320
|
+
assert data["config_dir"] == str(cfg)
|
|
321
|
+
assert data["settings_found"] is True
|