BetterOpts is a pure Bash runtime library for declarative command-line
argument parsing. You declare flags, options, and positional arguments up
front; the library parses $@, validates it, applies defaults, populates
shell variables, and generates --help/--usage/Bash completion — all from
that one declaration.
- Bash 4.2 or newer (associative arrays,
declare -g,mapfile). macOS ships Bash 3.2 by default — install a newer one (e.g.brew install bash) and point your script's shebang at it. - No other dependencies.
betteropts.shis a single file; copy it into your project (or add this repo as a submodule) andsourceit.
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/betteropts.sh"
summary "Build a project"
description "
Compile a project and write the resulting artifacts.
Supports incremental and parallel builds.
"
flag verbose -v --verbose \
help="Enable verbose logging"
flag force -f --force \
help="Overwrite existing output"
option output -o --output PATH \
required \
type=directory \
help="Output directory"
option jobs -j --jobs N \
default=4 \
type=integer \
help="Worker count"
argument source required \
type=directory \
help="Source directory"
argument destination optional \
type=directory \
help="Destination directory"
betteropts_parse "$@"
echo "$output"
echo "$jobs"
echo "$source"You never touch $@, write your own case/getopts loop, or hand-roll
--help — declare the CLI, call betteropts_parse "$@", then write your
business logic against the variables it populates.
A one-line description, shown by both --help and --usage.
An optional longer description, shown only by --help. Written as a
multi-line string with a leading and trailing newline (see the quick-start
example); the leading/trailing blank lines are stripped automatically, any
blank lines in the middle are preserved.
A boolean switch. Give a short form (-x), a long form (--xxx), or both.
Populates <name>=true if the flag was passed (any number of times),
<name>=false otherwise.
flag verbose -v --verbose help="Enable verbose logging"
flag quiet --quiet help="Suppress output" # long-only is fineoption <name> [-x] [--xxx] METAVAR [required] [type=T] [choices=a,b,c] [default=D] [implicit=V] [multi] [var=name] [help="..."]
A flag that takes a value. METAVAR (e.g. PATH, N) is the placeholder
name shown in --help/usage. Accepts:
-o value
--output value
--output=value
-o=value and bundled short flags (-vf) are intentionally not
supported, to keep the parser simple.
option output -o --output PATH required type=directory help="Output directory"
option jobs -j --jobs N default=4 type=integer help="Worker count"Add multi to make the option repeatable: each occurrence appends its value
to an ordered bash array instead of overwriting a scalar.
option topic -t --topic VALUE multi type=choice choices=fast,slow,auto \
help="Note topic to show (repeatable)"--topic fast --topic slow populates topic=(fast slow). type=/choices=
validation applies to each value independently. required + multi means
"at least one occurrence is required" — zero occurrences is a Missing required option error, same as a non-multi required option. Zero
occurrences without required populates an empty array, so
"${#topic[@]}" is always safe to check regardless of whether the option is
multi. default= combined with multi is a schema error (checked at
startup, same as the other schema rules above) — there's no single sensible
meaning for "the default list" when zero, one, or many values may be
supplied.
Add implicit=VALUE to make the option's value optional: a bare occurrence
(no attached value) is treated exactly as if implicit's value had been
typed explicitly. This is the pattern behind git log --notes[=<ref>]:
option notes --notes REF multi implicit=refs/notes/commits \
help="Show notes from REF"git log # no notes shown -> notes=()
git log --notes # -> notes=(refs/notes/commits)
git log --notes=refs/notes/other # -> notes=(refs/notes/other)
git log --notes --notes=refs/notes/other # -> notes=(refs/notes/commits refs/notes/other)
Only --notes=value (long form with =) attaches an explicit value; a
following bare token (--notes value) is not consumed as --notes's
value — it's left for the next positional/option, matching GNU
getopt_long's --opt[=arg] convention. The short form, if declared, is
always bare (-n behaves like the bare long form; there's no -nVALUE or
-n value attachment) — an explicit value always requires --long=value.
implicit= is orthogonal to default= — default= still means "value
used when the option is never provided at all", while implicit= means
"value used when the option is provided bare". The two can be combined:
option mode --mode VALUE default=off implicit=on help="Feature mode"
# (not passed at all) -> mode=off
# --mode -> mode=on
# --mode=custom -> mode=customA bare occurrence's value is recorded through the exact same path an
explicit value would be, so type=/choices= validation applies to it
exactly as it would to a value the user typed — unlike default=, which
stays trusted-as-is and is never validated. implicit= combined with
multi and default= together remains a schema error, same as multi +
default= without implicit= — that rule is about "the default list"
having no single sensible meaning for zero-vs-many occurrences, which
implicit= doesn't resolve.
argument <name> <required|optional|variadic|passthrough> [type=T] [choices=a,b,c] [default=D] [var=name] [help="..."]
A positional argument. Exactly one of required, optional, variadic, or
passthrough must be given:
required— must be supplied. Cannot declare adefault=(a required argument with a default is a contradiction — checked at startup as a schema error, the same way "more than one variadic argument" is).optional— may be omitted; populates an empty string when it is, or thedefault=value if one was declared.variadic— collects every remaining positional token into a bash array, zero or more. If none were given and adefault=was declared, the array is populated by splitting the default on commas (same convention aschoices=a,b,c).passthrough— likevariadic, but the parser stops looking for declared flags/options as soon as it hits the first token that isn't one of them, and captures every remaining token verbatim into a bash array — including tokens starting with-, with no "Unknown option" error. This is the same boundary a literal--already creates, just triggered automatically instead of requiring the marker. If a literal--is what triggers the boundary, it's captured too, as the array's first element - unlike plainvariadic, where a leading--is just syntax and is dropped. This matters because passthrough tokens are typically forwarded raw to another command, where the--can itself carry meaning (e.g. disambiguating a pathspec from a revision forgit); dropping it would be indistinguishable from the caller never having typed it. Notype=/choices=validation applies to passthrough tokens, and nodefault=is supported.
Only one variadic or passthrough argument is allowed per CLI
(whichever kind it is), and it must be the last one declared (checked at
startup; a broken schema is a bug in your script, not user input, so it's
reported the same way a parse error is).
Declared arguments must also follow one fixed order: any required
arguments first, then any optional ones, then the trailing variadic/
passthrough argument if there is one — required* optional* (variadic | passthrough)?. Declaring a required argument after an optional one is
a schema error rather than being silently accepted, since positional
tokens are assigned left to right in declaration order — an earlier
optional argument would otherwise greedily claim the token meant for the
later required one, surfacing as a confusing Missing required argument
error instead of a clear schema-declaration error.
argument source required type=directory help="Source directory"
argument destination optional type=directory help="Destination directory"
argument files variadic help="Extra files" # populates files=(...)
argument commit optional default=HEAD help="Commit ref"
argument reviewers variadic default=alice,bob help="Reviewers"
argument git_args passthrough help="Extra options forwarded to git log"option author -a --author VALUE help="Filter by author"
argument git_args passthrough help="Extra options forwarded to git log"invoked as mycommand --author alice --stat -M populates author=alice and
git_args=(--stat -M) — --stat/-M are never looked up as declared
options once the boundary is crossed.
As with option's default=, an argument's default is trusted as-is and is
never itself type-checked.
By default the populated variable is named after the declared name. Add
var=name to any flag, option, or argument to change that:
flag verbose -v --verbose var=is_verbose
option output -o --output PATH var=build_dir
argument source required var=input_dirpopulates $is_verbose, $build_dir, and $input_dir instead of
$verbose/$output/$source.
| Type | Validates | Completion behavior |
|---|---|---|
string |
(no validation) | none |
integer |
matches ^-?[0-9]+$ |
none |
float |
matches ^-?[0-9]+(\.[0-9]+)?$ |
none |
file |
path exists and is a regular file | file completion |
directory |
path exists and is a directory | directory completion |
choice |
one of choices=a,b,c |
the listed choices |
git-commitish |
resolves to a commit via git rev-parse |
none |
git-range |
resolves via git rev-list --count |
none |
Omitting type= is the same as type=string. A default value (default=)
is trusted as-is and is never itself type-checked.
type=git-commitish accepts anything git itself would accept as a
commit-ish — a SHA (full or abbreviated), branch, tag, or an expression like
HEAD~2 — by running git rev-parse --verify --quiet "<value>^{commit}".
A tree/blob SHA (not a commit) or an unresolvable name is rejected with the
usual Invalid value: error. If the command isn't running inside a git
repository at all, that's reported distinctly as Not inside a git repository: rather than letting git's own error for the inner check leak
through.
option base -b --base VALUE type=git-commitish help="Base commit to diff against"
argument commit optional type=git-commitish default=HEAD help="Commit to inspect"type=git-range accepts either a bare revision or a A..B/A...B range,
by running git rev-list --count "<value>". Kept as a separate type from
git-commitish rather than an extension of it, so a plain single-commit
field doesn't silently start accepting range syntax. Same not-in-a-repo
handling as git-commitish.
argument from_range required type=git-range help="Range to copy notes from"
argument to_range required type=git-range help="Range to copy notes to"A broken CLI declaration is treated as a bug in your script, not user
input: betteropts_parse rejects it before parsing $@ at all, naming the
exact bad token.
An unrecognized key=value attribute (a typo like chocies= instead of
choices=) is rejected:
'chocies' is not a recognized attribute for option 'mode'.
So is a bareword keyword that isn't valid for the given kind — required
or multi on a flag, optional/variadic/passthrough on anything but
an argument, or a second bareword after an option's metavar:
'optional' is not a valid option modifier for 'jobs'.
An argument must declare exactly one of required, optional,
variadic, or passthrough — declaring none, or more than one, is also a
schema error rather than silently behaving as optional.
A type= value that isn't one of the recognized types (a typo like
type=int instead of type=integer) is rejected the same way, rather than
silently being treated as no type constraint:
'int' is not a recognized type for option 'jobs'.
--help appends a parenthesized, comma-separated annotation list after an
option's or argument's label, summarizing schema facts that would otherwise
only be visible by reading the CLI's source: required, repeatable (a
multi option or a variadic argument), default: <value> (printed
verbatim, e.g. an unmodified alice,bob default list), implicit: <value>
(an option's implicit=, printed verbatim), and choices: <a, b, c> (for
type=choice). Only the annotations that actually apply are shown; an
option or argument with none of these renders exactly as it did before.
flag declarations never gain an annotation — they don't support
required, multi, default=, implicit=, or choices= at all.
option output -o --output PATH required type=directory help="Output directory"
option jobs -j --jobs N default=4 type=integer help="Worker count"
option topic -t --topic VALUE multi type=choice choices=fast,slow,auto help="Note topic to show"
option notes --notes REF multi implicit=refs/notes/commits help="Show notes from REF"renders as:
-o, --output PATH (required)
Output directory
-j, --jobs N (default: 4)
Worker count
-t, --topic VALUE (repeatable, choices: fast, slow, auto)
Note topic to show
--notes REF (repeatable, implicit: refs/notes/commits)
Show notes from REF
This is the only function you call after declaring the CLI, and it must be
called with your script's original "$@". It runs the full lifecycle:
- Finalizes the schema (catches a broken CLI declaration, e.g. two variadic arguments).
- Handles
-h/--help,--usage, and--__complete— checked against the raw arguments (so--helpwins even next to other invalid options), and only up to a literal--(so a positional argument that happens to be the string--helpafter--is not treated as the flag). These print their output andexit 0. - Parses
$@against the schema. An unknown option, a missing option value, or an unexpected/missing positional argument prints an error to stderr andexit 1s. - Validates required options/arguments are present and every provided
value matches its declared type. A failure prints to stderr and
exit 1s. - Applies defaults to options that weren't provided.
- Populates shell variables — ordinary variables in your script's scope, never exported.
On success, betteropts_parse returns normally and your script continues.
You never need to check its exit status yourself: if it returns at all, the
CLI was valid.
All errors go to stderr and exit with status 1:
Unknown option:
--verboes
Use --help for usage.
Missing value:
--output
Unexpected argument:
foo
Missing required argument:
SOURCE
Missing required option:
--output
Type-validation failures look like:
Invalid value:
--jobs abc (must be an integer)
Register the library's generic completion function against your command name(s):
source /path/to/betteropts.sh
complete -F _bo_bash_completion -o nosort mycommand_bo_bash_completion knows nothing about mycommand's schema — it
re-invokes mycommand --__complete -- <words...> and feeds the candidates
(one per line) it prints back into COMPREPLY. -o nosort keeps candidate
order as emitted (e.g. a choice list's declared order) instead of
alphabetizing it. --__complete is an internal interface: it's reserved,
never shown in --help, and not meant to be invoked by end users directly.
Tests are written in BATS
(vendored as git submodules under support/):
git submodule update --init --recursive
test/run.shtest/unit/*.bats exercise the library's internal functions directly (by
sourcing betteropts.sh); test/integration/*.bats run the fixture CLIs
under test/fixtures/ end-to-end as real subprocesses.
Line coverage is measured with kcov:
test/coverage.sh