Tab-completion for your command-line apps

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.