Contextual CLIs
A more contextual and dynamic approach to designing command-line interfaces.
Here is the output of vagrant --help in an empty directory:
Usage: vagrant [options] <command> [<args>] -h, --help Print this help. Common commands: autocomplete manages autocomplete installation on host box manages boxes: installation, removal, etc. cloud manages everything related to Vagrant Cloud destroy stops and deletes all traces of the vagrant machine global-status outputs status Vagrant environments for this user halt stops the vagrant machine help shows the help for a subcommand init initializes a new Vagrant environment by creating a Vagrantfile login package packages a running vagrant environment into a box plugin manages plugins: install, uninstall, update, etc. port displays information about guest port mappings powershell connects to machine via powershell remoting provision provisions the vagrant machine push deploys code in this environment to a configured destination rdp connects to machine via RDP reload restarts vagrant machine, loads new Vagrantfile configuration resume resume a suspended vagrant machine snapshot manages snapshots: saving, restoring, etc. ssh connects to machine via SSH ssh-config outputs OpenSSH valid configuration to connect to the machine status outputs status of the vagrant machine suspend suspends the machine up starts and provisions the vagrant environment upload upload to machine via communicator validate validates the Vagrantfile version prints current and latest Vagrant version winrm executes commands on a machine via WinRM winrm-config outputs WinRM configuration to connect to the machine For help on any individual command run `vagrant COMMAND -h` Additional subcommands are available, but are either more advanced or not commonly used. To see all subcommands, run the command `vagrant list-commands`. --[no-]color Enable or disable color output --machine-readable Enable machine readable output -v, --version Display Vagrant version --debug Enable debug output --timestamp Enable timestamps on log output --debug-timestamp Enable debug output with timestamps --no-tty Enable non-interactive output
Here is the output of the same command, after having created a vagrant file with vagrant init:
Usage: vagrant [options] <command> [<args>] -h, --help Print this help. Common commands: autocomplete manages autocomplete installation on host box manages boxes: installation, removal, etc. cloud manages everything related to Vagrant Cloud destroy stops and deletes all traces of the vagrant machine global-status outputs status Vagrant environments for this user halt stops the vagrant machine help shows the help for a subcommand init initializes a new Vagrant environment by creating a Vagrantfile login package packages a running vagrant environment into a box plugin manages plugins: install, uninstall, update, etc. port displays information about guest port mappings powershell connects to machine via powershell remoting provision provisions the vagrant machine push deploys code in this environment to a configured destination rdp connects to machine via RDP reload restarts vagrant machine, loads new Vagrantfile configuration resume resume a suspended vagrant machine snapshot manages snapshots: saving, restoring, etc. ssh connects to machine via SSH ssh-config outputs OpenSSH valid configuration to connect to the machine status outputs status of the vagrant machine suspend suspends the machine up starts and provisions the vagrant environment upload upload to machine via communicator validate validates the Vagrantfile version prints current and latest Vagrant version winrm executes commands on a machine via WinRM winrm-config outputs WinRM configuration to connect to the machine For help on any individual command run `vagrant COMMAND -h` Additional subcommands are available, but are either more advanced or not commonly used. To see all subcommands, run the command `vagrant list-commands`. --[no-]color Enable or disable color output --machine-readable Enable machine readable output -v, --version Display Vagrant version --debug Enable debug output --timestamp Enable timestamps on log output --debug-timestamp Enable debug output with timestamps --no-tty Enable non-interactive output
This isn't a game of spot the difference. There are no differences.
Probably this isn't a surprise to you. Help texts don't change. But I think that perspective is a serious missed opportunity. If I try running vagrant init after having already initialized, I quite reasonably get an error:
`Vagrantfile` already exists in this directory. Remove it before running `vagrant init`.
If this command isn't available, it could be moved to a section lower down — named, say, "Unavailable commands" — and only commands that are actually useful could be kept in the upper section. While we're on it, the powershell command could go there too — it doesn't work on Linux, and I'm on Linux. And winrm too works on Windows, or with a Ruby gem installed, which I don't have. So it too could move there, with a note about the installation being required. port too doesn't work with my installation for whatever reason. And then there's the fact that I don't have any machines running, so a number of commands related to machines — destroy, reload, halt, package, suspend — can go.
Imagine this behavior in a web application. There's a single page for everyone, with all the buttons. I click on a button, and get an error: must be logged in to do X. Why then did you show me the button? I log in, and click on X again. Must be an admin to do X. Ugh. I log in with my admin account. Error: must be logged out to log in. Ok… Now can I do X? Nope, must have items in your shopping cart before doing X.
Clearly we would say that the page is poorly designed. Why then do we not say that about command line tools?
I think a large part, though not the entirety, of the answer is historical.
CLI history
The first edition of UNIX had many of the programs we're familiar with: cat, rm, cp, date, find, …. These tools tend to follow what's been called "the UNIX philosophy." What precisely that means is up for debate, but broadly speaking, it encouraged simple and small programs that compose well. Frequently cited as a technique for achieving those aims is the notion of text as the universal interface. This is widely understood to rule out ad hoc binary formats, since then every downstream program in a pipe needs to include a parser for that format, complicating those programs. But in practice it has also meant more specifically using stdin and command-line arguments to provide everything a command needs (or at least optionally allowing that), instead of using state (in the filesystem). This makes UNIX tools closer to a pure function of their inputs (stdin and arguments). For example, find doesn't use the current working directory as it's search location, but instead takes it as an argument. If you ignore relative paths at least, this means it returns the same result no matter where it's run from.
In this setting, it doesn't make sense for help pages to be contextual, since there is no context to take into account. Tools behave the same every time.
Moreover, programs are small, so there is less of a premium on being able to select only relevant information, as there isn't a sea of information for things to get lost in.This state of affairs was for a long time considered the ideal of CLI programs, and by many still is.
But consider the differences between the UNIX mainstay find, and the newer fd that bills itself as a more user-friendly alternative. fd, unlike find, defaults to the current working directory. fd — like rg, which stands to grep as fd does to find — takes into account .gitignores. Both of these make the command more contextual, more implicit. But it makes for a much simpler CLI interface, and many people (myself included) evidently think that in this case, this is a good tradeoff.
fd and rg are, however, still UNIX-y in another way: they do just one thing. Programs such as git, npm, and nix do a lot more things (even if the many things they do are all related to one topic). They are very far from being the simple filters that are paradigmatic of UNIX tools.
First steps: subcommands
With bigger programs, there is a lot more help to organize. The main technique that most "large" programs have adopted in order to organize their functionality and help is the use of subcommands. Subcommands are positional arguments, but they are treated more like (and are sometimes implemented as) separate executables altogether. That is, git commit behaves in many ways like what you would expect from git-commit: it has a set of options that is different from other git subcommands (such as, say, "log"). From the point of view of the shell and the program, it's a positional argument; but we are not supposed to think of it that way.
But what if you do think of them as positional arguments, but broaden your notion of what is reasonable behavior for a program handling positional arguments? New possibilities emerge. rg '^\d{1,10} --help could generate a help text specific to that command — for example:
rg: rg '^\d{1,10}: Search for the specified string in the current directory Matches any string that - Begins with: - A sequence of 1 to 10 digits Examples that are matched: 23slkd hau3hb 43099!8bl shk
Meanwhile, printf could properly display it's help:
> printf "I am %d years old" --help printf: printf "I am %d years old" NUMBER [arguments]
Dynamic commands
What are the valid arguments to npm run? One answer is "a string." A more precise one is "a string that is a script in the local package.json." I would prefer that npm run list these valid strings: it's a good interface for discoverability. (Shell completion can already be made aware of these strings.)
Similarly, git allows aliases, so you can write, for example:
git config --global alias last 'log -1 HEAD'
And then run git last. I think this should show up in git --help: it's a good interface for reminding me what aliases I might have configured.
But these decisions all make the CLI more contextual.
This is how I started thinking about contextual CLIs. We've been building a tool called garn, which aims to be a more user-friendly interface to nix (not unlike, I suppose, fd and rg are to find and grep, though nix has a much bigger surface area to cover). And users can define in their garn.ts configuration files what executables, checks, packages, environments, etc. their project provides. Something like this:
import * as garn from "https://garn.io/ts/v0.0.15/mod.ts"; export const server: garn.Project = garn.go .mkGoProject({ description: "example backend server in go", src: ".", goVersion: "1.21", }) .addExecutable("migrate", "go run ./scripts/migrate.go") .addExecutable("dev", "go run ./main.go"); export const frontend = garn.javascript .mkNpmProject({ src: ".", description: "An NPM frontend", nodeVersion: "18", }) .addCheck("test", "npm run test") .addCheck("tsc", "npm run tsc") .addExecutable("tsc", "npm run tsc");
Given that, it's really nice to then have garn run --help display:
Usage: garn run COMMAND [...args] Build and run the default executable of a project Available options: -h,--help Show this help text Available commands: frontend.tsc Executes npm run tsc server.dev Executes go run ./main.go server.migrate Executes go run ./scripts/migrate.go
Since those are the executables that are available.
More context
Once you take that step, however, why not take the next? garn init, which creates a garn.ts file based on what it can deduce from the current directory, doesn't work if there's already a garn.ts file in the current directory. Conversely, all other commands only work if there is a garn.ts file present. So this is what garn --help looks like when you don't have that file:
garn - the project manager Usage: garn COMMAND [--version] Develop, build, and test your projects reliably and easily Available options: -h,--help Show this help text --version Show garn version (v0.0.15) Available commands: init Infer a garn.ts file from the project layout Unavailable commands: build run enter generate check
And when you do have a garn.ts file:
garn - the project manager Usage: garn COMMAND [--version] Develop, build, and test your projects reliably and easily Available options: -h,--help Show this help text --version Show garn version (v0.0.15) Available commands: build Build the default executable of a project run Build and run the default executable of a project enter Enter the default devshell for a project generate Generate the flake.nix file and exit check Run the checks of a project Unavailable commands: init
This, incidentally, works well: as our user tests have shown, people can very quickly figure out what they are supposed to do. This despite the fact that it's not yet widespread practice, and so people are not yet used to contextual CLIs.
Conclusion
That is what we're going with, and so far it has been great. Part of the cultural shift involved is that --help isn't something you internalize and then forget about: you use it continually, every time you are in a new project or are catching up on changes to a codebase.
Post-script: man and info: the place for non-contextual content
I've always wondered what the right thing to do with -h/--help, man, and info was. We have all three for historical reasons: man, as I understand it, because printed intruction manuals had to be anyhow typeset and printed, so it made sense to reuse those source files for generating help in the terminal. info, because nroff/troff were not open source, and because emacs was too big a project to document properly in the UNIX man system (info was actually one of the earliest tools to use hyperlinks).
Now I feel like the answer is that man is where the non-contextual version of the context goes. It makes sense given it's relationship to printed reference, and the fact that your own program doesn't get to handle generating the help text.
(info, in the meantime, just is excess.)
Continue Reading
We've added incremental compilation to garnix. In this blog, we discuss prior art on incremental compilation in Nix, and describe our own design.
A short note about custom typing for functions in Nix