$CLAUDE_TOOL_INPUT_FILE_PATH is not a real variable
Popular community hook configs guard on $CLAUDE_TOOL_INPUT_FILE_PATH, a variable the docs never mention; it expands to empty, the guard never matches, and the hook exits 0 having formatted nothing.
This advice is common and does not hold. The official page saying otherwise is linked below.
What this costs you if it is wrong
If you ship this hook believing it works you get no formatting and no error, so the repo drifts out of format while the config looks correct. The auto-test variant is worse: you believe tests ran on every edit and they never did. Fixing it also changes the blast radius — a hook that starts actually running will now execute formatters, installs or test suites on every Write and Edit, so check what the command does before you make it live.
You wired up a PostToolUse hook so Prettier runs on every file Claude writes. /hooks lists it. No error ever appears in the transcript. And the repo keeps drifting out of format, because the hook has been running successfully and doing nothing since the day you pasted it.
Check the hook's own text for this:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "if [[ \"$CLAUDE_TOOL_INPUT_FILE_PATH\" =~ \\.(ts|tsx)$ ]]; then npx prettier --write \"$CLAUDE_TOOL_INPUT_FILE_PATH\"; fi"
}
]
}
]
}
}$CLAUDE_TOOL_INPUT_FILE_PATH appears nowhere in the Claude Code documentation. Grep the whole docs corpus for CLAUDE_TOOL_INPUT and you get zero hits. In shell form the hook command is handed to sh -c, an unset variable expands to the empty string, the regex test against "" is simply false, the if body never runs, and the hook exits 0. Every layer reports health.
What a hook actually gets
The path does not arrive in the environment. It arrives on stdin, as one JSON object.
| Where you might look for the file path | Does it hold it |
|---|---|
$CLAUDE_TOOL_INPUT_FILE_PATH | No — the name is not in the docs at all |
$CLAUDE_PROJECT_DIR | No — the project root where the session started |
$CLAUDE_PLUGIN_ROOT, $CLAUDE_PLUGIN_DATA | No — plugin install and data directories |
stdin JSON, .tool_input.file_path | Yes — this is the documented route |
${tool_input.file_path} in a hook's input | Only for mcp_tool hooks, not command hooks |
The docs' own formatting example reads the path the way every working hook does:
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}And for anything with a guard, take the path out of stdin first:
#!/usr/bin/env bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
case "$FILE_PATH" in
*.ts|*.tsx) npx prettier --write "$FILE_PATH" ;;
*) exit 0 ;;
esacNote the // empty — with it, a missing field gives you an empty string rather than the literal text null, which is its own quiet way to format nothing.
Why nothing ever told you
Three independent facts have to line up for a broken hook to look healthy, and here they all do.
- An unset variable in shell form is an empty string, not an error. Bash's
[[ ... =~ ... ]]against an empty string is false, and a falseifis not a failure. - The hook exits 0, so there is nothing for Claude Code to report.
- Stderr from a hook that exits 0 goes to the debug log only. It never reaches the transcript and Claude never sees it.
So the hook that does nothing and the hook that works produce the same visible output: none. This is why the symptom reaches people as "hooks don't work" rather than "my hook has a typo". The hook worked perfectly.
The auto-test variant of the same snippet is worse than the formatting one. A formatter that silently skips leaves visible mess in the diff. A test runner that silently skips leaves you believing every edit was covered.
What is unverified here
Nobody at CCLearn has run this. What has been checked is the documentation, and what the documentation supports is narrower than the claim:
- Established: the documented way to get the edited file's path is
.tool_input.file_pathfrom stdin JSON, shown in multiple hook examples. - Established: the environment variables the docs say Claude Code exports onto a spawned hook process are
CLAUDE_PROJECT_DIR,CLAUDE_PLUGIN_ROOTandCLAUDE_PLUGIN_DATA. - Not established: that no undocumented
CLAUDE_TOOL_INPUT_FILE_PATHis set anyway. The docs are silent on the name, and silence is weaker evidence than a denial. It is worth knowing the docs do sometimes deny a variable outright — they say plainly that there is no$CLAUDE_MODELenvironment variable — so omission is not their only way of saying no.
What would settle it is one probe. Point a PostToolUse hook at a script that dumps its own environment, edit a file, and read what came out:
printf '#!/usr/bin/env bash\nenv | grep "^CLAUDE_" > /tmp/hook-env.txt\n' > /tmp/dump-env.sh
chmod +x /tmp/dump-env.sh
# wire /tmp/dump-env.sh as a PostToolUse hook with matcher "Write|Edit",
# have Claude edit any file, then:
cat /tmp/hook-env.txtIf CLAUDE_TOOL_INPUT_FILE_PATH is absent from that list, the snippet is dead and this tip is settled. Run it against a named Claude Code version and it moves from a documentation argument to a reproduced fact.
Meanwhile, the cheap move is to audit what you already have:
grep -rn 'CLAUDE_TOOL_INPUT' ~/.claude/settings.json .claude/settings*.json 2>/dev/nullCheckpoint
- Your
PostToolUsehook exits 0 every time and formats nothing. Which two properties of shell form make that failure invisible?Show answerAnswer
An unset variable expands to the empty string instead of erroring, so
[[ "" =~ \.(ts|tsx)$ ]]is merely false — and a falseifis not a failure. The command then exits 0, so there is nothing for Claude Code to report. A third fact seals it: stderr from a hook that exits 0 goes to the debug log only, never to the transcript and never to Claude. The dead hook and the working hook produce identical visible output, which is none. - Where does the edited file's path actually reach your hook, and what does
// emptyin the jq expression protect you from?Show answerAnswer
On stdin, as a single JSON object, at
.tool_input.file_path— the hooks docs' own formatting example reads it exactly that way,jq -r '.tool_input.file_path' | xargs npx prettier --write. It never arrives in the environment: the variables the docs say Claude Code exports onto a hook process areCLAUDE_PROJECT_DIR,CLAUDE_PLUGIN_ROOTandCLAUDE_PLUGIN_DATA, andCLAUDE_TOOL_INPUTreturns zero hits across the whole docs corpus.// emptyturns a missing field into an empty string rather than the literal textnull, which is its own quiet way to format nothing. One honest limit: documentation silence is weaker than a denial — a one-off hook runningenv | grep "^CLAUDE_"would settle it — but nothing documented supports the guard, so stop shipping it either way. ${tool_input.file_path}is documented for exactly one kind of hook. Which, and why does using it elsewhere fail quietly rather than loudly?Show answerAnswer
The
inputfield of anmcp_toolhook — that is the only place the docs document that substitution. For a command hook in exec form the documented substitutions are the path placeholders${CLAUDE_PROJECT_DIR},${CLAUDE_PLUGIN_ROOT}and${CLAUDE_PLUGIN_DATA}, plus${user_config.*}for plugins; nothing says${tool_input.file_path}resolves inside anargselement. If it does not resolve, the argument is passed through verbatim — your formatter is handed the literal text${tool_input.file_path}as a filename — and it fails in the same silent way the env-var version does, with nothing formatted and nothing to tell you so.