Terminal survival kit: what the shell does to your line
- Predict what the shell hands a program after quoting and expansion:
'…'vs"…",~,$VAR, globs, and zsh'sno matches found - Spot the zsh-vs-bash traps — an unquoted
$VARisn't split,pathis tied toPATH— and write the quoted or array form that works in both - Route output with
>,>>,2>&1and|, and explain why> file 2>&1and2>&1 > filediffer - Chain commands on exit codes with
&&,||andpipefail, and predict whena && b || crunsc - Explain how
PATHandexportdecide what a command and its children see, and why a script or a tool's shell can't see your terminal's setup
Read
The shell turns your line into words, then runs a program
FlagWhen you press Enter, the program you typed doesn't see your line. The shell (zsh, the macOS default since Catalina) reads it first, expands it, splits it into words, and then starts the program with those words as its arguments.
cd ~/projects/shop # ~ becomes /Users/you before cd ever runs
ls src/*.ts # the shell, not ls, turns *.ts into the matching file names
echo "Hello $USER" # "$USER" is replaced; echo receives one word: Hello ada
echo 'Hello $USER' # single quotes: nothing is replaced; echo receives Hello $USER
So ls never sees *.ts — it receives src/a.ts src/b.ts. That one fact explains most
terminal surprises.
Where you are, and how paths read
The shell has a current working directory (pwd prints it). A path is read from there
unless it starts with /:
| Path | Means |
|---|---|
/Users/ada/shop/package.json | absolute — from the root, works anywhere |
src/app.ts | relative — from the current directory |
. / .. | this directory / the parent (cd .. goes up one) |
~ | your home directory ($HOME), expanded by the shell |
The everyday verbs: pwd (where am I), cd (go), ls (list), cat (print a file),
less (page through one; q quits, /word searches), grep pattern file (lines that
match), find . -name '*.json' (files by name, recursively). The flags you'll reach for are
in the Common CLI flags cards; this unit is about what the shell does around them.
Quotes decide what the shell touches
- Single quotes
'…'— everything literal. No$VAR, no~, no*. - Double quotes
"…"—$VARand$(command)still expand, but the result stays one word, and*and~stay literal. - No quotes — every expansion happens:
~,$VAR, globs like*.ts.
That's why this fails:
% cd "~/Documents"
cd: no such file or directory: ~/Documents
The quotes stopped ~ from expanding, so cd looked for a folder literally named ~. Write
cd ~/Documents, or cd "$HOME/Documents" when the path has spaces.
zsh is not bash — three differences that bite
FlagMost shell snippets online are written for bash. Your Mac's terminal runs zsh (5.9 on current macOS). They agree on almost everything, and the differences show up as silent wrong behaviour, not errors.
1. zsh does not split an unquoted $VAR
FILES="a.txt b.txt"
for f in $FILES; do echo "[$f]"; done
| bash | zsh |
|---|---|
[a.txt] then [b.txt] — two iterations | [a.txt b.txt] — one iteration |
bash splits the unquoted value on spaces (the IFS characters). zsh doesn't — the zsh manual:
unquoted parameters "are not automatically split on whitespace unless the option SH_WORD_SPLIT is
set". So in zsh, rm $FILES tries to delete one file named a.txt b.txt:
% ls $FILES
ls: a.txt b.txt: No such file or directory
The zsh way to hold a list is an array, and an array works in bash too:
files=(a.txt b.txt)
for f in "${files[@]}"; do echo "[$f]"; done # portable: bash and zsh
for f in $files; do echo "[$f]"; done # zsh only: an unquoted array gives its elements
When the input really is a space-separated string (say, the output of a command), zsh's
${=FILES} splits it on purpose.
2. path is PATH
zsh keeps an array path tied to the scalar PATH: change one and the other changes. So a
loop variable named path overwrites your command search path:
% for path in a.txt b.txt; do echo $path; done
a.txt
b.txt
% ls
zsh: command not found: ls
After the loop, PATH is b.txt. A local path=... inside a function does the same thing for
the rest of that function. Use file, p or dir instead. (cdpath, fpath and manpath are
tied the same way.)
3. A glob that matches nothing is an error
% ls *.log
zsh: no matches found: *.log
bash would pass the literal *.log to ls and let it complain. zsh refuses to run the command
at all. The common victim is a URL: ? and * are glob characters, so an unquoted
open https://example.com/search?q=git dies with no matches found. Quote anything that
contains ?, * or [:
open 'https://example.com/search?q=git'
Splitting and globbing, precisely — and the habit that makes both shells agree
Flag"zsh doesn't word-split" is true for parameters only. The exact rules, checked in zsh 5.9
and the bash 3.2 that ships with macOS, with FILES="a.txt b.txt" and P="*.txt":
| Expansion | bash | zsh |
|---|---|---|
$FILES (unquoted variable) | split → 2 words | 1 word |
$P (variable holding a glob) | globbed → matching files | literal *.txt |
$(echo a.txt b.txt) (command substitution) | split → 2 words | split → 2 words |
X=$(…) then $X | split | 1 word (it's a variable again) |
"$FILES", "$(…)" (double-quoted) | 1 word | 1 word |
*.txt with no match | passes *.txt literally | error: no matches found |
So in zsh, for f in $(ls) iterates word by word (a file name with a space still splits in
two), but storing that output in a variable first
quietly turns it back into one word. And a pattern kept in a variable doesn't glob unless you
ask with ${~P} (or turn on GLOB_SUBST).
The habit: quote every expansion, use arrays for lists
Writing for both shells is simpler than memorising the table:
# A single value: always double-quote it. One word in both shells, spaces and all.
cp "$SOURCE" "$DEST/backup copy.txt"
# A list: an array, expanded as "${name[@]}" — each element one word, in both shells.
files=(a.txt "my notes.txt")
for f in "${files[@]}"; do wc -l "$f"; done
# Forwarding a script's arguments untouched:
exec node server.js "$@" # "$@" = every argument, each still one word
The unquoted forms are where the shells disagree; the quoted forms are where they agree. Bash
is the risky direction: an unquoted $FILE holding my notes.txt becomes two arguments,
my and notes.txt, and a value containing * expands to whatever files happen to match.
When you do want a string split
Sometimes the input really is a space- or line-separated string — a command's output, an env
var like EXTRA_ARGS. Split it deliberately, so the code says so:
# zsh
for f in ${=EXTRA_ARGS}; do …; done # split on IFS (spaces, tabs, newlines)
for line in ${(f)"$(git diff --name-only)"}; do …; done # split on newlines only
# bash
read -r -a args <<< "$EXTRA_ARGS" # into an array
Splitting on newlines rather than spaces matters for file names: git diff --name-only puts
one path per line, and a path can contain a space.
Quiz
cd "~/Documents" fails with cd: no such file or directory: ~/Documents, but cd ~/Documents works. Why?
In zsh, open https://example.com/search?q=git prints zsh: no matches found: https://example.com/search?q=git and nothing opens. What happened?
In zsh: FILES="old.log debug.log" then rm $FILES. What happens?
A zsh script runs for path in src/*.ts; do wc -l $path; done and then pnpm lint. Every pass of the loop prints command not found: wc, and pnpm lint fails with command not found: pnpm. Why?
You want everything pnpm build prints — normal output and errors — in build.log. Which line does that?
A CI step runs pnpm test | tee test.log. Three tests fail, yet the step is green. Why, and what fixes it?
setup.sh contains cd app and export NODE_ENV=production. You run ./setup.sh from ~/shop. Afterwards, in your terminal:
pnpm works in your terminal. A git pre-commit hook fails with pnpm: command not found — but only when you commit from a GUI git app opened from the Dock. Your export PATH=… line is in ~/.zshrc. Why?
Fill in the blank
Send both stdout and stderr of the build into one file.
Flagpnpm build > build.log Loop over a list of file names — one of them has a space — in a way that works in both zsh and bash.
Flagfiles=(a.txt "my notes.txt") for f in ""; do wc -l "$f" done
Make API_URL visible to the dev server you start next from this shell.
API_URL=http://localhost:4000
pnpm devPrint the message only when grep finds no match (it exits 1 then).
grep -q "TODO" notes.txt echo "no TODOs left"
In ~/.zprofile, put ~/.local/bin first in the command search path without losing the rest.
export PATH="$HOME/.local/bin:"
Predict the output
# zsh FILES="a.txt b.txt" for f in $FILES; do echo "[$f]"; done
# a.txt exists; nope does not ls a.txt nope 2>&1 > out.txt cat out.txt
false && echo A || echo B true && false || echo C true || echo D && echo E
% pwd /Users/ada/shop % cat goto-app.sh cd app export STAGE=dev % zsh goto-app.sh; pwd; echo "[$STAGE]" % source goto-app.sh; pwd; echo "[$STAGE]"