The CLI Contract: What Makes a Command-Line Tool a Good Citizen
argparse, Click, and Typer look like three different ways to build a command-line tool, but they're really three different ergonomics layered on top of the same underlying contract: a program that reads from standard input, writes results to standard output, writes diagnostics to standard error, and reports success or failure through its exit code. That contract predates all three libraries by decades, and it's the reason a well-built Python CLI composes cleanly with grep, xargs, and shell scripts written by people who've never heard of your tool.
This page explains that contract directly, because CLI Basics and the specific framework pages - argparse, Click, Typer - assume you already understand what a "well-behaved" CLI is supposed to do, and spend their space on how to implement it rather than why it matters.
Summary
- A CLI's real interface is its streams (stdout/stderr), its exit code, and its argument grammar - the parsing library you choose is an implementation detail on top of that contract.
- Insight: A tool that violates the contract - mixing errors into stdout, always exiting 0, ignoring pipes - breaks scripting and composition even if it works fine when a human runs it directly.
- Key Concepts: stdout/stderr separation, exit codes, argument vs option grammar, TTY detection, config precedence.
- When to Use This Model: Designing a new CLI's output format, deciding what should be an argument versus a flag, or debugging why a tool behaves differently in a script than in a terminal.
- Limitations/Trade-offs: Following the contract adds a small amount of discipline (structured output modes, careful stream separation) that a quick one-off script can skip - but skipping it is exactly what makes a tool unusable in automation later.
- Related Topics: Unix pipelines, shell scripting conventions, terminal capability detection, structured logging.
Foundations
Every process has three standard streams available to it: stdin (input), stdout (normal output), and stderr (diagnostics and errors).
The separation between stdout and stderr exists so a caller can redirect one without the other - piping a command's real output into another program while still letting error messages reach the terminal.
A tool that prints an error message to stdout pollutes whatever consumes that output, because there's no way downstream to distinguish "here is your data" from "here is what went wrong" once both streams are merged.
The second half of the contract is the exit code: 0 means success, any non-zero value means failure, and shells and scripts branch on that number, not on the text a command printed.
A CLI that always exits 0 - even when it failed - breaks every if mytool; then or mytool && next-step pattern a caller might reasonably write, silently turning failures into successes from the shell's point of view.
The third piece is argument grammar: positional arguments are required values in a fixed order (cp source dest), while options/flags are named and optional (--verbose, -o output.txt), following long-established POSIX/GNU conventions - short single-dash flags, long double-dash options, -- to end option parsing.
argparse, Click, and Typer all implement this same grammar; the difference between them is how much boilerplate you write to get there, not what the resulting command line looks like to a user.
Mechanics & Interactions
TTY detection is the mechanism behind a behavior every CLI user has seen without necessarily naming it: the same command looks different when its output goes to a terminal versus into a file or another process.
A process can check whether its stdout is connected to an interactive terminal or to a pipe/file, and well-behaved tools use that check to decide whether to show color, progress bars, and prompts at all.
import sys
if sys.stdout.isatty():
print("\033[32mDone\033[0m") # color escape codes - fine for a human
else:
print("Done") # plain text - safe for a log file or another programRich Output and Interactive Prompts both build on this check: Rich detects a non-terminal target and falls back to plain text automatically, and an interactive prompt library has to detect a non-interactive context (a CI pipeline, a piped input) and fail clearly instead of hanging forever waiting for keyboard input that will never come.
Config precedence is the same idea applied to a different question: not "how should I display this" but "which value wins when a setting is provided in more than one place." The near-universal convention - command-line flags override environment variables, which override a config file, which override built-in defaults - exists because it matches how specific a source is: a flag is the most explicit, one-time instruction a user can give, while a default is the least specific fallback.
Config & Environment covers implementing that precedence chain concretely; the reason the order is always flags-then-env-then-file-then-defaults, and not some other order, is that reversing it would mean a stale config file could silently override an explicit flag someone typed moments ago.
Advanced Considerations & Applications
The contract described so far explains why "does this tool work when I run it by hand" is a weaker bar than "does this tool work when something else calls it."
A CLI meant only for interactive use can get away with prompts, color, and progress bars as its primary interface; a CLI meant to be scriptable needs a machine-readable output mode (--json, --quiet), meaningful distinct exit codes beyond just 0/1, and streams that never assume a human is watching.
CLI Best Practices enumerates concrete guidance here, but the underlying reason each guideline exists traces back to this contract: --json exists because stdout needs to be parseable by something other than a human eye, distinct exit codes exist because a caller needs to distinguish "invalid input" from "network failure" without parsing error text, and a --quiet flag exists because progress output that's useful interactively is noise in a log file.
Packaging & Distributing CLIs is a related but distinct concern - it's about how the tool reaches a user's PATH, not about the contract the tool honors once invoked; a beautifully packaged tool that violates stream separation is still a bad citizen once it's installed.
| Design choice | Interactive-only tool | Scriptable/composable tool |
|---|---|---|
| Output format | Rich tables, color, human-readable prose | Plain text or --json on request, parse-friendly |
| Prompts | Freely used for missing input | Must fail clearly (or accept flags/stdin) in non-interactive contexts |
| Exit codes | 0 and 1 often sufficient | Distinct codes per failure category, documented |
| Progress indicators | Progress bars, spinners | Suppressed automatically when not a TTY, or via --quiet |
| Config source | Flags and prompts | Full flags > env > file > defaults precedence, all scriptable |
Getting this right from the start matters more for a CLI than for most other software, because a CLI's consumers include future scripts and CI pipelines that were never part of the original design conversation - a tool that only ever gets run by the person who wrote it can hide contract violations indefinitely, right up until someone tries to call it from cron or a CI job and it hangs waiting for a prompt that will never come.
Common Misconceptions
- "Printing everything to stdout is fine as long as it's readable." Readability for a human and parseability for a pipeline are different goals - error text mixed into stdout breaks any caller trying to separate real output from diagnostics, even if a human reading the terminal never notices.
- "Exit code 1 is good enough for all failures." Distinct exit codes let a caller branch on failure category (bad input versus a network timeout versus a permission error) without parsing text output, which matters the moment the tool is called from a script instead of by hand.
- "Click/Typer decorators are a different argument syntax from argparse." All three ultimately produce the same POSIX/GNU-style command line for the end user; the decorators change how much code the author writes, not the grammar the user types.
- "Color and progress bars are always a nice touch." They're a nice touch only when stdout is a terminal - shown unconditionally, escape codes and moving progress bars corrupt log files and confuse any program parsing that output.
- "Config precedence order doesn't really matter as long as all sources are supported." The order encodes intent - a flag typed just now should always beat a config file written months ago - and getting the order backwards means the least explicit source can silently override the most explicit one.
FAQs
Why does a CLI need separate stdout and stderr streams at all?
So a caller can redirect real output into a file or another program while error messages still reach the terminal (or vice versa) - merging them removes a downstream consumer's ability to tell "here is your data" from "here is what went wrong."
What's the actual difference between an argument and an option/flag?
An argument is positional and usually required, identified by its order on the command line (cp source dest); an option or flag is named (--verbose, -o file.txt) and optional, and can appear in any order - all three Python CLI libraries implement this same POSIX/GNU distinction.
Why do exit codes matter if a human just reads the printed output?
Because shells and CI systems branch on the numeric exit code, not on printed text - a script chaining commands with && or checking $? needs a correct non-zero code on failure, or it will proceed as if the previous step succeeded.
How does a program know whether it's being run interactively or piped?
By checking whether its stdout (or stdin) is connected to a terminal via an isatty() check; that single check is what lets a tool show color and progress bars for a human while emitting plain text automatically when piped into a file or another process.
Why is config precedence always flags, then environment, then file, then defaults?
Because that order matches specificity - a flag is the most explicit, immediate instruction a user can give right now, while a default is the least specific fallback - reversing the order would let a stale, less-specific source silently win over an explicit one.
Do argparse, Click, and Typer produce different command-line experiences for end users?
Not fundamentally - all three follow the same argument/option grammar an end user already knows from other Unix tools; they differ in how much Python code the author writes to get there, not in what the user types.
Why shouldn't an interactive prompt library just always ask for missing input?
Because a non-interactive context - a CI job, a piped script - has no human available to answer, so a prompt that assumes one will hang indefinitely; a well-behaved tool detects that context and fails with a clear message or falls back to flags/stdin instead.
Is a `--json` output flag just a nice extra feature?
It's closer to a requirement for scriptability - plain human-readable prose is genuinely hard to parse reliably from another program, so a stable, documented machine-readable mode is what lets other tools depend on your CLI's output without scraping text.
What actually breaks if a CLI ignores this contract but works fine when run by hand?
Nothing breaks until someone tries to call it from a script, cron job, or CI pipeline - at that point a merged stdout/stderr, an always-zero exit code, or an unexpected interactive prompt turns into a silent failure or a hang, often far from where the original design decision was made.
Does packaging a CLI well (pipx, entry points) also mean it honors this contract?
No - packaging determines how a tool reaches a user's PATH and gets installed, which is a separate concern from whether the tool's actual runtime behavior (streams, exit codes, TTY awareness) is well-behaved once invoked.
Why does this matter more for CLIs than for, say, a typical web service?
Because a CLI's consumers routinely include other scripts and automation that were never part of the original design conversation - a web service has a more controlled set of callers, while a CLI can end up piped into xargs or called from cron by someone who's never read its source.
Related
- CLI Basics - hands-on introduction to building a first command-line tool
- argparse - the stdlib implementation of the POSIX/GNU argument grammar
- Rich Output - TTY-aware formatting that falls back to plain text
- Config & Environment - implementing the flags-over-env-over-file precedence chain
- Interactive Prompts - prompting safely without breaking non-interactive callers
- CLI Best Practices - concrete guidance rooted in this same contract
Stack versions: This page is conceptual and not tied to a specific stack version.