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.1help2man 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.
npm install commander-mangenRequires commander (peer dependency, tested against 15.x) already installed as
part of your CLI.
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.
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() herenpx 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.
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 }],
},
});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}.
Same as generateManPages, then writes each page to
${options.outDir}/${page.name}.${page.section}, creating outDir if needed.
Returns the same ManPage[].
| 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.
- 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
--helpbehavior). - 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/extraSectionsByCommandsections, 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.
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.
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.
- 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 ownCommandexport, 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
Helpclass. 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-mangendynamic-import()s the module you point it at. A plain.js/.mjsmodule works on any supported Node. A.tsmodule 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
Commandtree per invocation; no batching multiple CLIs into one run. - No
.1.gzcompression, noinstall(1)-style install-tree writing — pages land inoutDiras plain files.
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.
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 22The 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.
MIT