CCLearn

$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.

circulating and false

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:

json
{
  "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 pathDoes it hold it
$CLAUDE_TOOL_INPUT_FILE_PATHNo — the name is not in the docs at all
$CLAUDE_PROJECT_DIRNo — the project root where the session started
$CLAUDE_PLUGIN_ROOT, $CLAUDE_PLUGIN_DATANo — plugin install and data directories
stdin JSON, .tool_input.file_pathYes — this is the documented route
${tool_input.file_path} in a hook's inputOnly for mcp_tool hooks, not command hooks

The docs' own formatting example reads the path the way every working hook does:

json
{
  "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:

bash
#!/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 ;;
esac

Note 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 false if is 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_path from 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_ROOT and CLAUDE_PLUGIN_DATA.
  • Not established: that no undocumented CLAUDE_TOOL_INPUT_FILE_PATH is 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_MODEL environment 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:

bash
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.txt

If 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:

bash
grep -rn 'CLAUDE_TOOL_INPUT' ~/.claude/settings.json .claude/settings*.json 2>/dev/null

Checkpoint

  • Your PostToolUse hook exits 0 every time and formats nothing. Which two properties of shell form make that failure invisible?
    Show answer

    An unset variable expands to the empty string instead of erroring, so [[ "" =~ \.(ts|tsx)$ ]] is merely false — and a false if is 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 // empty in the jq expression protect you from?
    Show answer

    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 are CLAUDE_PROJECT_DIR, CLAUDE_PLUGIN_ROOT and CLAUDE_PLUGIN_DATA, and CLAUDE_TOOL_INPUT returns zero hits across the whole docs corpus. // empty turns a missing field into an empty string rather than the literal text null, which is its own quiet way to format nothing. One honest limit: documentation silence is weaker than a denial — a one-off hook running env | 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 answer

    The input field of an mcp_tool hook — 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 an args element. 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.

Where this came from

What the docs actually say