Bash Scripting 30 ๐ง Advanced CLI Utilities โ Structuring enterprise-ready administrative tools
A script that starts as a single file with a few flags eventually becomes a tool that other people depend on. At that point, the concerns that were invisible in a small script become visible: how subcommands are organized, how options are parsed consistently, how logging is structured, how failures are reported, and how the tool behaves when it is invoked with no arguments or with an unknown subcommand. The difference between a script and an enterprise-ready utility is not the language; it is the structure.
This chapter covers the patterns that turn a Bash script into a maintainable administrative tool: the dispatcher pattern that routes subcommands to separate files or functions, the option parsing approach that handles both short and long options, the logging and error-handling conventions that make failures diagnosable, and the set -euo pipefail strict mode that prevents silent failures. By the end, you will understand how to structure a tool that another administrator can read, extend, and trust.
Key point: Enterprise-ready does not mean complex. It means predictable. A tool with a clear subcommand structure, consistent option parsing, structured logging, and strict error handling is enterprise-ready regardless of how many features it has.
Why structure matters more than features
The single-file growth problem. A script that begins as backup.sh with a few hardcoded paths becomes a file with hundreds of lines, nested conditionals, and option parsing scattered across the top. Adding a new capability requires understanding the entire file. The dispatcher pattern separates each subcommand into its own function or file, so a new capability is a new unit that does not require reading the existing ones .
The option parsing inconsistency problem. Bash’s builtin getopts handles short options (-v, -o file) but does not support long options (--verbose, --output file) . A tool that needs --help and --version in addition to short flags must either use the external GNU getopt command, which supports long options on Linux, or implement a manual while loop that handles both forms. The choice affects portability: GNU getopt is not available by default on macOS or FreeBSD .
The logging and error problem. A script that prints errors to stderr with echo and exits with status 1 is usable, but it does not tell the operator which command failed, on which line, or what the exit status of the failing command was. A logging function that includes a timestamp, a severity level, and the script name is the minimum for a tool that runs unattended. The set -euo pipefail header is the minimum for a tool that should fail fast rather than continue after an error .
a. The dispatcher pattern for subcommands
The dispatcher pattern routes the first argument to a function or a separate script file. For small tools, a case statement with a function per subcommand is sufficient. For larger tools, separate files in a subcommands/ directory keep each command isolated.
#!/bin/bash
set -euo pipefail
usage() {
cat <<EOF
Usage: $(basename "$0") <command> [options]
Commands:
backup Create a backup of the specified directory
restore Restore from a backup archive
verify Verify the integrity of a backup archive
help Show this help message
EOF
}
cmd_backup() {
echo "Running backup with args: $*"
# implementation
}
cmd_restore() {
echo "Running restore with args: $*"
# implementation
}
cmd_verify() {
echo "Running verify with args: $*"
# implementation
}
main() {
if [[ $# -eq 0 ]]; then
usage
exit 1
fi
local command="$1"
shift
case "$command" in
backup) cmd_backup "$@" ;;
restore) cmd_restore "$@" ;;
verify) cmd_verify "$@" ;;
help|-h|--help) usage; exit 0 ;;
*)
echo "Unknown command: $command" >&2
usage >&2
exit 1
;;
esac
}
main "$@"
The dispatcher separates the command name from its arguments. Each subcommand function receives only its own arguments and parses them independently. This isolation is what allows a new subcommand to be added without touching the existing ones .
For a file-based dispatcher, the main script locates a subcommands/ directory relative to its own path and executes the matching file. This pattern, used by tools like git, allows each subcommand to be a separate script in any language .
#!/bin/bash
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
export PROG="$0"
if [[ $# -eq 0 ]]; then
echo "Usage: $PROG <subcommand> [args...]" >&2
ls "$SCRIPT_DIR/subcommands"
exit 1
fi
subcommand="$1"
shift
if [[ -x "$SCRIPT_DIR/subcommands/$subcommand" ]]; then
exec "$SCRIPT_DIR/subcommands/$subcommand" "$@"
else
echo "Unknown subcommand: $subcommand" >&2
exit 1
fi
The exec replaces the main script with the subcommand, which avoids an extra process in the chain. The PROG environment variable lets subcommands call each other through the main dispatcher .
b. Option parsing for both short and long options
The builtin getopts is portable and handles short options correctly, including arguments with embedded spaces. It does not support long options. The external GNU getopt supports long options but is not available on all platforms .
For a tool that must handle both, the manual while loop is the most portable approach. It processes $1 directly, shifts as it goes, and handles --long, -s, and positional arguments in one loop.
parse_backup_args() {
local target=""
local output=""
local compress=false
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output)
output="$2"
shift 2
;;
-c|--compress)
compress=true
shift
;;
-h|--help)
backup_usage
exit 0
;;
-*)
echo "Unknown option: $1" >&2
backup_usage >&2
exit 1
;;
*)
target="$1"
shift
;;
esac
done
if [[ -z "$target" ]]; then
echo "Error: target directory is required" >&2
backup_usage >&2
exit 1
fi
echo "Target: $target, Output: $output, Compress: $compress"
}
This loop handles -o file, --output file, --output=file (if the case pattern includes --output=*), combined short flags are not handled by this pattern and require a separate getopts pass if needed. The trade-off is explicit: the manual loop is more code, but it is portable and handles both option forms without depending on GNU getopt.
For tools that only need short options, getopts is the correct choice because it handles the edge cases correctly and produces standard error messages .
c. Logging, strict mode, and error reporting
A structured logging function turns opaque failures into diagnosable events. The function should include a timestamp, a severity level, and the script name, and it should write to stderr for warnings and errors so that the output is visible even when stdout is redirected.
log() {
local level="$1"
shift
local message="$*"
local timestamp
timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
printf '%s [%s] %s: %s\n' "$timestamp" "$level" "${0##*/}" "$message" >&2
}
log_info() { log INFO "$@"; }
log_warn() { log WARN "$@"; }
log_error() { log ERROR "$@"; }
The set -euo pipefail header should be at the top of every script. set -e exits on a failing command, set -u errors on an unset variable, and set -o pipefail makes a pipeline fail if any stage fails, not just the last one . Without pipefail, a pipeline that includes a failing command can return success because the last command succeeded.
The ERR trap can report which command failed and on which line, which is useful when set -e terminates the script unexpectedly.
trap 'log_error "Command failed on line $LINENO: $BASH_COMMAND"' ERR
This trap runs whenever a command fails in a context that would cause set -e to exit. The $LINENO and $BASH_COMMAND variables identify the location and the command, which is more useful than a bare exit status.
Complete Example Session
# ============================================
# PART 1: THE STRICT MODE HEADER
# ============================================
#!/bin/bash
set -euo pipefail
# -e: exit on error
# -u: error on unset variable
# -o pipefail: fail if any pipeline stage fails
# ============================================
# PART 2: THE LOGGING FUNCTIONS
# ============================================
log() {
local level="$1"; shift
local message="$*"
local timestamp
timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
printf '%s [%s] %s: %s\n' "$timestamp" "$level" "${0##*/}" "$message" >&2
}
log_info() { log INFO "$@"; }
log_error() { log ERROR "$@"; }
# ============================================
# PART 3: THE USAGE FUNCTION
# ============================================
usage() {
cat <<EOF
Usage: $(basename "$0") <command> [options]
Commands:
backup Create a backup
restore Restore from a backup
verify Verify a backup
EOF
}
# ============================================
# PART 4: THE DISPATCHER
# ============================================
main() {
if [[ $# -eq 0 ]]; then
usage
exit 1
fi
local command="$1"; shift
case "$command" in
backup) cmd_backup "$@" ;;
restore) cmd_restore "$@" ;;
verify) cmd_verify "$@" ;;
help|-h|--help) usage; exit 0 ;;
*) log_error "Unknown command: $command"; usage >&2; exit 1 ;;
esac
}
# ============================================
# PART 5: A SUBCOMMAND WITH OPTION PARSING
# ============================================
cmd_backup() {
local target="" output="" compress=false
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output) output="$2"; shift 2 ;;
-c|--compress) compress=true; shift ;;
-h|--help) usage; exit 0 ;;
-*) log_error "Unknown option: $1"; exit 1 ;;
*) target="$1"; shift ;;
esac
done
[[ -z "$target" ]] && { log_error "Target required"; exit 1; }
log_info "Backing up $target to ${output:-default}"
}
# ============================================
# PART 6: THE ERR TRAP
# ============================================
trap 'log_error "Failed on line $LINENO: $BASH_COMMAND"' ERR
# Runs when set -e would exit due to a failing command.
# ============================================
# PART 7: THE COMPLETE SCRIPT
# ============================================
#!/bin/bash
set -euo pipefail
log() {
local level="$1"; shift
printf '%s [%s] %s: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$level" "${0##*/}" "$*" >&2
}
log_info() { log INFO "$@"; }
log_error() { log ERROR "$@"; }
trap 'log_error "Failed on line $LINENO: $BASH_COMMAND"' ERR
usage() { echo "Usage: $0 <backup|restore|verify> [options]"; }
cmd_backup() {
local target=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-*) log_error "Unknown option: $1"; exit 1 ;;
*) target="$1"; shift ;;
esac
done
[[ -z "$target" ]] && { log_error "Target required"; exit 1; }
log_info "Backing up $target"
}
main() {
[[ $# -eq 0 ]] && { usage; exit 1; }
local cmd="$1"; shift
case "$cmd" in
backup) cmd_backup "$@" ;;
help|-h|--help) usage; exit 0 ;;
*) log_error "Unknown command: $cmd"; exit 1 ;;
esac
}
main "$@"
# ============================================
# PART 8: TESTING THE SCRIPT
# ============================================
# ./tool.sh backup /data
# Output: 2026-11-05T14:30:00Z [INFO] tool.sh: Backing up /data
# ./tool.sh backup
# Output: 2026-11-05T14:30:01Z [ERROR] tool.sh: Target required
# ./tool.sh unknown
# Output: 2026-11-05T14:30:02Z [ERROR] tool.sh: Unknown command: unknown
# ============================================
# PART 9: FILE-BASED DISPATCHER (LARGER TOOLS)
# ============================================
#!/bin/bash
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
export PROG="$0"
[[ $# -eq 0 ]] && { echo "Usage: $PROG <subcommand>"; exit 1; }
sub="$1"; shift
if [[ -x "$SCRIPT_DIR/subcommands/$sub" ]]; then
exec "$SCRIPT_DIR/subcommands/$sub" "$@"
else
echo "Unknown: $sub" >&2; exit 1
fi
# ============================================
# PART 10: DIRECTORY STRUCTURE
# ============================================
# tool.sh
# subcommands/
# backup
# restore
# verify
#
# The main script locates the subcommand file
# and execs it with the remaining arguments.
The ten parts covered the strict mode header, logging functions, usage, the dispatcher, subcommand option parsing, the ERR trap, the complete script, testing, the file-based dispatcher, and the directory structure.
Quick Reference
Dispatcher patterns
| Pattern | Use case | Structure |
|---|---|---|
case + functions | Small tools | Single file |
subcommands/ directory | Larger tools | One file per command |
exec in dispatcher | Avoid extra process | Replaces main script |
Option parsing approaches
| Approach | Long options | Portability | Complexity |
|---|---|---|---|
getopts builtin | No | High | Low |
GNU getopt | Yes | Linux only | Moderate |
Manual while loop | Yes | High | Moderate |
Logging function
| Element | Example |
|---|---|
| Timestamp | date -u '+%Y-%m-%dT%H:%M:%SZ' |
| Level | INFO, WARN, ERROR |
| Script name | ${0##*/} |
| Destination | >&2 |
Strict mode
| Flag | Effect |
|---|---|
set -e | Exit on failing command |
set -u | Error on unset variable |
set -o pipefail | Fail if any pipeline stage fails |
Error reporting
| Mechanism | Purpose |
|---|---|
trap '...' ERR | Report failing command and line |
$LINENO | Line number of failure |
$BASH_COMMAND | Command that failed |
Best Practices
โ Do This:
# Use strict mode at the top of every script
set -euo pipefail # โ
# Use a dispatcher instead of a single long conditional block
case "$command" in backup) cmd_backup "$@" ;; esac # โ
# Write logs to stderr with a timestamp and level
printf '%s [%s] %s\n' "$(date -u ...)" "$level" "$msg" >&2 # โ
# Report failing commands with the ERR trap
trap 'log_error "line $LINENO: $BASH_COMMAND"' ERR # โ
โ Don’t Do This:
# Parse options with a single getopts loop and expect long options
getopts "vo:" opt # does not handle --output # โ
# Use echo for errors without a timestamp or level
echo "Error: something failed" >&2 # โ
# Put all subcommands in one giant case statement without functions
case "$1" in ... 200 lines ... esac # โ
# Skip set -e and continue after failures
false
echo "still running" # โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Long options not recognized | getopts does not support them | Use GNU getopt or a manual while loop |
| Script continues after failure | set -e not enabled | Add set -euo pipefail |
| Pipeline succeeds despite failure | pipefail not set | Add set -o pipefail |
| Unknown command produces a stack trace | No *) case in dispatcher | Add a catch-all case with exit 1 |
| Logs go to stdout and are lost | No >&2 on log function | Redirect logs to stderr |
| Subcommand file not found | Dispatcher path wrong | Use SCRIPT_DIR and exec |
Real-World Examples
1. Strict mode header
set -euo pipefail
2. Logging function
log() { printf '%s [%s] %s: %s\n' "$(date -u +%FT%TZ)" "$1" "${0##*/}" "${*:2}" >&2; }
3. Dispatcher
case "$1" in backup) cmd_backup "${@:2}" ;; *) exit 1 ;; esac
4. Manual option loop
while [[ $# -gt 0 ]]; do case "$1" in -o|--output) out="$2"; shift 2 ;; *) shift ;; esac; done
5. ERR trap
trap 'log_error "line $LINENO: $BASH_COMMAND"' ERR
6. Usage function
usage() { echo "Usage: $0 <command>"; }
7. File-based dispatcher
exec "$SCRIPT_DIR/subcommands/$sub" "$@"
8. Subcommand directory
mkdir -p subcommands && touch subcommands/{backup,restore,verify}
9. Unknown command handling
*) log_error "Unknown: $1"; usage >&2; exit 1 ;;
10. Test a failure
./tool.sh backup /nonexistent # should fail with a logged error
Visual
The dispatcher flow
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ DISPATCHER โ
โ โ
โ ./tool.sh backup /data -o /backups โ
โ โ โ
โ โผ โ
โ main() โ
โ โโโ $1 = "backup" โ
โ โโโ shift โ
โ โโโ case "backup" โโโบ cmd_backup "/data" "-o" "/backups" โ
โ โ
โ cmd_backup parses its own arguments. โ
โ No other subcommand is affected. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Strict mode error reporting
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ SET -E AND ERR TRAP โ
โ โ
โ Script line 42: cp /nonexistent /tmp/ โ
โ โ โ
โ โผ โ
โ cp fails โ
โ โ โ
โ โผ โ
โ set -e triggers exit โ
โ โ โ
โ โผ โ
โ ERR trap runs: โ
โ "line 42: cp /nonexistent /tmp/" โ
โ โ โ
โ โผ โ
โ Script exits โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Option parsing decision
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ WHICH PARSER? โ
โ โ
โ Need long options? โ
โ โ โ
โ โโโ Yes โโโบ Need portability? โ
โ โ โ โ
โ โ โโโ Yes โโโบ Manual while loop โ
โ โ โ โ
โ โ โโโ No (Linux only) โโโบ GNU getopt โ
โ โ โ
โ โโโ No โโโบ getopts builtin โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
File-based structure
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ TOOL DIRECTORY โ
โ โ
โ tool.sh (main dispatcher) โ
โ subcommands/ โ
โ backup (executable) โ
โ restore (executable) โ
โ verify (executable) โ
โ โ
โ tool.sh backup /data โ
โ โโโ exec subcommands/backup /data โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| Strict mode | set -euo pipefail |
| Dispatcher | case + functions or subcommands/ directory |
| Short options | getopts builtin |
| Long options | GNU getopt or manual while loop |
| Logging | Timestamp + level + script name, stderr |
| Error reporting | trap '...' ERR with $LINENO and $BASH_COMMAND |
| File-based dispatch | exec "$SCRIPT_DIR/subcommands/$sub" "$@" |
Key takeaways:
- Structure is the difference between a script and a tool. The dispatcher pattern separates subcommands so that each can be developed, tested, and understood independently. A new capability is a new function or file, not a modification to a growing conditional block .
getoptsis portable but limited. It handles short options correctly and is available in every Bash. Long options require either GNUgetopt(Linux-only) or a manualwhileloop (portable but more code) .set -euo pipefailis the minimum for reliability. Without it, a failing command in a pipeline can go unnoticed and the script can continue with corrupt state .- Structured logging is not optional for unattended tools. A timestamp, a severity level, and the script name turn an opaque failure into an event that can be correlated with other logs and diagnosed after the fact.
- The
ERRtrap reports the failing command and line.$LINENOand$BASH_COMMANDidentify exactly what went wrong, which is more useful than a bare exit status. - The file-based dispatcher scales beyond a single file. For tools with many subcommands, placing each in its own executable file and using
execin the dispatcher keeps the main script small and lets subcommands be written in any language .
Remember: An enterprise-ready Bash tool is not defined by the number of features it has. It is defined by whether another administrator can read it, extend it, and trust it. The dispatcher pattern makes the structure obvious. The option parsing choice makes the interface predictable. The logging and error handling make failures diagnosable. And strict mode makes the tool stop when something goes wrong instead of continuing with corrupted state. Start with the dispatcher, add the logging function, enable strict mode, and add the ERR trap. The rest of the tool is just subcommands, each with its own arguments and its own logic.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!