Ansi.md

CLI Design

This page is about building delightful command line apps, which is a deep topic. Colors and visual design is carved out separately, this page focuses on the basic principles of cli design - stdout/stderr, exit code, args, flags, subcommands, etc. We are having a bit of a CLI renaissance with the rise of LLMs. This is also powered by backlash against the plodding, buggy nature of web pages. You 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. Written by jdx, who went on to create mise.
  • CLIG.dev A bit overbearing, perhaps due to excessive use of Apple Garamond. Love the philosophical tone, though.

Let's stick to the important stuff:

Standard Flags

flagbehavior
-h, --helpshow help, then exit 0
-f, --forceyes, do the dangerous thing
-v, --versionshow version, then exit 0
-q, --quietno output
--dry-rundon't do anything, just preview
--jsonoutput JSON only (for LLMs)

I do not recommend -v for verbose, instead I like -v for version and use an ENV var like $MYAPP_DEBUG=1 for debug output.

CLI Golden Rules

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

  2. When running --help or "naked" (no args), print help on stdout and exit 0. Let me pipe it into my pager! Try not to commingle stdout and stderr, this is difficult to untangle.

    $ myapp              # print help on stdout, exit 0
    $ 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
  3. Fail fast, fail loud - Don't try to recover from strange errors. Humans and LLMs are both perfectly capable of fixing things, and will probably do a better job than some lightly tested limb of your cli. Has your LSP silently stopped working in vscode? Has prettier failed to format? Give me errors right away, don't silently fail!

  4. Don't prompt - Except maybe for destructive actions, support --force to skip.

  5. Use XDG - I may uninstall your app if it writes straight into $HOME.

  6. 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
  7. Flags > 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 setup 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.

  8. 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 flag. As a cli author I hesitate to add cli flags that increase verbosity, turn the cache off, disable retries, etc. Each flag comes with a small burden for testing, --help text, man page, README, and completions. Secret ENV flags mostly sidestep that burden. Add 'em!

Example - gum

Gum is a fun little app 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 this is unfriendly. In my terminal this shows a nasty red ERROR indicator. Why make me feel bad just for entering your command naked? This reminds me of web forms that display 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 ($GUM_CHOOSE_ORDERED)
      --height=10                   Height of the list ($GUM_CHOOSE_HEIGHT)
...
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