Skills/shell-scripting/SKILL.md
Practical bash scripting guidance emphasising defensive programming, ShellCheck compliance, and simplicity. Use when writing shell scripts that need to be reliable and maintainable.
npx skillsauth add sammcj/agentic-coding shell-scriptingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Guidance for writing reliable, maintainable bash scripts following modern best practices. Emphasises simplicity, automated tooling, and defensive programming without over-engineering.
Critical: If your script grows too large (1000+ lines) or complex, consider offering to rewrite it in a proper language (Python, Go, etc.) before it becomes unmaintainable.
Every bash script must have these elements:
#!/usr/bin/env bash
Why: Portable across systems where bash may not be at /bin/bash (e.g., macOS, BSD, NixOS).
Alternative: #!/bin/bash if you know the script only runs on Linux and prefer explicit paths.
set -euo pipefail
What each flag does:
-e: Exit immediately if any command fails (non-zero exit)-u: Treat unset variables as errors-o pipefail: Pipe fails if ANY command in pipeline fails (not just the last)When to add -x: Only for debugging, not in production scripts (makes output noisy).
Run ShellCheck on EVERY script before committing:
shellcheck script.sh
Fix all warnings. ShellCheck catches:
#!/usr/bin/env bash
set -euo pipefail
# Brief description of what this script does
# Simple error reporting
die() {
echo "Error: ${1}" >&2
exit 1
}
# Your code here
Why: Prevents word splitting and globbing disasters.
# Wrong - dangerous
cp $source $destination
rm -rf $prefix/bin
# Correct - safe
cp "${source}" "${destination}"
rm -rf "${prefix}/bin"
# Special case: Always use braces with variables
echo "${var}" # Good
echo "$var" # Acceptable but less consistent
echo $var # Bad - unquoted
# Fail fast if required variables aren't set
: "${REQUIRED_VAR:?REQUIRED_VAR must be set}"
# Or with custom message
: "${DATABASE_URL:?DATABASE_URL is required. Set it in .env}"
# Check file exists before operating on it
[[ -f "${config_file}" ]] || die "Config file not found: ${config_file}"
# Check command exists before using it
command -v jq >/dev/null 2>&1 || die "jq is required but not installed"
# Validate directory before cd
[[ -d "${target_dir}" ]] || die "Directory does not exist: ${target_dir}"
Use this for straightforward scripts:
#!/usr/bin/env bash
set -euo pipefail
# Description: Process log files and extract errors
die() {
echo "Error: ${1}" >&2
exit 1
}
# Check dependencies
command -v jq >/dev/null 2>&1 || die "jq required"
# Validate arguments
[[ $# -eq 1 ]] || die "Usage: ${0} <logfile>"
logfile="${1}"
[[ -f "${logfile}" ]] || die "File not found: ${logfile}"
# Main logic
grep ERROR "${logfile}" | jq -r '.message'
Use trap for guaranteed cleanup:
#!/usr/bin/env bash
set -euo pipefail
# Create temp directory and ensure cleanup
tmpdir=$(mktemp -d)
trap 'rm -rf "${tmpdir}"' EXIT
# Now use tmpdir safely - cleanup happens automatically
echo "Working in: ${tmpdir}"
Functions should be simple and focused:
# Good: Simple, single-purpose function
check_dependency() {
local cmd="${1}"
command -v "${cmd}" >/dev/null 2>&1 || die "${cmd} not installed"
}
# Good: Local variables, clear purpose
process_file() {
local file="${1}"
local output="${2}"
[[ -f "${file}" ]] || die "Input file missing: ${file}"
# Do processing
sed 's/foo/bar/g' "${file}" > "${output}"
}
Important: Declare and set variables from command substitution separately to catch errors:
# Wrong - hides errors
local result="$(failing_command)"
# Correct - catches errors
local result
result="$(failing_command)" # Will fail properly with set -e
Arrays are useful for handling lists with spaces:
# Create array
declare -a files=("file one.txt" "file two.txt" "file three.txt")
# Iterate safely - always quote with [@]
for file in "${files[@]}"; do
echo "Processing: ${file}"
done
# Build command arguments safely
declare -a flags=(--verbose --output "${output_file}")
mycommand "${flags[@]}" "${input}"
# Read command output into array
mapfile -t lines < <(grep pattern "${file}")
Use [[ ]] for bash (safer and more features):
# File tests
[[ -f "${file}" ]] # File exists
[[ -d "${dir}" ]] # Directory exists
[[ -r "${file}" ]] # File readable
[[ -w "${file}" ]] # File writable
[[ -x "${binary}" ]] # File executable
# String tests
[[ -z "${var}" ]] # String is empty
[[ -n "${var}" ]] # String is not empty
[[ "${a}" == "${b}" ]] # String equality (use ==, not =)
# Numeric comparison (use (( )) for numbers)
(( count > 0 ))
(( total >= minimum ))
# Combined conditions
[[ -f "${file}" && -r "${file}" ]] || die "File not readable: ${file}"
For simple scripts, prefer positional arguments:
#!/usr/bin/env bash
set -euo pipefail
# For 1-3 arguments, just use positional parameters
[[ $# -eq 2 ]] || die "Usage: ${0} <source> <dest>"
source="${1}"
dest="${2}"
[[ -f "${source}" ]] || die "Source not found: ${source}"
For scripts needing flags, keep it simple:
# Use environment variables instead of complex flag parsing
VERBOSE="${VERBOSE:-false}"
DRY_RUN="${DRY_RUN:-false}"
# Run like: VERBOSE=true DRY_RUN=true ./script.sh input.txt
Avoid creating temporary files when possible:
# Instead of:
first_command > /tmp/output.txt
second_command < /tmp/output.txt
rm /tmp/output.txt
# Use process substitution:
second_command <(first_command)
# For multiple inputs:
diff <(sort file1.txt) <(sort file2.txt)
Builtins are faster and more reliable:
# Use bash parameter expansion over sed/awk for simple cases
filename="${path##*/}" # basename
dirname="${path%/*}" # dirname
extension="${filename##*.}" # get extension
name="${filename%.*}" # remove extension
# Use (( )) for arithmetic over expr
count=$(( count + 1 )) # Not: count=$(expr ${count} + 1)
# Use [[ ]] over [ ] or test
[[ -f "${file}" ]] # Not: test -f "${file}"
# Use ${#var} for string length
length="${#string}" # Not: length=$(echo "${string}" | wc -c)
Keep logging simple and consistent:
log() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] ${1}" >&2
}
error() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: ${1}" >&2
}
# Usage
log "Starting process"
error "Failed to connect to database"
For longer scripts (50+ lines), use a main function:
#!/usr/bin/env bash
set -euo pipefail
setup() {
# Dependency checks, variable initialisation
command -v jq >/dev/null 2>&1 || die "jq required"
}
process() {
# Main logic here
log "Processing data"
}
cleanup() {
# Cleanup if needed
log "Cleanup complete"
}
main() {
setup
process
cleanup
}
# Call main with all script arguments
main "${@}"
Scripts should be safe to run multiple times:
# Check before creating
if [[ ! -d "${target_dir}" ]]; then
mkdir -p "${target_dir}"
fi
# Check before writing config
if [[ ! -f "${config_file}" ]]; then
echo "DEFAULT_VALUE=true" > "${config_file}"
fi
# Use atomic operations
mv "${source}" "${dest}" # Atomic on same filesystem
Don't pipe to while (creates subshell):
# Wrong - variables modified in subshell are lost
count=0
cat file.txt | while read -r line; do
(( count++ ))
done
echo "${count}" # Will be 0!
# Correct - use process substitution
count=0
while read -r line; do
(( count++ ))
done < <(cat file.txt)
echo "${count}" # Correct count
# Or use mapfile for simple cases
mapfile -t lines <file.txt
count="${#lines[@]}"
# Long command - break at logical points
docker run \
--name my-container \
--volume "${PWD}:/data" \
--env "FOO=bar" \
my-image:latest
# Long string - use here-doc
cat <<EOF
This is a long message
that spans multiple lines
and is more readable this way.
EOF
# Functions: lowercase with underscores
check_dependencies() { ... }
process_files() { ... }
# Local variables: lowercase with underscores
local input_file="${1}"
local line_count=0
# Constants/environment variables: UPPERCASE with underscores
readonly MAX_RETRIES=3
readonly CONFIG_DIR="/etc/myapp"
# Source files: lowercase with underscores
# my_library.sh
.sh extension OR no extension (prefer no extension for user-facing commands).sh extension and NOT executableDocument functions that aren't obvious:
# Good: Simple function, no comment needed (name says it all)
check_file_exists() {
[[ -f "${1}" ]]
}
# Good: Complex function, documented
# Processes log files and extracts error messages
# Arguments:
# $1 - Input log file path
# $2 - Output directory
# Returns:
# 0 on success, 1 on failure
process_logs() {
local logfile="${1}"
local output_dir="${2}"
# Implementation
}
# Don't use backticks - use $()
output=`command` # Old style
output=$(command) # Correct
# Don't use eval - almost always wrong
eval "${user_input}" # Dangerous!
# Don't use expr - use (( ))
result=$(expr 5 + 3) # Old style
result=$(( 5 + 3 )) # Correct
# Don't use [ ] when [[ ]] is available
[ -f "${file}" ] # POSIX compatible, less features
[[ -f "${file}" ]] # Bash, safer and more features
# Don't use $[ ] for arithmetic - deprecated
result=$[5 + 3] # Deprecated
result=$(( 5 + 3 )) # Correct
# Don't use function keyword unnecessarily
function foo() { ... } # Redundant
foo() { ... } # Cleaner
# Don't glob or split unquoted
rm ${files} # DANGEROUS
rm "${files}" # Safe
# Don't use ls output in scripts
for file in $(ls); do # Breaks with spaces
for file in *; do # Correct
# Don't pipe yes to commands
yes | risky-command # Bypasses important prompts
# Don't ignore error codes
make build # Did it work?
make build || die "Build failed" # Better
If your script has any of these, consider rewriting in Python/Go:
For scripts that modify things:
DRY_RUN="${DRY_RUN:-false}"
run() {
if [[ "${DRY_RUN}" == "true" ]]; then
echo "[DRY RUN] ${*}" >&2
return 0
fi
"${@}"
}
# Usage
run cp "${source}" "${dest}"
run rm -f "${old_file}"
# Run script: DRY_RUN=true ./script.sh
Before considering a bash script complete:
#!/usr/bin/env bash)set -euo pipefail)"${var}")command -v not whicheval, ls parsing, or backticks"${var}" not $var.set -euo pipefail and validate inputs.Remember: Shell scripts are for gluing things together, not building complex logic. Keep them simple, safe, and focused.
development
Use when answering questions from this machine-learning knowledge base. Triggers: questions about transformers, attention cost and efficiency, and long-context scaling; 'what do we know about attention', 'check the ML wiki'. Read-only querying of compiled knowledge; to add, update, supersede, lint, or audit, use the llm-wiki skill instead.
development
Use when building or maintaining a self-contained personal knowledge base (an LLM wiki) as plain markdown, optionally opened as an Obsidian vault. Triggers: ingesting sources into a wiki, querying wiki knowledge, linting wiki health, auditing article claims against their sources, superseding stale knowledge, 'add to wiki', or any mention of 'LLM wiki' or 'Karpathy wiki'.
tools
Provides guidance and tools for hardware design. Activate when using KiCAD, looking up electronic parts or designing PCBs.
testing
Grilling session that challenges your plan against the existing domain model, sharpens terminology, and updates documentation (CONTEXT.md, ADRs) inline as decisions crystallise.