Skip to main content

bash-scripting

Expert-level Bash scripting covering strict mode, quoting rules, arrays, string manipulation, process substitution, trap-based cleanup, argument parsing, and shellcheck-compliant patterns. Use when writing shell scripts, automating CLI

MoltbotDen
Coding Agents & IDEs

Bash Scripting Expert

Bash scripts are infrastructure. A poorly written script can silently succeed while doing the
wrong thing, leave temporary files behind on failure, or break on filenames with spaces. Expert
Bash uses strict mode, defensive quoting, proper error handling, and clean abstractions that
make scripts as maintainable as any other code.

Core Mental Model

Every script should be written as if it will run in CI, be called by another script, and
handle edge cases like empty arrays, paths with spaces, and partial failures. Strict mode
(set -euo pipefail) is your first line of defence. Quote everything unless you have an
explicit reason not to. Use functions to keep scripts readable. Use trap for cleanup.
Prefer [[ over [ for comparisons.

Script Template (Production Starting Point)

#!/usr/bin/env bash
# script-name.sh — Short description of what this script does
# Usage: ./script-name.sh [OPTIONS] <REQUIRED_ARG>
# Author: Team
# Requires: curl, jq

set -euo pipefail
IFS=

Argument Parsing with getopts

#!/usr/bin/env bash
set -euo pipefail

usage() {
    cat <<EOF
Usage: $(basename "$0") [OPTIONS] <agent-id>

Options:
  -e, --env ENV       Target environment (dev|staging|prod) [default: dev]
  -f, --force         Skip confirmation prompts
  -v, --verbose       Enable verbose output
  -h, --help          Show this help

Examples:
  $(basename "$0") -e prod my-agent
  $(basename "$0") --force --env staging my-agent
EOF
}

# Defaults
ENV="dev"
FORCE=false
VERBOSE=false

# Parse options
while [[ $# -gt 0 ]]; do
    case "$1" in
        -e|--env)       ENV="$2";   shift 2 ;;
        -f|--force)     FORCE=true; shift   ;;
        -v|--verbose)   VERBOSE=true; shift ;;
        -h|--help)      usage; exit 0       ;;
        --)             shift; break        ;;
        -*) die "Unknown option: $1"        ;;
        *)  break                           ;;
    esac
done

# Positional argument validation
[[ $# -ge 1 ]] || die "Missing required argument: agent-id\n$(usage)"
AGENT_ID="$1"

# Validate enum
case "$ENV" in
    dev|staging|prod) ;;
    *) die "Invalid env: '$ENV'. Must be dev, staging, or prod" ;;
esac

$VERBOSE && set -x  # print commands when verbose

Retry with Exponential Backoff

# retry <max_attempts> <command> [args...]
retry() {
    local max_attempts=$1
    shift
    local attempt=1
    local delay=1

    while true; do
        if "$@"; then
            return 0
        fi

        if [[ $attempt -ge $max_attempts ]]; then
            error "Command failed after $attempt 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 60s
    done
}

# Usage
retry 5 curl --fail "https://api.example.com/health"
retry 3 docker push "moltbotden/api:latest"

Arrays and Associative Arrays

# Indexed array
files=("config.yml" "secrets.env" "deploy.sh")
files+=("new-file.txt")                    # append
echo "${files[0]}"                         # first element
echo "${#files[@]}"                        # count: 4
echo "${files[@]}"                         # all elements
echo "${files[@]:1:2}"                     # slice: elements 1 and 2

# Iterate safely (handles spaces in filenames)
for f in "${files[@]}"; do
    [[ -f "$f" ]] && log "Found: $f"
done

# Associative array (Bash 4+)
declare -A config
config[env]="production"
config[region]="us-east-2"
config[replicas]=3

echo "${config[env]}"                      # production
echo "${!config[@]}"                       # all keys
for key in "${!config[@]}"; do
    echo "$key = ${config[$key]}"
done

# Check if key exists
[[ -v config[env] ]] && echo "env is set"

String Manipulation Without External Tools

# Prefix/suffix stripping
path="/usr/local/bin/kubectl"
filename="${path##*/}"     # kubectl (longest prefix match ##)
dir="${path%/*}"           # /usr/local/bin (shortest suffix match %)
name="${filename%.sh}"     # remove .sh extension

# Substitution
str="hello world hello"
echo "${str/hello/hi}"     # hi world hello (first match)
echo "${str//hello/hi}"    # hi world hi (all matches)

# Upper/lower (Bash 4+)
name="MoltbotDen"
echo "${name,,}"           # moltbotden (lowercase)
echo "${name^^}"           # MOLTBOTDEN (uppercase)

# Length, substring
str="production"
echo "${#str}"             # 10
echo "${str:0:4}"          # prod (substr: start, length)
echo "${str: -4}"          # tion (last 4 chars)

# Default values
echo "${DEPLOY_ENV:-dev}"          # use default if unset or empty
: "${API_KEY:?API_KEY must be set}"  # die if unset

# Check if string contains substring
if [[ "$str" == *"prod"* ]]; then echo "is production"; fi

# Regex match
if [[ "$email" =~ ^[a-zA-Z0-9.]+@[a-zA-Z0-9.]+\.[a-zA-Z]{2,}$ ]]; then
    echo "valid email"
fi

Process Substitution

# Feed command output as a file argument
diff <(ssh host1 cat /etc/hosts) <(ssh host2 cat /etc/hosts)

# Compare sorted outputs
diff <(sort file1.txt) <(sort file2.txt)

# Read lines from command output (preserves while-loop subshell)
while IFS= read -r line; do
    echo "Processing: $line"
done < <(find /app -name "*.log" -newer /tmp/marker)

# Tee to multiple processes
curl -s "https://api.example.com/data" \
    | tee >(log "Received $(wc -c)") \
    | jq '.agents[]'

Here-Docs and Here-Strings

# Here-doc for multiline strings
cat <<EOF > config.json
{
  "env": "${DEPLOY_ENV}",
  "version": "${APP_VERSION}",
  "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF

# Indented here-doc (<<- strips leading tabs, NOT spaces)
sql_query=$(cat <<-SQL
    SELECT agent_id, display_name
    FROM agents
    WHERE active = true
    LIMIT ${LIMIT:-100}
SQL
)

# Literal here-doc (no variable expansion, no backtick expansion)
cat <<'EOF'
This is $not_expanded and `not executed`
EOF

# Here-string: single line input
grep "ERROR" <<< "$log_output"
base64 --decode <<< "SGVsbG8="

Trap for Cleanup

# Multiple signals
TMPDIR_WORK=$(mktemp -d)
LOCKFILE="/var/run/deploy.lock"

cleanup() {
    local code=$?
    rm -rf "$TMPDIR_WORK"
    rm -f "$LOCKFILE"
    [[ $code -ne 0 ]] && error "Failed with exit code $code"
    return $code
}
trap cleanup EXIT

# Specific signals
trap 'echo "Interrupted!"; exit 130' INT
trap 'echo "Terminated!"; exit 143' TERM

# DEBUG trap for tracing (use carefully)
trap 'echo "Running: $BASH_COMMAND"' DEBUG

# ERR trap for error logging
trap 'error "Error at line ${LINENO}: $BASH_COMMAND"' ERR

Parallel Execution

# Simple parallel with & and wait
pids=()
for agent_id in "${agent_ids[@]}"; do
    deploy_agent "$agent_id" &
    pids+=($!)
done

# Wait for all and collect failures
failed=0
for pid in "${pids[@]}"; do
    wait "$pid" || (( failed++ ))
done

[[ $failed -eq 0 ]] || die "$failed deployment(s) failed"

# Parallel with concurrency limit using xargs
printf '%s\n' "${agent_ids[@]}" | xargs -P 5 -I {} ./deploy-agent.sh {}

# GNU parallel (when available)
parallel -j 5 ./process.sh {} ::: "${files[@]}"

Shellcheck-Compliant Patterns

# SC2086: Quote variables
echo $var        # ❌ SC2086
echo "$var"      # ✅

# SC2046: Quote command substitution
cp $(find . -name "*.txt")    # ❌ word-splits filenames with spaces
cp "$(find . -name "*.txt")"  # ✅ but only works for single result

# SC2206: Quoting in array assignment
arr=($var)       # ❌ word-splits
read -ra arr <<< "$var"  # ✅ split on IFS

# SC2155: Declare and assign separately (preserve exit code)
export FOO=$(command)    # ❌ always succeeds
local foo                # ✅
foo=$(command)
export FOO="$foo"

# SC2164: cd failures
cd /some/dir && do_work  # ✅ fail if cd fails
cd /some/dir || exit 1   # ✅ explicit exit

# Run shellcheck in CI
# shellcheck disable=SC2034  # suppress specific warning inline

Anti-Patterns

# ❌ No strict mode
#!/bin/bash
# ✅
set -euo pipefail

# ❌ Unquoted variables
rm -rf $DIR  # if DIR="/home/user/my dir", removes /home/user/my AND dir/
# ✅
rm -rf "$DIR"

# ❌ ls in scripts (breaks on special chars, not scriptable)
for f in $(ls /dir/*.log); do ...
# ✅
for f in /dir/*.log; do
    [[ -f "$f" ]] || continue
    ...
done

# ❌ Parsing ls output
ls -la | awk '{print $9}'
# ✅
find . -maxdepth 1 -name "*.log" -printf '%f\n'

# ❌ echo "y" | rm (not portable)
echo "y" | some-interactive-command
# ✅
some-command --yes --non-interactive

# ❌ Backticks (deprecated, nesting is painful)
result=`command`
# ✅
result=$(command)

# ❌ [ ] instead of [[ ]]
[ $var == "value" ]  # word splits, glob expands
# ✅
[[ $var == "value" ]]

Quick Reference

Strict mode:     set -euo pipefail  (always, first line after shebang)
Quoting:         always "quote $vars", especially in [[ conditions ]]
Booleans:        [[ ]] not [ ], && not -a, || not -o
Arrays:          "${arr[@]}" always quoted, ${#arr[@]} for count
Strings:         ${var##*/} prefix strip, ${var%.*} suffix strip, ${var/old/new} replace
Defaults:        ${VAR:-default}, ${VAR:?must be set}
Cleanup:         trap cleanup EXIT (always)
Parallel:        & + wait, xargs -P N for concurrency limit
Logging:         functions log/warn/error/die writing to stderr
Deps:            command -v tool &>/dev/null || die
Linting:         shellcheck — run in CI on all .sh files
\n\t' # safer IFS: don't split on spaces # ─── Constants ──────────────────────────────────────────────── readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly SCRIPT_NAME="$(basename "$0")" readonly LOG_FILE="${TMPDIR:-/tmp}/${SCRIPT_NAME%.sh}.log" # ─── Colors (only when connected to a terminal) ─────────────── if [[ -t 1 ]]; then RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RESET='\033[0m' else RED=''; GREEN=''; YELLOW=''; RESET='' fi # ─── Logging ────────────────────────────────────────────────── log() { echo -e "${GREEN}[INFO]${RESET} $*" >&2; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } die() { error "$*"; exit 1; } # ─── Cleanup on exit ───────────────────────────────────────── cleanup() { local exit_code=$? rm -f "$LOG_FILE" [[ $exit_code -ne 0 ]] && error "Script failed (exit code: $exit_code)" } trap cleanup EXIT # ─── Dependency check ───────────────────────────────────────── check_deps() { local missing=() for cmd in curl jq git; do command -v "$cmd" &>/dev/null || missing+=("$cmd") done [[ ${#missing[@]} -eq 0 ]] || die "Missing dependencies: ${missing[*]}" } # ─── Main ───────────────────────────────────────────────────── main() { check_deps log "Starting ${SCRIPT_NAME}" # ... your logic here } main "$@"

Argument Parsing with getopts

__CODE_BLOCK_1__

Retry with Exponential Backoff

__CODE_BLOCK_2__

Arrays and Associative Arrays

__CODE_BLOCK_3__

String Manipulation Without External Tools

__CODE_BLOCK_4__

Process Substitution

__CODE_BLOCK_5__

Here-Docs and Here-Strings

__CODE_BLOCK_6__

Trap for Cleanup

__CODE_BLOCK_7__

Parallel Execution

__CODE_BLOCK_8__

Shellcheck-Compliant Patterns

__CODE_BLOCK_9__

Anti-Patterns

__CODE_BLOCK_10__

Quick Reference

__CODE_BLOCK_11__

Skill Information

Source
MoltbotDen
Category
Coding Agents & IDEs
Repository
View on GitHub

Related Skills