One of the usability improvements in the latest version of Vault is that it supports tab-completion for options and saved service names, on both bash and zsh. Turns out this is really easy to do, and I’d like more Node/Ruby/whatever command-line apps to support it, so here’s how.
(I cribbed almost everything I know about this from rbenv. Sometimes the hardest thing about shell scripting is knowing what to google for, so when in doubt: dig through the scripts of a program you use a lot.)
Bash and zsh have different tab-completion systems with a large array of
features, and you can get quite fancy with them (read the completion scripts for
git
if you don’t believe me). But for really basic use they let you do
basically the same thing. You register a function to be called when the user
tries to tab-complete an argument to your program. Here’s what Vault’s scripts
for this look like, first bash:
# completion.bash
_vault_complete() {
COMPREPLY=()
local word="${COMP_WORDS[COMP_CWORD]}"
local completions="$(vault --cmplt "$word")"
COMPREPLY=( $(compgen -W "$completions" -- "$word") )
}
complete -f -F _vault_complete vault
And zsh:
# completion.zsh
_vault_complete() {
local word completions
word="$1"
completions="$(vault --cmplt "${word}")"
reply=( "${(ps:\n:)completions}" )
}
compctl -f -K _vault_complete vault
The important hooks are these lines:
# bash
complete -f -F _vault_complete vault
# zsh
compctl -f -K _vault_complete vault
These do basically the same thing: they say that when the first word in the
command-line is vault
, call the function _vault_complete
to perform
completion (-F
for bash, -K
for zsh), and also allow filenames to be used as
completions (the -f
flag).
So that’s how you register completion functions, but how do they work? Well,
first they get the current shell word: this is "${COMP_WORDS[COMP_CWORD]}"
in
bash, and "$1"
in zsh. They pass this word to Vault, as vault --cmplt WORD
.
This is just a special argument to the vault
executable that takes a
partially-complete word and prints a list of possible completions of it to
stdout, separated by newlines. You can implement this however you want; Vault
reads your config file and reflects on its own command-line flags to generate
completions. You don’t even have to filter the possible results yourself for
those that match the input word, the shell can do this for you. (It may be
advantageous if doing filtering yourself reduces the time it takes to find
completions for the input.)
The output of vault --cmplt
gets stored in the variable completions
, and
then a little post-processing takes place. In bash we do this:
COMPREPLY=( $(compgen -W "$completions" -- "$word") )
compgen
is what filters your list of completions for those that actually match
the input word, and bash expects the final completion result to be stored in the
special variable COMPREPLY
. Whatever ends up there is what will be used to
complete the user’s input.
In zsh we have this:
reply=( "${(ps:\n:)completions}" )
This is saying, split the value of completions
on newlines, and store the
resulting list in reply
, which is where zsh expects completions to end up.
So that’s all there is to doing basic completion. You’ll need to provide a way for the user to actually load these functions into their shell conveniently. Vault does this using a script that detects which shell you’re using and loads the right hooks:
# init.sh
if [ -n "$BASH_VERSION" ]; then
root="$(dirname "${BASH_SOURCE[0]}")"
source "$root/completion.bash"
elif [ -n "$ZSH_VERSION" ]; then
root="$(dirname "$0")"
source "$root/completion.zsh"
fi
This script, and the two completion scripts, live side-by-side in the Vault
source tree. The final bit of glue is that Vault has a command called vault
--initpath
, which returns the full path to init.sh
. This is because finding
the path to an installed library can be tricky, so it’s easier to just have the
executable be able to tell you where its scripts are, which in Node can easily
be done using the __dirname
variable.
This lets the user drop this in their profile to load your completion scripts:
which vault > /dev/null && . "$( vault --initpath )"
See? Couldn’t be easier. If you’re building a command-line program this is a really easy usability win that makes interacting with the program far more pleasant.