cli: faithful argparse port (single flag table, HelpFormatter, textwrap, abbreviations, usage on error)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 07:02:03 +01:00
co-authored by Claude Sonnet 5.5
parent f44e505b0f
commit c8f6818997
16 changed files with 1268 additions and 139 deletions
+66
View File
@@ -0,0 +1,66 @@
# cli: the argument parser is a port of argparse
Goal: `detz` parses its command line exactly like `../tai.py` (`build_parser()` run by the
Python installed here, 3.14). This document is the spec; it states what the code does today and
lists the gaps at the end.
## Layout (one class per `.h`/`.cpp` pair, `src/cli/`)
| class | file | role |
|---|---|---|
| `FlagTable` / `FlagSpec` | `flag_spec.*` | the single flag table: option strings, dest, arity, choices, metavar, help. Usage, help and the parser all read it, so they cannot drift |
| `TextWrap` | `text_wrap.*` | port of Python `textwrap.wrap` (hyphen breaking, long-word breaking) |
| `HelpFormatter` | `help_formatter.*` | port of `argparse.HelpFormatter` (3.14): usage wrapping, help columns |
| `ArgParser` | `arg_parser.*` | port of `ArgumentParser._parse_known_args` (pattern string of `A`/`O`/`-`, abbreviations, explicit values, `--`) |
| `Parser`, `Command`, `DetzCli`, `Options` | `cli.*` | stable API others compile against; maps `ArgParser` output onto `Options` |
`Options`, `Parser::parse/parseOpts/helpText`, `Command::run` keep their names and fields.
## Behavior (mirrors argparse, each point has a differential case)
- Unique prefixes of long options resolve (`--vers` is `--version`); an ambiguous prefix fails
with `ambiguous option: --ins could match --install, --install-telegram` (matches listed in
table order). `--tui-` resolves to `--tui-local` because `--tui` does not start with it.
- `--flag=value` for value flags; an explicit value on a switch fails with
`argument --yes: ignored explicit argument '1'`. Single-dash clusters (`-hx`) follow argparse.
- A flag that needs a value and sees an option-like word, `--`, or the end fails with
`argument --profile: expected one argument`. `-1` and `-.5` (anything matching `-\.?\d`) are
values, not options. A word with a space in it is a value.
- `--debug` takes an optional value (`file` when bare), checked against its choices.
- `--cap` and `--mplex` take every remaining word (argparse `REMAINDER`), options included.
- `--` ends options; the first `--` is dropped from the prompt, later ones are kept.
- `type=int` accepts what Python `int()` accepts for ASCII input (surrounding whitespace, sign,
single underscores between digits); anything else is `invalid int value: 'abc'`.
- `-h/--help` acts the moment it is reached, before later words are examined, so
`-h --bogus` prints help (exit 0) while `--cap-permission-mode zzz -h` is an error (exit 2).
`Options` then carries only `help = true`.
- Any parse error: full usage text on stderr, then `tai: error: <message>`, exit 2.
`Options::error` holds both (usage then the error line, no trailing newline).
- Terminal width: `COLUMNS` (if a positive integer), else the width of stdout's tty, else 80;
the formatter uses width - 2, like `shutil.get_terminal_size`.
- Flags that exist only in the old C++ parser (`--model`, `--driver`, `--task`, `--cwd`,
`--argv`, `--binary`, `--max-risk`, `--auto-answer`, `--allow-tool`, `--deny-tool`) are not in
`tai.py` and are rejected as unrecognized, like tai.py does. The `Options` fields stay so
other modules compile.
## Tests
Run all with `make -f tests/cli.mk test` (everything lands in `build/cli`).
- `tests/cli_test.cpp`: unit cases (table, abbreviations, errors, REMAINDER, `--`, int, usage, wrap).
- `tests/cli_parity.py` + `tests/cli_cases.txt`: every argv line of the list at COLUMNS 80 and 40,
`--help` and the usage printed by an error at every width 12..200, and 1500 seeded random argvs,
run through `build_parser()` of `../tai.py` (in process, the installed Python) and through
`build/cli/cli_driver`; stdout, stderr and exit code must be equal (2080 jobs, 0 differences).
- `make -f tests/cli.mk diff`: the generated cases of `tools/parity_diff.py` (`cli.*`,
`tests/diff/cases/cli_generated.json`) against `build/cli/cli_main`, a cli-only entry point that
mirrors the first lines of `main.cpp`.
## Gaps
- Colored help (Python 3.14 colors help when `FORCE_COLOR` is set or stdout is a tty with color):
not implemented, plain text only.
- Widths count bytes, not code points (all help text is ASCII; user words only appear in error
messages, which are not wrapped).
- The `--cap-manifest` text and every action after parsing live in `main.cpp`, not here: case
`cli.cap_manifest` fails against `cli_main` for that reason (55 of 56 `cli.*` cases pass).