Makes Claude Code's Bash tool behave like Linux on a Mac: bash 5 instead of zsh, and GNU
sed, date, stat, awk, tar, which, xargs and friends instead of the BSD
builds.
Two files do it, both under .claude/:
settings.json— anenvblock that switches the Bash tool to bash, and threeSessionStarthooks.hooks/put-gnu-tools-on-path.sh— puts Homebrew's GNU builds first on the PATH the Bash tool resolves.hooks/warn-shell-not-bash.shsays so if the Bash tool is not running bash at all, andhooks/warn-old-bash.shsays so if the bash that won is still Apple's 3.2.
Install it as a plugin, or copy the files in. Both are below.
Nothing outside a Claude Code session changes. Your own terminal keeps zsh and the macOS tools. That split is also the cost: the trade-off.
Two things are true of a Claude Code session on a stock Mac, and neither is visible until a command fails halfway through a task:
- The Bash tool is zsh. Claude Code picks the login shell, which is zsh on every
Mac since Catalina. Anything the model has learned about bash —
mapfile,${x^^}, word splitting, glob behaviour — is subtly off. - The userland is BSD.
sed -i -e 's/a/b/' fileedits the file, exits 0 and leaves a backup calledfile-ebeside it, because BSDsed -itakes the next argument as the backup suffix.date -d yesterdayis an error.sed -E 's/\s+/_/'does not match.stat -c %sis an error. The model writes the GNU form, because that is what almost all shell on the internet is, and then patches around the failure.
And the obvious fix does not do what you want. Your profile does reach the Bash tool,
but only through the terminal that launched claude, which inherited its PATH. So an
export there changes sed for everything in that terminal, not just for Claude Code.
Guarding it with CLAUDECODE doesn't help: that variable isn't set in your terminal,
and although Claude Code runs your profile again with CLAUDECODE=1 when it captures
its shell snapshot, it writes the snapshot's export PATH from its own inherited
environment and drops anything that run added. The snapshot is replayed before every
Bash call. Measured on 2.1.282, in both bash and zsh: the capture shell sourced the rc
file with CLAUDECODE=1 set, and neither a guarded nor an unguarded PATH prepend in it
reached the Bash tool.
CLAUDE_CODE_SHELL is read from the env block in settings.json before the shell is
chosen, so a bare bash there puts the Bash tool on the first bash on PATH — Homebrew's.
SHELL is set alongside it because the model's environment summary reads that variable,
and it would otherwise still say zsh.
"env": {
"CLAUDE_CODE_SHELL": "bash",
"SHELL": "bash"
}CLAUDE_ENV_FILE is the other half. 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 over the
snapshot's. The hook writes one line:
export PATH="/opt/homebrew/opt/coreutils/libexec/gnubin:...:${PATH}"Only the gnubin directories of formulas that are actually installed go on PATH, in a
fixed order, and only when a lookup does not already reach them first: one that sits
behind /usr/bin, behind another directory with its own sed, or behind an empty or
relative PATH entry is prepended.
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 is not
there fails quietly: sed stays BSD and nothing says so. The hook detects the prefix
instead of hardcoding it, from HOMEBREW_PREFIX when set and otherwise by probing
/opt/homebrew and then /usr/local. It probes the filesystem rather than asking brew
because a hook launched from the desktop app or an IDE does not necessarily have brew
on PATH either.
On a Mac the hook also tells the agent, once, at session start: "This session's date,
stat, xargs, awk, sed, tar and which are the GNU builds, not macOS." Putting the tools on
PATH is not enough on its own. Claude Code's environment block says Platform: darwin,
and since 2.1.260 its auto-mode and bypass-permissions prompts warn about "sed/awk flags
that differ between GNU and BSD/macOS". With only that in context, the model reads darwin
as BSD and writes sed -i '' …, which GNU sed rejects. find and grep are left out of
the line, because Claude Code answers those two names itself (below).
When it cannot deliver — a formula missing, no Homebrew, or a Claude Code too old to
supply CLAUDE_ENV_FILE — the agent's line also names what stayed macOS ("This session's
awk is the macOS build, not GNU."), and you get the fix ("GNU tools missing. Fix: brew
install gawk"). Off a Mac it says nothing.
Either way, first:
brew install bash coreutils findutils gawk gnu-sed gnu-tar gnu-which grepHomebrew's g-prefixed names (gsed, gdate) are untouched and your terminal still
resolves sed to /usr/bin/sed. Nothing here reaches past the Bash tool.
Once, for every repository you open:
/plugin marketplace add Baune8D/claude-code-macos
/plugin install claude-code-macos@claude-code-macos
Then add the env block to ~/.claude/settings.json by hand:
"env": {
"CLAUDE_CODE_SHELL": "bash",
"SHELL": "bash"
}A plugin cannot do that part. A plugin manifest carries hooks, commands, agents and
MCP servers; it has no env block, and CLAUDE_CODE_SHELL is read before the shell is
chosen, so no hook can write it either. The plugin delivers the PATH half of the fix;
the shell stays zsh until that block exists.
Which is what warn-shell-not-bash.sh is for. It reads CLAUDE_CODE_SHELL — measured
on 2.1.278, an env block reaches a SessionStart hook's own environment — falls back to
SHELL for a Mac whose login shell is already bash, and says one line to each audience
when neither is bash. With the block in place it never speaks.
Copy .claude/settings.json and .claude/hooks/ into your repo, or merge the env and
hooks.SessionStart blocks into a settings.json you already have. The hook commands
use $CLAUDE_PROJECT_DIR, which Claude Code sets for every hook, so they work from any
checkout location. This is the whole fix in one step, and it travels with the repository
to everyone who clones it.
That includes the people who don't use a Mac. The hooks are inert off a Mac
(below), but a settings.json cannot make its env block depend on the OS.
On Linux the block is harmless: the Bash tool becomes bash for someone whose login shell
is zsh or fish, and that's all. On Windows it is a risk. Claude Code accepts a
CLAUDE_CODE_SHELL that contains bash whenever bash --version succeeds, and it
resolves a bare bash through PATH, where C:\Windows\System32\bash.exe, the WSL
launcher, usually comes before Git Bash. That way the Bash tool would end up in WSL. This
comes from reading the 2.1.296 binary and has not been tested on Windows. So copy the
files in when the whole team is on Macs. For a team on mixed OSes, commit only the hooks
block, and let each Mac user add the env block to their own ~/.claude/settings.json or
to the gitignored .claude/settings.local.json. warn-shell-not-bash.sh tells them if
they haven't.
Both at once is harmless, not clever: each copy of the hook reads its own PATH, which is Claude Code's and carries neither prepend, so both write one. The Bash tool ends up with the gnubin directories twice on PATH, resolving to the same builds.
Ask Claude to run this in the Bash tool:
echo "$BASH_VERSION"; sed --version | head -1; date -d yesterday +%FMeasured on Claude Code 2.1.266, macOS 26.6, in a session started with a launchd-like environment (no inherited PATH), from a directory with and without these files:
| 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 |
error | GNU Awk 5.4.1 |
date -d yesterday +%F |
error | 2026-09-08 |
Claude Code ships its own grep and find. The session snapshot defines both 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 even with the GNU builds first on PATH. This hook
does not remove them, on purpose.
An earlier version did, and it was taken out after measuring. Across two dozen common
idioms — -rn, --include, -oP lookahead, \b, \| in basic regex, -A, -F,
-c, -printf, -regex, -mmin, -exec {} + — the two engines never disagreed on
syntax, so the model's GNU habits work as they are. Every difference came from ugrep's
default flags: a bare grep -r honours .gitignore, skips binaries silently and prints
paths without a ./ prefix. For an agent searching a repository, not walking
node_modules is the better default. The GNU grep and findutils formulas are still
worth installing: scripts, an explicit command grep, and the wrapper's own fall-through
for -z and --null all resolve through PATH and get the GNU builds instead of the
macOS ones.
CLAUDE_ENV_FILE applies to the Bash tool only. Hooks and stdio MCP servers are spawned
from Claude Code's own environment and see none of this. Measured on 2.1.263: a variable
written to the env file was set in the Bash tool and unset in a PostToolUse hook.
All three hooks check uname -s first and exit silently unless it says Darwin: no line
in CLAUDE_ENV_FILE, nothing for the agent, nothing for you.
- Linux: the plain names are already GNU, and
Platform: linuxtells the model so. Even with Linuxbrew's formulas installed, nothing is prepended. A distribution's bash 4.4 is not reported either, becausebrew install bashis not how you fix it. - Windows: Claude Code runs a hook's command through Git Bash, with Git Bash's own
directory first on PATH, so
bash "$CLAUDE_PROJECT_DIR/…"works there anduname -ssaysMINGW64_NT-…. If Git Bash is not installed, Claude Code cannot run the hooks at all and reports an error for each one at session start. The same machine has no Bash tool either, and nothing in a hook's config can limit it to one OS.
The env block is the one part that does not switch itself off.
Above is what that means for a repository you share.
Leaving your terminal alone is also what this costs. A Mac set up this way has two userlands: bash and GNU in the Bash tool, zsh and macOS in your terminal. Two things follow from that, and neither has a clean fix.
A script written for macOS fails when the agent runs it. A script the Bash tool
starts inherits its PATH, whatever the shebang says: a #!/usr/bin/env bash and a
#!/bin/zsh script both resolved sed to the gnubin. So sed -i '' 's/a/b/' file,
which works in your terminal, fails for the agent, and a script written with GNU flags
fails in your terminal. GNU sed 4.10 at least fails loudly: sed: can't read s/a/b/: No such file or directory, exit 2, and the file is left unchanged.
What exists are workarounds:
- Write forms both builds accept:
sed -i.bak 's/a/b/' file && rm file.bak, or redirect to a temporary file andmvit. That puts the cost on whoever writes the script. - Pin a script that is macOS-only by design to
/usr/bin/sed, or set PATH at its top. That helps only where someone thought to pin. - Put the gnubin directories first in your terminal as well. That is the only one that removes the split, and it changes your terminal, which is what this setup is built not to do.
The trade is made on purpose: an agent writes far more one-off commands than it runs scripts from the repository, and the one-off commands are where its GNU habits show.
~/.zshenv stops reaching the Bash tool. zsh reads .zshenv for every shell,
including the non-interactive zsh -c behind each Bash call, so with zsh as the Bash
tool its exports are set again on every call. That holds even after a launch that
strips the environment, as Claude Desktop's does. bash reads no startup file for
bash -c. From a terminal you will not notice, because the Bash tool inherits the
terminal's environment and the exports are already in it. From the desktop app, or
anything else launchd starts, they are gone.
Measured on 2.1.296 with claude -p under env -i and launchd's four-directory PATH
(a launchd-like environment, not Claude Desktop itself), with a throwaway ZDOTDIR
whose .zshenv logged each time it was sourced:
| Bash tool | .zshenv sourced per call |
Its variable | Its PATH prepend |
|---|---|---|---|
| zsh | yes | set | lost: the snapshot's export PATH runs after it |
| bash | no | unset | — |
BASH_ENV, bash's own startup file for non-interactive shells, did not bring it back.
Set in the same environment, its file was sourced by other bash processes in the
session, but the variable it exported was never set in the Bash tool. Why is not
established.
bash .claude/hooks/put-gnu-tools-on-path.test.sh
bash .claude/hooks/warn-shell-not-bash.test.sh
bash .claude/hooks/warn-old-bash.test.shEvery case runs against a throwaway Homebrew prefix under mktemp and a fake uname,
so the suites never read the formulas on the machine they run on or write to a real
CLAUDE_ENV_FILE.