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:
$envsec __complete contexts$envsec __complete keys myapp.dev$envsec __complete commandsmyapp.dev
myapp.prod
api.key
db.password
stripe.secret
deploy
migratePlain 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.
def install
bin.install "envsec"
generate_completions_from_executable(bin/"envsec", "--completions", shells: [:bash, :zsh, :fish])
endWithout Homebrew, install them by hand:
$# 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# ~/.zshrc, before compinit runs
fpath=(~/.zfunc $fpath)
autoload -Uz compinit && compinitA 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:
_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"
fiThen 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:
# 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 envsecThree bash quirks shape this script:
- Word breaks. bash splits
COMP_WORDSon the characters inCOMP_WORDBREAKS, which by default include=and:but not.. Context names are validated against[a-zA-Z0-9._-]and key segments against[a-zA-Z0-9_-]joined by dots, so a name likemyapp.devstays one word and a colon can never appear. The=is a different story, shown below. - Fallback.
-o defaultmakes bash fall back to file names when the function returns nothing. Soenvsec get <Tab>without a known context lists the files in the current directory. It's harmless, but it can look like a bug. - bash version.
-o nosortneeds bash 4.4 or newer. The/bin/bashthat ships with macOS is 3.2, rejects the option, and until 1.1.3 that left envsec with no completion at all. The script now retries withoutnosort, so bash 3.2 gets completions in alphabetical order instead.
Here is what COMP_WORDS looks like with the two ways of passing a context, captured with a debug completion function:
# 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:
#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:
_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:
# 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:
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:
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:
{
"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:
| Command | Median |
|---|---|
| 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 |
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
- Staleness. Only CLI commands refresh the cache. Secrets added from the SDK in an existing context may not show up in completions until the 60-minute TTL expires or you run a CLI command that refreshes it. Before 1.1.3 the same was true of
envsec tui, which now refreshes the cache when it exits. - --db. Before 1.1.3 the scripts called
envsec __completewithout your--dbflag, so completions came from the default database. They now pass it along, in both the--db pathand--db=pathforms. An exportedENVSEC_DBalways worked, because the child process inherits it. - A file on disk. The cache duplicates names that are already in
store.sqlite. If key names are sensitive to you, they were already on disk.
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):
| bash | zsh | fish | |
|---|---|---|---|
| Contexts after -c / --context | yes | yes | yes |
| Contexts after --to (move, copy) | yes | yes | yes |
| Keys from -c on the command line | yes | fixed in 1.1.3 | yes |
| Keys from exported ENVSEC_CONTEXT | yes | yes | yes |
| Keys with --context=value | fixed in 1.1.3 | fixed in 1.1.3 | no |
| Saved commands for cmd run | fixed in 1.1.3 | yes | yes |
| Saved commands for cmd delete | fixed in 1.1.3 | yes | fixed in 1.1.3 |
| Values from the --db database | fixed in 1.1.3 | fixed in 1.1.3 | fixed 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
- One hidden subcommand,
envsec __complete, prints names one per line. The shell scripts decide when to call it. - Each shell has its own parsing model: hand-rolled for bash,
_argumentsfor zsh, declarativecompletelines for fish. Each one had at least one bug I only found by driving a real shell. - A Tab press costs a process start. A JSON cache read before any other import keeps a cache hit at about 18 ms on my machine.
The full scripts are in packages/cli/src/completions, and the zsh _arguments reference is in the zsh completion system manual.