agentsclimarketplace

Bash scripting

Skill ebal/AI-Skills/bash-scripting

Guide and patterns for writing robust Bash scripts. Use when the user says "write a bash script", "shell script", "bash script", "write a script", "bash best practices", or needs help with Bash/shell scripting. Covers script structure, error handling, string manipulation, arrays, arithmetic, conditionals, loops, functions, traps, debugging, and common idioms distilled from the Advanced Bash-Scripting Guide.From its SKILL.md

Install
npx -y skills add ebal/AI-Skills --skill bash-scripting

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

2 things to look at

  • no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

25.3 KB, ~7.5k tokens by cl100k_base, as published. Nobody here has run it

Bash Scripting Skill

Comprehensive patterns and best practices distilled from the Advanced Bash-Scripting Guide by Mendel Cooper.


1. Script Skeleton

Every script should start with a proper shebang, strict mode, and metadata.

#!/usr/bin/env bash
#
# script_name.sh — Brief description
# Author: Name <email>
# Date: YYYY-MM-DD
# License: MIT

# Strict mode (set -euo pipefail is debated — use intentionally)
set -euo pipefail
IFS=$'\n\t'

# --- Global Constants ---
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
readonly VERSION="1.0.0"

# --- Functions ---
usage() {
    cat <<EOF
Usage: ${SCRIPT_NAME} [OPTIONS] <args>

Description of what this script does.

Options:
  -h, --help      Show this help message
  -v, --verbose   Enable verbose output
  -q, --quiet     Suppress output
  -V, --version   Show version

Example:
  ${SCRIPT_NAME} --verbose file.txt
EOF
}

# --- Logging ---
log()   { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
warn()  { log "WARN: $*" >&2; }
error() { log "ERROR: $*" >&2; }
die()   { error "$*"; exit 1; }

# --- Main ---
main() {
    # Argument parsing
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -h|--help)    usage; exit 0 ;;
            -v|--verbose) VERBOSE=1; shift ;;
            -q|--quiet)   QUIET=1; shift ;;
            -V|--version) echo "${VERSION}"; exit 0 ;;
            --)           shift; break ;;
            -*)           die "Unknown option: $1" ;;
            *)            break ;;
        esac
    done

    # Validate inputs
    [[ $# -lt 1 ]] && { usage >&2; die "Missing required argument"; }

    # --- Script logic here ---
    log "Starting..."
}

# Run main only if script is executed (not sourced)
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
    main "$@"
fi

Key patterns:

  • BASH_SOURCE[0] == "$0" guard lets the script be sourced for testing without auto-executing
  • $(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) is the canonical way to find the script's own directory
  • readonly for constants prevents accidental mutation
  • IFS=$'\n\t' avoids word-splitting bugs with unquoted variables

2. Error Handling

trap (Ch. 34)

trap is the most underused Bash feature. It catches signals and errors.

# Cleanup on exit (best pattern)
cleanup() {
    local exit_code=$?
    rm -f "${TEMP_FILE:-}"
    # Kill background jobs if any
    jobs -p | xargs -r kill 2>/dev/null
    return "$exit_code"
}
trap cleanup EXIT

# Catch errors with ERR trap (Bash 3.0+)
on_error() {
    local line=$1
    local code=$2
    error "Script failed at line ${line} with exit code ${code}"
}
trap 'on_error ${LINENO} $?' ERR

# Handle signals
trap 'echo "Interrupted"; exit 130' INT
trap '' TERM   # Ignore TERM during critical sections

Error handling patterns:

# Check return codes explicitly
command || { die "command failed"; }

# Capture and check
output=$(some_command 2>&1) || die "some_command failed: ${output}"

# Subshell error capture
if ! output=$(command_that_may_fail); then
    warn "Non-fatal: $output"
fi

# Pipe failure detection (requires set -o pipefail)
cmd1 | cmd2 | cmd3
# Without pipefail, only cmd3's exit code matters

Atomic file operations:

# Write atomically using a temp file
write_atomic() {
    local target="$1"
    local tmp="${target}.tmp.$$"
    # ... write to "$tmp" ...
    mv -f "$tmp" "$target"  # atomic on POSIX
}

3. Variables & Parameter Expansion

Quoting (Ch. 3) — THE MOST IMPORTANT RULE

# ALWAYS quote variables unless you intentionally want word splitting
filename="my file.txt"
cat "$filename"       # correct
cat $filename         # WRONG — word splits on space

# The only safe use of unquoted $* is in echo for display
echo "Args: $*"

# Array expansion
args=("--flag" "value with spaces" "--other")
cmd "${args[@]}"      # correct — each element is a separate word
cmd ${args[@]}        # WRONG — spaces break it

Parameter Expansion (Ch. 9):

# Default values
${var:-default}      # use default if unset/empty (don't assign)
${var:=default}      # assign default if unset/empty
${var:+alternate}    # use alternate if set and non-empty
${var:?error msg}    # die with error if unset/empty

# String manipulation
${#var}              # string length
${var#pattern}       # remove shortest match from start
${var##pattern}      # remove longest match from start
${var%pattern}       # remove shortest match from end
${var%%pattern}      # remove longest match from end
${var/pattern/repl}  # replace first match
${var//pattern/repl} # replace all matches
${var^}              # capitalize first letter (Bash 4+)
${var^^}             # uppercase all (Bash 4+)
${var,}              # lowercase first letter (Bash 4+)
${var,,}             # lowercase all (Bash 4+)

# Practical examples
filepath="/home/user/docs/report.pdf"
echo "${filepath##*/}"           # → report.pdf  (basename)
echo "${filepath%/*}"            # → /home/user/docs (dirname)
echo "${filepath##*.}"           # → pdf (extension)
echo "${filepath##*/}" | sed 's/\.[^.]*$//'  # → report (stem, no sed needed below)

# Better: pure parameter expansion for stem
temp="${filepath##*/}"           # report.pdf
echo "${temp%.*}"                # report

# Substring extraction
${var:offset:length}             # extract from offset for length
${var:offset}                    # extract from offset to end

# Case modification (Bash 4+)
declare -u upper="hello"        # → HELLO
declare -l lower="HELLO"        # → hello

Indirect references (Ch. 27):

# Bash 4+ nameref (preferred)
declare -n ref=original_var
ref="new value"
echo "$original_var"  # → new value

# Legacy indirect expansion
varname="myvar"
myvar="hello"
echo "${!varname}"    # → hello (Bash 4.2+)

# For arrays
arr=(a b c)
idx=1
echo "${!arr[$idx]}"  # → b (Bash 4.3+)

4. Arrays (Ch. 6, 27)

# Declaration
declare -a array_name          # indexed array
declare -A map_name            # associative array (Bash 4+)

# Initialization
fruits=(apple banana cherry)
declare -A config=(
    [host]="localhost"
    [port]=8080
    [debug]="true"
)

# Access
echo "${fruits[0]}"            # → apple (0-indexed)
echo "${fruits[-1]}"           # → cherry (last element, Bash 4.3+)
echo "${fruits[@]}"            # → all elements (space-separated)
echo "${#fruits[@]}"           # → 3 (length)
echo "${!fruits[@]}"           # → 0 1 2 (indices)

# Slicing
echo "${fruits[@]:1:2}"        # → banana cherry (offset, count)
echo "${fruits[@]:2}"          # → cherry (offset to end)

# Appending
fruits+=(date elderberry)

# Unset
unset 'fruits[1]'             # remove element at index 1 (keeps other indices)
unset fruits                   # remove entire array

# Iteration
for fruit in "${fruits[@]}"; do
    echo "$fruit"
done

# Iterate with index
for i in "${!fruits[@]}"; do
    echo "$i: ${fruits[$i]}"
done

# Associative array iteration
for key in "${!config[@]}"; do
    echo "$key = ${config[$key]}"
done

# Check if key exists
[[ -v config[debug] ]]        # Bash 4.2+

# Common patterns
# Collect args into array
args=()
for arg in "$@"; do
    args+=("$arg")
done

# Split string into array
IFS=',' read -ra items <<< "a,b,c,d"

# Array length as a conditional
if (( ${#array[@]} > 0 )); then
    echo "Array has elements"
fi

5. Conditionals (Ch. 8, 13)

Test constructs — use [[ ]] not [ ]:

# [[ ]] is safer: no word splitting, regex support, pattern matching
if [[ -f "$file" ]]; then
    echo "File exists"
fi

# String tests
[[ -z "$var" ]]        # empty or unset
[[ -n "$var" ]]        # non-empty
[[ "$a" == "$b" ]]     # string equality
[[ "$a" != "$b" ]]     # string inequality
[[ "$a" < "$b" ]]      # lexicographic less-than
[[ "$a" =~ regex ]]    # regex match (no anchoring needed)
[[ "$a" == glob* ]]    # glob pattern match

# Numeric tests (inside (()))
(( x > 5 ))
(( x >= 5 && x <= 10 ))
(( x % 2 == 0 ))       # even check

# File tests
[[ -f "$f" ]]   # regular file      [[ -L "$f" ]]   # symlink
[[ -d "$d" ]]   # directory          [[ -e "$f" ]]   # exists (any type)
[[ -r "$f" ]]   # readable           [[ -w "$f" ]]   # writable
[[ -x "$f" ]]   # executable         [[ -s "$f" ]]   # non-empty file
[[ -p "$f" ]]   # named pipe (FIFO)  [[ -S "$f" ]]   # socket
[[ -b "$b" ]]   # block device       [[ -c "$c" ]]   # character device
[[ "$a" -nt "$b" ]]   # a is newer than b
[[ "$a" -ot "$b" ]]   # a is older than b

# Compound conditions
[[ "$a" -gt 5 && "$a" -lt 100 ]]
[[ -f "$f" && -r "$f" ]]   # exists and readable
[[ -d "$d" ]] || mkdir -p "$d"

Arithmetic evaluation (( )) (Ch. 7):

(( result = 3 + 4 ))
(( result++ ))
(( x += 10 ))
(( a == b ))           # returns 0 (true) or 1 (false)
(( a > b )) && echo "a is bigger"

# Useful for numeric validation
[[ "$input" =~ ^[0-9]+$ ]] && (( input > 0 && input < 1024 ))

Ternary and logical short-circuit:

result=$(( condition ? true_val : false_val ))
(( verbose )) && echo "verbose mode"
(( quiet ))   || echo "this prints unless quiet"

6. Loops (Ch. 10, 12)

# C-style for loop
for (( i=0; i<10; i++ )); do
    echo "$i"
done

# Range-based
for i in {1..10}; do echo "$i"; done
for i in {0..100..5}; do echo "$i"; done    # step by 5

# For loop over files (always quote glob)
for f in *; do
    [[ -f "$f" ]] || continue
    echo "$f"
done

# While loop with file reading (robust pattern)
while IFS= read -r line; do
    echo "$line"
done < "$input_file"

# Process substitution (avoids subshell, preserves variables)
while IFS= read -r line; do
    count=$((count + 1))
done < <(command_that_produces_output)

# Read CSV with IFS
while IFS=',' read -r col1 col2 col3; do
    echo "$col1 | $col2 | $col3"
done < data.csv

# Read with timeout
read -t 5 -r -p "Enter answer: " answer || echo "Timed out"

# Iterate over command output (use process substitution, not pipes)
# BAD: pipe creates subshell — variables don't persist
find /tmp -name "*.log" | while read -r f; do
    echo "$f"   # works, but any variables set here die after the loop
done

# GOOD: process substitution
while IFS= read -r f; do
    log_files+=("$f")
done < <(find /tmp -name "*.log")

# Until loop (run until success)
until ping -c1 google.com &>/dev/null; do
    sleep 1
done

# Breaking and continuing
for f in *; do
    [[ "$f" == *.tmp ]] && continue   # skip .tmp files
    [[ "$f" == stop_here ]] && break  # stop completely
    process "$f"
done

7. Functions (Ch. 11, 24)

# Function definition (both forms work; keyword form is preferred for clarity)
function greet() {
    local name="$1"
    local greeting="${2:-Hello}"
    printf '%s, %s!\n' "$greeting" "$name"
    return 0   # explicit return is good practice
}

# Return values: use echo for data, $? for status
get_latest_version() {
    local pkg="$1"
    # ... logic ...
    echo "2.4.1"          # stdout is the return value
}

version=$(get_latest_version "myapp") || die "Failed to get version"

# Local variables — ALWAYS use local
# Without local, variables leak to the caller's scope
risky() { var="leaked"; }
safe()  { local var="contained"; }

# Default argument handling
process_file() {
    local file="${1:?Usage: process_file <file>}"
    local mode="${2:-normal}"
    # ...
}

# Readonly function-local (Bash 4.2+)
expensive_calc() {
    local -r result="$((RANDOM % 100))"  # cannot be reassigned
    echo "$result"
}

# Passing arrays to functions
pass_array() {
    local -n arr_ref="$1"    # nameref (Bash 4.3+)
    arr_ref+=(new_element)
}

my_array=(a b)
pass_array my_array
echo "${my_array[@]}"   # → a b new_element

# Function sourcing pattern
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
    main "$@"
else
    # Being sourced — export functions, don't run main
    export -f func1 func2
fi

8. String Manipulation (Ch. 9, 15)

# String length
echo "${#string}"                    # bytes (locale-dependent)
echo "${#string[@]}"                 # array element count

# Substring extraction
echo "${string:offset}"              # from offset to end
echo "${string:offset:length}"       # offset + length

# Pattern removal
echo "${string#prefix}"              # remove shortest prefix
echo "${string##prefix}"             # remove longest prefix (greedy)
echo "${string%suffix}"              # remove shortest suffix
echo "${string%%suffix}"             # remove longest suffix (greedy)

# Pattern replacement
echo "${string/pattern/replacement}"  # first occurrence
echo "${string//pattern/replacement}" # all occurrences
echo "${string/#pattern/replacement}" # only at start
echo "${string/%pattern/replacement}" # only at end

# Practical examples
filepath="/var/log/syslog.1.gz"
echo "${filepath##*/}"              # → syslog.1.gz (basename)
echo "${filepath%/*}"               # → /var/log (dirname)
echo "${filepath##*.}"              # → gz (extension)
name="${filepath##*/}"
echo "${name%.*}"                   # → syslog.1 (strip extension)
echo "${name%.*%.*}"                # → syslog (strip all extensions)

# Padding and alignment (Bash 4.4+)
printf '%-20s %10s\n' "left" "right"   # left-justify, right-justify
printf '%05d\n' 42                       # → 00042 (zero-pad)

# Here-strings and here-docs
command <<< "$variable"           # here-string
cat << 'EOF'                      # here-doc (single quotes prevent expansion)
  No expansion happens here.
  $VAR stays literal.
EOF

cat <<- EOF                       # heredoc with tab stripping
	$(echo "This works with tabs")
	$VAR is expanded
EOF

# Heredoc to variable
read -r -d '' content << 'EOF'
  Multi-line content here.
EOF

9. Arithmetic (Ch. 7)

# (( )) is preferred for arithmetic
(( result = a + b ))
(( result++ ))
(( remainder = dividend % divisor ))

# Declare variables as integers for performance
declare -i num=42
num=$(( num * 2 ))   # auto-evaluated as arithmetic

# Factorial
factorial() {
    local n=$1 result=1
    for (( i=2; i<=n; i++ )); do
        (( result *= i ))
    done
    echo "$result"
}

# Floating point — Bash doesn't support it natively. Use bc:
result=$(echo "scale=4; $a / $b" | bc -l)

# Hex/octal/binary literals (Bash 4.2+)
echo $(( 0xFF ))    # → 255
echo $(( 0777 ))    # → 511
echo $(( 0b1010 ))  # → 10

# Random numbers
(( val = RANDOM % 100 + 1 ))       # 1-100
uuid=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen)

10. Signals, Traps & Process Management (Ch. 34, 16)

# Comprehensive cleanup trap
cleanup() {
    local exit_code=$?
    set +e           # don't let cleanup errors mask the original exit
    # Kill background processes
    [[ -n "${PID:-}" ]] && kill "$PID" 2>/dev/null
    # Remove temp files
    rm -f "${TMPFILES[@]}"
    # Unlock lock file
    [[ -n "${LOCK_FD:-}" ]] && exec {LOCK_FD}>&-
    exit "$exit_code"
}
trap cleanup EXIT

# File locking (prevent concurrent runs)
LOCK_FILE="/tmp/${SCRIPT_NAME}.lock"
exec 9>"$LOCK_FILE"
flock -n 9 || { echo "Already running"; exit 1; }

# Timeout wrapper
timeout 30 long_running_command || {
    [[ $? -eq 124 ]] && echo "Timed out"
}

# Common signals:
# SIGHUP  (1)   — terminal hangup
# SIGINT  (2)   — interrupt (Ctrl+C)
# SIGQUIT (3)   — quit (Ctrl+\)
# SIGTERM (15)  — termination (kill default)
# SIGKILL (9)   — forced kill (cannot be caught)
# SIGCHLD (20)  — child process died

# Ignore signals during critical section
trap '' INT TERM
# ... critical code ...
trap - INT TERM   # restore default handling

# Background job management
run_with_timeout() {
    local timeout=$1; shift
    "$@" &
    local pid=$!
    ( sleep "$timeout" && kill "$pid" 2>/dev/null ) &
    local watchdog=$!
    wait "$pid" 2>/dev/null
    local exit_code=$?
    kill "$watchdog" 2>/dev/null
    return "$exit_code"
}

11. Here-Documents & Here-Strings (Ch. 18, 20)

# Unquoted delimiter: variables expanded
cat << EOF
Home is $HOME
Path is $PATH
EOF

# Quoted delimiter: literal text
cat << 'EOF'
No expansion: $HOME is literal
EOF

# Dash delimiter strips leading tabs (useful in functions)
function install_service() {
    cat <<- EOF
	[Unit]
	Description=My Service
	[Service]
	ExecStart=/usr/bin/myapp
	EOF
}

# Here-string (Bash 4+)
grep "pattern" <<< "$string_variable"

# Process substitution as input
diff <(sort file1) <(sort file2)
while read -r line; do
    process "$line"
done < <(generate_output)

# Null here-doc (for creating files atomically)
cat > "$config_file" << 'EOF'
key=value
debug=true
EOF

12. Regular Expressions (Ch. 19)

# [[ =~ ]] for regex (no quoting the pattern)
if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
    echo "Valid email"
fi

# Capture groups (Bash 4+)
if [[ "$string" =~ ^([0-9]+)-([0-9]+)$ ]]; then
    start="${BASH_REMATCH[1]}"
    end="${BASH_REMATCH[2]}"
fi

# Named character classes
[[ "$input" =~ ^[[:digit:]]+$ ]]    # digits only
[[ "$input" =~ ^[[:alpha:]]+$ ]]    # letters only
[[ "$input" =~ ^[[:alnum:]]+$ ]]    # alphanumeric

# BRE vs ERE vs PCRE
# [[ =~ ]] uses ERE by default
# For PCRE: use grep -P or perl

# Practical regex patterns
# IP address (simplified)
[[ "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]

# Semantic version
[[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]

# Glob patterns in [[ == ]]
[[ "$filename" == *.tar.gz ]]       # ends with .tar.gz
[[ "$filename" == data_*_20?? ]]    # starts with data_, ends with _20XX

13. Networking & File Operations (Ch. 16, 21)

# File descriptor management
exec 3< input.txt        # open fd 3 for reading
exec 4> output.txt       # open fd 4 for writing
exec 5<> data.txt        # open fd 5 for read/write
exec 3<&-                # close fd 3

# Parallel reading with multiple file descriptors
exec 3< file1
exec 4< file2
while IFS= read -r -u 3 line1 && IFS= read -r -u 4 line2; do
    echo "File1: $line1, File2: $line2"
done
exec 3<&- 4<&-

# Temporary files (safe pattern)
tmpfile=$(mktemp)                     # auto-named
tmpdir=$(mktemp -d)                   # temp directory
tmpfile=$(mktemp /tmp/myapp.XXXXXX)   # custom prefix

# Named pipe (FIFO) for IPC
mkfifo /tmp/myfifo
echo "data" > /tmp/myfifo &
read -r line < /tmp/myfifo

# Process substitution for comparison
diff <(ssh server1 cat /etc/config) <(ssh server2 cat /etc/config)

# Check network connectivity
if timeout 2 bash -c "echo >/dev/tcp/google.com/443" 2>/dev/null; then
    echo "Online"
fi

# Check if port is in use
if ss -tlnp 2>/dev/null | grep -q ":8080 "; then
    echo "Port 8080 in use"
fi

14. Common Patterns & Idioms

Argument parsing (getopts and manual):

# Manual (recommended for long options)
parse_args() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -h|--help)     usage; exit 0 ;;
            -v|--verbose)  VERBOSE=1; shift ;;
            -o|--output)   OUTPUT="$2"; shift 2 ;;
            -)             shift; break ;;
            --)            shift; break ;;
            -*)            die "Unknown option: $1" ;;
            *)             args+=("$1"); shift ;;
        esac
    done
}

# getopts (short options only, traditional)
while getopts ":hvqo:" opt; do
    case "$opt" in
        h) usage; exit 0 ;;
        v) VERBOSE=1 ;;
        q) QUIET=1 ;;
        o) OUTPUT="$OPTARG" ;;
        :) die "Option -$OPTARG requires an argument" ;;
        \?) die "Unknown option: -$OPTARG" ;;
    esac
done
shift $((OPTIND - 1))

Safe file iteration:

# ALWAYS use globstar + nullglob for file operations
shopt -s globstar nullglob
for f in **/*.txt; do
    echo "$f"
done

# Or handle the empty case
files=(*.log)
if (( ${#files[@]} == 0 )); then
    die "No .log files found"
fi

Retry with backoff:

retry() {
    local max_attempts=$1; shift
    local delay=1
    for (( attempt=1; attempt<=max_attempts; attempt++ )); do
        "$@" && return 0
        if (( attempt < max_attempts )); then
            warn "Attempt $attempt/$max_attempts failed, retrying in ${delay}s..."
            sleep "$delay"
            (( delay *= 2 ))   # exponential backoff
        fi
    done
    return 1
}

retry 5 curl -sf http://example.com

Parallel execution:

# Run N jobs in parallel
pids=()
for item in "${items[@]}"; do
    process_item "$item" &
    pids+=($!)
done

# Wait and check results
failed=0
for pid in "${pids[@]}"; do
    wait "$pid" || (( failed++ ))
done
(( failed > 0 )) && die "$failed jobs failed"

Color output:

# Define colors as constants
readonly RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
readonly BLUE='\033[0;34m' BOLD='\033[1m' RESET='\033[0m'

# Only colorize if stdout is a terminal
if [[ -t 1 ]]; then
    info()  { printf "${GREEN}[INFO]${RESET}  %s\n" "$*"; }
    warn()  { printf "${YELLOW}[WARN]${RESET}  %s\n" "$*" >&2; }
    error() { printf "${RED}[ERROR]${RESET} %s\n" "$*" >&2; }
else
    info()  { printf '[INFO]  %s\n' "$*"; }
    warn()  { printf '[WARN]  %s\n' "$*" >&2; }
    error() { printf '[ERROR] %s\n' "$*" >&2; }
fi

YAML/JSON/TOML config generation:

# Generate simple YAML
generate_config() {
    cat << EOF
app:
  name: ${APP_NAME}
  version: ${VERSION}
  debug: ${DEBUG:-false}
database:
  host: ${DB_HOST:-localhost}
  port: ${DB_PORT:-5432}
EOF
}

# Parse simple key=value config
while IFS='=' read -r key value; do
    [[ "$key" =~ ^[[:space:]]*# ]] && continue   # skip comments
    [[ -z "$key" ]] && continue                    # skip empty lines
    key="${key## }"                                 # trim leading space
    key="${key%% }"                                 # trim trailing space
    declare "$key=$value"
done < config.ini

15. Debugging (Ch. 22)

# Enable debug output
set -x          # print each command before execution
set -v          # print each line as read
set -e          # exit on first error
set -u          # treat unset variables as errors
set -o pipefail # pipelines fail if any command fails

# Selective debugging
set -x
# ... problematic section ...
set +x

# DEBUG trap (runs before each command)
trap 'echo "DEBUG: $BASH_COMMAND" >&2' DEBUG

# Bash profiling
# Run: bash -x script.sh 2> /tmp/trace.log
# Then: /usr/share/bashdb/bashdb /tmp/trace.log

# Useful debugging variables
echo "Script: ${BASH_SOURCE[0]}"
echo "Line: ${LINENO}"
echo "Func: ${FUNCNAME[0]:-main}"
echo "Exit code: $?"
echo "Args: $@"

# Check for common mistakes
shellcheck script.sh    # if shellcheck is installed

16. Security Best Practices (Ch. 28, 32)

# 1. Quote everything
rm -- "$filename"       # -- prevents filenames starting with -

# 2. Validate input
[[ "$input" =~ ^[0-9]+$ ]] || die "Not a number"

# 3. Use mktemp for temp files (prevent race conditions)
tmpfile=$(mktemp)       # never use predictable names like /tmp/myfile

# 4. Set restrictive permissions
umask 077               # new files created with 600/700 permissions

# 5. Avoid eval
# DANGEROUS: eval "$untrusted_input"
# SAFE: Use arrays and case statements instead

# 6. Prefer built-in checks over external commands
[[ -f "$file" ]]        # instead of: test -f "$file" (though equivalent)
[[ "$str" =~ ^[0-9]+$ ]] # instead of: echo "$str" | grep -q '^[0-9]+$'

# 7. Lock sensitive data in memory (Bash 4.4+)
read -r -s -p "Password: " password
# ... use password ...
unset password           # try to clear from memory (not guaranteed)

# 8. Sanitize filenames
sanitize_filename() {
    local name="$1"
    name="${name//[^a-zA-Z0-9._-]/_}"  # replace unsafe chars
    echo "$name"
}

# 9. Check for TOCTOU races (time-of-check-time-of-use)
# BAD:
[[ -f "$file" ]] && rm "$file"   # file could be replaced between check and rm
# GOOD:
rm -f "$file" 2>/dev/null         # atomically attempt removal

# 10. Avoid temp files in world-writable directories
# Use $TMPDIR or /tmp with restrictive umask

17. Portable Scripting (Ch. 37)

# For POSIX sh compatibility (Bash not guaranteed):
# Avoid: [[ ]], (( )), arrays, ${var/pattern/}, declare, local (though widely supported)
# Use: [ ], test, case, expr or $(( ))

# Detect Bash version
if ((BASH_VERSINFO[0] < 4)); then
    echo "Bash 4+ required" >&2
    exit 1
fi

# Feature detection
command -v jq &>/dev/null || { echo "jq required"; exit 1; }

# Portable dirname/basename
dir="$(dirname "$0")"
base="$(basename "$0")"

# Portable readlink (macOS compatibility)
realpath_portable() {
    local path="$1"
    cd "$(dirname "$path")" && echo "$(pwd)/$(basename "$path")"
}

18. Checklists

Before running any script:

  • set -euo pipefail or equivalent error handling
  • Trap set for cleanup (EXIT, ERR)
  • All variables quoted: "$var" not $var
  • Temp files use mktemp
  • Functions use local for variables
  • Array expansion uses "${arr[@]}" not ${arr[@]}
  • File operations use -- to end option parsing
  • Input validated before use
  • Colors disabled when not a terminal
  • usage() function exists and is called on bad args

Source: Advanced Bash-Scripting Guide by Mendel Cooper, distilled into actionable patterns.

What ships with it: 1 file

280 B alongside SKILL.md

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.