Skip to content

About

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

Topics

Resources

Stars

73 stars

Watchers

0 watching

Forks

Repository files navigation

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

About

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

Topics

Resources

Stars

73 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages