All posts
7 min readDavid Nussio

Dynamic tab completion in bash, zsh and fish

A completion script generated at build time knows every subcommand and flag of a CLI. It does not know the names you created yesterday. When those names are the arguments you type most, a static script completes everything except the part you need.

envsec groups secrets into contexts such as myapp.dev, and almost every command takes a context with -c and a key such as db.password. Contexts, keys and saved command names live in a SQLite metadata database on your machine, so the completion script has to ask envsec for them on every Tab press. This post walks through how that works in bash, zsh and fish, what each shell makes awkward, and the bugs I found while testing the scripts for this article. They are fixed in envsec 1.1.3; the snippets below show the scripts as they were when I found them.

One hidden subcommand

envsec is built on effect/cli, which can print a static completion script for --completions <shell>. envsec intercepts that flag before the CLI parser runs (both --completions zsh and --completions=zsh) and prints its own scripts instead. sh is accepted as an alias for the bash script.

All three scripts call back into the same hidden entry point, envsec __complete. It takes a type and prints one name per line:

Terminal
$envsec __complete contexts
$envsec __complete keys myapp.dev
$envsec __complete commands
text
myapp.dev
myapp.prod
api.key
db.password
stripe.secret
deploy
migrate

Plain lines are the lowest common denominator: bash splits them into words, zsh splits them on newlines, and fish reads one candidate per line. The handler wraps its whole body in Effect.ignore, because a stack trace in the middle of a completion menu is worse than an empty menu. The scripts also send stderr to /dev/null.

Completion only ever prints names. The values stay in the OS credential store, and listing names reads only the metadata database.

Installing the scripts

The Homebrew formula generates all three scripts at install time with Homebrew's generate_completions_from_executable, which runs envsec --completions bash and friends and drops the output where each shell looks for it. On Apple Silicon that is /opt/homebrew/etc/bash_completion.d/envsec, /opt/homebrew/share/zsh/site-functions/_envsec and /opt/homebrew/share/fish/vendor_completions.d/envsec.fish.

ruby
def install
  bin.install "envsec"
  generate_completions_from_executable(bin/"envsec", "--completions", shells: [:bash, :zsh, :fish])
end

Without Homebrew, install them by hand:

Terminal
# bash: add to ~/.bashrc
$eval "$(envsec --completions bash)"
# fish: add to ~/.config/fish/config.fish
$envsec --completions fish | source
# zsh: write the script into a directory on $fpath
$mkdir -p ~/.zfunc
$envsec --completions zsh > ~/.zfunc/_envsec
zsh
# ~/.zshrc, before compinit runs
fpath=(~/.zfunc $fpath)
autoload -Uz compinit && compinit

A correction for zsh users: the README and the docs used to say eval "$(envsec --completions zsh)". That does not work, and they now describe the fpath setup. The zsh script is written as an autoloadable #compdef file that ends by calling _envsec, so evaluating it in .zshrc runs _arguments outside a completion and prints _arguments:comparguments:327: can only be called from completion function. Nothing gets registered. The fpath setup above is the one that works, and it is what Homebrew does for you.

The script only contains the static structure; contexts and keys are fetched live. Regenerate it after upgrading envsec only if you want new subcommands and flags to complete.

bash: parsing COMP_WORDS by hand

bash hands the completion function an array of words, COMP_WORDS, and the index of the word under the cursor, COMP_CWORD. Everything else is up to you. The envsec script walks the words before the cursor once, remembering the first subcommand it sees and the word after -c or --context:

bash
_envsec_completions() {
    local i cur prev opts cmd subcmd context_val
    COMPREPLY=()
    cur="${COMP_WORDS[COMP_CWORD]}"
    prev="${COMP_WORDS[COMP_CWORD-1]}"
    cmd=""
    subcmd=""
    context_val=""

    # Detect current subcommand and --context value
    for ((i=1; i < COMP_CWORD; i++)); do
        case "${COMP_WORDS[i]}" in
            -c|--context)
                context_val="${COMP_WORDS[i+1]}"
                ((i++))
                ;;
            add|get|delete|del|search|list|run|env|env-file|load|rescue|cmd|audit|share|rename|move|copy|secret|shell|tui|doctor)
                if [[ -z "$cmd" ]]; then
                    cmd="${COMP_WORDS[i]}"
                fi
                ;;
            run|search|list|delete)
                if [[ "$cmd" == "cmd" && -z "$subcmd" ]]; then
                    subcmd="${COMP_WORDS[i]}"
                fi
                ;;
        esac
    done

    # Also check ENVSEC_CONTEXT env var
    if [[ -z "$context_val" && -n "$ENVSEC_CONTEXT" ]]; then
        context_val="$ENVSEC_CONTEXT"
    fi

Then it decides what to complete. If the previous word is -c, the answer is a context. If the subcommand takes a key and a context is known, the answer is a key from that context:

bash
    # Complete --context / -c values with dynamic contexts
    if [[ "$prev" == "-c" || "$prev" == "--context" ]]; then
        local contexts
        contexts="$(envsec __complete contexts 2>/dev/null)"
        COMPREPLY=( $(compgen -W "$contexts" -- "$cur") )
        return 0
    fi

    # ...

    case "$cmd" in
        get|delete|del|add|secret)
            # Complete secret keys if context is known
            if [[ -n "$context_val" ]]; then
                local keys
                keys="$(envsec __complete keys "$context_val" 2>/dev/null)"
                COMPREPLY=( $(compgen -W "$keys" -- "$cur") )
            fi
            ;;
    esac
}

complete -F _envsec_completions -o nosort -o bashdefault -o default envsec

Three bash quirks shape this script:

Here is what COMP_WORDS looks like with the two ways of passing a context, captured with a debug completion function:

text
# envsec -c myapp.dev get api.k<Tab>
[envsec] [-c] [myapp.dev] [get] [api.k]
# envsec --context=myapp.dev get <Tab>
[envsec] [--context] [=] [myapp.dev] [get] []

With --context=myapp.dev the loop read = as the context, asked for the keys of a context called =, got nothing back and fell through to file names. Since 1.1.3 the loop skips the = word, and also handles bash 3.2, which keeps --context=myapp.dev as a single word.

The same loop hides a real bug. run, list, search and delete are top-level subcommands too, so they match the first case pattern, and bash runs only the first matching branch. subcmd is never set, and envsec cmd run <Tab> offers run search list delete again instead of your saved commands. I found this while writing the post. 1.1.3 looks for the cmd subcommand in a separate step, after the top-level one is known.

zsh: _arguments, _describe and a lost flag

zsh does the parsing for you. _arguments takes a spec for every option and positional argument, and a spec can name a function that produces candidates. _describe then adds them with grouping and descriptions. The helpers read the output of __complete with the (@f) flag, which splits on newlines:

zsh
#compdef envsec esec

_envsec_contexts() {
    local -a contexts
    contexts=("${(@f)$(envsec __complete contexts 2>/dev/null)}")
    _describe 'context' contexts
}

_envsec_keys() {
    local ctx="${opt_args[-c]:-${opt_args[--context]:-$ENVSEC_CONTEXT}}"
    if [[ -n "$ctx" ]]; then
        local -a keys
        keys=("${(@f)$(envsec __complete keys "$ctx" 2>/dev/null)}")
        _describe 'key' keys
    fi
}

_describe treats a colon as the separator between a candidate and its description, so a colon in a name would need escaping. The same validation rules that help bash mean it never comes up.

The main function uses the state machine of _arguments -C: global options first, then the subcommand, then a nested _arguments call per subcommand:

zsh
_envsec() {
    local context curcontext="$curcontext" state line
    typeset -A opt_args

    _arguments -C \
        '(-c --context)'{-c,--context}'[Context name]:context:_envsec_contexts' \
        '(-d --debug)'{-d,--debug}'[Enable debug logging]' \
        '--json[Output in JSON format]' \
        '--db[Path to SQLite database]:file:_files' \
        '--completions[Generate completion script]:shell:(bash zsh fish)' \
        '(-h --help)'{-h,--help}'[Show help]' \
        '--version[Show version]' \
        '1: :->command' \
        '*:: :->args' \
        && return 0

    case $state in
        # ...
        args)
            case $line[1] in
                get)
                    _arguments \
                        '(-q --quiet)'{-q,--quiet}'[Print only the value]' \
                        '1:key:_envsec_keys'
                    ;;

_envsec_keys looks for the context in opt_args, the associative array where _arguments stores the options it has parsed. The catch is that the nested _arguments call for get repopulates opt_args with its own options, and -c belongs to the outer call. By the time _envsec_keys runs, the flag is gone. In my tests envsec -c myapp.dev get <Tab> never called envsec at all, and with ENVSEC_CONTEXT=myapp.dev exported, envsec -c myapp.prod get <Tab> offered the keys of myapp.dev. That second case is worse than no completion.

The fix, in 1.1.3, is to copy the context out of opt_args before the nested call and read the copy, which works because zsh functions see the locals of their callers. (Q) removes any quoting the user typed around the name:

zsh
# in _envsec: declare the copy, then fill it after the outer _arguments,
# before "case $state in"
local _envsec_ctx
# ...
_envsec_ctx="${(Q)${opt_args[-c]:-${opt_args[--context]}}}"

# in _envsec_keys
local ctx="${_envsec_ctx:-$ENVSEC_CONTEXT}"

Before 1.1.3, key completion in zsh worked only with an exported ENVSEC_CONTEXT. The same release also declares the --context and --db specs as --context= and --db=, so _arguments accepts --context=myapp.dev as well as the two-word form.

fish: complete -a with a command substitution

fish has the most declarative model. Each complete line says which command it applies to, an optional condition (-n), and the candidates (-a). When the candidates are written as '(…)', fish runs the substitution at Tab time. The conditions are fish functions, so checking them doesn't start envsec; only the substitution does. commandline -opc returns the tokens before the cursor, already split the way fish would split them:

fish
function __envsec_keys
    set -l ctx ""
    set -l args (commandline -opc)
    for i in (seq (count $args))
        if test "$args[$i]" = "-c"; or test "$args[$i]" = "--context"
            set -l next (math $i + 1)
            if test $next -le (count $args)
                set ctx $args[$next]
            end
        end
    end
    if test -z "$ctx"; and test -n "$ENVSEC_CONTEXT"
        set ctx $ENVSEC_CONTEXT
    end
    if test -n "$ctx"
        envsec __complete keys $ctx 2>/dev/null
    end
end

complete -c envsec -l context -s c -x -a '(__envsec_contexts)' -d 'Context name'
complete -c envsec -n '__envsec_using_command get' -x -a '(__envsec_keys)' -d 'Secret key'

-x (short for -r -f) turns off file-name completion for that argument, and -d adds the grey description fish shows next to each candidate. In my tests fish handled the most cases correctly. It still has the --context=value blind spot, since the loop compares whole tokens. It also had its own small bug: envsec cmd delete <Tab> mixed secret keys into the saved command names, because the condition for the top-level delete command also matched when delete came after cmd. Since 1.1.3 the condition compares against the first subcommand on the line only, skipping the values of global options.

The latency budget

Every dynamic completion starts a new envsec process. In my tests bash and zsh started one per Tab press, and fish started one per completion and then paged through the results without calling again. Whatever envsec spends on startup, you wait for before the menu appears.

This is where the startup work behind the Bun binary pays off (From 417 to 32 milliseconds). As measured there, an empty Node process takes about 76 ms to start and an empty Bun process about 4.5 ms, before envsec runs a line of its own code. The completion path goes one step further. Before it imports anything else, main.ts checks whether it was called as __complete and tries to answer from a JSON cache:

ts
const dbPath = resolveDbPath();
const cachePath = path.join(path.dirname(dbPath), "completions.cache");

/** Cache TTL — 60 minutes as safety net. */
const CACHE_TTL_MS = 60 * 60 * 1000;

const tryFastComplete = (): boolean => {
  const args = process.argv.slice(2);
  if (args[0] !== "__complete") {
    return false;
  }
  // ... read and JSON.parse the cache; return false when it is missing,
  // older than CACHE_TTL_MS, or has no keys for the requested context
};

// Fast path — serve from cache without loading any dependencies
if (tryFastComplete()) {
  process.exit(0);
}

// Lazy import the full CLI only when needed
const run = async () => {
  const mod = await import("./cli-runner.js");
  mod.runCli(resolveCustomDbPath(), cachePath);
};

The cache sits next to the database (~/.envsec/completions.cache by default, or next to ENVSEC_DB), is created with mode 0600, and holds names only. This one is pretty-printed; the real file is a single line:

json
{
  "commands": ["deploy", "migrate"],
  "contexts": ["myapp.dev", "myapp.prod"],
  "keys": {
    "myapp.dev": ["api.key", "db.password", "stripe.secret"],
    "myapp.prod": ["api.key", "db.password"]
  },
  "updatedAt": 1791548382410
}

It is rebuilt after every command that can change names (add, delete, load, rescue, cmd, rename, move, copy, secret and, since 1.1.3, tui), and after any completion that misses it. Here is what the two paths cost:

CommandMedian
envsec __complete keys myapp.dev (cache hit)18.3 ms
envsec __complete keys myapp.dev (no cache)31.6 ms
envsec --version (for reference)32.3 ms
Median of 40 runs (hyperfine -N, 5 warm-ups; the no-cache run deletes the cache before each run). Homebrew binary envsec 1.1.2 on an Apple M4 Pro, sandbox database with 2 contexts and 5 keys.

The cache saves about 13 ms per Tab. A miss costs about as much as any other envsec command, because the slow path loads the full CLI and SQLite. It also lists every context to rebuild the cache, so its cost grows with the number of contexts.

Trade-offs

What works today

I tested each case in bash 5.3, zsh 5.9 and fish 4.9 inside a sandbox, logging every call to envsec, then again after the fixes (adding macOS's bash 3.2):

bashzshfish
Contexts after -c / --contextyesyesyes
Contexts after --to (move, copy)yesyesyes
Keys from -c on the command lineyesfixed in 1.1.3yes
Keys from exported ENVSEC_CONTEXTyesyesyes
Keys with --context=valuefixed in 1.1.3fixed in 1.1.3no
Saved commands for cmd runfixed in 1.1.3yesyes
Saved commands for cmd deletefixed in 1.1.3yesfixed in 1.1.3
Values from the --db databasefixed in 1.1.3fixed in 1.1.3fixed in 1.1.3

The scripts also used to register completions for esec, a name envsec doesn't install. 1.1.3 drops it. The bash and fish scripts now have tests that source them in a real shell against a stub envsec, so the bugs above stay fixed.

In short

The full scripts are in packages/cli/src/completions, and the zsh _arguments reference is in the zsh completion system manual.