CLI Development; Part 2

Command line citizen

Continuing with series on development of command line tools, this week I'll look into more practical tips for making a CLI app that's nice to use. If you missed the first part where I discussed picking the language for your app you can find it here. Like I mentioned in the first post, what follows are just some of my own opinions, tips and tricks.

Arguments and flags

On the most basic level users interact with your app by invoking commands and sub-commands, and by passing arguments and flags to them. Maintaining a consistent and intuitive set of commands, arguments and flags makes for a better user experience. Here are a few to keep in mind when defining them:

  1. Keep command names short and intuitive. They should be easy to remember. You can define several aliases for the same command. For example you can make both new and init commands do the same thing.

  2. Provide helpful error message in case user attempts to invoke a non existing command. A good example of this is how git will suggest similar sounding alternative:

    $ git stats
    git: 'stats' is not a git command. See 'git --help'.
        
    The most similar command is
        status
    
  3. Define a short version for commonly used flags. One convention is to use double dash for full name and single dash for short name. Also, you can group short flags behind a single dash. For example docker run -it image is same as docker run --interactive --tty image.

  4. For extra credits add auto-completion support. Covering bash and perhaps zsh can make most of your users happy.

Common commands

There are some commands that many CLIs could benefit from. One example is a help command that exposes the documentation from within the app itself. Another one is version. Try to keep consistent format for the version number. Semantic versioning is always a good option.

If there's some kind of project setup, like scaffolding of new project or initialization of CLI configuration files, automate that with an init command. Examples of this include git init and npm init.

In case your CLI requires some specific setup on the local machine for some or all of its functions, it's a good idea to build a doctor command that verifies the local setup and offers instructions for fixing it. For examples check out npm doctor and flutter doctor. I've found that giving users a diagnostic tool like that makes supporting your CLI way easier.

Providing help

Regardless of how logically laid out the CLI commands seem to you, your users will manage to get lost. Having an always present and accessible help system helps with that. Make sure that every command supports the --help flag.

Get into a habit of writing help documentation for your commands. Think of it in same way you think of tests. Your command is not done without it. I find it interesting to use a documentation first approach and write the docs before implementation. Document, implement, refactor loop works well too. Docs don't have to be super detailed or cover all the features from the start. You can add details as your command develops, and you can go back and rephrase parts of it later. The trick is to have some docs from the beginning, and keep iterating on them.

When it comes to presentation, make sure help is accessible from the CLI itself. Depending on platform your users may expect docs to be available in a dedicated format as well. For example man pages on Linux. You might also want to package your docs as markdown and include it in your git repository. Most git servers like GitHub, GitLab and Bitbucket will nicely render markdown files. If that's not enough you can go one step further with dedicated GitHub wiki or a GitHub pages page.

When it's time to quit

By now your users can find their way around your well structured commands, and thy know where to look for help. They are happily using your tool, until they encounter some long running CLI process and they decide to quit. When that time comes, try not to turn your app into an Internet meme:

I've been using VIM for about 2 years now, mostly because I can't figure out how to exit it.

Average VIM user

You should listen to kill signals from the OS (like SIGINT and SIGTERM) and handle them by terminating your app. Perform any cleanup you need, like flushing files and stopping background processes, but make sure app exits in the end.

Miscellaneous tips

A few more random tips:

  1. Provide a global flag for controlling output verbosity level. No-one likes overly chatty apps, but having no debugging output when something goes wrong is worse. Add --verbose flag to all of your commands so your users can pick level they need.

  2. Provide users with a way to format the CLI output. Tabulated format is nice for showing results in a terminal. However, piping results to another command would benefit from more straightforward, simpler formatting. For an example of rich formatting support take a look at docker formatting.

  3. Allow users to define commonly used settings and flags in a configuration file. If your use-case revolves around working with individual projects, add support for project-level configuration, like git does.

What I did

Taking care of all the things mentioned above gets easier if you start with a feature rich framework. For my needs I picked Cobra, a well established library in the Go community. It's used by Docker, Kubernetes and etcd, to name a few. Other languages / ecosystems will have other popular frameworks. Take time to find one that fits your needs and coding style. You can often find them by looking at popular CLI tools written in a given language. For example, Heroku CLI is a Node app, and uses the oclif framework.

Next up

That wraps up the discussion of general patterns for making CLIs that are nice to use. In the next post of the series I'll go into more advanced use cases that CLI tools are good for. If that sounds interesting, you might want to: