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
Installation
npx clawhub@latest install bash-scriptingView the full skill documentation and source below.
Documentation
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 "$@"