← Back
kofiadeyemiq

kofiadeyemiq/commander-mangen

Generate man pages from a commander.js program, including every subcommand's options.

View on GitHub ↗
clicommanderdocumentationman-pagenodejsroff
Stars
34
Forks
11
Watchers
34
Open issues
0
Contributors
1
Language
TypeScript
License
MIT License
Default branch
main
Created Sep 28, 2026Updated Sep 28, 2026

Star growth

Today—
This week—
This month—

Star history will appear here once this repo has been tracked for a couple of days.

README

commander-mangen

Generate roff man pages from a commander.js Command tree, including subcommand options that help2man can't see.

import { Command } from 'commander';
import { writeManPages } from 'commander-mangen';

const program = new Command();
program.name('tool').description('a demo tool').version('1.0.0');
program.command('build').description('build the project').option('-w, --watch', 'watch for changes');

await writeManPages(program, { outDir: 'man' });
// man/tool.1, man/tool-build.1

Why

help2man builds a man page by running a program's --help (and --version) and scraping the text. That works for a single flat command, but it has no concept of subcommands living in one process: it lists a subcommand's name in the parent's Commands: block and stops there. It never runs tool build --help, so a subcommand's own options never make it into the page.

Measured: help2man --no-info ./tool.mjs, where tool.mjs is a commander program with a build subcommand carrying -w, --watch and -o, --output <dir>:

.\" DO NOT MODIFY THIS FILE!  It was generated by help2man 1.49.3.
.TH TOOL.MJS "1" "September 2026" "tool.mjs 1.0.0" "User Commands"
.SH NAME
tool.mjs \- a demo tool
.SH SYNOPSIS
.B tool
[\fI\,options\/\fR] [\fI\,command\/\fR]
.SH DESCRIPTION
a demo tool
.SH OPTIONS
.TP
\fB\-V\fR, \fB\-\-version\fR
output the version number
.TP
\fB\-h\fR, \fB\-\-help\fR
display help for command
.SS "Commands:"
.TP
build [options]
build the project
.TP
help [command]
display help for command

build [options] is the entire footprint of the build subcommand. -w/--watch and -o/--output <dir> are not in this file, anywhere, at any indentation.

commander-mangen reads the same Command tree that produces --help, before any text gets flattened, and writes one page per command (node examples/generate.ts reproduces this exact output):

.TH "TOOL-BUILD" "1" "September 28, 2026" "tool 1.0.0" "User Commands"
.SH "NAME"
tool\-build \- build the project
.SH "SYNOPSIS"
\fBtool build\fR [\fIoptions\fR]
.SH "DESCRIPTION"
build the project
.SH "OPTIONS"
.TP
\fB\-w, \-\-watch\fR
watch for changes
.TP
\fB\-o, \-\-output <dir>\fR
output directory (default: "dist")
.TP
\fB\-h, \-\-help\fR
display help for command
.SH "SEE ALSO"
.BR tool (1)

Checked for an existing tool that does this: npm has markdown-to-man converters (marked-man, remark-man), a man-page generator for a different CLI parser (@optique/man), and a Fig-completion exporter for commander (@fig/complete-commander, which proves the Command tree is introspectable but emits a completion spec, not roff); Python's equivalents (click-man, argparse-manpage) are for a different ecosystem. None of them walk a live commander tree into man pages.

Installation

npm install commander-mangen

Requires commander (peer dependency, tested against 15.x) already installed as part of your CLI.

Usage

From code

import { Command } from 'commander';
import { generateManPages, writeManPages } from 'commander-mangen';

const program = new Command();
program.name('tool').version('1.2.0');
// ... .command(), .option(), .argument() ...

// Get the pages as strings:
const pages = generateManPages(program, { date: 'January 1, 2026' });
// [{ name: 'tool', section: 1, content: '...' }, { name: 'tool-build', ... }, ...]

// Or write them straight to disk:
await writeManPages(program, { outDir: 'man' });

Call this after building the Command tree and before (or instead of) calling .parse() — generation only reads the tree, it doesn't run any command.

From the CLI

Export the Command from a module without calling .parse() on it:

// bin/program.ts
import { Command } from 'commander';
export const program = new Command();
program.name('tool')...;
// do not call program.parse() here
npx commander-mangen bin/program.ts --out man/

Importing the module runs its top-level code — anything outside the exported Command definition runs too. Keep side effects (like .parse()) out of the exported module, or point the CLI at a small wrapper module that only builds and exports the Command.

Adding EXAMPLES, ENVIRONMENT, AUTHOR, ...

await writeManPages(program, {
  outDir: 'man',
  extraSections: [{ heading: 'AUTHOR', body: 'Jane Doe <jane@example.com>' }],
  extraSectionsByCommand: {
    build: [{ heading: 'EXAMPLES', body: 'tool build src/main.ts\ntool build --watch', preformatted: true }],
  },
});

Reference

generateManPages(program, options?) => ManPage[]

Walks program's command tree and returns one ManPage per visible command, without touching the filesystem.

ManPage: { name: string; section: number; content: string }. name is the page's slug without the section suffix ("tool-build"); write it as ${name}.${section}.

writeManPages(program, options) => Promise<ManPage[]>

Same as generateManPages, then writes each page to ${options.outDir}/${page.name}.${page.section}, creating outDir if needed. Returns the same ManPage[].

Options

Option Type Default Description
section number 1 Man section number.
manual string "User Commands" .TH manual field.
source string "<name> <version>" (or just <name>) .TH source field.
date string today, "Month Day, Year" .TH date field. Pass a fixed value for reproducible output.
seeAlso boolean true Include a SEE ALSO section cross-referencing parent/sibling/child pages.
extraSections ManSection[] [] Extra sections appended to the top-level page.
extraSectionsByCommand Record<string, ManSection[]> {} Extra sections for one command's page, keyed by its path relative to the program, space-separated ("build", "build watch", "" for the top-level page).

outDir (string, required) is writeManPages-only: the directory pages are written into.

ManSection: { heading: string; body: string; preformatted?: boolean }. body is split into paragraphs on blank lines and reflowed by default; set preformatted: true to keep line breaks as written (for command examples), rendered under .nf/.fi.

What ends up on a page

  • NAME — the page's slug, plus one entry per .alias(), then the command's .summary() or .description().
  • SYNOPSIS — command path, [options] if it has any, each argument bracketed (<required>, [optional], trailing ... if variadic), [command] if it has visible subcommands.
  • DESCRIPTION — .description() (falls back to .summary()).
  • ARGUMENTS — one entry per argument, only if at least one has a description (matches commander's own --help behavior).
  • OPTIONS — one entry per visible option: flags, description, and whatever commander itself would print — defaults, choices, env var, negated form.
  • COMMANDS — one entry per visible direct subcommand, on pages that have any.
  • Any extraSections / extraSectionsByCommand sections, in the order given.
  • SEE ALSO — cross-references to the parent page, sibling pages, and this command's own child pages.

Hidden commands and hidden options (anything commander itself would hide from --help) are excluded, via Help#visibleCommands/visibleOptions.

CLI

commander-mangen <module> --out <dir> [options]
Flag Description
--export <name> Named export holding the Command (default: tries program, then the default export).
--out <dir> Directory to write pages into. Required.
--section <n> Man section number. Default 1.
--manual <text> .TH manual field.
--source <text> .TH source field.
--date <text> .TH date field.
--no-see-also Omit the SEE ALSO section.
-h, --help Show usage.

Exit codes: 0 success, 2 could not generate pages (bad arguments, the module couldn't be imported, or it has no Command export). Exit code 1 is not used — there's no "findings" state for a generator, only success or failure.

How it works

Everything is read through commander's public Command/Option/Argument/Help API: Command#commands, #options, #registeredArguments, #name(), #description(), #summary(), #aliases(), and — for visibility and the exact text commander's own --help would print — Command#createHelp() and the resulting Help's visibleCommands/visibleOptions/visibleArguments, subcommandTerm/subcommandDescription, optionTerm/optionDescription, commandUsage, and argumentTerm/argumentDescription. Nothing here reaches into commander's private (_-prefixed) fields; man-page generation and --help stay in sync because they're driven by the same public methods.

Generated text goes through two escaping paths before it hits roff: free-form prose (descriptions) gets a literal-backslash escape and a leading-dot/leading-quote guard (\&), since a line starting with . or ' would otherwise be read as a troff request; flag/command text (SYNOPSIS, the OPTIONS/COMMANDS term column) also gets every hyphen rendered as \-, per man-pages(7). Prose is word-wrapped across source lines at ~70 columns for readability and to keep mandoc -T lint quiet about long source lines — this never changes rendered output, since roff reflows fill-mode text regardless of input line breaks. It does mean the leading-dot/quote guard has to be re-applied per output line, not once to the string as a whole: wrapping can move any word — not just the first — to the start of a line, and a guard that only checked the original string's start would miss a ./'-prefixed word that lands at a line break introduced by wrapping. wrap() re-runs that guard on every line it produces.

Limitations

  • Executable subcommands are not introspectable. A subcommand declared with .command('name', 'description') (commander's two-argument, separate-executable form) delegates to another process at runtime; commander never loads that process's own options, and there's no public API to even tell such a command apart from an ordinary subcommand that simply has no options of its own. Both render the same way: NAME/SYNOPSIS/DESCRIPTION plus -h, --help. If the executable has real options, generate its page separately by pointing commander-mangen at its own Command export, or write that page by hand.
  • yargs is out of scope. Its command/option introspection lives on private internals, not a public API comparable to commander's Help class. Supporting it would mean depending on yargs internals that can change without a semver bump.
  • The CLI needs a runtime that can load your module. commander-mangen dynamic-import()s the module you point it at. A plain .js/.mjs module works on any supported Node. A .ts module needs a Node that can strip types on the fly (22.6+ with a flag, or 24+ by default) or a module you've already built to JS.
  • Only one Command tree per invocation; no batching multiple CLIs into one run.
  • No .1.gz compression, no install(1)-style install-tree writing — pages land in outDir as plain files.

Compatibility

Tested on Node 24.20.0 (macOS) against commander 15.0.0. Help#subcommandTerm / #argumentTerm / etc. and Argument#choices/Option#choices are present back to commander 9–11 in commander's own changelog, but only 15.x was actually run here — treat older 12.x/13.x/14.x as likely-compatible, not verified.

The .github/workflows/test.yml CI matrix (Node 20.6, 22, 24, on Linux) runs the built package's smoke tests; only the local run above is "tested" by the house standard for this package.

Development

npm install
npx tsc --noEmit                  # typecheck
node --test "test/*.test.ts"      # full suite (needs node 24+ for type stripping)
npm run build && npm run test:dist   # what CI runs against node 20 and 22

The roff/man lint test (test/lint.test.ts) runs groff -man -ww -z if available, falls back to mandoc -T lint, and skips loudly (not silently) if neither is installed.

License

MIT