Skip to content

Instantly share code, notes, and snippets.

@nicholaswmin
Last active June 10, 2025 08:21
Show Gist options
  • Save nicholaswmin/0ed274a5865f5981672837fb4e77c605 to your computer and use it in GitHub Desktop.
Save nicholaswmin/0ed274a5865f5981672837fb4e77c605 to your computer and use it in GitHub Desktop.
guide for concise and UX nuanced zsh

zsh style guide

guidelines for concise zsh scripts

authors: nicholaswmin - The MIT License.

The keywords must/must not are to be interpreted as described in RFC-2119.
This formality is kept to a minimum because this document is meant to be LLM-parseable;
in other cases, your own unique context dictates their applicability.

Core Philosophy:

User-level:

  • Excellent UX is paramount; The interaction should be user-centric.
    There's no wiggle-room here so even 3 basic-level design principles
    are enough to render it above and beyond UX-wise.

    1. Don't expect a power user will figure/google errors.
      Expect the fatal error, put yourself in their shoes.
      What pointer would help? Just half a sentence is enough.

    2. Use ANSI coloring - deliberately but conservatively.

      Red for errors, green for success. Use, but:

      • tend to underuse than overuse.
      • ensure it respects NO_COLOR/FORCE_COLOR, non-interactive.

    Otherwise there's a risk of diminishing it's power as a UI signal,
    which is it's greatest asset.
    Simply put:

    "if everything is an error, nothing is an error".

  • This is a CLI tool so it must be scriptable by default, without exception.
    The assertion that CLI's are for power-users because they are swiftier is false.

    A GUI tool can be just as nuanced about it's keymappings as a CLI;
    However it's almost never amenable to scripting and when it is,
    it's often a far more haphazard, platform-specific implementation.

    So if you're building a CLI, ensure it's an actual production-grade CLI,
    by treating it's interfaces and scriptability DX as first-class citizens.

Contributor/You:

  • Brevity suppresses fluff to a minimum which highlights the main() orchestration;
    the actual domain-specific steps.
    Nobody is interested to read a childrens book in source-code.
    The point is to enhance scannability; quick skimming of key info.

    Example:

     # funcname = step name, v. important
     connect() {
       # ok - it connects, not important
     }
    
     main() {
        # specific dance between steps, **paramount**
        connect "https://foobar.com/ws"
        
        transmit "ping"
        blockfor "pong"
     }

    connect() is not ambiguous.

    It's unlikely you got this far as to be reading the source code,
    yet oblivious about a key-concept or unable to figure it out in 2".

    Some context/scope is always implicit, even in global scope,
    ``Use it to avoid repeating yourself unnecessarily.

    Simply writing a function to do a specific task is straightforward and trivial.
    Therefore by minimizing noise, the main workflow itself becomes clarified.
    This is not an argument for brevity - it's the authors stylistic choice.
    There's more than one perfectly valid reasons on why you should avoid terseness.

  • Conventions are preferred, unequivocally, over ad-hoc solutions,
    as long as they don't conflict with the above points.

  • no inline code comments; Conscise !== mangled.
    It means taking a bit of time to figure out precise terminology.
    Unless you're unavoidably doing something that would cause a mid-senior dev to
    stop and think for a bit, steer well-clear.

Table of Contents


Why zsh

zsh ships as the default shell, it's marginally better and almost as POSIX-compliant as bash.
The bash version that ships with macOS is archaic.

Each of the above, standalone, is more than enough reason.

Influences

Formatting

Create consistent visual boundaries and spacing patterns.

Numeric limits create predictable code layout. Visual spacing reveals
logical structure. Statement grouping reduces noise.

Character Limits

Set hard boundaries for different content types.

Consistent limits improve readability and maintainability.
So unless you're Anya-Taylor Joy, stick to:

  • Code: 70 chars soft, 80 chars hard
  • Code comments: 60 chars soft, 70 chars hard
  • Text: 65 chars soft, 85 chars hard
  • Help display: 75 chars max

✅ Within limits:

export_schema() { pg_dump -s "$1" > schema.sql; }  # 50 chars

❌ Too long:

export_database_schema_with_full_structure() { pg_dump --schema-only "$database_url" > schema.sql; }  # 110 chars

+ Fits standard terminal widths, easy to scan
Requires horizontal scrolling, hard to review

Visual Spacing

Use blank lines to create visual paragraphs within functions.

Separate logical sections: declarations, work, result.

Required spacing pattern:

  • Declarations at top with no leading blank line
  • Blank line after declarations
  • Blank lines between distinct logic blocks
  • Blank line before return statement

✅ Good spacing:

sync_db() {
  local name="$1" url="$2"

  can_conn "$url" || return 1
  
  export_schema "$url"
  import_schema "$name"
  
  return 0
}

❌ Bad spacing:

sync_db() {
  local name="$1" url="$2"
  can_conn "$url" || return 1
  export_schema "$url"
  import_schema "$name"
  return 0
}

+ Clear visual structure, scannable sections
Merged logic blocks, hard to distinguish phases

Exceptions:

  • One-liners don't need spacing
  • Early returns don't need spacing before them

Statement Grouping

Combine simple commands without arguments on single lines.

Reduces visual noise while maintaining logical grouping.

Criteria for grouping:

  • No arguments on any command
  • Logically related sequence
  • Simple operations only

✅ Good grouping:

cleanup_and_exit() {
  log "Cleaning up..."
  cleanup; reset; exit
}

❌ Keep separate:

missing_cmd() {
  log_error "Invalid command"    # has argument
  echo                          # no argument
  show_help                     # no argument  
  exit 1                        # has argument
}

+ Related no-arg commands grouped logically
Mixed commands with/without arguments

Functions

Functions are the primary building blocks of clear architecture.

Three distinct types serve different purposes. Naming must be natural
and consistent. Visual structure reveals intent.

Function Types

Three distinct function types with opposing philosophies:

Utility Functions

Single-purpose helpers and predicates.

  • Do exactly one thing
  • Hide implementation details
  • Focused and narrow scope
has_db() { psql -lqt | grep -qw "$1"; }
auth() { heroku auth:whoami &>/dev/null; }

Domain Functions

Business logic following Single Responsibility Principle.

  • Encapsulate specific domain operations
  • Return meaningful exit codes
  • Validate inputs immediately
export_schema() {
  local url="$1"
  [[ -z "$url" ]] && return 1
  pg_dump -s "$url" > schema.sql
}

Orchestration Functions

Workflow visibility - opposite of Single Responsibility.

  • Show entire workflow explicitly
  • Bind multiple steps together
  • Include conditionals and loops
  • Reveal decision points
sync_cmd() {
  check_deps; find_creds
  
  if has_db "$name"; then
    prompt_overwrite
    drop_db "$name"
  fi
  
  for table in users orders; do
    export_table "$table"
  done
  
  report_success
}

Why separation matters: Utility/domain functions suppress details,
orchestration functions reveal the complete workflow.

Naming Conventions

Use names that eliminate the need for comments.

Natural language trumps all other rules. Standard abbreviations
maintain consistency while preserving brevity.

Function length limit: 80 characters maximum.

  • Same as code line limits for consistency
  • Abbreviate using standard terms when needed
  • Natural language clarity always trumps arbitrary length limits
Type Pattern Example
Functions verb() or verb_noun() sync(), export_schema()
Constants UPPER_CASE DB_URL, TIMEOUT
Locals lowercase name, port

Standard abbreviations - always use:

  • databasedb
  • configurationconfig
  • authenticationauth
  • connectionconn
  • temporarytemp
  • informationinfo

Contextual naming within functions: Avoid redundant context in variable names. Function name already
provides context.

✅ Good contextual naming:

connect_db() {
  local name="$1" host="$2" port="${3:-5432}"
  # Clear from function name - no need for db_name, db_host
}

export_file() {
  local path="$1" format="$2"
  # Not file_path, file_format - redundant
}

❌ Bad redundant context:

connect_db() {
  local db_name="$1" db_host="$2" db_port="${3:-5432}"
  # Redundantly repeats db_ context
}

✅ Good naming:

readonly DB_URL="postgres://..."
export_schema() {
  local url="$1"
  pg_dump -s "$url" > schema.sql
}

❌ Bad naming:

database_url="postgres://..."     # should be DB_URL
export_database_schema() {        # too long, use export_schema
  CONNECTION_STRING="$1"          # local should be lowercase
}

+ Consistent abbreviations, clear hierarchy
Mixed conventions, verbose naming

Predicates

Functions returning boolean exit codes - 0=true, 1=false.

Prefer modal/auxiliary verb prefixes for immediate recognition.

Guideline: Prefer <modal-or-auxiliary-verb>-<predicate> format
unless a natural language alternative exists.

Common modal/auxiliary verb examples - non-exhaustive:

  • is_* - state/condition testing
  • has_* - possession/existence
  • does_* - behavior/action verification
  • can_* - capability/permission testing
  • was_* - past state checking
  • must_* - requirement checking

Preference: Non-speculative verbs testing definite states.

✅ Preferred format:

is_auth()      # modal verb prefix
has_db()       # auxiliary verb prefix
can_conn()     # modal verb prefix
does_exist()   # auxiliary verb prefix

✅ Natural language alternatives:

db_exists()    # more natural than has_db()
file_readable() # more natural than can_read_file()

❌ Avoid unclear patterns:

check_*()      # ambiguous return type
test_*()       # unclear purpose
validate_*()   # could return boolean or throw error

Why modal/auxiliary verbs: Immediate boolean recognition in
conditionals.
Why natural language override: Clarity trumps consistency when
obvious.

Function Patterns

Write functions that do exactly one thing and return meaningful
exit codes.

Validate immediately. Use early returns aggressively.

Core patterns:

  • Functions must declare locals with local
  • Functions must return 0 for success, 1+ for failure
  • Functions must validate parameters first
  • Use early returns for error cases

✅ Good pattern:

create_db() {
  local name="$1"
  [[ -z "$name" ]] && return 1
  createdb "$name" &>/dev/null
}

❌ Bad pattern:

create_database() {
  if [[ -n "$1" ]]; then           # nested conditions
    createdb "$1" &>/dev/null
    if [[ $? -eq 0 ]]; then
      echo "success"               # side effects
      return 0
    else
      return 1
    fi
  fi
  return 1
}

+ Single responsibility, early validation, clear exit codes
Nested logic, side effects, verbose structure

Validation

Validation functions perform input validation and return the
validated value.

Different from predicates - they process and return data, not boolean status.

Validation patterns:

  • Functions must return the validated value itself
  • Functions must strip leading and trailing whitespace from all input
  • Functions must throw errors for invalid data

✅ Good validation:

# validate email address and return cleaned value
validate_email() {
  local email="$(echo "$1" | tr -d '[:space:]')"  # strip whitespace
  [[ "$email" =~ ^[^@]+@[^@]+\.[^@]+$ ]] || {
    log_error "Invalid email format: $email"
    exit 1
  }
  echo "$email"  # return validated value
}

# usage
user_email=$(validate_email "$input")

❌ Bad validation:

validate_email() {
  [[ "$1" =~ ^[^@]+@[^@]+\.[^@]+$ ]]  # returns boolean, not value
}

+ Returns validated data, strips whitespace, clear errors
Returns boolean instead of processed value

Shell

Leverage ZSH-specific features for brevity and safety.

Modern patterns prevent entire classes of bugs. Avoid temporary
files. Handle errors immediately.

ZSH Idioms

Use modern ZSH patterns consistently over portable alternatives.

Choose ZSH-specific features when they offer benefits.

Pattern Why Example
[[ ]] Safer, more features [[ -f "$file" ]]
"$var" Prevents word splitting echo "$input"
${VAR:-default} Clean defaults ${PORT:-3000}

Scripts must use modern ZSH patterns:

  • must use [[ ]] instead of [ ]
  • must quote all variables with "$var"
  • must use ${VAR:-default} for defaults

✅ Modern ZSH:

local port="${PORT:-3000}" host="${HOST:-localhost}"
[[ -f "$config" ]] && source "$config"

❌ Old patterns:

local port=$PORT                    # no default, unquoted
[ -f $config ] && source $config   # old syntax, unsafe

+ Leverages ZSH safety features, concise defaults
Vulnerable to word splitting, no fallbacks

Philosophy: Portability only when ZSH offers no advantage.

Temp Files

Avoid temporary files entirely when possible.

When absolutely necessary, use mktemp for safe creation.

# only when unavoidable
temp_file=$(mktemp)
trap 'rm -f "$temp_file"' EXIT

Preference: Stream processing, pipes, parameter expansion
over temporary file creation.

Error Handling

Fail fast with actionable error messages.

Check prerequisites before starting work. Provide specific
solutions, not generic descriptions.

Scripts must use standard exit codes:

  • 0: Success
  • 1: General errors - missing deps, invalid args
  • 2: Misuse - invalid flags, wrong usage
  • 126: Permission denied
  • 127: Command not found

Core patterns:

  • must use || for immediate error handling
  • must include solution steps in error messages
  • must exit with appropriate error codes

✅ Good error handling:

command -v heroku &>/dev/null || {
  log_error "Heroku CLI not found" \
    "- Install: brew install heroku/brew/heroku" \
    "- Then: heroku login"
  exit 127
}

❌ Bad error handling:

if ! command -v heroku &>/dev/null; then
  echo "Error: heroku not found"      # no solution
  exit                                # unclear code
fi

+ Immediate handling, actionable solutions, clear exit
Generic message, no guidance, ambiguous exit

Arguments

Strict flag handling with comprehensive input sanitization.

Input sanitization requirements:

  • must strip leading/trailing whitespace from all input
  • must validate argument formats before processing
  • must reject invalid flag variations - --FORCE vs --force
  • must sanitize file paths and database names

✅ Strict flag handling:

parse_args() {
  while [[ $# -gt 0 ]]; do
    case $1 in
      --force | -f) force=true; shift ;;
      --timeout) timeout="$2"; shift 2 ;;
      --FORCE|--Force)  # invalid variations
        log_error "Invalid flag: $1" \
          "- Use --force (lowercase)"
        exit 2
        ;;
      *) log_error "Unknown flag: $1"; exit 2 ;;
    esac
  done
}

Why strict: Prevents silent errors and unexpected behavior.
Invalid flags should fail explicitly, not be normalized.

Fail Early

Check prerequisites at the beginning of main function.

Not after work has already been done. Respect user's time by failing fast.

Core principles:

  • must check all dependencies first thing in main function
  • must exit immediately when prerequisites fail
  • Don't waste user's time with avoidable late failures

Smart dependency checking: Don't check dependencies that might
not be needed due to branching logic.

✅ Good pattern for common dependencies:

#!/usr/bin/env zsh

# Check core dependencies needed for all paths
has_deps() {
  command -v curl &>/dev/null || {
    log_error "curl not found"
    return 1
  }
  
  return 0
}

# Main function performs checks first
main() {
  # Check core deps first thing
  has_deps || exit 1
  
  # Determine execution path
  if [[ "$mode" == "postgres" ]]; then
    # Check postgres deps only if this branch is taken
    command -v psql &>/dev/null || {
      log_error "PostgreSQL client not found" \
        "- Install with: brew install postgresql"
      exit 127
    }
    process_postgres
  else
    # No postgres check needed for this branch
    process_alternative
  fi
}

main "$@"

Exception logic: If code follows one path without branching and
requires an external tool, check it upfront - all users need it.
If tool appears on a branch, check when branch is taken - don't
penalize users who won't hit that branch.

Why this exception matters: Avoids forcing users to install
tools they might not need for their specific use case, while
still failing early enough to prevent wasted work.

Shell Options

Optional conservative error handling for complex scripts.

# Optional: Conservative error handling
set -e          # Exit on any command failure
set -u          # Error on unset variables  
set -o pipefail # Pipe fails if any command fails

# Note: These can conflict with explicit error handling patterns
# Choose based on script complexity and error handling needs

Recommendation: Use explicit error handling patterns shown
throughout this guide rather than relying on shell options.
Shell options can make debugging more difficult and conflict
with intentional error handling.

Logging

Two distinct types of logging serve different purposes.

Interactive logging for user engagement. Audit logging for
clean records. Environment detection ensures appropriate output.

Interactive Logging

User-focused messages with colors and formatting to enhance
readability and draw attention to important information.

Start every script with consistent format:

# At beginning of main operation
log "Starting backup..."

General principles:

  • Log less - only meaningful state changes and outcomes
  • Use structured uniform format throughout
  • Don't log messages that don't help the user

Don't log: Start → progress → action → outcome for
instantaneous operations. Just log the outcome.

✅ Good logging - meaningful outcomes:

log "Starting backup..."
log_spacer

log_info "config.json preexisted and was overwritten"
log_info "assets/ created"
log_info "backup.tar preexisted and was overwritten"
log_warn "cache/ timed out and was skipped"  
log_info "sync completed successfully"

log_spacer
log_done "Backup completed successfully"

❌ Bad logging - useless messages:

log "Checking config.json..."
log "config.json exists"
log "Attempting to overwrite config.json..."
log "config.json overwritten successfully" 
log "config.json step completed successfully"

+ Each line provides meaningful status information
Generic completion messages and redundant state tracking

Exception for long-running actions: Use spinners to provide
immediate feedback while avoiding log clutter.

Logging Conventions

Structured format without colors or animation for machine
consumption and log files.

  • Structured format: Consistent patterns across operations
  • Message formatting: No colors, no animation, clean text only

Purpose: Creating clean record of script actions for later
review, supporting automated processing and audit trails.

Environment Detection

Detect execution context to adjust output appropriately.

Disable colors for NO_COLOR, non-interactive, DUMB terminals.

Standard environment detection functions:

# Standard environment detection function
should_use_color() {
  # Disable if NO_COLOR set
  [[ -n "${NO_COLOR}" ]] && return 1
  
  # Enable if FORCE_COLOR set
  [[ -n "${FORCE_COLOR}" ]] && return 0
  
  # Enable only for interactive terminals
  [[ -t 1 ]] && [[ -t 2 ]]
}

should_use_spinner() {
  # Only for interactive TTY
  [[ -t 1 ]] && [[ "${TERM}" != "dumb" ]]
}

Why detection needed: Different execution contexts - interactive
vs automated, TTY vs pipe - require different output formatting.

Output Streams

All logging goes to stderr. Stdout reserved for pipeline data.

Scripts must route output correctly:

  • must send all log messages, errors, user feedback to stderr
  • must reserve stdout only for data required for script pipelines
  • Most scripts: no stdout output - side-effects only

When script produces output data:

  • must use tab-separated values TSV format for stdout
  • Keep format as simple as possible
  • No headers or decoration, just values

✅ Correct output routing:

# All logging to stderr
log_info "Processing database..." >&2
log_done "Sync complete" >&2

# Script output: simple tab-separated values (TSV)
list_databases() {
  # outputs: dbname   size    owner
  psql -c "SELECT datname, pg_size_pretty(pg_database_size(datname)), 
           rolname FROM pg_database d JOIN pg_roles r ON 
           d.datdba = r.oid ORDER BY 1" -t
}

✅ Typical script - no stdout:

sync_cmd() {
  log "Starting sync..." >&2
  # do work...
  log_done "Sync complete" >&2
  # exit 0 with no stdout output
}

Principle: Simple formats maximize compatibility with other tools
and make pipeline processing straightforward.

Interactive UX

Create professional user experience through consistent visual patterns.

Thoughtful color usage, elegant spinners, clear confirmations, and
well-formatted help create polished, user-friendly scripts.

User Dialogs

Pretty user dialogs with appropriate color use and thoughtful
input sanitization.

Ensure users understand script state and provide clear visual cues for
required actions.

Color Usage

Limit color use to contextually relevant labels only.

Keep color as strong signal for important information.

Color usage rules:

  • Red: errors only
  • Yellow: warnings and confirmations
  • Green: success messages
  • Cyan: informational messages
  • Dim: secondary information
  • Normal: primary content and headers

Color limitations:

  • Maximum 2 colors per message
  • Labels only, never full sentences
  • must disable for NO_COLOR, non-interactive, DUMB terminals

✅ Good color usage:

log_error "Database connection failed"     # Red for errors
log_warn "Database exists"                 # Yellow for warnings
log_done "Sync completed"                  # Green for success
log_info "Found 3 tables"                  # Cyan for info
log "Processing table users..."            # Dim for secondary

❌ Bad color usage:

log_error "The database connection has failed completely"  # Full sentence
log_warn "Warning: Database exists. Proceed carefully."    # Multiple colors

Spinners

Show elegant, animated spinners for operations >100ms.

Default 1-minute timeout with clear success/failure outcomes.

Spinner requirements:

  • Elegant and animated - show terminal responsiveness
  • Default message: "loading..." if none provided
  • Default timeout: 60 seconds - sufficient for most operations
  • Custom timeout: Only when operation usually takes >30s
  • Non-interactive mode: Print message only, no animation
  • must exit with non-zero code on timeout

Good spinner animations:

Dots spinner:

downloading schema.
downloading schema..
downloading schema...
downloading schema.

Braille spinner:

⠋ downloading schema
⠙ downloading schema
⠹ downloading schema
⠸ downloading schema

Standard usage - recommended:

# With custom message (default 60s timeout)
spinner "downloading schema..." download_operation

# With default message (default 60s timeout)
spinner "" long_operation  # shows "loading..."

Rare cases requiring custom timeout:

# Custom timeout from user-provided flag
spinner "exporting large database..." export_operation "$timeout"

# Where $timeout comes from: ./script.zsh sync --timeout 300

Complete spinner flow:

# While running (animated):
⠋ downloading schema...

# When complete (replaces above line):
✓ schema downloaded

# On timeout (replaces above line):
✗ schema download timed out after 60s

Principle: Custom timeouts for main script operations
where users control the -t | --timeout flag.
Auxiliary operations use a default 60s timeout.

Confirmations

Ask permission only for operations that could lose user work.

Use distinctive colored prompts so users know script is waiting.

Confirmation decision criteria:

  • Unsure? → Ask - default to safety
  • Any of these conditions are NO: Ask for confirmation
    • Is file non-user editable?
    • Is file always the same across its lifetime?
    • Would regenerating always produce the same result?
    • Would overwriting have no impact on running systems?

When confirmation is unnecessary:

  • ALL criteria above are YES

✅ No confirmation needed - build artifacts:

# Compiled/built assets from source code
build_assets() {
  [[ -d "dist/" ]] && log_info "Removing existing build"
  rm -rf dist/
  
  webpack build --output-path dist/
  log_info "Assets built to dist/"
}

# Downloaded dependencies 
install_deps() {
  [[ -d "node_modules/" ]] && log_info "Removing existing dependencies"
  rm -rf node_modules/
  
  npm install
  log_info "Dependencies installed"
}

✅ Confirmation required - ambiguous case:

# Database backup files - seems like it might be safe?
backup_database() {
  if [[ -f "$backup_file" ]]; then
    # This DOES need confirmation despite seeming "generated"
    prompt_overwrite "database backup" || exit 0
    rm "$backup_file"
  fi
  
  pg_dump "$database" > "$backup_file"
}

Why backup needs confirmation: Fails criteria - not always
same - data changes, regenerating doesn't produce same result

  • database evolves, might lose valuable backup.

Distinctive confirmation prompt:

prompt_overwrite() {
  local target="$1"
  
  # Non-interactive or force flag - proceed
  [[ ! -t 1 || "$FORCE" == "true" ]] && return 0
  
  log_spacer
  log_warn "Database $target exists"
  printf "$(col yellow "Overwrite? (y/n):")\n\n"
  printf "$(col dim "press Ctrl+C to cancel")\n\n"
  printf "> "
  
  while true; do
    read -r answer
    case "$answer" in
      y|Y|yes) return 0 ;;
      n|N|no|"") return 1 ;;
      *) 
        log_error "Invalid answer"
        printf "> "
        ;;
    esac
  done
}

Principle: Balance safety with usability. Protect user data
without creating friction for routine operations.

Error Workarounds

Provide actionable guidance when errors occur.

Help users achieve their goals when critical errors happen.

Core principles:

  • Put commonly-encountered problems first
  • Include most actionable workarounds at top
  • 1-3 workarounds ideal, but only if genuinely useful
  • Skip workarounds if none are actionable

log_error structure:

  • First argument: error message
  • Remaining arguments: actionable workarounds
  • Format to match expected output visually

✅ Single-line workarounds:

log_error "Cannot drop database" \
  "- Close all connections first" \
  "- Check spelling of database name" \
  "- Use 'psql -l' to list available databases"

✅ Multi-line workarounds:

log_error "Connection timeout" \
  "- Check network connectivity to database host
     and verify firewall settings" \
  "- Increase timeout with PGCONNECT_TIMEOUT=30"

✅ No workarounds when none are useful:

log_error "Database does not exist"
# No workarounds - error is self-explanatory

Why effective: Users get immediate path forward instead of
frustration. Focus on most common issues saves debugging time.

Help Display

Standard CLI tool help format.

Must have consistent structure and professional appearance:

  • Use color to structure information
  • Most text dimmed, normal for emphasis, color for even more emphasis
  • Nuanced whitespace: 1 empty newline before and after
  • Start every line with 1 space
  • Domain-specific items first, then generic items
  • Empty newlines separate domain vs generic sections
  • Consistent alignment of explainer comments
  • 75 character max width

Required elements:

  • Title - always
  • Usage section - always
  • Common examples - 1-2 examples
  • Arguments section - with explainer comments
  • Env.var section - with explainer comments

Optional elements:

  • Subtitle
  • Notes section
  • Copyright notice

Required structure:

show_help() {
  printf "\n"
  # Title (required)
  printf " $(col cyan "􀤄 backup")\n"
  
  # Subtitle (optional)
  printf "   $(col yellow "create and manage backups")\n"
  printf "\n"
  
  # Usage (required)
  printf " $(col default "usage:")\n"
  printf " $(col dim "  backup <command> [options]")\n"
  printf "\n"
  
  # Common examples (required, 1-2 examples)
  printf " $(col default "examples:")\n"
  printf " $(col dim "  backup create /data --dest s3://mybucket")\n"
  printf " $(col dim "  backup restore /data --from backup-2025.tar")\n"
  printf "\n"
  
  # Arguments (required, domain-specific first)
  printf " $(col default "arguments:")\n"
  printf " $(col dim "  --dest <location>            # backup destination")\n"
  printf " $(col dim "  --from <source>              # restore source")\n"
  printf " $(col dim "  --compress                   # enable compression")\n"
  printf "\n"
  printf " $(col dim "  -f, --force                  # skip confirmations")\n"
  printf " $(col dim "  -h, --help                   # show this help")\n"
  printf " $(col dim "  -v, --verbose                # verbose output")\n"
  printf "\n"
  
  # Env.vars (required, domain-specific first)  
  printf " $(col default "env. vars:")\n"
  printf " $(col dim "  BACKUP_DIR=\"/backups\"       # default backup directory")\n"
  printf " $(col dim "  COMPRESSION_LEVEL=6          # gzip compression level")\n"
  printf "\n"
  printf " $(col dim "  NO_COLOR=\"1\"                # disable colors")\n"
  printf " $(col dim "  FORCE_COLOR=\"1\"             # force colors")\n"
  printf "\n"
  
  # Notes (optional)
  printf " $(col default "notes:")\n"
  printf " $(col dim "- Supports local and S3 destinations")\n"
  printf "\n"
  
  # Copyright (optional)
  printf " $(col dim "© $(date +%Y) - username - MIT")\n"
  printf "\n"
}

Scriptable UX

Ensure scripts remain useful in automated contexts.

Convention-based overrides for all interactive elements. Clean
stdout streams. Unix-style output formatting for reliable
machine consumption.

Dialog Overrides

Every interactive dialog must have a flag override.

Scripts must be completely scriptable without human presence.

Three types of prompts:

1. Input Prompts

"Enter database name: "
"Enter timeout seconds: "

Override: Specific flags - --database myapp, --timeout 300

2. Selection Prompts

"Choose environment (dev/staging/prod): "
"Select backup type (full/incremental): "

Override: Specific flags - --environment prod, --backup-type full

3. Confirm Y/N Prompts

"Overwrite database? (y/n): "
"Continue with deployment? (y/n): "
"Delete old backups? (y/n): "

Override: --force or -f answers "yes" to ALL confirmations

Standard override patterns:

  • Input prompts: --<input-name> <value>
  • Selection prompts: --<choice-name> <value>
  • Confirmation prompts: --force or -f

Implementation pattern:

parse_args() {
  while [[ $# -gt 0 ]]; do
    case $1 in
      --database) database="$2"; shift 2 ;;
      --environment) environment="$2"; shift 2 ;;
      -f|--force) force=true; shift ;;
      *) log_error "Unknown flag: $1"; exit 1 ;;
    esac
  done
}

✅ Usage examples:

# With custom message and default timeout (60s)
./backup create /data --database myapp

# With custom timeout  
./backup create /data --database myapp --timeout 300

# Skip all confirmations
./backup create /data --database myapp --force

Principle: --force or -f must answer "yes" to every confirmation
that gets asked. No exceptions, no categories.

Output Formatting

When script produces data output, use simple formats.

Tab-separated values maximize compatibility with other tools.

TSV format for data output:

# Simple tab-separated values to stdout
list_backups() {
  find /backups -name "*.tar" -printf "%f\t%s\t%TY-%Tm-%Td\n"
}

# Usage in pipelines
./backup list | while IFS=$'\t' read -r name size date; do
  echo "Backup: $name, Size: $size, Date: $date"
done

Keep formats simple:

  • No headers or decoration
  • No colors in data output
  • Tab-separated for easy parsing
  • One record per line

Exit Codes

Scripts must use standard exit codes consistently.

Standard meanings:

  • 0: Success, operation completed normally
  • 1: General errors - missing deps, invalid args, operation failed
  • 2: Misuse - invalid flags, wrong usage patterns
  • 126: Permission denied - cannot execute
  • 127: Command not found - missing dependencies

✅ Consistent usage:

# Success
backup_files && exit 0

# General error
[[ -z "$database" ]] && {
  log_error "Database name required"
  exit 1
}

# Misuse
case $1 in
  --FORCE) log_error "Use --force (lowercase)"; exit 2 ;;
esac

# Command not found  
command -v pg_dump &>/dev/null || {
  log_error "PostgreSQL client not found"
  exit 127
}

Stdout Discipline

Protect stdout for data pipelines.

All user communication must go to stderr. Stdout must be reserved for
machine-readable data only.

Routing rules:

  • stderr: logs, errors, warnings, user feedback
  • stdout: data that other programs will consume
  • Most scripts: no stdout output - side-effects only

✅ Correct discipline:

sync_database() {
  log "Starting database sync..." >&2
  
  if ! can_connect "$url"; then
    log_error "Cannot connect to database" >&2
    exit 1
  fi
  
  log_done "Sync completed" >&2
  # No stdout - this is a side-effect operation
}

# Data-producing script
list_tables() {
  log "Listing tables..." >&2
  psql -t -c "SELECT tablename FROM pg_tables;" # stdout for pipeline
}

❌ Bad discipline:

sync_database() {
  echo "Starting database sync..."  # pollutes stdout
  
  if ! can_connect "$url"; then
    echo "Error: Cannot connect" # should go to stderr
    exit 1
  fi
  
  echo "Sync completed"  # pollutes stdout
}

Why important: Maintains clean data pipelines and allows
proper composition with other Unix tools.

Organization

Create predictable structure across all scripts.

File organization follows book-like progression. Comments appear
only in specific contexts. Terminology remains unified.

File Structure

Scripts must follow exact top-to-bottom organization.

#!/usr/bin/env zsh
# script-name - brief description
# usage: script-name <command> [options]

# Config section
readonly SCRIPT_NAME="backup"
readonly DEFAULT_TIMEOUT=60

# Utilities
col() { # color function }
log() { # logging functions }

# Dependencies  
has_deps() { # check requirements }

# Core logic
backup_files() { # domain function }
restore_files() { # domain function }

# Orchestration
backup_cmd() { 
  has_deps || exit 1
  backup_files
  log_done "Backup complete"
}

# Main dispatcher
main() {
  case "$1" in
    backup) backup_cmd ;;
    restore) restore_cmd ;;
    -h|--help) show_help ;;
    *) log_error "Invalid command"; show_help; exit 1 ;;
  esac
}

main "$@"

Why this order: Configuration → Utilities → Logic → Commands
→ Main creates predictable mental model for any script reader.

Comments

Two specific comment types only:

Function Headers - standard:

# create backup of specified directory
# usage: backup_dir <source> <dest>
backup_dir() {
  local src="$1" dest="$2"
  tar czf "$dest" "$src"
}

Telegraphic Comments - extraordinary circumstances:

sync_data() {
  # postgres requires --no-owner due to heroku permissions
  pg_dump --no-owner "$url" > schema.sql
  
  # retry needed - heroku cli randomly fails
  for attempt in 1 2 3; do
    import_schema && break
    sleep 2
  done
}

Telegraphic principle: Maximum information density, minimum
words. Like old telegrams where every word costs money.

Why limited: Function names should eliminate implementation
comments. Only comment the WHY behind unusual choices, not
the WHAT.

Terminology

Choose ONE term per concept and use everywhere in code.

User-facing messages use natural language for non-technical
audiences.

Unified code terminology:

  • databasedb
  • connectionconn
  • authenticationauth
  • configurationconfig
  • temporarytemp
  • informationinfo

✅ Consistent usage:

has_db() { psql -lqt | grep -qw "$1"; }
create_db() { 
  local db_name="$1"
  log_info "Created db: $db_name"  # code uses "db"
}

❌ Mixed terminology:

has_db() { psql -lqt | grep -qw "$1"; }
create_database() {                    # inconsistent
  local db_name="$1"
  log_info "Created database: $db_name"  # mixed terms
}

Exception: User messages use full words for clarity:

  • Code: "Invalid db name"
  • Messages: "Database connection failed"

Why unified: Eliminates cognitive overhead when reading code.
Developers don't have to mentally map between different terms
for the same concept.

Prompts

Anthropic Claude Sonnet 4.0

System prompt optimized for Claude Sonnet 4.0.
It incorporates Anthropic's latest prompt engineering best practices.

Use this as your system message when asking Claude to write
or review ZSH scripts:

<role>
You are a **Senior ZSH Shell Script Engineer** 
with expertise in creating maintainable, professional command-line tools. 
Your scripts are known for their clarity, reliability, 
and excellent user experience. 
You follow a comprehensive style guide that produces scripts suitable for 
both interactive use and automation.

Your scripts will be used in **production environments** where reliability and 
maintainability are critical. Poor shell scripts can cause *data loss*, *security 
vulnerabilities*, and *operational failures*, so precision and adherence to best 
practices is essential.
</role>

<instructions>
When writing ZSH scripts, follow these **explicit requirements**. Include as many 
relevant features and best practices as possible. Go beyond basic functionality 
to create fully-featured, production-ready implementations.

<formatting_rules>
- Use **70 character soft limit**, **80 character hard limit** for code lines
- Use **60 character soft limit**, **70 character hard limit** for comments
- Structure functions with: declarations at top, blank line after declarations, 
  blank lines between logic blocks, blank line before `return`
- Group simple no-argument commands on single lines when logically related
- **must** follow **file structure**: `config` → `utilities` → `core logic` → `orchestration` → `main dispatcher`
</formatting_rules>

<function_architecture>
- Implement **exactly three function types**:
  * **Utility functions**: single-purpose helpers (`has_db`, `is_auth`, `can_conn`)
  * **Domain functions**: business logic with validation and meaningful exit codes
  * **Orchestration functions**: show complete workflow explicitly
    with conditionals and loops
- Use `verb()` or `verb_noun()` naming with **standard abbreviations**: 
  `database`→`db`, `config`, `auth`, `conn`, `temp`, `info`
- For **predicates**, use modal/auxiliary verbs: 
  `is_*`, `has_*`, `can_*`, `does_*` or natural language alternatives
- Functions **must** declare all locals with `local`
- Functions **must** validate parameters first
- Functions **must** use early returns and meaningful exit codes
- Validation functions **must** strip whitespace and return validated value
</function_architecture>

<shell_practices>
- **must** use modern ZSH: `[[ ]]` syntax, quote all variables 
  `"$var"`, use `${VAR:-default}` for defaults
- **must** implement fail-fast error handling with `||` operator
- **must** include actionable error messages with solutions
- **must** use standard exit codes: `0`=success, `1`=general errors, 
  `2`=misuse, `127`=command not found
- **must** check core dependencies at start of `main` function
- **must** strip whitespace from all input and validate formats
- **must not** use temporary files unless absolutely necessary
</shell_practices>

<logging_and_output>
- **must** route ALL user communication to stderr using `>&2`
- **must** reserve `stdout` exclusively for pipeline data
- Use structured log levels: `log_error`, `log_warn`, `log_info`, `log_done` 
  with appropriate colors
- **must** detect environment and disable colors for `NO_COLOR`, non-interactive, `DUMB` terminals
- For data output, **must** use simple tab-separated values (TSV) format with no headers
</logging_and_output>

<interactive_experience>
- Show elegant animated spinners for operations `>100ms` 
  with **60-second default timeout**
- Ask for confirmation **ONLY** for operations that could lose user work
- Use colors sparingly: **red**=errors, **yellow**=warnings, 
  **green**=success, **cyan**=info, **dim**=secondary
- Provide standard help format: title, usage, examples, 
  arguments (domain-specific first), environment variables
- Include error messages with **1-3 actionable workarounds** when genuinely useful
- **must** exit with non-zero code on spinner timeout
</interactive_experience>

<scriptable_compatibility>
- **must** provide flag overrides for every interactive prompt
- **must** support `--force` flag to skip ALL confirmations
- **must** check `[[ -t 1 ]]` for non-interactive detection
- **must** respect `FORCE`, `NO_COLOR`, and other standard environment variables
- **must** ensure clean stdout for pipeline usage
- `--force` **must** answer "yes" to every confirmation without exceptions
</scriptable_compatibility>

<implementation_checklist>
- **must** start with `#!/usr/bin/env zsh`
- **must** declare `readonly` config variables at top
- **must** include dependency checking function
- **must** provide `--help` flag with standard format
- **must** use consistent terminology throughout code
- Add function headers *only* for non-obvious functions
- Use telegraphic comments *only* for extraordinary circumstances
- **must** follow exact file structure order
</implementation_checklist>
</instructions>

<examples>
Follow these patterns exactly:

<good_function_structure>
# Domain function with proper structure
export_schema() {
  local url="$1" output="${2:-schema.sql}"
  
  [[ -z "$url" ]] && {
    log_error "Database URL required" >&2
    return 1
  }
  
  pg_dump -s "$url" > "$output" || {
    log_error "Schema export failed" \
      "- Check database connectivity" \
      "- Verify credentials" >&2
    return 1
  }
  
  return 0
}
</good_function_structure>

<orchestration_example>
# Orchestration function showing complete workflow
sync_cmd() {
  local db_name="$1"
  
  log "Starting database sync..." >&2
  log_spacer >&2
  
  # Check dependencies first
  has_deps || exit 1
  
  # Get credentials
  get_creds || {
    log_error "Authentication failed" >&2
    exit 1
  }
  
  # Handle existing database
  if has_db "$db_name"; then
    prompt_overwrite "$db_name" || exit 0
    drop_db "$db_name"
  fi
  
  # Perform sync operations
  spinner "downloading schema..." download_schema
  spinner "importing data..." import_data
  spinner "updating permissions..." update_perms
  
  log_spacer >&2
  log_done "Database sync completed successfully" >&2
}
</orchestration_example>

<error_handling_pattern>
# Proper error handling with actionable guidance
command -v pg_dump &>/dev/null || {
  log_error "PostgreSQL client not found" \
    "- Install with: brew install postgresql" \
    "- Or use: apt-get install postgresql-client" >&2
  exit 127
}
</error_handling_pattern>

<environment_detection>
# Environment detection for colors and spinners
should_use_color() {
  [[ -n "${NO_COLOR}" ]] && return 1
  [[ -n "${FORCE_COLOR}" ]] && return 0
  [[ -t 1 ]] && [[ -t 2 ]]
}

should_use_spinner() {
  [[ -t 1 ]] && [[ "${TERM}" != "dumb" ]]
}
</environment_detection>

<file_structure_template>
#!/usr/bin/env zsh
# script-name - brief description
# usage: script-name <command> [options]

# Config section
readonly SCRIPT_NAME="backup"
readonly DEFAULT_TIMEOUT=60
readonly DB_URL="${DATABASE_URL}"

# Utilities
log() { echo "$(date +'%H:%M:%S') $*" >&2; }
has_db() { psql -lqt | grep -qw "$1"; }

# Dependencies  
has_deps() {
  command -v psql &>/dev/null || {
    log_error "PostgreSQL client required"
    return 1
  }
}

# Domain functions
create_backup() {
  local name="$1" dest="${2:-./backup.tar}"
  [[ -z "$name" ]] && return 1
  
  pg_dump "$name" | gzip > "$dest"
}

# Orchestration
backup_cmd() {
  has_deps || exit 1
  
  log "Starting backup process..."
  create_backup "$database" "$output"
  log_done "Backup completed"
}

# Main dispatcher
main() {
  case "$1" in
    backup) shift; backup_cmd "$@" ;;
    -h|--help) show_help ;;
    *) log_error "Invalid command"; show_help; exit 1 ;;
  esac
}

main "$@"
</file_structure_template>
</examples>

<professional_conduct>
When interacting with users about shell scripts:

- **Push back firmly** on conflicting requirements that violate best practices
- **Never hesitate** to ask clarifying questions
  when requirements are ambiguous or incomplete
- **Challenge approaches** that are inconsistent with the style guide, common idioms, 
  or established best practices
- Be **honest and direct**, *not obsequious* - even brutal reviews are welcome when 
  they serve code quality
- Do **NOT** provide negative feedback just because you were asked to review something
- State clearly when code is **good as-is** and requires no changes 
  if that reflects your genuine assessment
- **Prioritize correctness and maintainability** over user convenience when they conflict
- Acknowledge when user requirements *cannot be safely implemented* and propose alternatives

Your **professional integrity** and the quality of production systems take precedence 
over avoiding uncomfortable conversations. Users benefit more from *honest technical 
guidance* than from agreeable responses that lead to problematic code.
</professional_conduct>

<output_requirements>
When creating ZSH scripts, your response should be composed of **clean, 
executable  shell code** following the above guidelines. 
Include thoughtful details like proper error handling, user feedback, 
and edge case management. 
Apply these principles comprehensively: *hierarchy*, *consistency*, *safety*, 
and *user experience*.

Create **impressive demonstrations** showcasing professional shell scripting 
capabilities. Don't hold back - give it your all in terms of implementing 
best practices and creating production-ready code.
</output_requirements>

<thinking_guidance>
Before writing code, think through the **complete architecture**: what functions 
are needed, how they interact, what error cases exist, and how the user 
experience should flow. Consider the script's lifecycle from initial run 
through error conditions to successful completion.

Reflect on whether each function serves a *single clear purpose*, whether error 
messages provide *actionable guidance*, and whether the script would be usable 
in both **interactive and automated contexts**.
</thinking_guidance>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment