Reference Cards
Reference Cards

Common CLI flags, and where macOS differs

Card · ~15 min
After this unit you can
  • Recall what each letter means in ls -la, rm -rf, grep -rniE, cp -a, mkdir -p, tail -F and ps aux
  • Guard rm -rf against an empty variable with ${VAR:?}, and put -- before an operand that starts with -
  • Find the process listening on a port with lsof -ti tcp:PORT -sTCP:LISTEN, and stop it with kill before reaching for kill -9
  • Spot the macOS (BSD) vs GNU differences that break scripts: sed -i '', date -v, cp -R src/, find without a path, xargs on empty input, readlink -f, timeout

Cards

ls -la — every entry, in the long format

Flag

-l is the long format; -a adds the dot-entries (.env, .git) that a plain ls hides.

$ ls -la
drwxr-xr-x@  5 ada  staff  160 Oct 17 10:42 .
drwxr-xr-x@  3 ada  staff   96 Oct 17 10:42 ..
-rw-r--r--@  1 ada  staff    7 Oct 17 10:42 .env
-rwxr-xr-x   1 ada  staff  214 Oct 17 10:42 deploy.sh

Columns: type and permissions (d = directory, then rwx for owner, group and others), link count, owner, group, size in bytes, last modified, name. On macOS a trailing @ on the permissions means the file has extended attributes, and + means an access control list.

Flags you'll combine with it: -A (dot-files, minus . and ..), -h (sizes with K/M/G units), -t (newest first) and -r (reverse the sort). ls -lahtr puts the newest file at the bottom, right above your prompt.

rm -rf — and the empty variable that aims it at your home directory

Flag

-r removes directories and everything under them; -f never prompts and stays quiet about missing files. Together: no confirmation and no undo — there's no trash can.

The classic accident is an empty variable:

PROJECT=""                     # an unset env var, a typo upstream…
rm -rf "$HOME/$PROJECT"        # → rm -rf /Users/ada/ — your whole home directory
rm -rf "$PROJECT"/*            # → rm -rf /Applications /bin /Users …
rm -rf "$HOME/${PROJECT:?}"    # the shell stops with an error; rm never runs

rm -rf "$DIR/" with an empty DIR becomes rm -rf /, which GNU and macOS rm both refuse: POSIX says rm must skip an operand that resolves to the root directory. Nothing guards the other two lines. ${VAR:?} aborts when the variable is empty or unset; set -u only catches unset ones.

grep -rn, -i, -E — search a tree, with line numbers

Flag
grep -rniE 'todo|fixme' src/
# src/app.ts:1:const a = 1; // TODO: rename
# src/lib/util.ts:1:// FixMe later
FlagMeans
-rrecurse into directories
-nprefix each match with its line number
-iignore case
-Eextended regex: |, +, ? and () work unescaped
-lprint only the names of files that match
-wmatch whole words only
-Ffixed string: . and * are literal characters
-vprint the lines that don't match

Without -E, 'todo|fixme' searches for the literal text todo|fixme and finds nothing. Keep the pattern in single quotes so the shell leaves |, * and $ alone.

cp -R vs cp -a — and the trailing slash macOS cares about

Flag
  • cp -R src dest copies a directory tree. In GNU cp, -r and -R are the same; on macOS use -R — its cp -r copies the files that symlinks point to instead of the links, and its man page discourages -r.
  • cp -a src dest is archive mode: recursive, symlinks kept as links, and modes, timestamps and (where permitted) ownership preserved. macOS defines it as -RpP; GNU as -dR --preserve=all. Reach for it for backups.

When backup/ already exists, a trailing slash on the source means different things:

cp -R src/ backup/   # macOS: copies src's *contents* into backup/
                     # GNU:   creates backup/src/
cp -R src backup/    # both:  creates backup/src/

macOS's man page documents the contents-only rule; GNU ignores the slash. A script written on one system can scatter files on the other.

mkdir -p — create the whole path, and don't fail if it's there

Flag

Plain mkdir creates one level, and errors if the directory exists or its parent doesn't. -p creates every missing parent and succeeds quietly when the directory already exists — so a script can run it every time.

mkdir a/b/c       # fails: parent a/b doesn't exist
mkdir -p a/b/c    # creates a, a/b and a/b/c
mkdir -p a/b/c    # already there: no error, exit status 0

That idempotence is why setup scripts use mkdir -p even for a single directory. Add -v to print each directory it actually creates.

tail -f and -F — watch a log as it grows

Flag
tail -n 100 -F logs/app.log
  • -n 100 starts with the last 100 lines (the default is 10).
  • -f keeps the file open and prints lines as they're appended. Ctrl-C stops it.
  • -F follows the file by name: when the log is rotated or recreated, tail reopens the new file, and it waits for a file that doesn't exist yet. Plain -f follows the open file descriptor, so after a rotation it keeps watching the renamed old file.

Prefer -F for anything a logger rotates. Both GNU and macOS tail support it. To watch for one thing, pipe it on: tail -F logs/app.log | grep ERROR.

ps aux — every process, with its owner and command line

Flag

BSD-style letters, no dash: a = other users' processes as well as yours, x = include processes with no terminal (servers, daemons), u = user-oriented columns: USER, PID, %CPU, %MEM, start time and the full COMMAND.

Filtering it with grep has a catch — the grep itself is a process whose command line matches:

ps aux | grep 'next dev'   # also lists "grep next dev"
pgrep -f 'next dev'        # just the PIDs of matching processes

-f makes pgrep match the whole command line instead of only the process name.

ps -aux, with a dash, is a different request: POSIX reads it as "processes with a terminal, plus those of a user named x". macOS fails with ps: No user named 'x'; on Linux, procps falls back to ps aux only when no user x exists.

lsof -i :PORT — who is using this port?

Flag

lsof -i :3000 lists every process with a socket on port 3000 — including clients connected to it, not just the server listening there:

$ lsof -nP -i :3000
COMMAND   PID USER   … NAME
node    66529 ada    … TCP 127.0.0.1:3000 (LISTEN)
node    66529 ada    … TCP 127.0.0.1:3000->127.0.0.1:58475 (ESTABLISHED)
node    66533 ada    … TCP 127.0.0.1:58475->127.0.0.1:3000 (ESTABLISHED)

PID 66533 is a client; it could just as well be your browser. So kill $(lsof -ti tcp:3000) can take it down along with the server. Ask for the listener only: lsof -ti tcp:3000 -sTCP:LISTEN.

-t prints bare PIDs (made for piping into kill), -sTCP:LISTEN keeps only listening sockets, and -n / -P skip host-name and port-name lookups, so it answers faster.

kill vs kill -9 — ask first, force second

Flag

kill PID sends SIGTERM (15): a request to stop. The process can catch it to finish in-flight work, flush logs and remove lock files. kill -9 PID sends SIGKILL, which can't be caught or ignored: the process stops wherever it is, with no cleanup.

kill 4242                                    # SIGTERM: ask
sleep 5
kill -0 4242 2>/dev/null && kill -9 4242     # still running? force it

kill -0 sends nothing; it only checks that the process exists and that you may signal it. A process killed with -9 shows exit status 137 (128 + 9) in the shell — a number worth recognising in CI logs. Ctrl-C in a terminal sends SIGINT (2), which most programs treat like SIGTERM.

chmod +x — make a script runnable

Flag

A new file isn't executable, so ./deploy.sh fails with "permission denied" (exit status 126). chmod +x adds the execute bit:

chmod +x deploy.sh   # -rw-r--r--  →  -rwxr-xr-x  (with the usual umask, 022)
./deploy.sh          # now runs

With no u, g, o or a in front, +x applies to everyone except the bits your umask masks — under umask 077 it adds only the owner's. Say who you mean when it matters: chmod u+x for the owner alone, or octal chmod 755 (rwx for you, r-x for group and others).

Give the script a first line such as #!/usr/bin/env bash, so it runs under the interpreter you meant. bash deploy.sh runs it without the execute bit at all.

-- — "no more options after this"

Flag

A program can't tell an option from a filename that starts with -, and quotes don't help: the shell strips them first. -- ends option parsing (POSIX utility guideline 10); everything after it is an operand.

rm -v                 # usage error: "-v" is read as a flag
rm -- -v              # removes the file named "-v"
grep -- -x notes.txt  # searches for the text "-x"

It matters most with globs. In a folder holding a file named -rf, rm * can expand to rm -rf keep notes.txt — and recursively delete the directory keep. Write rm -- * or rm ./*.

You'll meet it elsewhere too: git checkout -- app.ts (a path, not a branch) and npm run test -- --watch (a flag for the script, not for npm).

find . -name — quote the pattern, name the start

Flag
find . -name '*.log'             # every .log file under the current directory
find . -iname '*.log' -type f    # any case, regular files only
find . -name '*.log' -mtime -1   # modified in the last 24 hours

Quote the pattern. Unquoted, the shell expands *.log first: with a.log in the current folder, find . -name *.log becomes find . -name a.log and finds only files with exactly that name. (When nothing matches, zsh stops with "no matches found" instead.)

Give the starting directory. GNU find assumes . when you leave it out; on macOS, find -name '*.log' is a usage error.

Add -delete or -exec rm {} + only after running the same command without it and reading the list.

xargs — turn lines of input into arguments

Flag

xargs reads items from its input and runs a command with them as arguments — the bridge from find or grep -l to commands that don't read stdin.

find . -name '*.log' | xargs ls -l             # breaks on "my docs/d.log"
find . -name '*.log' -print0 | xargs -0 ls -l  # safe: items end with a NUL byte

By default xargs splits on spaces and newlines, so my docs/d.log arrives as two arguments, my and docs/d.log. -print0 and -0 separate items with a NUL byte instead, which no filename can contain. Also useful: -n 1 (one item per command) and -I {} (put the item where {} is).

Empty input differs: GNU xargs runs the command once anyway (-r stops that); macOS xargs doesn't run it, and accepts -r only for compatibility.

sed -i — same flag, different argument on macOS and Linux

Flag

-i edits a file in place, but the two seds disagree about what follows it:

GNU (Linux)macOS (BSD)
no backupsed -i 's/a/b/' fsed -i '' 's/a/b/' f
keep f.baksed -i.bak 's/a/b/' fsed -i.bak 's/a/b/' f

macOS sed requires a backup extension after -i (empty means none); GNU's is optional and must be attached. So each habit breaks on the other system:

sed -i 's/3000/3002/' app.env   # macOS: the script becomes the suffix → error
sed -i -e 's/x/y/' f            # macOS: works, but leaves a backup named f-e
sed -i '' 's/x/y/' f            # GNU: '' is read as the script, 's/x/y/' as a file

Portable: sed -i.bak 's/x/y/' f && rm f.bak.

date — relative dates and parsing differ on macOS

Flag

Formatting output is portable; arithmetic and parsing are not.

TaskGNU (Linux)macOS (BSD)
two days agodate -d '2 days ago' +%Fdate -v-2d +%F
from epoch secondsdate -d @0date -r 0
parse a datedate -d 2026-10-17 +%Adate -j -f %Y-%m-%d 2026-10-17 +%A

On macOS date -d fails with illegal option -- d; -j there means "don't try to set the clock", and fields -f doesn't parse come from the current time. Both agree on format strings:

date -u +%Y-%m-%dT%H:%M:%SZ   # 2026-10-17T14:50:53Z — ISO 8601 in UTC
date +%s                      # seconds since the Unix epoch

readlink -f, realpath, timeout — Linux habits that bite on macOS

Flag
  • readlink -f path resolves every symlink to an absolute path. macOS has had it only since 12.3 (earlier, its readlink accepted just -n), and the two disagree at the edge: GNU accepts a missing last component (readlink -f build/out.txt prints a path before out.txt exists), while macOS prints nothing and exits 1.
  • realpath does the same job and ships with current macOS; there it also fails for a path that doesn't exist.
  • timeout, GNU's "kill it after N seconds", isn't on macOS at all:
timeout 30 npm test   # Linux: stops the run after 30 seconds
                      # macOS: command not found

A script that has to run on both CI (usually Linux) and a Mac needs a run on each before you trust it.

Further reading