Having chartered and led my company’s “CLI Foundations” team, which built and owns their primary two CLIs, I’ve spent a lot of time thinking about CLI design. So when I stumbled upon this tweet today, I had to jump in:
I’m talking to various folks about good CLI design practices. What are you favourite CLI style guides, user research approaches or other hints and tips I can share?
— Gareth Rushgrove (@garethr) July 22, 2020
There’s so much thought and effort put into “User Experience” design for the web UI, mobile experience, etc… but somehow the CLI experience is often overlooked. For companies and products with a technical audience, this is a huge gap in their product offering.
The answer to the tweet’s question, like everything in engineering (and product, and business), is “it depends”. The right CLI design for your product starts with understanding the answers to these questions:
- What’s the scope of the CLI functionality?
- Is this a special purpose tool, or a complete toolbox? If you have new needs, will they be new CLIs or new commands in the CLI toolbox?
- Who are the intended users?
- Do your users primarily spend their days writing code or running the system?
- Is this CLI intended for use by beginners or advanced folks?
- How often do you expect users to interact with your CLI, based on the common use cases its designed for? (Do they only use it once during onboarding, occasionally, or regularly — monthly, weekly, daily)?
- What operating systems do they primarily use, and what dependencies are they likely to have installed? (Any browser or Chrome v80+, a Java runtime, etc?)
- How do you users want to interact with your CLI?
- Do you want to support users scripting around your CLI? (e.g., CI/CD)
- What sort of workflows should you support? (e.g., interactive, imperative, declarative, gitops, etc)
Just like traditional UX (for a UI), this will take time and evolve along with your understanding. Getting to the best design will involve talking to customers and sales/field reps to understand what your customers need today, and working with your product and business development teams to understand where your market is going and what your customers will need tomorrow.
That being said, this should be may be easier for a CLI than it is for a UI. In theory, your customers are technical enough to know what a command line is, and seek out a CLI tool to solve their problem. There are some industry-wide design patterns that almost all CLIs follow (e.g., --help
on any command) and these are no brainers. Within a particular market, you may have competitors which you can study and learn from.
And lastly, most CLI functionality should generally mirror the concepts found in your UI, product documentation, etc. to minimize the learning curve for your users. The easiest way I’ve found to do this is to make your CLI resource-oriented, just like the REST API that’s probably backing your CLI.
Ok, enough theory. Let’s go through a concrete example.
Say you work in an Enterprise SaaS company, and you’re building a CLI that enables your customers to manage all the resources in your product. This is very typical if you look at CLIs for aws/gcp/azure, heroku, kubernetes, salesforce, dropbox, twilio, github, and so on.
By making a single CLI, new functionality is more discoverable and more easily adopted. This means that you’re building a “toolbox” that will grow as your company adds new functionality. Depending on your Go-To-Market approach and customer segment (e.g., Russel 2000 only or garage startups), you may have primarily operators (e.g., “MegaCorp” with separate Development and Operations teams) or a mix of technical people (e.g., startups with a “devops” culture).
As an Enterprise SaaS that’s actively growing market share, we want to make this CLI very easy for beginners to adopt, while remaining powerful enough to be useful as they become more advanced. This is where the 80% rule comes into play; while power and ease-of-use don’t have to be zero-sum, they often are when it comes to the last 20% of power.
Since the functionality allows you to manage your resources, it will require login and some client-side state.
In such a case, the modern CLI paradigm is to be resource-oriented, with imperative commands for interactive ease of use, and declarative commands to enable a GitOps-style workflow, e.g., for CI/CD automation.
For this example, here’s a list of “CLI design practices” that I would recommend.
BASICS
- Provide a consistent command structure, like
<group> <resource> <operation>
. I can describe the design principles and methodology I use for determining what this should be in a follow-up post, if people want. - All
<group>
and<resource>
concepts should match to existing concepts elsewhere in your product and in your customers’ mind. This will greatly lower the learning curve. - Adopt a small, consistent set of
<operations>
across resources for predictability. The majority of your commands will likely be simple CRUDL actions on your resources; depending on your use case, you may also have a handful of “special” or custom operations (e.g.,kubernetes logs
orheroku scale
or a “wizard” workflow likegit bisect
). - Use positional arguments sparingly (no more than 1-2 per command). If one argument, it should generally be a resource name or other identifier; if two arguments, its should be in an easy-to-remember order like FROM/TO. Prefer flags if there is no logical order or if there are more than two positional arguments.
- Use consistent names for concepts. Don’t call it a server, node, host, and machine in different places.
FLAGS
- Don’t require a dozen flags per command. For commands which require a bunch of information — such as creating a new resource — you should read from a config file, prompt interactively, or open an interactive editor with placeholders for completion.
- Minimize interaction between flags; for example, if you specify
--x
, you also have to specify--y
, and if you specify--y
, that changes the behavior of--z
, and so on. While this can be powerful for advanced users, it makes usage more complicated and far less predictable. - Minimize the number of required flags on any given command. Especially avoid flags that are required across most or all commands. These often point to a form of “client-side” state that you’re forcing the users to type on every command.
- If a command supports a large number of flags, divide them into separate groups in the help output, such as Basic Usage and Advanced Usage. This will make skimming the help easier.
STATEFULNESS
- Minimize contextual “state” stored in the CLI. Some developers try to avoid mandatory flags by hiding this state inside a CLI config or state file. However, this move from “explicit” to “implicit” is a trade-off for conciseness at the expense of comprehension, especially if there are multiple bits of state which can interact.
- For example, if you have “classroom” and “student” resources, you can operate on the student resources more easily if you “enter” the classroom first, like “clicking in” to a “classroom” page in a UI.
- This could look like running
./myschool classroom use math-101 && ./myschool student create
to create the student in the “active”/used math-101 classroom - However, because there is no active “navigation” element in a CLI — you can step away and come back next month — its hard to remember your current state (e.g., that you were in “math-101″)
- Furthermore, many CLIs (like gcloud) have multiple bits of state that can interact in unexpected ways. If your school CLI has multiple campuses you could choose the “downtown” campus, then “math-101″, then create a student. What happens if you then
./myschool campus use oakpark
campus? Are you now in “math-101″ on the Oak Park campus? Does this invalidate the active “classroom” state? It’s not clear to users. - If you do use one or more bits of contextual state, allow them to be overridden on every command with a flag. This is super helpful for automation cases, copy and paste documentation, and so on.
- If you do use one or more bits of contextual state, then consider providing a PS1 prompt helper so that its easy for users to see their current state at a glance, without explicitly querying (like you see breadcrumbs on a UI).
LOGGING
- Provide a verbosity flag to control the amount of logging output. By default, only output errors; with
-v
output warnings; with-vv
output informational logs; and with-vvv
output debug logs. Optionally provide a--quiet/-q
flag to disable all output. - Output errors, warnings, and other logs to stderr, not stdout. Optionally, also log all output to a file, so users can ingest and alert in automated CI/CD use cases. Print these logs in a structured format, including a timestamp and log level.
- Log the full API request/response in the debug logs. This should include the HTTP method, URL, request/response bodies, and a subset of headers, most notably a trace header like
X-RequestId
. Consider redacting any secrets though — especially if they’re long-lived — such as in the authorization header or in the message bodies.
VERSIONING
- Version your CLI, following your normal product guidelines (ideally semver).
- Make the version easily discoverable and programmatically consumable.
- Most modern CLIs support a
--version
flag and aversion
command, where--version
is a short, one-line machine-readable output andversion
is more detailed (and optionally machine readable). - The
version
command can include more detailed information, such as the backing API version or server version, git commit, build date/host, dependency versions, links to legal information, release notes, etc. It’s sort of like an “About” page on installed software.
- Most modern CLIs support a
- Include this version in the
User-Agent
for all outgoing API calls, along with any OS/arch platform details. This will greatly help you when it comes time to retire old versions, or for understanding more about your common platforms. - Consider backward compatibility when making changes. Your CLI versioning should ideally follow semver, and you need to understand what is considered a breaking change for CLI users. This includes the commands, positional arguments, flags, output format (like removing a field from a JSON response), structure of the log messages, exit codes, etc.
- Define a deprecation and support policy to determine how long you’ll continue supporting old versions. This may be defined for you by the API deprecation and support policy.
- If you’re deprecating a command or flag, insert a deprecation warning well in advance and support both old and new simultaneously for some period of time. Sometimes you can get away with sunsetting a feature without a major version bump, depending on your versioning strategy.
SCRIPTABILITY
- Use appropriate exit codes. Never exit 0 on a failure. You can use different exit codes for different errors, but that’s a very nice-to-have feature in my experience. It depends on how people are scripting using your CLI.
- Allow users to output text in JSON, YAML, or something more human-readable (“tables”).
- Provide a consistent flag to control this output format on all commands. Use of
-o json
or--output yaml
flag is starting to become more common. This flag should only affect the primary output sent to stdout, not the logging output sent to stderr. - Ideally this output should literally be the API response. This way, as long as the API doesn’t make a breaking change to their responses, neither will the CLI… without investing in a second layer of transformation and compatibility.
- Literally using the API responses also enables a very clean gitops workflow, where you can get something in json, save it to a file in git, modify it in your text editor, and apply it back in the CLI to update the resource.
- Provide a consistent flag to control this output format on all commands. Use of
- Allow non-interactive, long-lived login for automation. This doesn’t have to be the user’s primary credentials (e.g., email/password). It can be a Personal Access Token, an API Key, or some other credential. Ideally, it’s revocable and non-expiring, or at least long-lived. You can use a standard location like ~/.netrc to store these securely, or alternatively read them from an environment variable if you prefer that security approach.
- Support reading from the command line, stdin, or a file. Anywhere a file is expected, support
-
to read from stdin. For properties that could be given directly, support@<filename>
to read from a file or-
to read from stdin . - Printing colors can be helpful, but always provide a
--no-color
option, and allow the user to permanently disable it via a config file or environment variable.
HELP AND ERRORS
- Use standard flags and commands, like
--help/help
and--version/version
. - Support
--help
on every command. This allows the fastest path to help without rewriting your command. That is, calling./mycli get foo --help
is much easier while troubleshooting than rewriting it to./mycli help get foo
. - You can also provide the top-level
help
command as well. Thehelp
command aids in exploration and discovery, while the flag form aids debugging. - Always show examples for the most common usages in the help message.
- Showing “suggestions” for very similar commands and flags goes a long way.
PACKAGING AND DISTRIBUTION
- Use all available distribution mechanisms for the supported OS’s
- Users tend to be opinionated about how their software is installed. Many use package management with official repos, such as brew, yum, apt, scoop, chocolatey, or the Apple App Store. Some want the raw packages (deb, rpm, snap, etc) to install into their private package management repos. Others want a Docker image, some want a zip/tarball, and some just want to run a one-line
curl | bash
install command. - If you use a non-managed installation such as a tarball or one-line
curl | bash
install, consider including an auto-update mechanism that will notify the user of available updates and allow a simple./mycli update
command. While this won’t work in some air-gapped Enterprise environments, it will drastically reduce the number of old or deprecated versions you have to support (and eventually sunset).
- Users tend to be opinionated about how their software is installed. Many use package management with official repos, such as brew, yum, apt, scoop, chocolatey, or the Apple App Store. Some want the raw packages (deb, rpm, snap, etc) to install into their private package management repos. Others want a Docker image, some want a zip/tarball, and some just want to run a one-line
- Provide auto-complete functionality for the default shell in every supported OS. This includes bash for most *nix OSs, and zsh as that’s the new default as of macOS Catalina.
CONFIG FILES
- Treat changes to your config file format with care. Automatically migrate older config files to the latest format when upgrading (and backup first!).
- Don’t store secrets in the main config file, because it’s really valuable for your support team to ask the user to share their config when troubleshooting.
- Allow overriding the default config file location with a flag on all commands and/or an environment variable.
I’m sure there are more I’m not thinking of right now. I’ll add them as I think of them, and you can tell me what I’m missing in the comments!
0 Responses
Stay in touch with the conversation, subscribe to the RSS feed for comments on this post.