Build a Real Command-Line Tool in Shell
Build a proper CLI tool in Bash with strict mode, long/short argument parsing, --help, usage(), and clean packaging via install and a Makefile.
Before you start
- ▸Bash 4.0 or later (bash --version to check)
- ▸Basic familiarity with writing and running shell scripts
- ▸A text editor and a terminal
- ▸sudo access if installing to /usr/local/bin
Shell scripts too often grow from a quick hack into something people depend on—without ever being treated as real software. This guide walks through building a shell tool properly: strict error handling, clean argument parsing, a --help flag, a usage() function, and packaging it so it runs from anywhere on the system. The example tool is syssnap, a simple utility that captures a timestamped system snapshot (uptime, disk, memory) to a file. The technique applies to any tool you want to build.
Strict Mode First
Every serious shell script starts with strict mode. It turns silent failures into loud ones, which is what you want in any tool others will run.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
- -e — exit immediately if any command returns non-zero.
- -u — treat unset variables as errors.
- -o pipefail — a pipeline fails if any command in it fails, not just the last.
- IFS — restricts word splitting to newlines and tabs, preventing subtle bugs with filenames that contain spaces.
Use #!/usr/bin/env bash rather than #!/bin/bash. It finds whatever bash is first in PATH, making the script portable to systems (or user environments) where bash lives elsewhere, such as Homebrew on macOS.
Defining Constants and Defaults
Declare program metadata and defaults near the top, before any logic. Using readonly prevents accidental mutation later.
readonly PROGNAME=$(basename "$0")
readonly VERSION="1.0.0"
readonly DEFAULT_OUTDIR="${HOME}/.local/share/syssnap"
# Runtime variables (not readonly — set by argument parser)
OUTDIR="$DEFAULT_OUTDIR"
VERBOSE=false
OUTPUT_FILE=""
Writing usage() and --help
A usage() function serves two purposes: print a short synopsis on bad input, and print full help for --help. Keep them in one function using an optional argument to switch between modes.
usage() {
local mode="${1:-short}"
if [[ "$mode" == "full" ]]; then
cat <<EOF
$PROGNAME $VERSION — capture a timestamped system snapshot
USAGE:
$PROGNAME [OPTIONS]
OPTIONS:
-o, --output-dir DIR Directory to write snapshots (default: $DEFAULT_OUTDIR)
-f, --file FILE Write to a specific file instead of auto-naming
-v, --verbose Print snapshot contents to stdout as well
-V, --version Print version and exit
-h, --help Show this help
EXAMPLES:
$PROGNAME
$PROGNAME -o /tmp/snaps -v
$PROGNAME --file /tmp/now.txt
EOF
else
echo "Usage: $PROGNAME [-o DIR] [-f FILE] [-v] [-V] [-h]" >&2
echo "Run '$PROGNAME --help' for full usage." >&2
fi
}
The heredoc (<<EOF) keeps the help text readable in source without fighting with echo escaping. Note that short-form usage goes to stderr (file descriptor 2); full --help output conventionally goes to stdout so it can be piped to less.
Argument Parsing with a while Loop
Bash has getopts for short options, but it does not handle long options (--verbose) natively. A while/case loop handles both cleanly and is easier to extend.
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output-dir)
[[ -z "${2-}" ]] && { usage; exit 1; }
OUTDIR="$2"
shift 2
;;
-f|--file)
[[ -z "${2-}" ]] && { usage; exit 1; }
OUTPUT_FILE="$2"
shift 2
;;
-v|--verbose)
VERBOSE=true
shift
;;
-V|--version)
echo "$PROGNAME $VERSION"
exit 0
;;
-h|--help)
usage full
exit 0
;;
--)
shift
break
;;
-*)
echo "$PROGNAME: unknown option '$1'" >&2
usage
exit 1
;;
*)
echo "$PROGNAME: unexpected argument '$1'" >&2
usage
exit 1
;;
esac
done
}
The -- case signals end-of-options, which is important if your tool ever passes arguments downstream. The ${2-} syntax (note: dash, not colon-dash) avoids tripping -u when $2 does not exist.
Core Logic
With arguments parsed, the actual work is straightforward. Wrap it in a function to keep main() clean.
take_snapshot() {
local outfile
if [[ -n "$OUTPUT_FILE" ]]; then
outfile="$OUTPUT_FILE"
else
mkdir -p "$OUTDIR"
outfile="${OUTDIR}/snap_$(date +%Y%m%d_%H%M%S).txt"
fi
{
echo "=== syssnap $VERSION — $(date --iso-8601=seconds) ==="
echo
echo "--- uptime ---"
uptime
echo
echo "--- memory ---"
free -h
echo
echo "--- disk ---"
df -h --output=source,size,used,avail,pcent,target -x tmpfs -x devtmpfs
} > "$outfile"
if [[ "$VERBOSE" == true ]]; then
cat "$outfile"
fi
echo "Snapshot written: $outfile"
}
main() {
parse_args "$@"
take_snapshot
}
main "$@"
Grouping output with { ... } > file means you open the file once instead of appending line by line—cleaner and slightly faster. Always pass "$@" through to main to preserve argument quoting.
Packaging and Installation
Make it executable and put it in PATH
The simplest installation is a single file in a standard location.
chmod +x syssnap
sudo install -m 755 syssnap /usr/local/bin/syssnap
install sets permissions and owner atomically. For a personal tool that does not need root, use ~/.local/bin instead—just confirm it is in your PATH.
mkdir -p ~/.local/bin
install -m 755 syssnap ~/.local/bin/syssnap
Distributing with a Makefile
Even for a single-file tool, a minimal Makefile makes installation and uninstallation reproducible.
PREFIX ?= /usr/local
BINDIR = $(PREFIX)/bin
.PHONY: install uninstall
install:
install -Dm755 syssnap $(DESTDIR)$(BINDIR)/syssnap
uninstall:
rm -f $(DESTDIR)$(BINDIR)/syssnap
The DESTDIR variable is a packaging convention used by distro maintainers to stage files before creating a package. Supporting it costs you nothing.
Creating a man page
For tools others will use, a man page lives in /usr/local/share/man/man1/. Write it in ronn, scdoc, or raw troff. At minimum, generate a stub:
help2man --no-discard-stderr ./syssnap > syssnap.1
sudo install -m 644 syssnap.1 /usr/local/share/man/man1/
sudo mandb
Verification
Run through these checks before considering the tool done:
# Help works
syssnap --help
# Version works
syssnap --version
# Unknown flag exits non-zero and prints usage to stderr
syssnap --bogus; echo "exit: $?"
# Normal run
syssnap -v
# Verify strict mode catches an unset variable (temporarily add this to test)
bash -u -c 'echo "$UNSET_VAR"'
Check the exit code discipline: --help and --version should exit 0; bad input should exit 1. This matters when the tool is called from scripts or CI pipelines.
Troubleshooting
- Script exits unexpectedly under -e: A command you expect to fail (like
grepreturning 1 when no match) will kill the script. Usegrep ... || trueor check exit codes explicitly withif grep .... - -u fires on an empty optional argument: Use
${VAR-}(dash, no default) instead of$VARfor variables that are intentionally unset at some point. - Heredoc indentation issues: Use
<<-EOF(note the dash) to strip leading tabs. This only strips tabs, not spaces—use actual tab characters to indent the heredoc body. - Tool not found after install to ~/.local/bin: Add
export PATH="$HOME/.local/bin:$PATH"to your~/.bashrcor~/.bash_profileand re-source it. - df flags differ on macOS or older Linux: The
--outputflag fordfis GNU coreutils-specific. On non-GNU systems, replace that line with a plaindf -h.
Frequently asked questions
- Why use a while/case loop instead of getopts?
- Bash's built-in getopts only handles single-character options like -v; it cannot parse long options like --verbose. The while/case pattern handles both and is straightforward to extend.
- Does 'set -e' make scripts fragile?
- It can if you rely on commands returning non-zero for normal control flow (like grep returning 1 on no match). Wrap those with '|| true' or use 'if command; then' to handle them explicitly.
- When should I use /usr/local/bin versus ~/.local/bin?
- Use /usr/local/bin for system-wide tools available to all users (requires root). Use ~/.local/bin for personal tools—just make sure that directory is in your PATH.
- Is a Makefile overkill for a single shell script?
- For personal use, maybe. But the DESTDIR convention in the Makefile makes it trivial for someone else to package your tool into a .deb or .rpm without modifying your source.
- How do I test strict mode without running the full script?
- Run 'bash -euo pipefail -c "your command here"' inline, or add 'set -x' temporarily to your script to trace every line as it executes.
Related guides
Bash Arrays and Associative Arrays
Master bash indexed and associative arrays: declaration, element access, looping, mapfile, namerefs, and practical patterns for real scripting work.
Bash Functions and Variable Scoping
Master Bash function scoping with local variables, source-based libraries, correct use of return codes, and array passing techniques including namerefs.
Bash Loops: for, while and until
Learn all three Bash loop types — for, while, and until — with practical, copy-paste examples covering file iteration, counting, polling, and safe line reading.
Bash Scripting for Beginners
Learn Bash scripting from scratch: shebang lines, variables, conditionals, loops, and arguments, plus a real backup script to tie it all together.