Skip to content

Commit 4f52891

Browse files
rafael5claude
andcommitted
docs(dev-practices): add CLI UX conventions guide
Canonical reference for CLI ergonomics across m-dev-tools: bare-invocation behavior at every depth, --help / exit-code rules, dispatcher-vs-leaf taxonomy, anti-patterns, argparse implementation pattern. Grounded in a survey of git/gh/kubectl/docker/POSIX/GNU/clig.dev. Linked from the dev-practices README index. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3e0e2b1 commit 4f52891

2 files changed

Lines changed: 384 additions & 0 deletions

File tree

docs/dev-practices/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ need concurrent sessions on the same repo.
103103
- **[`parallel-multi-repo-git-hygiene.md`](parallel-multi-repo-git-hygiene.md)**
104104
— full rules with rationale, three-tier model, filesystem
105105
shared-state inventory, guardrails, diagnostic recipes.
106+
- **[`cli-ux-conventions-guide.md`](cli-ux-conventions-guide.md)**
107+
— canonical CLI ergonomics for every tool in the org: bare-invocation
108+
behavior at root and at every subcommand depth, `--help` and exit-code
109+
conventions, dispatcher-vs-leaf taxonomy, anti-patterns, Python
110+
argparse implementation pattern. Survey of `git` / `gh` / `kubectl` /
111+
`docker` / POSIX / GNU / clig.dev.
106112

107113
---
108114

Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
1+
---
2+
created: 2026-05-11
3+
last_modified: 2026-05-11
4+
revisions: 1
5+
doc_type: [RESEARCH, REFERENCE, GUIDE]
6+
lifecycle: active
7+
owner: rmrich5
8+
title: "CLI UX conventions for m-dev-tools"
9+
---
10+
11+
# CLI UX conventions for m-dev-tools
12+
13+
> *How every command-line tool in the m-dev-tools org should behave at
14+
> every level — root, subcommand group, leaf — when invoked with no
15+
> arguments, with `--help`, and on error. Grounded in a survey of the
16+
> Unix and modern-CLI ecosystem.*
17+
18+
This document is the canonical reference for CLI ergonomics across the
19+
org. It applies to `m-cli` today (`m`, `m engine`, `m doc`, `m ci`, `m
20+
fmt`, `m lint`, `m test`, …) and to any future tool that ships a
21+
command-line entry point from any tier-2 repo (`m-tools`, `m-stdlib`'s
22+
helper scripts, `m-test-engine` CLIs, etc.). Tools that diverge from
23+
this guide need an explicit reason recorded in their own repo's
24+
documentation.
25+
26+
## TL;DR
27+
28+
1. **Bare invocation of a dispatcher prints a short overview**
29+
(synopsis + common subcommands + pointer to `--help`), not a wall
30+
of help and not nothing. Apply the rule *recursively*`m` and `m
31+
engine` behave the same way.
32+
2. **`-h` / `--help` is the only way to get the full reference**, goes
33+
to **stdout**, exits **0**.
34+
3. **Errors (unknown subcommand, missing required arg) print a short
35+
usage line to stderr**, exit non-zero, and point the user at
36+
`--help`.
37+
4. **Leaf commands** (`m fmt`, `m lint`) either operate on a sensible
38+
default (cwd, stdin) or print a short usage error — pick one rule
39+
per tool and apply it consistently across leaves.
40+
5. **Side-effectful actions never run as the default for a bare
41+
dispatcher invocation.** Listing / inspection defaults are fine
42+
(`git remote`); mutations are not.
43+
44+
The detailed rationale, the survey behind these rules, and the
45+
Python-argparse implementation pattern follow.
46+
47+
---
48+
49+
## 1. The taxonomy: dispatcher vs leaf
50+
51+
Every node in a CLI's command tree is one of two shapes:
52+
53+
- **Dispatcher** — a node whose only job is to route to children. Has
54+
no useful work to do on its own. Examples: `m` (root), `m engine`,
55+
`m doc`, `m ci`, `git`, `git stash` (in some configurations), `gh
56+
pr`, `kubectl config`, `docker image`.
57+
58+
- **Leaf** — a node that actually performs work. Takes its own flags
59+
and positional args; may consume input from stdin or the cwd.
60+
Examples: `m fmt`, `m lint`, `m test`, `git commit`, `gh pr create`,
61+
`kubectl apply`, `docker run`.
62+
63+
The bare-invocation rule depends on which shape the node is:
64+
65+
| Shape | Bare invocation behavior |
66+
|---|---|
67+
| Dispatcher | Print short overview of children + pointer to `--help`. Exit 0 or 1 (pick one, stay consistent). |
68+
| Leaf with sensible default | Run with the default (e.g. `m lint` lints cwd; `cat` reads stdin). Exit 0 on success. |
69+
| Leaf with required args | Print short usage to stderr + pointer to `--help`. Exit non-zero. |
70+
71+
A node is a *dispatcher* if and only if it has child subcommands and
72+
no useful standalone behavior. Adding children to a former leaf
73+
upgrades it to a dispatcher; the bare-invocation rule changes
74+
accordingly.
75+
76+
---
77+
78+
## 2. Survey: how the popular CLIs actually behave
79+
80+
### Bare-invocation behavior of the root command
81+
82+
| Tool | Bare `tool` behavior | Exit |
83+
|---|---|---|
84+
| `git` | Short usage + common commands list to stderr | 1 |
85+
| `gh` | Help overview to stdout | 0 |
86+
| `kubectl` | Help overview to stdout | 0 |
87+
| `docker` | Help overview to stdout | 0 |
88+
| `cargo` | Help overview to stdout | 0 |
89+
| `aws` | Usage error to stderr, hint to use `aws help` | 252 |
90+
| `npm` | Help overview to stdout | 0 |
91+
| `cp`, `mv`, `ln` | `missing file operand` + short usage to stderr | 1 |
92+
| `grep` | Short usage to stderr | 2 |
93+
| `ssh` | Short usage to stderr | 255 |
94+
| `curl` | `try 'curl --help'` hint to stderr | 2 |
95+
| `tar` | Short usage + hint to `--help` to stderr | 2 |
96+
| `find` (GNU) | Implicit `find .` — defaults to cwd | 0 |
97+
| `cat`, `sort`, `wc`, `tr`, `sed`, `awk` | Read stdin (filter mode) | 0 |
98+
| `python`, `node`, `psql`, `redis-cli`, `sqlite3`, `gdb` | Enter REPL | 0 |
99+
100+
Three families emerge:
101+
102+
1. **Dispatchers** (`git`, `gh`, `kubectl`, `docker`, `cargo`, `npm`,
103+
`aws`) print an overview or a usage error.
104+
2. **Leaf tools with required args** (`cp`, `mv`, `grep`, `ssh`,
105+
`curl`, `tar`) print a short usage to stderr and exit non-zero.
106+
3. **Filter / REPL tools** (`cat`, `sort`, `python`) do something
107+
useful — read stdin or enter interactive mode.
108+
109+
The dispatcher family is what `m` and every `m <group>` belongs to.
110+
The relevant peers are `git`, `gh`, `kubectl`, `docker`, `cargo`.
111+
112+
### Bare-invocation behavior of subcommand groups
113+
114+
| Tool | Bare `tool group` behavior |
115+
|---|---|
116+
| `git remote` | Lists remotes (leaf with inspection default) |
117+
| `git stash` | Runs `git stash push` (leaf with mutating default — controversial) |
118+
| `gh pr` | Help overview for `pr` subcommands |
119+
| `gh repo` | Help overview for `repo` subcommands |
120+
| `kubectl config` | Help overview for `config` subcommands |
121+
| `docker image` | Help overview for `image` subcommands |
122+
| `aws s3` | Usage error + available commands |
123+
124+
The modern consensus (`gh`, `kubectl`, `docker`) is: **dispatcher
125+
groups behave like the root command** — overview of their children,
126+
recursively. `git`'s mixed behavior reflects 20 years of organic
127+
growth and is not the model to copy.
128+
129+
### Best-practice references consulted
130+
131+
- [**clig.dev** — Command Line Interface Guidelines](https://clig.dev/)
132+
("Display help text when passed no options, the `-h` flag, or the
133+
`--help` flag.")
134+
- [**POSIX Utility Conventions**](https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html)
135+
(exit-code semantics, argument parsing).
136+
- [**GNU Coding Standards § Command-Line Interfaces**](https://www.gnu.org/prep/standards/standards.html#Command_002dLine-Interfaces)
137+
(long-option naming, `--help` and `--version` conventions).
138+
- [**12 Factor CLI Apps** (Jeff Dickey)](https://medium.com/@jdxcode/12-factor-cli-apps-dd3c227a0e46)
139+
(UX patterns for modern multi-command CLIs).
140+
- Reference implementations: `git`, `gh`, `kubectl`, `docker`,
141+
`cargo`, `npm`.
142+
143+
---
144+
145+
## 3. Canonical rules for m-dev-tools CLIs
146+
147+
The rules below apply to every CLI shipped from this org. They are
148+
written for `m-cli` but extend to any tier-2 repo that ships a binary.
149+
150+
### 3.1 Bare invocation of a dispatcher
151+
152+
When a user runs `m`, `m engine`, `m doc`, `m ci`, or any future
153+
dispatcher group with no further arguments:
154+
155+
- **Print a short overview to stdout.** Synopsis line + a list of the
156+
most common subcommands with one-line descriptions + a pointer to
157+
`<command> --help` for the full reference.
158+
- **Do not print the full `--help` output.** That's hundreds of lines
159+
for a tool like `m`; reserve it for explicit `-h`/`--help`.
160+
- **Do not silently exit 0 with no output.** The default argparse
161+
behavior in Python ≤ 3.6 is to do exactly this; it must be
162+
overridden.
163+
- **Exit code: 0 or 1 — pick one and apply consistently.** The org
164+
default is **0** (matching `gh`/`kubectl`/`docker`/`cargo`/`npm`),
165+
on the grounds that the user did not make an error; they just
166+
invoked the command without picking a subcommand, and the overview
167+
is the documented response.
168+
- **Apply the rule recursively.** Every dispatcher level — root,
169+
group, sub-group — uses the same template. Inconsistency between
170+
levels is the most common smell in evolved CLIs.
171+
172+
### 3.2 Bare invocation of a leaf
173+
174+
When a user runs a leaf command like `m fmt`, `m lint`, `m test` with
175+
no further arguments:
176+
177+
- **Prefer a sensible default over a usage error** when the default
178+
is unambiguous and non-destructive. `m lint` linting cwd is the
179+
obvious default. `m fmt --check` checking cwd is the obvious
180+
default. This matches `find . → find` and is friendlier than an
181+
error.
182+
- **When no sensible default exists**, print a short usage line to
183+
stderr and exit non-zero. `m run` (which executes a routine) has no
184+
obvious default — error is correct.
185+
- **Never mutate state silently as a default.** `m fmt` (without
186+
`--check`) rewriting cwd on bare invocation would be surprising and
187+
destructive; the safe default is `--check`-style read-only behavior
188+
unless the user opts in explicitly.
189+
190+
### 3.3 `--help` and `-h`
191+
192+
- **`--help` and `-h` are equivalent** and always supported.
193+
- **Output goes to stdout** (so users can `m fmt --help | less`).
194+
- **Exit code: 0.** Help is not an error.
195+
- **Content is the full reference for that node.** Synopsis, full
196+
flag list with descriptions, examples where relevant, list of
197+
subcommands if dispatcher.
198+
199+
### 3.4 Unknown subcommand / unknown flag
200+
201+
- **Print a short error to stderr** identifying what was unknown.
202+
- **Point the user at `<command> --help`** for the valid set.
203+
- **Exit non-zero** — conventionally `2` (matches POSIX getopt and
204+
most major CLIs).
205+
- **Do not auto-suggest** unless the suggestion algorithm is
206+
high-quality (`gh`'s "did you mean...?" is good; many homegrown
207+
implementations are noisy).
208+
209+
### 3.5 `--version`
210+
211+
- **Every CLI supports `--version`.** Output is a single line:
212+
`<name> <semver>` (e.g. `m 0.42.1`). Optionally a second line with
213+
build/commit info.
214+
- **Exit code: 0.** Output to stdout.
215+
216+
### 3.6 Output destination summary
217+
218+
| Output | Destination | Exit |
219+
|---|---|---|
220+
| `--help` content | stdout | 0 |
221+
| `--version` content | stdout | 0 |
222+
| Dispatcher overview (bare invocation) | stdout | 0 |
223+
| Normal command output | stdout | 0 on success |
224+
| Errors, usage hints, warnings | stderr | non-zero |
225+
| Progress / log output (interactive) | stderr | (irrelevant; in-flight) |
226+
227+
This separation lets users pipe real output to other tools without
228+
contamination: `m capabilities --json | jq …` must not mix help text
229+
into stdout.
230+
231+
### 3.7 Exit-code vocabulary
232+
233+
Standardize across all org CLIs:
234+
235+
| Code | Meaning |
236+
|---|---|
237+
| 0 | Success (or help requested) |
238+
| 1 | General error (operation failed for a domain reason) |
239+
| 2 | Usage error (unknown flag/subcommand, malformed args) |
240+
| Other | Domain-specific. Document in the CLI's own reference. |
241+
242+
`m lint --error-on=error` exits non-zero when findings exceed the
243+
threshold; that's a domain signal, separate from this taxonomy, and
244+
the CLI's own docs spell out the codes.
245+
246+
---
247+
248+
## 4. Anti-patterns to avoid
249+
250+
1. **Different behavior at different dispatcher depths.** `m` prints
251+
help but `m engine` errors out. Users build a mental model from
252+
the root level and expect it to hold; breaking it at depth is
253+
confusing.
254+
2. **Dumping full `--help` on bare invocation.** Walls of text on
255+
accidental invocation. Argparse's `print_help()` is hundreds of
256+
lines for a tool like `m`. Use a short overview instead.
257+
3. **Silent no-op.** `m engine` prints nothing and exits 0. Users
258+
think the command is broken. This is the Python ≤ 3.6 argparse
259+
default and must be overridden.
260+
4. **Mutating state as a bare-invocation default.** `m fmt` (bare)
261+
rewriting cwd, `git stash` (bare) pushing a stash. Inspection-only
262+
defaults like `git remote` (lists remotes) are fine; mutations are
263+
not.
264+
5. **Help to stderr, errors to stdout.** Breaks `m foo --help |
265+
less`; breaks `m foo 2>/dev/null` for error filtering.
266+
6. **Exit 0 on error.** Breaks shell scripting (`m foo && next-step`
267+
fires even when `m foo` failed).
268+
7. **`--help` that differs from `-h`.** Surprises users who expect
269+
them to be aliases.
270+
8. **Inconsistent help formatting across siblings.** All subcommands
271+
of one dispatcher should use the same section headers (Usage,
272+
Options, Examples) in the same order.
273+
274+
---
275+
276+
## 5. Implementation pattern (Python / argparse)
277+
278+
Python's `argparse` is the dominant choice in this org (`m-cli` is
279+
the primary user, and any new tier-2 CLI defaults to it). Two
280+
specific configurations are needed to land the conventions above.
281+
282+
### 5.1 Dispatcher overview on bare invocation
283+
284+
```python
285+
def _print_overview(parser: argparse.ArgumentParser) -> int:
286+
# Short overview: synopsis + common subcommands + pointer to --help.
287+
# NOT parser.print_help() — that dumps the full reference.
288+
sys.stdout.write(
289+
f"Usage: {parser.prog} <command> [options]\n\n"
290+
"Common commands:\n"
291+
" fmt Format M source\n"
292+
" lint Lint M source\n"
293+
" test Run tests\n"
294+
" ...\n\n"
295+
f"Run '{parser.prog} <command> --help' for more information.\n"
296+
)
297+
return 0
298+
299+
# In the dispatcher node:
300+
parser.set_defaults(func=lambda args: _print_overview(parser))
301+
```
302+
303+
The `set_defaults(func=…)` pattern fires when no subcommand is
304+
selected. Applied at every dispatcher level (root `m`, group `m
305+
engine`, group `m doc`, …), it gives the recursive consistency
306+
described in §3.1.
307+
308+
### 5.2 Unknown subcommand → exit 2
309+
310+
```python
311+
subparsers = parser.add_subparsers(dest="command")
312+
# Don't set required=True — argparse's error message is ugly.
313+
# Handle the "no subcommand" case via set_defaults above, and
314+
# handle "unknown subcommand" via argparse's built-in error path,
315+
# which already exits 2.
316+
```
317+
318+
### 5.3 Stdout vs stderr
319+
320+
`argparse`'s `parser.error()` writes to stderr and exits 2 —
321+
correct. `parser.print_help()` writes to stdout — correct. Custom
322+
overview functions must follow the same separation (§3.6).
323+
324+
### 5.4 Testing the contract
325+
326+
Each CLI's test suite should pin the conventions for that CLI:
327+
328+
```python
329+
def test_bare_invocation_prints_overview():
330+
result = run_cli([]) # no args
331+
assert result.exit_code == 0
332+
assert "Usage:" in result.stdout
333+
assert "--help" in result.stdout
334+
assert result.stderr == ""
335+
336+
def test_help_goes_to_stdout():
337+
result = run_cli(["--help"])
338+
assert result.exit_code == 0
339+
assert len(result.stdout) > 0
340+
assert result.stderr == ""
341+
342+
def test_unknown_subcommand_errors():
343+
result = run_cli(["bogus-command"])
344+
assert result.exit_code != 0
345+
assert result.stdout == ""
346+
assert "bogus-command" in result.stderr
347+
```
348+
349+
These three tests are the minimum CLI-contract gate; recommend
350+
adding them to every tier-2 CLI's test suite.
351+
352+
---
353+
354+
## 6. Applying this guide
355+
356+
- **New CLI in a tier-2 repo** — adopt these conventions from day
357+
one. Add the three contract tests (§5.4) to the repo's test
358+
suite. Reference this doc from the repo's CLAUDE.md / README.
359+
- **Existing CLI that diverges** — file an issue noting the
360+
divergence and the cost of converging. Do not refactor opportunistically
361+
inside a feature PR; converge on its own PR with the contract tests
362+
added.
363+
- **Tool that intentionally diverges** — record the reason in the
364+
repo's own documentation, linking back to this doc. Examples of
365+
legitimate divergence: a tool whose primary mode is a REPL (would
366+
enter REPL on bare invocation), a filter tool (would read stdin on
367+
bare invocation). The taxonomy in §1 already accommodates these.
368+
369+
---
370+
371+
## 7. References
372+
373+
- [clig.dev — Command Line Interface Guidelines](https://clig.dev/)
374+
- [POSIX Utility Conventions](https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html)
375+
- [GNU Coding Standards § CLI](https://www.gnu.org/prep/standards/standards.html#Command_002dLine-Interfaces)
376+
- [12 Factor CLI Apps](https://medium.com/@jdxcode/12-factor-cli-apps-dd3c227a0e46)
377+
- Reference implementations: `git`, `gh` (GitHub CLI), `kubectl`,
378+
`docker`, `cargo`, `npm`.

0 commit comments

Comments
 (0)