S35: Argparse-based CLI parsing
Purpose: Replace mdd’s home-grown argument-parsing helpers and command-registry layer with a single argparse tree, eliminating several hundred lines of duplicated parsing and dispatch code.
Status: Implemented (2026-05-16)
Introduction
Section titled “Introduction”Most of the mdd command modules implement their own flag parser. Today there are three coexisting styles:
- argparse — already used by
convert. - A bespoke flag-spec mini-framework —
FlagSpec/consume_flags/_coerce_value/_require_valueliving insrc/mdd/commands/_options.py(~120 lines), paired with per-subcommand@dataclassopt holders. Currently consumed byai,gitlab, andsharepoint.confluence.pykeeps its own pre-extraction copy of the same framework inline. - Hand-rolled
while i < len(args):loops — surviving inlucid,search, andskills. Each module reimplements value validation, error messages, and help printing on its own.
Each module also maintains a hand-written _USAGE triple-quoted string and a separate USAGE: list[str] consumed by a custom _registry.py that exists primarily to drive mdd help’s section-grouped output. The result is a mdd help listing that drifts out of sync with the actual flags, plus a 121-line _options.py plus a duplicate copy of the same framework still pinned inside confluence.py.
argparse from the standard library covers all of this and is already proven in the codebase (convert.py). This spec leans into the rewrite: single top-level argparse tree, all subcommand trees flattened to two levels, custom registry removed, error messages and help text from argparse defaults. No backward-compatibility shims — mdd has no external users yet.
Requirements
Section titled “Requirements”- Every command module under
src/mdd/commands/is wired into one top-levelargparse.ArgumentParserbuilt insrc/mdd/cli.py. No hand-rolled flag loops remain. - The home-grown helpers are deleted:
src/mdd/commands/_options.pyin full, the duplicate_FlagSpec/_consume_flags/_coerce_value/_require_valueblock still embedded inconfluence.py, every per-opts@dataclassused purely for parsing, every_print_usagehelper, every_USAGEtriple-quoted string, and everyUSAGE: list[str]constant. src/mdd/commands/_registry.pyis removed; command discovery happens via an explicit import list incli.py. The section-groupedmdd helpoutput is replaced by argparse’s flatmdd --help.- Subcommand trees flatten to at most two levels.
mdd <command> <verb>-<noun>replaces every existingmdd <command> <verb> <noun>form. Concrete renames (gitlabandlucidare commands a private wrapper injects throughextra_commands, not part of this distribution; they are listed because the migration covered them and the shape rule applies to any wrapper’s commands too):mdd confluence sync space …→mdd confluence sync-space …mdd confluence export page …→mdd confluence export-page …mdd confluence create page …→mdd confluence create-page …mdd confluence update page …→mdd confluence update-page …mdd gitlab list repos→mdd gitlab list-reposmdd gitlab create repo …→mdd gitlab create-repo …mdd lucid list folders/mdd lucid list docs …→mdd lucid ls [PATH](single ls subcommand)mdd lucid sync folder …→mdd lucid sync-folder …mdd sharepoint list sites→mdd sharepoint list-sitesmdd sharepoint sync site …→mdd sharepoint sync-site …mdd sharepoint sync folder …→mdd sharepoint sync-folder …- Subcommands already flat (
mdd ai rewrite|index|review,mdd skills list|install|uninstall,mdd confluence whoami,mdd gitlab push|status) stay as they are.
- Deprecated/alias subcommands (e.g.
mdd sharepoint export site/export folderforwarders) are deleted rather than ported. No transitional aliases. mdd echosurvives as a smoke test (the only flat top-level subcommand with no real work to do).mdd <subcommand> --helpandmdd <subcommand> <sub-subcommand> --helpwork at every level (argparse default).mdd --help(andmddwith no args) produces the top-level subcommand listing. The grouping that_registry.by_section()provided today is dropped; the flat argparse listing is the canonical view.- A shared parent parser provides
--config <file>and--dry-runfor the subcommands that take them; ad-hoc redeclarations are removed. - Top-level logging flags (
-v,-vv,-vvv,--trace,--trace-bodies,--log-level=) keep working. They MAY move from a manual pre-pass to argparse global options if that simplifies the dispatcher — see Design Approach. - Argparse’s default error phrasing and exit code (
SystemExit(2)on parse failure) are accepted everywhere. No wrapping ofparser.error(). - Test coverage does not regress. Tests that asserted on the old
"Error: …"wording switch to substring checks on argparse’s phrasing or to exit-code-only assertions. - The change set lands incrementally, one heavy module per PR after an architecture-setup PR.
Design Approach
Section titled “Design Approach”One global argparse tree, built in cli.py
Section titled “One global argparse tree, built in cli.py”src/mdd/cli.py:build_parser() returns a single top-level ArgumentParser(prog="mdd"). It owns:
- The top-level logging flags as real argparse arguments on the root parser.
- A
subparsers = root.add_subparsers(dest="command", required=True)factory. - An explicit list of
from mdd.commands import echo, convert, …imports, each followed bymodule.register(subparsers, parents=COMMON_PARENTS).
Each command module exposes:
def register(subparsers, parents) -> None: parser = subparsers.add_parser("name", parents=[parents.common], help="…", description="…") sub = parser.add_subparsers(dest="subcommand", required=True) # if any p1 = sub.add_parser("verb-noun", parents=[parents.config_required], help="…") p1.add_argument(...) p1.set_defaults(func=_run_verb_noun) ...No per-module cmd_<name>(args: list[str]) -> int entry point; routing is ns.func(ns).
Shared parent parsers
Section titled “Shared parent parsers”cli.py constructs one or two parent ArgumentParser(add_help=False) instances and passes them to each module via a small CommonParents struct (or simply a tuple of two parsers). Examples:
common— only--verbosereporting flags if any survive the logging-flag relocation; else empty / unused.config_required— adds--config <file>withtype=Path,default=None.dry_run— adds--dry-run(action="store_true").
Subparsers that need both compose them: parents=[config_required, dry_run].
If a parent parser saves fewer than two declarations per consumer (one declaration in two subparsers), keep flags inline — parents that don’t pay for themselves are noise.
_registry.py is removed
Section titled “_registry.py is removed”The registry exists today only to (1) auto-discover command modules and (2) drive mdd help section ordering. Argparse handles discovery via the explicit import list in cli.py; section ordering goes away with mdd help. Net deletion: ~70 lines plus the corresponding register(...) boilerplate in every command module.
mdd help either becomes a thin wrapper that prints root_parser.format_help() and exits, or is deleted in favour of mdd --help. Preference: keep the mdd help subcommand as a synonym for mdd --help because shell users type it more often, but back it with the same code path.
Each subcommand handler signature
Section titled “Each subcommand handler signature”class _SyncSpaceArgs(argparse.Namespace): config: Path | None space_key: str dry_run: bool
def _run_sync_space(ns: argparse.Namespace) -> int: args = cast(_SyncSpaceArgs, ns) space_key = args.space_key dry_run = args.dry_run ...See the typed-Namespace adapter convention below for the rationale; the convention was added as a 2026-05 amendment to the original S35 rollout (which used value: T = ns.attr # pyright: ignore[reportAny] typed locals).
Top-level logging flags
Section titled “Top-level logging flags”Two options:
- A (preferred): declare
-v/-vv/-vvv/--trace/--trace-bodies/--log-level=as real argparse arguments on the root parser. Argparse handles them in normalparse_args()and the subcommand handler can readns.log_level. Pre-pass is gone. - B (fallback): keep
_extract_log_flags()as today. Use this only if a real argparse conflict surfaces (e.g. a subcommand also wants-vfor something).
Default: A. If a conflict surfaces while migrating a specific module, fall back to B and note it in the migration plan.
Per-command test files (tests/commands/test_*.py) stay where they are. They now exercise the global parser via mdd.cli.main([...]) rather than cmd_<name>([...]). A small helper parse_and_run(argv: list[str]) -> tuple[int, str, str] returning (exit_code, stdout, stderr) can live in tests/_argparse_helpers.py if it removes repeated capsys boilerplate; otherwise leave per-test.
CI scripts and tooling
Section titled “CI scripts and tooling”The few internal scripts and mise.toml tasks that shell out to mdd <subcommand> need a sweep for the flattened names. The migration plan calls out specific paths to grep. Examples in agent skills under .claude/skills/ MUST be updated to use the new flat names.
Implementation Notes
Section titled “Implementation Notes”- PR 1 lands the architecture (global parser, parents, registry deletion) alongside the trivial modules; subsequent PRs migrate one heavy module each, smallest-to-largest, so the recipe is hardened before it meets
confluence.py. convert.pyalready uses argparse but with its ownbuild_parser()returning a standaloneArgumentParser. PR 1 also rewiresconvertinto the global tree viaregister(subparsers, parents).- The
confluence.pymigration deletes both its embedded copy of the framework and the shared_options.py, and lands last so the recipe is exercised on smaller modules first. - Residual
# noqa: C901,PLR09xxmarkers remain inlucid.py,search.py, andskills.pyfrom before the S34 hot-file pass. The argparse migration is expected to clear most of them incidentally;RUF100will flag any that survive a sufficiently flat handler. - No new third-party dependency is introduced;
argparseis in the standard library.
Typed Namespace adapter (2026-05 amendment)
Section titled “Typed Namespace adapter (2026-05 amendment)”The original S35 rollout used value: T = ns.attr # pyright: ignore[reportAny] typed locals at the top of every _run_* handler. That pattern shipped 132 # pyright: ignore[reportAny] suppressions across src/mdd/commands/. This amendment replaces the convention with a per-handler argparse.Namespace subclass + typing.cast, which is the stdlib-documented pattern for getting strict-typed handler bodies without leaving vanilla argparse.
Convention
Section titled “Convention”Each _run_<name> handler is paired with a private _<Name>Args(argparse.Namespace) subclass declared in the same module. The subclass declares one annotated attribute per add_argument call that the handler consumes, with the same dest name and the same type the argparse type= argument coerces to. The handler casts once at the top and reads only typed attributes thereafter.
class _ExportPageArgs(argparse.Namespace): config: Path | None output: Path | None page_ref: str include_export_header: bool skip_attachments: bool
def _run_export_page(ns: argparse.Namespace) -> int: args = cast(_ExportPageArgs, ns) output_dir = args.output if args.output is not None else Path() page_ref = args.page_ref include_header = args.include_export_header skip_attachments = args.skip_attachments ...Rules:
- The Namespace subclass MUST live next to its
_run_*handler — same module, named_<Verb><Noun>Args. No central namespaces module. - The subclass MUST list every
destthe handler reads, and only thosedests. Root logging flags (verbose,trace,log_level, etc.) and_root_parserare NOT declared on per-command subclasses; if a handler reads them, declare them locally too. Reading root state from_run_*handlers is rare and discouraged outsidecli.py. - The cast happens once at the top of the handler.
nsis not read directly anywhere else in the body. - The subclass MUST NOT carry a
__slots__,__init__, or methods. It is a pure typing artefact — argparse populates the instance. - No
# pyright: ignore[reportAny]may remain on lines covered byargs.<attr>reads after the convention is applied. - The
set_defaults(func=_run_<name>)line stays unchanged. The Namespace subclass is never passed toparse_args; the cast happens at handler entry.
Why cast and not parse_args(namespace=...)
Section titled “Why cast and not parse_args(namespace=...)”argparse only accepts one Namespace at parse_args() time. With subparser dispatch (the S35 design), the top-level parser does not know which subcommand will be selected until parsing completes. Passing a typed Namespace at parse time would require building a union Namespace that includes every subcommand’s fields, defeating the purpose. cast at handler entry is the canonical workaround and is documented as such by the basedpyright community.
Trade-offs (recorded for posterity)
Section titled “Trade-offs (recorded for posterity)”- Cast is an unchecked assertion. Pyright trusts the cast; runtime trusts argparse to populate the attrs. The only way the assertion is wrong is a programmer error where the handler reads an attr the parser doesn’t add. Tests already exercise each
_run_*end-to-end viamdd.cli.main([...]); misalignments surface asAttributeErrorin tests, not silently. - Field declarations are duplicated next to
add_argument. The Namespace subclass restates eachdestand type. This duplication is the price of vanilla argparse + strict typing. Alternatives evaluated (dataclassfrom_ns,getarghelper,pydantic-settings CliApp,cyclopts,tyro,Tap) either kept the duplication, introduced framework churn, or regressed--helpoutput quality. The full evaluation is in P03 session D’s DONE stanza. - One Namespace subclass per
_run_*handler. ~30 small classes across 16 modules. They are short (5–15 lines each) and live next to the handler that owns them. - Root flags stay typed in
cli.py.cli.py:_resolve_log_levelandcli.py:_apply_logginguse the same cast pattern with a_RootArgs(argparse.Namespace)subclass.
Related upstream specs
Section titled “Related upstream specs”- 000-specs — shared conventions
- S02 — mdd CLI Tool — the dispatcher this spec largely replaces
- S34 — Code Quality Gates — the structural-violation gate that this refactor incidentally helps satisfy
Open questions
Section titled “Open questions”(All four original open questions have been resolved by the design decisions above. This section is left as a placeholder in case implementation surfaces new ones.)
Out of scope
Section titled “Out of scope”- Backward compatibility shims. The CLI shape shifts: subcommand renames, argparse-default error wording,
SystemExit(2)on parse failure, no transitional aliases formdd confluence sync spaceand friends.mddhas no external users yet; this is the moment to standardise. - A click / typer / fire migration. The trade-off (third-party dep, doctest-style help vs. familiar argparse output, type-driven decorators) is not worth it for this codebase;
convert.pyis already proof that argparse fits. - Section grouping in
mdd --help. The custom_registry.by_section()output is dropped; the flat argparse listing is the canonical view. A customHelpFormattercould restore grouping later if anyone misses it. - Renaming entire commands (e.g.
mdd confluence-syncas a top-level verb instead ofmdd confluence sync-space). Only the verb-noun flattening is in scope; raise a separate spec for further reshuffles.
Site built 2026-07-30.