Command-line users carry decades of expectations into every new tool. -h shows help. -v -v or -vv means more verbose. --output=file.txt and --output file.txt mean the same thing. -- stops option parsing so a filename starting with a dash is treated as a filename. - means standard input. A usage error exits with status 2. None of this is enforced by the operating system; it is convention — partly written down in the POSIX utility syntax guidelines, extended by the GNU coding standards, and reinforced by every tool people use daily. A CLI that follows these conventions feels immediately familiar; one that breaks them feels foreign and is harder to script. This guide lists the conventions that matter, shows which ones Click, Typer and argparse give you for free, and implements the ones they leave to you. It belongs to the designing CLI interfaces and conventions topic.
Prerequisites
- A CLI built with Click, Typer or argparse.
- An interest in making it behave well in pipelines and scripts, not only interactively.
The conventions, and who implements them
Short options are a single dash and a single letter (-v), and several without arguments can be combined (-xvf file). Long options (a GNU extension, now universal) are two dashes and a word (--verbose). An option's value can follow as the next word or be attached: -o out.txt, -oout.txt, --output out.txt, --output=out.txt. Options and positional arguments may be interleaved (ls dir -l), another GNU extension that POSIX itself does not require. -- ends option processing: everything after it is positional, even if it starts with a dash. And - as a filename means standard input (or output), by long convention.
Click and argparse implement all of these except the last: - is just a string until your code gives it meaning. Typer, which is built on Click, behaves the same way.
The recipe: the parts you implement
Here is a small Typer command that follows the conventions end to end — combined short flags, -h, reading - as stdin, writing results to stdout and everything else to stderr, and exit status 2 for usage errors:
# src/mytool/cli.py
from typing import Annotated
import click
import typer
app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]})
@app.command()
def count(
files: Annotated[list[typer.FileText], typer.Argument(help="Files to read; '-' for stdin.")] = None,
lines: Annotated[bool, typer.Option("--lines", "-l", help="Count lines.")] = False,
words: Annotated[bool, typer.Option("--words", "-w", help="Count words.")] = False,
verbose: Annotated[int, typer.Option("--verbose", "-v", count=True)] = 0,
) -> None:
"""Count lines and words, like a small wc."""
if not (lines or words):
lines = words = True
sources = files or [click.open_file("-")] # no files: read stdin, like wc
total_l = total_w = 0
for fh in sources:
text = fh.read()
n_l, n_w = text.count("\n"), len(text.split())
total_l, total_w = total_l + n_l, total_w + n_w
name = "-" if fh.name == "<stdin>" else fh.name
cols = ([f"{n_l:>7}"] if lines else []) + ([f"{n_w:>7}"] if words else [])
typer.echo(" ".join(cols + [name])) # results -> stdout
if verbose:
typer.echo(f"read {name}", err=True) # narration -> stderr
if len(sources) > 1:
cols = ([f"{total_l:>7}"] if lines else []) + ([f"{total_w:>7}"] if words else [])
typer.echo(" ".join(cols + ["total"]))
if __name__ == "__main__":
app()
What this gives users, with only one explicit line of convention handling:
mytool -lw notes.txt todo.txt # combined short flags
mytool --lines=true ... # rejected: boolean flags take no value, as users expect
cat notes.txt | mytool -l # no files: read stdin
mytool -l - < notes.txt # '-' is stdin, via typer.FileText / click.File
mytool -l -- -draft.md # '--' ends options; '-draft.md' is a file
mytool -h # short help, because help_option_names includes -h
mytool --bogus # usage error, exit status 2
typer.FileText (Click's click.File) is what makes - work: Click opens the special name - as stdin for read mode and stdout for write mode, and opens real paths lazily with proper error messages. Reading piped input more generally is covered in reading piped input in Python CLIs.
-- matters most in scripts
Filenames can begin with a dash, and scripts that pass arbitrary filenames to your tool must be able to say "these are files, not options". Click and argparse support -- automatically; your documentation should mention it, and any code of yours that builds command lines for other programs should use it too — the same protection described in avoiding shell injection in Python CLIs.
argparse settings worth changing
argparse follows the conventions too, with two defaults worth revisiting:
import argparse
parser = argparse.ArgumentParser(
prog="mytool",
allow_abbrev=False, # --verb must not silently mean --verbose
)
parser.add_argument("-v", "--verbose", action="count", default=0)
parser.add_argument("files", nargs="*", type=argparse.FileType("r"), default=["-"])
allow_abbrev=True (the default) accepts any unambiguous prefix of a long option. It is convenient until you add --verify, at which point every script using --ver breaks. argparse.FileType("r") gives the same - handling as click.File. More argparse specifics are in the argparse topic.
Exit statuses are conventions too
0 means success, any non-zero value means failure, and several values have established meanings: 1 for a general failure, 2 for incorrect usage (which Click and argparse already use for parse errors), 126 and 127 for "cannot execute" and "not found" from shells, 124 from timeout(1), 128 plus the signal number for processes killed by a signal, and the sysexits.h range 64–78 for specific conditions. Scripts depend on these; the full treatment is in choosing exit codes for CLI tools.
UX considerations
- stdout for results, stderr for everything else. The single most important stream convention: progress, warnings, prompts and diagnostics go to stderr so pipelines carry only data. See working with stdin, stdout and pipes.
- Respect environment conventions.
NO_COLORdisables colour,TERM=dumbimplies no fancy output,PAGERchooses the pager,EDITORthe editor,TMPDIRthe temporary directory. Rich and Click already honour several of these. - Silence is golden. Unix tools say nothing on success unless they produce data. A tool that prints "Done!" after every command is noisy in scripts; reserve such messages for
-v. - Accept
-h. Click's default is only--help; adding-hthroughhelp_option_namesmatches what users try first — unless-hmeans something else in your domain (such as "host"), in which case do not overload it. - Do not invent syntax. Custom forms like
+option,/optionoroption:valueare learnable but unexpected; prefer the standard forms even when a custom one would be shorter.
Testing the behaviour
Conventions are exactly the kind of behaviour that regresses quietly, so pin them with tests:
# tests/test_conventions.py
from typer.testing import CliRunner
from mytool.cli import app
runner = CliRunner()
def test_combined_short_flags(tmp_path):
f = tmp_path / "a.txt"
f.write_text("one two\nthree\n")
result = runner.invoke(app, ["-lw", str(f)])
assert result.exit_code == 0
assert result.stdout.split()[:2] == ["2", "3"]
def test_dash_reads_stdin():
result = runner.invoke(app, ["-l", "-"], input="a\nb\nc\n")
assert result.stdout.split()[0] == "3"
def test_no_files_reads_stdin():
result = runner.invoke(app, ["-w"], input="x y z\n")
assert result.stdout.split()[0] == "3"
def test_double_dash_allows_dash_filenames(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "-draft.md").write_text("hi\n")
assert runner.invoke(app, ["-l", "--", "-draft.md"]).exit_code == 0
def test_short_help_and_usage_errors():
assert runner.invoke(app, ["-h"]).exit_code == 0
assert runner.invoke(app, ["--bogus"]).exit_code == 2
def test_narration_goes_to_stderr(tmp_path):
f = tmp_path / "a.txt"
f.write_text("x\n")
result = runner.invoke(app, ["-v", str(f)])
assert "read" not in result.stdout and "read" in result.stderr
The last test relies on Click 8.2+ CliRunner, which captures stdout and stderr separately (result.stdout, result.stderr). It is the test that keeps diagnostics out of pipelines. For whole-output comparisons, see snapshot testing CLI output.
Conclusion
POSIX and GNU conventions are the shared grammar of the command line, and following them is mostly free: Click, Typer and argparse already handle short and long options, combined flags, --opt=value, interleaving and --. Your part is giving - its meaning with click.File or argparse.FileType, adding -h where appropriate, disabling argparse's abbreviations, keeping stdout for results and stderr for everything else, honouring the usual environment variables and using conventional exit statuses. Pin them with tests, and your tool will behave the way users' fingers already expect.
Frequently asked questions
Should options be allowed after positional arguments?
Yes — GNU behaviour, which Click and argparse follow, and what users expect (mytool file.txt -v). POSIX strictly stops option processing at the first positional argument; set POSIXLY_CORRECT-style behaviour only if you have a specific need, such as a command that passes its trailing arguments to another program.
How do I pass trailing arguments through to another program?
Use context_settings={"allow_interspersed_args": False, "ignore_unknown_options": True} on that command and an argument with nargs=-1, so mytool run -- pytest -x -q hands pytest -x -q through untouched. uv run and poetry run work this way.
Should an option be allowed more than once?
Only when repetition has an obvious meaning. Counting flags (-vvv) and accumulating options (--tag a --tag b) are conventional; for everything else, the last occurrence should win, which is what Click and argparse do by default. That rule is what lets a script set a default early in a command line and a caller override it by appending the same option later, a pattern shell wrappers rely on.
Is -v for version acceptable?
It conflicts with the far more common -v for verbose. Use --version (and -V if you want a short form, as Python itself does).
What about Windows-style /option syntax?
Python CLI frameworks do not support it, and modern Windows tools (git, docker, winget) use dash syntax. Stick with dashes everywhere; Windows users are already used to them.