"Command not found" on remote
SSH non-interactive shell doesn't source your interactive profile. Move PATH additions and env vars to ~/.profile or /etc/environment.
Your shell decides what Claude's Bash tool actually invokes; your terminal emulator decides what it looks like; both affect what's hard or easy. Five shells, five emulators, and the practical rules for keeping ~/.claude/settings.json working across all of them.
Pair with /settings/os-matrix — that page covers OS-level differences; this one zooms in on the shell sitting on top.
When Claude calls its Bash tool, the actual subprocess is your shell — not "bash" in the abstract. The command string Claude generates is interpreted by that shell's quoting rules, glob expansion, variable substitution, and aliases. Two different shells running the "same" command can produce different results, sometimes spectacularly so.
Three places this matters most:
"Bash(curl:*)" matches what Claude prints into the shell. PowerShell users get curl.exe there; bash users get curl. Same allowlist, different match.settings.json is shell-interpreted before it runs. 2>/dev/null doesn't work in PowerShell; $VAR doesn't work in fish; backticks behave differently across them.settings.json is fine cross-shell, but if you customise via shell prompt integration, that integration is per-shell."Bash(curl:*)" and "Bash(curl.exe:*)" both in the allowlist.
The modern Windows shell. PowerShell 7 (sometimes called pwsh) is a cross-platform Microsoft shell — it runs on Windows, macOS, and Linux. Don't confuse it with the older powershell.exe (5.1) that ships with Windows by default. Claude Code prefers 7 if it's installed.
Get-Process | Where-Object {$_.CPU -gt 100}.&& and || work the same as bash (since v7).Get-ChildItem, Set-Location, Remove-Item. Plus aliases — ls, cd, rm — that map to those cmdlets but with different semantics from POSIX counterparts.$var, $env:NAME for env vars, $null, $true.`. Newline in a string is `n.$ needs escaping with backtick.PowerShell 7 on macOS/Linux means you can theoretically use the same shell everywhere. In practice few people do — the rest of the Unix toolchain expects bash/zsh, and PowerShell on Linux is missing a lot of muscle memory.
"hooks": { "Stop": [{ "matcher": "", "hooks": [{ "type": "command", // Note: powershell -c, with `$null` for swallowing output "command": "powershell -c \"[console]::Beep(880,200); curl.exe -s -d 'done' ntfy.sh/walhus 2>`$null\"" }] }] }
2>/dev/null trap: /dev/null doesn't exist on Windows. Use 2>$null in PowerShell or 2>NUL from cmd. If your hook needs to swallow stderr and you want it portable, run the inner command via bash: bash -c "... 2>/dev/null" (Git Bash or WSL).
The default on most Linux distributions, the most widely-targeted shell for hook scripts, and the one Claude Code conceptually means when it generates "bash" commands. Available everywhere: Linux native, macOS via Homebrew (or the old 3.x system bash), Windows via Git Bash or WSL.
<<EOF ... EOF for multi-line input. Use single-quoted <<'EOF' if you don't want variable expansion.*, ?, [abc], plus extglob (shopt -s extglob) for richer patterns.The bash that ships with macOS is 3.2 (held back by Apple's licensing concerns). brew install bash gets you 5.x. Some script patterns (associative arrays, readarray) only work in 5+. If your hooks use modern bash, declare it: #!/usr/bin/env bash picks up the brew-installed one.
Claude Code on Windows usually finds Git Bash first. If you've installed WSL and want Claude to use that, set "shell" in settings.json explicitly.
The default shell on macOS since Catalina (10.15). Mostly bash-compatible, with some quality-of-life upgrades around globbing, completion, and prompt customisation. The reason every Mac user's .zshrc looks slightly different is the plugin manager (oh-my-zsh, prezto, znap).
#!/usr/bin/env bash runs fine; a script with no shebang and bash-isms may work in zsh, depending on which bash-isms.for x in $list may break.*(.) for regular files, *(/) for directories). Don't use them in scripts you want bash-portable.If a hook script uses any shell-specific syntax, give it an explicit shebang and run it as a file rather than inline. #!/usr/bin/env bash at the top of ~/.claude/hooks/notify.sh, then reference the file in settings.json.
"command": "$HOME/.claude/hooks/notify.sh"
Friendly Interactive Shell. Beautiful out-of-the-box autosuggestions, syntax highlighting, and a saner config language than bash. Not POSIX-compatible, which is the whole point and also the problem.
$VAR in unquoted lists. set var "a b"; echo $var prints "a b" as one argument, not two. Different from bash.&&/|| until recently. Fish 3+ supports them; older versions used ; and / ; or..bashrc-style aliases.set -Ux, available in every fish on the machine.Claude Code's Bash tool still calls bash for its hooks (not fish), even if your interactive shell is fish. So most of this is irrelevant from the agent's perspective. It only bites if your shell PATH or env setup differs between fish and bash — keep critical PATH entries in ~/.config/fish/conf.d/ and mirror them in ~/.bash_profile if Claude needs them.
~/.profile (which both fish and bash respect on most systems). Don't ask fish to be a server.
The classic Windows command processor. Older than PowerShell, slower than PowerShell, missing pipeline chains, weak quoting. Claude Code's Bash tool will use it if neither bash nor PowerShell is available, but you should never let that happen.
winget install Microsoft.PowerShell.If you must use cmd for a specific reason (legacy script, a batch file invoked by Task Scheduler), wrap the invocation: cmd /c "old-script.bat" from inside PowerShell.
The shell is the program running your commands; the terminal emulator is the program drawing the window. They're orthogonal — you can run zsh inside any emulator. But the emulator matters for rendering, performance, and a couple of integration points (clickable URLs, OSC sequences, GPU acceleration, ligatures).
| Emulator | OS | Notes |
|---|---|---|
| Windows Terminal | Windows | Default modern Microsoft terminal. Tabs, panes, themes, GPU rendering. Pair with PowerShell 7. |
| Terminal.app | macOS | Ships with macOS. Fine; not exciting. zsh runs here by default. |
| iTerm2 | macOS | The macOS power user's choice. Better split panes, profiles, shell integration with prompt marks. |
| Alacritty | cross-platform | GPU-accelerated, minimal, config in YAML. Fastest of the lot; no tabs (pair with tmux). |
| Wezterm | cross-platform | GPU-accelerated with tabs/panes/Lua config. The maximalist option. |
| Ghostty | cross-platform | Newer fast terminal, native feel on each OS. Worth trying. |
| gnome-terminal / konsole | Linux | Distro defaults. Solid. |
| tmux | cross-platform | Not an emulator — a terminal multiplexer. Run it inside any emulator for persistent sessions across SSH disconnects. |
Claude Code's TUI mostly renders identically across these. Where you might see differences:
The cross-shell ground truth: single quotes are literal; double quotes interpolate. Beyond that, the details vary.
| Goal | bash / zsh | PowerShell 7 | fish |
|---|---|---|---|
| Literal string | 'foo $bar' | 'foo $bar' | 'foo $bar' |
| Interpolated string | "foo $bar" | "foo $bar" | "foo $bar" |
| Escape $ inside "" | "\$literal" | "`$literal" | "\$literal" |
| Newline in string | $'\n' (ANSI-C) | backtick-n: "`n" | (echo -e '\n') |
| Command substitution | $(cmd) | $(cmd) (subexpression) | (cmd) |
| Discard stderr | 2>/dev/null | 2>$null | 2>/dev/null |
The hook command in settings.json is a JSON string that's parsed once and then shell-interpreted. That means every quote inside it needs JSON-escaping and shell-escaping. The combo is what trips people up most.
// run: powershell -c "[console]::Beep(880, 200)" "command": "powershell -c \"[console]::Beep(880, 200)\""
The \" in the JSON becomes a literal " when Claude reads it; PowerShell then sees the outer "..." as one argument.
~/.claude/hooks/ and reference the file path. Saves you from spending an evening counting backslashes.
The Bash(cmd:*) pattern in settings.json matches against the literal command string Claude generates. That string is shaped by what shell it expects to talk to. Cross-shell allowlists need to list both forms.
| Action | bash form | PowerShell form |
|---|---|---|
| HTTP GET | Bash(curl -s*) | Bash(curl.exe -s*) |
| SSH to host | Bash(ssh root@host:*) | Bash(ssh root@host:*) (same) |
| Remove a file | Bash(rm:*) | Bash(Remove-Item:*) |
| Pipe to file | Bash(*>:*) (broad) | n/a — PowerShell uses cmdlets |
| DNS lookup | Bash(dig:*) / Bash(host:*) | Bash(nslookup:*) / Bash(Resolve-DnsName:*) |
| Process list | Bash(ps:*) | Bash(Get-Process:*) |
The allowlist is OR-matched, so listing every form is harmless. Many WholeTech-style settings.json files just have both — curl and curl.exe, ls and Get-ChildItem, etc.
The hook command in settings.json runs through the shell Claude Code launches subprocesses with. Here's how the same hook looks across the four most common combinations.
"command": "printf '\\a'"
Or for a louder one on macOS:
"command": "afplay /System/Library/Sounds/Glass.aiff"
"command": "powershell -c \"[console]::Beep(880,200)\""
"command": "fish -c 'printf \\a'"
(Note: hooks aren't usually run through fish; Claude's bash subprocess fires. Use this only if you've explicitly pointed Claude at fish.)
Put the platform-dependent logic in a script, reference the script from settings.json:
// ~/.claude/hooks/notify.sh — single shebang, branch internally #!/usr/bin/env bash case "$(uname)" in Darwin) afplay /System/Library/Sounds/Glass.aiff & ;; Linux) paplay /usr/share/sounds/freedesktop/stereo/complete.oga & ;; MINGW*|MSYS*|CYGWIN*) powershell.exe -c "[console]::Beep(880,200)" & ;; esac curl -s -d "[$(hostname)] claude finished" "$CLAUDE_NOTIFY_URL" >/dev/null 2>&1
// settings.json — same on every machine "command": "$HOME/.claude/hooks/notify.sh"
Write hooks once; run them across the fleet. Four rules.
#!/usr/bin/env bash at the top of every script. Picks up the user's preferred bash (homebrew on Mac, system on Linux) and gives the rest of the file predictable semantics.case "$(uname)" in Darwin) … ;; Linux) … ;; MINGW*) … ;; esac. Don't sniff the shell; the shell is bash by virtue of the shebang."$VAR", not $VAR. Stops word-splitting surprises across bash/zsh.set -euo pipefail at the top. Catches the bug where a hook silently failed and Claude kept marching.#!/usr/bin/env bash
set -euo pipefail
case "$(uname)" in
Darwin)
OS=mac
;;
Linux)
OS=linux
;;
MINGW*|MSYS*|CYGWIN*)
OS=windows
;;
*)
echo "unknown OS: $(uname)" >&2
exit 1
;;
esac
# ... your logic here, branching on $OS ...
Each shell sources a different set of files at start-up. Knowing which ones makes the difference between "my PATH is correct" and "Claude can't find rclone."
| Shell | Login | Interactive | Non-interactive |
|---|---|---|---|
| bash | ~/.bash_profile → ~/.bashrc | ~/.bashrc | Often nothing — set vars in ~/.profile instead |
| zsh | ~/.zprofile → ~/.zshrc | ~/.zshrc | ~/.zshenv |
| fish | ~/.config/fish/conf.d/*.fish | Same | Same |
| PowerShell | $PROFILE (various) | $PROFILE | Not run |
~/.profile (sourced by both at login).~/.zshenv (sourced even non-interactively).~/.config/fish/conf.d/env.fish.$PROFILE.CurrentUserAllHosts./etc/environment on Linux; launchctl setenv on macOS; setx VAR val in Windows env.ssh host 'claude -p "..."' run a non-interactive shell. That doesn't source ~/.bashrc. If ANTHROPIC_API_KEY is only in ~/.bashrc, the remote Claude won't see it. Move env vars to ~/.profile or /etc/environment for SSH-targeted hosts.
SSH non-interactive shell doesn't source your interactive profile. Move PATH additions and env vars to ~/.profile or /etc/environment.
Windows line endings in a script run on Unix. '\r': command not found. Add * text eol=lf to .gitattributes; re-checkout.
Aliases live in your .zshrc; non-interactive shells (hooks, ssh) don't see them. Use real command names in hooks.
Your hook used cd dir && cmd. The cd ran in the hook subshell and didn't affect Claude's working directory. Use absolute paths.
You forgot set -e; the first command failed; the script kept going. Add strict mode at the top of every hook.
The hook is waiting on stdin or hung on a network call. Background long-running pieces (&) and add a timeout (timeout 5 curl …).