CLI Best Practices
This page is about building delightful command line apps, which is a deep topic. Color is carved out separately, this page focuses on the basic principles of cli design. We're talking stdout vs stderr, exit code, args, flags, subcommands, etc.
CLI apps are having a bit of a renaissance with the rise of LLMs. Turbocharged by righteous backlash against the plodding, buggy nature of websites. You personally can contribute to the renaissance by building delightful apps.
I wanted to call out the two main cli guides that made an impact on me:
- 12 Factor Cli Apps. Essential reading by jdx, the creator of mise.
- CLIG.dev A bit overbearing due to excessive use of Apple Garamond. Love the philosophical tone, though.
Standard Flags
Where useful, support these standard flags and don't use them for anything else. Consistency helps on the command line.
| flag | behavior |
|---|---|
| -h, --help | show help, then exit 0 |
| -v, --version | show version, then exit 0 |
| -f, --force | use this to override typical safeguards |
| -q, --quiet | no output |
| --dry-run | don't do anything, just preview |
| --json | output JSON only (for LLMs) |
I do not recommend -v for verbose, -v means something else on CLI. Instead I like to use an ENV var like $MYAPP_DEBUG=1 for extra debug output.
CLI Golden Rules
-
stdoutis for output,stderris only for errors. Exit 0 on success, 1 on failure. -
Print --help to stdout and exit 0. Let me pipe it into my pager! Try not to commingle stdout and stderr, this is difficult to untangle.
$ myapp --help # print help on stdout, exit 0
$ myapp cmd --help # print help for this subcmd, exit 0
$ myapp --bogus # print error on stderr, exit 1
$ myapp bogus # print error on stderr, exit 1
$ myapp cmd --bogus # print error on stderr, exit 1
- Naked niceness - What happens if a user runs your app without any flags, arguments or input at all? I call this the
nakedcase and usually you want to print a short hint message and exit 0. If your app supports stdin as input, don't go naked in that case. Tricky! Tennis:
$ tennis
tennis: try 'tennis --help' for more information
-
Fail fast - Don't try to recover from strange errors. Humans and LLMs are both perfectly capable of learning and trying again. Also, it's easier to write great software that fails fast, since you don't burn dev time on lightly tested edge cases. I'M TALKING TO YOU, CLAUDE
-
Fail loud - Print errors right away and never silently fail. This is a personal pet peeve, software that fails silently in strange ways. At one point I submitted numerous PRs to a vscode extension that failed silently when misconfigured. Loud and proud, please.
-
Don't prompt - Except maybe for destructive actions. Always support
--forceor perhaps--yesto skip. -
Use XDG - XDG is an overly hectoring spec that helps you figure out where to read/write user settings, app cache, etc. You don't have to follow the whole spec, just avoid writing directly into $HOME. I may uninstall your app if it writes straight into $HOME. For real, and I'm not alone.
-
Errors - Guys, it's not that complicated. Make sure your error includes enough detail! Here is a handy chart that illustrates the problem. Some libraries and languages make this quite difficult, unfortunately. I am sending Zig the stink eye right now.
nope yup file not found /tmp/something.txt not found invalid arg --bogus is not a valid flag missing required arg curl: no URL specified -
--flag > $env > config file - If your app is successful, users will want to configure it in a more permanent fashion. Some flags will be very popular. Users will set up aliases. They will want ENV variables. They will want config files. You can add support for all this stuff, but remember the priority of each mechanism.
-
ENV is great for spelunking - I love it when apps & libs support special environment variables that modify behavior.
--helpis the public face of your app, but it doesn't have to end there. I love love love when I find a $MYAPP_DEBUG=1 env var. As a cli author I hesitate to add full flags that increase verbosity, turn off the cache, disable retries, etc. Each supported flag increases my engineering burden, including --help text, man page, README, and shell completions. ENV flags sidestep that burden. Add 'em!
Example - gum
gum is a fun little tool from our friends at Charm. Like all charm apps, it is written in golang and uses kong for args. A fine example of what you get out-of-the-box with a proper library. This is why I highlight Kong and other stellar libraries on my recommendations page.
1. Naked gum
Nice help message, and it goes to stdout. Only quibble is that naked gum exits with a non-zero code. I understand that this is technically an error ("the user didn't pass a subcommand") but it feels unfriendly. My shell shows a nasty ERROR indicator. Why make me feel bad just for entering your command naked? This reminds me of web forms that blurt out an error as soon as you focus the input, then the error disappears once the field is valid. Meanies.
$ gum
Usage: gum <command> [flags]
A tool for glamorous shell scripts.
Flags:
-h, --help Show context-sensitive help.
-v, --version Print the version number
Commands:
choose Choose an option from a list of choices
confirm Ask a user to confirm an action
...
2. gum --help
Same as above, but exit code zero. Sweet relief.
$ gum --help
Usage: gum <command> [flags]
A tool for glamorous shell scripts.
Flags:
-h, --help Show context-sensitive help.
-v, --version Print the version number
...
3. gum subcommand --help
Same as above, but the help text is specific to the subcommand. Great.
$ gum choose --help
Usage: gum choose [<options> ...] [flags]
Choose an option from a list of choices
Arguments:
[<options> ...] Options to choose from.
Flags:
-h, --help Show context-sensitive help.
-v, --version Print the version number
--ordered Maintain the order of the selected options
--height=10 Height of the list
...
4. gum errors
These show help again, a useful error message, and exit non-zero. Perfect!
$ gum bogus
$ gum --bogus
$ gum choose --bogus
...
[various surly error messages]