Generate Documentation for Your CLI Tool Automatically
Nobody reads docs they have to maintain manually. They go stale the moment you add a new flag or rename a command. The solution: generate documentation directly from your CLI's command definitions. If your code is the source of truth for behavior, it should also be the source of truth for docs.
This article shows how to build an auto-documentation system that generates README sections, man pages, and help text from your Commander.js command definitions.
The Source of Truth Problem
// Your CLI definition IS your documentation
program
.command('audit <url>')
.description('Run a performance audit on a URL')
.option('-t, --threshold <n>', 'Minimum acceptable score (0-100)', parseInt)
.option('-f, --format <fmt>', 'Output format: text, json, table', 'text')
.option('-m, --mobile', 'Simulate mobile device', false)
.option('--no-cache', 'Disable result caching')
.action(auditHandler);
This already contains everything needed for documentation: command name, arguments, options with types and defaults, and descriptions. We just need to extract it.
Step 1: Extract Command Metadata
Commander stores command data in accessible properties:
// lib/doc-generator.ts
import { Command } from 'commander';
interface CommandDoc {
name: string;
description: string;
usage: string;
arguments: Array<{
name: string;
required: boolean;
description: string;
}>;
options: Array<{
flags: string;
description: string;
default?: string;
required: boolean;
choices?: string[];
}>;
subcommands: CommandDoc[];
}
export function extractDocs(program: Command): CommandDoc[] {
return program.commands.map(cmd => extractCommandDoc(cmd));
}
function extractCommandDoc(cmd: Command): CommandDoc {
const args = (cmd as any)._args || [];
const options = cmd.options || [];
return {
name: cmd.name(),
description: cmd.description(),
usage: cmd.usage(),
arguments: args.map((arg: any) => ({
name: arg.name,
required: arg.required,
description: arg.description || '',
})),
options: options.map((opt: any) => ({
flags: opt.flags,
description: opt.description,
default: opt.defaultValue !== undefined ? String(opt.defaultValue) : undefined,
required: opt.required || false,
choices: opt.argChoices,
})),
subcommands: cmd.commands.map(sub => extractCommandDoc(sub)),
};
}
Step 2: Generate Markdown
export function generateMarkdown(
toolName: string,
docs: CommandDoc[],
): string {
let md = `# ${toolName}\n\n## Commands\n\n`;
for (const cmd of docs) {
md += `### \`${toolName} ${cmd.name}\`\n\n`;
md += `${cmd.description}\n\n`;
// Usage
const args = cmd.arguments
.map(a => a.required ? `<${a.name}>` : `[${a.name}]`)
.join(' ');
md += `\`\`\`bash\n${toolName} ${cmd.name} ${args}\n\`\`\`\n\n`;
// Arguments
if (cmd.arguments.length > 0) {
md += `**Arguments:**\n\n`;
md += `| Argument | Required | Description |\n`;
md += `|----------|----------|-------------|\n`;
for (const arg of cmd.arguments) {
md += `| \`${arg.name}\` | ${arg.required ? 'Yes' : 'No'} | ${arg.description} |\n`;
}
md += '\n';
}
// Options
if (cmd.options.length > 0) {
md += `**Options:**\n\n`;
md += `| Flag | Description | Default |\n`;
md += `|------|-------------|----------|\n`;
for (const opt of cmd.options) {
const def = opt.default !== undefined ? `\`${opt.default}\`` : '-';
const choices = opt.choices ? ` (${opt.choices.join(', ')})` : '';
md += `| \`${opt.flags}\` | ${opt.description}${choices} | ${def} |\n`;
}
md += '\n';
}
}
return md;
}
Step 3: Auto-Update README
Instead of replacing the entire README, use marker comments:
import { readFile, writeFile } from 'node:fs/promises';
const START_MARKER = '<!-- CLI_DOCS_START -->';
const END_MARKER = '<!-- CLI_DOCS_END -->';
export async function updateReadme(readmePath: string, docs: string): Promise<boolean> {
const readme = await readFile(readmePath, 'utf-8');
const startIdx = readme.indexOf(START_MARKER);
const endIdx = readme.indexOf(END_MARKER);
if (startIdx === -1 || endIdx === -1) {
console.error('README missing markers. Add these where docs should go:');
console.error(` ${START_MARKER}`);
console.error(` ${END_MARKER}`);
return false;
}
const before = readme.slice(0, startIdx + START_MARKER.length);
const after = readme.slice(endIdx);
const updated = `${before}\n\n${docs}\n${after}`;
if (updated === readme) return false; // No changes
await writeFile(readmePath, updated);
return true;
}
In your README:
## Usage
<!-- CLI_DOCS_START -->
(Auto-generated — do not edit manually)
<!-- CLI_DOCS_END -->
Step 4: Generate Man Pages
export function generateManPage(
toolName: string,
version: string,
docs: CommandDoc[],
): string {
let man = `.TH ${toolName.toUpperCase()} 1 "${new Date().toISOString().split('T')[0]}" "v${version}" "${toolName} Manual"\n`;
man += `.SH NAME\n${toolName} \\- ${docs[0]?.description || 'CLI tool'}\n`;
man += `.SH SYNOPSIS\n`;
for (const cmd of docs) {
const args = cmd.arguments.map(a => a.required ? `\\fI${a.name}\\fR` : `[\\fI${a.name}\\fR]`).join(' ');
man += `.B ${toolName} ${cmd.name}\n${args}\n[\\fIoptions\\fR]\n.br\n`;
}
man += `.SH COMMANDS\n`;
for (const cmd of docs) {
man += `.TP\n.B ${cmd.name}\n${cmd.description}\n`;
}
for (const cmd of docs) {
if (cmd.options.length === 0) continue;
man += `.SH OPTIONS (${cmd.name})\n`;
for (const opt of cmd.options) {
man += `.TP\n.B ${opt.flags}\n${opt.description}`;
if (opt.default !== undefined) man += ` (default: ${opt.default})`;
man += '\n';
}
}
return man;
}
Step 5: The Docs Command
Add a built-in docs generation command:
program
.command('docs')
.description('Generate documentation')
.option('--format <fmt>', 'Output format: markdown, man, json', 'markdown')
.option('--update-readme', 'Update README.md in place')
.option('-o, --output <file>', 'Write to file')
.action(async (options) => {
const docs = extractDocs(program);
let output: string;
switch (options.format) {
case 'json':
output = JSON.stringify(docs, null, 2);
break;
case 'man':
output = generateManPage(pkg.name, pkg.version, docs);
break;
default:
output = generateMarkdown(pkg.name, docs);
}
if (options.updateReadme) {
const updated = await updateReadme('README.md', output);
console.log(updated ? chalk.green(' ✓ README.md updated') : chalk.gray(' No changes'));
} else if (options.output) {
await writeFile(options.output, output);
console.log(chalk.green(` ✓ Written to ${options.output}`));
} else {
console.log(output);
}
});
CI Integration
Auto-update docs on every push:
- name: Update CLI docs
run: |
node bin/mytool.js docs --update-readme
git diff --quiet README.md || (
git add README.md
git commit -m "docs: auto-update CLI reference"
git push
)
Conclusion
Auto-generated documentation eliminates documentation debt. Your CLI definition is the source of truth — the docs command just makes it readable. Add it to CI and your README is always current.
Wilson Xu documents his 12+ npm CLI tools automatically. Follow at dev.to/chengyixu.
Top comments (0)