Fish Completion Engineering: Context, Argument Contracts, and Testable Generators
Design Fish completions as a command interface: model option context, control file candidates, generate safe values, and test behavior with complete -C.
Fish completions are executable descriptions of a command’s grammar. They tell the interactive reader which options, subcommands, paths, and values are plausible at the current cursor position. A completion is neither validation nor authorization: a suggestion can be stale, a user can type a value that was never suggested, and the command must still reject invalid input when it runs.
Good completion code is therefore a second, deliberately limited model of the CLI. It should match the command’s accepted syntax, avoid expensive or state-changing work while the user presses Tab, and be easy to inspect with Fish’s completion test interface.
Model the command grammar before writing rules
Start by listing the command’s top-level options, subcommands, which options take values, and which arguments are paths. Do not write completions by copying examples without checking how the program actually accepts an option argument. In Fish, an option argument is offered attached by default. For options that accept a separate value token, use –require-parameter, usually together with –no-files when that value is not a filename.
# ~/.config/fish/completions/deployctl.fish
complete -c deployctl -f
complete -c deployctl -s h -l help -d 'Show command help'
complete -c deployctl -s v -l verbose -d 'Show additional diagnostics'
complete -c deployctl -l context -x -a 'production staging development' \
-d 'Select the deployment context'
complete -c deployctl -n '__fish_use_subcommand' \
-a 'status plan apply rollback' -d 'deployctl operation'
complete -c deployctl -n '__fish_seen_subcommand_from plan apply' \
-l file -r -F -d 'Read a deployment manifest'
The -x form is Fish’s exclusive completion: it combines a required option parameter with suppression of ordinary file completion. The file option above uses -r and -F instead, so it requires a separate parameter while explicitly allowing filename suggestions. If a command accepts –file=path but not –file path, the grammar differs and so should its completion rule. Test the exact forms accepted by the executable.
The initial -f disables file candidates for the command as a whole. Re-enable them selectively with -F for options or contexts that really accept paths. This avoids a pager full of unrelated filenames when the user expects a fixed set of subcommands. Do not disable files globally if the command accepts bare path operands; model each path-taking context intentionally.
Conditions scope rules to the current command line
The -n condition option runs a Fish command and enables the completion only when it returns status zero. Fish provides helper predicates such as __fish_use_subcommand and __fish_seen_subcommand_from for common command structures. Use them instead of hand-parsing the entire line where they accurately express the desired context.
Conditions should be cheap and side-effect free. They can run repeatedly as the user edits, so do not use them to deploy, change configuration, make network mutations, or write cache state. When conditions invoke a helper function, keep that function narrow and ensure it handles incomplete command lines without errors. Multiple conditions are checked in order and Fish stops when a condition fails, so putting cheap tests first can avoid unnecessary work.
Avoid encoding ambiguous CLI behavior in completion conditions. For example, if the command treats the first non-option as a subcommand and later options belong to that subcommand, the completion should follow the same boundary. If users can place global options after the subcommand, test that arrangement instead of assuming a rigid position.
Generate candidate values with a bounded helper
For a small static set, pass words directly to -a. For dynamic candidates, Fish expands command substitutions in the arguments string when completions are requested. The generator must print candidates as separate lines and must not turn each candidate into shell source. Keep network and filesystem costs predictable; a completion that makes a slow API call for every Tab press feels broken even when it returns correct data.
function __fish_deployctl_contexts
# The CLI's list command must be read-only and safe to run repeatedly.
command deployctl context list --format=names 2>/dev/null
end
complete -c deployctl -l context -x \
-a '(__fish_deployctl_contexts)' \
-d 'Select a configured deployment context'
This pattern is correct only if the CLI really supports the named read-only operation and prints one context per line. If output includes headers, descriptions, warnings on stdout, or values containing newlines, the candidate list needs an explicit adapter. Avoid parsing human-oriented output when the CLI can emit a stable machine-readable format. If a generator can fail, its failure should produce no misleading candidates and should not interfere with command execution.
For a long-running or remote source, consider completing from locally available state or a separately refreshed cache rather than blocking each interactive completion. If the command’s own grammar accepts arbitrary context names, completion must remain a convenience and must not become the only path for entering them. A completion that lists known contexts cannot guarantee that a remote context will still exist when the command executes.
Quote completion specifications as Fish data
Fish completion definitions pass an argument string to the completion engine, which tokenizes and expands it at completion time. This differs from writing a normal command invocation in a script. Read the completion manual before adding nested quotes or command substitutions, and test candidates that contain spaces, wildcard characters, or punctuation. Use –escape with complete -C when inspecting values that Fish would otherwise need to escape for insertion.
Do not interpolate an untrusted string into a command fragment. A completion function should obtain values as data, not evaluate generated shell text. Avoid eval. For a generator that reads an existing file, use Fish list semantics, quote paths deliberately, and handle missing files without noisy errors on every tab press. Completion should not leak stack traces or diagnostics into the user’s command line.
Descriptions are part of the pager interface. Keep them concise and distinguish a candidate’s meaning without repeating the candidate. If a command accepts many dynamic values, avoid returning huge lists; filter based on the current token or use the CLI’s own prefix-aware list operation where possible.
Wrap related commands without duplicating their grammar
The -w option makes one command inherit another command’s completions. This is suitable for a true wrapper that accepts the wrapped command’s options unchanged. It is not suitable when the wrapper rewrites, removes, or gives different meaning to a flag. In that case, define the altered rules explicitly or create a shared helper function that registers a consistent subset for both commands.
Check that the wrapped executable is truly compatible. A wrapper may add global options, change argument order, or reserve a subcommand name. Inherited rules can then suggest invalid combinations. If only a subset is shared, factor the common completion definitions or helper predicates so that changes do not drift between two independent files.
Inspect completions without relying on a live pager
Fish’s complete -C STRING asks the completion engine for candidates for a supplied command line. This provides a repeatable way to check options and contextual behavior without manually pressing Tab. The result is especially useful for debugging why a filename appears, why an option argument is missing, or why a subcommand’s candidates appear too early.
complete -C 'deployctl '
complete -C 'deployctl apply --'
complete -C 'deployctl apply --file '
complete -C 'deployctl --context '
complete -C 'deployctl plan --file=./'
Run the test in a clean Fish process that loads the same completion paths as the intended users. Compare candidate output for a command position, a subcommand position, a required option parameter, an attached option parameter, an equals-form parameter, and a path-valued argument. If the command uses conditions based on earlier arguments, test both before and after the condition becomes true.
complete -C can escape results for insertion testing. Use both escaped and unescaped output when a generated candidate contains spaces or shell metacharacters. Also validate the completion file syntax with Fish itself when available. A static source file may parse while still returning incorrect context due to a condition or no-files interaction.
Keep the executable as the final authority
Completions can be stale because configuration may be loaded before a CLI upgrade, the user’s local cache may lag, or a remote object can change between suggestion and execution. A user can also paste a value without invoking completion. Every option and operand must therefore be validated by the command itself.
Never use a completion function to enforce access control, conceal a sensitive value, or make a remote mutation. The completion layer is interactive UX code running inside the user’s shell; it is not a trusted policy boundary. Do not include secrets in descriptions, logs, or candidate output. When dynamic completion needs credentials to read metadata, follow the CLI’s documented credential flow and keep the result minimal.
Operational acceptance checklist
Before distributing a completion, verify:
- The command’s current help or authoritative CLI specification agrees with each declared switch and parameter shape.
- Static and dynamic candidates appear only at the intended command-line positions.
- Required values are offered in the same attached or separate form that the command accepts.
- Filenames appear for file operands and are suppressed for closed vocabularies.
- Dynamic generation is read-only, bounded, quiet on failure, and does not evaluate generated data.
- complete -C test cases cover root options, subcommands, option values, and paths.
- The command still validates invalid, stale, or manually typed values at execution time.
After a CLI upgrade, rerun the candidate matrix. Completions are executable documentation, and stale executable documentation degrades trust quickly. Keep the smallest reliable grammar, test it as data, and treat the real command’s parser as the canonical authority.
Related:
Sources: