Skip to content

Your tasks, documented

Footman ships a first-party plugin that renders a project's task tree as markdown: the same names, params, docstring help, defaults, and examples that fm --help shows, as pages you can publish. Everything on this page is dogfooded: the task reference in this site's nav is generated output, and the sample further down is embedded live.

Write plain prose

Footman surfaces a docstring, and a doc() string, as plain text: verbatim in fm --help, so it reads in any terminal and survives a pipe (fm --help build | less) byte for byte, and as plain paragraphs in the exported markdown. It renders no rich markup in the terminal: no bold, no headings, no reflowed tables. That is deliberate: footman is zero-dependency, and plain text is the one format every terminal, pager, and CI log agrees on.

So write your help as prose. A sentence or two that reads straight is worth more than markup footman won't paint, and it exports cleanly either way. The one styling footman does apply is colour, to what footman itself prints (names, receipts, the progress line) and, through the tools it spawns, to their output, never injected into your text. Your task's own stdout is yours; footman routes it untouched.

(A future opt-in could render markdown in the terminal too, see the roadmap, but it stays off by default and never becomes a dependency.)

Mount it

The plugin mounts like any other, in one line in your tasks file:

from footman import plugin

plugin("footman.docs")

That's also the one-line demo of the plugin system: the entry point is the identity, into= is your placement, and after the mount fm --list shows docs.page and docs.site. (Cherry-pick with only=, or pass into="footman" to nest the group deeper, as footman.docs.page.)

One page: fm docs.page

fm docs.page > TASKS.md          # the whole tree, one document
fm docs.page --target=docs       # just one group…
fm docs.page --target=docs.build # …or one task
fm docs.page --out=TASKS.md      # write the file directly

The page goes to stdout (stdout is the answer; footman's summary is stderr commentary), so it pipes:

fm docs.page | pandoc -o tasks.pdf     # or .html, .docx, …

--heading=2 (up to 6) makes the headings start deeper, so the output nests under a host page's own title, which is exactly how the sample below is embedded, via a pymdownx.snippets include of a file the docs build regenerates:

### docs { #docs }

Documentation site (Zensical)

#### docs.serve { #docs-serve }

Build and serve the docs with live reload.

```text
fm docs.serve

Example

$ fm docs.serve

docs.coverage

Generate the coverage HTML report into docs/htmlcov (embedded in the site).

fm docs.coverage

Returns: The measured total and where the HTML report landed. — CoverageReport {percent, statements, missing, report}

Field Type
percent a number
statements an integer
missing an integer
report a path

Example

$ fm docs.coverage

docs.api

Generate the API reference from the export table; refuse on drift.

fm docs.api [--out=PATH]
Parameter Type Default Description
--out=PATH path "docs/api.md"

Example

$ fm docs.api

docs.build

Build the docs site into ./site; regenerates llms.txt and docs/tasks/.

Not unit-tested, and deliberately: the body is orchestration over zensical, a pty screenshotter and five real shells, so a test could only stub twenty collaborators and assert the call order back — a change detector that passes while the site breaks. Its real test is CI's strict docs build, which runs the whole thing against the actual tools. The pieces with logic of their own — the llms.txt generator, the cast guard, the scratch projects — are tested separately, where a test can say something true.

fm docs.build [--check]
Parameter Type Default Description
--check flag false build strictly (what CI runs)

Example

$ fm docs.build --check
`--flavor=plain` (the default) is pure CommonMark and pipe tables, safe for
pandoc and any renderer. `--flavor=material` opts into what a
zensical/mkdocs-material site already understands: heading anchors for
stable deep links and an `!!! example` admonition for the synthesized
invocation.

## A linked site: `fm docs.site`

```console
$ fm docs.site docs/tasks
wrote 20 pages under docs/tasks

One file per task, an index.md per group with relative links, directories mirroring your group tree: drop it into your docs source and put the index in your nav. This site's Task reference section is exactly that, wired into zensical.toml's nav. site defaults to --flavor=material because a docs site is where it lands; pass --flavor=plain for anything else.

The runner itself: fm docs.globals

Your tasks aren't the only thing worth documenting; the runner's global options deserve a page too. globals renders them as a markdown table straight from the CLI grammar: the same rows, in the same order, with the same words --help prints. This site's CLI reference table is exactly that, regenerated on every docs build, so it cannot drift, because it was never written by hand.

$ fm docs.globals --out=_generated/globals.md
wrote _generated/globals.md

Terminal screenshots: fm docs.shots

Prose about colours drifts the moment the palette changes; a screenshot generated from the CLI cannot. shots runs a command on a real pseudo-terminal (colours, receipts, taught errors, exactly as a terminal shows them), collapses the live rewrites to their final frame, and renders the capture as an SVG in a macOS-style window:

$ fm docs.shots --out=docs/_generated/shots/run.svg -- format lint
wrote docs/_generated/shots/run.svg

Everything after -- is the command line to capture; --width sets the terminal columns, --title the window title, and --cmd swaps the executable (default: the CLI that invoked it, so a branded CLI screenshots itself; App is a library, and the screenshotter simply runs your CLI). The command really executes, so don't screenshot tasks whose side effects you don't want. Every terminal image on this site is one of these, regenerated by the docs build.

This task needs rich and a POSIX pseudo-terminal. Neither is a footman dependency: the task is gated with footman's own @requires_dep("rich"), so without rich it lists as shots … (unavailable: requires rich) and refuses to run with that message; add rich to your docs dependency group and it comes alive. Footman documenting itself with its own availability machinery is exactly the use @requires_dep was built for.

Animated sessions: fm docs.cast

A static frame can't show Tab completion. cast boots a real interactive shell (zsh, bash, fish, pwsh, or nushell) from a scratch config with footman's hook loaded, types a keystroke script, and replays the captured bytes through a terminal emulator into an animated SVG: CSS keyframes with the session's own timing, no JavaScript, plays anywhere an image does. The session even answers its shell's terminal interrogations (capability, cursor, and colour queries) the way a plain xterm would, since modern shells refuse to paint a prompt into silence:

$ fm docs.cast --out=docs/_generated/shots/zsh-cast.svg \
      --shell=zsh -- "fm " "<TAB>" "<WAIT>" "che" "<TAB>" "<ENTER>" "<WAIT:2500>"
wrote docs/_generated/shots/zsh-cast.svg (55 frames)

Everything after -- is the script: plain text is typed at a human-ish cadence; <TAB>, <ENTER>, <SPACE>, <BACKSPACE>, <CTRL-C>, <WAIT>, and <WAIT:ms> are keys. The shell really runs what you type: the recording on the zsh completion page ends with a real fm check. Needs rich and pyte (the terminal emulator), gated the same way: without them the task lists as unavailable and says which package to add.

Keep it fresh

Generated pages drift unless a build regenerates them. The tasks are plain functions, so footman's own docs task calls them directly. Copy the shape:

from pathlib import Path
from footman import group

docs = group("docs", help="Documentation")

@docs.task(name="build")
def docs_build(check: bool = False):
    "Build the docs site; regenerates the task reference first."
    from footman.tasks.docs import globals_, page, site
    from toolroom import zensical

    site(Path("docs/tasks"))
    page(target="docs", heading=3, out=Path("_generated/tasks-page.md"))
    globals_(out=Path("_generated/globals.md"))
    zensical.build(clean=True, strict=check)

Add the generated paths to .gitignore: they're build output, not source. Under --json, both tasks return the list of files they wrote, so returned carries it for CI to verify.

Two flags to know. Usage lines and examples carry the CLI you invoked, so a branded CLI documents itself as acme with no flag at all, and --prog overrides the name when you need to. --all includes the mounted footman group itself (excluded by default, since the documenter doesn't document itself unless asked).

Once the pages are published, close the loop: a docs_url template under [tool.footman] turns every task and group name in --list, --tree and the --help pages into a terminal hyperlink to its own docs, and puts a docs_url field on each task's --json row for readers that cannot click.

[tool.footman]
docs_url = "https://docs.example.dev/tasks/{path}/"   # docs.site: page per task
# docs_url = "https://docs.example.dev/reference/#{slug}"  # docs.page: anchors

{path} is the task's slash-joined address (docs.build → docs/build), matching the per-task pages docs.site writes; {slug} is the dash-joined one (docs-build), matching the heading anchors the one-page docs.page export carries. An unknown placeholder is refused by name. The links ride the colour switch — piped output stays plain, and a terminal without hyperlink support simply ignores them — and each --help page also prints the URL as visible text, so there is always something to copy.

The live sample

Everything below this line is fm docs.page --target=docs --heading=3 --flavor=material, regenerated on every docs build:

docs

Documentation site (Zensical)

docs.serve

Build and serve the docs with live reload.

fm docs.serve

Example

$ fm docs.serve

docs.coverage

Generate the coverage HTML report into docs/htmlcov (embedded in the site).

fm docs.coverage

Returns: The measured total and where the HTML report landed. — CoverageReport {percent, statements, missing, report}

Field Type
percent a number
statements an integer
missing an integer
report a path

Example

$ fm docs.coverage

docs.api

Generate the API reference from the export table; refuse on drift.

fm docs.api [--out=PATH]
Parameter Type Default Description
--out=PATH path "docs/api.md"

Example

$ fm docs.api

docs.build

Build the docs site into ./site; regenerates llms.txt and docs/tasks/.

Not unit-tested, and deliberately: the body is orchestration over zensical, a pty screenshotter and five real shells, so a test could only stub twenty collaborators and assert the call order back — a change detector that passes while the site breaks. Its real test is CI's strict docs build, which runs the whole thing against the actual tools. The pieces with logic of their own — the llms.txt generator, the cast guard, the scratch projects — are tested separately, where a test can say something true.

fm docs.build [--check]
Parameter Type Default Description
--check flag false build strictly (what CI runs)

Example

$ fm docs.build --check