Ansi.md

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.

flagbehavior
-h, --helpshow help, then exit 0
-v, --versionshow version, then exit 0
-f, --forceuse this to override typical safeguards
-q, --quietno output
--dry-rundon't do anything, just preview
--jsonoutput 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

  1. stdout is for output, stderr is only for errors. Exit 0 on success, 1 on failure.

  2. 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
  1. Naked niceness - What happens if a user runs your app without any flags, arguments or input at all? I call this the naked case 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
  1. 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

  2. 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.

  3. Don't prompt - Except maybe for destructive actions. Always support --force or perhaps --yes to skip.

  4. 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.

  5. 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.

    nopeyup
    file not found/tmp/something.txt not found
    invalid arg--bogus is not a valid flag
    missing required argcurl: no URL specified
  6. --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.

  7. ENV is great for spelunking - I love it when apps & libs support special environment variables that modify behavior. --help is 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
  ...
exit: non-zero

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
...
exit: 0

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
...
exit: 0

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]
exit: non-zero