How to Design CLIs That Developers Actually Love Using

How to Design CLIs That Developers Actually Love Using

June 12, 2025
592 views
Get tips and best practices from Develeap’s experts in your inbox

Developers in general, and DevOps specifically, tend to work with the terminal a lot. As such, we encounter and use CLI tools all the time and get ample opportunity to evaluate them. Annoyances with the tools, which may seem inconsequential to other people, may occupy a disproportionately large part of our mind when working – simply because we see them over and over again.

In this article, we’ll review three CLI tools you’re probably familiar with, and see what we can learn from them. By the end, hopefully, we’ll have painted a clear picture of what good CLI design should look like and what pitfalls to avoid.

  

The Bad

The first tool we’ll be looking at is Terraform.
Terraform is, not to sugarcoat things, my chosen example of a bad CLI tool. You could argue that some of the criticism I feel towards it should be directed at Terraform the application, but as far as I’m concerned, they’re one and the same. It’s not like you can manage infrastructure with Terraform without using their command line interface.

Let’s review some of the issues I encounter regularly while using Terraform. First (and very annoying), the autocomplete is very inconsistent. As of writing this article (version 1.9.8 of Terraform), trying to tab after terraform init - will indeed provide completions for the various options. However, tabbing after does not – even though this is by far the more common command (either with -target or -auto-approve). These are actually two issues – the autocomplete not working is, of course, annoying, but the fact it works sometimes (for commands and only certain options) makes consistency an issue as well. Finally, Terraform’s autocomplete is available for Linux/Mac (Bash/Zsh) but not Windows. Hashicorp clearly considers autocomplete a bonus feature, and not a core part of their tool, which is unfortunate.

The next issue I’d like to point out is that I find convention to be lacking. The simplest example I can provide is related to state management – if you want to manage existing infrastructure with Terraform, you use the terraform import command, while providing the resource in your configuration as well as the identifier of the actual infrastructure. How do you get Terraform to stop managing specific infrastructure? That’s right, you use terraform state rm. Both commands make sense in a vacuum, but they make no sense when put together. Why are two commands, which are for all intents and purposes the opposite of each other, found in two different places? This is simply a confusing design choice.

One more point with regards to Terraform is that there is no short-hand form for commonly-used flags. For example, the Linux rm command has the --force flag. Since this is a frequently-used flag, it has a short form – -f. Terraform doesn’t have such forms for any flag, which is unfortunate.

The final issue I’d like to discuss regarding terraform is application-related. There are a few different ones I could bring up here, but the one I want to mention is specifically related to how Terraform handles variables when it comes to infrastructure management. Say you have two resources in your configuration. Something like this:


resource "aws_vpc" "a" {
  count = var.create_vpc ? 1 : 0
  cidr_block = var.vpc_a_cidr
}

resource "aws_vpc" "b" {
  count = var.create_vpc ? 0 : 1
  cidr_block = var.vpc_b_cidr
}

 Say we only want to create VPC a, for whatever reason. We prepare a terraform.tfvars file:

 create_vpc = true
vpc_a_cidr = "10.0.0.0/16"

And we apply the configuration. Will this work?

The answer is no, it won’t. Why not, you may ask? Because we didn’t provide a value for the vpc_b_cidr variable. This is confusing, because we know we don’t need it. Terraform doesn’t care though. It wants all variable values, necessary or not. Even if we were to run the command terraform apply -target="aws_vpc.a", we’d have to provide the values for VPC b. Here’s an even worse example of this behavior: if we were to destroy our infrastructure with terraform destroy, we’d still have to provide the values to the variables. Despite the fact that Terraform most certainly doesn’t need the values in this case – it’s simply destroying all the infrastructure as described in its state file!

 

Lessons Learned

  • Autocomplete is important for a CLI tool (assuming the tool has more than a couple of commands/flags). It speeds up usage and reduces the need to read documentation. Think of it as part of the tool, not a nice add-on, and make it available for all platforms on which the tool is available.
  • Consistency is even more important. In Terraform’s case, not having autocomplete might have been better than the one it offers, since I wouldn’t be tabbing all the time in hopes that it works when it often doesn’t.
  • Predictability is a big deal. The less a tool requires you to learn it, the easier it is to use. To this end, convention is very helpful – if your target audience is Linux users, making a tool which behaves like the Linux tools they’re already familiar with will make it much easier for them to pick up on how your tool works.
  • Convenience matters. Make commonly-used commands easy to use, make commonly-used options easy to use, and provide sane defaults that a person unfamiliar with the tool might expect.

 

The Ugly

Moving on from Terraform, the next tool on the list is much more prevalent – Git. Everyone who does any sort of software development uses it, so perhaps you’re already familiar with its quirks or are even used to them. Nevertheless, Git has its own share of strange design choices.

 The first issue I would like to discuss is quite well-known – the command checkout. You can use checkout to switch branches, commits, tags, create new branches, merge branches when switching, and more. This is way too much functionality packed into a single command… Luckily, the Git maintainers themselves were aware of this, which is why they introduced two other commands – switch and restore. This was definitely a step in the right direction, as it alleviates the mental load Git users experience (especially when learning the tool).

 The second issue in line is related to how commands can be presented in Git. We can use the command branch as an example. This may or may not be an issue depending on your perspective, admittedly, but here it is: functionality which could be considered subcommands is presented as options. Examples:

  • git branch -m – moves or renames a branch. Why not create a command - git branch mv?
  • git branch -d – deletes a branch. Why not create a command – git branch rm?
  • git branch -c – copies a branch. Why not create a command – git branch cp?

Each of these options also has a long version – --move, F2F2F2, and --copy respectively. And if you want to force the command you can either add the --force flag, shorthanded to -f (which offers a different type of functionality if used directly with git branch), or use another set of flags – -M, -D or -C respectively.

There are three issues I take with this approach: the first, and more obvious one, is that these “options” are certainly not semantically options. They don’t modify how the command performs an action on a branch. Rather, they perform an action on a branch. Each of them does something different, so it stands to reason that they should be different commands.

The second issue I believe this causes is choice overload. Why are there so many different ways to specify you want to force copy a branch? One good way should be enough.

The final reason I think this is problematic is that options, by definition, are generally usable together. Some options may be contradictory and can cancel/override each other; But if you run rm -rfiv on Linux, you can expect all four options to modify how the rm command behaves while removing items. You simply can’t run the command git branch -cdmf, because it’s actually 3 or 4 separate commands.

 

Lessons Learned

  • An action per command is much clearer and user-friendly than packing many actions into one command. Realistically, it’s also easier to maintain – think the S in SOLID.
  • Consider the difference between commands, subcommands, options and arguments before implementing anything. A command is the primary instruction related to the operation performed, a subcommand specifies exactly what action to be performed within the operation, an argument passed to the command (or subcommand) as a parameter, and an option modifies how the command or subcommand performs the required action. Example: aws s3 cp mydir s3://mybucket --recursive:
    • aws is the CLI tool.
    • s3 is the command – it specifies we’re operating within an S3 bucket.
    • cp is the subcommand – it specifies we’re going to copy items to/from the bucket.
    • mydir, s3://mybucket are arguments – they specify what we’re copying and where to.
    • --recursive is an option – it specifies that the command should recurse into the directory mydir and copy everything inside it as well.
  • Prefer providing a single clear way to perform an action. Ideally, your users should stumble into the best way to perform a task by accident, because it’s the easiest and most intuitive way.

 

The Good

Git doesn’t have a bad CLI per se, and it’s hard to judge a tool that thousands of developers have worked on for almost two decades now. However, the next tool is, in my opinion, the best example for a complex CLI tool that I’ve used. I’m talking about kubectl.

Why do I like kubectl? There are numerous reasons, but here are a few of the big ones:

  • It’s easy to install on any system – Linux, Mac or Windows. It’s also easy to install autocomplete (which is available on every one of these systems (Bash, Zsh, Fish and PowerShell).
  • The autocomplete is very good. It completes commands and options without any issue, making it extremely easy to find out various flags if you can’t remember the one you’re looking for off the top of your head.
  • The command layout is pretty intuitive - create creates resources, apply applies config files, destroy destroys resources, and so on.
  • Shorthand for common flags exists – -f or --filename for example.
  • Subcommands have a sensible hierarchy – for example, kubectl create service clusterip is very reasonable. You want to create a service of the type ClusterIP, which is found under general services, which we find by going to the command for creating resources.

I do have minor gripes with the tool – I’d prefer it if run was create pod, for example. I’d also like for the hierarchy to be by resource rather than by action – kubectl deployment create rather than kubectl create deployment – but I recognize this is completely up to preference (and it definitely makes more sense to combine all the creation commands into one under the hood either way). Overall though, I think kubectl is an excellent example of what a good CLI tool looks like. It’s intuitive, easy to use, and I can’t think of a time where I thought it was cumbersome or annoying – something that can’t be said for the other tools mentioned here.

 

Lessons Learned

There aren’t specific points I want to bring up with regards to kubectl, because the general advice I’d give anyone designing a CLI tool is “do it like the kubectl creators did.” However, I will take this opportunity to bring up some follies in various other tools:

  • I saw a tool which shall not be named, where the default behavior was to print logs to a file (you had to pass a flag to get logs in stdout). This is both unintuitive and cumbersome, especially if the tool is used in automation or CI (it was). If most tools do something the same way, it might be the correct way.
  • Another unnamed tool would exit successfully regardless of the results of its operation. This is not only confusing, but it’s straight up bad design. You have to give your users a clear indication that something went wrong – don’t hide it in the logs. Again, especially if the tool is used in CI (again, it was).
  • Finally, a third tool would require the output directory to be specified in a flag, but would then create several nested directories inside it, the bottom-most of which would end up containing the actual output. This is again confusing behavior, and doesn’t seem to serve any purpose other than the developer not being bothered to figure out relative paths. Not much to add here other than to suggest you take a few extra minutes to polish the rough parts of your tool. If it works, make it work well.

 

Hopefully these anecdotes and tips provided some insight into what I would consider good CLI tools. I truly believe that if you follow the lessons learned in this article, your tools will be better built – easier to use and more user-friendly.

 

Happy tooling!

We’re Hiring!
Develeap is looking for talented DevOps engineers who want to make a difference in the world.
Skip to content