The Command-Line Reference Pipeline: Building Self-Healing Docs
This post was drafted with assistance from Gemini.
When you build a customized command-line tool for your local development system, its commands, flags, and options evolve rapidly. If you are adding a new task manager, modifying a session logger, or refactoring a path resolver, you are constantly tweaking the CLI parameters.
This rapid development introduces a classic problem: documentation drift.
How do you maintain a beautiful, human-readable web reference manual that is guaranteed to stay 100% accurate, without wasting hours manually translating flags and options into Markdown?
I wanted a documentation system that is completely immune to drift. To achieve this, I built an automated introspection pipeline for my local CLI tool, soul. The CLI parser structure itself is the single source of truth, and the web documentation is compiled directly from the active code.
The Hook: The Friction of Manual Sync and Help Scraping
Traditional documentation structures rely on manual updates. You write the code, and then you write the docs. When velocity is high, this manual step is always the first thing to fail. You refactor a flag, forget to update the web page, and suddenly your reference manual is displaying outdated, broken instructions.
To automate this, developers often try to capture the stdout of the terminal’s standard --help screen and parse it into HTML. But this approach is highly brittle:
- Terminal help text is unstructured and free-form.
- Parsing changes in help layouts with regular expressions is error-prone.
- The captured text lacks semantic hierarchy, making it incredibly difficult to render as rich web tables, nested lists, and sticky sidebar navigations.
I needed a pipeline that could extract the deep, semantic hierarchy of my command parsers as structured data, while keeping descriptions, runnable examples, and exit codes human-friendly.
The Architectural Fork: Three Rejected Paths
Before designing the introspection pipeline, I evaluated and rejected three standard approaches.
Static Markdown Manuals (Rejected)
Writing static Markdown pages for each command is straightforward, but it offers zero safety against drift. If a flag is renamed from --id to --session-id in the Python source code, the Markdown file will silently continue to show the old name until a user runs into a broken command.
Docstring Scraping (Rejected)
I considered reading the docstrings of my command scripts at build-time and parsing them into HTML blocks. While this gets closer to the source code, docstrings are still just strings. They do not capture the actual, active constraints of your options—such as whether a parameter takes an argument, what its default value is, or what choices are valid.
The Chosen Path: Parser Introspection with a Dual-JSON Merge
I decided to treat Python’s standard argparse parser objects as the definitive schema. At build-time, a generation script imports each command, intercepts the parser creation, serializes the fully built parser into a structured JSON file (soul-cli.json), and exits before any actual command logic executes.
To keep the documentation warm and human-friendly, I separated the structural schema from the narrative prose. The pipeline merges two independent files:
soul-cli.json(Generated): Captures the raw structure—every command, flag, option, position, and default, directly from the code.soul-cli-prose.json(Hand-written): Holds command group assignments, human-readable summaries, step-by-step examples, and exit codes.
An Astro page merges the two JSON files at build-time, outputting a beautiful, search-indexed sidebar reference (you can view the live result on my command-line reference manual page).
The Proof of Work: Argument Interception without Execution
The challenge of parser introspection is extracting the parser parameters without running the script’s actual side-effects (like connecting to databases or starting daemon servers).
To do this cleanly, my generation script monkeypatches Python’s standard argparse.ArgumentParser.parse_args at runtime.
When the generator script imports a command module, the module builds its parser and calls parse_args(). Because of the patch, the parser intercepts this call, serializes its internal structures to stdout as a JSON string, and raises a safe SystemExit before the script can perform any real work:
def _introspect(script_path: str) -> int:
dumped = {"done": False}
def _dump(self, *args, **kwargs):
if not dumped["done"]:
dumped["done"] = True
sys.stdout.write(json.dumps(_serialize_parser(self)) + "\n")
raise SystemExit(0)
argparse.ArgumentParser.parse_args = _dump
# runpy executes the script safely in its own context
import runpy
runpy.run_path(script_path, run_name="__main__")
This ensures that extracting command-line structures is completely side-effect free. To enforce this, the prebuild process runs an in-memory comparison gate. If a developer refactors a CLI command but forgets to regenerate the committed JSON schema, the prebuild step fails the build, preventing drift from ever reaching production.
The Messy Middle: The Python Version Drift Pivot
A subtle issue with this dynamic introspection approach is environment leakage. Because the generator script imports the CLI command modules, it executes all module-level type definitions and syntax expressions.
During a recent build audit, my prebuild drift checks started throwing strange syntax and type errors on my macOS machine, failing the entire site build:
3 could not be introspected:
task (SyntaxError: f-string expression part cannot include a backslash)
delegate (TypeError: unsupported operand type(s) for |: 'type' and 'NoneType')
I checked the environment. When the build ran inside a standard subshell, the command resolved python3 to /usr/bin/python3—which maps to Python 3.9.6 on macOS.
My modern command scripts, however, utilized features introduced in newer Python runtimes:
- Union Types (
str | None): Type annotations likestr | Nonewere introduced in Python 3.10 (PEP 604). When Python 3.9.6 tries to evaluatestr | Noneat import-time, it doesn’t recognize the pipe operator for types and crashes. - Backslashes in f-Strings (
f"...\n..."): Placing backslashes inside f-string expression braces was forbidden before Python 3.12.
Because /usr/bin/python3 is a system-controlled executable protected by macOS System Integrity Protection (SIP), I could not symlink or overwrite it. At the same time, forcing the developer to always remember to manually source a specific virtual environment before running a static site build was a poor developer experience.
The Pivot: Self-Healing Process Re-execution
To resolve this process friction, I implemented a self-healing process bootstrapper directly at the top of the python generator script.
If the script is executed by an interpreter older than Python 3.12, it immediately stops, scans the system for a modern Homebrew or dotfiles virtual environment Python installation, and re-executes itself in-place using os.execv:
# Re-execute with a newer Python interpreter if the current one is too old
# (macOS system Python 3.9.6 lacks support for Union types and backslashes in f-strings).
if sys.version_info < (3, 12):
for py_path in ("/opt/homebrew/bin/python3", "/Users/ilteris/dotfiles/bin/python3"):
if os.path.exists(py_path):
os.execv(py_path, [py_path] + sys.argv)
By leveraging os.execv, the script replaces the current process with the newer Python process, preserving the exact command arguments and process IDs.
The build pipeline now automatically and transparently upgrades itself to Python 3.14.5, processes all 23 CLI commands cleanly, and compiles the site without a single syntax error.
The Output: Complete Synchronization
By decoupling structural schema parsing from human prose, you get the best of both worlds: highly detailed, accurate descriptions of flags and options, combined with polished, hand-written examples and narrative overviews.
The documentation can never drift out of sync, because the build process itself is a strict guardian of your CLI’s interface contract.