DEV Community

Cover image for Why Claude Code keeps writing shell commands that fail on your Mac
Morten Larsen
Morten Larsen

Posted on Edited on AI-assisted

Why Claude Code keeps writing shell commands that fail on your Mac

Claude wrote this, mid-task, while refactoring something unrelated:

sed -i -e 's/old/new/' config.yml
Enter fullscreen mode Exit fullscreen mode

It's correct. It's the form you'll find in half the StackOverflow answers, blog posts and Dockerfiles out there. It exited 0. And it left a file called config.yml-e sitting in my repo, because on macOS that command means something else entirely.

That's the good case, the one that leaves evidence. The louder failures look like this:

sed: 1: "config.yml": command c expects \ followed by text
date: illegal option -- d
stat: illegal option -- c
sed: illegal option -- -
(eval):1: no matches found: nope*.txt
Enter fullscreen mode Exit fullscreen mode

If you've used Claude Code on a Mac for more than a week, you've seen some of these. Claude writes a command, it fails, Claude apologises and writes a different one, and you lose thirty seconds and a bit of trust. It happens often enough to feel like the model being sloppy.

It isn't. There are two concrete, fixable reasons, and neither one is visible from inside a session.

Reason 1: the Bash tool is not bash

Claude Code's Bash tool runs your login shell. On every Mac since Catalina, that's zsh.

So the tool named Bash, whose description begins "Executes a bash command," is handing your commands to zsh. Everything the model knows about bash — mapfile, ${x^^}, word splitting, how an unmatched glob behaves — is subtly wrong. That last one is a good example of how quiet this gets:

rm -f nope*.txt && echo "FOLLOW-UP RAN"
Enter fullscreen mode Exit fullscreen mode

In bash, rm -f shrugs at the missing file and the follow-up runs. In zsh, nomatch is on by default, so the unmatched glob is a shell error, rm never runs, and the chain returns non-zero. Same line, opposite outcome, and the error you get back is prefixed (eval):1: — which tells you it went through eval, but never mentions zsh.

Reason 2: the userland is BSD

macOS ships BSD versions of the standard tools. Claude — like nearly every shell example ever written — assumes GNU.

What Claude writes What macOS does
sed -i -e 's/a/b/' file creates file-e, edits the original, exits 0
sed -i 's/a/b/' file sed: 1: "file": command c expects \ followed by text
date -d yesterday +%F date: illegal option -- d
stat -c %s file stat: illegal option -- c
sed -E 's/\s+/_/' doesn't match — BSD sed has no \s

sed -i is the one worth internalising. BSD sed -i reads the next argument as a backup suffix, always. Write -i -e 's/a/b/' and the suffix is -e: the script is read from the following argument, the file is edited in place, exits 0, and a copy named file-e appears beside it. That is literally where the -e in the filename comes from. Write plain -i 's/a/b/' and the suffix is s/a/b/, so the filename becomes the script and sed fails on its first letter. One form fails loudly, the other succeeds and litters. An agent that checks the exit code sees success on the second and moves on.

Why you can't fix this in your shell profile

This is the part that cost me an afternoon, and it's the reason a five-minute fix turns into a project.

The obvious move is to brew install coreutils gnu-sed and put the GNU binaries first on PATH in ~/.zprofile or ~/.bashrc. The Bash tool will pick them up. So will everything else in your terminal, and that's the problem: a profile can't give only Claude Code the GNU tools.

Your profile reaches the Bash tool one way only: through the terminal that launched claude, which ran the profile and passed its PATH on. An export there changes sed for every script, Makefile and muscle-memory command in that terminal, not just for the agent. Launch Claude Code from the desktop app or an IDE and there's no terminal in the chain at all.

The natural next step is to scope it with if [ -n "$CLAUDECODE" ]. That doesn't work either, and this is the non-obvious bit. Claude Code captures a shell snapshot when a session starts and replays it before every Bash call. It does run your profile to build that snapshot, with CLAUDECODE=1 set, so the guard fires. But the export PATH line in the snapshot is written from Claude Code's own inherited environment, not from the shell it just captured, so whatever that run added is dropped. The captured login shell is asked for its own PATH only on Windows. And in your terminal, the one place the export would stick, CLAUDECODE isn't set, so it never runs.

I verified this rather than assuming it. I put a probe in both ~/.bashrc and ~/.zshrc that logged CLAUDECODE, prepended one directory unconditionally and another only when CLAUDECODE was set. On Claude Code 2.1.282, in a bash session and a zsh session, the capture shell sourced the file with CLAUDECODE=1, and neither directory reached the Bash tool.

So your profile isn't being ignored. It runs. But only the PATH you launched claude with reaches the tool, never what the profile adds during the capture.

What actually works

Two pieces of documented-but-obscure surface, one for each problem.

The shell: CLAUDE_CODE_SHELL

First, get a bash worth pointing at:

brew install bash
Enter fullscreen mode Exit fullscreen mode

This step is easy to skip, and skipping it quietly undoes the rest. macOS does ship a /bin/bash, but it's 3.2, from 2007 — Apple froze it when bash moved to GPLv3 and has never shipped a newer one. It has no mapfile, no ${x^^}, no associative arrays. Switching the Bash tool to "bash" without installing one just moves you from a modern zsh to an 18-year-old bash, which is worse.

Then, CLAUDE_CODE_SHELL in the env block of settings.json. It's read before the shell is chosen, which is exactly why it works where a profile can't:

{
  "env": {
    "CLAUDE_CODE_SHELL": "bash",
    "SHELL": "bash"
  }
}
Enter fullscreen mode Exit fullscreen mode

A bare bash there means the first bash on PATH — Homebrew's 5.x now that you've installed it, not Apple's 3.2. Set SHELL alongside it, or the model's own environment summary keeps telling it the shell is zsh.

The tools: CLAUDE_ENV_FILE

Same shape — install first:

brew install coreutils findutils gawk gnu-sed gnu-tar gnu-which grep
Enter fullscreen mode Exit fullscreen mode

Each of those formulas ships a libexec/gnubin directory containing the GNU builds under their plain names — sed, not gsed. That directory is the thing you put on PATH; nothing is symlinked or copied, so a later brew install or brew uninstall takes effect at the next session with no step in between.

Getting it onto the agent's PATH is where CLAUDE_ENV_FILE comes in. A SessionStart hook may append shell statements to the file that variable names, and Claude Code sources that file after the snapshot, before every Bash call. So a PATH prepend written there wins:

export PATH="/opt/homebrew/opt/coreutils/libexec/gnubin:${PATH}"
Enter fullscreen mode Exit fullscreen mode

The hook writes one such line covering the gnubin directory of every formula that is actually installed.

That path is the Apple Silicon one. On an Intel Mac, or with an x86_64 Homebrew under Rosetta, the prefix is /usr/local, and a PATH entry pointing at a directory that isn't there fails quietly — sed stays BSD and nothing says so. The hook detects the prefix instead of hardcoding it, by probing the filesystem rather than asking brew: a hook launched from the desktop app or an IDE doesn't necessarily have brew on PATH either.

If you'd rather do both halves in one go:

brew install bash coreutils findutils gawk gnu-sed gnu-tar gnu-which grep
Enter fullscreen mode Exit fullscreen mode

The result

Measured on Claude Code 2.1.266, macOS 26.6, in a session started with no inherited PATH, from a directory with and without the config:

Without With
Shell zsh 5.9 bash 5.3.15
sed --version sed: illegal option -- - GNU sed 4.10
date --version date: illegal option -- - GNU coreutils 9.11
awk --version awk version 20200816 GNU Awk 5.4.1
date -d yesterday +%F error 2026-09-08

Your own terminal is untouched. sed still resolves to /usr/bin/sed in your shell, and gsed and gdate still work the way you're used to.

Packaged

I put both halves in a repo, because I was tired of pasting them into every project:

https://github.com/Baune8D/claude-code-macos (MIT)

As a plugin, once, for every repo you open:

/plugin marketplace add Baune8D/claude-code-macos
/plugin install claude-code-macos@claude-code-macos
Enter fullscreen mode Exit fullscreen mode

then add that env block to ~/.claude/settings.json by hand. A plugin manifest has no env block, and CLAUDE_CODE_SHELL is read before the shell is chosen so no hook can write it either — which means a plugin install alone gets you the GNU tools while leaving you on zsh. There's a hook that tells you when that's the state you're in, rather than letting the session run half-fixed.

Or copy .claude/settings.json and .claude/hooks/ into a repo, which is the whole fix in one step and travels to everyone who clones it.

It's silent when it succeeds. It speaks once, at session start, when it can't deliver: Homebrew missing, a formula not installed, the Bash tool not running bash, or the bash that won still being Apple's 3.2. The agent gets the fact ("This session's sed is the macOS build, not GNU."), you get the fix (brew install gnu-sed).

One deliberate omission: grep and find

Claude Code ships its own. Both are installed into the session as shell functions that re-exec the claude binary as ugrep and bfs — and a function beats a PATH lookup, so those two names stay Claude Code's however you arrange PATH.

An earlier version of my hook removed those functions. I took it back out after measuring: across a couple dozen common idioms (-rn, --include, -oP, \b, -A, -printf, -regex, -exec {} +) the engines never disagreed on syntax. Every difference was in ugrep's defaults — a bare grep -r honours .gitignore, skips binaries, and omits the ./ prefix. For an agent searching a repository, not walking node_modules is simply the better default.

The GNU grep and findutils formulas are still worth installing, because scripts, an explicit command grep, and the wrapper's own fall-through for -z/--null all resolve through PATH.

Upstream

This should ideally not need a workaround, so both halves are filed:

Until one of those lands, this is what I run. If you've been quietly assuming Claude is just bad at shell on your machine — it's the environment, and it takes about two minutes to fix.

Top comments (8)

Collapse
 
rulestack profile image
Rulestack •

Ran into your first cause from the other side: an echo of four equals signs inside the Bash tool came back as "=== not found", which is zsh's =command expansion firing, so the tool was plainly running zsh whatever its name says. The PATH snapshot section explains a second thing I'd been filing under randomness.

Collapse
 
baunegaard profile image
Morten Larsen •

The "====" one is a great find. That's zsh's "=command" expansion, and it's a perfect example of how invisible this is from inside a session. I'm honestly a bit baffled by how badly Claude behaves on a default MacBook setup, so I'm glad the post helped make sense of it.

Collapse
 
hamid_ahmadian_3570449f72 profile image
Hamid Ahmadian •

Great writeup, this matches exactly what I've hit. One thing worth adding for anyone copying the CLAUDE_ENV_FILE snippet verbatim: hardcoding /opt/homebrew/opt/coreutils/libexec/gnubin only works on Apple Silicon. Intel Macs still running Homebrew under Rosetta use /usr/local, so a portable version of that export should shell out to $(brew --prefix coreutils)/libexec/gnubin rather than hardcoding the prefix, otherwise the hook silently becomes a no-op on Intel machines and you're back to BSD sed with no error at all, which is worse than the loud failure. Also worth flagging: the same login-shell capture behavior bites direnv and asdf/mise shims too, since those also rely on the profile being sourced by the actual invoked shell rather than replayed from a snapshot, so if anyone's Bash tool calls silently ignore a project's .tool-versions, this is the same root cause.

Collapse
 
baunegaard profile image
Morten Larsen •

Hi @hamid_ahmadian_3570449f72 Thanks for reading and commenting.

Good catch on the snippet. The line in the post is just what the hook ends up writing on an Apple Silicon machine. I've added a note under the snippet.

The hook itself doesn't hardcode the prefix. It uses HOMEBREW_PREFIX when set and otherwise probes /opt/homebrew and then /usr/local, then skips any formula whose gnubin directory isn't there, so a missing formula turns into a hint at session start rather than nothing.

One thing to be aware of with $(brew --prefix coreutils): hooks run from Claude Code's own process environment, and when that's the desktop app or an IDE extension, brew often isn't on PATH at all. The substitution comes back empty and you're back to the quiet no-op. That's why the hook probes the filesystem instead of asking brew.

Agreed that the snapshot behaviour also bites asdf and mise shims when Claude Code is launched from something other than a terminal. I kept that out of the post since it's not a macOS default, but the same fix works: a SessionStart hook that appends the shims directory to CLAUDE_ENV_FILE.

Collapse
 
hamid_ahmadian_3570449f72 profile image
Hamid Ahmadian •

That HOMEBREW_PREFIX-then-probe order makes sense, and the brew-not-on-PATH point is a good catch too — I'd forgotten how often that's true for the desktop app/IDE-extension case specifically, since a terminal-launched session almost always has it.

On the asdf/mise-shims follow-up: since you're already writing a SessionStart hook to append the shims dir to CLAUDE_ENV_FILE, worth checking whether it needs to re-run per-session rather than being cached at install time — asdf's ".tool-versions" (and mise's local config) can point to a different shim target per-project, so a hook that only runs once at Claude Code install would go stale the moment someone cds into a project with a different pinned runtime version. Probing PWD/.tool-versions inside the hook itself, rather than baking in a fixed shims path, would keep it correct across projects without extra config.

Thread Thread
 
baunegaard profile image
Morten Larsen •

One correction to my own point: it's narrower than I made it sound. IDE extensions pass the full environment through, and the desktop app runs a login shell and keeps its PATH, so brew usually resolves there. What the desktop app drops is every other variable, HOMEBREW_PREFIX included anthropics/claude-code#92515. That's the case the probe fallback is really for. brew itself is only missing when Homebrew's setup isn't in a file the login shell reads, or the session starts with launchd's bare environment.

On the shims: SessionStart hooks run at the start of every session, not once at install, so nothing is cached there. And the shims path itself doesn't change per project. asdf and mise keep one shims directory, and each shim resolves .tool-versions from the current directory upward at the moment it runs. So putting that fixed directory on PATH already gives the right version after a cd. Reading $PWD/.tool-versions in the hook would actually pin the version of the directory the session started in. The case that does need care is mise activate in PATH mode, since its prompt hook never runs in the Bash tool, and there the answer is to use mise's shims directory instead.

Thread Thread
 
hamid_ahmadian_3570449f72 profile image
Hamid Ahmadian •

You're right, and I had it backwards. I was thinking of the hook as the place where the version gets pinned, but it isn't — the shim is. Since asdf/mise shims resolve .tool-versions upward from cwd at invocation time, the hook only needs to make the shims directory reachable once; re-deriving a path inside the hook would actually freeze it to wherever the session happened to start, which is strictly worse. Good catch on mise activate too — since its prompt hook doesn't fire inside the Bash tool's non-interactive shell, pointing PATH at mise's own shims dir sidesteps that entirely rather than trying to replicate the activation logic in a SessionStart hook. Appreciate you tracing the HOMEBREW_PREFIX/login-shell distinction back to the actual GitHub issue too — that's a much cleaner mental model than "sometimes brew is just missing."

Collapse
 
yahhi profile image
Valentina Koniukhova •

I counted before installing: 44 zsh "no matches found" errors, 6 BSD "illegal option" and 4 BSD sed errors across 21 of my Claude Code sessions. Same five commands before and after your fix, in fresh headless sessions: all five failed before, including one sed that exited 0 and left a stray f.txt-e behind; all five work after. Two things I'd predicted wrong: my API keys in ~/.zshenv survived the switch to bash, because the session inherits Claude Code's own environment; and what did break was my own habit of writing sed -i '' for macOS.

I wrote it up as a story with the exact steps, so anyone's agent can repeat the same before/after check on their own Mac: worklore.dev/s/2026-09-30-why-my-a...

One question: now that the agent gets the GNU versions of sed and date, but my terminal still has the macOS ones, how do you handle scripts in a repo that were written for macOS and are now also run by the agent?