Skip to content

Repository files navigation

BetterOpts

CI

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.

Requirements

  • 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.sh is a single file; copy it into your project (or add this repo as a submodule) and source it.

Quick start

#!/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.

Declaring the CLI

summary "text"

A one-line description, shown by both --help and --usage.

description "text"

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.

flag <name> [-x] [--xxx] [var=name] [help="..."]

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 fine

option <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=custom

A 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 a default= (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 the default= value if one was declared.
  • variadic — collects every remaining positional token into a bash array, zero or more. If none were given and a default= was declared, the array is populated by splitting the default on commas (same convention as choices=a,b,c).
  • passthrough — like variadic, 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 plain variadic, 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 for git); dropping it would be indistinguishable from the caller never having typed it. No type=/choices= validation applies to passthrough tokens, and no default= 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.

Overriding the populated variable name

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_dir

populates $is_verbose, $build_dir, and $input_dir instead of $verbose/$output/$source.

Types

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"

Schema validation

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 text annotations

--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

Calling betteropts_parse "$@"

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:

  1. Finalizes the schema (catches a broken CLI declaration, e.g. two variadic arguments).
  2. Handles -h/--help, --usage, and --__complete — checked against the raw arguments (so --help wins even next to other invalid options), and only up to a literal -- (so a positional argument that happens to be the string --help after -- is not treated as the flag). These print their output and exit 0.
  3. Parses $@ against the schema. An unknown option, a missing option value, or an unexpected/missing positional argument prints an error to stderr and exit 1s.
  4. Validates required options/arguments are present and every provided value matches its declared type. A failure prints to stderr and exit 1s.
  5. Applies defaults to options that weren't provided.
  6. 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.

Error messages

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)

Bash completion

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.

Testing

Tests are written in BATS (vendored as git submodules under support/):

git submodule update --init --recursive
test/run.sh

test/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

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages