Generated quickstart copy goes stale when CLI flags move and the README still shows last week's names. A small continuous-integration gate can import the real parser, extract option strings, and reject markdown that cites unknown flags. Models may draft the session narrative, but they should not be the source of flag names, default ports, or support hours. The sections below walk through a reproducible extractor, a session checker, and a decision table for ownership.
Why tone review misses the real defect
Most broken READMEs do not fail because the prose sounds awkward or too brief for newcomers. They fail because a documented command line no longer matches the parser that shipped users will actually import. A fluent model can still invent --env-file, swap --port for --listen, or promise round-the-clock support nobody signed. Treating those errors as editorial nits hides a cheaper check: every flag-like token must exist on the live argparse object.
That check stays mechanical and fast, and it remains independent of whoever drafted the surrounding paragraph. It also splits inventory text from promise text, which later sections encode as a linter rather than a style guide. Inventory is anything a parser, a compose file, or a package metadata file can prove in CI. Promises are support hours, production defaults, license claims, and security contacts.
Teams that debate comment density will still ship the wrong --output flag if nobody imports the parser. The remainder of the workflow therefore treats documentation as two pipelines that share a repository but not a writer.
Ownership table before any drafting
Use this table as the contract for the pipeline, not as optional writing advice after the fact. Copy the table into the pull-request template if reviewers otherwise treat generated prose as already verified.
| Doc fragment | Allowed drafter | Ground truth | Merge rule |
|---|---|---|---|
| Flag names, short options, metavars | None |
parser actions after import |
Must match extractor JSON |
| Example command sequence | Model or human | Each flag cited in extractor JSON | Checker must pass |
Published host:port bindings |
None |
compose.yaml ports |
Must match compose inventory |
| Support hours, paging, security mailbox | Human only | Signed HTML comment block | Unsigned hits fail CI |
| License, trademark, warranty | Human only |
LICENSE plus signed block |
Never model-authored |
| Why-this-flag one-liners | Model draft OK | Adjacent help= string |
Reviewer may edit tone |
The table is the first artifact to copy, because it prevents a chat transcript from becoming the README source. Everything after this heading is one implementation of that table for a small Python CLI.
Procedure
- Move argparse construction into an importable
build_parser()function with no network side effects. - Extract option strings and compose host ports into
docs/inventories/cli.jsonon every CI run. - Restrict model output to
docs/quickstart.mdfenced commands plus connective prose that cites that inventory. - Run the citation checker and fail the job on unknown flags, unknown ports, or unsigned promises.
- Keep support, license, and warranty text in signed blocks owned by a named human, not by the model.
1. Keep parser construction importable
Hide argparse behind a function so tests and extractors do not execute side effects at import time. The sample below is small on purpose, which keeps the later checker easy to read and easy to test.
# demo_cli/parser.py
from __future__ import annotations
import argparse
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog='demo-cli',
description='Batch resize images for a local preview pipeline.',
)
parser.add_argument('--input-dir', required=True, help='Directory of source images.')
parser.add_argument('--output-dir', required=True, help='Directory for resized files.')
parser.add_argument('--width', type=int, default=640, help='Target width in pixels.')
parser.add_argument('--height', type=int, default=480, help='Target height in pixels.')
parser.add_argument(
'--dry-run',
action='store_true',
help='Print planned writes without creating files.',
)
parser.add_argument(
'--log-json',
action='store_true',
help='Emit one JSON object per image to stdout.',
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.dry_run:
print(f'would process {args.input_dir} -> {args.output_dir}')
return 0
print(f'process {args.input_dir} -> {args.output_dir}')
return 0
A matching compose file supplies ports that the README must not invent. Keep published ports in simple host:container form so the extractor can take the leftmost field without guessing bind addresses.
# compose.yaml
services:
preview:
image: demo-cli:local
ports:
- '8088:8088'
2. Extract option strings in CI, not in chat
The extractor imports build_parser, walks actions, and writes JSON the checker can diff. It does not ask a model which flags exist, because that question is already answered by the parser object. Help strings travel with the flags so a later draft can paraphrase purpose without inventing names.
# tools/extract_cli_options.py
from __future__ import annotations
import json
import sys
from pathlib import Path
import yaml
from demo_cli.parser import build_parser
def option_rows() -> list[dict]:
rows: list[dict] = []
for action in build_parser()._actions:
if not action.option_strings:
continue
rows.append({
'options': list(action.option_strings),
'help': action.help or '',
'required': bool(getattr(action, 'required', False)),
})
return rows
def compose_host_ports(path: Path) -> list[str]:
data = yaml.safe_load(path.read_text(encoding='utf-8')) or {}
ports: list[str] = []
for service in data.get('services', {}).values():
for item in service.get('ports', []):
ports.append(str(item).split(':')[0].strip().strip("'\""))
return sorted(set(ports))
def main() -> int:
rows = option_rows()
payload = {
'prog': 'demo-cli',
'options': sorted({flag for row in rows for flag in row['options']}),
'flags': rows,
'host_ports': compose_host_ports(Path('compose.yaml')),
}
out = Path('docs/inventories/cli.json')
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(payload, indent=2) + '\n', encoding='utf-8')
print(json.dumps(payload, indent=2))
return 0
if __name__ == '__main__':
sys.exit(main())
Run the extractor with the same interpreter the package tests already use. Regeneration in CI should remain mandatory even if the JSON is committed for review diffs.
python -m pip install pyyaml
python tools/extract_cli_options.py
Walking parser._actions is convenient and brittle across Python versions, so pin the checker to the interpreter you already test. The committed JSON is a cache of the parser, not an independent specification. If the cache and a fresh extract disagree, the extract wins and the README must follow it.
3. Constrain the markdown the model may draft
The model may draft a quickstart session only as fenced commands plus connective prose. It may not introduce flag names, ports, or support sentences that the inventories do not already contain. A skeleton like the one below makes that constraint visible during review.
# Quickstart
Connective prose may be drafted, but every command must cite inventory flags.
```bash
demo-cli --input-dir ./in --output-dir ./out --width 640 --dry-run
```
Preview binds to the compose host port:
```bash
curl -sS http://127.0.0.1:8088/health
```
Unsigned promise language belongs in a separate region that CI rejects until a human wraps it. The date and owner attributes are review metadata, not proof that the mailbox is monitored.
<!-- signed:support owner='docs-oncall' date='2026-09-23' -->
Security reports go to security@example.com. Support hours are business days, 15:00-23:00 UTC.
<!-- /signed:support -->
4. Check citations and block unsigned promises
The checker is the original artifact for this workflow. It tokenizes fenced bash blocks, collects tokens that start with -, and compares them to the inventory. It also scans for promise patterns outside signed regions, which keeps support copy from riding in on a generated session.
# tools/check_quickstart.py
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
FENCE = re.compile(r'```
(?:bash|sh|shell)\n(.*?)
```', re.DOTALL)
FLAG = re.compile(r'(?<![\w-])(--[A-Za-z0-9][\w-]*|-[A-Za-z])\b')
SIGNED = re.compile(
r'<!-- signed:support\b.*?-->(.*?)<!-- /signed:support -->',
re.DOTALL,
)
PROMISE = re.compile(
r'\b(sla|24/7|twenty-four-hour|guaranteed|warranty|on-call|security@|'
r'production default|we promise|always available)\b',
re.I,
)
HOSTPORT = re.compile(r'127\.0\.0\.1:(\d+)|localhost:(\d+)')
def strip_signed(text: str) -> str:
return SIGNED.sub('', text)
def flags_in(command: str) -> list[str]:
return FLAG.findall(command)
def main(readme: Path, inventory: Path) -> int:
inv = json.loads(inventory.read_text(encoding='utf-8'))
allowed = set(inv['options'])
ports = set(inv['host_ports'])
text = readme.read_text(encoding='utf-8')
errors: list[str] = []
for block in FENCE.findall(text):
for token in flags_in(block):
if token not in allowed:
errors.append(f'unknown flag {token}')
for match in HOSTPORT.finditer(block):
port = match.group(1) or match.group(2)
if port not in ports:
errors.append(f'unknown host port {port}')
unsigned = strip_signed(text)
for match in PROMISE.finditer(unsigned):
errors.append(f'unsigned promise {match.group(0)!r}')
if errors:
print('\n'.join(errors))
return 1
print('quickstart citations ok')
return 0
if __name__ == '__main__':
raise SystemExit(
main(Path('docs/quickstart.md'), Path('docs/inventories/cli.json'))
)
5. Lock the behavior with tests
Label these tests as executable examples for the sample CLI, not as fleet metrics from a production incident. They document the gate, which is the point of the article. Put the repository root on PYTHONPATH so tools.check_quickstart imports cleanly.
# tests/test_check_quickstart.py
from pathlib import Path
from tools.check_quickstart import main
def write(tmp: Path, name: str, body: str) -> Path:
path = tmp / name
path.write_text(body, encoding='utf-8')
return path
def test_unknown_flag_fails(tmp_path: Path) -> None:
inventory = write(
tmp_path,
'cli.json',
'{"options": ["--input-dir"], "host_ports": ["8088"]}\n',
)
readme = write(
tmp_path,
'q.md',
'```
bash\ndemo-cli --input-dir ./in --recursive\n
```\n',
)
assert main(readme, inventory) == 1
def test_unknown_port_fails(tmp_path: Path) -> None:
inventory = write(
tmp_path,
'cli.json',
'{"options": ["--dry-run"], "host_ports": ["8088"]}\n',
)
readme = write(
tmp_path,
'q.md',
'```
bash\ncurl -sS http://127.0.0.1:3000/health\n
```\n',
)
assert main(readme, inventory) == 1
def test_signed_support_may_name_a_mailbox(tmp_path: Path) -> None:
inventory = write(
tmp_path,
'cli.json',
'{"options": ["--dry-run"], "host_ports": ["8088"]}\n',
)
readme = write(
tmp_path,
'q.md',
'```
bash\ndemo-cli --dry-run\n
```\n\n'
'<!-- signed:support owner=\'docs-oncall\' date=\'2026-09-23\' -->\n'
'Security reports go to security@example.com.\n'
'<!-- /signed:support -->\n',
)
assert main(readme, inventory) == 0
export PYTHONPATH=.
python -m pip install pytest pyyaml
python tools/extract_cli_options.py
python tools/check_quickstart.py
python -m pytest tests/test_check_quickstart.py -q
Wire those three commands as one CI step in whatever system already runs unit tests. Do not treat a green chat transcript as a substitute for that step, because the transcript cannot import build_parser.
What to do when the checker fails
When the checker prints unknown flag --recursive, the fix is not a warmer prompt to the model. Either add the flag to build_parser and regenerate the inventory, or remove the token from the quickstart fence. When it prints an unsigned promise, move that sentence into a signed block after a human confirms the hours. Regenerating the README from chat without re-running the extractor repeats the original failure, so CI should call extract and check in that order.
Unknown ports follow the same rule as unknown flags. If curl cites 3000 and compose publishes 8088, change the markdown or change compose, then extract again. Do not add a second default in prose, because the checker only sees fenced commands and signed blocks.
Where a free model may participate
After the extractor exists, narrative prose still needs a first draft, and that draft is the only place a model should write. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access can propose the connective sentences around the fenced commands, and its free server option can run the import-based extractor on the same tree. The checker still rejects invented flags, unknown ports, and unsigned support copy, so the model never becomes the inventory. If the draft cites --recursive and the parser has no such action, the run fails before review.
That arrangement keeps the merge rule in the repository. The useful output is the failing check, not a longer prompt log.
Limitations, and who should skip this
The gate assumes an importable Python argparse object and a compose file whose published ports are static strings. Click lazy loading, binary CLIs, flags generated from a remote schema, and ports allocated at runtime will produce false confidence or false failures. Promise detection is a regular-expression denylist, so legal language that avoids the listed words can still slip through unsigned.
Do not use this workflow as a substitute for legal review of licenses, warranties, or security policy. Do not use it on libraries that have no CLI and no compose surface, because the inventory will be empty and the checker will only scan for words. Teams that already maintain human-written READMEs with review on every sentence gain little, unless flag drift is already a recurring defect.
The approach also refuses to auto-sign anything. A model that pastes a signed:support block around its own mailbox should be treated as a process bug, not as a clever default. Keep signature rights on named humans, and rotate the owner attribute when on-call coverage changes. Positionals, mutually exclusive groups, and environment-variable fallbacks are outside this checker, so document those paths in signed prose or extend the inventory before claiming coverage.
If you run the extractor in a hosted workspace that already offers free model access, keep the checker in the same session as the draft so invented flags never reach the README.
Top comments (0)