Fixing Claude Code's PowerShell Problem with Hooks

TIP: Just paste this url into Claude Code and say "implement this for me"

Claude Code writes great PowerShell, but it's not the best at using it as a shell. It's basically bash on top of PowerShell. Every single session, I see the same thing:

  1. It tries powershell.exe -Command (Windows PowerShell 5.1, modules don't load)
  2. It fails, switches to pwsh -Command with inline code (bash eats the quoting)
  3. It fails again, finally writes a .ps1 file and runs pwsh -File (works instantly)

Three attempts. Most times. Even with explicit instructions in CLAUDE.md telling it to go straight to the script file approach. I documented the correct pattern months ago and it still burns through two failures before doing the right thing.

Why This Happens

Claude Code runs inside Git Bash on Windows. When you pass PowerShell commands inline through bash, everything goes wrong:

  • $_ gets expanded by bash before PowerShell ever sees it. Your Where-Object { $_.Name } becomes Where-Object { .Name }. Silent, wrong results.
  • Backslash paths get mangled. C:\Users\cl becomes C:Userscl.
  • Quoting is impossible. PowerShell's quoting rules conflict with bash's. Nested quotes, single vs double, escape characters — it's a guaranteed mess.
  • powershell.exe (5.1) can't load modules in Claude Code's execution context. ConvertTo-SecureString, Invoke-Command, basic stuff just fails with "module could not be loaded."

These are tracked as open issues on GitHub (#5049, #16225) with no official fix as of today in February 2026.

The Fix: A PreToolUse Hook

I LOVE HOOKS! Have I ever written one? No. Have I written any code in the past several months? No. Am I in heaven? Yes.

I often hang out on the reddit AI forums, but don't post (hell no) and a lot of people mentioned hooks. I thought they were talking about Git hooks and not Claude Code hooks.

So Claude Code hooks let you intercept tool calls before they execute. A PreToolUse hook on the Bash tool can inspect the command and actually BLOCK(!) it if it matches a bad pattern. Amazing.

I didn't know this, though. So I asked Claude to go and research hook and probably pasted it a discussion on reddit and then I said how can this apply to me?

So it gave me some hooks and after I understood how they worked, I asked Claude to help me build hooks to prevent how it interacts with PowerShell and the repeated errors I see.

Create ~/.claude/hooks/validate-powershell.ps1:

 1# PreToolUse hook: Block inline PowerShell execution from Bash.
 2# Exit code 0 = allow, exit code 2 = block (stderr sent to Claude).
 3
 4$data = [Console]::In.ReadToEnd() | ConvertFrom-Json
 5
 6if ($data.tool_name -ne 'Bash') { exit 0 }
 7
 8$command = $data.tool_input.command
 9if (-not $command) { exit 0 }
10
11# Block powershell.exe entirely (Windows PowerShell 5.1)
12if ($command -match '\bpowershell(\.exe)?\b') {
13    [Console]::Error.WriteLine(
14        "BLOCKED: Do not use powershell.exe (Windows PowerShell 5.1).`n" +
15        "Write a .ps1 file and run: pwsh -NoProfile -File <script.ps1>"
16    )
17    exit 2
18}
19
20# Block pwsh -Command / pwsh -c with inline code
21if ($command -match '\bpwsh(\.exe)?\s+(-[Cc](ommand)?)\b') {
22    [Console]::Error.WriteLine(
23        "BLOCKED: Do not use pwsh -Command/-c with inline code.`n" +
24        "Bash mangles PowerShell quoting, `$_ variables, and paths.`n" +
25        "Write a .ps1 file and run: pwsh -NoProfile -File <script.ps1>"
26    )
27    exit 2
28}
29
30exit 0

The hook receives JSON on stdin with tool_name and tool_input.command. It checks two patterns:

  1. powershell.exe — blocked entirely, you want PowerShell 7
  2. pwsh -Command / pwsh -c — blocked, forces the script file approach

Commands like pwsh -NoProfile -File script.ps1 pass through untouched. So does every non-PowerShell command.

Register the Hook

Add the hooks section to ~/.claude/settings.json:

 1{
 2  "hooks": {
 3    "PreToolUse": [
 4      {
 5        "matcher": "Bash",
 6        "hooks": [
 7          {
 8            "type": "command",
 9            "command": "pwsh -NoProfile -File \"C:\\Users\\YOU\\.claude\\hooks\\validate-powershell.ps1\"",
10            "timeout": 10
11          }
12        ]
13      }
14    ]
15  }
16}

That's it. Next session, Claude Code will get blocked on its first bad attempt and immediately switch to the correct pattern.

Bonus: Fixing the Glob Tool Too

While I was at it, I built a hook to manage the errors made by the built-in Glob tool (file search by pattern), which is unreliable on Windows. It uses ripgrep with --no-ignore, which means it scans .git directories, node_modules, build output, everything. On any non-trivial repo, it times out at 20 seconds or fails silently on permission errors (#22379).

fd is a much better alternative. It respects .gitignore by default and is significantly faster. So I installed fd with winget install sharkdp.fd, and added a second hook.

Create ~/.claude/hooks/redirect-glob.ps1:

 1# PreToolUse hook: Redirect Glob tool to fd.
 2
 3$data = [Console]::In.ReadToEnd() | ConvertFrom-Json
 4
 5if ($data.tool_name -ne 'Glob') { exit 0 }
 6
 7$pattern = $data.tool_input.pattern
 8$path = $data.tool_input.path
 9$searchIn = if ($path) { " `"$path`"" } else { '' }
10
11[Console]::Error.WriteLine(
12    "BLOCKED: Glob tool is unreliable on Windows.`n" +
13    "Use fd via Bash instead. Your pattern: $pattern`n" +
14    "  fd --type f --glob '$pattern'$searchIn`n" +
15    "  fd --type f --extension ps1$searchIn"
16)
17exit 2

Add both hooks to settings.json:

 1{
 2  "hooks": {
 3    "PreToolUse": [
 4      {
 5        "matcher": "Bash",
 6        "hooks": [
 7          {
 8            "type": "command",
 9            "command": "pwsh -NoProfile -File \"C:\\Users\\YOU\\.claude\\hooks\\validate-powershell.ps1\"",
10            "timeout": 10
11          }
12        ]
13      },
14      {
15        "matcher": "Glob",
16        "hooks": [
17          {
18            "type": "command",
19            "command": "pwsh -NoProfile -File \"C:\\Users\\YOU\\.claude\\hooks\\redirect-glob.ps1\"",
20            "timeout": 10
21          }
22        ]
23      }
24    ]
25  }
26}

Claude also added "Bash(fd:*)" to my permissions allow list so fd commands run without prompts.

The "Sibling Tool Call Errored" Thing

If you've seen this in your logs and wondered what it is, it's actually unrelated to PowerShell. When Claude Code uses multiple tool calls in parallel and one fails, the other calls in the same batch get cancelled and show "Sibling tool call errored." Turns out it's nothing to worry about.

What's Actually Happening Under the Hood

If you're curious (and I'm not) PreToolUse hook runs as a subprocess every time Claude Code is about to execute a Bash command. The flow:

  1. Claude Code decides to run pwsh -Command 'Get-Process'
  2. Before executing, it pipes JSON to your hook script via stdin
  3. Your script checks the command against the blocked patterns
  4. Exit code 2 → command is blocked, stderr message sent back to Claude
  5. Claude reads the feedback and writes a .ps1 file instead
  6. Hook runs again on pwsh -File script.ps1 → exit code 0 → allowed

The 10-second timeout keeps calls fast. pwsh startup is a bit slower than python (~500ms vs ~50ms), but hooks only fire on tool calls so it's not noticeable.

All My Hooks

Once I got the hang of it, I went a little nuts. Here's everything I'm running now.

Global Hooks (all projects)

These live in ~/.claude/settings.json and fire on every project.

PreToolUse (before execution):

HookTypeWhat it does
PowerShell validatorcommandBlocks powershell.exe and pwsh -Command inline. Forces script files.
Glob redirectcommandBlocks the built-in Glob tool. Redirects to fd.

PostToolUse (after writing code):

HookTypeWhat it does
Backtick checkercommandScans .ps1 files for backtick line continuation. Tells Claude to use splatting.
Variable-colon detectorpromptCatches "$vmName: offline" in .ps1 files. PowerShell treats that as a scope reference. Should be "$($vmName): offline".

Project-Specific Hooks (my LeLab project)

These live in the project's .claude/settings.json and only fire when I'm working in that repo. This is a Hyper-V lab that uses DSC (Desired State Configuration) for everything, so the hooks enforce infrastructure-as-code discipline.

PreToolUse:

HookTypeWhat it does
Block auto-generated filescommandPrevents editing configuration/LeLab.ps1 which is compiled from fragments.
Lab access validatorpromptValidates SSH/remote commands to lab VMs. Always allows lab access (credentials are infrastructure-as-code).
Ad hoc install blockerpromptBlocks winget install, choco install, etc. on lab VMs. Forces software installation through DSC fragments.
Ad hoc config blockerpromptBlocks ad hoc system changes (firewall rules, registry, services) on lab VMs. Forces DSC.

PostToolUse:

HookTypeWhat it does
Chocolatey enforcerpromptFlags raw URL downloads in DSC fragments. Suggests cChocoPackageInstaller instead.

That's 9 hooks total across two scopes. The global ones fix Claude Code's Windows problems. The project ones enforce my infrastructure-as-code rules so Claude can't take shortcuts that won't survive a lab rebuild.

Command vs Prompt Hooks

You'll notice two types in the table. Command hooks run a script that parses the JSON and makes a decision. Prompt hooks are wild — they send the tool input to Claude itself and ask it to evaluate whether the action is okay. It's Claude reviewing Claude's work. The prompt hooks are slower (they make an extra API call) but they can evaluate nuanced things that regex can't, like "is this command making an ad hoc configuration change on a lab VM?"

Results

Before hooks: three attempts per PowerShell operation, two failures, wasted tokens and time. Boring and expensive.

After hooks: zero failures. Claude Code gets clear feedback on what to do instead and does it right the first time. The glob redirect is even better — fd is genuinely faster than the built-in Glob tool, even when Glob doesn't time out.

Use hooks early and often.