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.
@@ -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
+ [![CI](https://github.com/trinity-organism/bridle-audit/actions/workflows/test.yml/badge.svg)](https://github.com/trinity-organism/bridle-audit/actions/workflows/test.yml)
35
+ [![PyPI](https://img.shields.io/pypi/v/bridle-audit)](https://pypi.org/project/bridle-audit/)
36
+ [![Python versions](https://img.shields.io/pypi/pyversions/bridle-audit)](https://pypi.org/project/bridle-audit/)
37
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](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
+ [![CI](https://github.com/trinity-organism/bridle-audit/actions/workflows/test.yml/badge.svg)](https://github.com/trinity-organism/bridle-audit/actions/workflows/test.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/bridle-audit)](https://pypi.org/project/bridle-audit/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/bridle-audit)](https://pypi.org/project/bridle-audit/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Audit a Claude Code config: dead hooks, duplicate levers, boot-token cost."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ """Entry point for `python -m bridle_audit`."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
@@ -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
+ [![CI](https://github.com/trinity-organism/bridle-audit/actions/workflows/test.yml/badge.svg)](https://github.com/trinity-organism/bridle-audit/actions/workflows/test.yml)
35
+ [![PyPI](https://img.shields.io/pypi/v/bridle-audit)](https://pypi.org/project/bridle-audit/)
36
+ [![Python versions](https://img.shields.io/pypi/pyversions/bridle-audit)](https://pypi.org/project/bridle-audit/)
37
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](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,2 @@
1
+ [console_scripts]
2
+ bridle-audit = bridle_audit.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [dev]
3
+ pytest
@@ -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