Our friend C01t is riding the elevator to the 15th floor for a meeting with Naomi, one of the system admins in charge of Guava Orchard’s IT infrastructure. After settling in as a member of project Feijoa, C01t has been tasked with building a command line app. Admins will use the tool to configure, maintain and troubleshoot the Feijoa service. He has never built a CLI before. Furthermore, no one on the team has a clear idea of what the requirements for the tool are. Therefore, Kaya suggested he meet with Naomi to get her input on the requirements.
“Why don’t I show you the
Tamarillo command line app”, Naomi proposes once they are settled in the conference room. “Sam and I think its easily one of the best CLIs we use”, she continues nodding at the system admin sitting across from her. “I run Linux. Sam runs windows. Yet both of us can use the
Tamarillo CLI from our desktops.” Then Naomi demos the intuitive command structure and contextual help feature of the app.
Next Sam opens one of the bash scripts they wrote to provision new users. To his surprise, C01t notices that the script invokes the same app that Naomi just demoed. “Scripting this tool was easy and straight forward. That capability alone simplifies our jobs a ton”, explains Sam.
“How long have you been using this app?”, asks C01t. “When did we deploy
Tamarillo? Maybe 15 months ago”, replies Naomi. “That sounds about right”, confirms Sam. “And you know what has been truly amazing? In these 15 months they have released 22 updates to the command line app. That’s roughly a new version every 3 weeks. And not once did they break or regress existing functionality”, Naomi explains. “That is amazing”, agrees C01t. “They must have a huge army of testers.” Naomi gives him a knowing smile. “As a matter of fact 2 developers spend 20% of their time developing the app. That’s it. There is not army of testers. Automated tests validate all functionality”, she explains still smiling.
The anatomy of a CLI
Naomi and Sam introduced C10t to a well written CLI. The features they highlighted provide an excellent road-map for teasing out the characteristics of a good command line app.
Our primary goal when developing a CLI is to make it available on all platforms that our customers use. Furthermore, we aim to make installation of the CLI as simple as possible. Therefore,
go is our preferred language for the task.
go you can write the code once and run it everywhere. Additionally, all
go programs compile into binary executables. Since version 1.5
go has built-in support for cross-compiling. Check out The Go Cookbook for instructions on how to cross compile your code. In addition, you can find the list of supported compile targets in the official docs.
spf13/cobra and urfave/cli are 2 widely used libraries for building command line apps in the vein of the
git command. Both provide a declarative way to define the command and sub-commands. Furthermore, both will handle the parsing and extraction of flags and command arguments. And both implement a contextual help system that enable you to build self documenting apps. Overall the libs are very similar. So you can confidently choose either one.
Design for usability
We aim to make the command line app easy to use. Therefore, we need to be mindful of how commands are organized and how complex the user input is.
At the core of every CLI app there are 2 concepts: actions and entities those actions apply to. By organizing our command structure around these 2 concepts, we can build an intuitive self documenting application.
Consequently we can chose from 2 different approaches. The first approach is to build an action centric command structure. In this model the primary commands represent actions and the secondary commands are the entities. To illustrate this approach consider what command to start a service for the
Tamarillo app would look like:
> tamarillo start service [args ...]
Another option is to build an entity centric command structure. In this case the primary commands all represent entities and the secondary commands are the actions. Now the same command to start a service would look like:
> tamarillo service start [args ...]
So how do we decide which structure to apply? First we consider the symmetry of the actions. Do all or a plurality of the actions apply to a plurality of the entities? Next we consider the cardinality of the 2 concepts. Does our domain have a large number of actions but only a handful of entities? Or vice versa? If the actions are symmetric we make the primary command the actions regardless of cardinality. Otherwise we use the concept with the lower cardinality as the primary command.
The key is consistency. A consistent structure makes it easier for a new customer to explore the commands. Therefore, we must apply the structure we choose consistently to all commands.
To improve ease of use we avoid requiring the user to provide UUIDs as parameters to commands. Because UUIDs are the preferred entity identifiers in most distributed systems, satisfying this principle is more complicated than it seems.
We recommend using config files to load and save the UUID(s) of the “active” entity(s). If the app is similar to
git, where commands are executed in the context of a working folder, then we use a config file per working folder. However, if the commands don’t have a folder execution context we can store any config files in the user’s home folder. To save the UUIDs we can implement explicit app commands for setting the active entity.
> tamarillo service set -id a0744c52-36fe-11e8-b467-0ed5f89f718b
However, our preferred way is to implicitly set the “active” entity to the last entity that was explicitly specified. Then all subsequent commands can omit specifying the UUID explicitly.
# Start the service > tamarillo service start -id a0744c52-36fe-11e8-b467-0ed5f89f718b # Query for the status of the serice > tamarillo service status # Restart the service > tamarillo service restart
Another option is to accept entity names instead of unique identifiers. However, for this to work the service has to support entity names.
There is no output harder to read than rows of data where the columns are misaligned. So we align tabular output produced by the app. We use the tablewriter library to format tabular output.
Another priciple is to make information stand out by leveraging colorful text. However, we must be judicious with the use of color. Too much color can actually impair the readability of your output. In a number of our applications we have used the color library to generate colorized output.
Build for versatility
Probably the most consequential omission during the initial design of a CLI is scriptability. Following a few simple patterns we can implement a command line app that is capable of producing JSON output in addition to the human readable one. And as a result one can now easily parse the output using jq in any shell script.
Here are some simple rules of thumb to follow:
- Output a single JSON object. Even displaying multiple JSON objects with clear delimiters, e.g. “\n”, makes the output considerably harder to parse.
- Suppress all other output. If we do not any additional output will interfere with parsing of the JSON output.
- Display errors as valid JSON objects when the command fails.
Structure for testability
Finally, you want to write suite of automated tests to guard against regressions. To accomplish this goal we write both integration tests and unit tests.
We write the integration tests to exercise the app against real services/environments. Here we leverage all the work we did to make the CLI scriptable. Because we can emit JSON output from all commands, we can build robust test cases that validate app behavior. Furthermore, we are able to reliably implement complex test scenarios that requires us to pass parameters between commands.
On the other hand, we use unit-tests to ensure all execution paths are covered, especially failure paths. However, to write unit tests we have to carefully structure the app. First of all, we use client abstractions to interact with external services. During unit-test execution we replace the clients with mocks. Then we make the command action function easy to unit-test by following some simple rules such as: do not access global variables; return detailed results that can be asserted on; propagate errors through return values – never use
panic; and pass in an io.Writer to receive all output.