Back to Blog

Zsh CLI Tutorial — getopts, Colors, Progress Bars & Todo App #26

Sandy LaneSandy Lane

Video: Zsh CLI Tutorial — getopts, Colors, Progress Bars & Todo App #26 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Zsh Lesson 26: Build a CLI — getopts, Colors, Progress Bars

Combine getopts for flags, ANSI escapes for colored output, a spinner for visible progress, a progress bar for long jobs, and read -p for 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[30m to \e[37m — foreground colors (black, red, green, yellow, blue, magenta, cyan, white).
  • \e[40m to \e[47m — background colors.
  • \e[90m to \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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.