Zsh CLI Tutorial — getopts, Colors, Progress Bars & Todo App #26
Video: Zsh CLI Tutorial — getopts, Colors, Progress Bars & Todo App #26 by Taught by Celeste AI - AI Coding Coach
Zsh Lesson 26: Build a CLI — getopts, Colors, Progress Bars
Combine
getoptsfor flags, ANSI escapes for colored output, a spinner for visible progress, a progress bar for long jobs, andread -pfor prompts. The recipe for terminal tools that feel polished.
This lesson is the integration of the previous 25. We build a CLI that takes flags, shows colored status, animates a spinner, and tracks progress.
Foundation: getopts + usage
#!/usr/bin/env zsh
set -euo pipefail
usage() {
cat <<EOF
Usage: ${ZSH_ARGZERO:t} [-v] [-n name] [-h] file
-v Verbose mode
-n name Set the name
-h Show this help
EOF
}
verbose=false
name="World"
while getopts "vn:h" opt; do
case $opt in
v) verbose=true ;;
n) name="$OPTARG" ;;
h) usage; exit 0 ;;
?) usage; exit 1 ;;
esac
done
shift $((OPTIND - 1))
if [[ $# -lt 1 ]]; then
echo "Error: file argument required" >&2
usage >&2
exit 1
fi
echo "Hello, $name!"
echo "Processing $1"
$verbose && echo "Verbose mode"
${ZSH_ARGZERO:t} is the script's basename (:t is "tail"). Heredoc for usage. Error to stderr; exit non-zero.
This is the skeleton for any non-trivial CLI script.
ANSI colors
success() { echo "\e[32m✓ $1\e[0m"; }
error() { echo "\e[31m✗ $1\e[0m"; }
warn() { echo "\e[33m⚠ $1\e[0m"; }
info() { echo "\e[34mℹ $1\e[0m"; }
success "Operation completed"
error "File not found"
warn "Disk space low"
info "Version 2.1.0"
ANSI escape codes:
\e[30mto\e[37m— foreground colors (black, red, green, yellow, blue, magenta, cyan, white).\e[40mto\e[47m— background colors.\e[90mto\e[97m— bright colors.\e[1m— bold.\e[2m— dim.\e[4m— underline.\e[0m— reset.
The echo \e interpretation is shell-specific — printf is more portable:
printf '\033[32m✓ %s\033[0m\n' "$1"
Detecting TTY (don't color piped output)
if [[ -t 1 ]]; then
GREEN='\033[32m'; RESET='\033[0m'
else
GREEN=''; RESET=''
fi
echo "${GREEN}OK${RESET}"
[[ -t 1 ]] is true if stdout is a terminal. Disable colors when piped — otherwise users see literal \e[32m in logs.
For complete cross-shell support, use tput:
GREEN=$(tput setaf 2 2>/dev/null || echo '')
RESET=$(tput sgr0 2>/dev/null || echo '')
A spinner
spinner() {
local msg="$1"
local chars='|/-\'
for i in {1..8}; do
printf "\r%s %s" "${chars:$((i%4)):1}" "$msg"
sleep 0.2
done
printf "\r✓ %s\n" "$msg"
}
spinner "Installing packages"
spinner "Building project"
\r returns the cursor to the start of the line (without a newline). Each iteration overwrites. Final line uses \n to commit.
For a real spinner during a long operation:
spin() {
local msg="$1"; shift
local chars='|/-\'; local i=0
"$@" & # run in background
local pid=$!
while kill -0 $pid 2>/dev/null; do
printf "\r%s %s" "${chars:$((i%4)):1}" "$msg"
sleep 0.1
i=$((i+1))
done
wait $pid; local status=$?
printf "\r%s %s\n" "$([[ $status -eq 0 ]] && echo "✓" || echo "✗")" "$msg"
return $status
}
spin "Compiling..." make
Runs "$@" in the background, animates while waiting, prints final status.
Progress bar
progress_bar() {
local pct=$1
local width=30
local filled=$((pct * width / 100))
local empty=$((width - filled))
local bar=""
for ((i=0; i<filled; i++)); do bar+="#"; done
for ((i=0; i<empty; i++)); do bar+="."; done
printf "\r[%s] %3d%%" "$bar" "$pct"
}
for p in 10 30 50 70 90 100; do
progress_bar $p
sleep 0.3
done
echo "" # newline at end
For real progress, calculate percent from work done:
total=100
for ((i=0; i<=total; i++)); do
do_one_unit_of_work
progress_bar $((i * 100 / total))
done
echo ""
Interactive prompts
read -p "Continue? (y/n) " ans
case "$ans" in
y|Y|yes) ;;
*) echo "Aborted"; exit 0 ;;
esac
read -sp "Password: " pass; echo
echo "Got password: $pass"
read -p "Hostname [localhost]: " host
host="${host:-localhost}"
-p prompt displays inline. -s for silent (passwords). Default values via ${var:-default}.
For richer prompts (multi-select, fuzzy search), use fzf — covered later.
Putting it all together: a todo CLI
#!/usr/bin/env zsh
set -euo pipefail
TODO_FILE="${TODO_FILE:-$HOME/.todo.txt}"
success() { printf "\033[32m✓ %s\033[0m\n" "$1"; }
error() { printf "\033[31m✗ %s\033[0m\n" "$1" >&2; }
usage() {
cat <<EOF
Usage: todo <command> [args]
add <text> Add a task
list List tasks
done <n> Mark task n as done
clear Remove all done tasks
EOF
}
cmd_add() {
echo "[ ] $*" >> "$TODO_FILE"
success "Added: $*"
}
cmd_list() {
if [[ ! -s "$TODO_FILE" ]]; then
echo "(no tasks)"
return
fi
awk '{
if ($1 == "[x]") printf "\033[2m%d. %s\033[0m\n", NR, $0
else printf "%d. %s\n", NR, $0
}' "$TODO_FILE"
}
cmd_done() {
local n="$1"
local tmp=$(mktemp)
awk -v n="$n" 'NR == n { sub(/^\[ \]/, "[x]") } { print }' "$TODO_FILE" > "$tmp"
mv "$tmp" "$TODO_FILE"
success "Marked task $n as done"
}
cmd_clear() {
local tmp=$(mktemp)
grep -v '^\[x\]' "$TODO_FILE" > "$tmp" || true
mv "$tmp" "$TODO_FILE"
success "Cleared completed tasks"
}
case "${1:-}" in
add) shift; cmd_add "$@" ;;
list) cmd_list ;;
done) cmd_done "$2" ;;
clear) cmd_clear ;;
""|-h|--help) usage ;;
*) error "Unknown command: $1"; usage; exit 1 ;;
esac
Save as ~/bin/todo, chmod +x. Now:
todo add "Buy milk"
todo add "Write blog"
todo list
todo done 1
todo clear
A real CLI. Color, error messages, dispatch table, file storage.
Configuration via env vars
COLOR="${COLOR:-auto}"
LOG_LEVEL="${LOG_LEVEL:-info}"
TIMEOUT="${TIMEOUT:-30}"
case "$COLOR" in
auto) [[ -t 1 ]] && use_color=1 || use_color=0 ;;
always) use_color=1 ;;
never) use_color=0 ;;
esac
Env vars are the standard way to configure CLIs. Provide defaults, allow override.
Long-vs-short options
getopts only supports short options (-v). For long (--verbose), use a manual loop:
verbose=false
help=false
while [[ $# -gt 0 ]]; do
case "$1" in
-v|--verbose) verbose=true; shift ;;
-h|--help) help=true; shift ;;
-n|--name) name="$2"; shift 2 ;;
--name=*) name="${1#*=}"; shift ;;
--) shift; break ;;
-*) echo "Unknown: $1" >&2; exit 1 ;;
*) break ;;
esac
done
More verbose but supports --long-flag and --flag=value.
A polished status line
status_line() {
local label="$1" value="$2" color="${3:-}"
printf " \033[2m%-15s\033[0m \033[%sm%s\033[0m\n" "$label" "$color" "$value"
}
status_line "Status:" "Healthy" "32"
status_line "Errors:" "12" "31"
status_line "Uptime:" "3d 4h" "33"
Aligned columns with colored values. Looks like a real CLI tool.
Common stumbles
Color codes in pipes. command | grep sees raw \e[32m. Detect TTY with [[ -t 1 ]].
echo -e non-portable. echo -e "\e[32mhi\e[0m" works in some shells but not all. Use printf or print (Zsh).
Spinner overlapping text. Always print enough whitespace to overwrite the previous line, or use \033[K (clear to end of line):
printf "\r\033[K%s %s" "$char" "$msg"
read -p with newline. read -p "Name: " name shows Name:; user types and Enter. Default echoes the newline. For silent, -s.
getopts only handles short. For long options, write a manual loop.
Forgot to shift. After getopts, $1 is still the first option. shift $((OPTIND-1)).
Backslash escapes in double quotes. "\n" inside double quotes is literal \n for echo (in some shells). Use printf '%s\n' for robust newlines.
What's next
Lesson 27: automation. Backup, deploy, and maintain with shell scripts.
Recap
Combine getopts (short flags) + heredoc usage + colored helpers (success/error/warn/info) + spinner + progress bar + read -p for interactive prompts. Detect TTY with [[ -t 1 ]] to skip colors when piped. Use ${ZSH_ARGZERO:t} for the script's basename. For polished output: align columns with printf "%-15s", use status colors, keep error messages on stderr.
Next lesson: automation scripts.