standards-shell

SKILL.md

Shell/Bash Coding Standards

Core Principles

  1. Simplicity: Simple, understandable scripts
  2. Readability: Readability over cleverness
  3. Maintainability: Scripts that are easy to maintain
  4. Testability: Scripts that are easy to test
  5. DRY: Don't Repeat Yourself - but don't overdo it
  6. Defensiveness: Fail early, fail loudly

General Rules

  • Defensive Header: Always use set -euo pipefail
  • Quote Variables: Always quote variables "$var"
  • Descriptive Names: Meaningful names for variables and functions
  • Minimal Changes: Only change relevant code parts
  • No Over-Engineering: No unnecessary complexity
  • ShellCheck Clean: All scripts must pass ShellCheck

Naming Conventions

Element Convention Example
Variables snake_case user_name, file_count
Functions snake_case get_user_by_id, validate_input
Constants UPPER_SNAKE_CASE MAX_RETRIES, DEFAULT_TIMEOUT
Files kebab-case or snake_case deploy-app.sh, run_tests.sh
Environment Vars UPPER_SNAKE_CASE API_URL, DATABASE_HOST

Script Template

#!/bin/bash
set -euo pipefail

readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"

# Cleanup on exit
cleanup() {
    rm -f "$SCRIPT_DIR"/*.tmp 2>/dev/null || true
}
trap cleanup EXIT

# Error handler
error_handler() {
    echo "Error on line $1" >&2
    exit 1
}
trap 'error_handler $LINENO' ERR

main() {
    # Script logic here
    echo "Running $SCRIPT_NAME"
}

main "$@"

Defensive Scripting

# REQUIRED: Always start with this
set -euo pipefail
# -e: Exit on error
# -u: Error on undefined variables
# -o pipefail: Pipe fails if any command fails

# RECOMMENDED: Safer IFS
IFS=$'\n\t'

# REQUIRED: Quote all variables
echo "$var"                     # Good
echo $var                       # Bad - word splitting

# REQUIRED: Use [[ ]] for conditionals (Bash)
if [[ -f "$file" ]]; then       # Good
if [ -f "$file" ]; then         # POSIX only

Parameter Expansion

# Defaults and validation
${var:-default}             # Use default if unset
${var:=default}             # Assign default if unset
${var:?error message}       # Error if unset

# String manipulation
${var#pattern}              # Remove prefix (shortest)
${var##pattern}             # Remove prefix (longest)
${var%pattern}              # Remove suffix (shortest)
${var%%pattern}             # Remove suffix (longest)
${var/old/new}              # Replace first
${var//old/new}             # Replace all
${#var}                     # Length

# Examples
file="document.txt"
echo "${file%%.*}"          # "document" (remove extension)
echo "${file##*.}"          # "txt" (get extension)

Functions

# REQUIRED: Use local variables
get_user_name() {
    local user_id=$1
    local name
    name=$(grep "^${user_id}:" /etc/passwd | cut -d: -f5)
    echo "$name"
}

# Return values via stdout
result=$(get_user_name "1000")

# Return status codes
validate_file() {
    local file=$1
    if [[ ! -f "$file" ]]; then
        echo "Error: File not found: $file" >&2
        return 1
    fi
    return 0
}

if validate_file "$input_file"; then
    process_file "$input_file"
fi

Arrays

# Indexed arrays
files=("file1.txt" "file2.txt" "file3.txt")
echo "${files[0]}"          # First element
echo "${files[@]}"          # All elements
echo "${#files[@]}"         # Array length

# Iterate safely
for file in "${files[@]}"; do
    echo "Processing: $file"
done

# Associative arrays (Bash 4+)
declare -A config
config[host]="localhost"
config[port]="8080"
echo "${config[host]}:${config[port]}"

File Operations

# Read file line by line
while IFS= read -r line; do
    echo "Line: $line"
done < "input.txt"

# Read into array
mapfile -t lines < "input.txt"

# Write to file (heredoc)
cat > output.txt <<EOF
Line 1
Line 2
EOF

# Temp files with cleanup
temp_file=$(mktemp)
trap 'rm -f "$temp_file"' EXIT

Error Handling

# Trap for cleanup
cleanup() {
    echo "Cleaning up..."
    rm -f "$temp_file"
}
trap cleanup EXIT

# Trap for errors
error_handler() {
    local line=$1
    echo "Error occurred on line $line" >&2
}
trap 'error_handler $LINENO' ERR

# Check command exists
if ! command -v python3 &>/dev/null; then
    echo "Error: python3 not found" >&2
    exit 1
fi

# Conditional execution
command1 && command2        # Run command2 only if command1 succeeds
command1 || command2        # Run command2 only if command1 fails

Argument Parsing with getopts

usage() {
    echo "Usage: $0 [-v] [-o output] [-h]"
    echo "  -v        Verbose mode"
    echo "  -o FILE   Output file"
    echo "  -h        Show help"
    exit 1
}

verbose=false
output_file=""

while getopts "vo:h" opt; do
    case $opt in
        v) verbose=true ;;
        o) output_file="$OPTARG" ;;
        h) usage ;;
        *) usage ;;
    esac
done
shift $((OPTIND - 1))

# Remaining args in $@

Logging

log() {
    local level=$1
    shift
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" >&2
}

log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }

# Usage
log_info "Starting process"
log_error "Failed to connect"

Debugging

# Enable debugging
set -x                      # Print commands
PS4='+ ${BASH_SOURCE}:${LINENO}: '  # Better debug output

# Debug specific section
set -x
# code to debug
set +x

# Run script with debug
bash -x script.sh
bash -n script.sh           # Syntax check only

Common Patterns

# Check if root
if [[ $EUID -ne 0 ]]; then
    echo "This script must be run as root" >&2
    exit 1
fi

# Safe directory change
cd "$target_dir" || exit 1

# Process files safely (handles spaces)
find . -name "*.txt" -print0 | while IFS= read -r -d '' file; do
    echo "Processing: $file"
done

# Retry pattern
retry() {
    local max_attempts=$1
    local delay=$2
    shift 2
    local attempt=1

    while [[ $attempt -le $max_attempts ]]; do
        if "$@"; then
            return 0
        fi
        log_warn "Attempt $attempt failed, retrying in ${delay}s..."
        sleep "$delay"
        ((attempt++))
    done
    return 1
}

retry 3 5 curl -f "https://api.example.com/health"

Recommended Tooling

Tool Purpose
shellcheck Static analysis (required)
shfmt Code formatting
bats-core Testing framework
bash 5.x Modern features (avoid macOS default 3.2)

ShellCheck Usage

# Run ShellCheck
shellcheck script.sh

# Disable specific warning (sparingly)
# shellcheck disable=SC2086
echo $UNQUOTED_VAR

# Follow sourced files
shellcheck -x script.sh

Testing with bats-core

#!/usr/bin/env bats
# File: test_script.bats

source ./script.sh

@test "add function returns correct sum" {
    result=$(add 5 3)
    [ "$result" = "8" ]
}

@test "validate_file fails on missing file" {
    run validate_file "nonexistent.txt"
    [ "$status" -eq 1 ]
}

Run tests:

bats tests/

POSIX Compatibility

For maximum portability (sh, dash, ash):

#!/bin/sh
# Use [ ] instead of [[ ]]
if [ -f "file.txt" ]; then
    echo "File exists"
fi

# No arrays, use positional parameters
set -- "apple" "banana" "cherry"
echo "First: $1"

# No $() in older shells, use backticks
current_date=`date +%Y-%m-%d`

Production Best Practices

  1. Defensive header - Always set -euo pipefail
  2. Quote everything - Prevent word splitting and glob expansion
  3. Local variables - Use local in functions
  4. ShellCheck clean - No warnings before commit
  5. Cleanup traps - Always clean up temp files
  6. Meaningful exit codes - 0 for success, non-zero for errors
  7. Logging to stderr - Keep stdout for data, stderr for logs
  8. Check dependencies - Verify required commands exist
  9. Handle signals - Trap SIGTERM for graceful shutdown
  10. Document usage - Include --help option

References

Weekly Installs
2
GitHub Stars
47
First Seen
Feb 28, 2026
Installed on
mcpjam2
claude-code2
replit2
junie2
windsurf2
zencoder2