A script buried in someone's home directory and a properly packaged CLI tool are the same code with very different levels of trust, discoverability, and maintainability. This page covers the patterns that turn a working script into something you can confidently distribute to your team or install across a fleet.
1
Argument parsing with getopts
BASH
#!/usr/bin/env bash
# backup.sh — Production backup tool with proper CLI
readonly VERSION="1.2.0"
readonly SCRIPT_NAME=$(basename "${0}")
usage() {
cat << EOF
Usage: ${SCRIPT_NAME} [OPTIONS] <database>
Backup a MySQL database to a compressed archive.
Options:
-h HOST Database host (default: localhost)
-p PORT Database port (default: 3306)
-u USER Database user (default: root)
-o DIR Output directory (default: /backups)
-c Compress output with gzip
-v Verbose output
-V Print version and exit
--help Show this help
Examples:
${SCRIPT_NAME} myapp
${SCRIPT_NAME} -h prod-db-01 -u app_user -c myapp
${SCRIPT_NAME} -o /tmp/backups -v myapp
EOF
}
# Defaults
DB_HOST="localhost"
DB_PORT="3306"
DB_USER="root"
OUTPUT_DIR="/backups"
COMPRESS=false
VERBOSE=false
# Parse options
while getopts ":h:p:u:o:cvV" opt; do
case "${opt}" in
h) DB_HOST="${OPTARG}" ;;
p) DB_PORT="${OPTARG}" ;;
u) DB_USER="${OPTARG}" ;;
o) OUTPUT_DIR="${OPTARG}" ;;
c) COMPRESS=true ;;
v) VERBOSE=true ;;
V) echo "${SCRIPT_NAME} v${VERSION}"; exit 0 ;;
:) echo "Error: -${OPTARG} requires an argument" >&2; usage; exit 1 ;;
?) echo "Error: unknown option -${OPTARG}" >&2; usage; exit 1 ;;
esac
done
shift $(( OPTIND - 1 )) # remove parsed options, leave positional args
# Validate positional argument
[[ $# -eq 0 ]] && { echo "Error: database name required" >&2; usage; exit 1; }
DATABASE="${1}"
VERBOSE && echo "Backing up ${DATABASE} from ${DB_HOST}:${DB_PORT}"
2
Long options with manual parsing
BASH
# getopts only handles single-char options
# For --long-options, parse manually
ENV="production"
DRY_RUN=false
FORCE=false
while [[ $# -gt 0 ]]; do
case "${1}" in
--env=*)
ENV="${1#--env=}"; shift ;;
--env)
ENV="${2:?--env requires a value}"; shift 2 ;;
--dry-run)
DRY_RUN=true; shift ;;
--force)
FORCE=true; shift ;;
--help|-h)
usage; exit 0 ;;
--version|-V)
echo "v${VERSION}"; exit 0 ;;
--)
shift; break ;; # end of options
-*)
echo "Unknown option: ${1}" >&2; exit 1 ;;
*)
break ;; # first non-option arg
esac
done
echo "ENV=${ENV} DRY_RUN=${DRY_RUN} FORCE=${FORCE}"
echo "Remaining args: $*"
3
Self-contained installer — the shar pattern
BASH
#!/usr/bin/env bash
# install.sh — Self-contained installer for myapp-scripts
set -euo pipefail
readonly VERSION="2.1.0"
readonly INSTALL_DIR="/usr/local/lib/myapp"
readonly BIN_DIR="/usr/local/bin"
echo "Installing myapp-scripts v${VERSION}"
# Check root
[[ $(id -u) -eq 0 ]] || { echo "Run as root" >&2; exit 1; }
# Create directories
mkdir -p "${INSTALL_DIR}/lib" "${INSTALL_DIR}/config"
# Install library files
for lib in lib/logging.sh lib/db.sh lib/network.sh; do
install -m 644 "${lib}" "${INSTALL_DIR}/lib/"
echo " ✔ ${lib}"
done
# Install executables
for bin in bin/deploy bin/backup bin/health-check; do
install -m 755 "${bin}" "${BIN_DIR}/"
echo " ✔ ${bin} → ${BIN_DIR}/"
done
# Install default config (don't overwrite existing)
[[ -f /etc/myapp/config.env ]] || {
install -Dm 640 config/defaults.env /etc/myapp/config.env
echo " ✔ Default config installed"
}
echo "Installation complete — run 'deploy --help' to get started"
4
Versioning and release workflow
BASH
#!/usr/bin/env bash
# release.sh — Tag and package a new release
set -euo pipefail
VERSION="${1:?Usage: release.sh VERSION}"
PKG_NAME="myapp-scripts-${VERSION}"
# Validate semver format
[[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
|| { echo "ERROR: Version must be X.Y.Z" >&2; exit 1; }
# Update version in scripts
sed -i "s/^readonly VERSION=.*/readonly VERSION=\"${VERSION}\"/" bin/*
# Commit version bump
git add bin/ lib/
git commit -m "Release v${VERSION}"
git tag -a "v${VERSION}" -m "Release v${VERSION}"
# Create release tarball
git archive --prefix="${PKG_NAME}/" \
-o "/tmp/${PKG_NAME}.tar.gz" \
"v${VERSION}"
echo " ✔ Created /tmp/${PKG_NAME}.tar.gz"
echo " Size: $(du -sh /tmp/${PKG_NAME}.tar.gz | cut -f1)"
# Push tag
git push origin main "v${VERSION}"
echo " ✔ Pushed v${VERSION} to origin"
vriddh@prod-01:~$backup --help
Usage: backup [OPTIONS] <database>
Backup a MySQL database to a compressed archive.
Options:
-h HOST Database host (default: localhost)
-c Compress output with gzip
vriddh@prod-01:~$backup -h prod-db-01 -u app -c myapp
Backing up myapp from prod-db-01:3306
✔ Saved: /backups/myapp_20260501_021402.sql.gz (87MB)
vriddh@prod-01:~$./release.sh 2.2.0
✔ Created /tmp/myapp-scripts-2.2.0.tar.gz
Size: 42K
✔ Pushed v2.2.0 to origin
█
✔ Packaging rules — Always implement
--help and --version. Use getopts for single-char options; manual parsing for --long-options. Always validate positional arguments. Use install -m MODE instead of cp + chmod for proper permissions. Version every release with semver tags and git archive for clean tarballs without development files.