# CliDoc Brings TSDoc-Style Documentation to CLIs

> Source: <https://ben3d.ca/blog/clidoc-openapi-for-clis>
> Published: 2026-09-30 00:00:00+00:00

# 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](https://ben3d.ca/about) •  • 7 min read

I build a lot of command-line tools: [mtlx](https://github.com/bhouston/mtlx), [node-prewarm](https://github.com/bhouston/node-prewarm), [HDRify](https://github.com/bhouston/hdrify), [git-dedup](https://ben3d.ca/blog/git-dedup-faster-smaller-checkouts), and [webgpu-bench](https://ben3d.ca/blog/introducing-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](https://clidoc.dev)**. 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[#](#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](https://github.com/bcdxn/opencli) 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[#](#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:

``` js
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[#](#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`docgen` command. Running`mycli docgen` prints 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[#](#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:

``` python
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](https://clidoc.dev/docs/frameworks).

I also wrote [yargs-file-commands](https://github.com/bhouston/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`:

``` js
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[#](#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:

``` js
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](https://git-dedup.ben3d.ca/docs/cli) use the Docusaurus plugin, and CliDoc's own [CLI reference](https://clidoc.dev/docs/cli/reference) is generated from its own commands.

## The Rest of the Toolbox[#](#the-rest-of-the-toolbox)

The `@clidoc/cli` package (`npm install -g @clidoc/cli`) provides a CLI for working with OpenCLI documents:

- **`clidoc validate`** checks a JSON or YAML document against the OpenCLI schema offline, plus the main logical checks from the upstream Go validator.
- **`clidoc markdown`** renders a document to Markdown.
- **`clidoc completion`** generates standalone Bash, Zsh, and Fish completion scripts from a document.
- **`clidoc mcp`** turns a document into[Model Context Protocol](https://modelcontextprotocol.io) 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](https://clidoc.dev/docs/mcp) .

There is also a [GitHub Action](https://clidoc.dev/docs/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[#](#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[#](#try-it)

```
npm install -g @clidoc/cli
```

Start with the [documentation at clidoc.dev](https://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](https://github.com/bhouston/clidoc) under the MIT license and requires Node 22.12 or newer.
