Skip to content
AgileLogics
6 min read

Structuring Larger Scripts and Tools

Prerequisites: Parsing options with getopts, Testing scripts with Bats

A 20-line script can be a flat list of commands. A tool used by a team needs structure: clear entry points, reusable functions, help text, and tests.

A template for a real tool

cat > tool.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

readonly VERSION="1.2.0"
readonly SCRIPT_NAME=${0##*/}

usage() {
  cat <<USAGE
Usage: $SCRIPT_NAME <command> [options]

Commands:
  greet <name>   Print a greeting
  version        Show the version

Options:
  -h, --help     Show this help
USAGE
}

die() { echo "$SCRIPT_NAME: $*" >&2; exit 1; }

cmd_greet() {
  [[ $# -ge 1 ]] || die "greet: missing <name>"
  echo "Hello, $1!"
}

cmd_version() { echo "$SCRIPT_NAME $VERSION"; }

main() {
  local command=${1:-}
  [[ -n $command ]] || { usage; exit 2; }
  shift
  case "$command" in
    greet)     cmd_greet "$@" ;;
    version)   cmd_version ;;
    -h|--help) usage ;;
    *)         die "unknown command: $command (try --help)" ;;
  esac
}

main "$@"
EOF

bash tool.sh greet Ada
bash tool.sh version
bash tool.sh greet || echo "exit code: $?"
bash tool.sh launch || echo "exit code: $?"
Hello, Ada!
tool.sh 1.2.0
tool.sh: greet: missing <name>
exit code: 1
tool.sh: unknown command: launch (try --help)
exit code: 1

What makes it maintainable:

  • Strict mode and a die helper for consistent errors.
  • readonly constants for settings that must not change.
  • One function per subcommand (cmd_greet, cmd_version), dispatched from main with case — the same pattern as git and docker.
  • A usage message built from a here document.
  • main "$@" at the bottom, so all functions are defined before anything runs.

Splitting into files

Shared helpers can live in a library file that scripts source:

# illustrative
# lib/common.sh
log() { printf '%s %s\n' "$(date +%T)" "$*" >&2; }
require_cmd() { command -v "$1" > /dev/null || die "missing dependency: $1"; }
# illustrative
# bin/deploy
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
source "$script_dir/../lib/common.sh"
require_cmd rsync

Finding the library relative to the script (BASH_SOURCE) makes the tool work wherever it's installed. command -v checks for required programs up front, with a clear error.

Configuration

Read settings in a clear order of precedence: built-in defaults, then a config file (~/.config/tool/config), then environment variables, then command-line options.

Keep the tool healthy

  • ShellCheck and Bats in CI.
  • A README with installation, examples, and every option.
  • A --version flag and a changelog.