CLI Design Fundamentals
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against the guidelines at clig.dev, the POSIX.1-2024 Utility Conventions, the GNU
Coding Standards and the 12 Factor CLI Apps article, and against Python 3.12 (argparse from the standard library),
as checked on 2026-09-30. The docsctl tool used in the examples is the small, fictional running example of this
sub-section; its commands, flags and exit codes are illustrative and exist only on these pages.
A command-line tool has three kinds of caller: a person at a terminal, a script in a pipeline or CI job, and an AI agent
that read --help a moment ago and is now composing a command line from memory. All three depend on the same thing:
that the tool behaves the way similar tools behave. Conventions are what let a model guess the right flag on the first
try, and what let a script written today keep working after the next release. This page collects those conventions; the
pages that follow build on them one concern at a time.
The clig.dev principles
Command Line Interface Guidelines (clig.dev) is an open, opinionated guide written by practitioners. Its philosophy section names a handful of ideas that the rest of the guide elaborates:
| Principle | What it asks of the tool | What it means when the caller is an agent |
|---|---|---|
Human-first design |
Optimise for the person typing, not for another program, and offer a machine-readable mode on top. |
Keep the readable default, and add an explicit |
Simple parts that work together |
Small tools with plain text streams, exit codes and signals, composable through pipes. |
Composition is exactly what an agent does with a shell: |
Consistency across programs |
Follow existing patterns (flag names, |
A model has seen thousands of CLIs; following the common pattern makes your tool guessable without reading its docs. |
Saying (just) enough |
No output feels broken, too much output buries the answer. |
Every extra line costs context tokens; small defaults and explicit |
Ease of discovery |
Help text with examples, suggestions for typos, the next command to try. |
|
Conversation as the norm |
Users run a command, see what happened, correct and re-run; make that loop cheap and safe. |
Agents iterate the same way, so an error must say what to change (the fix, not just the fault). |
Robustness |
Validate input early, be responsive, handle signals, behave the same in the corner cases. |
Fail fast with a distinct exit code instead of hanging on a prompt nobody will answer. |
Empathy |
Give the user the feeling the tool is on their side. |
Errors carry a hint, and destructive actions say exactly what will happen before it does. |
The guide’s "Basics" section turns these into rules that recur throughout this sub-section: use an argument-parsing
library, return exit code zero on success and non-zero on failure, send primary output to stdout and messages to stderr.
Its "Help" section (show help on -h and --help, lead with examples, suggest corrections) reappears in the page on
self-describing help. Each of those is expanded in its own page rather than repeated here; the rest of this page covers
the syntax layer underneath them.
POSIX utility syntax guidelines
Most command-line parsers descend from getopt, and the POSIX
Utility Conventions (chapter 12 of the Base
Definitions) write down the syntax that programs are asked to follow. The guidelines that matter most in practice are
these.
-
Options start with a single hyphen followed by one alphanumeric character:
-h,-q,-v. Each option is independent, so its meaning does not depend on where it appears among the other options. -
Grouping. Options that take no argument can be written together after one hyphen:
-qvmeans-q -v. Only the last option in a group may take an argument (-vo out.txt). -
Option-arguments follow their option, either in the next word (
-o out.txt) or attached (-oout.txt). -
Operands (the non-option arguments) come after the options.
-
--ends the options. Everything after a lone--is an operand, even if it starts with a hyphen. This is how a caller safely passes a value that a person or a model did not choose; Security returns to it, because a tool that always inserts--before untrusted operands cannot be tricked into reading a value as a flag. -
-as an operand conventionally means standard input (or standard output, for an output file). It lets a tool sit in the middle of a pipeline without a temporary file.
# Search for the literal text "--component": everything after -- is an operand
docsctl search --limit 5 -- --component
# A dash names a stream, not a file called "-": unpack an archive arriving on stdin
curl -sSfL "https://example.invalid/release.tar.gz" | tar -xzf -
- operand in a program that follows the convention (Python)import sys
from pathlib import Path
def read_operand(operand: str) -> str:
"""Return the text a file operand names; '-' means standard input."""
if operand == "-":
return sys.stdin.read()
return Path(operand).read_text(encoding="utf-8")
The POSIX conventions are guidelines for utilities, not a law that every parser enforces, and a few well-known tools
break them (find, dd, `tar’s old-style options). Follow them by default and deviate only when you have a reason you
can write down. Reference: POSIX — Utility Conventions.
GNU long options, --help and --version
The GNU Coding Standards add the convention that made command lines readable: long options with two hyphens and a
word, such as --verbose or --output=FILE. Every short option should have a long twin, and many long options need no
short form at all. Long options are what a script or an agent should prefer: docsctl search kalman --component
irurueta-statistics --limit 5 states its intent, while -c … -n 5 needs a lookup.
The same standards define two options every program is expected to have, in the section on Command-Line Interfaces:
-
--versionprints the program name and version on the first line of stdout and exits with status zero. The output should be the same shape across your tool’s releases, because scripts compare it. -
--helpprints a usage summary on stdout and exits with status zero, precisely so thatdocsctl --help | lessworks. See the standards' section on--help. A usage error, in contrast, prints a short message on stderr and exits non-zero (see Streams & Exit Codes).
$ docsctl --version
docsctl 0.1.0
$ docsctl --help | head -n 8
Usage: docsctl [OPTIONS] COMMAND [ARGS]...
Search, read and validate the Irurueta docs.
Commands:
search Search the published docs.
show Print one page (COMPONENT::PATH).
list-components List documented components and versions.
Accept both -h and --help, and make --help work on every subcommand (docsctl publish --help), not only at the
root. Do not add side effects to either: a help request must never need credentials, network access or a valid
configuration file.
12-factor CLI apps
12 Factor CLI Apps (Jeff Dickey) borrows the "twelve-factor" idea from web applications and applies it to command-line tools. It is a checklist rather than a specification; paraphrased, its twelve points are:
| Factor | The idea | Where this sub-section covers it |
|---|---|---|
Great help is essential |
|
The overview and the page on self-describing help |
Prefer flags to args |
Flags are self-documenting and easier to extend than positional arguments. |
|
What version am I on? |
|
GNU long options above |
Mind the streams |
stdout for output, stderr for messages. |
|
Handle things going wrong |
Clear errors, a suggested fix, a way to report bugs. |
|
Be fancy |
Colour and animation help people when used with restraint. |
|
Prompt if you can |
Ask for a missing value when a person is there to answer. |
|
Use tables |
Aligned columns that stay grep-friendly. |
The page on machine-readable output |
Be speedy |
Fast startup; a slow tool is a tool that is not run. |
The page on agent ergonomics |
Encourage contributions |
Open source, a contribution guide, plugin hooks. |
Out of scope here |
Be clear about subcommands |
A consistent grammar for tools with many commands. |
|
Follow the XDG spec |
Config, cache and data in the standard directories. |
Where the 12 Factor CLI Apps article and clig.dev differ in detail, treat the article as the earlier and shorter of the two; clig.dev is the more complete reference. Two related sources define the underlying idea of "config in the environment" for programs: the twelve-factor Config factor, which is written for web services but explains why environment variables and files are the two mechanisms every tool ends up supporting. References: 12 Factor CLI Apps and clig.dev.
Subcommand design and naming
A tool with more than a few actions is easier to learn as a set of subcommands under one program name, the pattern that
git, docker and gh use. clig.dev recommends it when a tool is complex enough to need it, and lists the rules that
keep such a tool learnable. For git and gh conventions in practice see the
Git and GitHub section.
Rules worth adopting from clig.dev and the 12 Factor CLI Apps article, with their docsctl consequences:
-
One name per concept, used everywhere. The word for the thing being read is
showon every command; there is nogetin one place andviewin another. -
Verbs for actions, nouns for things.
docsctlis small and flat, so its commands are verbs (search,show,validate,publish,login) plus one hyphenated verb-noun (list-components). A tool with many object types usually goes to two levels, noun then verb (docker container create,gh pr list); if you do, use the same verbs (list,show,create,delete) across every noun. -
Lower-case, kebab-case, short but not cryptic.
list-componentsis preferable tolscorlistComponents: it is easy to type and easy for a model to reproduce. -
No catch-all subcommand. Do not make the most common command implicit (
docsctl kalmanmeaningsearch): the day you add a command calledkalman, or any command at all, every existing script that relied on the implicit form changes meaning. -
Avoid names that shadow flags on other scopes.
docsctl --versionprints the tool version, whiledocsctl search --version Vselects a documentation version. Both are legal because their scope differs, but the same word meaning two things is exactly the kind of ambiguity a model gets wrong; a stricter design would name the second flag--docs-version. -
Aliases are fine when explicit and stable.
docsctl lsas a documented alias oflist-componentsis a promise you keep; a rule such as "any unambiguous prefix works" is a promise you cannot keep (see below).
| Command | Purpose | Typical form |
|---|---|---|
|
Full-text search over the published docs. |
|
|
Print one page, or one section of it. |
|
|
List documented components and their versions. |
|
|
Check AsciiDoc sources for missing cross-references. |
|
|
Publish a component to the site (destructive; supports |
|
|
Store or remove credentials. |
|
Flags versus positional arguments
Positional arguments are short to type, but they are ordered and unlabelled. Flags are longer, self-documenting and can be added, removed or reordered without breaking callers. The guidance from clig.dev and the 12 Factor CLI Apps article converges on a rule of thumb:
-
Use a positional argument for the one thing the command is about (the query in
search, the page inshow) and for repeated operands of the same kind (rm a b c). -
Use a flag for everything else: options, filters, limits, formats, and any value where the reader would otherwise have to remember an order (
--component irurueta-algebrarather than a second positional argument). -
Two or more positional arguments of different meaning are a design smell; the caller must remember the order, and so must a model.
-
Offer both forms for a value only if you can keep them equivalent. If a flag can also be given through an environment variable or a config file, the precedence between them is defined once (see Configuration & Auth).
-
Secrets never go in flag values. A flag such as
--tokenlands in shell history and inpsoutput; prefer a file, standard input (login --token-stdin) or a credential store. -
Give every flag with a common meaning its common name:
-h/--help,--version,-q/--quiet,-v/--verbose,--json,--dry-run,-f/--force,-o/--output. Where a short form means different things in different tools (-f,-n), keep only the long form.
import argparse
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="docsctl",
description="Search, read and validate the Irurueta docs.",
allow_abbrev=False, # "--comp" must not silently mean "--component"
)
parser.add_argument("--version", action="version", version="docsctl 0.1.0")
sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
search = sub.add_parser("search", help="Search the published docs.", allow_abbrev=False)
search.add_argument("query", metavar="QUERY") # what the command is about: positional
search.add_argument("--component", metavar="NAME") # a filter: flag
search.add_argument("--limit", type=int, default=10, metavar="N")
search.add_argument("--json", action="store_true", help="Emit JSON on stdout.")
return parser
if __name__ == "__main__":
args = build_parser().parse_args()
print(f"would run: {args.command} {args.query!r}")
argparse exits with status 2 and a usage message on stderr when the arguments are wrong, which matches the
usage-error code in the docsctl exit-code table. Reference: Python — argparse.
Consistency across commands
Consistency is the property that lets a caller apply what they learned from one command to the next. For docsctl, it
means these rules hold on every subcommand:
-
The global flags (
--json,--format,--fields,--limit,--no-input,--yes,--quiet,--verbose,--no-color,--timeout,--config,--profile) are accepted and mean the same thing everywhere they make sense. -
The same value has the same name: a component is always
--component NAMEand a page is alwaysCOMPONENT::PATH, never--projectin one command and--repoin another. -
Output shapes are related. Every JSON result has a top-level
schemaVersion; every list result has the same pagination fields. See the page on machine-readable output. -
Exit codes are shared. Usage errors are always 2, authentication problems always 7; a script never has to look up a per-command table.
-
Help has one layout: a summary line, a usage line, examples, then the options.
A cheap way to keep this true is to define the global flags and the error handling once, in the root command, and to let every subcommand inherit them (mix-ins in picocli, a callback in Typer, persistent flags in Cobra), instead of copying them command by command. The framework pages of this sub-section show how each library does it: Python, Java, Node.js, Go & Rust and Swift & .NET.
No ambiguous prefix matching
Some parsers let the caller abbreviate: --comp for --component, or docsctl pub for docsctl publish. It feels
friendly and it is a trap, for the reason clig.dev gives about subcommands: once scripts depend on a prefix, you can
never add another command or option that starts with the same letters without breaking them. A tool that accepts
docsctl pub today cannot add docsctl publish-preview tomorrow; a tool that accepts --comp cannot add
--compact. In agent workflows the risk is sharper, because a model that abbreviates by guessing has no way to know the
abbreviation is ambiguous in the next release.
-
Require the full name of every subcommand and long option, and add explicit aliases for the few you want short.
-
Check what your library does by default. Python’s
argparseaccepts unique prefixes of long options unless you passallow_abbrev=False; picocli disables both abbreviated options and abbreviated subcommands by default (CommandLine::setAbbreviatedOptionsAllowedandsetAbbreviatedSubcommandsAllowedturn them on); Cobra only matches subcommand prefixes if you opt in withcobra.EnablePrefixMatching. -
A typo is different from an abbreviation. Reject it, but suggest the nearest valid name in the error message ("Unknown command
pubish. Did you meanpublish?"), and exit with the usage code so that scripts fail fast.
$ docsctl pub --component irurueta-algebra
docsctl: unknown command 'pub'. Did you mean 'publish'?
Run 'docsctl --help' to list the commands.
$ echo $?
2
References: clig.dev — Subcommands, picocli user manual (abbreviated options and
subcommands), Python — argparse (allow_abbrev) and Cobra.