Skip to content

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 a man <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:

  1. 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.
  2. gtb docs (pkg/cmd/docs/docs.go) is an interactive Bubble Tea markdown browser over embedded assets/docs, with ask/serve subcommands. The navigation model lives in pkg/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 file internal/cmd/generate/man.go), the artefact path packagers and CI use. It writes man1/<tool>.1, man1/<tool>-<sub>.1, … into an output directory.
  • A hidden runtime man command (new pkg/cmd/man/), gated behind a new default-off props.ManCmd feature, so a tool's own binary can emit its pages on demand (e.g. a packaging postinstall, or mytool man --dir) without re-running the generator against source.

Both call a single new exported function in pkg/docs:

// pkg/docs/man.go
func GenerateManTree(root *cobra.Command, opts ManOptions) error

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 .TH header (Title, Section, Source, Manual, Date) from props.Tool / props.Version rather than cobra's default "Auto generated by spf13/cobra" placeholder.
  • Expose generation as a build-time gtb generate man step usable from CI, just, and the sibling packaging spec.
  • Expose an opt-in runtime man command (hidden, default-off feature) for tools that want self-emission.
  • Keep the rendering policy in pkg/docs so downstream tools reuse it.

Non-goals

  • No replacement of the Markdown docs generation or the docs TUI browser.
  • No man2–man9 sections; 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/Example fields. 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:

  • GenManTree walks cmd.Commands() recursively and dashes the command path for filenames; we pass the root command to cover the whole tree.
  • fillHeader defaults Section to "1" and stamps a "Auto generated by spf13/cobra" source unless Source is set β€” we always set Source/ Manual so 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).
  • GenManTreeFromOpts accepts a CommandSeparator and a Header β€” we will prefer it over GenManTree to 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/Manual defaults from opts and the root command when fields are blank.
  • Set root.DisableAutoGenTag = true when opts.Date == nil.
  • Create opts.Dir (using os here is acceptable β€” this is a developer/ packaging tool path, not a props.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.GenManTreeFromOpts and wrap errors with cockroachdb/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:

gtb generate man [--dir DIR] [--section N] [--source S] [--manual M]
  • --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-run global (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/.

<tool> man [--dir DIR]
  • With --dir: writes the tree to disk (postinstall / packaging use).
  • Without --dir: renders the current command's page to stdout, so a user can mytool 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 --help listings, consistent with its default-off feature.

Feature flag β€” pkg/props/tool.go

Add a new feature constant and leave it out of DefaultFeatures:

const (
    // … existing …
    ManCmd = FeatureCmd("man")
)
  • 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 ManCmd to the IsBuiltIn switch (tool.go:184) so it is recognised as a framework feature.
  • The root wires pkg/cmd/man only when IsEnabled(ManCmd), exactly like the other built-ins in pkg/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) produces man1/*.1 files in a stable, packager-friendly layout.
  • goreleaser nfpms[].contents maps man/man1/*.1 β†’ /usr/share/man/man1/ (gzip handled by the packager); brews[].extra_install installs the same pages. The packaging spec owns those goreleaser stanzas β€” there is no nfpms:/brews: block in .goreleaser.yaml today (only builds: and archives:), 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/doc is 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)

  1. Downstream tree rendering. Should gtb generate man be able to render a scaffolded downstream tool's tree (analogous to how generate docs targets 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 how GenManTree consumes a live *cobra.Command. Confirm.
  2. Single-page stdout UX. For runtime man without --dir, do we emit the current command's roff to stdout (pipe to man -l -), or always require a directory? Draft assumes stdout-for-current-command as a convenience.
  3. 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.
  4. Output layout. man/man1/*.1 (FHS-style subdir) vs a flat man/*.1. Draft uses man1/ to match goreleaser/nfpm expectations; confirm with the packaging spec author so both halves agree.
  5. md2man dependency surface. cobra/doc pulls in cpuguy83/go-md2man/v2 transitively. Confirm this is acceptable in the FIPS/CGO-disabled build and does not trip just vuln. (It is pure-Go; no expected issue, but flag it for the dependency review.)

Resolutions (open questions confirmed with user 2026-06-21)

  1. Downstream tree rendering β€” RESOLVED: own-tree only. gtb documents gtb; a scaffolded mytool documents mytool. Matches how GenManTree consumes a live *cobra.Command; no downstream-tree targeting.
  2. Single-page stdout UX β€” RESOLVED: runtime man without --dir emits the current command's roff to stdout (pipe to man -l -). --dir writes the tree.
  3. Reproducibility / date stamping β€” RESOLVED: reproducible by default (DisableAutoGenTag = true, no date footer, byte-identical output) with an opt-in --date to stamp the build/release date.
  4. 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 with 2026-06-21-linux-package-signing.
  5. md2man dependency surface β€” RESOLVED: accept the transitive cpuguy83/go-md2man/v2 dependency (pure-Go, rides in with cobra/doc), subject to the standard just vuln check 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:
  • GenerateManTree over a small fixture command tree writes the expected man1/*.1 files with one file per command/subcommand.
  • Header policy: .TH line contains the configured Title/Section/ Source/Manual, and never the cobra auto-gen placeholder.
  • DisableAutoGenTag path produces byte-identical output across two runs (reproducibility assertion).
  • Error wrapping on an unwritable Dir (use afero/temp + permission or a closed dir) returns a cockroachdb/errors-wrapped error.
  • internal/cmd/generate/man_test.go: flag wiring, default --dir, --dry-run lists-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 when ManCmd is enabled; --dir writes, no---dir emits current page to stdout.
  • pkg/props/tool_test.go: ManCmd recognised by IsBuiltIn; absent from DefaultFeatures (default-off assertion).
  • BDD: a features/ Gherkin scenario β€” "Given a tool with the man feature enabled, When I run man --dir, Then man1/<tool>.1 exists and contains the tool name" β€” per the CLI-command BDD requirement.

Documentation impact

  • docs/components/docs.md β€” new "Man-page generation" section documenting pkg/docs.GenerateManTree and ManOptions.
  • docs/components/commands/ β€” entries for gtb generate man and the runtime man command (noting the default-off ManCmd feature and how to enable it).
  • Cross-reference from 2026-06-21-linux-package-signing once that spec lands, documenting the man1/*.1 β†’ /usr/share/man/man1/ contract.
  • just man recipe documented in the command table.