| created | 2026-05-11 | |||
|---|---|---|---|---|
| last_modified | 2026-05-11 | |||
| revisions | 1 | |||
| doc_type |
|
|||
| lifecycle | active | |||
| owner | rmrich5 | |||
| title | CLI UX conventions for m-dev-tools |
How every command-line tool in the m-dev-tools org should behave at every level — root, subcommand group, leaf — when invoked with no arguments, with
--help, and on error. Grounded in a survey of the Unix and modern-CLI ecosystem.
This document is the canonical reference for CLI ergonomics across the
org. It applies to m-cli today (m, m engine, m doc, m ci, m fmt, m lint, m test, …) and to any future tool that ships a
command-line entry point from any tier-2 repo (m-tools, m-stdlib's
helper scripts, m-test-engine CLIs, etc.). Tools that diverge from
this guide need an explicit reason recorded in their own repo's
documentation.
- Bare invocation of a dispatcher prints a short overview
(synopsis + common subcommands + pointer to
--help), not a wall of help and not nothing. Apply the rule recursively —mandm enginebehave the same way. -h/--helpis the only way to get the full reference, goes to stdout, exits 0.- Errors (unknown subcommand, missing required arg) print a short
usage line to stderr, exit non-zero, and point the user at
--help. - Leaf commands (
m fmt,m lint) either operate on a sensible default (cwd, stdin) or print a short usage error — pick one rule per tool and apply it consistently across leaves. - Side-effectful actions never run as the default for a bare
dispatcher invocation. Listing / inspection defaults are fine
(
git remote); mutations are not.
The detailed rationale, the survey behind these rules, and the Python-argparse implementation pattern follow.
Every node in a CLI's command tree is one of two shapes:
-
Dispatcher — a node whose only job is to route to children. Has no useful work to do on its own. Examples:
m(root),m engine,m doc,m ci,git,git stash(in some configurations),gh pr,kubectl config,docker image. -
Leaf — a node that actually performs work. Takes its own flags and positional args; may consume input from stdin or the cwd. Examples:
m fmt,m lint,m test,git commit,gh pr create,kubectl apply,docker run.
The bare-invocation rule depends on which shape the node is:
| Shape | Bare invocation behavior |
|---|---|
| Dispatcher | Print short overview of children + pointer to --help. Exit 0 or 1 (pick one, stay consistent). |
| Leaf with sensible default | Run with the default (e.g. m lint lints cwd; cat reads stdin). Exit 0 on success. |
| Leaf with required args | Print short usage to stderr + pointer to --help. Exit non-zero. |
A node is a dispatcher if and only if it has child subcommands and no useful standalone behavior. Adding children to a former leaf upgrades it to a dispatcher; the bare-invocation rule changes accordingly.
| Tool | Bare tool behavior |
Exit |
|---|---|---|
git |
Short usage + common commands list to stderr | 1 |
gh |
Help overview to stdout | 0 |
kubectl |
Help overview to stdout | 0 |
docker |
Help overview to stdout | 0 |
cargo |
Help overview to stdout | 0 |
aws |
Usage error to stderr, hint to use aws help |
252 |
npm |
Help overview to stdout | 0 |
cp, mv, ln |
missing file operand + short usage to stderr |
1 |
grep |
Short usage to stderr | 2 |
ssh |
Short usage to stderr | 255 |
curl |
try 'curl --help' hint to stderr |
2 |
tar |
Short usage + hint to --help to stderr |
2 |
find (GNU) |
Implicit find . — defaults to cwd |
0 |
cat, sort, wc, tr, sed, awk |
Read stdin (filter mode) | 0 |
python, node, psql, redis-cli, sqlite3, gdb |
Enter REPL | 0 |
Three families emerge:
- Dispatchers (
git,gh,kubectl,docker,cargo,npm,aws) print an overview or a usage error. - Leaf tools with required args (
cp,mv,grep,ssh,curl,tar) print a short usage to stderr and exit non-zero. - Filter / REPL tools (
cat,sort,python) do something useful — read stdin or enter interactive mode.
The dispatcher family is what m and every m <group> belongs to.
The relevant peers are git, gh, kubectl, docker, cargo.
| Tool | Bare tool group behavior |
|---|---|
git remote |
Lists remotes (leaf with inspection default) |
git stash |
Runs git stash push (leaf with mutating default — controversial) |
gh pr |
Help overview for pr subcommands |
gh repo |
Help overview for repo subcommands |
kubectl config |
Help overview for config subcommands |
docker image |
Help overview for image subcommands |
aws s3 |
Usage error + available commands |
The modern consensus (gh, kubectl, docker) is: dispatcher
groups behave like the root command — overview of their children,
recursively. git's mixed behavior reflects 20 years of organic
growth and is not the model to copy.
- clig.dev — Command Line Interface Guidelines
("Display help text when passed no options, the
-hflag, or the--helpflag.") - POSIX Utility Conventions (exit-code semantics, argument parsing).
- GNU Coding Standards § Command-Line Interfaces
(long-option naming,
--helpand--versionconventions). - 12 Factor CLI Apps (Jeff Dickey) (UX patterns for modern multi-command CLIs).
- Reference implementations:
git,gh,kubectl,docker,cargo,npm.
The rules below apply to every CLI shipped from this org. They are
written for m-cli but extend to any tier-2 repo that ships a binary.
When a user runs m, m engine, m doc, m ci, or any future
dispatcher group with no further arguments:
- Print a short overview to stdout. Synopsis line + a list of the
most common subcommands with one-line descriptions + a pointer to
<command> --helpfor the full reference. - Do not print the full
--helpoutput. That's hundreds of lines for a tool likem; reserve it for explicit-h/--help. - Do not silently exit 0 with no output. The default argparse behavior in Python ≤ 3.6 is to do exactly this; it must be overridden.
- Exit code: 0 or 1 — pick one and apply consistently. The org
default is 0 (matching
gh/kubectl/docker/cargo/npm), on the grounds that the user did not make an error; they just invoked the command without picking a subcommand, and the overview is the documented response. - Apply the rule recursively. Every dispatcher level — root, group, sub-group — uses the same template. Inconsistency between levels is the most common smell in evolved CLIs.
When a user runs a leaf command like m fmt, m lint, m test with
no further arguments:
- Prefer a sensible default over a usage error when the default
is unambiguous and non-destructive.
m lintlinting cwd is the obvious default.m fmt --checkchecking cwd is the obvious default. This matchesfind . → findand is friendlier than an error. - When no sensible default exists, print a short usage line to
stderr and exit non-zero.
m run(which executes a routine) has no obvious default — error is correct. - Never mutate state silently as a default.
m fmt(without--check) rewriting cwd on bare invocation would be surprising and destructive; the safe default is--check-style read-only behavior unless the user opts in explicitly.
--helpand-hare equivalent and always supported.- Output goes to stdout (so users can
m fmt --help | less). - Exit code: 0. Help is not an error.
- Content is the full reference for that node. Synopsis, full flag list with descriptions, examples where relevant, list of subcommands if dispatcher.
- Print a short error to stderr identifying what was unknown.
- Point the user at
<command> --helpfor the valid set. - Exit non-zero — conventionally
2(matches POSIX getopt and most major CLIs). - Do not auto-suggest unless the suggestion algorithm is
high-quality (
gh's "did you mean...?" is good; many homegrown implementations are noisy).
- Every CLI supports
--version. Output is a single line:<name> <semver>(e.g.m 0.42.1). Optionally a second line with build/commit info. - Exit code: 0. Output to stdout.
| Output | Destination | Exit |
|---|---|---|
--help content |
stdout | 0 |
--version content |
stdout | 0 |
| Dispatcher overview (bare invocation) | stdout | 0 |
| Normal command output | stdout | 0 on success |
| Errors, usage hints, warnings | stderr | non-zero |
| Progress / log output (interactive) | stderr | (irrelevant; in-flight) |
This separation lets users pipe real output to other tools without
contamination: m capabilities --json | jq … must not mix help text
into stdout.
Standardize across all org CLIs:
| Code | Meaning |
|---|---|
| 0 | Success (or help requested) |
| 1 | General error (operation failed for a domain reason) |
| 2 | Usage error (unknown flag/subcommand, malformed args) |
| Other | Domain-specific. Document in the CLI's own reference. |
m lint --error-on=error exits non-zero when findings exceed the
threshold; that's a domain signal, separate from this taxonomy, and
the CLI's own docs spell out the codes.
- Different behavior at different dispatcher depths.
mprints help butm engineerrors out. Users build a mental model from the root level and expect it to hold; breaking it at depth is confusing. - Dumping full
--helpon bare invocation. Walls of text on accidental invocation. Argparse'sprint_help()is hundreds of lines for a tool likem. Use a short overview instead. - Silent no-op.
m engineprints nothing and exits 0. Users think the command is broken. This is the Python ≤ 3.6 argparse default and must be overridden. - Mutating state as a bare-invocation default.
m fmt(bare) rewriting cwd,git stash(bare) pushing a stash. Inspection-only defaults likegit remote(lists remotes) are fine; mutations are not. - Help to stderr, errors to stdout. Breaks
m foo --help | less; breaksm foo 2>/dev/nullfor error filtering. - Exit 0 on error. Breaks shell scripting (
m foo && next-stepfires even whenm foofailed). --helpthat differs from-h. Surprises users who expect them to be aliases.- Inconsistent help formatting across siblings. All subcommands of one dispatcher should use the same section headers (Usage, Options, Examples) in the same order.
Python's argparse is the dominant choice in this org (m-cli is
the primary user, and any new tier-2 CLI defaults to it). Two
specific configurations are needed to land the conventions above.
def _print_overview(parser: argparse.ArgumentParser) -> int:
# Short overview: synopsis + common subcommands + pointer to --help.
# NOT parser.print_help() — that dumps the full reference.
sys.stdout.write(
f"Usage: {parser.prog} <command> [options]\n\n"
"Common commands:\n"
" fmt Format M source\n"
" lint Lint M source\n"
" test Run tests\n"
" ...\n\n"
f"Run '{parser.prog} <command> --help' for more information.\n"
)
return 0
# In the dispatcher node:
parser.set_defaults(func=lambda args: _print_overview(parser))The set_defaults(func=…) pattern fires when no subcommand is
selected. Applied at every dispatcher level (root m, group m engine, group m doc, …), it gives the recursive consistency
described in §3.1.
subparsers = parser.add_subparsers(dest="command")
# Don't set required=True — argparse's error message is ugly.
# Handle the "no subcommand" case via set_defaults above, and
# handle "unknown subcommand" via argparse's built-in error path,
# which already exits 2.argparse's parser.error() writes to stderr and exits 2 —
correct. parser.print_help() writes to stdout — correct. Custom
overview functions must follow the same separation (§3.6).
Each CLI's test suite should pin the conventions for that CLI:
def test_bare_invocation_prints_overview():
result = run_cli([]) # no args
assert result.exit_code == 0
assert "Usage:" in result.stdout
assert "--help" in result.stdout
assert result.stderr == ""
def test_help_goes_to_stdout():
result = run_cli(["--help"])
assert result.exit_code == 0
assert len(result.stdout) > 0
assert result.stderr == ""
def test_unknown_subcommand_errors():
result = run_cli(["bogus-command"])
assert result.exit_code != 0
assert result.stdout == ""
assert "bogus-command" in result.stderrThese three tests are the minimum CLI-contract gate; recommend adding them to every tier-2 CLI's test suite.
- New CLI in a tier-2 repo — adopt these conventions from day one. Add the three contract tests (§5.4) to the repo's test suite. Reference this doc from the repo's CLAUDE.md / README.
- Existing CLI that diverges — file an issue noting the divergence and the cost of converging. Do not refactor opportunistically inside a feature PR; converge on its own PR with the contract tests added.
- Tool that intentionally diverges — record the reason in the repo's own documentation, linking back to this doc. Examples of legitimate divergence: a tool whose primary mode is a REPL (would enter REPL on bare invocation), a filter tool (would read stdin on bare invocation). The taxonomy in §1 already accommodates these.
- clig.dev — Command Line Interface Guidelines
- POSIX Utility Conventions
- GNU Coding Standards § CLI
- 12 Factor CLI Apps
- Reference implementations:
git,gh(GitHub CLI),kubectl,docker,cargo,npm.