DEV Community

jidonglab
jidonglab

Posted on

Claude Code on zsh: 7,795 Commands Broke, 5,559 Still Exited 0

While I was counting Claude Code on zsh failures for this post, the agent failed in the exact way I was counting. It ran a grep over my blog folder and got this back:

(eval):1: no matches found: --include=*.md
Enter fullscreen mode Exit fullscreen mode

The grep never ran. The next command in the script did, so the call looked fine.

An agent with bash habits is typing into a shell that isn't bash. I parsed 32 days of my own session logs to see what that costs. 7,795 of 166,430 Bash commands hit an error that only zsh produces, and 5,559 of those still came back as a success.

TL;DR

  • 4.7% of my Claude Code Bash calls (7,795 of 166,430) hit a zsh-only expansion error. 71% of those exited 0, so an exit-code dashboard misses them.
  • Unquoted grep --include=*.ts accounted for 4,489 calls. zsh refuses the glob, so grep never starts.
  • Unquoted echo ====== accounted for 1,973 calls and 34.7% of all my non-zero exits. It also kills every command after it.
  • The agent sees the error and repeats it anyway: 61% of affected transcripts hit one again.
  • CLAUDE_CODE_SHELL=/bin/bash fixed both in my test run. It does nothing for BSD sed.

How did I measure this?

Claude Code writes every session to JSONL under ~/.claude/projects/. I streamed all of it, paired each tool_use with its tool_result, and deduplicated by tool call ID so resumed sessions didn't count twice.

The setup: macOS, /bin/zsh, Claude Code 2.1.319. The window was October 6 to November 6, across 6,417 transcripts with Bash calls. Most of those are subagents, which made 74% of all tool calls.

A call counted only if the output contained a zsh error line and the offending token appeared in the command itself. That filter dropped 307 no-match lines whose token wasn't in the command.

Pattern Calls Exited 0 What gets lost
grep --include=*.ts and friends 4,489 97% The grep
Other unmatched globs (*.png, app/[locale]/x, ? in URLs) 1,450 85% That command
echo ====== unquoted 1,973 3% That command and the rest of the script

A few calls hit two patterns, so the rows sum to slightly more than 7,795.

Why does Claude Code on zsh say "no matches found"?

zsh treats a glob that matches nothing as an error. bash passes the literal text through. That one difference explains the biggest row in the table.

grep -rn needle . --include=*.ts
Enter fullscreen mode Exit fullscreen mode

In bash, no file in the current directory is named --include=something.ts, so --include=*.ts reaches grep intact and works. In zsh, the shell tries to expand it, finds nothing, and stops before grep exists.

Plenty of examples online write that flag unquoted because on bash it rarely matters. My guess is that's where the habit comes from.

The exit code is what makes this nasty. On its own, the failed grep returns 1. Inside a pipeline, it doesn't:

grep -rn needle . --include=*.ts | head -3   # exit 0
Enter fullscreen mode Exit fullscreen mode

head succeeds on empty input, and the pipeline reports head. 4,480 of the 4,489 calls had a pipe in them, so 97% exited 0.

Why did echo ====== cause a third of my failures?

An unquoted echo ====== fails because zsh reads a word starting with = as "the path of this command." It looks for a program called =====, doesn't find one, and raises a hard error.

This is the one I did not see coming. Agents love to print separators between steps:

sed -n 1,80p config.ts; echo ======; grep -n "export" lib.ts
Enter fullscreen mode Exit fullscreen mode

Here is what each shell does with a smaller version:

# bash
before
===== two =====
after

# zsh
before
(eval):1: ==== not found
Enter fullscreen mode Exit fullscreen mode

after never runs. An unmatched glob only kills its own command, while this error kills the whole script, including later lines. Quoting it as echo "=====" fixes it.

1,910 of my 5,505 non-zero exits came from this. That's 34.7% of every Bash failure in the month, caused by decoration.

At least 865 of those calls had real commands queued behind the separator, including 110 git and 54 curl invocations. My parser is conservative there, since it skips separators inside loops. I didn't check which of those were writes, so I can't tell you what was left half done.

Does the agent learn from the error?

Mostly no. The error text is right there in the tool result, and the agent repeats the mistake anyway.

  • 3,125 of 6,417 transcripts (49%) hit a zsh error at least once.
  • 1,902 of those 3,125 (61%) hit one again in the same transcript.
  • After an echo ====== failure, only 185 of 1,970 next commands (9%) used a quoted separator.
  • My session prompt already says Shell: zsh. So telling the model the shell does not reliably stop it.

Rates by model, per 1,000 Bash calls, with the caveat that these models got different work, so read this as a description and not a ranking:

Model Bash calls Hits per 1,000
Claude Sonnet 5.5 110,923 53.9
Claude Opus 5.5 48,444 29.8
Claude Fable 5.1 2,392 92.8
Claude Haiku 5.5 413 2.4

Did a silent grep ever cause a wrong answer?

Not that I could find, and I went looking for it.

The scary case is a grep that returns nothing, followed by "this function has no callers." 1,086 --include calls returned only the error line and exited 0. In 8 of those, the agent's next move was prose and not another tool call. None of the 8 claimed something was absent.

The agent reads the line and moves on to another approach. Within the next 3 Bash calls, 2,379 of 4,372 exit-0 failures re-searched the same term. The other 1,993 didn't by my crude matching, but that count includes commands where I couldn't extract a search term, and other greps in the same script often worked.

So the honest cost is wasted round trips, not proven wrong answers. Across 7,426 affected calls with usable timestamps, the median gap to the next Bash call was 3.5 seconds, and the gaps summed to 14.3 hours. That is an upper bound spread over parallel subagents. It isn't 14 hours of my life.

Which repo took the most damage?

About 65% of all Bash calls and 71% of the zsh errors came from work on Preterview, an interview prep platform (full disclosure: I built it). It runs voice interviews with 3 interviewer styles, scores a portfolio or resume, and returns a written report. It's a Next.js TypeScript codebase, which is why the top --include targets were *.ts (2,251) and *.tsx (791).

Preterview — an interview session in progress

Next.js adds its own trap. In zsh, the brackets in app/[locale]/page.tsx are a character class, so an unquoted route path fails with the same error. That happened 52 times.

How do you fix Claude Code zsh errors?

Set CLAUDE_CODE_SHELL=/bin/bash before launching Claude Code. That was the one fix that worked in my testing. I ran one headless claude -p session per config, using this command in a folder holding one .ts file:

echo before; echo ===== two =====; grep -rn needle . --include=*.ts; echo after
Enter fullscreen mode Exit fullscreen mode
Config Result
Default zsh Stopped after before, exit 1
CLAUDE_CODE_SHELL=/bin/bash All 4 lines ran, grep found the match
ZDOTDIR pointing at a .zshrc with setopt no_nomatch no_equals Same failure as default

The setopt idea is correct in plain zsh. It didn't take through ZDOTDIR in my run, and I didn't test editing my real ~/.zshrc, so that route stays open.

What changes under bash: my PATH and tools (node, gh, vercel) were identical, but my 2 aliases disappeared, and macOS ships bash 3.2.57, which is old.

What doesn't change: BSD userland. 487 calls hit BSD sed errors, usually GNU sed -i syntax, and 255 hit head: illegal line count. Installing GNU tools is a separate fix.

The caveat on all of this: three single runs prove the mechanism, not the savings. I have no month of bash-mode logs to compare against yet.

You can check your own logs in a minute:

grep -rhoE '\(eval\):[0-9]+: (no matches found|=+[^ ]* not found)' \
  ~/.claude/projects --include='*.jsonl' | wc -l
Enter fullscreen mode Exit fullscreen mode

Note the quotes around *.jsonl. This raw count will run higher than mine, because it includes duplicates and logs that merely quote the error.

Why does Claude Code on zsh break commands that still exit 0?

Claude Code on zsh breaks commands because the model writes bash idioms and zsh handles two of them differently: it rejects unmatched globs like --include=*.ts, and it treats echo ====== as a failed command lookup. In my 32 days of logs that hit 7,795 of 166,430 Bash calls. 5,559 still exited 0, because a pipeline reports its last stage and later commands overwrite the status. The fix I verified is CLAUDE_CODE_SHELL=/bin/bash. If you stay on zsh, stop trusting exit codes alone and search your tool output for (eval): lines.


Written by the developer behind Preterview, an interview prep platform.

Top comments (0)