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 --json mode (see the machine-readable output page of this sub-section) rather than making the text output "parsable".

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: docsctl search …​ | jq …​.

Consistency across programs

Follow existing patterns (flag names, -h, --version, Ctrl-C), because users' muscle memory is a shared resource.

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 --verbose keep the cost predictable.

Ease of discovery

Help text with examples, suggestions for typos, the next command to try.

--help is the documentation the agent actually reads.

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: -qv means -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.

Ending options with — and reading standard input with -
# 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 -
Handling the - 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:

  • --version prints 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.

  • --help prints a usage summary on stdout and exits with status zero, precisely so that docsctl --help | less works. 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).

The two universal options on docsctl
$ 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

-h and --help, examples, links to more.

The overview and the page on self-describing help

Prefer flags to args

Flags are self-documenting and easier to extend than positional arguments.

Flags versus positional arguments below

What version am I on?

--version, -V and a version subcommand are all common.

GNU long options above

Mind the streams

stdout for output, stderr for messages.

Streams & Exit Codes

Handle things going wrong

Clear errors, a suggested fix, a way to report bugs.

Streams & Exit Codes

Be fancy

Colour and animation help people when used with restraint.

TTY, Colour & Interactivity

Prompt if you can

Ask for a missing value when a person is there to answer.

TTY, Colour & Interactivity

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.

Subcommand design and naming below

Follow the XDG spec

Config, cache and data in the standard directories.

Configuration & Auth

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 show on every command; there is no get in one place and view in another.

  • Verbs for actions, nouns for things. docsctl is 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-components is preferable to lsc or listComponents: 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 kalman meaning search): the day you add a command called kalman, 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 --version prints the tool version, while docsctl search --version V selects 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 ls as a documented alias of list-components is a promise you keep; a rule such as "any unambiguous prefix works" is a promise you cannot keep (see below).

Table 1. The docsctl command surface
Command Purpose Typical form

search

Full-text search over the published docs.

docsctl search QUERY [--component NAME] [--limit N]

show

Print one page, or one section of it.

docsctl show irurueta-algebra::index.adoc --max-chars 4000

list-components

List documented components and their versions.

docsctl list-components --include-versions

validate

Check AsciiDoc sources for missing cross-references.

docsctl validate [PATH] --strict

publish

Publish a component to the site (destructive; supports --dry-run).

docsctl publish --component NAME --dry-run

login, logout

Store or remove credentials.

docsctl login --token-stdin

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 in show) 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-algebra rather 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 --token lands in shell history and in ps output; 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.

A subcommand parser that follows these rules (Python, standard library)
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 NAME and a page is always COMPONENT::PATH, never --project in one command and --repo in 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 argparse accepts unique prefixes of long options unless you pass allow_abbrev=False; picocli disables both abbreviated options and abbreviated subcommands by default (CommandLine::setAbbreviatedOptionsAllowed and setAbbreviatedSubcommandsAllowed turn them on); Cobra only matches subcommand prefixes if you opt in with cobra.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 mean publish?"), and exit with the usage code so that scripts fail fast.

Rejecting a prefix instead of guessing
$ 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.