CliDoc Brings TSDoc-Style Documentation to CLIs
CliDoc generates an OpenCLI description of your CLI from the command definitions you already wrote, then publishes reference docs to Docusaurus and VitePress, and gives AI agents the same description through a docgen command.
Ben Houston • • 7 min read
I build a lot of command-line tools: mtlx, node-prewarm, HDRify, git-dedup, and webgpu-bench. Each one needs reference documentation, and writing it by hand is tedious work that repeats what the command definitions already say.
So I wrote CliDoc. It reads your CLI's command definitions, produces a structured description of the whole tool, and turns that into reference documentation. All five tools above now use it.
TSDoc, but for CLIs#
Library code solved this years ago. With TSDoc or JSDoc, the comment next to a function becomes its reference page. Web APIs have the same thing in OpenAPI (Swagger): you describe an endpoint once, in code, and get a machine-readable spec, a reference site, client generators, and validators.
CLIs have pieces of this. oclif can generate a README from its commands, and there are man pages and --help. But each framework uses its own format, and --help is free-form text meant for a person at a terminal. There is no portable, framework-neutral description of a CLI that other tools can build on.
The OpenCLI specification provides one. It defines a JSON/YAML format for describing a CLI: commands, subcommands, arguments, flags, types, defaults, choices, examples, and exit codes. CliDoc implements OpenCLI for Node.
How It Works#
clidoc takes your CLI definitions, builds an OpenCLI document, renders that document as Markdown pages, and publishes those pages to your docs site.
The framework packages read the commands you already wrote:
| Package | Reads |
|---|---|
@clidoc/yargs | Yargs command modules |
@clidoc/commander | Commander command trees |
@clidoc/oclif | oclif manifests |
There is no second place to describe a command. Here is the validate command from CliDoc itself:
export const command = defineCommand({ command: 'validate <input>', describe: 'Validate an OpenCLI JSON or YAML document', builder: (yargs) => yargs.positional('input', { type: 'string', demandOption: true, describe: 'OpenCLI document filename', }), handler: async ({ input }) => { parse(await readFile(input, 'utf8')); process.stdout.write('Valid OpenCLI document\n'); }, });
CliDoc turns that into this entry in the OpenCLI document:
"clidoc validate": { "summary": "Validate an OpenCLI JSON or YAML document", "args": [ { "name": "input", "required": true, "type": "string", "summary": "OpenCLI document filename" } ] }
and this Markdown page:
## clidoc validate Validate an OpenCLI JSON or YAML document ### Usage ```sh clidoc validate <input> [--help] [--version] ``` | Argument | Type | Required | Description | | --- | --- | --- | --- | | `input` | string | Yes | OpenCLI document filename |
Why This Matters for Agents#
The command line is the natural interface for coding agents. Every agent can already run a shell command, so a CLI works as an agent tool without an SDK, a server, or a custom integration.
But an agent still has to learn the tool. Today that means running --help, parsing the output, running --help on each subcommand, and hoping the text is complete. It works, but it is slow and loses information.
CliDoc gives agents two better options:
- Introspection. Any CliDoc-enabled CLI has a
docgencommand. Runningmycli docgenprints the full OpenCLI document: every command, argument, flag, type, default, and choice as JSON, in one call. - Online documentation. The same document renders into a reference site with one page per command, which agents and people can read instead of guessing from help text.
Both come from the command definitions, so as long as you regenerate them on build, they match the code.
Adding It to Your CLI#
Install the packages for your framework. For Yargs:
pnpm add @clidoc/yargs @clidoc/core
Then register a docgen command that builds the document from the same commands the CLI runs:
import { readFileSync } from 'node:fs'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import { createDocgenCommand, fromYargs } from '@clidoc/yargs'; import { infoFromPackageJson, type OpenCliDocument } from '@clidoc/core'; import { command as greet } from './commands/greet.js'; // title, binary name, and version come from package.json const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); const info = infoFromPackageJson(pkg); const parser = yargs(hideBin(process.argv)).command(greet); let document: OpenCliDocument; parser.command(createDocgenCommand(() => document)).demandCommand(); // build the document once every command, including docgen, is registered document = fromYargs(parser, info); parser.parse();
Now your CLI can document itself:
mycli docgen # JSON to stdout mycli docgen --output cli.json mycli docgen --format markdown --output reference.md
docgen is a normal, visible command, listed in --help like any other, so an agent exploring your CLI finds it on its own. It builds the document from command metadata and never runs your other command handlers.
Commander works the same way with fromCommander. oclif reads the manifest generated at build time, and its docgen command comes from the separate @clidoc/oclif/docgen entry point, so importing fromOclif doesn't require @oclif/core. Each framework has a guide and runnable demo.
I also wrote yargs-file-commands, which puts each Yargs command in its own file. CliDoc supports it directly, including subcommands loaded lazily through async builders.
Some things can't be read from framework metadata, such as examples, exit codes, or the license. Add them with mergeDocument inside the function you pass to docgen:
import { mergeDocument } from '@clidoc/core'; parser.command( createDocgenCommand(() => mergeDocument(document, { commands: { 'mycli greet': { examples: [{ title: 'Basic', content: 'mycli greet Ada' }], exitCodes: [{ code: 1, status: 'BAD_USER_INPUT_ERROR', summary: 'Missing name' }], }, }, }), ), );
Publishing to Docusaurus and VitePress#
Once you have an OpenCLI document, publishing is a plugin. The output is ordinary Markdown with front matter, plus sidebar entries, so it sits alongside your hand-written guides.
For Docusaurus, @clidoc/docusaurus generates the pages at build time. Set markdown.format to 'md' so Docusaurus doesn't parse generated descriptions as MDX:
module.exports = { markdown: { format: 'md' }, plugins: [ [clidocPlugin, { input: 'cli.json', outputDir: 'docs/generated-cli', basePath: '/cli' }], ], };
For VitePress, @clidoc/vitepress writes the pages and returns the matching sidebar:
const cliSidebar = await writeVitePress(document, { outputDir: fileURLToPath(new URL('..', import.meta.url)), basePath: '/cli', });
Each command gets its own page with a usage line, an argument table, and a flag table. Regenerate on every build and the reference stays current. The git-dedup docs use the Docusaurus plugin, and CliDoc's own CLI reference is generated from its own commands.
The Rest of the Toolbox#
The @clidoc/cli package (npm install -g @clidoc/cli) provides a CLI for working with OpenCLI documents:
clidoc validatechecks a JSON or YAML document against the OpenCLI schema offline, plus the main logical checks from the upstream Go validator.clidoc markdownrenders a document to Markdown.clidoc completiongenerates standalone Bash, Zsh, and Fish completion scripts from a document.clidoc mcpturns a document into Model Context Protocol tool definitions and can serve them over stdio, so an MCP client can call your CLI's commands as typed tools. See the MCP guide.
There is also a GitHub Action that rejects invalid OpenCLI documents in pull requests.
From one description you get the reference docs, shell completions, and agent tools for a CLI.
Dogfooding#
CliDoc documents itself. clidoc docgen describes the clidoc binary from its own command files, and the reference site is regenerated from that output on every release. Adopting CliDoc across my five tools also surfaced cases the first version didn't handle, such as required variadic flags and lazily loaded Yargs subcommands, which are now supported.
Try It#
npm install -g @clidoc/cli
Start with the documentation at clidoc.dev, pick the guide for your framework, and add a docgen command. Then run mycli docgen and hand the output to your coding agent. The source is on GitHub under the MIT license and requires Node 22.12 or newer.