Heroku CLI: Completing Autocomplete

The CLI Team at Heroku strives to create a CLI user experience that is intuitive and productive. We had “build CLI autocomplete” in the icebox of our roadmap for many years. But if we were going to ship it, it had to complement the existing CLI experience. This is challenging because the Heroku CLI is very dynamic: it comprises user installable plugins, and the data needed for completions is behind an API.

Recently, we spent some time brainstorming the experience we wanted from Heroku CLI Autocomplete and decided it was time. We took “build autocomplete” out of the icebox and shipped it.

This post will discuss the main challenges we faced building Heroku CLI Autocomplete and how we solved them.

Challenges

Here is a quick overview of each challenge.

Plugin-based CLI: The Heroku CLI’s set of commands is extendable using user-installed plugins. This means different CLI users may have different commands installed. Heroku CLI Autocomplete needs to handle command completion for any set of plugins the user has installed.

Widely variable shell configurations: Heroku CLI Autocomplete must be resilient to a wide variety of shell configurations and allow us to update the autocomplete code without asking the user to edit their shell configuration files on every update.

Completion data behind an API: Whereas most autocomplete systems work with local data like file names and git branches, most of the Heroku CLI data, such as app names or config vars, is behind an API. This data needs to be fetched and cached.

Cache invalidation: Using a cache means we need to handle cache invalidation. Without cache invalidation, the completion data might look “stuck in the past” with an inconsistent listing of apps or config vars compared to the API.

Contextual command completion: To make autocomplete really useful for more advanced use cases, we wanted it to complete data that can only be known after other bits of data have already been specified. For example, to complete an add-on name, we first have to know for which app, then autocomplete can return the names of add-ons attached to that app.

Plugin-based CLI

Conceptually, autocomplete is simple. You define a function that is called by the shell's completion system whenever a user prompts for completion assistance—typically by hitting Tab. This function returns possible completion values to the shell’s completion system. The inner working of this function—what completion values to return and when—is where the complexity lurks.

Most command line tools' commands, arguments, and values don't change much. For example, below are the options available to the cat command, and the user cannot change these unless they install a different version of cat.

cat-command

The implementation of most autocomplete functions—like autocomplete for cat—is a static file full of case statements. However, one of the Heroku CLI's superpowers is the ability to use plugins to augment its functionality. Users can add and remove plugins, customizing the CLI for their needs. No two users' Heroku CLI can be assumed to be exactly alike. This means we can't just define a static file of case statements. Instead, we need an autocomplete function that is capable of handling any set of Heroku CLI plugins and all associated commands, arguments, and flags.

For Heroku CLI Autocomplete, rather than define hundreds of case statements, we define a variable that will contain the appropriate completion value. However, this variable isn’t assigned a value until you ask for completion values (i.e., hit Tab).

In order for that variable to have the appropriate value when you hit Tab there is work we work to do beforehand. When you run heroku autocomplete and see the output Building the autocomplete cache..., the autocomplete cache builder is iterating through all the available commands including commands from the plugins you have installed. As it iterates, we create setters—functions that assign a value to that variable—with all the necessary information to provide completion results for the commands installed. The autocomplete function, when executed with Tab, then calls the appropriate setter to provide a list of all the available commands. Or determines a command name is already present and uses that command name to call the corresponding setter containing all the needed information for completing that command's flags names or values.

This dynamic completion using generated setters facilitates autocomplete's ability to adapt to every user's customized Heroku CLI.

Widely Variable Shell Configurations

Initial setup of Heroku CLI Autocomplete requires a user to modify their shell profile—the .bashrc or .zshrc file. Adding anything to shell profiles is tricky. Shells are like people's offices. Developers spend a lot of time in them, and their smooth functioning is critical to getting work done. Some are highly customized and decorated. Some are simple. Some use a pre-defined setup (e.g. oh-my-zsh, prezto, or bash-it). Some are well maintained and others a bit broken. With autocomplete, we are deploying software into a similar environment. We don’t know how it’s going to be set up, we have little control over it, and our attempts to help should never get in the way.

We solve this with a shim. During installation, Heroku CLI Autocomplete asks you to source a shim path in your shell profile. This shim is a file under our control in the user's cache directories (more about the XDG Data Directories spec). If the shim file can't be found because of an unexpected problem, we fail silently so as not to block the user’s workflow. If Heroku CLI Autocomplete doesn’t work, that’s not ideal, but it’s failure should not break other aspects of the user’s shell. Sourcing this shim file also allows us to fix bugs and add features in future updates without the user having to edit their shell profile again.

Completion Data Behind an API

For most command line tools, the data needed for flag or argument completion is on local disk. For example, git autocomplete gets completion values for branch, remote, and tag names from disk. In contrast, Heroku CLI's flag and argument values are mostly not on disk. Instead, they are behind the Heroku API. This includes app names, config vars, pipelines, and some other values.

The autocomplete function fetches these values from the API when you hit Tab. And because network requests can be three orders of magnitude slower than disk reads, we cache these values to disk for future completions. You may notice a completion take slightly longer one time over another, that is likely because the cache was invalidated and a network request was required to repopulate it.

Cache Invalidation

Since we employ a cache for completion data, we need some mechanism for cache expiration. When we first started building Heroku CLI Autocomplete, we used timers to invalidate the cache—a common practice. But this can cause a confusing user experience in some Heroku CLI use cases. For example, if a user creates a new app and there is an hour remaining on the cache expiration timer, the new app won’t show up in autocomplete results until an hour later. Similarly, if a user deletes an app, that app will continue to show up in autocomplete results until the timer triggers a cache refresh.

Cache invalidation is one of the “two hard things” in computer science. However, this spring we migrated the Heroku CLI to oclif, our recently open-sourced CLI framework. In doing so, more intelligent cache invalidation became a breeze using oclif's custom hooks. Now, individual commands can emit a custom hook event to which the Heroku CLI Autocomplete plugin can subscribe. The plugin hook then invalidates, and in some cases, rebuilds the appropriate completion cache. Even better, with oclif there is no dependency coupling with custom hooks. If a hook event is fired, but nothing is subscribed to it (e.g. autocomplete is not installed), the CLI lifecycle continues without producing an error.

Contextual Command Completion

This is the most interesting and complex feature of Heroku CLI Autocomplete and also where it provides a huge benefit. Often, it is difficult to remember an app's exact add-on names or config vars, but the user has to type these values in many CLI commands. Without autocomplete, the solution to this problem is to invoke another CLI command to retrieve the add-on's names or config vars and copy/paste them where needed into the next CLI command. Eliminating this extra manual step was an ideal problem for autocomplete to solve.

Solving this was by far the hardest challenge and would require another post to explain fully. But in short, autocomplete reads what has already been typed onto the command line, for example, heroku addons:info --app=serene-hollows-34516, and parses that to determine the current context. In parsing, we can tell if all arguments are supplied, what flags are present and have been supplied, and then look for additional completion values that could only be known with that parsed context.

For example, in the addons:info example mentioned above, the app name, serene-hollows-34516, is already specified in the command, so we can fetch the app's add-on aliases from the Heroku API and return them as completion values.

heroku-cli-addon-autocomplete

Moving Forward

Many developers are building their own CLI’s on our open source framework, oclif. We're committed to building features for the Heroku CLI as open source components for oclif. To that end, we are incorporating what we have learned developing Heroku CLI Autocomplete into an oclif plugin. Oclif developers can learn more about trying out this plugin in our oclif Gitter.

We hope you enjoy using Heroku CLI Autocomplete as much as we do. Please send any feedback to ecosystem-feedback@heroku.com.

Browse the archives for engineering or all blogs Subscribe to the RSS feed for engineering or all blogs.