Building a Command Line Interface (CLI) is as intimidating as trying to draw a painting in front of a blank canvas. You can feel inspired by the ones that resonate with your desired user experience, but ultimately you need to figure out some important things on your own along the way.
In this blog post, based on our own experience building the Meroxa CLI, I’ll guide you through some important aspects to consider when either architecting a CLI from scratch or maintaining an existing one.
Why build a Command Line Interface
Our mission at Meroxa is enabling engineers to build applications with real-time data while automating repetitive operations. Although we also offer a visual interface, we knew that by offering a CLI we were empowering engineers to stay in workflow.
By having a Command Line Interface as part of our product line-up we’ve given our customers the ability to automate their use of our platform since the beginning while also providing a user interoperability that feels natural and intuitive. The best of both worlds.
Starting a CLI
Let’s start with the simplest scenario, where you get to answer the most immediate and common questions:
- What language will it be based on?
- Is there an existing framework that will make my life easier as a developer?
- Can I leverage existing tooling or solutions for the releasing process?
- How should I structure the syntax of my CLI? “noun verb” or “verb noun” 🥫🪱
Language, framework, and tooling trifecta.
Your language of choice should be based on aspects as simple as what language you know, who you expect will contribute, and how you expect your CLI will be distributed. All these factors were easy to answer at Meroxa, considering the majority of our expertise has been embodied in services written in Go, and this language shines when it comes to portability across different operating systems.
Like with any other development product you are building, a framework comes in handy so you can focus on developing new features and not so much on repeating yourself with things that are not part of your core business. For CLIs written in Go, Cobra’s framework is the standard. It’s widely used by many developer tools and provides a variety of features that we knew we needed, so this seemed like a reasonable decision. On top of it, the appearance of many development tools are starting to elevate the CLI experience to another level (e.g. Charm’s tools), so continuing with the decision of using Go for our CLI seemed like a no-brainer.
Frameworks are not the only type of tooling that is important to consider in the development of your CLI. To make it accessible to others, choosing what tool to use for releasing could affect your focus substantially. Letting distribution to be managed with automated tools such as GoReleaser with Homebrew are a match made in heaven, allowing you to leverage GitHub actions to release new versions of your Homebrew formula every time a new tag is created. More about this topic below.
“noun verb”, “verb noun”, or how to make someone unhappy
Here comes the time to decide in which color you’ll paint your bikeshed.
At the time of structuring your CLI commands, you’ll hear arguments for whether you should use the “noun verb” form (e.g. meroxa resources list
) or the one with “verb noun” instead (e.g.: meroxa list resources
). This is probably a debate that will last until something like a search ahead autocomplete type of tool is in place on every CLI terminal out there. There’s no clear winner.
The first aspect we considered at the time of making a decision was by looking at how other CLIs that our customers could be using were structured. If our CLI users were accustomed to using a tool with a specific design, kubectl
for example, which uses “verb noun”, we thought it made sense to go in that direction. The intention was to reduce friction between the two, and let users transition from one tool to another without too much overhead. We started with this approach when we bootstrapped our CLI, and we used this design for a few months.
Guess what we ended up doing. We decided to change it to “noun verb”. The reason was that since there wasn’t such a standard as “noun verb” vs “verb noun” across the community, we could always find other tools that would be a counterargument to our first decision. We had to keep digging on what direction we ultimately had to take, and our conclusion was that as humans, we tend to think on what is the thing we want to operate on first, and then what we can do with it after. We also considered that the form “noun verb” could also be beneficial for discovery purposes. At the time of running meroxa help
, this command currently lists the main “things” you can interact with in our platform (apps, resources, etc). We found that having actions listed instead such as run, list, create, etc… wasn’t that helpful unless you were already familiar with all the features our Meroxa Platform provides.
Before Releasing
Before you take the step of sharing your CLI with other people, there are questions you should ask yourself. Prioritize accordingly before they become problematic for your development productivity.
The fact that you won’t know in what environments your users will run your CLI means that before going wild and sharing it with the world, spend a bit of time including features that will help you understand how to fix those previously released CLI bugs. Again, what we’re aiming for here is for you, as a developer, to spend as much time as possible developing new features (or fixing bugs) rather than having to go back and forth between your customers asking for more information so you can finally fix the issue.
The most important aspect is that for every CLI issue your customer finds, you should be able to respond with a specific command that once executed should give you some insight on what’s happening.
Here are some things we prioritized early in the development process:
Knowing your version
The most important command after help
is version
. This command should indicate exactly what CLI version your customers are running. When a customer reports an issue, you need to verify what version they’re on so you can identify whether that issue they’re reporting was already fixed and they only need to upgrade, or if in fact it’s a new issue you need to take care of.
Example:
$ meroxa version
meroxa/2.8.1 darwin/amd64
Later in the process we noticed we also needed to be more specific for those advanced users that weren’t using an upstream version of the CLI, but rather if they had built the binary locally. For that reason, we included a dev
indication as part of the version, the git commit sha
they were running, and the closest git tag
its commit was associated with.
For those adventurous users who had modified the code locally, meroxa version
would include (updated)
to tell us this was the case. Here’s a changelog we published announcing this change.
Showing API headers and stack trace
Another very common issue I often see on other CLIs is their code not dealing with API errors correctly. For example, a customer runs a command and returns a very generic error message, or not error at all. That’s not very helpful, is it?
Ideally, you should be able to ask your customer to run the same command they did before, but with some special flag or header instead that could include the entire trace of your command and then give you the exact information you’re looking for.
This command, in addition to the expected output, should return things such as:
- What API endpoints this command called with its HTTP headers.
- Their actual API responses as they happened including response HTTP headers.
At Meroxa, we offered two options to accomplish this:
- Setting a
MEROXA_DEBUG
environment variable. (e.g.:MEROXA_DEBUG=1 meroxa resources ls
- Providing a
--debug
flag (e.g.:meroxa resources ls --debug
)
The benefit of using an environment variable is that you could easily set this so it works with all your commands. The second option should be documented via meroxa help
, and it’s more suitable for one-off attempts when something goes wrong.
As part of this, you should also bear in mind that users will likely copy the entire stack trace and send it to you. To make this more secure, consider obfuscating your user’s access token: Authorization: Bearer eyAtIe...FHtiNTA
Otherwise, these could easily end up in some chat, email, or support tool when in fact these should only belong to your customer.
Logged in user
Different users could have different behaviours, so an easy checkpoint you should have around your logged in users is being able to precisely identify what account they’re using. Something like this is sufficient:
$ meroxa whoami
raul@meroxa.io
Automated testing
For every pull-request we try to merge in our main branch of our CLI repository, we run a sequence of tests to ensure an expected output based on the provided input.
In order to make our CLI compatible with automated testing we needed to make scripting possible with things such as:
- Providing a
--json
flag to all commands so we could check for specific deterministic results and instead not compare with string outputs that could easily change and break our automation scripts. - Being able to execute a command with no prompts. We have some commands where customers are expected to provide some required information so the CLI can carry on with its execution. We needed to be able to accomplish the same without any user input. Let’s take removing an artifact as an example which usually requires confirmation as it’s a destructive action. At Meroxa, you’d use
--force
with the value to confirm. e.g.:meroxa resources rm my-resource --force my-resource
- Being able to have different configuration files. This one is highly dependent on what kind of CLI you’re developing, but in our case, we wanted to make sure our CLI was operating correctly in different environments, and an easy way to configure that is by using a configuration file. Something users could do, such as
meroxa resources ls --config PATH_OF_ANOTHER_CONFIG_FILE
which should contain all the configuration that makes your CLI operate with another environment very easily.
Ready to release
Once you have put together a bare minimum set of features that you think will make your life as a developer not too complicated, it’s releasing time.
To distribute our CLI, we decided to start using an industry standard such as Homebrew.
Like I mentioned before, considering our CLI was written in Go, using the fantastic tool GoReleaser made perfect sense. This one includes a way to automatically generate a Homebrew Tap so with every tag that’s created in our CLI repository, a GitHub action creates a new version of our HomeBrew formula.
Maintaining a CLI
At this point, we were able to ship a first iteration of our CLI so users could download it with certain confidence. Now, I’ll mention the other things we prioritized that weren’t necessarily our main product features.
Collaboration
Being a sole developer can only get you that far. It was certainly time to invest to make our code ready for contributions. This is especially important if you’re working in the open (source). Here’s some guidance I would consider relevant.
Get creative, CLI builder
At Meroxa, we decided that while Cobra’s set of features was great to start with, we considered that CLI composition could be improved to our own benefit.
If we wanted to replicate behaviour across certain types of commands, while maintaining the same user experience across them regardless of our own developer’s awareness of these, we would need a way of building commands based on the desired behaviour of each command. Something declarative and easily tested.
For example, on root (meroxa
), every subcommand is added like this, which uses this function to return a Cobra Command interface type, based on the methods that it implements.
For instance, when creating a new command, we would define what methods it needs to implement like this:
var (
_ builder.CommandWithDocs = (*Remove)(nil)
_ builder.CommandWithAliases = (*Remove)(nil)
_ builder.CommandWithArgs = (*Remove)(nil)
_ builder.CommandWithClient = (*Remove)(nil)
_ builder.CommandWithLogger = (*Remove)(nil)
_ builder.CommandWithExecute = (*Remove)(nil)
_ builder.CommandWithConfirmWithValue = (*Remove)(nil)
)
This way, it forces the developer to implement any method that's required for each of its interfaces.
As an example, the following interface is added for every destructive command:
type CommandWithConfirmWithValue interface {
Command
// ValueToConfirm adds a prompt before the command is executed where the user is asked to write the exact value as
// wantInput. If the user input matches the command will be executed, otherwise processing will be stopped.
ValueToConfirm(ctx context.Context) (wantInput string)
}
What this gives us is a command that, before executing, prompts you to input a specific value:
func buildCommandWithConfirmWithValue(cmd *cobra.Command, c Command) {
v, ok := c.(CommandWithConfirmWithValue)
if !ok {
return
}
var force bool
cmd.Flags().BoolVarP(&force, "force", "f", false, "skip confirmation")
old := cmd.RunE
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if old != nil {
err := old(cmd, args)
if err != nil {
return err
}
}
// do not prompt for confirmation when --force is set
if force {
return nil
}
wantInput := v.ValueToConfirm(cmd.Context())
reader := bufio.NewReader(os.Stdin)
fmt.Printf("To proceed, type %q or re-run this command with --force\n▸ ", wantInput)
input, err := reader.ReadString('\n')
if err != nil {
return err
}
if wantInput != strings.TrimSuffix(input, "\n") {
return errors.New("action aborted")
}
return nil
}
}
Any command that implements the CommandWithConfirmWithValue interface would require that the given argument be provided a second time, unless the override flag is used. Example:
func (r *Remove) ValueToConfirm(_ context.Context) (wantInput string) {
return r.args.NameOrUUID
}
Documentation
Inline with what’s been mentioned on different occasions on this blog post is the need to automate as much as possible. Cobra’s framework provides the ability to generate documentation automatically,, and we do so in a specific format so it’s live in our public documentation.
For every change we’re able to communicate externally, we consider presenting those in a changelog so our customers can keep up with announcements they might be interested in.
Keep your users using the latest
The expectation of our Platform is that we’ll release new features often, and we need to make our customers aware of this so they upgrade fast. At Meroxa, we accomplished this by presenting a warning if they haven’t upgraded within the last week:
$ meroxa whoami
raul@meroxa.io
🎁 meroxa v2.8.1 is available! Update it by running: `brew upgrade meroxa`
🧐 Check out latest changes in https://github.com/meroxa/cli/releases/tag/v2.8.1
💡 To disable these warnings, run `meroxa config set DISABLE_NOTIFICATIONS_UPDATE=true`
Always aim for a good Developer Experience
Above all the features we have considered adding to our CLI, there’s one bucket that is always high on our list, and this one is improving its User Experience, even if this implies adding support to other external tools.
For example, we recently integrated with Fig and Warp to improve autocomplete and resource workflows as mentioned in the following changelogs:
Conclusion
Developing a CLI is a very exciting journey. The speed of interacting programmatically with a Platform is difficult to beat when you’re using a terminal. With this blog post, I hope I gave you some ideas on how to approach your own CLI development. If that’s the case, I highly recommend giving this a read: https://clig.dev/.
Have questions or want to chat about the process?, I’ll be happy to help on our Discord channel, or reach out via support@meroxa.io.