Man-page generation from the Cobra command tree (roff via cobra/doc)¶
- Authors
- Matt Cockayne, Claude (claude-opus-4-8) (AI drafting assistant)
- Date
- 2026-06-21
- Status
- IMPLEMENTED (roadmap item B4, 2026-06-21)
- Driver
- Roadmap item B4 (man-page generation). Linux distribution packages
(
.deb/.rpm) and Homebrew formulae are expected to ship aman <tool>entry; GTB currently produces only Markdown docs and an interactive TUI browser, neither of which a packager can install into/usr/share/man. Companion to the sibling Linux-packaging/signing spec (2026-06-21-linux-package-signing), which consumes the roff output produced here.
Summary¶
GTB today has two documentation surfaces, neither of which produces machine-installable Unix man pages:
gtb generate docs(internal/cmd/generate/docs.go) renders Markdown documentation for a single command's source, optionally AI-assisted, following the docs-site conventions. It does not walk the live Cobra tree β it reads command source and writes.md.gtb docs(pkg/cmd/docs/docs.go) is an interactive Bubble Tea markdown browser over embeddedassets/docs, withask/servesubcommands. The navigation model lives inpkg/docs/docs.go(NavNode,MkDocsConfig,parseMkDocsNav).
Neither emits roff. A Linux packager building a .deb/.rpm, or a Homebrew
formula, has no artefact to drop into man1/.
This spec adds roff man-page generation rendered directly from the live
Cobra command tree using the upstream github.com/spf13/cobra/doc
subpackage (GenManTree / GenManHeader), already transitively available via
the pinned github.com/spf13/cobra v1.10.2 (go.mod:42). It is delivered as
both of the following, sharing one library entry point:
- A build-time generator step β
gtb generate man(new fileinternal/cmd/generate/man.go), the artefact path packagers and CI use. It writesman1/<tool>.1,man1/<tool>-<sub>.1, β¦ into an output directory. - A hidden runtime
mancommand (newpkg/cmd/man/), gated behind a new default-offprops.ManCmdfeature, so a tool's own binary can emit its pages on demand (e.g. a packaging postinstall, ormytool man --dir) without re-running the generator against source.
Both call a single new exported function in pkg/docs:
This keeps the roff-rendering policy (header metadata, section, output layout) in one library location per the library-first rule, with the two command surfaces as thin callers.
The existing Markdown generation is untouched; man pages are a parallel, additive output.
Goals¶
- Produce standards-compliant roff man pages for the entire command tree of
any GTB-based tool, named
man1/<command-path>.1(e.g.gtb.1,gtb-generate.1,gtb-generate-man.1). - Populate the
.THheader (Title,Section,Source,Manual,Date) fromprops.Tool/props.Versionrather than cobra's default "Auto generated by spf13/cobra" placeholder. - Expose generation as a build-time
gtb generate manstep usable from CI,just, and the sibling packaging spec. - Expose an opt-in runtime
mancommand (hidden, default-off feature) for tools that want self-emission. - Keep the rendering policy in
pkg/docsso downstream tools reuse it.
Non-goals¶
- No replacement of the Markdown docs generation or the
docsTUI browser. - No
man2βman9sections; CLI tools are section 1 only. - No embedding of pre-rendered roff into the binary's
assets/. Pages are rendered from the live tree at generate/runtime, so they never drift from the command set. (Packaging embeds them in the package, not the binary.) - No AI involvement. Roff is deterministically rendered from cobra metadata;
there is no provider call, unlike
generate docs. - No man-page content authoring DSL. Long descriptions come from the
command's existing
Long/Short/Examplefields. Improving those is a per-command concern, not this spec's.
Verified upstream API (cobra v1.10.2 doc)¶
Confirmed present in the module cache at
github.com/spf13/[email protected]/doc/man_docs.go:
// GenManTree generates a man page for cmd and every descendant, writing
// each to dir as <dashed-command-path>.<section>.
func GenManTree(cmd *cobra.Command, header *GenManHeader, dir string) error
func GenManTreeFromOpts(cmd *cobra.Command, opts GenManTreeOptions) error
// GenMan writes a single command's man page to w.
func GenMan(cmd *cobra.Command, header *GenManHeader, w io.Writer) error
type GenManHeader struct {
Title string
Section string
Date *time.Time
Source string
Manual string
// (unexported `date` cache)
}
Notes that shape the design:
GenManTreewalkscmd.Commands()recursively and dashes the command path for filenames; we pass the root command to cover the whole tree.fillHeaderdefaultsSectionto"1"and stamps a "Auto generated by spf13/cobra" source unlessSourceis set β we always setSource/Manualso the placeholder never ships.- The renderer honours
cmd.DisableAutoGenTag; we set it to suppress the date-stamped trailer that would otherwise make output non-reproducible (see Open Question 3). GenManTreeFromOptsaccepts aCommandSeparatorand aHeaderβ we will prefer it overGenManTreeto keep filenames stable across cobra changes.
Design¶
Library entry point β pkg/docs/man.go¶
package docs
import (
"time"
"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"
)
// ManOptions configures roff man-page generation for a command tree.
type ManOptions struct {
// Dir is the output directory; pages are written as man1/<path>.1.
Dir string
// Title is the upper-cased program name for the .TH line (defaults to
// the root command's Name, upper-cased).
Title string
// Section is the man section; defaults to "1".
Section string
// Source/Manual populate the .TH footer (e.g. "gtb 0.16.0", "GTB Manual").
Source string
Manual string
// Date, when nil, is omitted via DisableAutoGenTag for reproducibility.
Date *time.Time
}
// GenerateManTree renders roff man pages for root and all descendants into
// opts.Dir, applying GTB's header policy. It is the single rendering seam
// used by both `gtb generate man` and the runtime `man` command.
func GenerateManTree(root *cobra.Command, opts ManOptions) error
Responsibilities:
- Derive
Title/Section/Source/Manualdefaults fromoptsand the root command when fields are blank. - Set
root.DisableAutoGenTag = truewhenopts.Date == nil. - Create
opts.Dir(usingoshere is acceptable β this is a developer/ packaging tool path, not aprops.FS-injected runtime data path; mirror the generator's existing direct-FS usage and justify any gosec G301/G304 with a narrow inline//nolint). - Call
doc.GenManTreeFromOptsand wrap errors withcockroachdb/errors.
Build-time command β internal/cmd/generate/man.go¶
New NewCmdMan(p *props.Props) *cobra.Command registered alongside
NewCmdDocs in the generate group (internal/cmd/generate/). Shape mirrors
docs.go:
--dir(default./man) β output directory.- Builds the target tool's root command tree to render. For GTB itself this
is the gtb root; the command documents gtb's own tree. (Rendering a
scaffolded downstream tool's tree is a non-goal here β that tool runs its
own
mytool man; see Open Question 1.) - Calls
docs.GenerateManTree. - Respects the generator's existing
--dry-runglobal (generator.Config.DryRun) by listing intended files instead of writing.
A just man recipe and a CI artefact step are added so packaging picks the
pages up (coordinated with the sibling packaging spec).
Runtime command β pkg/cmd/man/man.go¶
New NewCmdMan(p *props.Props) *setup.Command, hidden, gated behind the new
props.ManCmd feature (default off). Mirrors the version/docs command
shape in pkg/cmd/.
- With
--dir: writes the tree to disk (postinstall / packaging use). - Without
--dir: renders the current command's page to stdout, so a user canmytool generate man | man -l -style preview (exact single-page UX in Open Question 2). - Obtains the root via
cmd.Root()so it documents the live tree including self-registered tool commands. cmd.Hidden = trueβ present for packaging/power users, absent from--helplistings, consistent with its default-off feature.
Feature flag β pkg/props/tool.go¶
Add a new feature constant and leave it out of DefaultFeatures:
- Default-off: most tools ship man pages via packaging, not a runtime
subcommand; adding a visible verb to every tool is undesirable. A tool opts in
with
props.SetFeatures(props.Enable(props.ManCmd)). - Add
ManCmdto theIsBuiltInswitch (tool.go:184) so it is recognised as a framework feature. - The root wires
pkg/cmd/manonly whenIsEnabled(ManCmd), exactly like the other built-ins inpkg/cmd/root/root.go.
Relationship to the Linux packaging spec¶
2026-06-21-linux-package-signing is the consumer. The contract this spec
provides to it:
gtb generate man --dir <out>(and, for downstream tools,mytool man --dir) producesman1/*.1files in a stable, packager-friendly layout.- goreleaser
nfpms[].contentsmapsman/man1/*.1β/usr/share/man/man1/(gzip handled by the packager);brews[].extra_installinstalls the same pages. The packaging spec owns those goreleaser stanzas β there is nonfpms:/brews:block in.goreleaser.yamltoday (onlybuilds:andarchives:), so they are introduced there, not here. - This spec guarantees only the artefact; the packaging spec owns where it lands and how it is signed.
Decision log¶
Status: open β nothing rejected. Recording the deliberate choices for reviewer confirmation. No conflict was found with any existing or in-flight spec: there is no prior man-page spec,
cobra/docis currently unused anywhere in the tree (no clash with existing rendering), and the sibling Linux-packaging spec is a downstream consumer rather than an overlap.
| # | Decision | Rationale | Alternatives considered |
|---|---|---|---|
| D1 | Render from the live Cobra tree via cobra/doc, not from Markdown source |
Single source of truth; pages can't drift from the actual command set; zero new rendering code to maintain | Convert existing .md β roff (pandoc dep, drift, AI-shaped content unsuitable for roff) |
| D2 | Ship both a build-time generate man and a hidden runtime man |
Packagers/CI need a source-tree step; downstream tools need self-emission without the generator | Build-time only (no self-emit); runtime only (CI can't render without running the binary) β both rejected as incomplete |
| D3 | Single library seam pkg/docs.GenerateManTree |
Library-first; both commands are thin callers; header policy lives once | Duplicate doc.GenManTree calls in each command |
| D4 | Runtime man feature is default-off and hidden |
Most tools distribute pages via packages; a visible verb on every tool is noise | Default-on (clutters every tool's help) |
| D5 | Always set Source/Manual; suppress the auto-gen tag by default |
Avoid the cobra "Auto generated by spf13/cobra" placeholder; reproducible output for packaging | Accept defaults (non-reproducible, placeholder footer) |
Open questions (resolve before implementation)¶
- Downstream tree rendering. Should
gtb generate manbe able to render a scaffolded downstream tool's tree (analogous to howgenerate docstargets a command in a project), or is rendering strictly the running binary's own tree (gtb documents gtb; mytool documents mytool)? The current draft assumes own-tree only, which is simpler and matches howGenManTreeconsumes a live*cobra.Command. Confirm. - Single-page stdout UX. For runtime
manwithout--dir, do we emit the current command's roff to stdout (pipe toman -l -), or always require a directory? Draft assumes stdout-for-current-command as a convenience. - Reproducibility / date stamping. Default to
DisableAutoGenTag = true(no date footer, byte-reproducible) versus stamping the build/release date into.TH. Draft prefers reproducible-by-default with an opt-in--date. Confirm this is acceptable for distro packaging norms. - Output layout.
man/man1/*.1(FHS-style subdir) vs a flatman/*.1. Draft usesman1/to match goreleaser/nfpm expectations; confirm with the packaging spec author so both halves agree. md2mandependency surface.cobra/docpulls incpuguy83/go-md2man/v2transitively. Confirm this is acceptable in the FIPS/CGO-disabled build and does not tripjust vuln. (It is pure-Go; no expected issue, but flag it for the dependency review.)
Resolutions (open questions confirmed with user 2026-06-21)¶
- Downstream tree rendering β RESOLVED: own-tree only. gtb documents
gtb; a scaffolded
mytooldocumentsmytool. Matches howGenManTreeconsumes a live*cobra.Command; no downstream-tree targeting. - Single-page stdout UX β RESOLVED: runtime
manwithout--diremits the current command's roff to stdout (pipe toman -l -).--dirwrites the tree. - Reproducibility / date stamping β RESOLVED: reproducible by default
(
DisableAutoGenTag = true, no date footer, byte-identical output) with an opt-in--dateto stamp the build/release date. - Output layout β RESOLVED:
man/man1/*.1(FHS-style subdir) to match goreleaser/nfpm expectations and the/usr/share/man/man1/install contract; keeps this spec aligned with2026-06-21-linux-package-signing. md2mandependency surface β RESOLVED: accept the transitivecpuguy83/go-md2man/v2dependency (pure-Go, rides in withcobra/doc), subject to the standardjust vulncheck during implementation. No special handling expected for the FIPS/CGO-disabled build.
Test plan¶
Per the β₯90% pkg/ coverage policy and the table-driven t.Parallel() norm:
pkg/docs/man_test.go:GenerateManTreeover a small fixture command tree writes the expectedman1/*.1files with one file per command/subcommand.- Header policy:
.THline contains the configuredTitle/Section/Source/Manual, and never the cobra auto-gen placeholder. DisableAutoGenTagpath produces byte-identical output across two runs (reproducibility assertion).- Error wrapping on an unwritable
Dir(useafero/temp + permission or a closed dir) returns acockroachdb/errors-wrapped error. internal/cmd/generate/man_test.go: flag wiring, default--dir,--dry-runlists-not-writes (purelogic-style, no real cobra/doc call needed for the flag-parse cases).pkg/cmd/man/man_test.go: command is hidden; registered only whenManCmdis enabled;--dirwrites, no---diremits current page to stdout.pkg/props/tool_test.go:ManCmdrecognised byIsBuiltIn; absent fromDefaultFeatures(default-off assertion).- BDD: a
features/Gherkin scenario β "Given a tool with the man feature enabled, When I runman --dir, Thenman1/<tool>.1exists and contains the tool name" β per the CLI-command BDD requirement.
Documentation impact¶
docs/components/docs.mdβ new "Man-page generation" section documentingpkg/docs.GenerateManTreeandManOptions.docs/components/commands/β entries forgtb generate manand the runtimemancommand (noting the default-offManCmdfeature and how to enable it).- Cross-reference from
2026-06-21-linux-package-signingonce that spec lands, documenting theman1/*.1β/usr/share/man/man1/contract. just manrecipe documented in the command table.