Skip to content

08 · Packaging & Distributing Scripts

A script that only runs on your machine, from the one directory you wrote it in, isn't finished. This module covers making a CLI tool installable system-wide, documenting it with a man page, and versioning it so users know what they're running.

Making a script installable

#!/usr/bin/env bash
# mytool — lives at bin/mytool in the project repo
set -euo pipefail
echo "mytool v1.0.0"
chmod +x bin/mytool

# put it somewhere already on $PATH so it can be run from anywhere
sudo cp bin/mytool /usr/local/bin/mytool

# or, without root, a per-user bin directory
mkdir -p "$HOME/.local/bin"
cp bin/mytool "$HOME/.local/bin/mytool"
# then make sure ~/.local/bin is on PATH (add to .bashrc/.zshrc if not):
export PATH="$HOME/.local/bin:$PATH"
which mytool        # confirm it resolves
mytool               # runs from any directory now

A standard project layout

mytool/
    bin/mytool              # the executable entry point
    lib/mytool/*.sh          # sourced helper functions (if the tool is big)
    man/mytool.1              # man page (see below)
    install.sh                  # copies bin/ + man/ into place
    CHANGELOG.md                 # version history
    VERSION                       # single source of truth for the version
# install.sh — a simple installer
#!/usr/bin/env bash
set -euo pipefail
PREFIX="${PREFIX:-/usr/local}"

install -Dm755 bin/mytool "$PREFIX/bin/mytool"
install -Dm644 man/mytool.1 "$PREFIX/share/man/man1/mytool.1"

echo "installed mytool to $PREFIX/bin/mytool"
echo "run 'man mytool' to view the manual"

install(1) sets permissions and creates parent directories in one step — more reliable than a manual mkdir -p && cp && chmod.

Writing a man page

Man pages use the troff/groff markup format. A minimal one:

.TH MYTOOL 1 "2026-07-18" "mytool 1.0.0" "User Commands"
.SH NAME
mytool \- do a useful thing from the command line
.SH SYNOPSIS
.B mytool
[\fB\-v\fR] [\fB\-\-help\fR] \fIARGUMENT\fR
.SH DESCRIPTION
.B mytool
processes ARGUMENT and prints a result. Designed to be used in pipelines
and scripts.
.SH OPTIONS
.TP
.B \-v, \-\-verbose
Print extra diagnostic information to stderr.
.TP
.B \-\-help
Show usage and exit.
.SH EXIT STATUS
0 on success, 1 on invalid usage, 2 on a processing error.
.SH AUTHOR
Written by you.
man ./man/mytool.1              # preview it locally before installing
man mytool                       # after install.sh has copied it into place

Self-documenting --help

Every real CLI tool should answer its own --help without needing the man page installed:

usage() {
    cat <<'EOF'
Usage: mytool [OPTIONS] ARGUMENT

Process ARGUMENT and print a result.

Options:
  -v, --verbose   print extra diagnostic information
  -h, --help      show this help and exit

Exit status:
  0  success
  1  invalid usage
  2  processing error
EOF
}

case "${1:-}" in
    -h|--help) usage; exit 0 ;;
esac

Versioning conventions

Semantic versioning (MAJOR.MINOR.PATCH) communicates the impact of a release at a glance: bump MAJOR for breaking changes, MINOR for backward-compatible features, PATCH for bug fixes.

# VERSION file, read by the script itself and by install.sh
echo "1.2.0" > VERSION
# embed it in the script so `mytool --version` works without extra files
VERSION="1.2.0"

case "${1:-}" in
    --version) echo "mytool $VERSION"; exit 0 ;;
esac
# CHANGELOG.md
## [1.2.0] - 2026-07-18
### Added
- `--json` output flag.

## [1.1.0] - 2026-06-01
### Fixed
- Crash when ARGUMENT contained spaces.

Distributing via a package manager

For wider distribution, a Homebrew formula lets macOS/Linuxbrew users install with one command:

# mytool.rb — a minimal Homebrew formula
class Mytool < Formula
  desc "Do a useful thing from the command line"
  homepage "https://github.com/you/mytool"
  url "https://github.com/you/mytool/archive/refs/tags/v1.2.0.tar.gz"
  sha256 "REPLACE_WITH_REAL_SHA256"
  license "MIT"

  def install
    bin.install "bin/mytool"
    man1.install "man/mytool.1"
  end

  test do
    system "#{bin}/mytool", "--version"
  end
end
brew install --build-from-source ./mytool.rb        # test locally
brew tap you/tap && brew install mytool               # after publishing the tap

How It Actually Works

A single-file self-extracting installer that bundles a shebang plus an embedded archive works by exploiting the fact that execve and the bash parser both only care about finding a newline-terminated shebang line and then reading the rest of the file as script source until they hit whatever marker the script itself defines (often something like exit 0 followed by a binary payload) — bash reads and executes the script text sequentially and simply never reaches the binary bytes appended after the exit, while a separate tail -n +N (or similar) call does read past that same line, extracting the payload — one file serving two completely different read patterns to two different tools.

Packaging via a package manager (apt/yum/homebrew formula) shifts execution away from a raw shebang-and-run model entirely: the package manager itself fork()/exec()s pre/post-install scripts you provide, at points defined by its internal lifecycle rather than bash's, and typically records the list of installed files so a later uninstall step knows exactly what to unlink(2) — which is the actual difference between "just copy a script somewhere" and "properly package it": lifecycle hooks and a manifest, not the script content itself.

Version pinning and checksums matter because package installation is just "run some code with elevated privileges, sourced from wherever the manifest points" at the OS level — a checksum comparison (typically SHA-256) is a cheap way to detect a payload that changed between when a maintainer signed off on it and when it's actually fetched and executed.

Cheat sheet

Task Command
Install a script to $PATH install -Dm755 bin/tool /usr/local/bin/tool
Install a man page install -Dm644 man/tool.1 /usr/local/share/man/man1/tool.1
Preview a man page before installing man ./man/tool.1
Check where a command resolves which tool / type tool
Read VERSION in a script VERSION=$(cat VERSION)

Exercise

Take the backup.sh script from Level 1's capstone project. Turn it into an installable tool called bkup: give it a --help and --version flag, a VERSION file, a one-page man page describing its usage and exit codes, and an install.sh that installs both into /usr/local. Verify with man bkup and bkup --version after installing.