Skip to main content
ArticlesProjects

Designing CLIs people want to use

API design is a user experience problem and nobody argues any more. Then we build a CLI and forget all of it. Cobra defaults, which ones are wrong, and what I do instead.

I have spent a long time arguing that API design is a user experience problem. Nobody pushes back on that any more. We accept that a confusing endpoint, a vague error body, or an inconsistent status code is a design failure rather than a documentation gap.

Then we go and build a CLI, and all of that thinking evaporates.

We wire up Cobra, add some subcommands, ship the binary, and call it done. The tool works. It does the thing. And it is quietly horrible to use, in ways that are entirely fixable and that we never look at, because “UX” has been colonised by the web to the point where it feels like something that happens in Figma rather than something that happens in a terminal.

So let me put the argument plainly. A terminal is an interface. A person is on the other end of it. Everything we know about affordances, feedback, discoverability and error recovery applies just as hard at a shell prompt as it does in a browser. The vocabulary transfers unchanged. We just stopped using it.

This is a long one, because I want to go through the whole surface rather than pick one thing and wave at the rest. Errors, exit codes, help text, arguments, flags, configuration, output streams, and discoverability. Cobra’s defaults for each, why several of them are wrong for a tool you expect people to use daily, and what I do instead.

A note before we start. Cobra is excellent. Every criticism here is about a default, not a capability, and in most cases Cobra already gives you the switch. Defaults are decisions though, and Cobra’s defaults are mostly optimised for the author who is still figuring out their command structure, not for the person who has been using your tool every day for six months and just fat fingered a flag.

The wall of text

Here is a Cobra command. Nothing unusual about it.

var deployCmd = &cobra.Command{
Use: "deploy",
Short: "Deploy the current project",
RunE: func(cmd *cobra.Command, args []string) error {
return errors.New("no deployment target configured")
},
}

Run it and you get your error message, then the full usage output. Every flag. Every subcommand. The long description. All of it, because the user made a mistake.

Think about what that actually communicates. Someone ran a command, something went wrong, and your tool responded by printing its entire manual at them. The one line that matters, the line that tells them what to fix, is now at the top of forty lines of scrollback. On a small terminal it has gone off the screen entirely.

The fix is two fields on your root command, and child commands respect both when they are set on the parent:

var rootCmd = &cobra.Command{
Use: "flow",
Short: "Shape Up, specs and ADRs from the terminal",
SilenceUsage: true,
SilenceErrors: true,
}

Now, there is a widely repeated claim that SilenceUsage only suppresses usage on runtime errors and still shows it for flag and argument errors. I believed that too. It is wrong, and it is worth being precise about why, because the correct mental model changes what you do.

Here is the relevant part of ExecuteC in Cobra’s command.go:

err = cmd.execute(flags)
if err != nil {
// ... help handling ...
if !cmd.SilenceErrors && !c.SilenceErrors {
c.PrintErrln(cmd.ErrPrefix(), err.Error())
}
if !cmd.SilenceUsage && !c.SilenceUsage {
c.Println(cmd.UsageString())
}
}

The usage print is gated on SilenceUsage and nothing else. Flag parsing errors and Args validation failures both come back through cmd.execute, so setting the field on the struct silences usage for those too.

That matters, because for a flag error, usage is genuinely the right response. If someone typed --targt instead of --target, showing them the available flags is helpful. Losing that is a downgrade.

If you want both behaviours, set the field inside RunE rather than on the struct:

RunE: func(cmd *cobra.Command, args []string) error {
cmd.SilenceUsage = true
return deploy(cmd.Context(), target)
},

By the time RunE runs, flag parsing and argument validation have already passed. Anything that fails from here is a runtime problem, and usage cannot help with it. Anything that failed earlier never reaches this line, so it still gets usage. One line per command, and the behaviour is now correct in both directions.

SilenceErrors is the one people miss entirely. Execute prints the returned error to stderr for you, prefixed with Error:. If you leave that on and then also print the error yourself at the top level, which is the natural thing to do once you are handling failures in one place, you get the message twice. Every failure, doubled. I have seen this in shipped tools more than once and it always reads as sloppiness, even though it is just a default doing exactly what it said it would.

There is a catch with silencing errors globally, though, and it is not obvious. Unknown command errors do not come from cmd.execute. They come from Find, earlier, and that path has its own handling:

if !c.SilenceErrors {
c.PrintErrln(c.ErrPrefix(), err.Error())
c.PrintErrf("Run '%v --help' for usage.\n", c.CommandPath())
}

Notice that the “Run x —help for usage” hint is inside the SilenceErrors check and has nothing to do with SilenceUsage. So when you set SilenceErrors: true you lose that hint, and a user who typos a subcommand gets your error message with no signpost at all. If you silence errors, put the hint back yourself at the top level. It is one line and it is the single most useful thing you can say to someone who is lost.

If you like Cobra’s error printing but want a different prefix, cmd.SetErrPrefix changes it without you having to take over the whole path.

One exit code is not enough

There is a second problem in the failure path, and it is the one that separates a tool people script against from a tool people fight.

The standard shape, straight from Cobra’s own user guide, is this:

func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

Every failure exits with 1. Missing configuration, network timeout, validation failure, file not found, all of it collapses into one signal. From the outside your CLI has exactly two states: worked, or did not.

That is fine until someone puts it in CI. Now they want to retry on a transient network failure but fail the build on a validation error, and they cannot tell those apart without parsing your error strings. So they parse your error strings. Then you improve an error message in a patch release and quietly break their pipeline.

Exit codes are an API. They are the part of your interface that other programs consume, and they deserve the same care you would give a response schema.

The pattern I reach for is a small typed error that carries the code:

type ExitError struct {
Code int
Err error
}
func (e *ExitError) Error() string { return e.Err.Error() }
func (e *ExitError) Unwrap() error { return e.Err }

Because it implements Unwrap, it composes with everything else in the standard library. You can wrap it, errors.Is through it, and it stays an ordinary error to every layer that does not care about exit codes.

Then the top level checks for it:

func main() {
if err := cmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
var exit *ExitError
if errors.As(err, &exit) {
os.Exit(exit.Code)
}
os.Exit(1)
}
}

Commands with nothing meaningful to say return a plain error and get 1, exactly as before. Commands that can be specific, are. Then document the codes in your help output, and people can build on them without reverse engineering your prose.

Keep the set small. Four or five meaningful codes that never change beat twenty that shift between releases. And stay clear of the shell’s reserved range above 125, because 126, 127 and the 128+n signal codes already mean something to anyone reading your exit status.

Nobody reads your help text

Cobra generates help for you, and the generated help is structurally fine. Usage line, available commands, flags, a footer pointing at --help for subcommands. Help is added automatically once you have subcommands, --help is added to every command, and help is just a normal command you can replace if you want to.

The structure is not the problem. The content is, because Cobra gives you three fields and most of us fill in one and a half.

Short is the one line that appears next to your command in the parent’s command list. This is the highest traffic string in your entire application. It is what someone reads when they are scanning for the command that does the thing they want. It should say what the command does, in the user’s language, not yours.

Short: "Manage deployments"

That is the version I write on autopilot and it is nearly useless. Manage how? Compared to what? Against a list of a dozen commands that all start with “Manage”, it gives a scanner nothing to grip.

Short: "Deploy the current project to a configured target"

Longer, and worth it. Keep them under about sixty characters so the column does not wrap, start with a verb, and make each one distinguishable from its neighbours when read as a list rather than in isolation. That last point is the one people miss, because you write Short while looking at one command and it gets read while looking at all of them.

Long shows on command --help. This is where you have room to explain the model behind the command, not restate the short description with more words. What does this actually do to my project? What state does it expect? What does it change?

Example is the field almost nobody sets, and it is the one that does the most work:

var deployCmd = &cobra.Command{
Use: "deploy [target]",
Short: "Deploy the current project to a configured target",
Example: ` # Deploy to the default target
flow deploy
# Deploy to a named target
flow deploy staging
# Preview without making changes
flow deploy staging --dry-run`,
}

People pattern match. Give someone three examples and they will adapt the closest one to their situation without reading a word of your prose. Give them a flag reference and they have to assemble the command themselves from parts, which is slower and gets it wrong more often. Cobra renders this in an Examples section automatically, so the entire cost is writing the examples.

Once you get past eight or ten subcommands, the flat list stops working. Cobra supports grouping, and it is worth doing before it feels necessary:

func init() {
rootCmd.AddGroup(
&cobra.Group{ID: "project", Title: "Project Commands:"},
&cobra.Group{ID: "shaping", Title: "Shaping Commands:"},
)
deployCmd.GroupID = "project"
rootCmd.AddCommand(deployCmd)
}

Groups render in the order you add them. Any command without a GroupID falls into an “Additional Commands” section at the bottom, which is a reasonable default but looks unfinished if half your commands land there. If you group, group everything. The generated help and completion commands can be placed too, via SetHelpCommandGroupId and SetCompletionCommandGroupId on the root.

One last thing on help. Set Version on your root command. Cobra only adds the top level --version flag if that field is non-empty, which means a lot of tools ship without it. It is the first thing anyone does when filing a bug against you.

Arguments, and the default that will bite you

This is my least favourite Cobra default, and it is documented plainly in the user guide: if Args is undefined or nil, it defaults to ArbitraryArgs.

Your command accepts any number of positional arguments and silently ignores every one of them.

var deployCmd = &cobra.Command{
Use: "deploy",
Short: "Deploy the current project to a configured target",
RunE: runDeploy,
}

Someone runs flow deploy staging, expecting to deploy to staging. Nothing in the interface tells them they are wrong. The command runs, deploys to the default target, and exits zero. They now believe something false about your tool, and they will not find out until it matters.

Silent acceptance of input you ignore is the worst failure mode in interface design, because the feedback loop never closes. Set Args on every command:

Args: cobra.NoArgs,

Cobra ships validators for the common cases: NoArgs, ArbitraryArgs, MinimumNArgs(n), MaximumNArgs(n), ExactArgs(n), RangeArgs(min, max), OnlyValidArgs, and NoDuplicateArgs. MatchAll combines them, which covers most of what you need:

Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
ValidArgs: []string{"staging", "production"},

Anything satisfying func(cmd *cobra.Command, args []string) error works as a custom validator, so validating properly is never more than a few lines. There is no excuse for leaving it nil, and I would put an Args field on every command in the tree even where NoArgs feels obvious. Especially where it feels obvious.

Flags people can guess

Cobra uses pflag, which is a fork of the standard library flag package that maintains the same interface while adding POSIX compliance. That is the single most important thing about flag handling in Cobra, and it happens without you doing anything: --flag, -f, -abc for combined short flags, and -- to stop parsing. Those conventions are decades old and people rely on them without thinking. Getting them free is the whole reason to use pflag.

What Cobra will not do is stop you naming things badly. A few rules I hold to.

Use the standard name when one exists. -v is verbose, -o is output, -f is file or force depending on context, -q is quiet. If you use -v for version, you have broken a convention that predates your tool and everyone will get it wrong forever. Version goes on --version, and Cobra gives you that for free.

Be sparing with short flags. Every short flag you define is a letter you can never reuse, and single letters are a small namespace. Reserve them for flags used constantly and let everything else be long form only.

Name flags after the user’s concept rather than your implementation. --parallelism is a number your code uses. --jobs is a thing a person wants.

For flags that must be used together, or must not be, say so declaratively instead of hand rolling the checks in RunE:

rootCmd.MarkFlagsRequiredTogether("username", "password")
rootCmd.MarkFlagsMutuallyExclusive("json", "yaml")
rootCmd.MarkFlagsOneRequired("json", "yaml")

The last two combined give you exactly one of a set, which is the common case for output formats. Note the caveat from the docs: the group is only enforced on commands where every flag in it is defined. Persistent flags on a parent will not be validated on a child that does not define them all.

Mark required flags with MarkFlagRequired, or MarkPersistentFlagRequired for persistent ones, rather than checking for the zero value in your handler. Cobra produces a consistent error message, and the user gets the same experience for every required flag in your tool rather than whatever each of us wrote that day.

One nice thing worth knowing about: CountVarP gives you the SSH style repeated flag, so -v, -vv and -vvv map to 1, 2 and 3. Verbosity is naturally a scale rather than a boolean, and people already know this idiom.

rootCmd.PersistentFlags().CountVarP(&verbose, "verbose", "v", "verbose output (repeatable: -v, -vv, -vvv)")

Also worth knowing: StringArrayVar and StringSliceVar are not the same. The slice variant splits on commas, so a value containing a comma gets torn in half. If your values might contain commas, and file paths and messages often do, use the array variant.

Configuration precedence

Once a tool has more than a couple of options, people want to stop typing them. That means environment variables and a config file, and the moment you have three sources you have a precedence question. There is only one correct answer, and it holds across effectively every tool worth copying:

Flags beat environment variables. Environment variables beat the config file. The config file beats your defaults.

The logic is that specificity wins. A flag applies to one invocation, an environment variable to one shell, a config file to one machine. The narrower scope should always be able to override the wider one, because that is what someone is reaching for when they type the flag: this time, do it differently.

The failure mode when you get this wrong is horrible to debug. A user sets a flag, the tool ignores it because something in a config file they forgot about takes priority, and there is nothing on screen to explain it. They will assume the flag is broken. They are not wrong.

Viper handles this and Cobra binds to it directly, which is the path of least resistance:

func init() {
rootCmd.PersistentFlags().String("target", "", "deployment target")
viper.BindPFlag("target", rootCmd.PersistentFlags().Lookup("target"))
viper.SetEnvPrefix("FLOW")
viper.AutomaticEnv()
}

Whatever you use, add a way to show the resolved configuration and where each value came from. flow config printing the effective value plus its source turns a category of confusing support conversations into a single command. It costs you an afternoon.

On where the config file lives: use os.UserConfigDir rather than dropping a dotfile in $HOME. On Unix it returns $XDG_CONFIG_HOME when that is set and falls back to ~/.config, which is what Linux users expect in 2026.

One trap. os.UserConfigDir does not read $XDG_CONFIG_HOME on macOS, it returns ~/Library/Application Support. That is a defensible platform choice, but it means anyone who exports XDG_CONFIG_HOME to share dotfiles across Linux and macOS ends up with your tool reading from a different place than they expect. If you care about that audience, check the variable yourself first and fall back to os.UserConfigDir.

Output that survives a pipe

The rule is simple. Data goes to stdout. Everything else goes to stderr. Progress, status, warnings, prompts, the reassuring message that says which config file you loaded. All of it is stderr.

The reason is that the moment someone writes flow list --json | jq, anything you put on stdout has become part of their data. Your friendly startup banner is now a parse error in someone’s pipeline.

Cobra gets this right by default, and it is worth knowing exactly how, because there is a trap hiding in it. Help output goes to OutOrStdout, with an explicit comment in the source saying help should be sent to stdout. Error messages go through PrintErrln, which writes to ErrOrStderr. And usage on error goes through Println, which is this:

func (c *Command) Print(i ...interface{}) {
fmt.Fprint(c.OutOrStderr(), i...)
}

OutOrStderr returns your configured output writer if you set one, and os.Stderr if you did not. So out of the box, help goes to stdout and error usage goes to stderr, which is exactly right.

The trap is that this is one writer. If you call cmd.SetOut(os.Stdout), perhaps because you are capturing output in a test or you wanted to be explicit, you have just redirected usage-on-error to stdout as well. Your error output is now in the user’s data stream. Set both writers deliberately, or neither.

Within your own commands, write through cmd.OutOrStdout() and cmd.ErrOrStderr() rather than reaching for fmt.Println. It costs nothing and it makes your commands testable, because the test can hand you a buffer.

The second half of this is knowing when a human is watching. Colour, spinners and progress bars are good when someone is looking at a terminal and are line noise when they are not:

import "golang.org/x/term"
func isInteractive() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}

Then respect NO_COLOR. It is an informal standard, documented at no-color.org, and it says that command line software which outputs ANSI colour should check for a NO_COLOR environment variable that suppresses colour when present regardless of its value. That last part catches people out. You check for presence, not for a truthy value, so NO_COLOR=0 still disables colour.

func useColour() bool {
if _, ok := os.LookupEnv("NO_COLOR"); ok {
return false
}
return isInteractive()
}

Add a --no-color flag so it can be forced per invocation, and while you are there, give any command that produces structured data a machine readable mode. --json or --output json. Someone will want to script against your tool, and the alternative is that they parse your human formatting with awk, which locks your presentation layer for all eternity because changing a column width is now a breaking change.

Making it discoverable

The last piece is the one that turns a tool people can use into a tool people can learn.

Cobra prints suggestions when an unknown command is used, which gives you git style behaviour for free:

$ flow deply
Error: unknown command "deply" for "flow"
Did you mean this?
deploy
Run 'flow --help' for usage.

That is Levenshtein distance, and every registered command within a minimum distance of 2, ignoring case, gets suggested. You can tune it with SuggestionsMinimumDistance or turn it off with DisableSuggestions, though I cannot think of a good reason to turn it off.

What is worth doing is SuggestFor, which handles the case that distance cannot: words that are nowhere near each other as strings but are the same idea in the user’s head.

var deleteCmd = &cobra.Command{
Use: "delete",
SuggestFor: []string{"remove", "rm", "destroy"},
}

Someone coming from another tool will type the word that tool used. This costs one line and catches them.

Then completions. Cobra generates completion scripts for bash, zsh, fish and PowerShell, and adds the completion command automatically. That gets you command and flag names for nothing, which is already most of the benefit.

The part worth the extra effort is completing values, because that is where people actually stall. They know they want flow deploy, they cannot remember what the targets are called:

deployCmd.ValidArgsFunction = func(
cmd *cobra.Command,
args []string,
toComplete string,
) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return listTargets(), cobra.ShellCompDirectiveNoFileComp
}

RegisterFlagCompletionFunc does the same for flag values. The directive matters: without ShellCompDirectiveNoFileComp the shell falls back to completing filenames, which is worse than useless when the argument is a target name.

Cobra also has an Active Help mechanism built on the completion system, which lets you push hints to the user as they type rather than waiting for them to ask. I have not used it in anger, so I will point at the docs rather than pretend otherwise, but it is there and it is unusual.

The thread through all of this is that a discoverable CLI does not require the user to already know things. It tells them what is available, corrects them when they are close, and points at the next step when they are lost.

Where the ideas come from

None of this is new thinking, and I would rather point at the prior art than pretend otherwise. The Command Line Interface Guidelines at clig.dev, written by Aanand Prasad, Ben Firshman, Carl Tashian and Eva Parish, describes itself as taking traditional UNIX principles and updating them for the modern day. It is very good, and it makes the argument I opened with better than I did.

The part I keep returning to is where they push back on the idea that command line tools are the remember-and-type opposite of see-and-point. Discoverable CLIs, they argue, have thorough help text, plenty of examples, suggestions for what to run next, and suggestions for what to do when something fails. They cite Don Norman and the original Macintosh Human Interface Guidelines to make the point, which tells you how old and how settled this thinking is everywhere except in our own tooling.

What clig.dev deliberately does not do is tell you how to implement any of it. It is language agnostic by design. That gap is what this article is for.

The short version

If you read none of the above, this is the list:

  • Set SilenceErrors on the root, and set SilenceUsage inside RunE rather than on the struct, so flag errors still show usage and runtime errors do not.
  • If you silence errors, print the “run with —help” hint yourself, because you have just removed Cobra’s.
  • Give failures meaningful exit codes and document them.
  • Set Args on every single command. The default accepts anything and ignores it.
  • Fill in Example. It is the highest value field in the struct and the least used.
  • Write Short descriptions to be read as a list, not in isolation.
  • Data on stdout, everything else on stderr, and do not set one output writer without thinking about the other.
  • Check NO_COLOR for presence, not for a value.
  • Add value completions for the arguments people cannot remember.

Every one of these is small. That is rather the point. The gap between a CLI that works and a CLI people enjoy using is not an architectural rewrite, it is about two hundred lines spread across the places where a default was decided by someone who was not thinking about your users.

If you have opinions about which of these matters most, or a Cobra default I have been too kind about, I would like to hear it.

Share

XLinkedIn

Related

Keep Reading

All posts →