Bash / Advanced: channels, concurrency, and maintainable CLI design / Lesson 18
Publish a maintainable command-line contract
Choose a supported shell language, parse options predictably, document compatibility, and keep modules and tests aligned with the CLI.
On this page
Lesson goals
- Separate POSIX portability from Bash-version compatibility.
- Use getopts with deliberate error and operand handling.
- Design a stable CLI with tests for unsupported and malformed input.
Portability is a declared target set
A portable script names the shells and platforms it intends to support. POSIX sh is a specification target rather than a synonym for every installed shell. Bash adds arrays, [[, process substitution, coprocesses, and other facilities that should not appear in a script advertised as generic sh without qualification. Conversely, avoiding every Bash feature can make a Bash-only program needlessly obscure. Choose the language from deployment needs and maintenance cost. Bash POSIX mode adjusts documented behavior but does not prove the script works on another implementation; test the actual target set.
A recent manual can describe unavailable features
Record a supported minimum or feature set and verify it on deployment. New substitution forms, wait options, array details, and builtin flags can differ from the tested Bash baseline. A feature probe or explicit startup check can produce a clear diagnostic instead of a later syntax surprise, but code requiring unsupported syntax may fail parsing before a runtime check executes. Keep the entry strategy consistent with that limitation. Use matching documentation and tests rather than claiming all current online examples run on older distribution shells. The course records exactly which interpreter validated its runnable examples.
Parse short options with explicit errors
getopts processes an option vocabulary and tracks progress in OPTIND. A colon after an option letter requires an argument; a leading colon selects controlled diagnostic handling. After parsing, shift by OPTIND minus one and validate the remaining operands. Reset OPTIND when reusing a parser in the same shell with a new argument list. getopts does not automatically provide a universal long-option parser or validate the meaning of an option value. Keep help, invalid-option, missing-argument, and extra-operand behavior explicit and return documented statuses without mixing diagnostics into data.
parse() {
local OPTIND=1 option label=default
while getopts ':n:' option; do
case $option in
n) label=$OPTARG ;;
:) return 2 ;;
\?) return 2 ;;
esac
done
shift "$((OPTIND - 1))"
[[ $# == 1 ]] || return 2
printf 'label=<%s> operand=<%s>\n' "$label" "$1"
}
parse -n 'review mode' -- 'red apple'
label=<review mode> operand=<red apple>
The parser uses Bash local state and a deliberate one-operand contract. -- terminates options, while quoted values preserve spaces; the example performs no external work.
Design outputs and evolution before adding flags
A stable CLI documents what stdout contains, where human diagnostics go, what nonzero statuses mean, and whether output ordering is guaranteed. Do not add progress banners to a machine-readable stream in a maintenance release. New flags should have clear defaults and conflict rules, and invalid combinations should fail before effects begin. Separate argument parsing from the operation so each can be tested with synthetic inputs. A reusable library should avoid implicit execution at source time. Keep version notes truthful: compatibility claims should identify tests actually run, not merely an intended future environment.
Keep a portable core simple when it is needed
For a small portable transformation, use positional parameters, case, quoted expansions, and documented POSIX utilities. Avoid relying on Bash local, arrays, or [[ in that core. Test under another available target shell as well as Bash, recognizing that one additional shell is evidence for that target only. When requirements genuinely need Bash, declare it and retain its clearer data structures rather than emulating arrays with unsafe strings. Maintain a compact regression matrix covering values, statuses, parsing, and execution boundaries. A predictable interface is more valuable than a clever script that no maintainer can safely change.
set -- 'red apple' ''
for item do
printf '<%s>\n' "$item"
done
printf 'count=%s\n' "$#"
<red apple>
<>
count=2
The implicit positional for list preserves both arguments. This complete example is checked under Bash and the locally available sh; that is not a claim about every shell version.
Common errors and fixes
A Bash extension appears under a generic sh claim
- Why it happens
- Language requirements were not documented or tested.
- How to fix it
- Declare Bash or replace the extension and test targets.
- Prevent it next time
- Maintain an explicit compatibility matrix.
Repeated getopts calls skip options
- Why it happens
- OPTIND retained parser progress from an earlier call.
- How to fix it
- Reset parser state for the new argument list.
- Prevent it next time
- Test repeated parser invocation.
A new banner breaks an automated consumer
- Why it happens
- Diagnostic text was added to stable data output.
- How to fix it
- Move human messages to the documented diagnostic channel.
- Prevent it next time
- Test stdout as a public interface.
Check your understanding
1. Why can a runtime version check fail to protect unsupported syntax later in the same parsed file?
Show worked solution
The shell may need to parse that syntax before it executes the check. Choose an entry structure that remains parseable on the rejected interpreter or use a launcher with an explicit supported interpreter. Test the rejection path, not just the supported path.
2. What does passing a portable example under local sh establish?
Show worked solution
It establishes observed behavior for that specific implementation and version under the test environment. It supports, but does not replace, a standards-based review and tests on the remaining declared targets. Report exactly which shells were exercised.
Key takeaways
- Declare shell and version support as part of the CLI.
- Keep parsing, effects, outputs, and regression contracts separate.