Documenting CLIs
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against the documentation of picocli, clap_mangen, Cobra and the Asciidoctor manpage backend as checked on
2026-09-30, and against the docsctl running example of this sub-section (fictional, illustrative). The generator script is a
fragment that was not run, because docsctl does not exist; the tools it relies on are named as their documentation names them.
A CLI has several audiences and, usually, several copies of the same facts: the --help text, the man page, the reference page on the
documentation site, the shell completion, the agent skill, and (when the tool is wrapped) the MCP tool descriptions and schemas. Every copy
that is written by hand drifts, and drift is worse for an agent than for a person, because an agent trusts the text it reads. The
rule of this page is the same as the one on
Self-Describing Help: define the interface once, in the command tree, and generate everything else from it.
One source, many outputs
The command tree (commands, arguments, flags, defaults, descriptions, examples, exit codes, environment variables) lives in code, in the
annotations or builders of the framework. The framework already renders it as --help; the rest is a question of which generator
reads the tree next. docsctl schema --json, whose shape is defined in the sub-section’s contract, is the framework-independent
form of the tree, and it is what the wrappers and the documentation build consume.
Generated files are either committed and checked in CI, or built on every release and never committed. Committing them makes reviews show
interface changes; the price is a CI step that regenerates them and fails on a difference (git diff --exit-code docs/cli), so a stale
file cannot merge.
Man pages and reference docs from the command tree
Each of the mainstream frameworks can walk its command tree and write documentation. The framework pages show the code in context; the summary here is what each generator produces and where its output goes.
| Framework | Generator | Output |
|---|---|---|
picocli |
|
AsciiDoc man-page sources, one per command, to be converted with Asciidoctor’s manpage backend (see the Java page) |
clap |
|
ROFF man pages; the crate recommends running it from |
Cobra |
The |
One Markdown, man or ReST file per command path ( |
Cobra’s own guide on documenting CLIs for LLMs makes the useful points that Markdown, one file per command, is a good default because it chunks well,
that DisableAutoGenTag should be set so that regeneration produces identical files with no timestamp footer, and that the quality of the output depends on the
Short, Long and Example fields you fill in. The same is true of every framework: a generator publishes what you wrote in the descriptions and examples of the
tree, so the effort goes there.
root := newRoot()
root.DisableAutoGenTag = true // no timestamp footer, so diffs stay empty
if err := doc.GenMarkdownTree(root, "./docs/cli"); err != nil {
log.Fatal(err)
}
header := &doc.GenManHeader{Title: "DOCSCTL", Section: "1"}
if err := doc.GenManTree(root, header, "./docs/man"); err != nil {
log.Fatal(err)
}
References: picocli — Generating Documentation, clap_mangen and Cobra — Documenting CLIs for LLMs.
AsciiDoc man pages with the Asciidoctor manpage backend
When the framework does not generate man pages, or when you want the reference and the man page from AsciiDoc, use
Asciidoctor’s manpage backend. The rules are few: the document title is
the program name and section as progname(volume) with no spaces, a NAME section holds a one-line description in the form progname - purpose,
a SYNOPSIS section is recommended, and the attributes manmanual, mansource and manversion fill in the header and footer of the rendered page.
The output file name comes from the title, so docsctl(1) becomes docsctl.1.
= docsctl(1)
:manmanual: Docsctl Manual
:mansource: docsctl 0.1.0
== Name
docsctl - search and publish this site's documentation
== Synopsis
*docsctl* [_GLOBAL OPTIONS_] _COMMAND_ [_ARGS_]
== Exit status
*0*:: Success, including an idempotent no-op and a dry-run plan.
*2*:: Usage error.
*3*:: Validation problems found.
*5*:: Confirmation required; the error names the exact command that confirms.
*7*:: Authentication required.
== Environment
*DOCSCTL_TOKEN*:: Access token (never printed).
*DOCSCTL_NO_INPUT*:: When set to `1`, never prompt.
asciidoctor -b manpage docsctl.1.adoc # writes docsctl.1
man ./docsctl.1
This site is built with Antora, which renders AsciiDoc pages to HTML; the man-page conversion is a separate step that you run with the
standalone asciidoctor command in the tool’s own repository. The reference page for the documentation site and the man page therefore
have different build paths. Avoid maintaining both by hand: generate both from the same source (the next section), so that the exit-status table on
the man page is the exit-status table on the site. Antora resolves include targets by resource ID (such as partial$), and standalone Asciidoctor by
file path, so a fragment shared between the two needs care and a check of both builds; generation from the tree avoids the question.
Man pages are worth the trouble because man docsctl works offline and is what packagers install (see
Packaging & Distribution), and an agent that has a shell can read one with man docsctl | col -b. Windows has no man, so
the equivalents there are --help and the published site.
Keeping help, docs and MCP schemas in sync
The synchronisation mechanism is a small program that reads docsctl schema --json and writes whatever is needed. Because the schema
already carries the description, the arguments and flags, the exit codes and the safety metadata of every command, the same input feeds three outputs.
import json
import subprocess
from pathlib import Path
schema = json.loads(subprocess.run(
["docsctl", "schema", "--json"], capture_output=True, text=True, check=True, timeout=30,
).stdout)
def esc(text: str) -> str:
return text.replace("|", "\\|")
out = [f"= {schema['name']} {schema['version']}", ""]
for cmd in schema["commands"]:
out += [f"== {cmd['name']}", "", esc(cmd["summary"]), "",
"[cols=\"2,1,4\",options=\"header\"]", "|===", "| Flag | Type | Description"]
for flag in cmd["flags"]:
out.append(f"| `{flag['name']}` | {flag['type']} | {esc(flag['description'])}")
out += ["|===", "",
"Exit codes: " + ", ".join(str(c) for c in cmd["exitCodes"]), ""]
Path("modules/ROOT/pages/reference.adoc").write_text("\n".join(out) + "\n")
The other two outputs read the same file. The MCP wrapper maps each command to a tool: the arguments and flags become the inputSchema, the
output.schema becomes the outputSchema, the command summary becomes the description, and safety becomes the tool annotations. The mapping
is specified in Wrapping a CLI as an MCP Server, and the annotation names are those of the
MCP primitives page and the MCP tools specification. The agent skill (see Self-Describing Help)
takes its command list and examples from the same file, so it cannot advertise a flag that no longer exists.
Four checks keep the pipeline honest.
-
Regenerate and diff in CI. The build fails when generated files differ from those committed, which is the signal to run the generator.
-
Parity test for the wrapper. A test compares the tools an MCP wrapper exposes with the commands in
schema --json, minus the ones it deliberately withholds (loginandlogout, as Wrapping a CLI as an MCP Server advises), so a new command is either exposed or consciously excluded. -
Examples run. Every example in
--helpand in the reference is executed in the black-box suite of Testing CLIs against a fixture, so an example that stopped working fails a test rather than misleading a reader. -
One version number. The version in the man page, the site and the schema comes from the tool’s own
--version.
Writing what the generators cannot
A generator cannot supply intent. Three pieces of prose are written by hand, once, next to the code that they describe.
-
Descriptions that say when to use the command, not only what it is called ("Search the published documentation; use
showto read a result"), because these become the MCP tool descriptions and what an agent chooses on. -
Examples with real inputs and their output shape, one or two per command.
-
Exit-code and error-code tables, kept as data in the code so that the man page, the reference and the
--helpfooter print the same table.
Longer material (tutorials, the agent-facing guide, the reasoning behind design choices) belongs on a documentation site, linked from
--help and from the man page’s SEE ALSO section. Keep it short and let the generated reference carry the details, in the manner of the
token budget rules: what an agent reads should be the least that lets it act.
Checklist
-
--help, completion, man pages, the reference and the MCP definitions are all generated from the command tree. -
Generation is reproducible (no timestamps) and CI fails when generated files are stale.
-
Every command has a description that says when to use it and at least one runnable example.
-
The exit-code, error-code and environment-variable tables are data, printed identically everywhere.
-
Man pages and completions ship in the package.
-
The list is condensed on the Agent-Readiness Checklist.