Claude Code's Bash tool runs zsh, not fish
I am a long-time user of the fish shell. And I love it.
And I use Claude Code in this shell.
At launch, Claude Code copies $SHELL into its system prompt, so the LLM saw
Shell: /opt/homebrew/bin/fish and wrote fish syntax. The Bash tool then choked
on set, ; and and for … end, every few sessions, and the LLM retried in
bash until something stuck. I asked it to check what it was actually running in:
$ ps -p $$ -o comm=; echo $ZSH_VERSION; [[ -o login ]] && echo login; [[ -o interactive ]] || echo not-interactive/bin/zsh5.9loginnot-interactive
A login, non-interactive zsh. That means ~/.zshenv and ~/.zprofile are read,
~/.zshrc is not. I had neither of the first two. Everything lived in
~/.config/fish/config.fish.
It had worked for months anyway, because Claude Code runs fish once at startup
to harvest PATH and the zsh child inherits it. Started from a fish terminal,
node and pnpm were there. An isolated login zsh showed what the tool would get
from anywhere else:
$ env -i HOME=$HOME zsh -l -c 'echo $PATH'/Users/caillou/.docker/bin:/bin:/usr/bin:/usr/ucb:/usr/local/bin
No brew, no asdf, no rg, no locale. Docker was there only because Docker
Desktop had appended itself to a ~/.zshrc I never opened.
The fix was a ~/.zprofile that mirrors the non-interactive parts of my fish
config:
# Read by login shells, including Claude Code's non-interactive Bash tool shell.# Mirrors the non-interactive parts of ~/.config/fish/config.fish.eval "$(/opt/homebrew/bin/brew shellenv)"export LC_ALL=en_US.UTF-8export LC_CTYPE=en_US.UTF-8export LANG=en_US.UTF-8export XDG_CONFIG_HOME="$HOME/.config"export PATH="./node_modules/.bin:$PATH:$HOME/.local/bin"# asdf must come after PATH is set. "$HOME/.asdf/asdf.sh"source "$HOME/.docker/init-zsh.sh" || true # Added by Docker Desktopif command -v wt >/dev/null 2>&1; then eval "$(command wt config shell init zsh)"; fi
~/.zshrc shrank to one line, so an interactive non-login zsh gets the same
environment without prepending PATH twice:
[[ -o login ]] || source ~/.zprofile
Same test, after:
$ env -i HOME=$HOME zsh -l -c 'command -v node pnpm rg'/Users/caillou/.asdf/shims/node/Users/caillou/.asdf/shims/pnpm/opt/homebrew/bin/rg
I also pinned the shell in ~/.claude/settings.json, so the picker does not
have to guess:
"env": { "CLAUDE_CODE_SHELL": "/bin/zsh"}
That setting changes the executable and nothing else. The prompt still says
Shell: fish, so the LLM still reaches for fish syntax. The part that stopped
it was ~/.claude/CLAUDE.md:
# ShellThe Bash tool runs zsh 5.9 (login, non-interactive), even though the system prompt reports `Shell: fish`. Write POSIX/zsh syntax, never fish (`set`, `; and`, `for … end` will fail).zsh gotchas:- A word starting with `=` is expanded as `=command`. Never `echo ===x===`; quote it.- Unquoted `$VAR` is not word-split. Use `${=VAR}`, an array, or list items explicitly.- Environment comes from `~/.zprofile` (brew, asdf, locale, `./node_modules/.bin`), not `~/.zshrc`.
The gotchas earned their place. Minutes after confirming it was in zsh, the LLM
ran echo ===x=== out of bash habit and got ===== not found.
Why not fish? The shell picker accepts $SHELL only if the path contains bash
or zsh. Anything else falls back to searching for zsh, then bash. That comes
from a decompiled bundle posted in
anthropics/claude-code#7490,
open since September 2025. Until that changes, fish users get zsh, and zsh needs
a ~/.zprofile.