|
| 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