Summary
bully --doctor always prints [FAIL] no PostToolUse hook invoking hook.sh found in .claude/settings.json — and exits 1 — when bully is installed as a Claude Code plugin, even though the hook is wired correctly and firing. The check only scans .claude/settings.json, which is the manual-install location; it has no awareness of the hooks/hooks.json the plugin ships and registers itself.
The same run of --doctor reports the evaluator agent and all four skills as (plugin install), so the tool already knows it is looking at a plugin. The hook check is the one probe that hasn't learned about that install mode.
Version
- bully
0.8.6 (latest release), installed via claude plugin install bully@bully-marketplace
- Claude Code, macOS 15 (Darwin 25.5.0), Python 3.14
Reproduction
- Install bully as a Claude Code plugin (no manual
.claude/settings.json hook entry).
- Confirm the hook actually works — SessionStart prints
bully active. N rules configured, and edits are linted.
- Run
bully --config .bully.yml --doctor.
Actual
[OK] Python 3.14
[OK] config present at /path/to/repo/.bully.yml
[OK] config parses (16 rules)
[OK] config trusted on this machine (c22e12327596...)
[OK] ast-grep on PATH (8 engine:ast rule(s))
[FAIL] no PostToolUse hook invoking hook.sh found in .claude/settings.json
[OK] evaluator agent at /Users/<me>/.claude/plugins/cache/bully-marketplace/bully/0.8.6/agents/bully-evaluator.md (plugin install)
[OK] skill bully present at /Users/<me>/.claude/plugins/cache/bully-marketplace/bully/0.8.6/skills/bully/SKILL.md (plugin install)
[OK] skill bully-init present at ... (plugin install)
[OK] skill bully-author present at ... (plugin install)
[OK] skill bully-review present at ... (plugin install)
echo $? → 1.
Expected
Something like [OK] PostToolUse hook wired in <plugin>/hooks/hooks.json (plugin install), and exit 0.
Cause
src/bully/cli/doctor.py:97-123 looks for a PostToolUse entry whose command string contains hook.sh, in exactly two files:
for settings in (
Path.cwd() / ".claude" / "settings.json",
Path.home() / ".claude" / "settings.json",
):
A plugin install writes neither. The wiring lives in the plugin's own hooks/hooks.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/hook.sh\"" }
]
}
]
}
}
Line 123 sets ok = False, so this also makes --doctor unusable as a health gate in CI for plugin installs — the exit code can never be 0.
Suggested fix
Fall back to the plugin cache when neither settings file has the hook: glob ~/.claude/plugins/cache/*/bully/*/hooks/hooks.json, parse it, and apply the existing PostToolUse → hooks[].command contains hook.sh test. plugin_cache_candidates() at doctor.py:31 already does this glob for skills/agents, but its path shape is name-based (<name>/SKILL.md, <name>.md), so hooks need a small variant rather than a reuse.
Worth also checking .claude/settings.local.json while in there — it's a third place a user can wire the hook manually today, and it's equally invisible to the current check.
Why it matters
The message reads as "your linting isn't running", which is the opposite of the truth. The obvious user reaction is to add a redundant PostToolUse entry to .claude/settings.json to make it go green — which registers hook.sh a second time and double-runs every check on every edit.
Summary
bully --doctoralways prints[FAIL] no PostToolUse hook invoking hook.sh found in .claude/settings.json— and exits1— when bully is installed as a Claude Code plugin, even though the hook is wired correctly and firing. The check only scans.claude/settings.json, which is the manual-install location; it has no awareness of thehooks/hooks.jsonthe plugin ships and registers itself.The same run of
--doctorreports the evaluator agent and all four skills as(plugin install), so the tool already knows it is looking at a plugin. The hook check is the one probe that hasn't learned about that install mode.Version
0.8.6(latest release), installed viaclaude plugin install bully@bully-marketplaceReproduction
.claude/settings.jsonhook entry).bully active. N rules configured, and edits are linted.bully --config .bully.yml --doctor.Actual
echo $?→1.Expected
Something like
[OK] PostToolUse hook wired in <plugin>/hooks/hooks.json (plugin install), and exit0.Cause
src/bully/cli/doctor.py:97-123looks for aPostToolUseentry whose command string containshook.sh, in exactly two files:A plugin install writes neither. The wiring lives in the plugin's own
hooks/hooks.json:{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/hook.sh\"" } ] } ] } }Line 123 sets
ok = False, so this also makes--doctorunusable as a health gate in CI for plugin installs — the exit code can never be0.Suggested fix
Fall back to the plugin cache when neither settings file has the hook: glob
~/.claude/plugins/cache/*/bully/*/hooks/hooks.json, parse it, and apply the existingPostToolUse→hooks[].commandcontainshook.shtest.plugin_cache_candidates()atdoctor.py:31already does this glob forskills/agents, but its path shape is name-based (<name>/SKILL.md,<name>.md), so hooks need a small variant rather than a reuse.Worth also checking
.claude/settings.local.jsonwhile in there — it's a third place a user can wire the hook manually today, and it's equally invisible to the current check.Why it matters
The message reads as "your linting isn't running", which is the opposite of the truth. The obvious user reaction is to add a redundant
PostToolUseentry to.claude/settings.jsonto make it go green — which registershook.sha second time and double-runs every check on every edit.