DEV Community

Avery Lin
Avery Lin

Posted on

Compile a CLI Flag Ledger From argparse; Hand-Write Exit Codes and Destructive Ops

CLI help text is a parser dump, not a support contract, because it never records exit codes or irreversible side effects. A useful documentation pipeline therefore compiles a flag ledger from argparse or from an equivalent parser tree. It refuses to publish until a human signs the cells a model must not invent. Generated rows may include names, types, defaults, and the help strings already present in source.

Signed rows must include examples, exit-code meaning, destructive warnings, and the supported version window. Models can draft surrounding prose from those signed facts, but they cannot originate destructive-op language. The rest of this article specifies a reproducible extract-overlay-gate workflow, including sample code a docs build can run.

Why --help is not the published page

--help enumerates flags that the parser already knows, which is necessary and still insufficient for operators. It does not state which non-zero exits mean usage error, backend failure, or partial apply. It does not mark which flags delete data, rotate credentials, or mutate production in place. Those omissions are documentation bugs even when the help strings themselves are accurate and reviewed.

Treat the --help output as a compile input rather than as the published operator page. The published page is a join of generated parser facts and a human overlay file. Missing overlay keys fail the docs job, the same way missing tests fail CI. That failure mode is the entire point of splitting compile facts from signed contracts.

This split also limits what a draft model is allowed to touch during documentation generation. Parser-derived fields are mechanical and should be regenerated on every commit without editorial rewrite. Contract fields are judgments about operators, data loss, and support windows, so they stay outside generated JSON. Mixing those lanes is how CLI pages quietly invent exit codes that the binary never implemented.

Two lanes for every flag row

Keep a hard boundary between fields a script may emit and fields a reviewer must sign. If a field can be proven from argparse.ArgumentParser after parse_args setup, it belongs in the compile lane. If a field would still be wrong after a perfect parser dump, it belongs in the signature lane. The table below is the contract for this workflow, not a product feature list.

Lane Field Allowed source Forbidden source
Compile command_path subparser names marketing aliases
Compile flag option_strings guessed short flags
Compile dest, type_name, required Action attributes comments in chat
Compile default_repr action.default when not SUPPRESS production secrets
Compile help action.help paraphrased slogans
Signature examples reviewed, redacted invocations model-invented hostnames
Signature exit_codes the CLI process contract HTTP status folklore
Signature destructive human classification inferred from flag names
Signature support_window release owners generated changelogs

Name collisions across subcommands are compile bugs, not overlay bugs, and the extractor must fail closed. Signature cells may stay empty during a prototype, but emptiness must be an explicit null with a failing gate, not a missing key. Reviewers then fill examples and exit codes in the overlay file, never by editing generated Markdown. That ordering keeps diffs small when flags move and keeps blame on the humans who own irreversible operations.

Worked artifact: extract, overlay, then gate

The following Python is a labeled worked example, not a production CLI framework. It assumes one parser object is built by build_parser() and that docs CI can import that module without executing network calls. Dynamic flags registered only after reading remote config are out of scope and are listed later as a limitation. Run the extractor on a clean tree so default values cannot leak local machine paths.

Sample parser under documentation

# cli_app.py — illustrative parser only
import argparse


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="orders")
    sub = parser.add_subparsers(dest="command", required=True)

    export = sub.add_parser("export", help="Write orders to a file.")
    export.add_argument("--format", choices=("csv", "json"), default="csv")
    export.add_argument("--out", required=True, help="Destination path.")

    purge = sub.add_parser("purge", help="Delete orders matching a filter.")
    purge.add_argument("--older-than-days", type=int, required=True)
    purge.add_argument("--apply", action="store_true", help="Actually delete.")
    return parser
Enter fullscreen mode Exit fullscreen mode

Step 1 — Compile a deterministic flag ledger

Walk each subparser and each Action, then emit JSON sorted by command path and flag. Do not pretty-print help into Markdown in this step, because Markdown invites hand edits that drift from source. Keep SUPPRESS defaults as a typed null so later joins can distinguish “no default” from an empty string. Stable keying matters more than complete typing of custom type= callables.

# tools/extract_cli_ledger.py — worked example
from __future__ import annotations

import argparse
import json
from pathlib import Path
from typing import Any

from cli_app import build_parser

SKIP_DEST = {"help", "command"}


def type_name(action: argparse.Action) -> str:
    if action.type is None:
        return "str" if action.default is not True else "bool"
    return getattr(action.type, "__name__", str(action.type))


def walk(parser: argparse.ArgumentParser, path: tuple[str, ...]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for action in parser._actions:
        if action.dest in SKIP_DEST or not action.option_strings:
            continue
        default = action.default
        rows.append(
            {
                "id": "/".join(path + (action.option_strings[0],)),
                "command_path": list(path),
                "flags": list(action.option_strings),
                "dest": action.dest,
                "required": bool(action.required),
                "type_name": type_name(action),
                "default_repr": None if default is argparse.SUPPRESS else repr(default),
                "help": action.help or "",
            }
        )
    for act in parser._actions:
        if isinstance(act, argparse._SubParsersAction):
            for name, sub in act.choices.items():
                rows.extend(walk(sub, path + (name,)))
    return rows


def main() -> None:
    rows = sorted(walk(build_parser(), ("orders",)), key=lambda r: r["id"])
    ids = [r["id"] for r in rows]
    if len(ids) != len(set(ids)):
        raise SystemExit("duplicate flag ids; refuse to compile ledger")
    Path("docs/generated/cli_flag_ledger.json").write_text(
        json.dumps({"rows": rows}, indent=2, sort_keys=True) + "\n",
        encoding="utf-8",
    )


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode
python tools/extract_cli_ledger.py
Enter fullscreen mode Exit fullscreen mode

The command writes docs/generated/cli_flag_ledger.json and must be checked in or produced in CI before the overlay gate. Reviewers should not rewrite that JSON, because the next commit will regenerate it from parser structure. If extraction cannot import build_parser without side effects, that is a CLI packaging bug, not a documentation-tooling bug.

Step 2 — Keep a human overlay beside the ledger

Store signature cells in YAML keyed by the same id strings the extractor emits. Humans write examples, exit codes, and destructive classification here, including an explicit false when a flag is safe. A draft model may propose text for empty notes, but the overlay file is still the publication source after review. Label any unexecuted example as a proposal so operators do not paste it into production.

# docs/overlays/cli_flag_overlay.yaml — humans own every non-null cell
orders/--format:
  examples:
    - "orders export --format json --out /tmp/orders.json"
  exit_codes:
    0: "Export completed and the file was closed."
    2: "Usage error, including an unknown --format value."
  destructive: false
  support_window: "supported in 1.x; no removal announced"
  notes: "JSON export is newline-delimited objects, not a single array."
orders/--out:
  examples:
    - "orders export --out /var/lib/orders/export.csv"
  exit_codes:
    0: "File replaced atomically if the parent directory exists."
    1: "Parent directory missing or not writable."
  destructive: false
  support_window: "supported in 1.x"
  notes: "Overwrites the destination; it does not append."
orders/purge/--older-than-days:
  examples:
    - "orders purge --older-than-days 90"
  exit_codes:
    0: "Dry run completed; no rows deleted without --apply."
    1: "Filter parsed but the store was unreachable."
  destructive: false
  support_window: "supported in 1.x"
  notes: "Without --apply this flag must not delete."
orders/purge/--apply:
  examples:
    - "orders purge --older-than-days 90 --apply"
  exit_codes:
    0: "Matching rows deleted; partial deletes must not return 0."
    3: "Delete started and then stopped; treat as unknown state."
  destructive: true
  support_window: "supported in 1.x; requires dual control in production"
  notes: "Irreversible. Dry-run first. Do not document a recycle bin."
Enter fullscreen mode Exit fullscreen mode

Exit code 3 in the --apply row is a human assertion about process behavior, not something argparse can prove. If the binary does not actually distinguish partial deletes, the overlay is lying and the gate cannot save you. That is why signature review is a release activity, not a formatting pass on generated help. Keep owners in CODEOWNERS for docs/overlays/, not in the generated ledger.

Step 3 — Join rows and fail closed

The gate loads both files, requires every ledger id to exist in the overlay, and forbids overlay keys the parser no longer emits. It also requires destructive to be a boolean and exit_codes to include 0 plus at least one non-zero code. Those checks are documentation tests: they do not prove the binary honors the table, but they prove the table exists before publish. Print missing keys as a sorted list so the failure is copy-pasteable into a review comment.

# tools/gate_cli_docs.py — worked example
from __future__ import annotations

import json
import sys
from pathlib import Path

import yaml

LEDGER = Path("docs/generated/cli_flag_ledger.json")
OVERLAY = Path("docs/overlays/cli_flag_overlay.yaml")


def main() -> int:
    rows = json.loads(LEDGER.read_text(encoding="utf-8"))["rows"]
    overlay = yaml.safe_load(OVERLAY.read_text(encoding="utf-8")) or {}
    ledger_ids = [r["id"] for r in rows]
    missing = sorted(i for i in ledger_ids if i not in overlay)
    extra = sorted(k for k in overlay if k not in set(ledger_ids))
    errors: list[str] = []
    if missing:
        errors.append("unsigned flag ids:\n  " + "\n  ".join(missing))
    if extra:
        errors.append("overlay keys not in parser:\n  " + "\n  ".join(extra))
    for flag_id in ledger_ids:
        cell = overlay.get(flag_id) or {}
        if not isinstance(cell.get("destructive"), bool):
            errors.append(f"{flag_id}: destructive must be boolean")
        codes = cell.get("exit_codes") or {}
        if 0 not in codes and "0" not in codes:
            errors.append(f"{flag_id}: exit_codes must include 0")
        if not any(str(k) != "0" for k in codes):
            errors.append(f"{flag_id}: exit_codes need a non-zero entry")
        if not cell.get("examples"):
            errors.append(f"{flag_id}: examples must be a non-empty list")
        if not cell.get("support_window"):
            errors.append(f"{flag_id}: support_window is unsigned")
    if errors:
        print("\n".join(errors), file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode
python tools/extract_cli_ledger.py && python tools/gate_cli_docs.py
Enter fullscreen mode Exit fullscreen mode

Wire both commands into the docs job so a new --force flag cannot ship with only a help string. Generated Markdown, if you render any, should be a template over the join result and must be treated as uneditable output. Do not accept pull requests that patch rendered CLI pages without changing parser or overlay. The gate is the review surface; the page is a projection.

Step 4 — Render only after the gate passes

A small renderer can emit one section per command path, listing compile fields as a definition list and signature fields as warnings. Destructive flags should render as admonitions that quote the overlay notes verbatim, not as paraphrases. Exit codes should render as a table whose left column is the integer from YAML, not a severity word invented at render time. If you need narrative intro paragraphs, write them in a separate signed file keyed by command path, using the same fail-closed join.

# tools/render_cli_docs.py — projection only, after gate_cli_docs.py succeeds
def render_row(compile_row: dict, signed: dict) -> str:
    flags = ", ".join(compile_row["flags"])
    lines = [
        f"### `{flags}`",
        f"- dest: `{compile_row['dest']}`",
        f"- type: `{compile_row['type_name']}`",
        f"- required: `{compile_row['required']}`",
        f"- default: `{compile_row['default_repr']}`",
        f"- parser help: {compile_row['help']}",
        f"- support window: {signed['support_window']}",
    ]
    if signed["destructive"]:
        lines.append(f"> Destructive: {signed['notes']}")
    lines.append("Examples:")
    lines.extend(f"- `{ex}`" for ex in signed["examples"])
    lines.append("Exit codes:")
    for code, meaning in sorted(signed["exit_codes"].items(), key=lambda kv: int(kv[0])):
        lines.append(f"- `{code}`: {meaning}")
    return "\n".join(lines) + "\n"
Enter fullscreen mode Exit fullscreen mode

What a model may draft, and what it must not own

Draft generation is optional and belongs after extraction, never before the ledger exists. A model may turn terse action.help strings into longer operator notes, propose additional examples that still require redaction, and flag overlay keys that look unsigned. It must not invent exit integers, claim a recycle bin for purge --apply, or fill support_window from related marketing copy. Those cells stay human-owned even when the prose around them is machine-drafted.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access is relevant only as a draft lane on top of the generated ledger, and the free server option is relevant only as a place to run extract-and-gate when primary CI is already full. Neither option authorizes skipping the overlay file, and neither option is a source of exit-code truth. Keep model output in a suggestion path such as docs/overlays/cli_flag_overlay.draft.yaml that the gate never reads.

A practical review rule is to diff draft YAML against signed YAML and accept only comments and example wording, never destructive flips, without a second reviewer. If the draft file adds a flag id the ledger does not contain, discard that id instead of teaching the overlay to outrun the parser. Chat transcripts are not an overlay, because they are not keyed, not gated, and not owned by CODEOWNERS. The compile-and-sign split fails as soon as the signed file becomes a paste buffer for unreviewed completions.

Limitations the gate cannot see

Argparse walking via parser._actions is convenient and brittle across Python versions, custom Action subclasses, and mutually exclusive groups. Runtime-registered flags, plugin CLIs, and environment-variable backdoors will not appear in the ledger unless build_parser() constructs them deterministically. Defaults that depend on hostname, cwd, or secret stores must be suppressed in extraction, or the ledger becomes a credentials leak. Help strings that describe behavior the binary no longer implements will compile cleanly and still mislead operators.

Exit-code tables are assertions about process contracts, not unit tests of SystemExit. Teams that need proof should add executable CLI tests that spawn the binary and check status codes, then keep those tests separate from the docs gate. This workflow also does not classify legal warranty language, data-retention policy, or on-call rosters, and those topics should not be smuggled into notes. If two subcommands share a flag name with different dests, the id scheme here is sufficient; if they share dest with different semantics, the CLI design needs a rename more than it needs better docs.

Who should not use this approach

Do not adopt extract-overlay-gate for a toy binary with two flags and no irreversible operations, because the overlay will cost more than the CLI. Do not use it when the parser is generated at runtime from a remote schema you cannot pin in the docs job. Do not use a draft model as the owner of destructive or exit_codes because those cells are incident-response artifacts. Do not publish the compiled ledger alone and call it operator documentation; without signature cells it is only a prettier --help.

Skip the free-server path when your existing CI already imports build_parser() cheaply and you have no extra job to host. Skip model drafts when help strings are already the overlay quality you need and reviewers would only rubber-stamp completions. In those cases, keep extraction and the fail-closed gate, and leave the prose lane empty. The core conclusion does not require a model: unsigned irreversible operations must not render.

Closing constraint

Parser facts are cheap to regenerate, and support contracts are expensive to get wrong, so the docs build should treat them as different artifacts. Compile the flag ledger from argparse, sign examples and exit codes in YAML, and fail the job when those keys drift. If extract-and-gate needs a runner outside primary CI, MonkeyCode's free server option can host that job while signature authority stays in the overlay. The published CLI page is then a join, not a chat transcript, and destructive operations stay attributed to a human row.

Top comments (0)