Nov 17, 2023Julian K. Arni

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.

If websites were built like CLI tools. All options are provided, cluttering the interface, and tricking you into believing you can use them. Thankfully this site only has a few options…

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 historyshare

The man page for cat from the first edition of UNIX.

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: subcommandsshare

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 commandsshare

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 contextshare

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.

Conclusionshare

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 contentshare

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

Aug 27, 2024Julian K. Arni

A short note about custom typing for functions in Nix

Aug 22, 2024The garnix team

A guide to deploying NixOS servers - without even installing Nix!

May 14, 2024Sönke Hahn, Alex David, Julian Arni

A simpler, more composable Haskell process library

View Archive
black globe

Say hi, ask questions, give feedback