DEV Community

Wilson Xu
Wilson Xu

Posted on

Generate Documentation for Your CLI Tool Automatically

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);
Enter fullscreen mode Exit fullscreen mode

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)),
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

In your README:

## Usage

<!-- CLI_DOCS_START -->
(Auto-generated — do not edit manually)
<!-- CLI_DOCS_END -->
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
  });
Enter fullscreen mode Exit fullscreen mode

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
    )
Enter fullscreen mode Exit fullscreen mode

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)