Skip to content
VLSI Mentor

Linux & Bash · Part I · Module 21 · From Script to Reusable Tool

Interface First: Arguments, Options and a Usage Message

Module 20 made a script fail honestly. This module makes it something another person can run — and that is a different property. It starts with the part they actually touch: what the script takes, what it promises, and the one line that tells them both. Written first, that line is a specification; written last, it is a guess at what the code already does.

Advanced19 min readLinuxPart IBashInterfaceUsageScripting

Part I · Module 21 · Chapter 21.1 · Interface First: Arguments, Options and a Usage Message

Every script in the last five modules has been correct. None of them has been reusable, and the difference is not internal quality.

A reusable tool is one where someone who did not write it can work out how to call it, gets told clearly when they call it wrongly, and finds that it behaves the same way on their machine as on yours. Those are interface properties, and this module is about them.

1. Write the Usage Line First

Deciding the interface before writing the work

Writing the interface before the work. Decide what the tool takes and what it promises, write the usage message from that decision, make a wrong invocation fail before anything happens, and only then write the body — which now has a fixed shape to fill rather than an interface that grew by accident.01DECIDE WHAT IT TAKESand what it promises back02WRITE THE USAGE MESSAGE FIRSTit is the specification, in one line03REJECT A WRONG CALL IMMEDIATELYbefore anything has been created04ONLY THEN WRITE THE BODYinto a shape that is already decided
Decide what the tool takes and what it promises, write the usage message from that decision, make a wrong call fail before anything has happened, and only then write the body — which now has a fixed shape to fill rather than an interface that accumulated by accident as the code grew.

The habit is small and the effect is large. Before writing any of the work, write the line a user will see:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
usage: run-suite.sh [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>

That one line forces four decisions you would otherwise make accidentally, one at a time, while writing the body:

  • What is required and what is optional. <testlist> in angle brackets is required; everything in square brackets is not.
  • What is a flag and what takes a value. -v and -n are switches; -s and -o take arguments, and the placeholder names say what kind.
  • How many positional arguments there are. One. Not "one or more, and if it is more then something different happens" — a decision easily made by accident.
  • And what the defaults must be, because every optional thing needs one.

A script whose usage line you cannot write in one line has an interface problem, and it is better to discover that before the body exists than after.

The conventions in that line are not arbitrary — they are the ones man pages use, so a reader already knows how to parse them:

In a usage lineMeans
<name> or NAMErequired
[-v]optional flag
[-s SEEDS]optional flag taking a value
<file>...one or more
[file...]zero or more
{a|b}required, one of these

2. The Usage Function

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
usage() {
  printf 'usage: %s [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>\n' "${0##*/}" >&2
}

Three things in that are deliberate.

${0##*/} rather than a hardcoded name, so a renamed or symlinked script tells the truth — Chapter 8.3's basename, and Chapter 18.2 §2's point that $0 is the script even inside a function.

>&2, because a usage message printed in response to a mistake is a diagnostic. Chapter 20.7's rule.

And printf rather than echo, for Chapter 17.2's reason: a usage line containing a -n or a backslash is not hypothetical.

Then the rejection, which belongs before anything else happens:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
[ $# -ge 1 ] || { usage; exit 2; }

exit 2, from Chapter 15.6's convention: 2 means you called it wrongly, which is a different thing from 1 meaning the work failed. Chapter 21.4 makes that a documented contract rather than a habit.

3. --help Is Not an Error

This is the distinction most scripts get wrong, and it is measurable.

A user typing tool --help has not made a mistake. They are asking a question, and the answer is the output they asked for — so it goes on stdout with status 0:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
case ${1:-} in
  -h|--help) help_text; exit 0 ;;
esac

Compare the two from the caller's side. The difference is invisible at a bare prompt — stderr still reaches your terminal — so it has to be measured by what a consumer receives:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./right.sh --help 2>/dev/null | wc -l          # lines through a pipe
3
$ ./wrong.sh --help 2>/dev/null | wc -l
0

$ o=$(./right.sh --help 2>/dev/null); echo "${#o}"   # bytes captured
73
$ o=$(./wrong.sh --help 2>/dev/null); echo "${#o}"
0

$ ./right.sh --help >/dev/null 2>&1; echo $?         # status
0
$ ./wrong.sh --help >/dev/null 2>&1; echo $?
2

$ ./right.sh --help >/dev/null 2>&1 && echo ok       # usable in a chain
ok
$ ./wrong.sh --help >/dev/null 2>&1 && echo ok
$

$ ./right.sh --help > doc.txt 2>/dev/null; wc -c < doc.txt   # redirected
74
$ ./wrong.sh --help > doc.txt 2>/dev/null; wc -c < doc.txt
0

Five consumers, and the second script answers none of them. | less, | grep, $( ), && … and > doc.txt all get an empty stream, and the status says the user made a mistake by asking. On a bare terminal the two look identical, which is exactly why this survives into shipped tools.

So:

StreamStatus
--help requestedstdout0
a wrong invocationstderr2

Both print usage. They differ in where and in what they claim, and the difference is what makes --help usable.

4. Positional Arguments: Decide the Count

$# checks are cheap and the choice of comparison is a design decision:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
[ $# -eq 1 ] || { usage; exit 2; }        # exactly one
[ $# -ge 1 ] || { usage; exit 2; }        # one or more
[ $# -le 2 ] || { usage; exit 2; }        # at most two

Prefer -eq unless an argument really is optional. A script that accepts extra arguments and ignores them accepts your typos:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./loose.sh tests.txt tsets.txt
running tests from tests.txt

The second file was a mistyped retry that the script silently discarded. With -eq 1:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./strict.sh tests.txt tsets.txt
usage: strict.sh [-v] [-s SEEDS] <testlist>
$ echo $?
2

And when you do take several, say so with ... and handle them as a list — Chapter 19.3's "$@":

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# usage: tool <file>...
[ $# -ge 1 ] || { usage; exit 2; }
for f in "$@"; do ...; done

5. -- Ends the Options

A convention from Chapter 1.5 that a reusable tool must honour, because its arguments come from somewhere you do not control:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
while [ $# -gt 0 ]; do
  case $1 in
    -v) verbose=1; shift ;;
    --) shift; break ;;                 # everything after this is positional
    -*) printf '%s: unknown option: %s\n' "${0##*/}" "$1" >&2; usage; exit 2 ;;
    *)  break ;;
  esac
done
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./tool.sh -- -weird.sv
processing [-weird.sv]

$ ./tool.sh -weird.sv
tool.sh: unknown option: -weird.sv
usage: tool.sh [-v] [--] <file>...
$ echo $?
2

Without --, a file genuinely named -weird.sv cannot be passed at all. With it, the caller has a way — and the -*) arm means a mistyped option is reported rather than treated as a filename, which Chapter 16.6 §8 measured as the reason that arm must come before *).

And pass -- on to the commands you call, for Chapter 20.4's reason:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
rm -rf -- "$dir"
grep -q -- "$pattern" "$f"

6. A Complete Script

The interface, complete, with the body deliberately trivial so the shape is visible:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/usr/bin/env bash
# run-suite.sh — run every test in a testlist, once per seed
#
# usage: run-suite.sh [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>
#
# exit: 0 all passed · 1 at least one failed · 2 bad invocation
#       4 the environment is not set up

set -o errexit
set -o nounset
set -o pipefail

note() { printf '%s: %s\n' "${0##*/}" "$*" >&2; }
die()  { note "$*"; usage; exit 2; }

usage() {
  printf 'usage: %s [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>\n' "${0##*/}" >&2
}

help_text() {
  usage 2>&1
  cat <<'EOF'

Run every test named in <testlist>, once per seed.

  -v           verbose: report each test as it starts
  -n           dry run: print what would run, then exit 0
  -s SEEDS     seeds per test (default 1, maximum 100)
  -o OUTDIR    where logs go (default ./results)
  -h, --help   this message, on stdout

  <testlist>   one test name per line; blanks and # comments ignored

Exit status:
  0  every test passed
  1  at least one test failed
  2  bad invocation
  4  the environment is not set up
EOF
}

# ---- interface -----------------------------------------------------------

verbose=0
dry_run=0
seeds=1
outdir=./results

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

while [ $# -gt 0 ]; do
  case $1 in
    -v) verbose=1; shift ;;
    -n) dry_run=1; shift ;;
    -s) [ $# -ge 2 ] || die "-s needs a value"; seeds=$2;  shift 2 ;;
    -o) [ $# -ge 2 ] || die "-o needs a value"; outdir=$2; shift 2 ;;
    --) shift; break ;;
    -*) die "unknown option: $1" ;;
    *)  break ;;
  esac
done

[ $# -eq 1 ] || { usage; exit 2; }
testlist=$1

[ "$seeds" -ge 1 ] 2>/dev/null   || die "seeds must be a positive integer, got: $seeds"
[ "$seeds" -le 100 ] 2>/dev/null || die "seeds must be 100 or fewer, got: $seeds"
[ -r "$testlist" ]               || die "cannot read testlist: $testlist"

# ---- the work (deliberately trivial) ------------------------------------

declare -a tests=()
while IFS= read -r line || [ -n "$line" ]; do
  case $line in ''|\#*) continue ;; esac
  tests+=("$line")
done < "$testlist"

[ "${#tests[@]}" -gt 0 ] || die "no tests in $testlist"

if [ "$dry_run" -eq 1 ]; then
  printf 'would run %d test(s) x %d seed(s) into %s\n' \
    "${#tests[@]}" "$seeds" "$outdir"
  for t in "${tests[@]}"; do
    for (( s = 1; s <= seeds; s++ )); do
      printf '  %s seed %d\n' "$t" "$s"
    done
  done
  exit 0
fi

mkdir -p -- "$outdir" || die "cannot create $outdir"

failed=0
for t in "${tests[@]}"; do
  for (( s = 1; s <= seeds; s++ )); do
    [ "$verbose" -eq 0 ] || note "starting $t seed $s"
    if [ "$t" = known_bad ]; then
      printf 'FAIL  %-16s seed %d\n' "$t" "$s"
      failed=$(( failed + 1 ))
    else
      printf 'pass  %-16s seed %d\n' "$t" "$s"
    fi
  done
done

printf -- '-- %d test(s) x %d seed(s), %d failure(s)\n' \
  "${#tests[@]}" "$seeds" "$failed"
[ "$failed" -eq 0 ]
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ printf '# the suite\n\nsmoke\ncache coherency\nknown_bad\n' > tests.txt

$ ./run-suite.sh --help | head -4
usage: run-suite.sh [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>

Run every test named in <testlist>, once per seed.

$ ./run-suite.sh --help > /dev/null; echo "rc=$?"
rc=0

$ ./run-suite.sh -n tests.txt
would run 3 test(s) x 1 seed(s) into ./results
  smoke seed 1
  cache coherency seed 1
  known_bad seed 1
$ echo $?
0

$ ./run-suite.sh -s 2 tests.txt
pass  smoke            seed 1
pass  smoke            seed 2
pass  cache coherency  seed 1
pass  cache coherency  seed 2
FAIL  known_bad        seed 1
FAIL  known_bad        seed 2
-- 3 test(s) x 2 seed(s), 2 failure(s)
$ echo $?
1

$ ./run-suite.sh
usage: run-suite.sh [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>
$ echo $?
2

$ ./run-suite.sh tests.txt extra.txt
usage: run-suite.sh [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>
$ echo $?
2

$ ./run-suite.sh -s tests.txt
run-suite.sh: -s needs a value
usage: run-suite.sh [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>
$ echo $?
2

$ ./run-suite.sh -z tests.txt
run-suite.sh: unknown option: -z
usage: run-suite.sh [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>
$ echo $?
2

$ ./run-suite.sh -s 500 tests.txt
run-suite.sh: seeds must be 100 or fewer, got: 500
usage: run-suite.sh [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>
$ echo $?
2

Six decisions, all interface rather than implementation:

The header comment is the usage line plus the exit statuses, so head -6 run-suite.sh is documentation. That is cheaper than a README and cannot get out of sync with the usage() below it as easily.

--help goes to stdout and exits 0; every wrong call goes to stderr and exits 2. Measured above: --help | head -4 works and --help > /dev/null returns 0.

die prints the specific problem and then the usage line. -s needs a value alone leaves the user guessing at the syntax; the usage line alone leaves them guessing at which argument was wrong. Both is two lines and no guessing.

-eq 1 on the positional count, so tests.txt extra.txt is rejected rather than silently half-used.

The hand-rolled option loop handles -s VALUE with an explicit [ $# -ge 2 ] check — because shift 2 on a one-element list is a failure, and Chapter 15.4 measured that. Chapter 21.2 replaces this whole loop with four lines.

Validation is ordered: options, then count, then the values, then the world, then — only at mkdir -p — anything that changes the system. Chapter 20.3 §3's rule, which is also why -n can exit before the mkdir.

Practice

  1. Take a script of your own and write its usage line in one line, using the §1 conventions. If you cannot, describe the interface problem that makes it impossible.
  2. Write usage() with ${0##*/} and >&2. Then copy the script under a different name and confirm the message changes.
  3. Write a script whose --help goes to stderr and exits 2. Run ./s.sh --help | head -3 and ./s.sh --help && echo ok. Then fix it and re-run both.
  4. Build the help_text function that reuses usage 2>&1. Confirm the one-line form appears identically in both outputs, then change usage() and confirm both changed.
  5. Write the same check with -eq 1 and -ge 1. Call each with two arguments and describe which typo each one catches.
  6. Create a file named -weird.sv. Pass it to a script with and without --. Then add the -*) arm and confirm a mistyped option is reported instead.
  7. In run-suite.sh, remove the [ $# -ge 2 ] check from the -s arm and run ./run-suite.sh -s. What happens, and which chapter measured it?
  8. Run head -6 run-suite.sh. Is that adequate documentation for someone else? Add whatever is missing to the comment rather than to a README.
  9. Add a -j JOBS option to run-suite.sh, usage line first. Count how many places you had to change, and whether the usage line made any of them obvious.

Check Your Understanding

  1. Why write the usage line before the body?
  2. What do angle brackets and square brackets mean in a usage line?
  3. Where does --help output go, and with what status? Why?
  4. What is the measurable consequence of sending --help to stderr?
  5. Why prefer -eq to -ge on a positional count?
  6. What does -- buy a caller, and what does the -*) arm buy?
  7. Why should die print both the specific problem and the usage line?

7. Remember

  • A script large enough to have stages wants its stages separated by files, not functions — Chapter 32.2 measures why, on a driver whose cheapest stage is the one most likely to be wrong.
  • The interface is the part other people touch, so decide it before writing the work.
  • Write the usage line first. It forces the four decisions you would otherwise make by accident, and if it will not fit on one line you have found a design problem.
  • Use the man-page conventions: <required>, [optional], [-s VALUE], <file>....
  • usage() uses ${0##*/}, printf, and >&2.
  • --help goes to stdout with status 0. A help request is not an error, and stderr makes it unusable through a pipe or a redirect.
  • Let help_text reuse usage 2>&1 so the two cannot drift.
  • Document the exit statuses in the help text. It is the part an automated caller needs most.
  • Prefer -eq on the positional count — a script that ignores extra arguments accepts typos.
  • Honour --, and put the -*) arm before *) so a mistyped option is reported.
  • die prints the specific problem and then the usage line.
  • Put the usage line and exit statuses in the header comment, so head is documentation.

Chapter 21.2 replaces the hand-rolled option loop. Everything above stays — the usage line, the statuses, --help — but the fifteen lines of case/shift become four, and the two mistakes a caller can make get reported with the offending letter.

  • A usage message belongs inside the script, as a quoted heredoc. Chapter 35.6 measures a README whose documented --help ran the flow instead of describing it — four false claims in ten.

Where this fits

Part of the Linux & Bash curriculum.