Expert shell scripting for bash, zsh, and POSIX sh. Covers safety patterns (set -euo pipefail, quoting, error handling), argument parsing, functions, file operations, process management, testing with bats/ShellCheck, and operational patterns (cron, locks, signals). Also covers zsh-specific features including .zshrc configuration, prompts, completions, and scripting differences. Use when writing bash/zsh scripts, configuring shells, or when user mentions .sh files, shell safety, or asks about zsh/bash differences.
Resources
9Install
npx skillscat add abhijit-without-h/shell-scripting-skill Install via the SkillsCat registry.
Shell Scripting Guide
Safe, production-grade shell scripts for bash, zsh, and POSIX sh.
Philosophy
Shell scripts fail silently and unpredictably by default. This skill teaches you to write scripts that:
- Fail fast and loudly when something goes wrong
- Quote religiously to handle spaces and special characters
- Validate inputs before destructive operations
- Clean up after themselves even when interrupted
- Run reliably in cron, CI/CD, and other non-interactive environments
Quick Start — The Safety Header
Every bash script must start with this:
#!/usr/bin/env bash
set -euo pipefail
# Optional but recommended:
# set -x # Debug mode — prints each command before executionWhat each flag does:
set -e— Exit immediately if any command fails (exit code ≠ 0)set -u— Exit if you reference an undefined variableset -o pipefail— Exit if any command in a pipeline fails (not just the last one)
For POSIX sh (Alpine, minimal containers):
#!/bin/sh
set -eu
# Note: pipefail is not POSIX, omit or check for bashFor zsh scripts:
#!/usr/bin/env zsh
setopt ERR_EXIT NO_UNSET PIPE_FAIL
# Or use: set -euo pipefail (bash compatibility)Core Safety Patterns
1. Always Quote Variables
# BAD — breaks on spaces, globs, fails silently
rm -rf $DIR
echo $USER_INPUT > file.txt
for file in $FILES; do echo $file; done
# GOOD — safe quoting
rm -rf "${DIR:?}" # :? fails if DIR is empty/unset
echo "$USER_INPUT" > file.txt
for file in "${FILES[@]}"; do # array-safe iteration
echo "$file"
done2. Use command -v, Never which
# BAD — which is not POSIX, may not exist
if which python >/dev/null; then
# GOOD — POSIX-compliant
if command -v python >/dev/null 2>&1; then
python script.py
fi3. Cleanup Traps
#!/usr/bin/env bash
set -euo pipefail
# Create temp file
TMP=$(mktemp)
# Always cleanup on exit (success, failure, or interrupt)
cleanup() {
rm -f "$TMP"
}
trap cleanup EXIT INT TERM
# Script continues...4. Error Handling
# die() helper function
die() {
echo "ERROR: $*" >&2
exit 1
}
# Validate preconditions
[[ -f "$CONFIG_FILE" ]] || die "Config file not found: $CONFIG_FILE"
[[ -n "${API_KEY:-}" ]] || die "API_KEY environment variable not set"
# Check command success
if ! curl -f "$URL" -o output.json; then
die "Failed to fetch $URL"
fiArgument Parsing
Positional Arguments with Validation
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<EOF
Usage: $0 <input-file> <output-file>
Converts input file to output format.
EOF
exit 1
}
# Validate argument count
[[ $# -eq 2 ]] || usage
INPUT="${1:?Input file required}"
OUTPUT="${2:?Output file required}"
# Validate input exists
[[ -f "$INPUT" ]] || die "Input file not found: $INPUT"getopts for Flags (POSIX)
#!/usr/bin/env bash
set -euo pipefail
VERBOSE=false
OUTPUT=""
usage() {
echo "Usage: $0 [-v] [-o output-file] input-file"
exit 1
}
while getopts "vo:h" opt; do
case $opt in
v) VERBOSE=true ;;
o) OUTPUT="$OPTARG" ;;
h) usage ;;
*) usage ;;
esac
done
shift $((OPTIND - 1))
# Remaining positional args
INPUT="${1:-}"
[[ -n "$INPUT" ]] || usageFunctions & Structure
The main() Pattern
#!/usr/bin/env bash
set -euo pipefail
# Helper functions
log() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" >&2
}
die() {
log "ERROR: $*"
exit 1
}
# Main logic
main() {
log "Starting backup..."
# Do work
if ! backup_database; then
die "Database backup failed"
fi
log "Backup complete"
}
backup_database() {
# Function implementation
mysqldump mydb > backup.sql
}
# Call main with all script arguments
main "$@"Function Best Practices
# Use local variables
process_file() {
local file="$1"
local temp_file
temp_file=$(mktemp)
# Process...
rm -f "$temp_file"
}
# Return values: use exit codes, not echo
file_exists() {
[[ -f "$1" ]]
# Returns 0 (success) if file exists, 1 otherwise
}
# Capture output when needed
result=$(get_timestamp)
# Readonly constants
readonly CONFIG_DIR="/etc/myapp"
readonly MAX_RETRIES=3File & Path Operations
Safe File Operations
# Script-relative paths
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="${SCRIPT_DIR}/config.ini"
# Test file types
[[ -f "$FILE" ]] # regular file
[[ -d "$DIR" ]] # directory
[[ -r "$FILE" ]] # readable
[[ -w "$FILE" ]] # writable
[[ -x "$FILE" ]] # executable
[[ -L "$LINK" ]] # symbolic link
[[ -s "$FILE" ]] # non-empty file
# Safe deletion — guard with variable check
[[ -n "${DIR:-}" ]] || die "DIR not set"
rm -rf "${DIR:?DIR must be set}"
# Atomic file writes
echo "data" > "$FILE.tmp"
mv "$FILE.tmp" "$FILE" # atomic on same filesystemDirectory Navigation
# BAD — loses error context
cd /tmp
do_work
# GOOD — fails if cd fails (with set -e)
cd /tmp || die "Failed to cd to /tmp"
do_work
# BETTER — automatic cleanup with subshell
(
cd /tmp || exit 1
do_work
) # automatically returns to original directory
# BEST — pushd/popd for explicit state
pushd /tmp > /dev/null || die "Failed to cd to /tmp"
do_work
popd > /dev/nullCommon Pitfalls to Avoid
| Bad Pattern | Why It's Bad | Safe Pattern |
|---|---|---|
rm -rf $DIR |
If $DIR is empty, deletes current directory |
rm -rf "${DIR:?}" |
if [ $? -eq 0 ] |
$? changes with every command |
if command; then |
ls | grep foo |
Breaks on spaces, parses ls output | find . -name "*foo*" or glob |
for f in $(ls) |
Word splitting breaks on spaces | for f in ./*; do |
which python |
Not POSIX, may not exist | command -v python |
cat file | grep pattern |
Useless use of cat (UUOC) | grep pattern file |
#!/bin/sh with [[ |
[[ is not POSIX |
Use [ or change shebang to bash |
echo $VAR |
Breaks on spaces and globs | echo "$VAR" |
Hardcoded /tmp/myfile |
Race conditions, permissions issues | mktemp |
exit 0 on error |
Misleading exit code | exit 1 or higher |
Testing & Linting
ShellCheck — Mandatory
Every script should pass ShellCheck:
# Install: brew install shellcheck (macOS) or apt install shellcheck (Linux)
shellcheck script.sh
# In CI/CD
find . -name "*.sh" -exec shellcheck {} +bats — Bash Automated Testing
See references/testing.md for full bats coverage.
# test.bats
#!/usr/bin/env bats
@test "script exits 0 on success" {
run ./backup.sh --dry-run
[ "$status" -eq 0 ]
}
@test "script fails without config file" {
run ./backup.sh
[ "$status" -ne 0 ]
[[ "$output" =~ "Config file not found" ]]
}Operational Patterns
Cron-Safe Scripts
#!/usr/bin/env bash
set -euo pipefail
# Use absolute paths (cron has minimal PATH)
PATH=/usr/local/bin:/usr/bin:/bin
# Log to file with rotation
LOG_FILE="/var/log/backup.log"
exec >> "$LOG_FILE" 2>&1
# Always log timestamp
echo "[$(date -Iseconds)] Starting backup"
# Rest of script...Lock Files (Prevent Concurrent Runs)
#!/usr/bin/env bash
set -euo pipefail
LOCK_FILE="/var/lock/backup.lock"
# Acquire lock
exec 200>"$LOCK_FILE"
flock -n 200 || {
echo "Another instance is running"
exit 1
}
# Script continues... lock released on exitSignal Handling
#!/usr/bin/env bash
set -euo pipefail
cleanup() {
echo "Caught signal, cleaning up..."
# Kill child processes
jobs -p | xargs -r kill
# Remove temp files
rm -f "$TMP_FILE"
exit 130 # 128 + SIGINT
}
trap cleanup INT TERM
# Long-running work...Shell Selection Guide
| Shell | Use When | Notes |
|---|---|---|
#!/usr/bin/env bash |
Default choice | Portable across Linux/macOS, rich features |
#!/bin/bash |
System scripts only | Hardcoded path, may not exist on all systems |
#!/bin/sh |
Maximum portability | Alpine, minimal containers. POSIX only. |
#!/usr/bin/env zsh |
zsh-specific features | Mostly for interactive config, not scripts |
Variable Naming
# UPPERCASE for environment variables and exported vars
export DATABASE_URL="postgresql://..."
export PATH="/usr/local/bin:$PATH"
# lowercase for local/internal variables
input_file="data.csv"
user_name="admin"
# Readonly for constants
readonly MAX_RETRIES=3
readonly CONFIG_DIR="/etc/myapp"zsh-Specific Content
For zsh scripting differences, interactive configuration (.zshrc), prompts, and completions, see:
references/zsh.md— Comprehensive zsh guide
Advanced Topics
See reference files for deep dives:
references/bash.md— Bash-specific features (arrays, string manipulation, advanced patterns)references/posix-sh.md— POSIX portable scripting constraints and patternsreferences/safety-patterns.md— Advanced error handling, input validation, securityreferences/argument-parsing.md— Complex CLI argument parsers, long optionsreferences/testing.md— bats, ShellCheck integration, debugging techniquesreferences/operational.md— Systemd units, log rotation, daemon patterns
Quick Reference Card
# Safety header (bash)
#!/usr/bin/env bash
set -euo pipefail
# Die helper
die() { echo "ERROR: $*" >&2; exit 1; }
# Temp file
TMP=$(mktemp)
trap 'rm -f "$TMP"' EXIT
# Validate args
[[ $# -ge 1 ]] || die "Usage: $0 <file>"
[[ -f "$1" ]] || die "File not found: $1"
# Check command exists
command -v jq >/dev/null || die "jq not installed"
# Safe variable reference
rm -rf "${DIR:?DIR must be set}"
# Array iteration
for item in "${array[@]}"; do
echo "$item"
done
# Function with local vars
process() {
local input="$1"
# ...
}
# Conditional execution
if command; then
# Success path
else
# Failure path
fiWhen to Use Each Skill Section
- Getting started with a script? → Safety header + core patterns
- Need argument parsing? → Argument parsing section
- Writing for production? → Operational patterns + testing
- Debugging failures? → Error handling + ShellCheck
- Configuring zsh? →
references/zsh.md - Need POSIX portability? →
references/posix-sh.md - Building a complex CLI? →
references/argument-parsing.md
Remember: The goal is safe, maintainable scripts that fail loudly and clean up after themselves. When in doubt, quote more, validate more, and test with ShellCheck.