{"slug": "why-claude-code-keeps-writing-shell-commands-that-fail-on-your-mac", "title": "Why Claude Code keeps writing shell commands that fail on your Mac", "summary": "A developer documented why Claude Code's Bash tool frequently produces failing shell commands on macOS, tracing the issue to two root causes: the tool executes commands through the user's login shell (zsh on modern Macs) despite being described as a bash executor, and macOS ships BSD versions of standard utilities while the model assumes GNU. The writeup also notes that adding GNU binaries to PATH via shell profiles has no effect because Claude Code captures a shell snapshot whose PATH comes from its own process environment, and points to the CLAUDE_CODE_SHELL setting in settings.json as a workaround.", "body_md": "Disclaimer: AI helped me write this since I am not that good with words :)\n\nClaude wrote this, mid-task, while refactoring something unrelated:\n\n```\nsed -i 's/old/new/' config.yml\n```\n\nIt's correct. It's the form you'll find in almost every StackOverflow answer, every blog post, every Dockerfile. 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.\n\nThat's the good case, the one that leaves evidence. The louder failures look like this:\n\n```\ndate: illegal time format\nstat: illegal option -- c\nsed: illegal option -- -\n(eval):1: no matches found: nope*.txt\n```\n\nIf 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.\n\nIt isn't. There are two concrete, fixable reasons, and neither one is visible from inside a session.\n\nClaude Code's Bash tool runs your **login shell**. On every Mac since Catalina, that's zsh.\n\nSo 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:\n\n```\nrm -f nope*.txt && echo \"FOLLOW-UP RAN\"\n```\n\nIn 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.\n\nmacOS ships BSD versions of the standard tools. Claude — like nearly every shell example ever written — assumes GNU.\n\n| What Claude writes | What macOS does | \n|---|---|\n| `sed -i 's/a/b/' file` | creates `file-e` , edits the original,**exits 0** | \n| `date -d yesterday +%F` | `date: illegal time format` | \n| `stat -c %s file` | `stat: illegal option -- c` | \n| `sed -E 's/\\s+/_/'` | doesn't match — BSD `sed` has no`\\s` | \n\n`sed -i` is the one worth internalising. It doesn't fail. BSD `sed -i` reads the *next argument* as a backup suffix, so `-i 's/a/b/'` means \"back up with suffix `s/a/b/`\"... except it doesn't even do that here; it takes the empty string that follows `-i`, edits in place, and drops an `-e` file beside it. An agent that checks the exit code sees success and moves on.\n\nThis is the part that cost me an afternoon, and it's the reason a five-minute fix turns into a project.\n\nThe obvious move is to `brew install coreutils gnu-sed` and put the GNU binaries first on `PATH` in `~/.zprofile` or `~/.bashrc`. **It has no effect whatsoever on the Bash tool.**\n\nClaude Code captures a *shell snapshot* when a session starts and replays it before every Bash call. The `export PATH` line in that snapshot is written from Claude Code's **own process environment** — not from the shell it captured in. The captured login shell is asked for its own PATH only on Windows.\n\nI verified this rather than assuming it. I put a guard in my profile that appended a directory and logged when it ran. In the capture shell the guard fired — `CLAUDECODE=1` was set, the profile executed, the directory was added. The resulting snapshot still came out without it.\n\nSo your profile isn't being ignored. It runs. Its PATH just never reaches the tool.\n\nTwo pieces of documented-but-obscure surface, one for each problem.\n\n`CLAUDE_CODE_SHELL`\nThis is read from the `env` block of `settings.json` **before the shell is chosen**, which is exactly why it works where a profile can't:\n\n```\n{\n  \"env\": {\n    \"CLAUDE_CODE_SHELL\": \"bash\",\n    \"SHELL\": \"bash\"\n  }\n}\n```\n\nA bare `bash` means the first bash on PATH — Homebrew's 5.x, not Apple's 3.2. Set `SHELL` alongside it, or the model's own environment summary keeps telling it the shell is zsh.\n\n`CLAUDE_ENV_FILE`\nA `SessionStart` hook may append shell statements to the file that `CLAUDE_ENV_FILE` names, and Claude Code sources that file **after** the snapshot, before every Bash call. So a PATH prepend written there wins:\n\n```\nexport PATH=\"/opt/homebrew/opt/coreutils/libexec/gnubin:${PATH}\"\n```\n\nHomebrew ships coreutils, findutils, gawk, gnu-sed, gnu-tar, gnu-which and grep with a `libexec/gnubin` directory containing the GNU builds under their **plain names** — `sed`, not `gsed`. Putting those directories on the agent's PATH gives it a GNU userland without symlinking anything or touching your own terminal.\n\n```\nbrew install bash coreutils findutils gawk gnu-sed gnu-tar gnu-which grep\n```\n\nMeasured 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:\n\n|  | Without | With | \n|---|---|---|\n| Shell | zsh 5.9 | bash 5.3.15 | \n| `sed --version` | `sed: illegal option -- -` | GNU sed 4.10 | \n| `date --version` | `date: illegal option -- -` | GNU coreutils 9.11 | \n| `awk --version` | error | GNU Awk 5.4.1 | \n| `date -d yesterday +%F` | error | `2026-09-08` | \n\nYour own terminal is untouched. `sed` still resolves to `/usr/bin/sed` in your shell, `gsed` and `gdate` still work the way you're used to, and nothing is copied or linked — a `brew install` or `brew uninstall` takes effect at the next session with no step in between.\n\nI put both halves in a repo, because I was tired of pasting them into every project:\n\n**[https://github.com/Baune8D/claude-code-macos](https://github.com/Baune8D/claude-code-macos)** (MIT)\n\nAs a plugin, once, for every repo you open:\n\n```\n/plugin marketplace add Baune8D/claude-code-macos\n/plugin install claude-code-macos@claude-code-macos\n```\n\nthen 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.\n\nOr 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.\n\nIt'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`).\n\n`grep` and `find`\nClaude Code ships its own. Both are installed into the session as **shell functions** that re-exec the `claude` binary as [ugrep](https://github.com/Genivia/ugrep) and [bfs](https://github.com/tavianator/bfs) — and a function beats a PATH lookup, so those two names stay Claude Code's however you arrange PATH.\n\nAn 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.\n\nThe 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.\n\nThis should ideally not need a workaround, so both halves are filed:\n\nUntil 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.", "url": "https://wpnews.pro/news/why-claude-code-keeps-writing-shell-commands-that-fail-on-your-mac", "canonical_source": "https://dev.to/baunegaard/why-claude-code-keeps-writing-shell-commands-that-fail-on-your-mac-4bne", "published_at": "2026-09-20 19:06:18+00:00", "updated_at": "2026-09-20 19:24:17.173899+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-products"], "entities": ["Claude Code", "Anthropic", "macOS", "zsh", "bash", "Homebrew", "BSD", "GNU coreutils"], "alternates": {"html": "https://wpnews.pro/news/why-claude-code-keeps-writing-shell-commands-that-fail-on-your-mac", "markdown": "https://wpnews.pro/news/why-claude-code-keeps-writing-shell-commands-that-fail-on-your-mac.md", "text": "https://wpnews.pro/news/why-claude-code-keeps-writing-shell-commands-that-fail-on-your-mac.txt", "jsonld": "https://wpnews.pro/news/why-claude-code-keeps-writing-shell-commands-that-fail-on-your-mac.jsonld"}}