Marketplace

shell-best-practices

Use when writing shell scripts following modern best practices. Covers portable scripting, Bash patterns, error handling, and secure coding.

allowed_tools: Read, Write, Edit, Bash, Grep, Glob

$ Installieren

git clone https://github.com/TheBushidoCollective/han /tmp/han && cp -r /tmp/han/jutsu/jutsu-shfmt/skills/shell-best-practices ~/.claude/skills/han

// tip: Run this command in your terminal to install the skill


name: shell-best-practices description: Use when writing shell scripts following modern best practices. Covers portable scripting, Bash patterns, error handling, and secure coding. allowed-tools:

  • Read
  • Write
  • Edit
  • Bash
  • Grep
  • Glob

Shell Scripting Best Practices

Comprehensive guide to writing robust, maintainable, and secure shell scripts following modern best practices.

Script Foundation

Shebang Selection

Choose the appropriate shebang for your needs:

# Portable bash (recommended)
#!/usr/bin/env bash

# Direct bash path (faster, less portable)
#!/bin/bash

# POSIX-compliant shell (most portable)
#!/bin/sh

# Specific shell version
#!/usr/bin/env bash
# Requires Bash 4.0+

Strict Mode

Always enable strict error handling:

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

# What these do:
# -e: Exit immediately on command failure
# -u: Treat unset variables as errors
# -o pipefail: Pipeline fails if any command fails

For debugging, add:

set -x  # Print commands as they execute

Script Header Template

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

# Script: script-name.sh
# Description: Brief description of what this script does
# Usage: ./script-name.sh [options] <arguments>

readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"

Variable Handling

Always Quote Variables

Prevents word splitting and glob expansion:

# Good
echo "$variable"
cp "$source" "$destination"
if [ -f "$file" ]; then

# Bad - can break on spaces/special chars
echo $variable
cp $source $destination
if [ -f $file ]; then

Use Meaningful Names

# Good
readonly config_file="/etc/app/config.yml"
local user_input="$1"
declare -a log_files=()

# Bad
readonly f="/etc/app/config.yml"
local x="$1"
declare -a arr=()

Default Values

# Use default if unset
name="${NAME:-default_value}"

# Use default if unset or empty
name="${NAME:-}"

# Assign default if unset
: "${NAME:=default_value}"

# Error if unset (with message)
: "${REQUIRED_VAR:?Error: REQUIRED_VAR must be set}"

Readonly and Local

# Constants
readonly MAX_RETRIES=3
readonly CONFIG_DIR="/etc/myapp"

# Function-local variables
my_function() {
    local input="$1"
    local result=""
    # ...
}

Error Handling

Exit Codes

Use meaningful exit codes:

# Standard codes
readonly EXIT_SUCCESS=0
readonly EXIT_FAILURE=1
readonly EXIT_INVALID_ARGS=2
readonly EXIT_NOT_FOUND=3

# Exit with code
exit "$EXIT_FAILURE"

Trap for Cleanup

cleanup() {
    local exit_code=$?
    # Clean up temporary files
    rm -f "${temp_file:-}"
    # Restore state if needed
    exit "$exit_code"
}

trap cleanup EXIT

# Script continues...
temp_file=$(mktemp)

Error Messages

error() {
    echo "ERROR: $*" >&2
}

warn() {
    echo "WARNING: $*" >&2
}

die() {
    error "$@"
    exit 1
}

# Usage
[[ -f "$config_file" ]] || die "Config file not found: $config_file"

Validate Inputs

validate_args() {
    if [[ $# -lt 1 ]]; then
        die "Usage: $SCRIPT_NAME <input_file>"
    fi

    local input_file="$1"
    [[ -f "$input_file" ]] || die "File not found: $input_file"
    [[ -r "$input_file" ]] || die "File not readable: $input_file"
}

Functions

Function Definition

# Document functions
# Process a log file and extract errors
# Arguments:
#   $1 - Path to log file
#   $2 - Output directory (optional, default: ./output)
# Returns:
#   0 on success, 1 on failure
process_log() {
    local log_file="$1"
    local output_dir="${2:-./output}"

    [[ -f "$log_file" ]] || return 1

    grep -i "error" "$log_file" > "$output_dir/errors.log"
}

Return Values

# Return status
is_valid() {
    [[ -n "$1" && "$1" =~ ^[0-9]+$ ]]
}

if is_valid "$input"; then
    echo "Valid"
fi

# Capture output
get_config_value() {
    local key="$1"
    grep "^${key}=" "$config_file" | cut -d= -f2
}

value=$(get_config_value "database_host")

Conditionals

Use [[ ]] for Tests

# Good - [[ ]] is more powerful and safer
if [[ -f "$file" ]]; then
if [[ "$string" == "value" ]]; then
if [[ "$string" =~ ^[0-9]+$ ]]; then

# Avoid - [ ] has limitations
if [ -f "$file" ]; then
if [ "$string" = "value" ]; then

Numeric Comparisons

# Use (( )) for arithmetic
if (( count > 10 )); then
if (( a == b )); then
if (( x >= 0 && x <= 100 )); then

# Or -eq/-lt/-gt in [[ ]]
if [[ "$count" -gt 10 ]]; then

String Comparisons

# Equality
if [[ "$str" == "value" ]]; then

# Pattern matching
if [[ "$str" == *.txt ]]; then

# Regex matching
if [[ "$str" =~ ^[a-z]+$ ]]; then

# Empty/non-empty
if [[ -z "$str" ]]; then  # empty
if [[ -n "$str" ]]; then  # non-empty

Loops

Iterate Over Files

# Good - handles spaces in filenames
for file in *.txt; do
    [[ -e "$file" ]] || continue  # Skip if no matches
    process "$file"
done

# With find for recursive
while IFS= read -r -d '' file; do
    process "$file"
done < <(find . -name "*.txt" -print0)

# Bad - breaks on spaces
for file in $(ls *.txt); do  # Don't do this

Read Lines from File

# Correct - preserves whitespace
while IFS= read -r line; do
    echo "$line"
done < "$filename"

# With process substitution
while IFS= read -r line; do
    echo "$line"
done < <(some_command)

Iterate with Index

files=("one.txt" "two.txt" "three.txt")

for i in "${!files[@]}"; do
    echo "Index $i: ${files[i]}"
done

Arrays

Declaration and Usage

# Indexed array
declare -a files=()
files+=("file1.txt")
files+=("file2.txt")

# Access all elements
for f in "${files[@]}"; do
    echo "$f"
done

# Array length
echo "${#files[@]}"

# Associative array (Bash 4+)
declare -A config
config[host]="localhost"
config[port]="8080"

echo "${config[host]}"

Array Best Practices

# Quote expansions
"${array[@]}"   # All elements, word-split
"${array[*]}"   # All elements, single string

# Check if empty
if [[ ${#array[@]} -eq 0 ]]; then
    echo "Empty array"
fi

# Check for key (associative)
if [[ -v config[key] ]]; then
    echo "Key exists"
fi

Command Execution

Check Command Existence

# Preferred method
if command -v docker &>/dev/null; then
    echo "Docker is installed"
fi

# In conditionals
require_command() {
    command -v "$1" &>/dev/null || die "Required command not found: $1"
}

require_command git
require_command docker

Capture Output and Status

# Capture output
output=$(some_command)

# Capture output and status
if output=$(some_command 2>&1); then
    echo "Success: $output"
else
    echo "Failed: $output" >&2
fi

# Check status without output
if some_command &>/dev/null; then
    echo "Command succeeded"
fi

Safe Command Substitution

# Use $() not backticks
result=$(command)      # Good
result=`command`       # Avoid

# Nested substitution
result=$(echo $(date)) # Works with $()

Portability

POSIX vs Bash

FeaturePOSIXBash
Test syntax[ ][[ ]]
ArraysNoYes
$()YesYes
${var//pat/rep}NoYes
[[ =~ ]] regexNoYes
(( )) arithmeticNoYes

Portable Alternatives

# Instead of [[ ]], use [ ] with quotes
if [ -f "$file" ]; then
if [ "$str" = "value" ]; then

# Instead of (( )), use [ ] with -eq
if [ "$count" -gt 10 ]; then

# Instead of ${var//pat/rep}
echo "$var" | sed 's/pat/rep/g'

# Instead of arrays, use space-separated strings
files="one.txt two.txt three.txt"
for f in $files; do
    echo "$f"
done

Security

Avoid Eval

# Bad - code injection risk
eval "$user_input"

# Better - use arrays for command building
cmd=("grep" "-r" "$pattern" "$directory")
"${cmd[@]}"

Sanitize Inputs

# Validate expected format
if [[ ! "$input" =~ ^[a-zA-Z0-9_-]+$ ]]; then
    die "Invalid input format"
fi

# Escape for use in commands
escaped=$(printf '%q' "$input")

Temporary Files

# Secure temp file creation
temp_file=$(mktemp) || die "Failed to create temp file"
trap 'rm -f "$temp_file"' EXIT

# Secure temp directory
temp_dir=$(mktemp -d) || die "Failed to create temp dir"
trap 'rm -rf "$temp_dir"' EXIT

Logging

Basic Logging

readonly LOG_FILE="/var/log/myapp.log"

log() {
    local level="$1"
    shift
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a "$LOG_FILE"
}

log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@" >&2; }
log_error() { log "ERROR" "$@" >&2; }

# Usage
log_info "Starting process"
log_error "Failed to connect"

Verbose Mode

VERBOSE="${VERBOSE:-false}"

debug() {
    if [[ "$VERBOSE" == "true" ]]; then
        echo "DEBUG: $*" >&2
    fi
}

# Enable with: VERBOSE=true ./script.sh

Complete Script Template

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

# =============================================================================
# Script: example.sh
# Description: Template demonstrating shell best practices
# Usage: ./example.sh [options] <input_file>
# =============================================================================

readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"

# Exit codes
readonly EXIT_SUCCESS=0
readonly EXIT_FAILURE=1
readonly EXIT_INVALID_ARGS=2

# Logging functions
log_info() { echo "[INFO] $*"; }
log_error() { echo "[ERROR] $*" >&2; }

# Error handling
die() {
    log_error "$@"
    exit "$EXIT_FAILURE"
}

cleanup() {
    local exit_code=$?
    rm -f "${temp_file:-}"
    exit "$exit_code"
}
trap cleanup EXIT

# Argument parsing
usage() {
    cat <<EOF
Usage: $SCRIPT_NAME [options] <input_file>

Options:
    -h, --help      Show this help message
    -v, --verbose   Enable verbose output
    -o, --output    Output directory (default: ./output)

Examples:
    $SCRIPT_NAME input.txt
    $SCRIPT_NAME -v -o /tmp/output input.txt
EOF
}

parse_args() {
    local OPTIND opt
    while getopts ":hvo:-:" opt; do
        case "$opt" in
            h) usage; exit "$EXIT_SUCCESS" ;;
            v) VERBOSE=true ;;
            o) OUTPUT_DIR="$OPTARG" ;;
            -) case "$OPTARG" in
                   help) usage; exit "$EXIT_SUCCESS" ;;
                   verbose) VERBOSE=true ;;
                   output=*) OUTPUT_DIR="${OPTARG#*=}" ;;
                   *) die "Unknown option: --$OPTARG" ;;
               esac ;;
            :) die "Option -$OPTARG requires an argument" ;;
            \?) die "Unknown option: -$OPTARG" ;;
        esac
    done
    shift $((OPTIND - 1))

    if [[ $# -lt 1 ]]; then
        usage
        exit "$EXIT_INVALID_ARGS"
    fi

    INPUT_FILE="$1"
}

# Validate inputs
validate() {
    [[ -f "$INPUT_FILE" ]] || die "File not found: $INPUT_FILE"
    [[ -r "$INPUT_FILE" ]] || die "File not readable: $INPUT_FILE"
    mkdir -p "$OUTPUT_DIR" || die "Cannot create output directory"
}

# Main logic
main() {
    # Defaults
    VERBOSE="${VERBOSE:-false}"
    OUTPUT_DIR="${OUTPUT_DIR:-./output}"

    parse_args "$@"
    validate

    log_info "Processing $INPUT_FILE"
    # ... main logic here ...
    log_info "Done"
}

main "$@"

When to Use This Skill

  • Writing new shell scripts from scratch
  • Reviewing shell scripts for issues
  • Refactoring legacy shell code
  • Debugging script failures
  • Improving script security
  • Making scripts more portable
  • Setting up proper error handling