When I started using Claude Code on Windows, it kept making the same handful of mistakes. It ran python3 and the Microsoft Store opened. It wrote > nul and I ended up with a file called nul that Explorer couldn’t delete. It used /mnt/c/ paths as if it was running in WSL. So I did what you did in the early days of agentic AI: I had Claude vibe code a set of hooks to catch them. An AI fix for an AI problem.

Illustration of a Git Bash window where a PreToolUse hook rewrites python3, a /c/ path and > nul into commands that work
Illustration (AI generated)

Those hooks turned out to be far more useful than a quick fix usually is: they still rewrite almost half of all Bash commands Claude runs. But Claude got better, and since I wrote them in February the hooks never got a second look. So I went through the three months of logs I had to see which fixes still earn their keep and which now get in the way. Adding the tests the project never had turned up real bugs, including one that silently corrupted every rewritten command containing non-ASCII text. And they showed the real flaw: the hooks could not tell a command from text about a command. That is what v2 fixes.

Why I wrote them

Hooks are your own scripts that Claude Code runs at fixed moments, configured in settings.json. A PreToolUse hook receives a tool call as JSON just before it runs (for Bash, the command) and can let it through, rewrite it, or block it with a message Claude reads. Unlike an instruction in CLAUDE.md, a hook is not a request the model can forget.

Claude Code runs Bash commands in Git Bash (MSYS2), but early Claude often behaved as if it were somewhere else. My claude-hooks caught these:

  • python3: on a python.org install, python3 is the Microsoft Store install stub, so the Store opened and Claude Code sat waiting for a script that would never finish.
  • Paths and flags caught between two worlds: bash treats the backslash in C:\Users\... as an escape, while Windows programs don’t understand MSYS2-style /c/... paths. Doubled // flags (tasklist //fi) failed, and dir /b treated /b as a path.
  • WSL: the first Claude Code versions ran inside WSL, and Claude kept reaching for wsl and /mnt/c/ paths.
  • > nul: in Git Bash this creates a real file named nul, a reserved name Explorer can’t delete (my older post Invalid file handle when trying to delete a file shows how; rm nul from Git Bash works too). Worse, one stray nul makes git refuse to stage anything:
text Download
$ git add -A
error: short read while indexing nul
error: nul: failed to insert into database
error: unable to index file 'nul'
fatal: adding files failed

Safe fixes are rewritten silently (python3 to python, > nul to > /dev/null), riskier ones are blocked with a suggested command. Each new annoyance became a new check, until the README listed 20 of them.

For the screenshot below I had to switch the python3 stub back on, because I had turned it off long ago. It lives in Settings > Apps > Advanced app settings > App execution aliases, as one of several entries called “App Installer”, in a list that is not sorted in any order a human would recognise. I hope the person who decided not to sort that list steps on Lego in the morning.

App execution aliases in Settings: App Installer python.exe switched off, App Installer python3.exe switched on
The python3.exe alias, somewhere in an unsorted list

The Microsoft Store showing the Python Install Manager page, opened by running python3
What python3 opens today

What three months of logs say

All numbers come from my own Claude Code transcripts, 8 August to 5 November 2026. The hook rewrote 9,654 of about 20,500 Bash commands (47%), and nearly all of that was one fix:

The /c/ path fix does almost all the rewriting

97% of 9,654 rewritten Bash commands needed it, 8 Aug to 5 Nov 2026

/c/ drive paths to C:/9,358
C:\ backslashes to C:/334
> nul to > /dev/null158
Doubled // flags154
cmd /c working directory55
python3 to python47
Windows environment variables24
pwsh -NoProfile3
Commands rewritten per fix. One command can get several fixes, so the bars add up to more than the total.

And the fixed commands did not fail more often: commands the hook left alone failed 3.8% of the time, commands with the /c/ fix 2.1%. That is a comparison, not proof the fix prevented those errors, but it shows the rewrites don’t hurt.

So Claude no longer confuses Git Bash with WSL and rarely reaches for python3 or > nul, but it still writes /c/... paths all the time. With MSYS2_ARG_CONV_EXCL=* in my environment (a setting of my own, not Claude Code’s), Git Bash doesn’t convert them, so Windows programs receive /c/... literally. That fix stays.

The 31 blocks told a different story: 26 were WSL blocks, and every one of them was genuine WSL work (Linux builds, Linux-only tools). Claude once even dodged the hook with M="/mnt/$(printf c)". The rest were 3 correct powershell.exe catches and 2 blocked commit messages. A guardrail written for last year’s model needs re-checking against this year’s behaviour, with data rather than gut feeling.

Vibe coded, never properly tested

There were 56 unit tests, but they called individual fix functions with hand-picked strings. Nothing ran the hooks the way Claude Code does. So Claude added three layers:

  1. End-to-end tests: the hook scripts run as subprocesses, fed UTF-8 JSON on stdin exactly as Claude Code sends it, with PYTHONUTF8 and PYTHONIOENCODING removed so the Windows code page is in effect.
  2. Tests from real commands, copied from transcripts: WSL builds, ssh commands, heredocs writing .bat files and Python scripts.
  3. A replay of history: all 18,833 distinct Bash commands from the three months run through the old and the new hook. Only 31 changed outcome. Claude reviewed each of them, I reviewed the test results. The first pass caught a regression the unit tests had missed.

The tests were committed first: 33 of the 43 new tests fail on the old code, and all 99 pass after the fix.

Bugs the tests surfaced

The worst was invisible for months. The hook decoded its input as cp1252 instead of UTF-8, so whenever any fix fired, — became — and → became →. That hit 155 of 155 rewritten commands with non-ASCII text. Because the /c/ fix fires on any cd /c/..., most heredocs that wrote Markdown or source files saved mojibake (UTF-8 bytes read as cp1252 characters). The same bug meant the emoji check never blocked a single emoji.

BugFix in the releaseIssue
Input decoded as cp1252UTF-8 in and out; invalid input fails with a clear message#35
WSL blocked while WSL is installed (26 of 26 false)wsl blocked only when WSL is missing; /mnt/c/ in plain Git Bash is auto-fixed to C:/#36
Fixes rewrote text that runs elsewhere: python3 in ssh payloads and shebangs, > nul in .bat files written by a heredocA quote- and heredoc-aware scanner skips arguments of wsl, ssh, plink and adb shell, and heredoc bodies#37
python3 rewritten even where it is a real interpreterRewritten only when missing or the Store stub#38
Blocks not logged, log kept about two daysEvery block logged with its check id, log capped at 5 MB instead of 500 lines#39

The fixed emoji check proved itself within the hour: in another, already running session, it blocked a README edit containing an arrow that the old hook had let through. Claude then used the same arrow in its reply about the block.

Claude Code output: the PreToolUse hook blocks a README edit because it contains the arrow character U+2192, and Claude’s reply uses the same arrow
Blocked, and then used again in the very next sentence

A few hours later it caught Claude again, while it was writing the notes for this post. It wrote a test script with the arrow spelled as an ASCII escape: a backslash, the letter u and 2192. Blocked anyway, because tool parameters travel as JSON, where that escape is decoded into a real arrow before the file is written. The project’s CLAUDE.md says “No emoji or decorative unicode”, and it still happened twice in one day. A rule makes the habit rarer; a hook stops it.

Two more bugs turned up while writing this post, both by biting Claude itself. Copying the Store screenshot failed because the hook rewrote the file name python3-store.png to python-store.png: the regex treated python3 inside a file name as a command. The history replay then showed the same bug had silently changed three grep patterns, so they searched for the wrong text without any error. And while Claude was filing the fix, the hook rewrote an example in the pull request description, because the Windows environment variable fix didn’t skip quoted heredoc bodies (#45, #47, PR #46).

Python and UTF-8

One related problem a hook cannot fix: Python scripts that Claude writes crash on Unicode output with UnicodeEncodeError: 'charmap' codec can't encode character. That happened in 13 sessions, mostly on → and λ. And “ASCII only” is not the answer: λ is the lambda (air-fuel ratio) value on my BMW dashboard. Claude Code captures output through a pipe, and Python on Windows encodes piped output with the ANSI code page. One line in ~/.claude/settings.json fixes it for every script, no restart needed (#41):

settings.json · json Download
{
  "env": {
    "PYTHONUTF8": "1"
  }
}

Side effect: open() without an encoding then reads and writes UTF-8 instead of cp1252, which matters for scripts that read old cp1252 files. That is why the installer leaves it to you. From Python 3.15 UTF-8 mode is the default (PEP 686).

These fixes went out as PR #40 on 5 November 2026, later tagged v1.0: the last release of the regex-per-fix design.

v2: telling a command from text

v1 ran each fix as its own regex over the raw command, and every wrong rewrite we found was the same mistake: the hook changed text about a command, not the command. A file name, a grep pattern, a commit message with a literal /c/Users example.

The breakthrough was a question I asked Claude: isn’t the secret knowing what is a command and what is the text of a command, for example in documentation? More regexes would never get there. So v2 (#48) parses each command first and labels every part of it:

KindExamples
command wordpython3, ls, also inside $(...) and bash -c
argumentfile names, paths, options
codepython -c "...", pwsh -Command, cmd /c, a heredoc fed to python -
foreignwhat runs elsewhere: wsl, ssh, adb shell
dataecho text, commit messages, grep patterns, heredocs written to a file
comment# ...

Each fix then says where it applies: python3 only as a command word, /c/ paths in commands, arguments and code, > nul only as a real redirect. Literal text stays exactly as Claude wrote it. Two principles came out of the rewrite:

  • When only the caller knows, don’t guess. A $ in pwsh -Command "..." can be a bash variable or a PowerShell one, sometimes both in one line. Swapping the quotes fixes one and breaks the other; in my history it would have blocked a correct command. So the hook blocks, names the variables, and tells Claude how to mark each one: \$x for PowerShell, an assignment in the same command for bash (#58).
  • One escape hatch, and you approve it. For the rare wrong rewrite, Claude adds a # hook:verbatim comment, and the hook asks you first, showing the rewrite it would skip (#57).

The logs also showed a habit we had missed: in 553 commands Claude had added -X utf8 to python -I by hand, because isolated mode ignores PYTHONUTF8 and a script had crashed. v2 adds it automatically when PYTHONUTF8=1 is set.

Every change was checked the same way: replay every Bash command from my transcripts through the old and the new hook, and look at each outcome that changed.

ChangeCommands changed in the replay
python3 only where it runs as a command (#60)8
> nul only as a real redirect (#62)3
Windows env vars to paths native programs can open (#64)26
WSL paths in text, multi-line cmd /c (#66, #67)3
/c/ paths in text (#69)1
pwsh $ handed back to Claude (#58)1
Unicode check on heredocs written to files (#55)0 by default, 135 with the check on
-X utf8 for python -I (#56)1,224
Backslash paths on the parser (#74)292

The small numbers are the point: each fix removed only the wrong rewrites and kept every right one. The two big rows are new behaviour, checked line by line: every one of the 1,224 changes is only an inserted -X utf8.

Trying v2

v2 is out as the v2.0 preview, a GitHub pre-release, with 511 tests. It runs every day on my machine, and the final v2.0 follows after a period of real-world testing. To install or update:

bash Download
git pull
python install.py

Your config.json is kept, and running sessions use the new version from their next command, because Claude Code starts the hook fresh for every tool call. Two checks are not on the parser yet, doubled_flags and unc_paths; the replay found no wrong rewrites from either. Wrong rewrites or blocks are welcome as issues, with the line from ~/.claude/hooks/fixups.log.

Being open about AI

Everyone uses AI now, so the useful question is not whether you used it but where, and what a person actually checked. That is what separates AI-assisted work from AI slop.

This project is an awkward place to say it: it has opt-in checks that block AI attribution in commit messages. A Co-Authored-By: Claude trailer doesn’t say what anyone reviewed either. So we changed both the checks and our own rule (PR #44). The checks now catch only AI trailers and say the line breaks a repo policy, rather than telling you to hide AI use. The project’s own commits now carry the trailer, and each PR says what was AI-written and what a person reviewed. Fittingly, the hook blocked Claude while it was writing the tests for that change.

For both releases: Claude wrote the code changes, the tests and the history replays, and reviewed every changed outcome. I reviewed the test results.

A note on how this post was written: I used AI assistance. Claude made the first draft from my notes. It was not written automatically: I reviewed the draft, corrected what was wrong and rewrote parts in my own words.