Skip to content
Shell & TerminalDeep Dive Published Updated 8 min readViews unavailable

Fish argparse Contracts: Option Grammar, Forwarding, and Reliable Validation

Build predictable Fish CLIs with argparse option specs, local flag state, strict long options, subcommand boundaries, forwarding, and explicit validation.

Fish’s argparse builtin turns a function or script’s argument vector into declared flags and remaining operands. Its value is not just shorter parsing code. A declared grammar makes the command’s accepted syntax explicit, gives a function local flag variables, and separates consumed options from positional arguments. The parser does not decide whether a path exists, whether a numeric value is sensible for an application, or whether a downstream command can safely act on the result. Those are separate validation and execution stages.

The essential call has three parts: parser options, option specifications, a mandatory separator, and the arguments being parsed. Treating that separator and the resulting variables as a contract avoids many subtle wrapper bugs.

The separator is part of the interface

The first literal double dash ends argparse’s own options and option specifications. It is mandatory even when there are no user arguments. A reliable function passes its original argument list after that separator and returns immediately if parsing fails:

function artifact-publish
    argparse --strict-longopts \
        'h/help' \
        'n/name=' \
        't/tag=+' \
        -- $argv
    or return

    if set -q _flag_help
        printf 'Usage: artifact-publish --name NAME [--tag TAG]... PATH...\n'
        return 0
    end

    if not set -q _flag_name
        printf 'artifact-publish: --name is required\n' >&2
        return 2
    end

    set -l artifact_name $_flag_name[-1]
    set -l tags $_flag_tag
    set -l paths $argv
    # Validate and act only after parsing is complete.
end

Fish variables are lists. Passing an unquoted list expansion such as $argv to another command passes its elements as separate arguments; it does not perform the POSIX shell’s ordinary whitespace word-splitting. Avoid converting an argument list to a single string and reparsing it. That destroys the original element boundaries and makes filenames containing spaces difficult to handle correctly.

The parser changes local variables named argv and argv_opts in the calling function’s scope. After success, argv contains non-option arguments and argv_opts contains consumed options and their values. If there are no remaining operands, argv is still a set variable with a count of zero. Use count or set -q intentionally; do not infer that parsing failed merely because argv is empty.

Write specifications as a grammar

A specification may define a short name, long name, and value rule. For example, h/help is a boolean flag, n/name= requires one value and retains the final occurrence, and tag=+ accepts a required value on each occurrence. A boolean flag’s generated variable records which spelling was seen, and a repeated flag may have multiple entries. An absent flag variable is unset. Check presence with set -q _flag_name rather than relying on an empty-string convention.

The suffix distinguishes cardinality, not semantic validity. A trailing equals sign means a value is required and only the last occurrence is stored. Equals-question-mark defines an optional value and keeps only the last occurrence. Equals-plus requires a value on every use and retains every value. Equals-asterisk accepts repeated optional values. For optional values, the value must be attached, as in –color=always or -calways. In –color always, always is instead a positional argument. Prefer required values for interfaces where a detached value is expected; users otherwise have to learn this easily missed boundary.

Prefer explicit long spellings and enable –strict-longopts. Without strict mode, fish may accept prefixes and certain single-dash forms as abbreviations for a declared long flag when no conflicting name exists. That can make a typo look valid today, or cause a previously accepted abbreviation to become ambiguous as the command grows. Strict mode requires the full declared long option, which makes scripts and documentation less dependent on incidental parser behavior.

Use –exclusive to define mutually exclusive flags and –min-args or –max-args to constrain operand count. These options encode simple structural rules in the parser, but do not replace checks for required combinations or application-specific rules. A command can parse –region and –account successfully and still need to reject a region that is not supported by the selected account.

Parse first, then validate values

Parsing answers whether the tokens conform to the declared syntax. It is a separate responsibility to validate their meaning. Check required values, numeric ranges, supported names, file types, and cross-field relationships after argparse succeeds. Keep diagnostics on stderr and choose a stable nonzero exit status for invalid input.

function artifact-retain
    argparse --strict-longopts \
        'd/days=' \
        'n/dry-run' \
        -- $argv
    or return 2

    if not set -q _flag_days
        printf 'artifact-retain: --days is required\n' >&2
        return 2
    end

    set -l days $_flag_days[-1]
    if not string match -rq '^[0-9]+$' -- $days
        printf 'artifact-retain: days must be a non-negative integer\n' >&2
        return 2
    end

    set -l numeric_days (math -- $days 2>/dev/null)
    or return 2
    if test $numeric_days -gt 3650
        printf 'artifact-retain: days must not exceed 3650\n' >&2
        return 2
    end

    # Perform the operation only after the complete input is validated.
end

Fish argparse can run a validation script as part of an option specification, but that facility has a specific diagnostic contract: the validator writes its error text to stdout and returns zero for a valid value or nonzero for an invalid one. That is surprising for commands whose normal diagnostics go to stderr. For reusable CLIs, post-parse validation is often easier to test and makes all error paths consistent. If using parser validators, test both their output stream and how the caller reports the parse failure.

Do not use a numeric-looking value as though it were already a safe integer. A pattern check can constrain accepted syntax; math can then perform arithmetic, but its own failure must be handled. Bound values before using them to construct loops, resource requests, or retention operations. Validation should establish an invariant that the rest of the function can rely on.

Preserve options when wrapping another command

Wrappers sometimes add an option while forwarding the underlying command’s options. argv_opts exists for this purpose, but unknown-option handling needs a deliberate grammar. By default, unknown options are treated as though they may take optional values. If a wrapper opts into –move-unknown, unknown tokens are moved to argv_opts, and –unknown-arguments controls how those options bind to subsequent tokens.

A safe wrapper should list the downstream options it needs to understand, use strict long option parsing for its own interface, and select an explicit unknown-argument policy. Then it should forward an argument vector, not a reconstructed command string. The Fish documentation demonstrates this pattern for a wrapper that transforms a custom option and forwards the rest to head.

function my-head
    argparse --strict-longopts \
        --move-unknown \
        --unknown-arguments=none \
        'n/lines=' \
        'qwords=&' \
        -- $argv
    or return

    set -l forwarded $argv_opts
    if set -q _flag_qwords
        set -l bytes (math -- $_flag_qwords \* 8)
        or return
        set -a forwarded --bytes=$bytes
    end

    command head $forwarded -- $argv
end

This is an illustrative contract, not a complete drop-in replacement for head. A real wrapper must decide which downstream options accept values, whether unknown flags are allowed, how a downstream – separator is preserved, and what semantics it promises. The example also shows why strict-longopts matters: a short flag for the wrapper must not accidentally be interpreted as a prefix of one of its long options.

An option’s ampersand modifier keeps that option and any attached value out of both argv and argv_opts; it does not suppress the generated flag variable. This is useful when the wrapper consumes a flag and later emits a transformed downstream option. Document that transformation and preserve ordering where the child program treats ordering as significant.

Establish subcommand boundaries

There are two common CLI shapes. A single parser can define every option for a command, or a top-level command can parse global options and then dispatch a subcommand. In the latter design, –stop-nonopt stops parsing at the first non-option so that the subcommand can own the remaining syntax. Do not let the top-level parser silently consume a subcommand’s flags.

function tool
    argparse --strict-longopts --stop-nonopt \
        'v/verbose' \
        -- $argv
    or return 2

    if test (count $argv) -lt 1
        printf 'Usage: tool [--verbose] COMMAND [ARGS...]\n' >&2
        return 2
    end

    set -l subcommand $argv[1]
    set -l subargs $argv[2..-1]
    switch $subcommand
        case inspect
            command tool-inspect $subargs
        case publish
            command tool-publish $subargs
        case '*'
            printf 'tool: unknown command: %s\n' $subcommand >&2
            return 2
    end
end

The invoked program receives only the intended operands after dispatch. In a production CLI, dispatch to named Fish functions or an executable path that is controlled by the installation rather than assuming a command name resolves to the expected binary. Keep the command’s parser and the subcommand parser independently testable.

Failure cases worth testing

Test the grammar as a matrix, not only with a successful example. Cover an empty argument list, each short and long spelling, missing required values, repeated values, attached optional values, unknown options, an operand beginning with a dash, mutually exclusive switches, too few and too many operands, and the literal end-of-options separator. Include values with spaces and glob characters to verify that list elements remain distinct.

For a wrapper, test both known and unknown downstream options, options whose values are separate tokens, and the point where a child command’s own separator begins. Capture stdout and stderr independently. Confirm that a parse error returns before side effects, and that help does not accidentally run the operation. Add regression tests for long-option abbreviations if the interface previously accepted them.

Finally, state the accepted grammar in user-facing help. A parser is only a useful contract when the documented grammar matches the actual grammar. Keep option names stable, avoid optional values unless they materially improve the interface, and treat the distinction between parsing, validation, and execution as three independently testable stages.

Related:

Sources:

Comments