$linuxjunkies
>

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.

IntermediateUbuntuDebianFedoraArch9 min readUpdated June 7, 2026

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 grep returning 1 when no match) will kill the script. Use grep ... || true or check exit codes explicitly with if grep ....
  • -u fires on an empty optional argument: Use ${VAR-} (dash, no default) instead of $VAR for 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 ~/.bashrc or ~/.bash_profile and re-source it.
  • df flags differ on macOS or older Linux: The --output flag for df is GNU coreutils-specific. On non-GNU systems, replace that line with a plain df -h.
tested on:Ubuntu 24.04Fedora 40Arch rollingDebian 12

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