Claude on WholeTechnetwork
home/settings/terminals
Shells & emulators

Terminals & shells

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.

01 — Why

Your shell is the bottom layer

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:

The fleet rule: for portability, pick one default shell and stick with it on every machine. If you must run multiple — PowerShell on Windows, zsh on Mac, bash on Linux — write hook commands as shell-independent as possible, and use "Bash(curl:*)" and "Bash(curl.exe:*)" both in the allowlist.
02 — PowerShell 7

PowerShell 7

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.

What's distinctive

Cross-platform note

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 in PowerShell

"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\""
    }]
  }]
}
The 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).
03 — Bash

Bash

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.

What's distinctive

Bash on macOS — version matters

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.

Bash on Windows

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.

04 — Zsh

Zsh

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).

Bash vs zsh, for our purposes

The portability rule

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"
05 — Fish

Fish

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.

What changes

Claude Code + fish

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.

Practical: if you live in fish, write your hooks for bash, and store env vars that Claude needs in ~/.profile (which both fish and bash respect on most systems). Don't ask fish to be a server.
06 — cmd.exe

cmd.exe — don't

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.

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.

07 — Emulators

Terminal emulators

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).

EmulatorOSNotes
Windows TerminalWindowsDefault modern Microsoft terminal. Tabs, panes, themes, GPU rendering. Pair with PowerShell 7.
Terminal.appmacOSShips with macOS. Fine; not exciting. zsh runs here by default.
iTerm2macOSThe macOS power user's choice. Better split panes, profiles, shell integration with prompt marks.
Alacrittycross-platformGPU-accelerated, minimal, config in YAML. Fastest of the lot; no tabs (pair with tmux).
Weztermcross-platformGPU-accelerated with tabs/panes/Lua config. The maximalist option.
Ghosttycross-platformNewer fast terminal, native feel on each OS. Worth trying.
gnome-terminal / konsoleLinuxDistro defaults. Solid.
tmuxcross-platformNot an emulator — a terminal multiplexer. Run it inside any emulator for persistent sessions across SSH disconnects.

The Claude angle

Claude Code's TUI mostly renders identically across these. Where you might see differences:

08 — Quoting

Quoting & escaping

The cross-shell ground truth: single quotes are literal; double quotes interpolate. Beyond that, the details vary.

Goalbash / zshPowerShell 7fish
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 stderr2>/dev/null2>$null2>/dev/null

JSON-in-shell escaping (settings.json hooks)

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.

The "use a file" rule: the moment your hook command needs more than two levels of escaping, write it as a script in ~/.claude/hooks/ and reference the file path. Saves you from spending an evening counting backslashes.
09 — Permissions

Permission patterns by shell

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.

Actionbash formPowerShell form
HTTP GETBash(curl -s*)Bash(curl.exe -s*)
SSH to hostBash(ssh root@host:*)Bash(ssh root@host:*) (same)
Remove a fileBash(rm:*)Bash(Remove-Item:*)
Pipe to fileBash(*>:*) (broad)n/a — PowerShell uses cmdlets
DNS lookupBash(dig:*) / Bash(host:*)Bash(nslookup:*) / Bash(Resolve-DnsName:*)
Process listBash(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.

10 — Hooks

Hooks per shell

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.

The "beep when Claude stops" hook, four ways

bash / zsh (Linux, macOS)

"command": "printf '\\a'"

Or for a louder one on macOS:

"command": "afplay /System/Library/Sounds/Glass.aiff"

PowerShell 7 (Windows)

"command": "powershell -c \"[console]::Beep(880,200)\""

fish

"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.)

The portable hook

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"
11 — Portability

Cross-shell portable scripts

Write hooks once; run them across the fleet. Four rules.

  1. Use #!/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.
  2. Branch on OS, not on shell. case "$(uname)" in Darwin) … ;; Linux) … ;; MINGW*) … ;; esac. Don't sniff the shell; the shell is bash by virtue of the shebang.
  3. Quote every variable expansion. "$VAR", not $VAR. Stops word-splitting surprises across bash/zsh.
  4. Set strict mode. set -euo pipefail at the top. Catches the bug where a hook silently failed and Claude kept marching.

The portable script skeleton

#!/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 ...
12 — Profiles

Profile files per shell

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."

ShellLoginInteractiveNon-interactive
bash~/.bash_profile~/.bashrc~/.bashrcOften nothing — set vars in ~/.profile instead
zsh~/.zprofile~/.zshrc~/.zshrc~/.zshenv
fish~/.config/fish/conf.d/*.fishSameSame
PowerShell$PROFILE (various)$PROFILENot run

The "where do I put env vars Claude needs" answer

The non-interactive trap: SSH commands like 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.
13 — Pitfalls

What goes wrong

missing

"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.

quoting

"\r" in scripts

Windows line endings in a script run on Unix. '\r': command not found. Add * text eol=lf to .gitattributes; re-checkout.

alias

"ls works in my shell"

Aliases live in your .zshrc; non-interactive shells (hooks, ssh) don't see them. Use real command names in hooks.

cd

"It worked yesterday"

Your hook used cd dir && cmd. The cd ran in the hook subshell and didn't affect Claude's working directory. Use absolute paths.

history

Hook ran twice

You forgot set -e; the first command failed; the script kept going. Add strict mode at the top of every hook.

stuck

Hook blocks Claude

The hook is waiting on stdin or hung on a network call. Background long-running pieces (&) and add a timeout (timeout 5 curl …).

Live
◐ Theme