Windows: exec-form hook args with ${CLAUDE_PLUGIN_ROOT} lose backslashes (path mangled)
## Summary
On Windows, command-type plugin hooks that use the exec form (command + args) with ${CLAUDE_PLUGIN_ROOT} fail, because the substituted backslash path has its backslashes stripped before the script is invoked. The docs describe exec form as passing each arg as one literal argument with no shell interpretation, but on Windows the path is clearly being re-parsed by a shell that eats the backslashes.
Environment
- OS: Windows 11
- Claude Code: 2.1.186
- Hook shell: Git Bash
Repro
A plugin hooks/hooks.json with an exec-form command hook:
{
"type": "command",
"command": "bash",
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/scripts/foo.sh"]
}
On Windows ${CLAUDE_PLUGIN_ROOT} expands to e.g. C:\Users\me\.claude\plugins\cache\acme\foo\1.0.0.
When the hook fires:
bash: C:Usersme.claudepluginscacheacmefoo1.0.0/hooks/scripts/foo.sh: No such file or directory
The backslashes are gone (C:\Users\me\... → C:Usersme...), so the path cannot resolve and the hook never runs.
Expected
Exec form should pass command plus each arg as literal argv entries with no shell re-parsing, so the backslashes survive and the script is found — as the documentation states.
Actual
The substituted value is placed into an unquoted command line that a shell (Git Bash) re-parses, consuming the backslashes as escape characters.
Why this contradicts the docs (not expected behavior)
The hooks reference defines exec form with an explicit cross-platform guarantee:
Exec form runs whenargsis present. Claude Code resolvescommandas an executable onPATHand spawns it directly withargsas the argument vector. There is no shell, so eachargselement is one argument exactly as written, and path placeholders like${CLAUDE_PLUGIN_ROOT}are substituted intocommandand into eachargselement as plain strings. Special characters such as apostrophes,$, and backticks pass through verbatim because there is no shell to interpret them. No shell tokenization happens on any platform.
(Quoted verbatim; emphasis on the final sentence is mine.) The backslash stripping shown above is shell tokenization, so it directly violates this contract.
The plugins reference goes further and recommends the exact pattern that breaks:
In hook commands, use exec form with args so the path is passed as one argument with no quoting.
and states the variable is exported to the hook process:
All are also exported as environment variables to hook processes and MCP or LSP server subprocesses.
I searched both pages for any Windows path-handling / backslash / forward-slash-normalization caveat and found none — the only Windows-specific notes concern .cmd/.bat shims not being spawnable in exec form (bash.exe is a real executable and does spawn), mklink for symlinks, and shell selection. So there is no documented exception that would make this expected behavior.
Additional findings
- The breakage is specifically Claude Code's substitution of the braced
${CLAUDE_PLUGIN_ROOT}token: Claude Code replaces the token with the backslash path, and those backslashes are then lost during command-line assembly on Windows. CLAUDE_PLUGIN_ROOTis exported as a real environment variable to (shell-form) hook processes. The unbraced$CLAUDE_PLUGIN_ROOTis left untouched by Claude Code and is expanded by bash at runtime, with the backslashes preserved as literal characters. (Verified on Windows 11: an unbraced shell-form hook resolves and runs correctly.)- Recommended workaround for plugin authors: shell form with the unbraced, double-quoted variable:
``json`
{ "command": "bash \"$CLAUDE_PLUGIN_ROOT/hooks/scripts/foo.sh\"" }
$(...)`, backticks) in a path cannot trigger command substitution. (Substituting the braced token into the command would instead place any such characters into the source text, where the shell would parse them.)
bash expands the variable at runtime, so the backslashes survive. This is also injection-safe: bash does not re-scan the *result* of a variable expansion, so shell metacharacters (
Impact
Any plugin that invokes bundled scripts via exec-form hooks is broken on Windows. Real-world example and the shell-form workaround in practice: https://github.com/zoharbabin/claude-code-message-timestamps/pull/5
Suggested fix
The root issue is that substituting the braced ${CLAUDE_PLUGIN_ROOT} token strips backslashes on Windows. Either (a) preserve the backslashes when substituting the token (don't let them be consumed during command-line assembly), and/or (b) for exec form, pass command/args as a real argv (an argument array to the process API, or quote each argument when building the command line) so the substituted path is never shell-re-parsed.
A useful consistency note: the unbraced $CLAUDE_PLUGIN_ROOT already works on Windows (bash expands it at runtime) while the braced ${CLAUDE_PLUGIN_ROOT} does not — so today the documented braced form is the broken one and the unbraced form is the reliable one, which is the opposite of what an author would expect.