script-patterns
This skill should be used when implementing common shell script patterns such as argument parsing, logging, cleanup, temp files, script directory resolution, input validation, process management, and portability helpers. It provides ready-to-use patterns with CORRECT and WRONG examples.
From ccfg-shellnpx claudepluginhub jsamuelsen11/claude-config --plugin ccfg-shellThis skill uses the workspace's default tool permissions.
Shell Script Patterns
This skill provides production-ready patterns for common shell scripting tasks. Each pattern includes CORRECT and WRONG examples to guide implementation.
Argument Parsing
getopts for Short Options
Use getopts when you only need short (single-character) options. This is the POSIX-standard
approach and works in both bash and sh.
# CORRECT: getopts with proper error handling
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: backup.sh [-h] [-v] [-n COUNT] [-o DIR] <source>
Options:
-h Show help
-v Verbose output
-n COUNT Number of backups to keep (default: 5)
-o DIR Output directory (default: ./backups)
EOF
}
verbose=false
keep_count=5
output_dir="./backups"
while getopts ":hvn:o:" opt; do
case $opt in
h) usage; exit 0 ;;
v) verbose=true ;;
n) keep_count="$OPTARG" ;;
o) output_dir="$OPTARG" ;;
:) echo "Error: -$OPTARG requires an argument" >&2; exit 1 ;;
*) echo "Error: Unknown option -$OPTARG" >&2; usage >&2; exit 1 ;;
esac
done
shift $((OPTIND - 1))
# Validate required positional argument
if (( $# < 1 )); then
echo "Error: source argument is required" >&2
usage >&2
exit 1
fi
source_dir="$1"
# WRONG: getopts without leading colon (no custom error messages)
while getopts "hvn:o:" opt; do # Missing leading : for silent error handling
case $opt in
h) usage; exit 0 ;;
v) verbose=true ;;
# Missing : case for options requiring arguments
# Missing * case for unknown options
esac
done
# Missing shift to consume parsed options
getopts rules:
- Leading
:in optstring enables silent error mode (custom error messages) - A colon after a letter means the option takes an argument (
n:means-n VALUE) $OPTARGcontains the argument value (or the bad option character on error)- Always
shift $((OPTIND - 1))after the loop to access positional arguments
while+case for Long Options
Use while+case when you need --long-option support. This is the standard bash approach.
# CORRECT: while+case with both short and long options
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: deploy.sh [OPTIONS] <environment>
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output
-t, --tag TAG Docker image tag (required)
-r, --replicas N Number of replicas (default: 3)
--dry-run Show what would happen without making changes
--no-health-check Skip post-deploy health check
EOF
}
verbose=false
tag=""
replicas=3
dry_run=false
health_check=true
while (( $# > 0 )); do
case "$1" in
-h|--help)
usage
exit 0
;;
-v|--verbose)
verbose=true
shift
;;
-t|--tag)
if [[ -z "${2:-}" ]]; then
echo "Error: --tag requires a value" >&2
exit 1
fi
tag="$2"
shift 2
;;
-r|--replicas)
if [[ -z "${2:-}" || ! "$2" =~ ^[0-9]+$ ]]; then
echo "Error: --replicas requires a numeric value" >&2
exit 1
fi
replicas="$2"
shift 2
;;
--dry-run)
dry_run=true
shift
;;
--no-health-check)
health_check=false
shift
;;
--)
shift
break
;;
-*)
echo "Error: Unknown option: $1" >&2
usage >&2
exit 1
;;
*)
break
;;
esac
done
# Validate required options
if [[ -z "$tag" ]]; then
echo "Error: --tag is required" >&2
exit 1
fi
# Validate required positional arguments
if (( $# < 1 )); then
echo "Error: environment argument is required" >&2
usage >&2
exit 1
fi
environment="$1"
shift
# WRONG: Missing argument validation for options that take values
while (( $# > 0 )); do
case "$1" in
--tag) tag="$2"; shift 2 ;; # Crashes if $2 is missing
*) break ;;
esac
done
# WRONG: Not handling -- for end-of-options
while (( $# > 0 )); do
case "$1" in
--verbose) verbose=true; shift ;;
*) break ;; # "--" is treated as positional arg
esac
done
Subcommand Pattern
# CORRECT: Subcommand dispatch
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: myctl <command> [args...]
Commands:
start Start the service
stop Stop the service
status Show service status
logs View service logs
Run 'myctl <command> --help' for command-specific help.
EOF
}
cmd_start() {
echo "Starting service..."
}
cmd_stop() {
echo "Stopping service..."
}
cmd_status() {
echo "Service status: running"
}
cmd_logs() {
local follow=false
while (( $# > 0 )); do
case "$1" in
-f|--follow) follow=true; shift ;;
*) break ;;
esac
done
if [[ "$follow" == "true" ]]; then
tail -f /var/log/myservice.log
else
tail -100 /var/log/myservice.log
fi
}
main() {
if (( $# < 1 )); then
usage >&2
exit 1
fi
local command="$1"
shift
case "$command" in
start) cmd_start "$@" ;;
stop) cmd_stop "$@" ;;
status) cmd_status "$@" ;;
logs) cmd_logs "$@" ;;
-h|--help|help) usage ;;
*)
echo "Error: Unknown command '$command'" >&2
usage >&2
exit 1
;;
esac
}
main "$@"
Logging Functions
Standard Logging Library
All diagnostic output must go to stderr so stdout remains clean for data piping.
# CORRECT: Full logging library with timestamps and colors
#!/usr/bin/env bash
# Detect color support
if [[ -t 2 ]]; then
readonly _C_RED='\033[0;31m'
readonly _C_GREEN='\033[0;32m'
readonly _C_YELLOW='\033[0;33m'
readonly _C_BLUE='\033[0;34m'
readonly _C_BOLD='\033[1m'
readonly _C_RESET='\033[0m'
else
readonly _C_RED='' _C_GREEN='' _C_YELLOW='' _C_BLUE='' _C_BOLD='' _C_RESET=''
fi
log() {
printf "${_C_BLUE}[%s]${_C_RESET} %s\n" "$(date '+%H:%M:%S')" "$*" >&2
}
warn() {
printf "${_C_YELLOW}[%s] WARN:${_C_RESET} %s\n" "$(date '+%H:%M:%S')" "$*" >&2
}
err() {
printf "${_C_RED}[%s] ERROR:${_C_RESET} %s\n" "$(date '+%H:%M:%S')" "$*" >&2
}
die() {
err "$@"
exit 1
}
success() {
printf "${_C_GREEN}[%s] OK:${_C_RESET} %s\n" "$(date '+%H:%M:%S')" "$*" >&2
}
# WRONG: Logging to stdout (breaks piping)
log() {
echo "[$(date)] $*" # Goes to stdout, mixes with data
}
# WRONG: No timestamp (hard to debug)
log() {
echo "$*" >&2 # When did this happen?
}
# WRONG: Using echo -e for colors (not portable)
err() {
echo -e "\033[31mERROR: $*\033[0m" >&2 # -e not portable
}
POSIX sh Logging
# CORRECT: POSIX sh logging (no color, no bash extensions)
#!/bin/sh
log() {
printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*" >&2
}
warn() {
printf '[%s] WARN: %s\n' "$(date '+%H:%M:%S')" "$*" >&2
}
err() {
printf '[%s] ERROR: %s\n' "$(date '+%H:%M:%S')" "$*" >&2
}
die() {
err "$@"
exit 1
}
Verbose-Mode Logging
# CORRECT: Debug logging controlled by verbose flag
VERBOSE="${VERBOSE:-false}"
debug() {
if [[ "$VERBOSE" == "true" ]]; then
printf '[%s] DEBUG: %s\n' "$(date '+%H:%M:%S')" "$*" >&2
fi
}
# Usage
debug "Connecting to $host:$port"
debug "Response headers: $(curl -sI "$url")"
# WRONG: Verbose logging that cannot be disabled
echo "DEBUG: Connecting to $host:$port" # Always prints, clutters output
Trap Cleanup Patterns
Basic Cleanup
# CORRECT: Trap EXIT for guaranteed cleanup
tmpdir=""
cleanup() {
local exit_code=$?
if [[ -n "$tmpdir" && -d "$tmpdir" ]]; then
rm -rf "$tmpdir"
fi
exit "$exit_code"
}
trap cleanup EXIT
tmpdir=$(mktemp -d)
# WRONG: Cleanup at end of script (skipped on error)
tmpdir=$(mktemp -d)
# ... work ...
rm -rf "$tmpdir" # Never reached if script fails!
Cleanup with Background Process Management
# CORRECT: Kill child processes on exit
child_pid=""
cleanup() {
local exit_code=$?
if [[ -n "$child_pid" ]]; then
kill "$child_pid" 2>/dev/null || true
wait "$child_pid" 2>/dev/null || true
fi
if [[ -n "${tmpdir:-}" && -d "${tmpdir:-}" ]]; then
rm -rf "$tmpdir"
fi
exit "$exit_code"
}
trap cleanup EXIT
# Start background process
long_running_command &
child_pid=$!
# Wait for it
wait "$child_pid"
child_pid="" # Clear so cleanup doesn't try to kill completed process
Error Line Reporting
# CORRECT: Report the failing line on ERR
on_error() {
local exit_code=$?
local line_no="$1"
err "Failed at line $line_no with exit code $exit_code"
err "Command: ${BASH_COMMAND}"
}
trap 'on_error ${LINENO}' ERR
# WRONG: Trapping ERR without useful information
trap 'echo "error" >&2' ERR # No line number, no exit code
Stacking Trap Handlers
# CORRECT: Preserve existing traps when adding new ones
existing_trap=$(trap -p EXIT | sed "s/trap -- '//;s/' EXIT//")
new_cleanup() {
rm -f "$my_temp_file"
eval "$existing_trap"
}
trap new_cleanup EXIT
Temporary File Patterns
Safe Temp Directory Pattern
# CORRECT: Create a temp directory and derive all temp files from it
work_dir=$(mktemp -d "${TMPDIR:-/tmp}/myapp.XXXXXX")
trap 'rm -rf "$work_dir"' EXIT
# Derive temp files inside the directory
input_tmp="$work_dir/input.json"
output_tmp="$work_dir/output.json"
log_tmp="$work_dir/process.log"
curl -sf "$url" > "$input_tmp"
jq '.data' "$input_tmp" > "$output_tmp"
# WRONG: Multiple independent temp files (each needs cleanup)
tmp1=$(mktemp)
tmp2=$(mktemp)
tmp3=$(mktemp)
trap 'rm -f "$tmp1" "$tmp2" "$tmp3"' EXIT # Easy to forget one
Atomic File Writes
# CORRECT: Write to temp, then atomically move
write_file() {
local target="$1"
local content="$2"
local tmpfile
tmpfile=$(mktemp "${target}.tmp.XXXXXX")
if printf '%s\n' "$content" > "$tmpfile"; then
mv -f "$tmpfile" "$target"
else
rm -f "$tmpfile"
return 1
fi
}
# WRONG: Direct write (leaves partial file on failure)
printf '%s\n' "$content" > "$target" # Partially written if interrupted
Script Directory Resolution
Bash Script Directory
# CORRECT: Resolve script directory (handles symlinks in directory path)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# CORRECT: Fully resolve symlinks (script itself is a symlink)
resolve_script_dir() {
local source="${BASH_SOURCE[0]}"
while [[ -L "$source" ]]; do
local dir
dir="$(cd "$(dirname "$source")" && pwd)"
source="$(readlink "$source")"
# Resolve relative symlink
[[ "$source" != /* ]] && source="$dir/$source"
done
cd "$(dirname "$source")" && pwd
}
SCRIPT_DIR="$(resolve_script_dir)"
# WRONG: Using $0 in bash (unreliable when sourced)
SCRIPT_DIR="$(dirname "$0")" # Wrong if script is sourced
# WRONG: Using pwd (depends on where script is called from)
SCRIPT_DIR="$(pwd)" # Wrong if called from another directory
POSIX sh Script Directory with Fallback
# CORRECT: POSIX sh with readlink fallback
#!/bin/sh
set -eu
resolve_script_dir() {
local dir
dir="$(cd "$(dirname "$0")" && pwd)"
# Try to resolve symlinks
if command -v readlink >/dev/null 2>&1; then
local resolved
resolved="$(readlink -f "$0" 2>/dev/null)" || resolved=""
if [ -n "$resolved" ]; then
dir="$(cd "$(dirname "$resolved")" && pwd)"
fi
fi
printf '%s' "$dir"
}
SCRIPT_DIR="$(resolve_script_dir)"
Input Validation Patterns
Numeric Validation
# CORRECT: Validate integers
is_positive_integer() {
local value="$1"
[[ "$value" =~ ^[1-9][0-9]*$ ]]
}
validate_port() {
local port="$1"
if ! [[ "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
die "Invalid port: $port (must be 1-65535)"
fi
}
# WRONG: No validation
port="$1"
curl "http://localhost:$port/" # What if port is "abc"?
Path Validation
# CORRECT: Validate file/directory paths
validate_input_file() {
local file="$1"
if [[ ! -f "$file" ]]; then
die "File not found: $file"
fi
if [[ ! -r "$file" ]]; then
die "File not readable: $file"
fi
}
validate_output_dir() {
local dir="$1"
if [[ -e "$dir" && ! -d "$dir" ]]; then
die "Not a directory: $dir"
fi
mkdir -p "$dir" || die "Cannot create directory: $dir"
if [[ ! -w "$dir" ]]; then
die "Directory not writable: $dir"
fi
}
Whitelist Validation
# CORRECT: Validate against known-good values
validate_environment() {
local env="$1"
case "$env" in
development|staging|production) return 0 ;;
*) die "Invalid environment: $env (must be development, staging, or production)" ;;
esac
}
validate_log_level() {
local level="$1"
case "$level" in
debug|info|warn|error|fatal) return 0 ;;
*) die "Invalid log level: $level" ;;
esac
}
# WRONG: No whitelist validation
environment="$1"
# Using unvalidated input directly in a URL or command
curl "https://${environment}.example.com/api" # What if env contains "evil.com/"?
Process Management Patterns
Wait for Service Ready
# CORRECT: Wait with timeout and backoff
wait_for_ready() {
local url="$1"
local timeout="${2:-60}"
local interval="${3:-2}"
local elapsed=0
log "Waiting for $url (timeout: ${timeout}s)"
while (( elapsed < timeout )); do
if curl -sf "$url" > /dev/null 2>&1; then
log "Service is ready (${elapsed}s)"
return 0
fi
sleep "$interval"
(( elapsed += interval ))
done
err "Service not ready after ${timeout}s: $url"
return 1
}
# Usage
wait_for_ready "http://localhost:8080/health" 30
# WRONG: Infinite loop with no timeout
while ! curl -sf http://localhost:8080/health; do
sleep 1
done
# Never exits if service is permanently down
Lock File Pattern
# CORRECT: Directory-based lock (atomic on all filesystems)
acquire_lock() {
local lockdir="$1"
if ! mkdir "$lockdir" 2>/dev/null; then
local pid
pid=$(cat "$lockdir/pid" 2>/dev/null || echo "unknown")
# Check if the lock holder is still alive
if [[ "$pid" != "unknown" ]] && kill -0 "$pid" 2>/dev/null; then
err "Lock held by active process $pid"
return 1
fi
warn "Removing stale lock (pid=$pid)"
rm -rf "$lockdir"
mkdir "$lockdir" || { err "Cannot acquire lock"; return 1; }
fi
echo $$ > "$lockdir/pid"
trap 'rm -rf "'"$lockdir"'"' EXIT
}
# Usage
acquire_lock /var/lock/myapp.lock || exit 1
# WRONG: File-based lock (race condition between check and create)
if [[ -f "$lockfile" ]]; then
echo "Locked" >&2
exit 1
fi
echo $$ > "$lockfile" # Race: another process may create between check and write
Retry with Exponential Backoff
# CORRECT: Retry with backoff and max attempts
retry() {
local max_attempts="$1"
local delay="$2"
shift 2
local attempt=1
while true; do
if "$@"; then
return 0
fi
if (( attempt >= max_attempts )); then
err "Failed after $max_attempts attempts: $*"
return 1
fi
warn "Attempt $attempt/$max_attempts failed, retrying in ${delay}s"
sleep "$delay"
(( attempt++ ))
(( delay = delay * 2 > 60 ? 60 : delay * 2 )) # Cap at 60 seconds
done
}
# Usage
retry 5 2 curl -sf "https://api.example.com/data"
retry 3 1 docker pull "myapp:latest"
# WRONG: Retry without limit
while ! curl -sf "$url"; do
sleep 1 # Runs forever on permanent failure
done
# WRONG: Retry without backoff
for i in {1..5}; do
curl -sf "$url" && break
sleep 1 # Constant delay, no backoff
done
Portability Helper Patterns
Cross-Platform stat
# CORRECT: Portable file size
file_size() {
local file="$1"
if stat -f%z "$file" 2>/dev/null; then
return 0 # macOS
elif stat -c%s "$file" 2>/dev/null; then
return 0 # Linux
else
wc -c < "$file" | tr -d ' ' # Fallback
fi
}
Cross-Platform sed In-Place
# CORRECT: Portable sed -i
sed_inplace() {
if [[ "$(uname -s)" == "Darwin" ]]; then
sed -i '' "$@"
else
sed -i "$@"
fi
}
# Usage
sed_inplace 's/old/new/g' config.txt
Cross-Platform Readlink
# CORRECT: Portable readlink -f
resolve_path() {
local target="$1"
if command -v realpath &>/dev/null; then
realpath "$target"
elif command -v greadlink &>/dev/null; then
greadlink -f "$target" # macOS with coreutils
elif readlink -f "$target" 2>/dev/null; then
return 0 # Linux readlink -f
else
# Manual resolution
cd "$(dirname "$target")" && printf '%s/%s' "$(pwd)" "$(basename "$target")"
fi
}
Portable Date Formatting
# CORRECT: ISO 8601 timestamp that works everywhere
timestamp() {
date -u '+%Y-%m-%dT%H:%M:%SZ'
}
# CORRECT: Epoch seconds (works on both Linux and macOS)
epoch_seconds() {
date '+%s'
}
# WRONG: GNU date-only syntax
date -d "2 hours ago" '+%Y-%m-%d' # Fails on macOS
date --iso-8601=seconds # GNU extension
Configuration File Patterns
.env File Loading
# CORRECT: Safe .env file loader
load_dotenv() {
local env_file="${1:-.env}"
if [[ ! -f "$env_file" ]]; then
return 0
fi
while IFS= read -r line || [[ -n "$line" ]]; do
# Skip comments and blank lines
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
# Remove 'export ' prefix if present
line="${line#export }"
# Extract key and value
local key="${line%%=*}"
local value="${line#*=}"
# Validate key format
[[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || continue
# Strip surrounding quotes
value="${value#\"}"
value="${value%\"}"
value="${value#\'}"
value="${value%\'}"
export "$key=$value"
done < "$env_file"
}
# WRONG: Sourcing .env directly (insecure, executes arbitrary code)
source .env # If .env contains "$(rm -rf /)", it runs
. .env # Same problem
INI-Style Config Parsing
# CORRECT: Simple INI parser
parse_ini() {
local file="$1"
local section=""
while IFS= read -r line; do
# Skip comments and empty lines
[[ -z "$line" || "$line" =~ ^[[:space:]]*[#\;] ]] && continue
# Section header
if [[ "$line" =~ ^\[([a-zA-Z0-9_-]+)\] ]]; then
section="${BASH_REMATCH[1]}"
continue
fi
# Key-value pair
if [[ "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_]*)=(.*)$ ]]; then
local key="${BASH_REMATCH[1]}"
local value="${BASH_REMATCH[2]}"
# Strip inline comments
value="${value%%#*}"
# Trim trailing whitespace
value="${value%"${value##*[![:space:]]}"}"
if [[ -n "$section" ]]; then
printf '%s_%s=%s\n' "$section" "$key" "$value"
else
printf '%s=%s\n' "$key" "$value"
fi
fi
done < "$file"
}
# Usage: eval "$(parse_ini config.ini)"
# Produces: section_key=value variables
Signal Handling Patterns
Graceful Shutdown
# CORRECT: Handle SIGTERM and SIGINT for graceful shutdown
shutdown_requested=false
handle_signal() {
log "Shutdown requested, finishing current work..."
shutdown_requested=true
}
trap handle_signal SIGTERM SIGINT
# Main processing loop checks the flag
while [[ "$shutdown_requested" == "false" ]]; do
process_next_item || sleep 1
done
log "Graceful shutdown complete"
# WRONG: Immediate exit on signal (may leave work half-done)
trap 'exit 1' SIGTERM SIGINT
while true; do
process_next_item # Interrupted mid-operation
done
Signal Forwarding to Child Processes
# CORRECT: Forward signals to child process
child_pid=""
forward_signal() {
if [[ -n "$child_pid" ]]; then
kill -TERM "$child_pid" 2>/dev/null || true
fi
}
trap forward_signal SIGTERM SIGINT
some_long_command &
child_pid=$!
wait "$child_pid"
exit_code=$?
child_pid=""
exit "$exit_code"
Summary Checklist
When implementing shell script patterns:
- Argument parsing validates all input before use
- Logging functions send diagnostics to stderr
- Color output checks for terminal with
[[ -t 2 ]] - Trap EXIT handles cleanup for all temporary resources
- Temp files use
mktempin a single temp directory - Atomic writes use temp-then-move pattern
- SCRIPT_DIR uses
${BASH_SOURCE[0]}(bash) or$0(sh) - Lock files use
mkdirfor atomic creation - Retries have max attempts, backoff, and timeout
- Service readiness checks have timeouts
- .env loading validates key format, strips quotes, avoids sourcing
- Cross-platform helpers detect OS and provide fallbacks
- Signal handlers allow graceful shutdown of long-running processes
These patterns are the building blocks of reliable shell automation.