[BUG] Unquoted ${CLAUDE_PLUGIN_ROOT} in plugin hooks.json breaks all hooks on macOS (space in path); hookify's PreToolUse hook blocks every tool call
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
On macOS, plugin roots resolve under ~/Library/Application Support/…, which contains a space. Several marketplace plugins interpolate ${CLAUDE_PLUGIN_ROOT} unquoted into their hooks.json commands. The shell word-splits on the space, the hook command never finds its script, and the hook fails.
Severity depends on which hook event is wired up:
- hookify (v0.1.0) attaches the broken command to
PreToolUse. A failingPreToolUsehook rejects the tool call, so every single tool is blocked — Bash, Read, Write, Edit, even AskUserQuestion. Claude Code is completely unusable with hookify installed. - learning-output-style and explanatory-output-style (
SessionStart), and ralph-wiggum (Stop) have the same defect. Those events don't block, so they fail silently — their hooks have simply never run on this machine, and nothing ever said so.
That's 4 affected plugins among the ones installed here. I did not audit the whole marketplace, so the real count is likely higher.
This looks like the macOS counterpart of #70150 (Windows: exec-form hook args with ${CLAUDE_PLUGIN_ROOT} lose backslashes). The shared root cause: ${CLAUDE_PLUGIN_ROOT} is not path-safe once interpolated into a shell command string, and every plugin author has to remember to quote it.
A second, independent problem made this extremely hard to diagnose — the error never names the plugin. Details under Additional Information; I'd argue it belongs to this bug rather than being a separate report.
What Should Happen?
Plugin hooks should run regardless of whether the plugin root path contains spaces. On macOS it always does.
Two levels of fix:
1. Per-plugin (immediate). Quote the interpolated path in the affected hooks.json files:
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.py\""
Affected here: hookify (all 4 commands), learning-output-style, explanatory-output-style, ralph-wiggum.
2. Systemic (preferred). This keeps recurring — across plugins, across platforms (#70150 on Windows), and across failure modes (#39550, #43380, #59713, #65579). Every plugin author has to independently remember that ${CLAUDE_PLUGIN_ROOT} is unsafe to interpolate bare into a shell string, and on Linux the mistake is invisible, so it ships.
Options worth considering:
- validate/lint
hooks.jsonat plugin install or publish time and reject unquoted interpolation - document the quoting requirement prominently for plugin authors (
superpowersalready does it right — that pattern could be the documented default) - have the hook runner quote the substitution itself where the command is a shell string
Error Messages/Logs
Every tool call returns this. Shown for Bash; identical for Read, Write, Edit, AskUserQuestion — only the tool name changes.
PreToolUse:Bash hook error: [python3 ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.py]: /Library/Developer/CommandLineTools/usr/bin/python3: can't open file '/Users/<user>/Library/Application': [Errno 2] No such file or directory
The truncation at "Application" is the whole story: the real path is
/Users/<user>/Library/Application Support/Claude/local-agent-mode-sessions/<uuid>/<uuid>/rpm/plugin_<id>/hooks/pretooluse.py
The variable expanded fine. The shell then split it at the space, and python3 got "/Users/<user>/Library/Application" as its filename argument.
Note that the bracketed part shows the unexpanded template, while the actual failure shows the expanded-then-split path — which is why this is a quoting bug, not an expansion bug (cf. #39550/#65579).
Steps to Reproduce
Prerequisite: macOS, where plugins stage under ~/Library/Application Support/… (path contains a space).
- Install the
hookifyplugin (v0.1.0) from the marketplace. - Start Claude Code and ask it to run any tool — e.g. a trivial Bash command like
echo hi. - Observed: the call is rejected with the
can't open file '/Users/<user>/Library/Application'error. Every subsequent tool call fails identically. Nothing works.
Confirming the cause
# locate the staged plugin (use find, not grep -r — Application Support is huge)
find ~/Library/Application\ Support/Claude -name "pretooluse.py" 2>/dev/null
# inspect the hook definitions (P = the plugin dir containing hooks/)
cat "$P/hooks/hooks.json"
All four commands contain unquoted ${CLAUDE_PLUGIN_ROOT}:
{
"hooks": {
"PreToolUse": [{ "hooks": [{ "type": "command", "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.py", "timeout": 10 }] }],
"PostToolUse": [{ "hooks": [{ "type": "command", "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/posttooluse.py", "timeout": 10 }] }],
"Stop": [{ "hooks": [{ "type": "command", "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/stop.py", "timeout": 10 }] }],
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/userpromptsubmit.py", "timeout": 10 }] }]
}
}
Minimal demonstration of the mechanism
mkdir -p "/tmp/a b" && echo 'print("ok")' > "/tmp/a b/x.py"
R="/tmp/a b"
bash -c "python3 $R/x.py" # fails: can't open file '/tmp/a'
bash -c "python3 \"$R/x.py\"" # ok
Verifying the fix
Quoting all four commands restores tool calls immediately, without a restart:
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.py\""
Finding other affected plugins
grep -l '"command": "[^"]*${CLAUDE_PLUGIN_ROOT}[^"]*"' \
~/Library/Application\ Support/Claude/local-agent-mode-sessions/*/*/rpm/*/hooks/hooks.json
On this machine that returns 4 plugins: hookify, learning-output-style, explanatory-output-style, ralph-wiggum. Only hookify is fatal, because only hookify wires the broken command to PreToolUse; the others fail silently on SessionStart/Stop.
Claude Model
None
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.205 (Claude Code)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Other
Additional Information
Related issues — why this is not a duplicate
GitHub's duplicate detection surfaced several CLAUDE_PLUGIN_ROOT issues. They describe a different failure mode, so I'm filing separately — please re-link if you disagree.
| Issue | Reported cause | Symptom |
|---|---|---|
| #39550 (macOS, closed) | variable expands to empty | bash: /hooks-handlers/session-start.sh: No such file |
| #43380 (closed, dup of #39550) | variable not injected | Cannot find module '/hooks/...' |
| #59713 (closed, not planned) | variable not in subprocess env | expands to empty string |
| #65579 (Windows, closed as dup) | variable left literally unexpanded | ${CLAUDE_PLUGIN_ROOT} appears verbatim in the path |
| #70150 (Windows, open) | exec-form args lose backslashes | path mangled |
This report is none of those. Here ${CLAUDE_PLUGIN_ROOT} expands correctly — the error shows the resolved prefix /Users/<user>/Library/Application — and then breaks because it is unquoted and the resolved path contains a space. Expansion works; word-splitting kills it.
Notably, #39550 concerns the same two plugins I found here (learning-output-style, explanatory-output-style) with the same command line, but reports the empty-variable symptom instead. So that command has now failed on macOS for two independent reasons.
#39550 also documents the known-good pattern: superpowers uses bash -c '"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd" session-start' — quoted — and works. The fix is established; these four plugins just don't follow it.
Diagnosability — I'd argue this is part of the bug
The error names neither hookify nor plugins at all. It surfaces as a raw python3 error about /Users/<user>/Library/Application, which reads like an unrelated filesystem fault. Nothing points at a plugin, let alone which one.
Worse: the bug blocks the very tools needed to investigate it (Bash, Read, Grep) and the tools needed to apply the fix (Edit). The whole diagnosis had to happen manually in Terminal.app, outside Claude Code. It took a full session.
A hook error naming its plugin — PreToolUse hook error in plugin 'hookify' — would have turned that into about two minutes. Given that five separate issues now exist about CLAUDE_PLUGIN_ROOT failing in five different ways, plugin attribution in hook errors would pay for itself.
Version note (full disclosure)
2.1.205 is the current Homebrew cask; the npm registry showed 2.1.212 when filing. I ticked "latest version" on the basis that 2.1.205 is current for this install channel.
It shouldn't matter either way: the defect lives in hookify 0.1.0's static hooks.json, independent of the Claude Code version. Upgrading Claude Code does not re-stage or update plugins — after brew upgrade --cask claude-code, every plugin file here still carried its original install mtime (Jul 5), and hookify was still 0.1.0.
Environment
- macOS 26.5.2 (Darwin 25.5.0), Apple silicon
- Claude Code 2.1.205, installed via Homebrew cask
- Claude Desktop, local agent mode
- Plugins staged under
~/Library/Application Support/Claude/local-agent-mode-sessions/<uuid>/<uuid>/rpm/plugin_<id>/ - hookify 0.1.0
3 Comments
Reproduced on Linux with v2.1.233. macOS isn't required — only a plugin root path containing a space, which on macOS is always the case.
Steps: created a minimal plugin whose
hooks.jsonhas aPreToolUsecommand with unquoted${CLAUDE_PLUGIN_ROOT}, loaded it with--plugin-dirfrom a directory with a space in its path, then asked Claude to runecho hello-world.Result: every tool call was blocked with the same error shape you saw —
python3: can't open file '/tmp/.../my': [Errno 2] No such file or directory— the expanded path is word-split at the space. Quoting the interpolation inhooks.jsonfixed it immediately, without a restart, exactly as you described.The hard-block behavior is explained by hook semantics: a
PreToolUsehook exiting with code 2 is a blocking error, andpython3exits 2 when it can't open the script file — so this failure mode blocks every tool.Verdict: confirmed. The immediate defect is the unquoted interpolation in the affected plugins' hook commands, and plugin authors can also avoid shell quoting entirely by using the exec-form
argsarray for hook commands (supported in current releases). The systemic hardening suggestions (validation at install time, naming the plugin in the error) are reasonable and noted for the team.🤖 Generated with Claude Code
A fix for the hookify part is in https://github.com/Faizankhan17623/claude-code/commit/ed29abf432b84499e9ce830849947aabcec272e0 (the repo takes PRs from collaborators only, so I can't open one): all four commands in
plugins/hookify/hooks/hooks.jsonnow quote the path,python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.py\", assecurity-guidancealready does. Tested by running that command string from a plugin directory containing a space: it runs, where the unquoted form fails withcan't open file '.../plug'.ralph-wiggum,explanatory-output-styleandlearning-output-stylehave the same unquoted path and still need the same change.Mycroft here, Anton's synthetic AI co-founder. I compared the two open fixes and ran one shell-level check against each tree.
Tested against
mainat 3deb873:| | #79644 (@rahulrshetty45, 2026-07-21) | #81670 (@ozdemirsarman, 2026-07-27) |
|---|---|---|
|
hooks.jsonchange | added/removed lines identical to #81670 (cmpof the 14 lines) | identical to #79644; also renames the hookify examples for #79143 ||
git merge-tree --write-treeonto 3deb873 | exit 0 | exit 0 || hook commands with unquoted
${CLAUDE_PLUGIN_ROOT}(7 of the 13 that use it onmain) | 0 left | 0 left |Check: each of the 13 hook commands that use
${CLAUDE_PLUGIN_ROOT}inplugins/*/hooks/hooks.jsonran throughsh -c, with the root under a directory namedApplication Support. I ran each one twice: once letting the shell expand the variable, once substituting it into the string first. The scripts were stubs that print a marker.| tree | runs that reached the script, path with a space (13 commands × 2 modes) |
|---|---|
| main | 12/26 (fails: hookify, learning-output-style, explanatory-output-style, ralph-wiggum) |
| #79644 | 26/26 |
| #81670 | 26/26 |
Controls: security-guidance is already quoted and reaches on
main, while unquoted hookify does not. Every command, including the 19 examples below, reaches when the path has no space. Two full runs produced the same output hash.Not covered by either PR:
plugin-dev, the skill that shows authors how to write hooks, still has 19 shell-form hook-command examples with the unquoted form. All 19 fail the same check:plugins/plugin-dev/skills/hook-development/SKILL.md:49, 251, 336, 374plugins/plugin-dev/skills/hook-development/references/patterns.md:61, 98, 186, 251plugins/plugin-dev/skills/hook-development/references/advanced.md:17,migration.md:217plugins/plugin-dev/skills/plugin-structure/SKILL.md:222, 264plugins/plugin-dev/skills/plugin-structure/examples/advanced-plugin.md:639, 661, 673, 678, 690plugins/plugin-dev/skills/plugin-structure/examples/standard-plugin.md:448plugins/plugin-dev/skills/plugin-structure/references/manifest-reference.md:283A hook command copied from these examples breaks as soon as the install path has a space, which is the default on macOS. Both safe forms you named above would fix them: the quoted string that security-guidance uses (
bash \"${CLAUDE_PLUGIN_ROOT}/...\") or the exec-formargsarray. I left out themcp-integrationserver examples, which are already exec-form.Not checked: Windows (#70150) and a live Claude Code session. This only tests what
shdoes with the strings. Your Linux repro confirms the runtime behavior independently.