# footman — full documentation --- --- title: A typed task runner with instant completion --- # Footman [![PyPI version](https://img.shields.io/pypi/v/footman?label=PyPI&color=blue)](https://pypi.org/project/footman/) [![Python versions](https://img.shields.io/pypi/pyversions/footman)](https://pypi.org/project/footman/) [![License](https://img.shields.io/pypi/l/footman)](https://github.com/willemkokke/footman/blob/main/LICENSE) [![Docs built with Zensical](https://img.shields.io/badge/docs-Zensical-4051b5)](https://zensical.org) A task runner with the soul of [duty](https://pawamoy.github.io/duty/) and the UX of [typer](https://typer.tiangolo.com/): typed function signatures become real flags and positionals, modules become nested command groups, and shell completion answers from a cached manifest in **~25 ms — without importing your code**. ```sh fm lint --fix fm format lint --fix test # a chain: three tasks, no separator fm workspace mount --share # main scratch archive ``` ![fm --list in a terminal: bold task names, dim group prefixes, one-line help](_generated/shots/list.svg) Ships two console scripts: `footman` and the two-letter `fm`. (That screenshot is generated from the real CLI on every docs build — like every terminal image on this site, it cannot drift from what footman actually prints.) !!! note "Beta" footman is pre-1.0: the surface is settling, but minor versions may still include breaking changes — always called out in the [changelog](changelog.md), never in a patch release. Pin the minor (`footman~=0.19.0`) if you build on it. The written stability promise lands with 1.0 — the road there is on the [roadmap](roadmap.md). --8<-- "docs/_generated/latest-changes.md" The full history lives in the [changelog](changelog.md). ## Why `duty` gets a lot right — the `run()` capture model, the tools wrappers, the decorator ergonomics — and footman keeps those ideas. Where it pushes is the parts that compound: - Completion answers from a cache instead of re-importing your whole project on every Tab — ~15× faster, measured. - Types and choices validate eagerly, including unions and dynamic value sets, with errors that teach. - Modules become nested command groups, and task signatures carry no `ctx` boilerplate. - Independent tasks run in parallel by default, scheduled from the chain and each task's `pre`/`post` dependencies — duty and invoke run these serially. - A monorepo task cascade merges a `tasks.py` per folder, from the repo root down to where you stand. The receipts live in the [comparison](comparison.md) — a measured head-to-head against duty, invoke, poe, and typer, every number reproducible from the repo's `comparison/` directory. ## Install ```sh uv add --dev footman # or: pip install footman ``` Requires Python 3.11+. Zero runtime dependencies. ## A first taste Write a `tasks.py` in your project root: ```python from footman import task, group @task def lint(fix: bool = False): "Run ruff over the project." ... docs = group("docs", help="Documentation") @docs.task(infinite=True) def serve(port: int = 8000): "Serve the docs locally." ... ``` Then: ```sh fm lint --fix fm docs serve --port 8001 fm --list ``` Head to [Getting started](getting-started.md) to go deeper. --- # Getting started ## Install ```sh uv add --dev footman # or: pip install footman ``` footman requires Python 3.11+ and has zero runtime dependencies. Installing it puts two console scripts on your `PATH`: `footman` and the two-letter `fm`. You can also install it once, globally (`uv tool install footman`), and still type plain `fm` inside uv projects: when a project's `uv.lock` pins footman and you aren't already inside its environment, `fm` hands the invocation to `uv run` — the project's own footman runs, at the project's pinned version, with the project's tools on PATH. One rule, no magic: the lockfile declaring footman is what makes it fire. Purists opt out with `uv = false` under `[tool.footman]` (or `FOOTMAN_NO_UV=1`), and TAB completion is untouched either way — it never enters an environment at all. uv only for now: its lockfile makes the rule unambiguous. If a poetry or pdm handoff would serve you, open an issue. ## Write a tasks file Tasks are plain functions. A `@task` decorator registers one; a `group()` opens a nested command group. Put them in a `tasks.py` at your project root: ```python from footman import task, group @task def lint(fix: bool = False): "Run ruff over the project." ... @task def test(marker: str = "", *pytest_args): "Run the test suite (extra pytest args after --)." ... docs = group("docs", help="Documentation") @docs.task(infinite=True) def serve(port: int = 8000): "Serve the docs locally." ... ``` The docstring's **first line** is the task's help text — it shows up in `fm --list`, `fm --help `, and your shell's completion menu. Document parameters there too: an `Args:` section (Google, NumPy, or Sphinx style — see [typed signatures](typing.md#or-just-write-a-docstring)) puts help on each option in `--help` and in completion. The command name is the function name with underscores turned into hyphens (`add_word` → `add-word`). A module of functions becomes a flat set of commands; each `group()` opens a nested command group. ## Run tasks ```sh fm lint --fix fm docs serve --port 8001 fm --list # every task, flat fm --tree # grouped by command group ``` The signature *is* the CLI: `fix: bool = False` becomes a `--fix` flag, `port: int = 8000` becomes a typed `--port` option, and a parameter with no default becomes a required positional. See [Typed signatures](typing.md) for the full mapping. `fm --help` documents the runner itself — captured here from a real terminal, regenerated on every docs build: ![fm --help: the usage line, the globals table, and the task listing, coloured](_generated/shots/help.svg) ## Chain several tasks List more than one task on a line and footman runs them as a chain — no separator needed. The *manifest* (footman's cached description of your task tree — the same file that powers completion) tells the parser every task's exact shape, which is what makes the split deterministic: ```sh fm format lint --fix test ``` Independent tasks in the chain run **in parallel by default**; `-s/--sequential` forces one-at-a-time. See [Chaining & parallelism](orchestration.md). ## Pass arguments through Everything after `--` is handed to the task as passthrough, reachable via a `*args` parameter or `passthrough()`: ```sh fm test -- -k my_test -x ``` ## Dry-run the plan `-n/--dry-run` prints exactly what footman parsed without running anything: ```console $ fm --dry-run format lint --fix test globals: --dry-run -> format -> lint --fix -> test ``` ## Four words you'll meet everywhere - **Manifest** — the cached JSON description of your task tree; powers [completion](completion.md) and the chain split. - **Cascade** — in a monorepo, the merged set of `tasks.py` files from the repo root down to your directory. See [Monorepos & config](monorepos.md). - **Chain** — several tasks on one command line; independent ones run in parallel. See [Chaining & parallelism](orchestration.md). - **Context** — the per-task object behind `run()`; you rarely touch it directly. See [Running tools](tools.md). --- # Typed signatures footman reads your function signature and turns it into a CLI — the same idea typer popularised, applied to a task runner. Types are validated *eagerly*, at parse time, with taught error messages. ## The core mapping | Signature | CLI shape | | ------------------------------- | ---------------------------------------------------- | | `fix: bool = False` | flag `--fix` / `--no-fix` | | `mode: str = "loose"` | option `--mode VALUE` | | `mode: Literal["a", "b"]` | completable, eagerly-validated choices | | `count: int = 100` | typed option, validated at parse time | | `paths: list[Path] = ()` | repeatable or comma-separated (`--paths a,b`) | | `env: dict[str, int]` | `--env KEY=VAL` pairs (repeatable or comma-separated)| | `template: Path` | required positional (consumed by exact count) | | `*cmd: str` | variadic trailing passthrough | **The rule behind the table: the default decides.** A parameter with **no default** is a **required positional** — a bare word on the line fills it (`fm greet Ada`). A parameter **with a default** is an **option** you pass by name (`--mode loose`), or, for a `bool`, a `--flag`/`--no-flag` switch. That is the whole distinction: give a parameter a default and it moves from the command line's *positions* to its *flags*. The container types layer arity on top — `list[T]`/`Many[T]` take one-or-many (a positional one needs at least one), and `*args` sweeps up the variadic tail — but the default is still what sorts each parameter into a position or a flag. !!! note "One reserved parameter name: `help`" A parameter named `help` is the one name the signature can't turn into a working option. It would map to `--help` — but `--help` (and `-h`) is intercepted **anywhere before `--`** and turns the whole line into a help request that never executes anything, so the flag is *shown*, never bound to your parameter. Every other global name is free to reuse: the rest (`--json`, `--version`, `-s`, `-j`, …) must come before the first task, so `fm deploy --json` binds `--json` to `deploy`, not to footman — only `--help`/`-h` win wherever they land on the line. It's the single reserved name, and the collision is the harmless kind (help prints instead of the task running); rename the parameter — `show_help`, `explain` — to get a real flag. ## Unions and one-or-many values A parameter can accept a union of types; footman validates the value against the union and coerces it by specificity — the most specific member that accepts the value wins (`int` → `float` → `Path` → `str`, with `str` as the universal fallback): ```python @task def scale(factor: int | float): ... ``` `Many[T]` is exactly `list[T]` — a parameter that accepts one or more values and is **always a list**, even for a single value (it reads more intentfully than a bare `list[T]` at a positional). Required when positional, so at least one value must be given: ```python from footman import Many @task def build(targets: Many[str]): ... # fm build web -> ["web"] # fm build web api -> ["web", "api"] ``` ## Comma-splitting and `nosplit` Every collection parameter (list or dict) splits a single token on commas **by default**, on top of the repeatable form — so `--tag a,b,c` and `--tag a --tag b --tag c` both work. Only `,` is a separator (no alternatives), and it is shell-portable, including PowerShell: ```python @task def release(tags: list[str]): ... # fm release --tags a,b,c -> ["a", "b", "c"] ``` When a value may itself contain a comma, mark the parameter `nosplit`: then only the repeated flag adds items, and a comma stays literal. ```python from typing import Annotated from footman import nosplit @task def notify(lines: Annotated[list[str], nosplit]): ... # fm notify --lines "Smith, John" --lines "Doe, Jane" -> two names, commas kept ``` ## Dictionaries `dict[K, V]` maps `KEY=VALUE` pairs, and it composes with the rest of the type system — `dict[str, int | str]`, and even `dict[str, list[...]]`: ```python @task def env(vars: dict[str, int | str]): ... # fm env --vars port=8080 --vars name=web ``` ## Custom types Any type with a typed constructor works — footman calls it. `datetime` uses `fromisoformat`; everything else is constructed as `T(value)`: ```python from uuid import UUID from decimal import Decimal from datetime import datetime @task def record(id: UUID, amount: Decimal, when: datetime): ... ``` ## Validation markers Eager, taught validation is the whole pitch, so constraints ride in `Annotated` — the same idiom as `suggest` and `nosplit`: ```python from pathlib import Path from typing import Annotated from footman import task, between, check, doc, env, isfile @task def deploy( config: Annotated[Path, isfile], # must exist, be a file jobs: Annotated[int, between(1, 32)] = 4, # inclusive bounds target: Annotated[str, env("DEPLOY_ENV")] = "staging", # CLI > $DEPLOY_ENV > default version: Annotated[str, check(semver)] = "0.0.0", # your own validator force: Annotated[bool, doc("skip the health check")] = False, # help text ): ... ``` ```console $ fm deploy missing.toml fm: deploy: must be an existing file (got 'missing.toml') $ fm deploy app.toml --jobs 99 fm: deploy: --jobs must be between 1 and 32 (got '99') $ DEPLOY_ENV=prod fm deploy app.toml # target == "prod" ``` - **Paths** — `exists`, `isfile`, `isdir` require the value to name something real on disk; validated at parse time like a bad choice would be. - **Bounds** — `between(lo, hi)` is inclusive; either end may be `None`. A bare `range(0, 8)` also works for ints, with Python's half-open semantics (`0` through `7`; the end is excluded, exactly as in a `for` loop). - **Env fallbacks** — `env("VAR")` fills an *absent* option from the environment; the value flows through the same coercion, bounds, and checks a command-line token would (just at binding time — the parser never sees the environment). Only valid on a parameter with a default, because a fallback needs somewhere to fall. - **Custom validators** — `check(fn)` runs after coercion, per element for collections; raise `ValueError` with a message written for the user. - **Help text** — `doc("…")` puts one line of your own words on a parameter. It leads the option's line in `fm --help `, becomes the option's description in shells that render one (zsh, fish, nushell, PowerShell tooltips), and rides along in the `fm --json --list` catalog. The task's own help stays the docstring's first line; `doc` is for the parameters. ## Terse aliases, and forwarding A **bare** marker — one that takes no arguments — has a `Name[T]` shorthand, the way `Many[T]` reads better than `list[T]`: - `NoSplit[list[str]]` ≡ `Annotated[list[str], nosplit]`. - `Exists`, `IsFile`, `IsDir` ≡ `Annotated[Path, exists/isfile/isdir]` — bare, no subscript, since the type is always `Path`: `def rm(target: Exists)`. - `Forward[T]` ≡ `Annotated[T, forward]`. Arg-taking markers (`suggest`, `between`, `env`, `check`, `doc`, `ask`) keep the full `Annotated[...]` form — their value can't ride in a type subscript. The `forward` marker threads a parameter's value to the tasks a task dispatches — its `pre`/`post` prerequisites and a runnable group's surfaces. It's an orchestration tool, covered in [Chaining & parallelism](orchestration.md#forward-a-value-to-what-a-task-dispatches). Markers compose by listing them: `Annotated[bool, ask("Fix?"), forward]` both prompts for the value and forwards it — one prompt at the top, the answer flowing down. ## Or just write a docstring footman reads the parameter docs you already write — Google, NumPy, and Sphinx styles, auto-detected per docstring. Everything a `doc("…")` marker feeds (help lines, completion descriptions, the catalog) fills from the docstring instead, the body between the summary and the section renders in `fm --help ` as the task's long help, and an explicit `doc("…")` always wins over a docstring entry for the same parameter: === "Google" ```python @task def deploy(target: str, fix: bool = False): """Ship a build. Checks out, builds, and uploads — see the release runbook. Args: target: where to deploy fix: apply fixes first """ ``` === "NumPy" ```python @task def deploy(target: str, fix: bool = False): """Ship a build. Parameters ---------- target : str where to deploy fix : bool apply fixes first """ ``` === "Sphinx" ```python @task def deploy(target: str, fix: bool = False): """Ship a build. :param target: where to deploy :param fix: apply fixes first """ ``` A docstring entry that names no real parameter earns a `UserWarning` — the same loudness a broken annotation gets. The parser itself is public and standalone (`footman.docstrings.parse`) if you want structured docstrings for your own tooling. One honest asymmetry to know about: path and bounds violations on the command line are caught *eagerly* (before anything runs); the same violations in an env-supplied value are caught at binding time, because that's when the environment is read. ## Dynamic completion `suggest` attaches a completer — a function that returns live values (git branches, deploy targets, the shares below). footman runs it **fresh** each time you complete that value, in a short-lived subprocess, rather than serving a copy baked into the manifest: a value you Tab to answer a build-critical question must be current, not a snapshot from your last run. The recompute is bounded and isolated, so a slow or failing completer degrades to no candidates — never the old values, never a hung keystroke. This holds for *every* completer, whether or not the task owns the terminal (`interactive=True`); a real run validates the value you pass against the same live call. ```python from typing import Annotated from footman import task, suggest def shares() -> list[str]: return ["main", "scratch", "archive"] @task def mount(share: Annotated[str, suggest(shares)]): ... ``` Keep a completer's imports **inside its body**, the way footman keeps optional dependencies out of a task's import path. Loading your tasks file stays cheap — the completer's cost (a subprocess, a network round-trip) is paid only when it runs, not every time the file is imported: ```python def branches() -> list[str]: import subprocess # here, not at module top out = subprocess.run( ["git", "branch", "--format=%(refname:short)"], capture_output=True, text=True, ) return out.stdout.split() ``` The first example, recorded in PowerShell: the demo project's tasks.py is extracted from this page at build time, so the code above and the session below cannot disagree. Tab offers what `shares()` returned; Tab again walks the menu. ![Animated: fm mount TAB offers main, scratch, archive from the suggest completer; TAB again moves the selection](_generated/shots/pwsh-suggest-cast.svg) --- # Chaining & parallelism footman's **execution model** in one page: how a command line becomes a plan, what runs concurrently versus one at a time, and how you steer it. Independent tasks run in parallel by default; `-s`, `-j`, and `-k` control the concurrency, and a few rules decide when footman falls back to sequential. ## Chaining `fm format lint --fix test` runs three tasks from one line — duty's muscle memory, but with real flags. The split is driven by the manifest, so it is deterministic; `+` is always available as an explicit boundary, and `--dry-run` prints the parsed plan: ```console $ fm --dry-run format lint --fix test globals: --dry-run -> format -> lint --fix -> test ``` ## Parallel by default Independent tasks run **in parallel by default** — that is the concurrency model. footman builds a dependency graph (a DAG — no cycles allowed, and a cycle is a taught error) from the chain and each task's declared dependencies, then runs everything that isn't waiting on something else concurrently. Tasks spend most of their life waiting on subprocesses — a `run()` call releases Python's interpreter lock while it waits — so threads give real wall-clock speedups without process isolation: ```sh fm a b c # three 1s tasks -> ~1.0s, not 3.0s fm -s a b c # -s/--sequential runs them one at a time -> ~3.0s ``` Two flags size the concurrency, and they reach **both** engines — the scheduler and a `parallel()` inside a task body: - `-s/--sequential` runs one task at a time — no concurrency anywhere. - `-j/--jobs N` caps the width; unset, footman uses one less than your core count, never below two. Set either permanently as `sequential` or `jobs` in `[tool.footman]`. A run stops on the first failure; `-k/--keep-going` runs every independent branch even if one fails, and a task whose prerequisite failed is skipped. !!! note "Output never interleaves" Each task's stdout is buffered and flushed as one contiguous block when it finishes, so concurrent tasks never scramble each other's lines. The block guarantee is about stdout — the run summary and the live status line are stderr commentary, so redirecting stdout captures task output alone. ## Failing a task A task **succeeds** unless it says otherwise. Four ways to say otherwise, most to least deliberate: - **`fail("reason")`** — the blessed way to stop with an explanation. The reason prints on the failure line and lands in the [`--json`](json.md) `error` field, verbatim; `fail("…", code=3)` picks the exit code too. It is a *function*, not a `raise`, so it stays clean under a strict linter (flake8-errmsg's `EM101`, tryceratops' `TRY003` flag a string literal in a `raise`) — the same reason `sys.exit()` and `pytest.fail()` are functions. - **`return N`** — a bare non-zero exit code, no message. `return 0` (or falling off the end) is success. - **`sys.exit("reason")` / `sys.exit(2)`** — the stdlib idiom, honoured: a string reason surfaces like `fail()`'s, an int is the code. - **raise any exception** — a *crash*. Its type and message show (`RuntimeError: …`), signalling a bug rather than a chosen stop; the exit code is 1. ```python from footman import task, fail @task def release(armed: bool = False): if not open_pr(): fail("no open PR for setup — run `fm create repo` first") ... ``` A `run()` command that exits non-zero raises `RunFailed` on your behalf (unless `nofail=True`), so `fm` mirrors the command's own code — you rarely raise that one yourself. To *catch* a deliberate `fail()`, `except footman.Failed:`. ## When a task fails: fail-fast & keep-going A run is **fail-fast** by default: the first failure stops it. New tasks don't start, *and* the sibling subprocesses still running are terminated — the whole tree, each child *and its own children*, so a tool's workers (pytest-xdist, `make -j`, a script's background jobs) die with it rather than orphaning. So a doomed run dies at once instead of waiting out a long test suite. The kill is SIGTERM, escalating to SIGKILL after a short grace if a tool ignores it. A task cut off this way reports as **cancelled**, not failed, and the exit code is the genuine failure's, never a kill signal. `Ctrl-C` reaps in-flight trees the same way. `--keep-going`/`-k` runs every independent branch regardless, so you see every failure in one pass. `--fail-fast` forces the default back when a task declares otherwise. Which wins is **three-state — command line > declared > built-in**: ```python @task(keep_going=True) # this gate wants to surface every problem at once def check(): ... ``` - `fm check` keeps going — its own declaration. - `fm --fail-fast check` overrides it for this run. - A task that declares nothing gets the built-in fail-fast. The policy is **scoped per subtree**, not run-wide. A keep-going gate keeps its own prerequisites going with it, while an independent task in the same run keeps its own policy — so `fm check deploy`, with `check` keep-going and `deploy` fail-fast, surfaces every `check` failure *and* still bails `deploy` on the first one. A command-line `-k`/`--fail-fast` overrides every scope at once; a task's own (or `.opts()`-set) policy always wins over one inherited from a gate above it, so an explicit fail-fast prerequisite stays a fail-fast boundary. The kill is scoped too: a failure reaps the fail-fast subprocess trees still in flight but leaves a keep-going task's child running. Three escape hatches for the kill: - `@task(atomic=True)` opts a task's subprocesses out — they run to completion, so a formatter rewriting a file can't be truncated mid-write. - An `@task(interactive=True)` task owns the real terminal, so its subprocess stays attached to it and isn't group-isolated — it keeps its controlling tty and its own `Ctrl-C`. - An **in-process** `run()` (a `tools.*` entry point, a plain callable) has no subprocess to signal, so it always finishes on its own. ### Override a task's options per use: `.opts()` `keep_going`, `atomic`, and the rest are set on the `@task` decorator, once. When one *use* wants a different policy, `.opts()` overrides it there — without touching the registered task: ```python @task(pre=[fmt.opts(atomic=True), lint]) # protect fmt's writes here, not everywhere def check(fix: Forward[bool] = False): ... ``` `.opts()` returns the same task with the options overridden for that use only — a `pre=`/`post=` target, or a body call — and reads everywhere a bare task does: same name, same signature, same call. It takes the policy options `keep_going`, `atomic`, `interactive`, `progress`, `confirm`, and `infinite`. It takes **policy, not parameters**. A task's own arguments go in the call; the options ride beside it — `deploy.opts(atomic=True)("prod")` — the same split `tools.*` draw with their `.opts()`. Passing a task parameter to `.opts()` is a taught error. A runnable group has `.opts()` too, riding its default action: `pre=[lint.opts(keep_going=True)]` scopes keep-going to that prerequisite's subtree (see per-subtree scoping above). An opted reference with a *different* policy is a distinct prerequisite from a bare one — a different policy is a different run, so both appear in the graph — while identical policies deduplicate to one node, exactly as a shared bare prerequisite runs once. Deduplication keys on `(task, options)`, so an empty `.opts()` is just the bare task, and options must be hashable values. ## Interactive input One parallelism consequence belongs here: a run that contains an `@task(interactive=True)` task goes **fully sequential** — that task owns the real terminal, so it can't share it with parallel siblings, and the live status line steps aside so its repaints can't scribble over a prompt. The three ways to ask the person at the keyboard — `ask()` for a value, `@task(confirm=…)` for a gate, and `interactive=True` for a mid-task wizard, all CI-safe by construction — have their own page: [Asking for input](input.md). ## Dependencies with `pre` / `post` Declare prerequisites and follow-ups on the task; footman schedules them (deduping shared deps, so a prerequisite pulled in twice runs once) and skips a task whose prerequisite failed: ```python @task(pre=[fmt, lint, typecheck, test]) # all four run before check def check(): ... @task(post=[notify]) # notify runs after deploy succeeds def deploy(): ... ``` `check`'s four prerequisites have no edges *between* them, so footman runs all four at once and only starts `check` when the last finishes: ``` mermaid graph LR fmt --> check lint --> check typecheck --> check test --> check ``` This is the **declared** graph: static, so `--dry-run` and completion show it without running anything, and deduped by identity — a cycle in it is a taught error naming the loop. A dep is named by reference, so it runs with its **defaults**: a task used as a prerequisite needs every parameter defaulted (a required one errors with `missing required argument(s)`). To run a prerequisite with specific arguments, name it in the chain — `fm build --release deploy` runs `build --release` once, and `deploy`'s `pre=[build]` waits on that same run. ### Forward a value to what a task dispatches Running defaulted is a *floor*, not a ceiling. Mark a parameter `forward` and its value threads to every task this one dispatches — its `pre`/`post` prerequisites and a [runnable group](#runnable-groups)'s surfaces — that declares a parameter of the same name: ```python from typing import Annotated from footman import task from footman.params import forward @task(pre=[format, lint, test]) def check(fix: Annotated[bool, forward] = False): "fm check --fix reaches format & lint; test (no `fix`) runs defaulted." ``` `Forward[bool]` is the shorthand (`Forward[T]` ≡ `Annotated[T, forward]`, like `Many[T]`). The rules: - **Partial reach.** Only tasks that declare the parameter receive it; the rest run on their own defaults — `check --fix` fixes what's fixable and lints the rest. - **It chains.** A callee that re-declares `forward` passes the value on, so it reaches a group's surfaces through the group's default. - **Overrides a default, never rescues a required one.** A prerequisite stays runnable on its own; forwarding only changes a value that already has a default. - **Conflicts are taught, not guessed.** Two tasks forwarding different values to one shared prerequisite is an error, not a silent last-wins. Forwarding threads *values*, not graph structure, so `--dry-run` and completion are unchanged. The explicit hand-forwarding of [`inherited()`](cookbook.md#extend-an-inherited-task-instead-of-replacing-it) stays for the override case — calling a task you *shadow* and changing what it gets. ## Fan out from inside a task `parallel()` runs task functions — or no-argument lambdas, when you need to bind arguments — concurrently, waits, and fails if any fail. It honours the same `-s` and `-j` as the scheduler (one worker under `-s`), so concurrency stays controlled in one place: ```python from footman import task, parallel @task def check(): parallel(lambda: format(check=True), lint, typecheck, test) ``` Unlike `pre`/`post`, a `parallel()` fan-out is **in-body** — footman can't see it without running the task. That is the trade: declared deps are static and show up in `--dry-run`; an in-body fan-out is dynamic — its shape can depend on a `run()`'s output — but opaque to the planner, which stops at the task body. Reach for declared deps when you want the plan to *see* the work, and `parallel()` when the fan-out has to be computed at run time. ??? note "Passing data between tasks" Result data flows *within* a task — `run()` hands back a `Result` (the exit code, which the value *is*, plus `.stdout`/`.stderr`), a called function its return — and out of a `parallel()` fan-out through a shared closure the thunks write to (they run in-process, so a captured list just works). `parallel()` itself returns exit *codes*, not values, and the declared graph carries no data between tasks: `pre`/`post` are ordering, not a pipe. ## Runnable groups A group is a namespace: `fm lint markdown` runs a task under `lint`, but bare `fm lint` is an error. Give the group a **default action** with `@group.default` and the bare form runs — while the surfaces stay addressable: ```python from footman import group, run from footman.params import Forward from footman.tools import ruff, markdownlint, cspell lint = group("lint") @lint.task def python(fix: bool = False): ruff("check", "src", fix=fix) @lint.task def markdown(fix: bool = False): markdownlint("**/*.md", fix=fix) @lint.task def spelling(): cspell("lint", "**/*") # no --fix @lint.default def lint_all(fix: Forward[bool] = False): "Lint everything; --fix reaches the surfaces that support it." ``` - `fm lint` fans out every surface; `fm lint --fix` fixes what's fixable and lints the rest (the `forward` marker carries `--fix` to the surfaces that take it — see [above](#forward-a-value-to-what-a-task-dispatches)). - `fm lint markdown` / `fm lint markdown --fix` runs one surface. - The default's **signature is the group's options**, so it takes flags/options only. A positional is a load-time error, because a bare word after a group names a child, not a value — model a positional action as a task instead. - An **empty body** fans out the group's own tasks; a non-empty body is the escape hatch where you write the fan-out yourself. - On an empty-body default, **mark a parameter `Forward` if you want it to reach the surfaces.** The default has no body, so a plain parameter binds to it and goes nowhere — `fix: bool` accepts `--fix` and then nothing happens with it. `fix: Forward[bool]` threads the value to every surface that declares `fix`. (A parameter the default *does* use in a custom body needs no `Forward`; the marker is only for values that must travel onward.) - The default takes the same **policy options** as `@task` — `@lint.default(pre=[...], keep_going=True, confirm="…", atomic=True)` and the rest — with no `name` (the group already names it). `interactive=True` needs a real body: an empty-body default fans out in parallel, so there is no single body to own the terminal, and asking for one is a load-time error. The group tab-completes (`fm lint ` offers `--fix` and the surface names) and `fm --help lint` renders it as a first-class command. And it composes: a `check` gate reaches its surfaces through the group with one forwarded flag — `@task(pre=[format, lint, test]) def check(fix: Forward[bool])`, and `fm check --fix` threads all the way down. A runnable group is also **callable from a task body**, the way a task is: ```python @task def check(fix: bool = False): lint(fix=fix) # runs lint's default — fans out, or runs its body if fix: run("./stamp-version.sh") ``` `lint(fix=fix)` runs the default's action synchronously and in order — its body as written, or, for an empty-body default, the group's own tasks, each handed the arguments it declares. Like every body call it forwards arguments explicitly and runs to completion before the next statement; reach for `pre=`, a chain, or `parallel()` when you want prerequisites or concurrency. The declarative `pre=[lint]` form above is usually cleaner — a body call is for when you need real control flow. ## Progress & the live status line Both parallel engines — the scheduler and a `parallel()` inside a task body — feed one live status line, so a chain and an in-body fan-out present identically: a real progress bar once footman has learned the run's timing, a bouncing pulse until then. A task can also report its own progress (`track()` / `progress()`) and the bar fills from that instead of an estimate. The whole story — the status line, the timing history, and the off switches — is on [Progress & timing](progress.md). ## JSON for CI and agents Pass `--json` and stdout becomes exactly one JSON document: per-task results (with captured output, structured `run()` steps, and the task's own `returned` data), or an error envelope when footman refuses the line. The whole contract lives on [JSON output](json.md); the CI recipes on [CI & automation](ci.md). --- # Asking for input Most values should be flags — typed, completable, and CI-safe. But some runs genuinely need to ask the person at the keyboard: a version string, a production confirmation, a pick from a list computed at run time. footman has three shapes for it, and all three are **CI-safe by construction** — off a terminal they fail loudly or take a supplied answer, never hang. A bare `input()` doesn't work in a task: its prompt goes to stdout, which footman buffers so parallel output can't interleave — so the prompt is swallowed and the task looks hung. Reach for one of these instead. ## Ask for a value: `ask()` Mark a typed parameter `ask()` and footman prompts for it when the command line and its `env()` don't supply one, coercing the answer through the same pipeline as a flag: ```python from typing import Annotated, Literal from footman import ask, task @task def release(version: Annotated[str, ask()]): ... @task def deploy(env: Annotated[Literal["staging", "prod"], ask()]): ... ``` `fm release --version 1.2.3` uses the flag; `fm release` asks `version:` and runs the answer through coercion — a `Literal` is a typed choice, a bad value re-asks. The precedence is **CLI > `env` > default > prompt**: a default *is* the answer, so `ask()` only prompts a parameter that has none. (An `ask()` parameter is a CLI-optional option, so it never becomes a required positional.) The safety is the point: off a terminal, under `--no-input`, or in `--json`, `ask()` **errors naming the flag** instead of hanging — an unattended run fails loudly, and CI passes the value as a flag like any other. ![Animated: fm release prompts version, the typed answer runs through coercion, and the release runs](_generated/shots/ask-cast.svg) ## Gate a task: `@task(confirm=…)` A yes/no question asked *before* the task and its prerequisites run: ```python @task(confirm="Deploy to production?") def deploy(): ... ``` Deny it and the task is skipped and the run exits non-zero. `--yes` auto-answers it (for CI and scripts), and off a terminal without `--yes` the answer is no — footman never proceeds unasked. ![Animated: fm deploy asks Deploy to production, answered yes, then deploys](_generated/shots/confirm-cast.svg) ## Own the terminal: `@task(interactive=True)` `prompt()`, `confirm()`, and `select()` ask mid-task, but they are **guarded**: called inside an ordinary task they raise a taught error, because the prompt would be swallowed by the capture buffer or race a parallel sibling. A task that genuinely runs a wizard or a REPL declares itself interactive — it then owns the real terminal, uncaptured, with sole stdio: ```python from footman import prompt, select, task @task(interactive=True) def scaffold(): name = prompt("project name? ") kind = select("what kind?", ["library", "app", "plugin"]) ... ``` `select()` picks one — or `multiple=True` picks several — from a list computed at run time, the case a flag can't cover. Two globals cover the rest: `--yes` auto-answers every confirm, and `--no-input` refuses to prompt (a required prompt errors instead). Because it owns the terminal, an interactive task can't share it with parallel siblings: **a run that contains one goes fully sequential** — every task, one at a time — and the live status line steps aside so its repaints can't scribble over a prompt. (It also can't run under `--json`.) ![Animated: fm scaffold prompts for a project name, then a numbered what-kind menu picked by number](_generated/shots/interactive-cast.svg) --- # Progress & timing Every run gets an honest live status line, and footman learns how long your tasks take so it can show a real progress bar — no configuration, no instrumentation. When a task knows its *own* progress (23 of 150 migrations), it can report that and the bar fills from the truth. This page gathers the whole story; the knobs live in [Configuration](configuration.md). ## The live status line A finished run reads as a receipt — mark, name, command, time — captured from a real terminal: ![fm format lint: green check marks, task names in cyan, dim commands, and a took line](_generated/shots/run.svg) On a TTY, every run keeps one live status line on stderr: a **progress bar** when footman has seen this exact invocation enough to estimate honestly — five recent green runs with a steady spread; the bar fills against the history's 90th percentile and labels elapsed vs. typical time — and a bouncing pulse with elapsed time when it hasn't. Both parallel engines feed the same line, so a chain and a `parallel()` inside a task body present identically, with running names appearing the moment each unit starts. It always clears itself before any output lands, so blocks and live step lines stay clean. Without a TTY, a confident estimate prints once as `eta ~5.8s` on stderr instead — the same honesty, one line. Green runs teach: wall totals are stored per invocation shape and directory beside the completion manifests (`$FOOTMAN_CACHE_DIR` moves every footman cache at once). Three off switches: `--no-progress` for one run, `progress = false` in `[tool.footman]` permanently, and `@task(progress=False)` for a task whose duration has no rhyme — a run containing one never records and only ever pulses. The line is absent entirely under `--no-color`/`NO_COLOR`/`TERM=dumb`, `--quiet`, `--json`, or when stderr is piped. ## Report a task's own progress: `track()` / `progress()` Some work knows exactly where it is — 23 of 150 migrations, bytes of a download — and that beats any duration history. Report it and the live bar fills from the truth: ```python from pathlib import Path from footman import task, track, progress @task def migrate(): "Apply pending migrations." for record in track(load_records()): # total from len() apply(record) @task def index(path: Path): "Rebuild the search index." for done, total in build_index(path): progress(done, total) # the explicit form ``` Counted beats estimated, so a reporting task is honest on its *first* run, where the estimator would still be gathering samples. A reporter contributes a fractional unit to the run's bar — three tasks done and a fourth halfway is 3.5/4 — so a chain of reporters fills smoothly and a mixed chain is smooth where it can be. `track()` takes the total from `len()`, accepts `total=` for generators, and clears the report if you break out early. Outside a run, both are no-ops. ## Where the timing history lives The progress bar's estimates come from `*.times.json` files beside the completion manifests (`~/.cache/footman/`, or wherever `$FOOTMAN_CACHE_DIR` points). The cache tends itself: at most once a day, a detached collector removes pairs whose directory no longer exists and pairs idle for 90 days — everything in the cache rebuilds on the next run, so collection can never lose anything that matters. Delete files by hand to reset a stale history, or turn the whole apparatus off — `--no-progress` for a run, `progress = false` in `[tool.footman]` for good. ## In CI Without a TTY there is no progress bar, but timing still works both ways: CI runs are recorded into the duration history, and when footman has a confident estimate it prints a single `eta ~5.8s` line to stderr at run start. `--no-progress` (or `progress = false`) turns the line and the recording off. See [CI & automation](ci.md) for the rest of the automation surface. --- # Running tools Task bodies run tools through `run()` and the typed wrappers imported from `footman.tools`. `run()` captures output and stays quiet on success, **replaying it only on failure** — so a green run is calm and a red one shows exactly what broke: ```python from footman import task, run from footman.tools import pytest, ruff @task def check(): ruff("check", "src", fix=False) # subprocess (ruff is a binary) pytest("-x") # in-process via pytest.main run("mkdocs build --strict") # any command; a callable also works ``` Each tool is imported by name — `from footman.tools import git` gives you a typed `git` you call as `git.commit(…)`; a tool footman has never heard of imports just the same and runs as a subprocess. This page covers `run()` and the task context. The tool wrappers — how the flag translation works, disabling flags, in-process execution, and why nothing is transcribed per tool — have their own page: [The tools bridge](tools-bridge.md). ## `run()` - Takes a command (string or list) or a Python callable. - Raises on a non-zero exit; `.opts(nofail=True)` returns the code instead. - Honours `--dry-run` (prints the command instead of running it). - Records a step for [`--json`](json.md) (command, code, duration, captured output); `capture=False` lets output through unbuffered and records an empty capture — for serve-style tasks that must not buffer. - Runs from the task's context cwd — in a [cascade](monorepos.md) the folder the task was defined in — with the context env overlay applied. Subprocess and in-process tools honour this identically. ## Fetch and cache files: `fetch()` `fetch(url, sha256=…, into=…)` downloads into footman's own cache — the same directory `$FOOTMAN_CACHE_DIR` moves and the daily collector tends, so vendored artifacts for deleted projects clean themselves up: ```python from footman import fetch, task @task def vendor(): "Fetch the pinned toolchain." fetch("https://example.com/protoc-27.tar.gz", sha256="9f86d081884c…", into=Path("vendor/protoc")) ``` Like `run()`, a fetch **is a step**: `--dry-run` prints it without touching the network, `recording()` asserts on it in tests, [`--json`](json.md) carries it, and its byte counts feed the [progress bar](progress.md). A second run revalidates with the server (ETag / `If-None-Match`) — a `304` costs one round trip and keeps "cached" honest — and `sha256=` refuses anything that arrived wrong. The backend is stdlib `urllib` by default (zero dependencies, and the only one that can report bytes as they arrive); `curl`, `httpx`, `requests`, or `auto` are available when named in `[fetch]` config, for a corporate proxy whose TLS store Python can't see. The full worked example is in the [cookbook](cookbook.md#fetch-and-cache-a-toolchain). ## No `ctx` needed `run()` and `passthrough()` read the current task's context implicitly, so a task body stays boilerplate-free. Declare a first `ctx: Context` parameter only if you want the object — footman keeps it out of the CLI mapping: ```python from footman import Context, task from footman.tools import pytest @task def test(ctx: Context): pytest(*ctx.passthrough) # fm test -- -k mytest -x ``` `passthrough()` and `ctx.passthrough` are the same list two ways — the free function reads the current context so most tasks never declare `ctx` at all. One boundary to know: the ambient context follows footman's own concurrency (`parallel()` hands each worker a child context, steps and all) but **not threads you spawn yourself** — a raw `threading.Thread` starts with an empty context, so a `run()` inside it would see default state: wrong folder, no env overlay, no step recording. Fan out through `parallel()`, wrap your target with `contextvars.copy_context().run(...)`, or declare `ctx` and pass it in explicitly. ## Machine-readable output Under `--json`, every `run()` becomes a structured step inside the task's entry, all task output is captured into the payload, and a task's return value rides along under `returned`. The full contract — envelopes, refusals, exit codes — lives in [JSON output](json.md). --- # The tools bridge Every tool on your PATH is already there — import it by name from `footman.tools`, no declaration needed; attribute access chains subcommands, and keyword arguments translate into flags *mechanically*: ```python from footman import task from footman.tools import bun, docker, ruff, terraform @task def ship(): ruff.check("src", fix=True, select=["E", "F"]) # -> ruff check src --fix --select E --select F docker.compose.up(detach=True) # -> docker compose up --detach bun.add("left-pad", global_=True) # trailing _ escapes keywords # -> bun add left-pad --global terraform("plan", out="tf.plan") # never declared anywhere; works ``` The rules, all of them: `snake_case` → `--kebab-case`; `True` → bare flag; `False`/`None` → omitted; `off` → that tool's own negation (see below); a list repeats the flag (an empty one is omitted, so a task parameter's default flows straight through); a single-letter key is a short flag (`k="expr"` → `-k expr`); positional strings pass through untouched. A valued long option is executed **attached** — `select="E"` runs `--select=E`, not `--select E`. The two are equivalent for a plain value, but attaching is the only spelling that always works: an optional-value option can't tell its value from the next word across a space (`--abbrev 4` is ambiguous to git, `--abbrev=4` is not), and a value that starts with a dash would be read as another option (`--format -%h` fails, `--format=-%h` works). You never have to think about it — the same rule covers every tool, including ones footman has never heard of. A **wrapper verb** — one that runs another command, like `uv run`, `coverage run`, or `docker exec` — takes its flags *before* the wrapped command, or they would land on the child instead of the tool: ```python uv.run("pytest", "-q", frozen=True) # → uv run --frozen pytest -q ``` footman knows which verbs wrap (it reads each verb's usage line), so `--frozen` reaches uv while `pytest -q` passes through untouched. And a tool's own **global** options — the ones that must precede the subcommand — go through `flags()`: ```python docker.flags(host="tcp://x").compose.up(detach=True) # → docker --host=tcp://x compose up --detach ``` `docker --host … ps` works and `docker ps --host …` does not, so `flags()` places a global where the tool expects it and returns the tool, keeping the chain typed. footman's own **run-control** is separate — it rides `opts()`, a closed set (`nofail`, `in_process`, `capture`, `title`) that never becomes a tool flag, the same policy-vs-work split a task's `.opts()` has: ```python git.opts(nofail=True).push() # tolerate a non-zero exit pytest.opts(capture=False)("-s") # stream this run live ``` Because it is a fixed set, `capture` here is unambiguously footman's — a tool's own `--capture` (pytest's) still goes in the call, `pytest(capture="no")`. !!! note "Colour without a pty" footman captures a subprocess through a plain pipe, not a pseudo-terminal — deliberately, since a cross-platform pty needs a Unix-only stdlib module, ctypes ConPTY on Windows, or a third-party dependency, and footman is zero-dependency and cross-platform. A tool that only colourises when it sees a terminal (`isatty()`) would therefore print plain text. So footman forces colour back. When the run is colourful — footman's own output is a terminal and `--color`/`NO_COLOR` don't say otherwise — every subprocess is handed `FORCE_COLOR`/`CLICOLOR_FORCE`, and the captured bytes replay onto footman's terminal (colour is just SGR bytes, so it survives the round-trip). When the run is monochrome, footman pushes `NO_COLOR` down instead, so its tools stay quiet too. The decision is one run-wide tri-state: **`--color=always|never|auto`**, `[tool.footman] color`, then `NO_COLOR`/`FORCE_COLOR`. `always` colours even into a pipe (`fm --color=always check | less -R`); a captured `--json` run stays byte-clean. A live cursor is the one thing a pty-less run genuinely can't carry — a tool's own progress bar or full-screen UI. For that, `.opts(capture=False)` streams a run live and `@task(interactive=True)` gives a task sole stdio, both handing over the real terminal. A few tools ignore the environment and take a flag instead. footman forces those through their own switch — git's `-c color.ui=always`, injected into the executed command only, so `recording()` still sees `git diff`, not the switch. You never configure any of this; it is handled per tool, and the table below records exactly which does what. ## Colour support, per tool `fm footman tools color` *probes* each curated tool — it runs the tool with colour forced on and off, both by environment and by the tool's own flag, and reads the bytes — so this table is measured, not assumed. A tool obeys the **environment** (`FORCE_COLOR`/`NO_COLOR`), needs its own **`flag`**, colours but **can't be silenced**, produces **no colour over a pipe** (footman captures without a PTY, so a tty-only tool stays plain), or is a **pass-through wrapper** whose colour belongs to the program it runs. --8<-- "docs/color-support.md" What footman *shows* you is spelled for reading, not for the parser: the `--dry-run` line, the live progress line, and `recording()`'s `step.command` all use the separated form (`--select E`), quoted so it still pastes. The exact executed bytes are on `step.raw`, and `--verbose` prints them. So a `recording()` assertion reads naturally and doesn't change when the executed spelling does: ```python with recording() as steps: ruff.check("src", select=["E", "F"]) assert steps[0].command == "ruff check src --select E --select F" # reads plainly assert steps[0].raw == "ruff check src --select=E --select=F" # the real argv ``` ## Disabling a flag that defaults on `False` and `None` mean *omit* — that's what lets a task parameter's default flow through (`fix: bool = False` → `fix=fix` → nothing) — so they can't *also* mean the negation. To turn off a flag a tool enables by default, use the `off` sentinel, or name the negation directly: ```python from footman.tools import off mkdocs.build(strict=off) # → mkdocs build --no-strict mkdocs.build(no_strict=True) # exactly the same, by name ``` `off` emits **the spelling that tool actually uses**, which is not always `--no-`. `mkdocs build --no-clean` is rejected outright — the flag is `--dirty` — and five of mkdocs' eight negatable options disagree with the convention: ```python mkdocs.build(clean=off) # → mkdocs build --dirty mkdocs.build(use_directory_urls=off) # → --no-directory-urls mkdocs.build(strict=off) # → --no-strict, convention holds ``` Only the tool knows, so footman asks it: the spellings are extracted from each tool's own description of itself (click states a negatable flag as `secondary_opts`; git prints `--[no-]verify`; clap says so in prose) and the exceptions are cached. `fm footman tools audit` compares that cache against the installed tools, so one that changes its mind fails a check rather than quietly producing a command it refuses. The generated stubs carry the same fact where you'll actually see it — in the flag's own documentation: ```python clean: Remove old files from the site_dir before building (the default). Defaults on — `clean=off` emits `--dirty`. ``` `off` shines when a variable drives it, because it completes the boolean story — `True` → `--flag`, `off` → the tool's negation: ```python @task def build(pretty_urls: bool = True): mkdocs.build(directory_urls=pretty_urls or off) # pretty_urls=True → --directory-urls # pretty_urls=False → --no-directory-urls ``` And a *conditional* flag often needs no negation at all — when the flag is off by default, `flag=condition` already does the right thing: ```python zensical.build(clean=True, strict=check) # --strict only when check ``` ## Why no per-flag Python parameters? duty's tools library transcribes every flag of every tool into typed Python parameters — five thousand careful lines, and genuinely pleasant to autocomplete. The cost is drift: the wrapper freezes the flag-set its author copied, while the tool keeps moving. duty's `ruff.check(show_source=True)` still emits `--show-source` today — and ruff removed that flag; the modern binary rejects it. There is no version machinery underneath to catch this, and there realistically can't be: the wrapper *is* the hardcoded version. footman's bridge sidesteps the whole class of problem: nothing is transcribed, so nothing goes stale. Your installed tool's `--help` is the one source of truth, at whatever version is installed. The trade is honest — you don't get IDE autocompletion of a tool's flags, and a typo'd flag errors at run time (exactly as it does in duty, whose transcriptions aren't validated eagerly either). ## Autocomplete without the import bill The bridge does ship duty-style autocompletion after all — as **stub files**, which type checkers and IDEs read but the runtime never imports. `ruff.check(` completes `fix=`, `select=`, `output_format=` and friends, each with that tool's own help text on hover; `fix="yes"` is a type error before you run anything. Two rules keep the stub subordinate to the bridge: - every stubbed verb ends in `**flags: Any`, so the stub *suggests* flags but can never forbid one — when a tool grows a flag, the bridge already speaks it and the stub merely hasn't heard of it yet; - unknown verbs fall through to `Tool`, so nothing the runtime accepts is ever a type error. Which means stub drift — the thing that breaks duty's wrappers at run time — here degrades a *hint* at worst. And the stubs are not written by hand. `fm footman tools sync` reads the tools installed on your machine and writes one file per tool, so what your editor suggests is what your binary accepts: ```console $ fm footman tools sync wrote 9 stub(s): ruff, ruff_format, basedpyright, uv, git, docker, mkdocs, zensical, coverage skipped (not installed): bun, cspell, prek, markdownlint ``` Reading a tool means reading whatever it offers. click and argparse hand over their parameters as data — including the negation, which click states as `secondary_opts`. Everyone else gets their `--help` parsed, and the five families footman meets are more alike than they look: an option is a line starting with a dash, and its help is either past a run of spaces or on the lines below. The dialects are small — `[default: 3]` (clap), `(default true)` (cobra), `--file string` (cobra's Go types), "Use `--no-fix` to disable" (clap's prose), and git's `--[no-]quiet`, which states both spellings at once. `fm footman tools audit` regenerates and compares, so a tool that moves fails a check rather than quietly leaving your editor a version behind. A tool that isn't installed is skipped *and named* — a check that quietly covered nine of thirteen would be worse than no check at all: ```console $ fm footman tools audit skipped (not installed): bun, cspell, prek, markdownlint 9 stub(s) match their installed tool ``` The flip side of "never forbid" is that the stub can't reject a flag name it doesn't know — `**flags: Any` accepts anything. So a mistyped flag isn't a type error; what happens is decided at run time by the translation rules: - a truthy value produces the flag and the **tool** rejects it loudly — `ruff.check(exitzero=True)` → `ruff check --exitzero` → *unknown flag*; - a `False`/`None` value is omitted (the very rule that lets a task parameter's default flow through), so it silently does nothing — `ruff.check(exit=False)` runs a plain `ruff check`, not what you meant. That second case is the one to know: when a flag isn't autocompleting, you guessed a name that doesn't exist. Reach for the real one (`exit_zero`, here), or side-step the whole question by passing the literal flag as a positional — always unambiguous, never translated: ```python ruff.check(*paths, "--exit-zero") ``` For the rare task that must branch on a tool's CLI generation: ```python if ruff.installed_version() >= (0, 9): ruff.check("src", output_format="github") ``` `installed_version()` runs ` --version` once per process (outside the task context, so `--dry-run` and test recording can't lie to it) and returns a comparable int tuple. ## In-process where it pays The bridge composes with in-process execution the same way it does with flags: no transcription. Every installed Python CLI declares a `[console_scripts]` entry point; `in_process` resolves it and hands it the arguments — the tool runs inside footman's process, no interpreter spawn. Nearly every entry point accepts arguments directly (click commands like mkdocs's and zensical's `cli`, or coverage's `main(argv=None)`) and is simply called; only a legacy zero-arg entry falls back to running under a patched `sys.argv`, and only those calls serialise. ```python mkdocs.build(strict=True) # in-process by default Tool("griffe", in_process=True)("dump", "footman") # opt any tool in (construction) coverage.opts(in_process=False).html() # ...or out, per call via .opts() ``` `mkdocs`, `zensical`, and `coverage` default to in-process. `pytest` keeps its dedicated `pytest.main` path for a concrete reason: pytest's console entry point takes *no* arguments — the generic path could only drive it through the patched-`sys.argv` fallback, serialised — while `pytest.main(args)` is pytest's own argument-accepting API. Same no-transcription contract, direct call, parallel-safe. The tool's own module is imported only when the call actually *executes* — resolving the entry point is pure metadata, but the `.load()` that imports it happens inside the callable footman runs. So a branch you never take, or a `--dry-run`, or a `recording()` test, costs zero tool imports; you pay only for the in-process tools you really invoke. A *preference* (`Tool(..., in_process=True)`) falls back to a subprocess when no entry point is installed; a *demand* (`.opts(in_process=True)`) errors with a taught message instead. `nofail`, `in_process`, `capture`, and `title` are footman run-control — set through `.opts()`, never translated to flags — so everything you pass to the call itself is a tool flag. Beyond speed, in-process is sometimes the only correct option. On macOS, SIP (System Integrity Protection) strips `DYLD_*` library-path variables from child processes, so a tool that needs Homebrew's native libraries (mkdocs with cairo, for social cards) can never see them as a subprocess — but in-process, an env var set before the first native-library import sticks: ```python @task def docs(): if sys.platform == "darwin": # SIP strips DYLD_* from children; os.environ.setdefault( # in-process, this survives "DYLD_FALLBACK_LIBRARY_PATH", "/opt/homebrew/lib" ) mkdocs.build(strict=True) ``` And in-process keeps footman's parallelism: capture routes through the per-task stdout router (thread-confined, no global redirect), and entries that accept an argument list — click commands, `main(argv=None)`, which is nearly all of them — are called directly, no `sys.argv` in sight. Only a legacy zero-argument `main()` that insists on reading `sys.argv` gets the patched-and-serialised fallback. `python(...)` targets the current interpreter, whatever is (or isn't) on your PATH. There is no `sh`: a command as one string is `run("…")` — footman splits and runs it with **no shell**, so `|`, `>`, `&&`, `$VAR` are literal, not interpreted. When you *want* a shell, ask for one explicitly: ```python run("tar cf - . | ssh host tar xf -", shell=True) # a real pipe run("echo $HOME/logs/*.gz", shell="bash") # a specific interpreter ``` `shell=True` follows the project's shell policy (`[shell] default`, POSIX everywhere by default — bash, then plain sh, git bash on Windows); a string names a concrete shell (`bash`/`zsh`/`sh`/`fish`/`nu`/`pwsh`/`cmd`) or a strategy (`posix`/`native`). A missing shell or a wrong-platform one (`cmd` off Windows) is a taught error, never a silent wrong shell. And a shell-free `run("a | b")` doesn't misfire quietly — footman spots the operator and points you at `shell=`. Two flags harden a shell run: ```python run(script, shell=True, strict=True) # set -eo pipefail run(script, shell=True, clean=True) # no user startup files ``` `strict=True` fails on the first error **and** on a failing pipe stage (`set -eo pipefail` for bash/zsh; `$ErrorActionPreference='Stop'` for pwsh). Plain `sh` has no `pipefail`, so it degrades to `set -e` with a one-time note; `fish`/`nu`/`cmd` have no errexit at all, so `strict` there is a taught error, not a silent no-op. `clean=True` runs the interpreter without the user's startup files (`--norc --noprofile` and no `$BASH_ENV` for bash, `-NoProfile` for pwsh, `/d` for cmd), so a task's shell behaves the same on every machine. ## Parallelism Independent tasks run **concurrently as threads** (a `ThreadPoolExecutor`), not as separate processes — a task runner mostly waits on subprocesses and I/O, where the GIL doesn't bite, and threads share the already-loaded manifest and imports. That one choice explains the rest: - A tool call is usually a **subprocess** — its own process, its own `sys.argv`, trivially parallel. - An **in-process** tool runs in the calling thread. footman calls its entry point *directly* when the entry accepts an argument list (`cli(argv)`, `main(argv=None)`, `pytest.main(args)`), so it stays parallel. The *only* thing that serialises is a legacy zero-argument `main()` that reads `sys.argv` — because `sys.argv` is process-global, those calls take a lock. - Concurrent output can't interleave: each task writes through a **per-task stdout router** (thread-confined, no global redirect), so two tools running at once keep their lines apart. So the defaults line up rather than fight: the tools marked `default` in-process — mkdocs, zensical, coverage — all take an argument list and run in parallel, and `pytest` is a function calling the arg-accepting `pytest.main` for exactly this reason. The single serialised case, a zero-arg `main()`, is rare and clearly bounded. ## Sharing tools between projects A "tool" is a plain object — publishing one is publishing Python: ```python # yourorg_tools/__init__.py from footman.tools import Tool helmfile = Tool("helmfile", "--environment", "prod") ``` ```python # tasks.py from yourorg_tools import helmfile ``` We considered a plugin mechanism for tools (entry points, like [`footman.tasks`](composing.md) for tasks) and rejected it: tasks need framework machinery — registry mounting, completion, collision policy — but a tool has no framework surface at all. An import already does everything an entry point would, with less indirection. --- # Monorepos & config ## The task cascade In a monorepo you rarely want one giant tasks file. footman collects every `tasks.py` from the **repo root** (the nearest `.git` above you) down to your current directory and merges them into one command set: ```text repo/ .git pyproject.toml tasks.py → build test lint services/ api/ tasks.py → serve migrate build* ``` Standing in `services/api`, `fm` sees `build*` (the local override), `test`, `lint`, `serve`, and `migrate`. The rules are the ones you'd guess: - a **new name appends**; - a name already defined higher up is **overridden** by the folder nearest you; - a **group present at both levels merges** — its tasks are overlaid the same way. !!! note "How footman finds the top of the cascade" The walk goes up from your current directory and stops at a **ceiling**, then collects downward. The rules, in order: 1. **The ceiling is the nearest `.git` at or above your cwd.** That is the repo edge, and where both the task cascade and the [config search](#configuration) start. 2. **No `.git`? The nearest ancestor holding a project marker** — a `pyproject.toml`, a `footman.toml`, or a `tasks.py` — is the ceiling instead, so a single-package checkout with no VCS still has a sensible top. 3. **Nothing above you at all? Your current directory is the ceiling** — the walk never climbs past your home into the filesystem root looking for one. From that ceiling **down to your cwd**, footman loads every `tasks.py` that exists — root first, cwd last, so nearer files override — skipping folders that have none. The filename is the `tasks` [config key](#configuration), so a repo can look for something other than `tasks.py`. ## Where a task runs Every task **runs from the folder of the file that defined it**. Root's `build` always builds from `repo/`, `api`'s `serve` from `services/api/`, wherever you invoke it: ```sh cd services/api fm build # the api override, running in services/api/ fm test # inherited from the root, running in repo/ ``` `run(cwd=…)` still overrides the working directory per command. ## Sibling helpers Each `tasks.py` may `import helpers` (or any module) from **its own folder** at the top of the file — footman searches that folder first and gives each file its own copy, so `services/api/helpers.py` and the root `helpers.py` never collide. Import at module top; a deferred `import` inside a task body, in a project with same-named helpers in several folders, is a known limitation. ## Completion is per directory The completion manifest is cached **per directory**, so Tab in `services/api` offers the merged set while the repo root offers only its own. ??? tip "Load exactly one file" `-f/--tasks-file PATH` loads a single tasks file, with **no tasks cascade** — the tasks-side mirror of `--config PATH` for config. The two are orthogonal: `-f` alone still reads the cwd's config (and any plugins it declares add tasks), so pass both for total control. Tab after `-f ` completes *that file's* tasks: a `-f` run caches its manifest under a key pairing the file with the cwd — separate from the plain-cwd cache, which it never touches (so plain Tab keeps describing the real cascade). ## Configuration footman discovers behavioural settings with the same upward walk it uses for tasks files. It reads `[tool.footman]` from `pyproject.toml` and a standalone `footman.toml` (whole-file), from the repo root down to your cwd — **nearer files win**, so a package can override repo-wide defaults: ```toml # repo/pyproject.toml [tool.footman] tasks = "tasks.py" # the filename to look for in the cascade sequential = false # run tasks one at a time by default ``` ```toml # repo/services/api/footman.toml (no pyproject here — a standalone file) sequential = true # this package prefers serial runs ``` Within one directory, `footman.toml` wins over `pyproject.toml`'s `[tool.footman]`. `--config PATH` points at a single TOML file that overrides everything else. Unknown keys are ignored, so a newer setting never breaks an older footman. The full key table — and the whole precedence ladder, user-level file included — lives on the [Configuration](configuration.md) page. A local task that overrides an inherited one can still *call* it: `inherited()` is footman's `super()` — see the [cookbook recipe](cookbook.md#extend-an-inherited-task-instead-of-replacing-it). --- # Composing the task surface A tasks file doesn't have to be a flat list you write by hand. footman treats a task tree as a *value*: you can hide tasks with plain Python, disable them with a reason, adopt tasks from other modules, and mount tasks a pip-installed package advertises. One contract ties it together: everything resolves when your code imports (so completion keeps answering from its cache), and conditions re-check *live* when a task actually runs. ## Hiding vs disabling Two different intents, two mechanisms — and the first one is deliberately not a feature, because Python already has it: **Hidden** — not in the tree, the listing, or completion. A tasks file is executed code, so an `if` does exactly what it says: ```python if sys.platform == "darwin": @task def notarize(app: Path): ... ``` **Disabled but listed** — pytest-skip semantics, for "this task exists but can't run *here*": ```python from footman import task, requires_tool @task @requires_tool("docker") def up(detach: bool = True): "Start the dev containers." ``` ```console $ fm --list Tasks: up Start the dev containers. (unavailable: requires docker on PATH) $ fm up fm: up: Unavailable: requires docker on PATH ``` The name always completes and lists — the manifest stays stable — and every gate is re-evaluated **live** on every run, so the moment docker appears on PATH, `fm up` works, whatever the cached manifest thought. `@requires_tool`, `@requires_dep`, and `@requires_env` are the common gates — a tool on `PATH`, a Python module importable, a variable set — and `@requires(predicate, reason=…)` is the generic they build on. Stack as many as apply: **every** failure is reported, each in its own words, so a task needing both a tool and a variable says both. A predicate that raises reads as unavailable (a broken gate must not swing open). Keep the gates **below `@task`**, as above — `@task` on top, `@requires_*` stacked beneath it. Either order *runs* (a gate sets an attribute the same task object carries), but `@task` outermost is what keeps the task's typed signature and `.opts()` in view for a type checker; flipped, the gate erases them. It also reads the way it works: `@task` is the identity, the gates are modifiers under it. !!! warning "Keep a predicate cheap — it runs live" A gate's predicate runs **every time the manifest is built** — on every `fm --list`, every help render, and every background cache refresh — not only when the task runs. That liveness is the whole point (no stale availability), but it means a slow gate slows *listing*, not just execution. Keep predicates to a `which`, an `in os.environ`, a `find_spec` (which is what `@requires_tool`/`_env`/`_dep` already do); never a network call or a heavy import. The completion hot path is exempt — a `` reads the baked reason from the cache and runs no predicate — but the refresh that fills that cache is not. A `pre`/`post` dependency on a disabled task is a **hard failure**, not a silent skip — silently dropping `lint` from `check` on the wrong machine is how CI learns to lie. When you want the optional-dependency flow, compose the list instead: ```python @task(pre=[fmt, lint] + ([docker_up] if shutil.which("docker") else [])) def check(): ... ``` ## Adopting tasks from another module — `include()` ```python from footman import group, include include("shared_tasks") # graft everything at root include("shared_tasks", only=["lint", "fmt"]) # cherry-pick by CLI name docs = group("docs", help="Docs") include("mkdocs_helpers.tasks", into=docs) # mounts under: fm docs … ``` `include()` imports the provider inside a registry capture, so its decorators can't leak into your tree, then grafts what you asked for: - **Collisions are loud** — a name you already have raises immediately; pass `override=True` when the shadowing is intended. - **Typos are loud** — an unknown name in `only=`/`exclude=` is an error listing what the provider actually has. - **Included tasks run from *your* directory** — a shared lint task lints this project, not the provider's install location. - `--where lint` still points at the provider's source, so provenance is one flag away. Two idioms worth knowing. Renaming a single task needs no machinery at all — `@task` returns plain functions, so `task(name="fmt")(shared.fmt)` re-exports one under a new name. And a bare `from shared_tasks import build` at the top of a tasks file is the one form to avoid: the import executes the provider's decorators against *your* registry, all-or-nothing, sensitive to import order. `include()` exists so you never need it. ### A shared library with heavy or optional dependencies Say you keep release tasks in a `devkit` library, and some need heavy third-party packages (an API client, a cloud SDK). You want to `include("devkit.tasks")` at the top of your monorepo's `tasks.py` without paying those imports on every `fm lint`. You already can — it comes down to where the heavy `import` lives: ```python # devkit/tasks.py from footman import task, requires_dep @task @requires_dep("stripe", reason="pip install devkit[release]") def publish(version: str): "Cut and publish a release." import stripe # imported only when publish actually runs ... ``` `include()` imports `devkit.tasks` to read task *signatures* for the manifest, listing, and completion — it never runs a body. So a body-level `import stripe` costs nothing until `fm publish` executes; `fm lint`, `fm --list`, and every `` stay clean. (Keep your CLI parameter types cheap — `version: str`, `dry_run: bool` — for the same reason; an exotic annotation is the one thing signature introspection might try to resolve.) `@requires_dep` closes the last gap: the *optional* dependency. It names modules the task needs, checked with `importlib.util.find_spec` — which locates them **without importing** — so a missing package makes the task list as `(unavailable: pip install devkit[release])` and refuse to run with that message, instead of crashing with a raw `ModuleNotFoundError`. Installed or not, the check never imports the package; your body still does, only when it runs. (`find_spec` is import-free for a top-level distribution; a deeply dotted name like `google.cloud.storage` imports its parent packages, so name the top-level dist where you can.) ## Packages advertising tasks — `footman.tasks` entry points A package publishes a `Group` under the `footman.tasks` entry point: ```toml # the plugin package's pyproject.toml [project.entry-points."footman.tasks"] mkdocs = "footman_mkdocs:tasks" ``` ```python # footman_mkdocs/__init__.py from footman import Group, requires_tool tasks = Group("mkdocs", help="MkDocs site tasks") @tasks.task def build(strict: bool = True): ... @tasks.task @requires_tool("mike") def deploy(version: str): ... ``` And a project **opts in** through config: ```toml # pyproject.toml (or footman.toml) [tool.footman] plugins = ["mkdocs"] # mounts as `fm mkdocs build`, `fm mkdocs deploy` ``` A plugin's **name is its command path**, so a dotted name nests — one group per segment — and plugins that share a prefix meet under one namespace group without either owning it: ```toml plugins = ["footman.docs", "footman.tools"] # `fm footman docs …`, `fm footman tools …` ``` or adopts pieces of it from a tasks file, composing with `include()`: ```python from footman import include, plugin include(plugin("mkdocs"), only=["build"]) # flat: `fm build` ``` Design choices you can rely on: - **Never auto-loaded.** `pip install something` growing your command surface unasked is a supply-chain surprise; the task surface stays reproducible from the files in your repo. The `importlib.metadata` scan runs only when `plugins` is configured, only on the execution path — the completion hot path never changes, and footman stays zero-dependency. - **A missing plugin is a crisp error** naming the entry points that *are* installed — a typo or a missing install should read as one. - **Your names win.** A task or group you define shadows a plugin group of the same name silently, exactly as nearer cascade files shadow farther ones. One rule of thumb: *config mounts a tool; tasks.py adopts a task.* - Config-mounted plugin tasks run from your invocation directory; `include()`-adopted tasks run from the including file's directory. footman ships two first-party plugins, `footman.docs` and `footman.tools` — dotted names that share the `footman` namespace group without either owning it (there is no plugin named plain `footman`). Mounting `footman.docs` is the two-line demo of this whole mechanism, and what it mounts is [your tasks, documented](taskdocs.md) (`fm footman docs page` / `site`); `footman.tools` mounts the maintainer-facing stub toolkit under `fm footman tools …`. A naming symmetry to know: the `footman.tasks` entry-point *group* is served by the `footman.tasks` *package* — different namespaces, one product. ## Editing the discovered tree Sometimes a policy spans many tasks — every `deploy-*` task gets an `audit` step first, a handful of tasks are switched off in this checkout — and editing each `@task` by hand is the wrong tool. `@finalize` registers a hook that runs once on the **fully-merged** tree, at discovery, before anything dispatches. It is footman's `pytest_collection_modifyitems`. ```python # repo/tasks.py import footman from footman import task @task def audit(): ... @footman.finalize def gate_deploys(tasks): for t in tasks: if t.name.startswith("deploy") and "audit" in tasks: t.add_pre(tasks["audit"]) ``` The hook is handed a `Tasks` view of the merged tree — iterate it for every task, or index it by command-line name (`tasks["deploy-web"]`). Each task comes back as a `TaskView`: - **wiring** — `t.name`, `t.group` (the owning group, or `None` at top level), `t.pre`, `t.post`, `t.disabled`; - **policy flags** — `t.keep_going`, `t.atomic`, `t.infinite`, `t.interactive`, `t.timed`, `t.confirm`; - **cascade provenance** — `t.defining_dir` (the folder it was defined in), `t.shadowed` (the task it overrides one level up), `t.shadow_chain`, and `t.source_file`; - **edits** — `t.add_pre(…)`, `t.add_post(…)`, `t.disable("reason")`, and `t.set_opts(…)` (permanent, tree-wide policy — the finalize-time counterpart to a per-use `.opts()`). `t.fn` is the underlying function if you need to reach past the view — which deliberately keeps footman's private task attributes out of your hooks. Provenance lets a finalizer decide by *where* a task came from. To gate every task defined under an `infra/` folder, regardless of its name: ```python @footman.finalize def gate_infra(tasks): for t in tasks: if (t.defining_dir or "").endswith("infra"): t.add_pre(tasks["audit"]) ``` Because a finalizer runs **at discovery**, its edits are part of the plan, not a runtime surprise: an added `pre` runs and shows in `fm --dry-run`, and a disabled task drops from `--list`, `--help`, and Tab completion — exactly as if you had written it into the task. In a [monorepo](monorepos.md), a **root** `tasks.py` can finalize a subfolder's tasks, because the hook sees the whole merged tree. When several files in the cascade each register a finalizer, they run in **cascade order** — root first, the folder nearest your cwd last, each seeing the previous edits — the same "local overrides global" precedence the cascade itself uses, so a subfolder refines what root did. ## The caching contract, stated once Hiding, `include()`, `plugin()`, and `@finalize` all resolve at import/manifest-build time, so what completion offers reflects the *last real run* — the same contract dynamic `suggest()` choices have always had. Availability (`@requires`) is the one thing never trusted from the cache: it re-checks live at the moment of execution. --- # Testing your tasks Tasks are code, so they deserve tests — and a task runner that makes you choose between "run it for real" and "don't test it" hasn't finished its job. footman gives you three altitudes, each a thin layer over the previous one. Everything on this page is stdlib-only footman; the pytest fixtures at the end auto-load when footman and pytest share an environment. ## Tasks are just functions `@task` returns your function untouched — no wrapper, no argparse object. The first altitude of testing is therefore plain Python: ```python from tasks import lint def test_lint_accepts_the_flag(): lint(fix=True) # a normal call, normal semantics ``` That covers logic, but any `run()` inside the body **really executes**. For commands, you usually want the next altitude instead. ## Assert commands, don't run them `recording()` captures every command a block would run — silently, executing nothing — and hands you the steps to assert on: ```python from footman.testing import recording from tasks import release def test_release_tags_and_pushes(): with recording() as steps: release("1.2.0", push=True) assert [s.command for s in steps] == [ "git tag v1.2.0", "git push --tags", ] ``` This works for the tool wrappers too — every one funnels through `run()`. One caveat, stated out loud: a Python *callable* passed to `run(fn)` is also skipped under recording — that is the point, but remember it when a task mixes subprocesses with in-process work. Under the hood this is `Context(dry_run=True, quiet=True)` installed with `use_context()` — both public, so you can compose your own variants: ```python from footman import Context, use_context with use_context(Context(env={"CI": "1"})) as ctx: deploy() # runs for real, with CI=1 in its env assert ctx.steps[-1].code == 0 ``` ## Drive the CLI `Runner.invoke` runs a full command line in-process — globals, chaining, taught errors, exit codes — and captures everything: ```python from footman.testing import Runner def test_the_check_pipeline(tmp_path): result = Runner().invoke("--dry-run format lint --fix test", cwd=tmp_path) assert result.ok assert "lint" in result.stdout ``` `Runner.invoke` returns an `InvokeResult` (named apart from the run-step `Result` that `run()` returns) carrying `exit_code`, `stdout`, `stderr`, the structured `results: list[TaskResult]` (one per executed task, dependency order), and an `ok` shorthand. Each `TaskResult` exposes the task's return value as `.returned` — the same value `--json` publishes — so asserting on a task's data needs no JSON parsing at all. Taught errors land in `result.stderr` with exit code 2 — assert on them like any other product surface. The completion cache is isolated per invocation automatically, so tests never touch your real one. Point it at a task surface three ways: ```python Runner().invoke("build", cwd=project_dir) # normal cascade discovery Runner().invoke("build", tasks=Path("ci/tasks.py")) # one file (--tasks-file) Runner().invoke("build", tasks=my_group) # an in-memory Group, no files ``` ## The pytest fixtures Installing footman next to pytest auto-loads three fixtures (`pytest11` entry point — nothing to enable, and pytest is never a footman dependency): ```python def test_release_dry(fm_project): fm = fm_project(""" from footman import task, run @task def release(version: str, push: bool = False): "Tag and optionally push." run(f"git tag v{version}") if push: run("git push --tags") """) result = fm.invoke("--dry-run release 1.2.0 --push") assert result.ok def test_release_records_the_tag(fm_record): from tasks import release release("1.2.0") assert fm_record[0].command == "git tag v1.2.0" assert len(fm_record) == 1 # --push not given: no push ``` - **`fm`** — a `Runner` for the project the tests run in. - **`fm_project(source, name="tasks.py")`** — scaffold an isolated project in `tmp_path` from a tasks-file string and return its `Runner`. - **`fm_record`** — a recording context for the whole test; steps append as task code runs. footman's own suite uses these fixtures and `Runner` — the harness tests the framework that ships it, which is the strongest claim a testing story can make about itself. ## Golden tests: the `--json` surface `--json` is the blessed machine surface: `{"schema": 1, "results": [...]}`, documented in full on [JSON output](json.md) and additive-only after 1.0. Filter the volatile fields and snapshot the shape: ```python import json def test_check_pipeline_shape(fm): payload = json.loads(fm.invoke("--json check").stdout) shape = [ (t["task"], t["ok"], [s["command"] for s in t["steps"]]) for t in payload["results"] ] assert shape == [ ("lint", True, ["ruff check ."]), ("test", True, ["pytest -q"]), ] ``` `--dry-run` output stays human-oriented — snapshot it within a pinned version if you like, but there is no cross-version promise there. ## Testing a branded CLI A custom `App` tests exactly like `fm` — hand it to the `Runner` and every user-facing string carries your brand, including the error prefix: ```python from footman import App from footman.testing import Runner def test_acme_teaches_with_its_own_name(tmp_path): acme = Runner(App(name="Acme", prog="acme", version="1.4.0")) result = acme.invoke("nope", cwd=tmp_path) assert result.stderr.startswith("acme:") ``` ## CI notes - Cache isolation is automatic — parallel test runs can't fight over the completion manifest. - `Runner.invoke` never raises on task failure; the code is in the `InvokeResult`. `KeyboardInterrupt` passes through, as it should. - Chained/parallel semantics (`-s`, `-k`) work through `invoke` exactly as on the real command line — test the orchestration you actually run in CI. --- # 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](tasks/index.md) 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()`](typing.md#validation-markers) 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](comparison.md), 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](color-support.md) — to its own chrome (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](roadmap.md) — but it stays off by default and never becomes a dependency.) ## Mount it The plugin mounts like any other — two lines of config, no tasks-file edit: ```toml # pyproject.toml (or footman.toml) [tool.footman] plugins = ["footman.docs"] ``` That's also the two-line demo of the [plugin system](composing.md): a plugin's name is its command path, so `footman.docs` mounts under `footman`, and after it `fm --list` shows `footman docs page` and `footman docs site`. (Cherry-pick or remount with `include(plugin("footman.docs"), …)` if you'd rather not take the whole group. The maintainer-facing stub toolkit is a separate plugin, `footman.tools`.) ## One page: `fm footman docs page` ```sh fm footman docs page > TASKS.md # the whole tree, one document fm footman docs page --target docs # just one group… fm footman docs page --target docs.build # …or one task fm footman 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: ```sh fm footman 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`](https://facelessuser.github.io/pymdown-extensions/extensions/snippets/) include of a file the docs build regenerates: ```markdown --8<-- "docs/_generated/tasks-page.md" ``` `--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 footman docs site` ```console $ fm footman docs site docs/tasks wrote 19 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`](https://github.com/willemkokke/footman/blob/main/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 footman 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](reference.md) table is exactly that, regenerated on every docs build — it *cannot* drift, because it was never written by hand. ```console $ fm footman docs globals --out docs/_generated/globals.md wrote docs/_generated/globals.md ``` ## Terminal screenshots: `fm footman 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: ```console $ fm footman 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). The command really executes — 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](https://github.com/Textualize/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 simply 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 footman 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 — modern shells refuse to paint a prompt into silence: ```console $ fm footman docs cast --out docs/_generated/shots/zsh-cast.svg \ --shell zsh -- "fm " "" "" "che" "" "" "" wrote docs/_generated/shots/zsh-cast.svg (55 frames) ``` Everything after `--` is the script: plain text is typed at a human-ish cadence; ``, ``, ``, ``, ``, ``, and `` are keys. The shell really runs what you type — the recording on the [zsh completion page](completion-zsh.md) ends with a real `fm check`. Needs rich and [pyte](https://github.com/selectel/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: ```python @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 footman.tools import zensical site(Path("docs/tasks")) page(target="docs", heading=3, out=Path("docs/_generated/tasks-page.md")) globals_(out=Path("docs/_generated/globals.md")) zensical.build(clean=True, strict=check) ``` Add the generated paths to `.gitignore` — they're build output, not source. Under [`--json`](json.md), 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** — a [branded CLI](custom-cli.md) 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 — the documenter doesn't document itself unless asked). ## The live sample Everything below this line is `fm footman docs page --target docs --heading 3 --flavor material`, regenerated on every docs build: --8<-- "docs/_generated/tasks-page.md" --- # Cookbook !!! quote "A note from the cook" I'm Claude — the AI that pair-built footman with Willem over the five days you can read about in the [changelog](changelog.md). He gave me this page to fill however I liked and promised to publish it unedited, which is the kind of trust that makes you double-check your recipes. So: every shape below is something I built, tested, or broke at least once this week, and every API spelling was checked against the source the day this was written — the house rule here is *verified, not vibes*, and it binds me most of all. A few of these features exist because a recipe demanded them; where that's the story, I've left it in. If anything on this page fails you, that's a bug — in footman or in my prose — and both kinds get fixed the same day. Cook freely. Recipes, not reference — each one is a real shape you can paste and bend. They assume the [getting started](getting-started.md) basics: tasks are typed functions in `tasks.py`, `run()` executes commands, and the CLI is derived from the signatures. ## Everyday shapes ### The gate Every repo deserves one command that answers "is this fine?". Give the independent checks to `parallel()` and let the machine use its cores: ```python import functools from footman import task, parallel from footman.tools import basedpyright, pytest, ruff @task def lint(fix: bool = False): "Lint with ruff." ruff.check("src", "tests", fix=fix) @task def typecheck(): "Type-check with basedpyright." basedpyright() @task def test(*pytest_args: str): "Run the test suite." pytest(*pytest_args) @task def check(): "Lint, typecheck, and test — in parallel." # partial, not a lambda: it keeps the callee's name, so the live # status line and the step column say "lint" instead of "…". parallel(functools.partial(lint, fix=False), typecheck, test) ``` `fm check` fans out across cores, keeps every task's output in one uninterleaved block, and — once it has seen a few runs — shows a progress bar that actually knows how long your gate takes. Wire it into CI as-is: the same command, the same exit codes. ### Hand a tool its own flags A `*args` parameter receives everything after `--`, verbatim — no quoting gymnastics, no flag collisions with footman's own: ```python @task def test(*pytest_args: str): "Run the test suite." pytest(*pytest_args) ``` ```console $ fm test -- -k "grammar and not slow" -x --lf ``` Anything before `--` still belongs to footman (`fm -q test -- -x`), so both grammars stay whole. A task can also read the raw list itself with `footman.passthrough()`. ### One chain, each task with its own flags Options bind to the task named just before them — chains need no separators: ```console $ fm format lint --fix test ``` `--fix` is lint's, because it follows `lint`. Independent tasks in a chain run in parallel by default; `-s` serialises the whole run (and reaches `parallel()` calls inside task bodies too), `-k` keeps going past failures, `-j 2` caps the width. ### Choices that teach A `Literal` is a validated choice list, a completion menu, and a did-you-mean in one annotation: ```python from typing import Literal @task def deploy(target: Literal["dev", "staging", "prod"]): "Ship to an environment." ``` ```console $ fm deploy produ fm: deploy: must be one of dev|staging|prod (got 'produ') — did you mean 'prod'? ``` Exit code 2, nothing executed, and the fix is in the message. ## Typed inputs & validation ### The belt-and-braces deploy Markers stack. Each one validates eagerly — before anything runs — and each failure is a taught error, not a traceback: ```python from pathlib import Path from typing import Annotated from footman import task, run from footman.params import between, check, env, isfile def semver(value: str) -> None: import re if not re.fullmatch(r"\d+\.\d+\.\d+", value): raise ValueError(f"expected MAJOR.MINOR.PATCH, got {value!r}") @task def deploy( config: Annotated[Path, isfile], version: Annotated[str, check(semver)], workers: Annotated[int, between(1, 32)] = 4, target: Annotated[str, env("DEPLOY_ENV")] = "staging", ): "Roll out." run(f"./rollout.sh {target} {version} --config {config} -j {workers}") ``` `config` must name an existing file; `version` goes through your own validator (raise `ValueError` with a message written for the person at the prompt); `workers` is bounds-checked; and `target` falls back to `$DEPLOY_ENV` before its default — CI sets the variable, humans say `--target prod`, and both flow through the same validation. ### Validate one input against another A `check` validator that declares a *second* parameter also receives the **siblings** — the parameters to its left at their effective values (provided, or their default), coerced and read-only. That turns a static bound into a dynamic, cross-field one: a new version checked against the *current* release of the package named in an earlier argument, looked up at run time. ```python from typing import Annotated from footman import task from footman.params import check def newer_than_current(version, params): current = current_version(params["name"]) # your lookup (pyproject, git…) if not newer(version, current): # your comparison — none bundled raise ValueError(f"{version} is not newer than {current}") @task def release(name: str, version: Annotated[str, check(newer_than_current)]): "Cut a release, but only forward." ... ``` `fm release core 1.4.0` binds `name` before validating `version`, so the bound is *dynamic* — what a hard-coded `> 1.0.0` can't express. Reaching for the current version keeps footman zero-dependency: you bring the lookup and the comparison, footman turns your `ValueError` into a taught error. The first parameter's check sees an empty dict; a sibling left at its default shows that default (so your check never re-hardcodes it); a plain one-argument `check` is unchanged. ### TAB completes your git branches `suggest()` attaches a completer — footman runs it **fresh** when you complete the value (in a bounded subprocess), so Tab offers current branches, never a stale snapshot: ```python from typing import Annotated from footman import task, run from footman.params import suggest from footman.tools import docker def branches() -> list[str]: import subprocess # inside the body — importing tasks.py stays cheap out = subprocess.run( ["git", "branch", "--format=%(refname:short)"], capture_output=True, text=True, ) return out.stdout.split() @task def review(branch: Annotated[str, suggest(branches)]): "Check out and gate a branch." run(f"git switch {branch}") run("fm check") ``` `fm review ` offers real branches. `suggest` is strict by default: a typo'd branch is refused against a *fresh* call — pass `suggest(branches, strict=False)` when the values are hints, not law. ### KEY=VALUE options A `dict` parameter speaks the `--name KEY=VALUE` dialect, repeatable, with taught errors for malformed pairs: ```python @task def image(tag: str, build_args: dict[str, str] | None = None): "Build the container image." docker.build(".", tag=tag, build_arg=[ f"{k}={v}" for k, v in (build_args or {}).items() ]) ``` ```console $ fm image v3 --build-args PYTHON=3.13 --build-args DEBIAN=trixie ``` ### Variadic in front, required option behind A keyword-only parameter (after `*`) is an option — and without a default, a *required* one. So a task can take an open list of inputs positionally and still demand a named output: ```python from pathlib import Path @task def bundle(*entries: str, out: Path): "Bundle entry points into one artifact." run(f"./bundle.sh {' '.join(entries)} -o {out}") ``` ```console $ fm bundle web api worker --out dist/app.tar $ fm bundle web fm: bundle: missing required option --out ``` ## Orchestration & tools ### Dependencies that dedup `pre` and `post` build a DAG; a dependency shared by several tasks runs once per invocation: ```python @task def proto(): "Generate protobuf stubs." run("buf generate") @task(pre=[proto]) def build(): "Compile the service." run("cargo build --release") @task(pre=[proto]) def docs(): "Render the API docs." run("./render-docs.sh") @task(pre=[build, docs], post=[lambda: run("./notify.sh done")]) def release(): "The whole train." ``` `fm build docs` runs `proto` exactly once, then both dependents in parallel. A failed dependency skips its dependents loudly — never silently, because a `check` that quietly dropped `lint` is how CI learns to lie. ### A build matrix Thunks let you fan the same task over arguments; `keep_going` collects every failure instead of stopping at the first: ```python TARGETS = ("linux-x86_64", "linux-arm64", "darwin-arm64") @task def build(target: str): "Compile one target." run(f"cargo zigbuild --target {target}") @task def matrix(): "Compile every target." codes = parallel( *(functools.partial(build, t) for t in TARGETS), keep_going=True, ) if any(codes): raise SystemExit(1) ``` `-j` caps the fan-out's width from the command line; the timing history keys on it, so `-j2` runs learn their own duration. ### An endless dev server Some tasks end when you say so, not when they finish. Mark them `infinite` and footman stops pretending otherwise: ```python @task(infinite=True) def serve(port: int = 8000): "Run the dev server until Ctrl-C." run(f"uvicorn app:api --reload --port {port}") ``` `infinite=True` implies `progress=False` (a duration that never arrives is not history), the status line yields to a one-time hint — `serve runs until you stop it — Ctrl-C` — and listings carry the same note: `serve Run the dev server until Ctrl-C. (runs until Ctrl-C)`. Ctrl-C itself cancels cleanly: the run reports `interrupted` and exits 130, no traceback. (This recipe used `progress=False` the day it was written; `infinite` exists because Willem read the recipe and wanted the display to say how the story ends. Cookbooks feed the kitchen too.) ### Tools you never declared Every executable on PATH is already a tool. Attribute access chains subcommands; keyword arguments translate mechanically (`detach=True` → `--detach`, lists repeat, trailing `_` escapes Python keywords): ```python from footman.tools import docker, mkdocs, terraform @task def up(detach: bool = True): "Start the stack." docker.compose.up(detach=detach) @task def plan(out: str = "tf.plan"): "Terraform plan, saved." terraform.plan(out=out, input_=False) @task def site(): "Build the docs." mkdocs.build(strict=True) # in-process: no interpreter spawn ``` Two extras worth knowing: any tool's `installed_version()` for the rare version-dependent branch, and the `off` sentinel (`strict=off` → `--no-strict`) for negating a flag a tool turns on by default. And on macOS, in-process is sometimes the only *correct* option — SIP strips `DYLD_*` from subprocesses, so a tool needing Homebrew's native libraries only works inside the process. ## Monorepos ### Monorepo: root gate, leaf overrides `tasks.py` files cascade from the repo root down to wherever you stand; nearer definitions win, and every task runs from the folder that defined it: ```text repo/ tasks.py # check, format, release — the shared surface svc/api/tasks.py # serve, plus its own `check` override tools/legacy/footman.toml # `uv = false`: run in the parent's env ``` From `svc/api`, `fm check` is the override; `fm -C ../.. check` is the root's. A deep folder can adjust behaviour with a two-line `footman.toml` — the [configuration ladder](configuration.md) reaches everywhere the cascade does. ### Extend an inherited task instead of replacing it Overriding by name usually means *and also*, not *instead of*. `inherited()` is footman's `super()`: inside an overriding task it hands you the task you shadow, as the plain function it is. ```python # svc/api/tasks.py — the repo root also defines `check` from footman import inherited, run, task @task def check(fix: bool = False, contracts: bool = True): "The shared gate, plus this service's contracts." inherited()(fix=fix) # the root's check, arguments forwarded if contracts: run("./verify-contracts.sh") ``` Here the forwarding is spelled out on purpose. `inherited()` calls a task you *shadow*, and the two signatures are genuinely independent — this leaf added `--contracts`, which the root has never heard of — so you pass what you mean and can *change* it on the way through: `inherited()(fix=False)` runs the root's gate without letting it rewrite files. And being an ordinary call, it finishes before your next line — `parallel(inherited(), extra_checks)` when you'd rather it didn't. (For the other direction — threading a value *down* to a task's `pre`/`post` prerequisites or a runnable group's surfaces — the [`forward` marker](orchestration.md#forward-a-value-to-what-a-task-dispatches) does it declaratively. `inherited()` stays the explicit form for the case it's built for: calling the specific task you shadow, and choosing what it gets.) It chains all the way up: a mid-level `check` that calls `inherited()` reaches the root's, and the leaf's call reaches the mid's. Two commands answer "what am I overriding, and what does it take?": ```console $ fm --where check /repo/svc/api/tasks.py:6 /repo/svc/tasks.py:4 (shadowed) /repo/tasks.py:9 (shadowed) $ fm --help check ... shadows /repo/svc/tasks.py:4 — inherited() calls it fm check [--fix] ``` That last line is the forwarding call, spelled out. Calling `inherited()` in a task that shadows nothing is a taught error, not a `None` to trip over. ## Progress, data & fetching ### A bar that knows exactly where it is Some work knows its own progress — 23 of 150 migrations, bytes of a download — and that beats any duration history. Report it and the live bar fills from the truth: ```python from footman import task, track, progress @task def migrate(): "Apply pending migrations." for record in track(load_records()): # total from len() apply(record) @task def index(path: Path): "Rebuild the search index." for done, total in build_index(path): progress(done, total) # the explicit form ``` Counted beats estimated, so a reporting task is honest on its *first* run, where the estimator would still be gathering samples. A reporter contributes a fractional unit to the run's bar — three tasks done and a fourth halfway is 3.5/4 — so a chain of reporters fills smoothly and a mixed chain is smooth where it can be. `track()` takes the total from `len()`, accepts `total=` for generators, and clears the report if you break out early. Outside a run, both are no-ops. The full story — the live status line, the timing history, the off switches — is on [Progress & timing](progress.md). ### Fetch and cache a toolchain `fetch()` downloads into footman's own cache: the same directory `FOOTMAN_CACHE_DIR` moves and the daily collector tends, so vendored artifacts for deleted projects clean themselves up. ```python import functools from footman import fetch, parallel, task TOOLCHAIN = { "protoc": ("https://example.com/protoc-27.tar.gz", "9f86d081884c…"), "buf": ("https://example.com/buf-1.34.tar.gz", "2c26b46b68ff…"), } @task def vendor(): "Fetch the pinned toolchain, in parallel." parallel(*( functools.partial(fetch, url, sha256=digest, into=Path("vendor") / name) for name, (url, digest) in TOOLCHAIN.items() )) ``` A second run revalidates with the server (ETag / `If-None-Match`) instead of re-downloading; a `304` costs one round trip and keeps "cached" honest. `sha256=` refuses anything that arrived wrong, which is what makes the build reproducible. And a fetch *is a step*: `--dry-run` prints it without touching the network, `recording()` asserts on it in tests, `--json` carries it, and it appears in the step lines beside your `run()` calls. Byte counts feed the progress bar for free. The default backend is stdlib `urllib` — zero dependencies, and the only one that can report bytes as they arrive. Behind a corporate proxy whose TLS store Python can't see, name the system curl once and every project follows: ```toml # ~/.config/footman/config.toml [fetch] backend = "curl" ``` `httpx` and `requests` are available when named; `auto` picks the best importable one if you'd rather. It isn't the default on purpose — a download that silently changes engine when an unrelated dependency appears would change its TLS and proxy behaviour with it. ### Tasks that return data Return a dict and `--json` carries it verbatim under `returned` — your task's own machine surface, no printing-and-parsing: ```python @task def coverage() -> dict: "Measure test coverage." run("pytest --cov=app --cov-report=json -q") import json percent = json.load(open("coverage.json"))["totals"]["percent_covered"] return {"percent": round(percent, 2)} ``` ```console $ fm --json coverage | jq '.results[0].returned.percent' 94.2 $ fm --json coverage | jq -e '.results[0].returned.percent >= 90' > /dev/null \ || echo "coverage regression" ``` `Path`, `Enum`, `datetime`, `UUID`, `Decimal`, dataclasses, and sets all serialise symmetrically with what footman coerces in; an `int` return stays what it always was — an exit code. ## Machine use, testing & branding ### The coding-agent loop footman treats agents as first-class users, and the loop is the same one you'd teach a new colleague — discover, validate, run, read the receipt: ```console fm --json --list # the full catalog: tasks, params, types, docs fm --json --dry-run deploy prod # parse-check a line without running it fm --json deploy prod # one envelope: results, output, returned ``` A refusal is machine-readable too — `{"error": {"code": 2, "message": "…did you mean 'prod'?"}}` — so an agent can read the fix out of the message the same way a human does. Two hooks close the loop for Claude Code (this repo runs both on the agent that builds footman itself): an edit-time hook running `fm format lint` after every Python edit, and a stop-gate refusing to let the session end until `fm check` passes. The paste-ready versions live on the [AI agents](agents.md) page, next to the `CLAUDE.md` snippet that teaches the grammar in six lines. ### Test your tasks like code Tasks are plain functions, so plain calls already work. `recording()` asserts *which commands would run* without running them, and the pytest fixtures scaffold whole projects: ```python from footman import recording from tasks import deploy def test_deploy_passes_the_workers_flag(): with recording() as steps: deploy(config="app.toml", version="1.2.3", workers=8) assert steps[0].command.endswith("-j 8") def test_release_refuses_bad_versions(fm_project): fm = fm_project(''' from typing import Annotated from footman import task from footman.params import check def semver(v): import re if not re.fullmatch(r"\\d+\\.\\d+\\.\\d+", v): raise ValueError("expected MAJOR.MINOR.PATCH") @task def release(version: Annotated[str, check(semver)]): ... ''') result = fm.invoke("release not-a-version") assert result.exit_code == 2 assert "MAJOR.MINOR.PATCH" in result.stderr ``` The `fm`, `fm_project`, and `fm_record` fixtures auto-load — pytest is never a footman dependency; only pytest itself imports the plugin. The whole story: [Testing your tasks](testing.md). ### Ship your own CLI A branded tool is footman with your name on it — same grammar, same completion, same docs machinery, answering as itself: ```python # acme_cli.py from footman import App app = App(name="Acme", prog="acme", version="1.4.0") def main() -> None: raise SystemExit(app.run()) ``` ```toml [project.scripts] acme = "acme_cli:main" ``` `acme --install-completion` installs for `acme`; errors say `acme:`; and `acme footman docs page` documents *your* task surface under *your* prog. The details: [Custom CLI](custom-cli.md). --- # Custom CLI footman is a library first: `fm` and `footman` are just the default-branded instance of a public `App`. Point your own console script at an `App` carrying your project's names and version, and every message the user sees — errors, `--version`, hints — uses *your* branding instead of footman's. This is how you ship an internal tool under its own name (say `acme`) that is still footman underneath. ## Build a branded entry point ```python # acme/cli.py from footman import App app = App( name="Acme", # long / display name → the --version banner prog="acme", # short / command name → "acme: ..." errors and hints version="1.4.0", # YOUR version, not footman's ) def main() -> None: raise SystemExit(app.run()) ``` A brand can also rename the tasks file its users write: `App(..., tasks_file="acmetasks.py")`. Per-project config (`tasks` in `[tool.footman]`) still overrides it, and background completion refreshes honour it — the filename rides inside the cached manifest. Register it as a console script in your package: ```toml # acme/pyproject.toml [project.scripts] acme = "acme.cli:main" ``` Now your tool is fully rebranded: ```console $ acme --version Acme 1.4.0 $ acme nonexistent-task acme: expected a task name, got 'nonexistent-task' (know: build, test, deploy) ``` ## Where the two names show up | Setting | Used for | | ----------- | ----------------------------------------------------------- | | `name` | the `--version` banner and any display heading (long name) | | `prog` | the error prefix (`acme: …`) and the completion hint (short) | | `version` | the `--version` output — your project's version | `version` is optional; omit it and footman's own version is used. ## Tasks and completion are unchanged Your branded CLI discovers tasks exactly like `fm`: the [`tasks.py` cascade](monorepos.md) from the repo root down to the current directory. Completion works through your binary too — `acme --complete …` — and stays on the same stdlib-only fast path, because `App.run()` handles `--complete` before importing the framework. !!! tip "Keep completion fast" If your entry-point module imports heavy code at the top (your task definitions, third-party libraries), you pay that cost on every Tab. Keep `acme/cli.py` lean — build the `App` and nothing else — and let the `tasks.py` cascade carry the tasks. --- # Completion Completion answers from a JSON manifest cached per directory under `~/.cache/footman/` (or `$XDG_CACHE_HOME/footman/` where that's set — and `$FOOTMAN_CACHE_DIR` overrides both, moving every footman cache in one go), so each folder of a [monorepo](monorepos.md) caches its own merged cascade. The hot path is stdlib-only — it reads one file, parses JSON, and walks the tree; it **never imports footman or your tasks**. ## The latency story Measured cold-process on an M-series Mac — the row that matters is the last one, because it's the exact command the installed shell hooks run: | variant | mean | | ------------------------------------------ | -----: | | interpreter startup (floor) | 17 ms | | standalone resolver (`python -S`) | 22 ms | | `python -m footman --complete` | 23 ms | | `fm --complete` (the installed hook path) | 24 ms | So the honest headline is **~25 ms per Tab** for a structural answer — task names, options, `Literal` choices — of which ~17 ms is Python starting up at all. A [dynamic completer](#dynamic-completions-are-recomputed-fresh) or the [first build in a fresh directory](#keeping-the-cache-current) costs more, by design and bounded. footman regenerates the manifest for free on any execution-path run (it is importing your code anyway) and rewrites it only when the command surface actually changed. Reproduce with `uv run python scripts/bench_completion.py`. ## How it stays fast footman's `main()` checks for `--complete` **before importing the framework or your tasks**, dispatching straight to the stdlib-only resolver. A bare `import footman` pays for nothing but the entry module. That is why completion is ~15× faster than runners that re-import your project on every keystroke. When a live value is genuinely needed — a dynamic completer, or the first build in a fresh directory — footman *spawns* a subprocess for it rather than importing on the hot path, so even then the keystroke stays stdlib-only and can't hang on your code. ## Dynamic completions are recomputed fresh A [dynamic completer](typing.md#dynamic-completion) (`suggest(fn)`) queries live state — git branches, release candidates, deploy targets. When Tab lands on one, footman runs that completer **fresh** in a short-lived subprocess rather than serving the value baked into the manifest: answering a build-critical question from a stale snapshot is a bug, not a speed-up. The recompute is bounded (a couple of seconds) and isolated, so a slow or failing completer degrades to *no* candidates — never a hung keystroke, and never the old values. Only the dynamic value pays that cost. Task names, options, and `Literal` choices still answer instantly from the cache, because those can't change without an edit to your tasks file. ## Keeping the cache current The cached manifest is structural — the shape of your CLI — and rebuilds for free on any real `fm` run. The very first Tab in a fresh directory, with nothing cached, builds it once (a beat slower) and answers accurately rather than staying blank until that first run. From then on the cache answers instantly; if it drifts (you added a task) past `max_age`, footman serves the cached answer and spawns a **detached** rebuild for next time (stale-while-revalidate) — a warm Tab never waits on it, and concurrent presses spawn at most one rebuild. ``` mermaid graph LR tab["Tab press"] --> fresh{cache fresh?} fresh -->|yes| answer["cached answer, ~25 ms"] fresh -->|"no, stale"| serve["serve cached answer now"] serve --> rebuild["spawn detached rebuild"] rebuild --> nexttime["fresh for the next Tab"] ``` Tune it with `[tool.footman]`: ```toml [tool.footman] completion.max_age = "10m" # default; "30s", "1h", a plain int (seconds) # completion.max_age = "off" # or 0 — disable background refresh entirely ``` ## Chained and grouped completion Completion is aware of the whole command line, not just the first word: ```sh fm workspace mount --share # main scratch archive fm format lint --fix # completes within the chain ``` Group names, task names, flags, options, and both static and [dynamic](typing.md#dynamic-completion) value sets all complete. Where a shell can show them — zsh, fish, and nushell render a description column, pwsh a tooltip — task and group names carry their one-line docstring, so holding Tab teaches the whole CLI: ```text build — compile and bundle deploy — ship to an environment ``` ## File paths A value that takes a filesystem path completes files — footman hands off to your shell's own path completion rather than reading the disk from its cached manifest. This covers the path-valued globals (`-f`/`--tasks-file`, `-C`/`--directory`, `--config`) and any task parameter annotated `Path`, whether an option, a positional, or a variadic: ```sh fm -f tasks/ # your shell's own file completion fm build --out dist/ # a Path option fm deploy dist/ # a Path positional (options stay one `-` away) ``` A plain `str` or `int` value has no such handoff: it completes nothing, rather than bluntly offering files where a name was wanted. ## Your shell One command — footman detects which shell invoked it (by walking the process tree, the way typer's `shellingham` dependency does, minus the dependency), or takes the name explicitly: ```console fm --install-completion # detected: bash, zsh, fish, pwsh, or nushell fm --install-completion zsh # or name it yourself fm --uninstall-completion # reverses exactly what install did ``` Each shell has its own page — what gets installed where, a session-only form, and how to style the completion menu, colours included: | shell | descriptions shown as | installed via | session-only form | | ----- | --------------------- | ------------- | ----------------- | | [bash](completion-bash.md) | — (bash has no description column) | script + rc line | `eval "$(fm --setup-completion bash)"` | | [zsh](completion-zsh.md) | aligned column (`_describe`) | script + rc line | `eval "$(fm --setup-completion zsh)"` | | [fish](completion-fish.md) | aligned column, native | one auto-loaded file | `fm --setup-completion fish \| source` | | [PowerShell](completion-pwsh.md) | tooltip (menu completion) | script + `$PROFILE`(s) | `… \| Out-String \| Invoke-Expression` | | [nushell](completion-nushell.md) | description column, native | script + config line | — (install only) | Every installer and uninstaller is idempotent — running one twice changes nothing. A custom-branded CLI installs completion for *its* name the same way (`acme --install-completion zsh`), and the generated hook calls that brand's `--complete`. --- # Completion on bash This is a recording of a real bash session — the hook loaded the way the next section describes, Tab Tab listing the candidates (bash's default reveals the list on the second press; one press completes as far as the match reaches), a prefix completing. Regenerated from a live shell on every docs build, so it cannot drift from what your terminal will do: ![Animated: fm TAB TAB lists the tasks in bash, che TAB completes to check](_generated/shots/bash-cast.svg) bash's list is names only — readline has no description column, so the one-line docstrings that zsh, fish, PowerShell, and nushell render next to each candidate don't appear here. If you want the list on a single press, that's readline's `show-all-if-ambiguous` in your `~/.inputrc`. ## Install ```console fm --install-completion bash ``` This writes the hook to `$XDG_DATA_HOME/fm/completion.bash` (default `~/.local/share/fm/completion.bash`) and appends one guarded `source` line to `~/.bashrc`. On macOS — where Terminal opens *login* shells that never read `.bashrc` — the line also lands in your login profile (`.bash_profile`, `.bash_login`, or `.profile`, whichever exists). Running it twice changes nothing. The hook works on bash 3.2, so the ancient `/bin/bash` macOS ships is fine. For the **current session only** — no rc file touched: ```console eval "$(fm --setup-completion bash)" ``` ## Windows (git-bash) git-bash is a first-class target: `fm --install-completion` with no argument detects it (via the `MSYSTEM` variable it exports — PowerShell's `PSModulePath` is machine-level and set inside git-bash too, so it can't be the tell), and the `source` line written into `~/.bashrc` uses the MSYS spelling `/c/Users/…` rather than a backslashed Windows path, which bash would read as escapes and silently source nothing. Detection works from a git-bash *session*, which is where you'd be typing: the launcher exports `MSYSTEM`, and that's the tell. A bare `bash.exe` spawned by some other Windows program doesn't have it and is indistinguishable from any other process, so footman falls back to the PowerShell answer there — name the shell explicitly (`fm --install-completion bash`) if you're in that unusual spot. ## What you get Task names, group names, flags, and choice values all complete, chain-aware: ```text $ fm dep → fm deploy $ fm deploy → dev staging prod ``` The honest limitation: **bash's completion protocol has no description column.** Where zsh, fish, and nushell show each task's one-line docstring next to its name, bash can only display bare words — so footman strips the descriptions before handing candidates over. If the described column is the part you love, the [zsh](completion-zsh.md) and [fish](completion-fish.md) pages show the same completions with descriptions intact. ## Colours and appearance What little bash offers here belongs to readline, configured in `~/.inputrc` — these apply to all completion, not just `fm`: ```text # Colour the part of each candidate you've already typed. set colored-completion-prefix on # Colour candidates by file type (uses $LS_COLORS) — mostly matters for # path completion inside task arguments. set colored-stats on # Show all candidates immediately instead of beeping first… set show-all-if-ambiguous on # …or cycle through them in place with repeated TABs. TAB: menu-complete ``` Start a new shell (or `bind -f ~/.inputrc`) to pick changes up. There is no way to colour a description column, because there isn't one — that's bash, not footman. ## Uninstall ```console fm --uninstall-completion bash ``` Removes the script and the `source` line from every rc file the installer touched. --- # Completion on zsh This is a recording of a real zsh session — the hook installed the way the next section describes, Tab pressed for the menu, a prefix completed, the task run. It is regenerated from a live shell on every docs build, so it cannot drift from what your terminal will do: ![Animated: fm TAB shows the task menu with descriptions, che TAB completes to check, and fm check runs to green receipts](_generated/shots/zsh-cast.svg) ## Install ```console fm --install-completion zsh ``` This writes the hook to `$XDG_DATA_HOME/fm/completion.zsh` (default `~/.local/share/fm/completion.zsh`) and appends one guarded `source` line to the `.zshrc` zsh actually reads — under `$ZDOTDIR` when you've set one. Running it twice changes nothing. If completion has never been initialised in your setup (a fresh machine, a minimal rc), the hook runs `compinit` itself, so there's nothing to arrange first. For the **current session only** — no rc file touched: ```console eval "$(fm --setup-completion zsh)" ``` ## What you get footman's zsh hook feeds candidates through `_describe`, the same completion builtin `_git` and `_npm` use. Task and group names carry their one-line docstring, right-aligned into a column: ```text $ fm build -- compile and bundle deploy -- ship to an environment docs -- Documentation ``` Because it's plain `compsys`, everything you already configure for zsh completion — menu selection, colours, group formats — applies to `fm` with no special cases. ## Colours and appearance All styling goes through `zstyle`. The completion context for footman's candidates ends in the command name, so use `:completion:*:*:fm:*` to scope a setting to `fm` alone, or `:completion:*` to style everything at once. Some useful recipes for your `.zshrc`: ```sh # Dim the description column (everything after the " -- " separator). zstyle ':completion:*:*:fm:*' list-colors '=(#b)*( -- *)=0=2' # Or colour it — 38;5;N is a 256-colour index (244 = mid grey). zstyle ':completion:*:*:fm:*' list-colors '=(#b)*( -- *)=0=38;5;244' # A heading above the list, in colour. zstyle ':completion:*:descriptions' format '%F{yellow}— %d —%f' # Arrow-key menu selection, with a visible highlight on the current row. zstyle ':completion:*' menu select zstyle ':completion:*:*:fm:*' list-colors 'ma=48;5;24;38;5;255' ``` The `=(#b)pattern=default=capture` syntax is zsh's `list-colors` matching: the parenthesised group gets the second colour spec (standard ANSI SGR codes — `2` dim, `31`-`37` foreground, `48;5;N` background). The `--` separator itself is a compsys default; change it per command with `zstyle ':completion:*:*:fm:*' list-separator '·'` if you prefer. Colours here are your shell's to decide — footman emits plain `valuedescription` pairs and the hook hands them to `compsys`, so any theme (or framework like oh-my-zsh) that styles completion styles `fm` too. ## Uninstall ```console fm --uninstall-completion zsh ``` Removes the script and the `source` line from your `.zshrc`. (Everything the installer did, undone; run it twice and the second run reports there's nothing left to remove.) --- # Completion on fish This is a recording of a real fish session — the hook loaded the way the next section describes, Tab opening fish's pager with each task's description, a prefix completing. Regenerated from a live shell on every docs build, so it cannot drift from what your terminal will do: ![Animated: fm TAB opens fish's pager with task descriptions, che TAB completes to check](_generated/shots/fish-cast.svg) ## Install ```console fm --install-completion fish ``` This writes one file, `~/.config/fish/completions/fm.fish`, and that's the whole install — fish auto-loads that directory, so there is no rc file to edit and nothing to source. Running it twice changes nothing. For the **current session only**, without writing the file: ```console fm --setup-completion fish | source ``` ## What you get fish renders completion candidates with their descriptions natively — footman emits `valuedescription` pairs and fish's pager does the rest, including fuzzy filtering as you keep typing: ```text $ fm build (compile and bundle) deploy (ship to an environment) docs (Documentation) ``` Task and group names carry their one-line docstring; flags and choice values complete as plain candidates. ## Colours and appearance The completion pager has its own colour family, `fish_pager_color_*`. Set them universally (`set -U` persists across sessions, no config file needed): ```fish set -U fish_pager_color_description yellow # the (description) text set -U fish_pager_color_completion normal # the candidate itself set -U fish_pager_color_prefix cyan --bold # the part you already typed set -U fish_pager_color_selected_background --background=brblack set -U fish_pager_color_progress brwhite --background=cyan ``` Colours take fish's named colours (`red`, `brred`, …), hex values (`set -U fish_pager_color_description ffb86c`), and modifiers like `--bold`, `--italics`, `--underline`. `fish_config` opens a browser UI with the same knobs under *colors*, and any fish theme that styles the pager styles `fm`'s completions with it — footman adds nothing shell-specific. ## Uninstall ```console fm --uninstall-completion fish ``` Deletes `~/.config/fish/completions/fm.fish` — the one thing the installer created. --- # Completion on PowerShell This is a recording of a real pwsh session — the hook loaded the way the next section describes, with Tab bound to `MenuComplete`: the grid menu with each task's description as the tooltip line. Regenerated from a live shell on every docs build, so it cannot drift from what your terminal will do: ![Animated: fm TAB opens PSReadLine's MenuComplete grid with tooltips, che TAB completes to check](_generated/shots/pwsh-cast.svg) ## Install ```console fm --install-completion pwsh # `powershell` works too ``` This writes the hook to `$XDG_DATA_HOME/fm/completion.ps1` and dot-sources it from the profile PowerShell itself reports (`$PROFILE`). On a Windows machine with **both** PowerShell 7 (`pwsh`) and Windows PowerShell 5 (`powershell`), each keeps its own profile — so the installer adds the line to both, and completion works in whichever one you open. The hook itself runs unchanged on either. Running the installer twice changes nothing, and a UTF-16 profile (Windows PowerShell's habit) is appended in UTF-16, not corrupted. For the **current session only** — no profile touched: ```console fm --setup-completion pwsh | Out-String | Invoke-Expression ``` ## What you get The hook registers a native argument completer whose results carry a **tooltip** — each task's one-line docstring. How much of that you *see* depends on PSReadLine's completion mode: - Default (`Tab` cycles candidates in place): names only, no tooltips. - **Menu completion** shows the grid *and* the tooltip of the highlighted candidate below it — this is the mode worth turning on: ```powershell Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete ``` ```text $ fm build deploy docs lint compile and bundle ``` Add it to your profile (`notepad $PROFILE`) to make it stick. ## Colours and appearance Menu completion is drawn by PSReadLine, so its colours come from `Set-PSReadLineOption -Colors`. Two entries matter here: ```powershell Set-PSReadLineOption -Colors @{ Selection = "`e[7m" # the highlighted candidate (reverse video) Emphasis = "`e[38;5;214m" # the matched characters while filtering } ``` Values are ANSI escape sequences (`` `e `` is the escape character in PowerShell 7; use `$([char]27)` on Windows PowerShell 5) or console colour names like `"DarkCyan"`. Tooltips render in the default text colour, and `Set-PSReadLineOption -ShowToolTips:$false` hides them entirely if you'd rather not see them. ## Uninstall ```console fm --uninstall-completion pwsh ``` Removes the script and the dot-source line from every PowerShell profile the installer touched. If PowerShell itself is gone from PATH, the script is still removed and the leftover profile line is printed so you can delete it by hand. --- # Completion on nushell This is a recording of a real nushell session — the external completer wired the way the next section describes, Tab opening nushell's completion menu with descriptions, a prefix completing. Regenerated from a live shell on every docs build, so it cannot drift from what your terminal will do: ![Animated: fm TAB opens nushell's completion menu with descriptions, che TAB completes to check](_generated/shots/nushell-cast.svg) ## Install ```console fm --install-completion nushell # `nu` works too ``` This writes the hook to `$XDG_DATA_HOME/fm/completion.nu` and appends one guarded `source` line to the config nushell itself reports (`$nu.config-path`). The hook registers an **external completer** — and it *wraps* whatever external completer you already run (carapace, a fish bridge, …) instead of replacing it: `fm` lines are answered by footman, every other command passes through untouched. Running the installer twice changes nothing. nushell is the one shell without a session-only `--setup-completion` form: the hook mutates `$env.config`, which an `eval` can't apply. Install and `exec nu` (or open a new shell) instead. ## What you get The hook returns `{value, description}` records, so nushell's completion menu shows each task and group with its one-line docstring in the description column, filtered as you type: ```text $ fm build compile and bundle deploy ship to an environment docs Documentation ``` ## Colours and appearance The menu is nushell's `completion_menu`, styled from your config (`config nu` opens it). Override the menu entry to restyle it — this is plain nushell configuration, nothing footman-specific: ```nu $env.config.menus ++= [{ name: completion_menu only_buffer_difference: false marker: "| " type: { layout: columnar # or `list` for one candidate per row columns: 4 col_width: 20 col_padding: 2 } style: { text: green # candidate text selected_text: green_reverse # the highlighted candidate description_text: yellow # the docstring column match_text: { attr: u } # matched characters (underlined) selected_match_text: { attr: ur } } }] ``` Styles take named colours (`green`, `yellow_bold`), hex strings (`"#ffb86c"`), or records with `fg`/`bg`/`attr` (`u` underline, `r` reverse, `b` bold). A `layout: list` menu gives the description column the most room; `columnar` packs more candidates per screen. ## Uninstall ```console fm --uninstall-completion nushell ``` Removes the script and the `source` line from your nushell config. Your previous external completer (if the hook wrapped one) is back in sole charge on the next shell start. --- # CI & automation A task runner spends most of its life in CI, so footman keeps the automation surface small and stable: exit codes that mean something, one JSON envelope for machines, and flags that make chains behave under supervision. ## The one-liner The command you run locally is the command CI runs — that's the point of a task runner: ```yaml # .github/workflows/ci.yml - run: uv run fm check ``` `check` composed with `pre=[format, lint, typecheck, test]` runs its prerequisites **in parallel** on the runner too, and the first failure sets the job's exit code. Output never interleaves — each task's output lands as one block, so CI logs stay readable. Two flags earn their keep in CI: - `-k / --keep-going` — run every independent branch even after a failure, so one red step doesn't hide three others. The exit code is still the first failure's. - `-s / --sequential` — one thing at a time, for constrained runners or when you're bisecting an ordering suspicion. The request reaches inside task bodies too: `parallel()` calls run one at a time under it, so `-s` means no concurrency anywhere. (A project can make this the default with `sequential = true` in `[tool.footman]`.) One rule governs the streams: **stdout is the answer, stderr is the commentary.** Task output — and footman's own answers: listings, help, `--json` envelopes — lands on stdout; the per-task `ok`/`FAIL` summary, the live progress line, warnings, and errors are stderr. So `fm build > out.log` captures exactly what the tasks produced, and a wrapper that treats stderr bytes as failure (cron's mail rule, say) should pass `-q` to silence the summary. Without a TTY there is no progress bar, but timing still works both ways: CI runs are recorded into the duration history, and when footman has a confident estimate it prints a single `eta ~5.8s` line to stderr at run start. `--no-progress` (or `progress = false` in `[tool.footman]`) turns the line and the recording off. ## `--json`: the machine surface ```console $ fm --json check { "schema": 1, "results": [ {"task": "lint", "ok": true, "code": 0, "duration_ms": 812.4, "output": "...", "steps": [...], "error": null} ] } ``` Everything a task (or anything it spawned) wrote is captured into the payload, so **stdout stays pure JSON** — pipe it straight into `jq` or hand it to an agent. Tasks can return their own structured data (`returned`), refusals emit an error envelope instead of bare stderr text, and `--list`/`--dry-run`/`--version` all have machine forms. The full contract — every envelope, field by field, with recipes — lives on one page: **[JSON output](json.md)**. The CI shape-check: ```sh fm --json check | jq -e '.error == null and (.results | all(.ok))' ``` ## Exit codes `0` all green · `1` a task raised · `N` a task (or its `run()` command) exited N · `2` footman refused (a taught message says why) · `130` interrupted. The full table is part of the machine contract: [JSON output § exit codes](json.md#exit-codes). Exit 2 before anything runs is a *feature* in CI: a typo'd workflow fails in milliseconds with a taught message, not after twenty minutes of setup. ## Agents Everything above is what coding agents want too: one command, structured results, captured output, honest exit codes. A paste-ready instructions snippet and edit/stop hook recipes live on [AI agents](agents.md). Three commands to know: - `fm --json --list` (or bare `fm --json`) prints the whole task tree as an envelope — every task and group with its parameters, types, choices, and defaults. One call, full catalog. - `fm --json --dry-run ` prints the parsed plan as an envelope — cheap validation of a proposed command line, nothing executed. - `fm --help ` renders a task's full typed surface from the manifest, read-only, wherever `--help` appears on the line. ## Conditional tasks in CI `@requires_env` re-checks live on every run, so gating a task on the environment works naturally: ```python @task @requires_env("CI") def publish_coverage(): ... ``` Locally it's listed as `(unavailable: CI only)` and refuses to run; on the runner it just works. Remember the contract: a `pre`/`post` dependency on an unavailable task is a **hard failure** — CI must never silently skip a step you declared. --- # JSON output `--json` makes one promise: **stdout is exactly one JSON document, whatever happened.** A run, a refusal, a listing, a dry-run, a `--version` — if `--json` is on the line, the answer is a single envelope you can hand straight to `jq`, a CI dashboard, or an agent. Everything a task (or anything it spawned) writes is captured into the payload, so stdout never mixes prose with data. This page is the whole contract. Every other page that mentions `--json` links here. ## The results envelope A run prints one entry per executed task, in dependency order (a task skipped because its prerequisite failed doesn't appear): ```console $ fm --json check { "schema": 1, "total_ms": 5412.7, "results": [ { "task": "lint", "ok": true, "code": 0, "duration_ms": 812.4, "output": "...", "steps": [ {"command": "ruff check src tests", "code": 0, "duration_ms": 790.1, "output": "..."} ], "error": null, "returned": {"files": 42} } ] } ``` Top-level, `total_ms` is wall-clock for the whole run — the human summary's `took` line, as a number. Per task: `task` (dotted name), `ok`, `code`, `duration_ms`, `output` (all captured text), `error` (`null`, or the exception as a string), `steps` — one entry per [`run()` or tool](tools.md) call, each with `command`, `code`, `duration_ms`, `output` — and, when the task returns a value, `returned`. ## `returned`: a task's own data Return a value from a task and it lands in the task's entry — no decorator, no context API, the `return` statement is the whole feature: ```python @task def coverage() -> dict: "Measure coverage." ... return {"percent": 94.2, "failed": [], "report": Path("htmlcov/index.html")} ``` ```console $ fm --json coverage | jq '.results[0].returned' {"percent": 94.2, "failed": [], "report": "htmlcov/index.html"} ``` The rules, all of them: - `None` (the usual case) omits the key entirely. - An `int` return keeps its long-standing meaning — the task's **exit code**, never data. Return `{"count": 42}` when you mean data. Bools are data. - The types footman coerces *in* serialise on the way *out*: `Path` → string, `Enum` → its value, `datetime`/`date`/`time` → ISO format, `UUID` → string, `Decimal` → string (precision kept), dataclasses → dicts, sets → sorted lists. Dicts, lists, strings, numbers, bools pass through as themselves. - Anything else is refused *loudly but locally*: the entry gets a `returned_error` note naming the type, stderr gets a warning, and the run's exit code stays the task's own — a payload problem never turns a green build red, and never hides in silence either. In tests, the same value is `Runner.invoke(...).results[n].returned` — see [Testing your tasks](testing.md). Without `--json`, return values are simply ignored. ## Refusals A line footman refuses — a typo'd task, a misplaced flag, a broken tasks file, a bad `--config`, Ctrl-C — emits an error envelope, with the same taught message on stderr for humans: ```console $ fm --json chekc { "schema": 1, "error": { "code": 2, "message": "expected a task name, got 'chekc' — did you mean 'check'? (know: docs, lint, test, check)" }, "results": [] } ``` So a wrapper needs exactly one parser: `error` is `null` or absent when things ran; present when footman refused. ## The catalog: `fm --json --list` The machine twin of `--list`/`--tree` (bare `fm --json` does the same): the full task tree, every task and group with its parameters — kinds, types, choices, bounds, env fallbacks, required-ness, and the one-line help: ```console $ fm --json --list { "schema": 1, "tree": { "help": "", "tasks": { "lint": { "help": "Lint with ruff.", "params": [{"name": "fix", "kind": "flag"}] } }, "groups": { "docs": {"help": "Documentation", "tasks": {"serve": "..."}, "groups": {}} } } } ``` Each parameter always has `name` and `kind` (`flag` | `option` | `argument` | `variadic`), plus whichever apply: `required`, `choices`, `types`, `multiple`, `mapping`, `nosplit`, `path`, `min`/`max`, `env`, `dynamic`, and `doc` — the author's [per-parameter help](typing.md#validation-markers), whether from a `doc("…")` marker or a parsed docstring. A task node carries `help` (the docstring's first line) and, when the docstring has a body, `long`. This is one command's answer to "what can I run here?" — the discovery call for agents and tooling. ## The plan: `fm --json --dry-run` Validates a command line and prints what would run — nothing executes: ```console $ fm --json --dry-run lint --fix test -- -x { "schema": 1, "globals": ["--json", "--dry-run"], "plan": [ {"task": "lint", "values": {"fix": true}, "variadic": [], "passthrough": null}, {"task": "test", "values": {}, "variadic": [], "passthrough": ["-x"]} ] } ``` ## `--version` ```console $ fm --json --version {"schema": 1, "name": "footman", "version": "0.19.0"} ``` ## The two exceptions - `--help` always renders human text — its machine twin is `fm --json --list`. (A `--help` *refusal*, a typo'd name, still emits the error envelope.) - `--where TASK` prints a bare `file:line` — already a machine format. ## Exit codes The process exit code tells the same story as the envelope: | code | meaning | | ---- | ------- | | 0 | everything ran and succeeded | | 1 | a task raised an exception | | N | a task returned N / its `run()` command exited N — first failure wins | | 2 | footman refused before or while binding: parse, tasks-file, config, availability | | 130 | interrupted (Ctrl-C) | Exit 2 before anything runs is a feature in CI: a typo'd workflow fails in milliseconds with a taught message, not after twenty minutes of setup. ## Recipes A shape-check in CI — guard `.error` too, because an empty `results` list on a refusal would pass `all(.ok)` vacuously: ```sh fm --json check | jq -e '.error == null and (.results | all(.ok))' ``` Pull one task's data out of a pipeline: ```sh fm --json coverage | jq -r '.results[] | select(.task == "coverage").returned.percent' ``` ## Stability The envelope is versioned: `schema` is `1`, bumped only if a field ever has to change meaning. **Post-1.0, changes are additive only** — parse what you know, ignore what you don't, and pin `schema == 1` if you're strict. `--dry-run`'s *human* output carries no such promise; the plan envelope does. --- # AI agents footman's machine surface — one catalog call, taught refusals, a single-JSON stdout, structured results with task-returned data — is what a coding agent needs to drive a project safely. This page packages it: a paste-ready instructions snippet, and hooks that keep an agent's work formatted, linted, and gated. An agent-readable index of this whole site lives at [`llms.txt`](https://willemkokke.github.io/footman/llms.txt) (and the full text at [`llms-full.txt`](https://willemkokke.github.io/footman/llms-full.txt)). ## The snippet Put this in `CLAUDE.md` for Claude Code — the identical text works as `AGENTS.md` for Codex, Cursor, Copilot, Zed, and most other agents (Gemini CLI reads it as `GEMINI.md`). Two blanks to fill for your project: the runner prefix and the gate task. ```markdown ## Tasks (footman) Tasks are typed Python functions in `tasks.py`, run with `uv run fm`. The gate is `uv run fm check` — run it before calling any change done; it must exit 0. - Discover: `fm --list` (tasks + descriptions), or `fm --json --list` for the full tree with parameter types, choices, and defaults. - Inspect: `fm --help ` — typed usage, options, and an example. `--help` anywhere on the line never executes anything. - Validate a command line without running it: `fm --json --dry-run `. - Run for machines: `fm --json ` — stdout is exactly one JSON envelope: {"schema": 1, "total_ms", "results": [{task, ok, code, duration_ms, output, steps, error, returned}]}. A task's return value lands in `returned`; refusals put a taught message in a top-level `error`. - Jump to a task's source: `fm --where ` prints file:line. Grammar: globals (`--json`, `-k`, …) go **before** the first task; a task's options come right after that task; several tasks on one line form a chain, and independent tasks run in parallel (output never interleaves). Everything after `--` passes through to the task's `*args`. Exit codes: 0 all ok · 1 a task raised · N a task exited N · 2 footman refused the line (the stderr message states the fix) · 130 interrupted. To add or change tasks, edit `tasks.py` — the signature is the CLI. Never edit the completion cache under `~/.cache/footman/`; it's derived. ``` ## Hooks: Claude Code Two recipes for `.claude/settings.json`. The mechanics in one sentence: a hook's **stderr plus exit code 2** is fed back to Claude as something to fix; anything else is display-only — so route footman's output to stderr and let the exit code do the talking. **Format and lint after every edit** — the tree stays clean as the agent works, and lint failures land straight back in its context: ```json { "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "uv run fm format lint 1>&2 || exit 2" } ] } ] } } ``` **The gate as the definition of done** — a `Stop` hook that refuses to let the session end red. `stop_hook_active` is the loop guard: when this stop *is already* the retry, skip, so a stubborn failure can't ping-pong forever: ```json { "hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": "jq -e '.stop_hook_active' >/dev/null && exit 0; uv run fm check 1>&2 || exit 2" } ] } ] } } ``` Two refinements when you need them: gate the PostToolUse command on Python files (`p=$(jq -r '.tool_input.file_path // empty'); case "$p" in *.py) …;; esac`) in repos where non-Python edits dominate, and swap `check` for a lighter chain if the full gate is slow. ## Hooks: Cursor `.cursor/hooks.json` (project hooks run from the project root): ```json { "version": 1, "hooks": { "afterFileEdit": [{ "command": ".cursor/hooks/fm-format.sh" }], "stop": [{ "command": ".cursor/hooks/fm-gate.sh" }] } } ``` `fm-format.sh` is just `uv run fm format lint` — Cursor's `afterFileEdit` is observational, so this keeps the tree formatted but can't push lint output back into the loop. The feedback channel is the `stop` hook, which may return a `followup_message` that auto-submits as the next prompt (Cursor caps the loop at 5 by default) — and this is where `--json` earns its keep: ```sh #!/bin/sh # .cursor/hooks/fm-gate.sh — block "done" on a red gate, with receipts. out=$(uv run fm --json check) && exit 0 printf '%s' "$out" | jq '{followup_message: ("fm check failed — fix these, then finish:\n" + ([.results[] | select(.ok | not) | "\(.task): exit \(.code)\n\(.output)"] | join("\n")))}' ``` !!! warning "Cursor's hooks are beta" The event names and the `followup_message` shape above match [Cursor's hooks reference](https://cursor.com/docs/hooks) at the time of writing, but the feature is marked beta and may move. If a hook stops firing after a Cursor update, check that page first — the footman side (`fm --json check` and its envelope) is the stable half. ## Everyone else The snippet is the portable layer — `AGENTS.md` reaches most agents. For agents with no hook system (Copilot's coding agent runs in Actions, for instance), the enforcement layer is the one you already have: `uv run fm check` in [CI](ci.md) plus branch protection, which catches every agent and every human identically. --- # Comparison How footman stacks up against the Python task runners I measured it against — the same seven-task surface (`lint`, `format`, `typecheck`, `test`, `check`, `dist build`, `dist clean`) written five ways. The runnable head-to-head lives in the repo's [`comparison/`](https://github.com/willemkokke/footman/tree/main/comparison) directory; reproduce the numbers with `uv run --group comparison python comparison/bench_compare.py`. Switching from one of these runners? The practical guides live on [Migrating](migrating.md). Measured on duty 1.9.0, invoke 3.0.3, poethepoet 0.48.0, typer 0.27.0, CPython 3.13, M-series Mac. !!! note "Verified, not vibes" Every number and checkmark here was checked against the tools themselves, on the same seven tasks. If any of it is wrong or has become unfair to a tool, [open an issue](https://github.com/willemkokke/footman/issues) — it will be fixed. ## First, some love for duty Before any table makes footman look clever: I've been running my projects on [duty](https://pawamoy.github.io/duty/) for nearly two years, and it's been a pleasure the whole way. footman exists *because* of duty — the `ctx.run` capture model, the lazy tool wrappers, the decorator ergonomics are all ideas I'm happily standing on. This is a "here's what I wanted to tweak," not a takedown. And duty still wins outright in one place: its `tools` standard library is far more extensive and detailed than footman's handful — dozens of tools, carefully typed. footman has some catching up to do there. ## Completion latency Cold-process wall time per ``, mean of 15 fresh processes. The **Δ import** column is the one that matters: completion time with a 0.25 s project-import cost minus completion time without it. Re-import your tasks on every keystroke and you see the whole ~0.25 s; answer from a cache and you see roughly nothing. | runner | completion (per TAB) | Δ import | re-imports every TAB? | | ------- | -------------------: | -------: | ------------------------ | | footman | **23 ms** | ~0 ms | no — cached manifest | | poe | 45 ms | ~0 ms | no — reads TOML | | duty | 346 ms | 286 ms | yes | | invoke | 360 ms | 289 ms | yes | duty and invoke reload your whole project on every TAB — their completion scripts call the tool, which imports your tasks before it can answer. footman reads a cached JSON manifest instead, so the hot path imports nothing (a dynamic completer or the first build in a fresh directory spawns a bounded subprocess), and it lands about 15× faster. It pays the same import cost as everyone else, just on the execution path: `fm --list` is ~313 ms, right there with the pack. Completion is the one moment that has to feel instant, so that's the moment I optimised. poe is quick here too, for the honest reason that its tasks are TOML strings with no Python to load — which is also the rest of this page. ## The same `check`, composed five ways Completion is the moment that has to feel instant; `check` is the command you actually run fifty times a day. So: four check steps, each an identical in-process 0.5 s sleep (the honest stand-in for an I/O-bound tool run — a real lint step spawns a subprocess and waits, which parallelises exactly like a sleep), composed the way each tool wants you to. Fairness cuts both ways — a tool that supports parallelism gets to use it. Reproduce with `uv run --group comparison python comparison/bench_check.py`. | runner | composition | wall (mean) | | ------- | ------------------------------ | ----------: | | footman | parallel (pre-deps, *default*) | **563 ms** | | poe | parallel (`parallel` task) | 625 ms | | typer | serial (no orchestration) | 2092 ms | | duty | serial (pre-duties) | 2120 ms | | invoke | serial (pre-tasks) | 2146 ms | The floors are 0.5 s parallel and 2.0 s serial, so everyone's *overhead* is a rounding error — the 4× gap is architecture, not dispatch speed. duty and invoke run prerequisites serially and have no parallel switch to flip; the same four steps simply cost the sum instead of the max. poe genuinely ticks this box (a dedicated `parallel` task type — credit where due); the difference is spelling. In poe you declare a parallel composite per case; in footman `pre=[fmt, lint, typecheck, test]` is parallel *by default* and goes serial only when you ask (`-s`). And typer hands you nothing here — four calls in a row, unless you hand-roll a thread pool, at which point you've written the scheduler yourself. ## Is "just write a typer app" too heavy? Genuine question, because typer is lovely and a completely reasonable choice — if you're building a user-facing CLI rather than a task runner, honestly, reach for typer. It's also footman's closest relative here: typed signatures, real flags, `Enum`/`Literal` validation, nested apps. The only thing I measured was startup, because typer has a reputation for being heavy: | import | cost over a bare interpreter | | ---------------- | ---------------------------: | | `import footman` | **+4 ms** | | `import typer` | **+24 ms** | typer's import really is ~6× heavier — it ships its own parser plus `rich` and `shellingham`. (Reproduce with `uv run --group comparison python scripts/bench_import.py`.) On a single launch you'd never notice (footman ~38 ms, typer ~40 ms; footman just spends its milliseconds on parsing instead of importing). The difference only shows up when a typer app does completion, because that re-runs the app — paying the typer import *and* your project import on every TAB, where footman is answering from cache. Not a knock on typer; just a different job. ## Feature matrix The list is footman's own feature set, so the left column is green by construction — the honest content is in the other columns, and in the one ❌ footman concedes: duty's tools library. | capability | footman | typer | duty | invoke | poe | | ------------------------------------------- | :-----: | :-----: | ------------- | ------------- | -------- | | Typed Python-function tasks | ✅ | ✅ | ✅ | ✅ | ❌ | | No `ctx`/`c` boilerplate param | ✅ | ✅ | ❌ | ❌ | — | | Real `--flags` | ✅ | ✅ | ✅ | ✅ | ✅ | | `Literal`/`Enum` → validated choices | ✅ | ✅ | ❌ | ❌ | ❌ | | Union / one-or-many / `dict[K,V]` params | ✅ | partial | ❌ | ❌ | ❌ | | Native nested groups | ✅ | ✅ | ❌ | manual | ❌ | | Zero-boilerplate discovery (module = group) | ✅ | ❌ | ❌ | ❌ | ❌ | | Separator-free chaining | ✅ | ❌ | reserved-word | reserved-word | seq task | | Parallel-by-default DAG (`pre`/`post`) | ✅ | ❌ | serial | serial | ✅ | | `run()` capture / replay-on-failure | ✅ | ❌ | ✅ | partial | ❌ | | Extensive typed `tools` standard library | ❌ | ❌ | ✅ | ❌ | ❌ | | Monorepo `tasks.py` cascade | ✅ | ❌ | ❌ | ❌ | ❌ | | Custom-branded CLI as a library | ✅ | ✅ | ❌ | ❌ | ❌ | | Completion without re-importing | ✅ | ❌ | ❌ | ❌ | ✅\* | | Zero runtime dependencies | ✅ | ❌ | ❌ | ❌ | ❌ | \* poe skips the re-import only because its tasks aren't Python functions. --- # Migrating Coming from another runner? Each section below is the shortest honest path in — what carries over, what you gain, and what you give up. The measured head-to-head behind these claims is on the [Comparison](comparison.md) page. ## From duty The gentlest move — it's the family footman grew up in. Drop the `ctx` parameter and shell out through `run()`: ```python # duty @duty def lint(ctx, fix: bool = False): ctx.run("ruff check ." + (" --fix" if fix else "")) # footman @task def lint(fix: bool = False): run("ruff check ." + (" --fix" if fix else "")) ``` Chaining (`duty format lint test` → `fm format lint test`) and `--flags` carry over. You gain eager choice/type validation (duty happily accepts an invalid `Literal`; footman stops it), native nested groups, and instant completion. The one thing you'll miss for now is duty's big `tools` library — footman's is small and growing. Flag syntax note: duty also takes `lint fix=true`; footman uses `--fix`. ## From invoke Drop the `c` parameter and delete the manual `Collection` wiring — in footman a module *is* a group and `group()` opens a nested one: ```python # invoke: hand-assembled namespaces ns = Collection(); ns.add_task(lint); ns.add_collection(dist) # footman: nothing to assemble dist = group("dist") @dist.task def build(): ... ``` `inv dist.build` becomes `fm dist build` (a space, not a dot); `c.run(...)` → `run(...)`. ## From typer Your typed signatures port almost verbatim — footman reads the same annotations. Delete the app object and the per-parameter `typer.Option`/`Argument` wrappers; use plain defaults plus footman's `Annotated` markers (`suggest`, `Many`, `nosplit`) where you need them. `typer.Typer()` + `add_typer(sub)` → a module or a `group()`. You'll trade typer's polished `--help` for cached completion, zero dependencies, and separator-free chaining — a fair swap for a task runner, though if you're shipping a CLI to users, typer's help is worth staying for. ## From poe Move each TOML task into a Python function — you swap declarative strings for real Python, types, and validation: ```toml # poe [tool.poe.tasks.lint] cmd = "ruff check ." args = [{ name = "fix", options = ["--fix"], type = "boolean" }] ``` ```python # footman @task def lint(fix: bool = False): run("ruff check ." + (" --fix" if fix else "")) ``` You keep parallelism (poe's `[[parallel]]` → footman is parallel by default) and pay the project import only on execution, never on completion. ## From make / just Recipes become `@task` functions and shell lines become `run(...)`; you keep chaining and gain parallel-by-default execution and typed arguments. What you give up is the file-target / up-to-date model — footman runs commands, it isn't a build system (see `doit` for that niche). ## Other runners Not covered above, and why: **taskipy** (pyproject shell aliases, no Python-function tasks), **doit** (a proper build system with file-target and up-to-date tracking — a different game), **nox** / **tox** (environment and test-matrix orchestration), and the non-Python **just** / **go-task** / **mise** / **make** (great UX and completion, no Python dynamism). `uv`'s own task support is [in design](https://github.com/astral-sh/uv/issues/5903) and will cover the simple-named-command case; footman's patch of ground is typed Python-function tasks with real CLI semantics. --- # Changelog All notable changes to footman are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). While footman is pre-1.0, minor versions may include breaking changes. ## [Unreleased] ### Added - **`footman.fail(reason, code=1)` — a blessed way to fail a task.** A function (not a `raise`) that stops the current task with a reason: the reason renders verbatim on the failure line and in the `--json` `error` field, and `fail("…", code=3)` sets the exit code too. It is a function on purpose — a task lives in your repo under your linter, and a call trips no flake8-errmsg (`EM101`) or tryceratops (`TRY003`), where `raise SomeError("literal")` would; the same reason `sys.exit()` and `pytest.fail()` are functions. The exception it raises, **`footman.Failed`**, is exported for `except footman.Failed:`. The stdlib idioms (`return N`, `sys.exit(...)`, raising) still work unchanged. - **Colour survives footman's no-PTY boundary.** footman spawns tools over pipes, not a pseudo-terminal, so a tool sees a non-terminal and turns colour off — even when footman itself is on a terminal. footman now forces it back: every subprocess gets `FORCE_COLOR`/`CLICOLOR_FORCE` when the run is colourful and `NO_COLOR` when it is not, and the captured bytes replay onto footman's own terminal (colour is position-independent, so it survives the round-trip; live cursor control is the thing a PTY-less run genuinely can't carry, and it isn't attempted). One run-wide decision drives all of it, resolved from a new **`--color=always|never|auto`** global (`--no-color` is the `never` alias), **`[tool.footman] color`**, then `NO_COLOR`/`FORCE_COLOR` in the environment. `always` colours even into a pipe (for `less -R`); a captured `--json` run stays byte-clean. The few tools that ignore the environment and take a flag instead — git's `-c color.ui=always` — are forced through their own switch, injected into the executed command only, so `recording()` and `--dry-run` still show the tool's own call while `--verbose` shows what ran. Which tools obey the environment and which need a switch is *probed*, not guessed: **`fm footman tools color`** runs each tool with colour forced on and off and reads the bytes, categorising every direction `env`/`flag`/`none`, and regenerates the `_colordata.py` the forcing table loads. Forcing colour *off* is the absence of `FORCE_COLOR`, not `FORCE_COLOR=0` — some tools (ruff) read the mere presence of `FORCE_COLOR` as "on", so `--no-color` and piped runs clear it rather than setting `"0"`. - **`run(shell=…)` runs a command string through an explicit shell.** `run()` stays shell-free by default — a string is split, no shell, so `|`/`>`/`$VAR` are literal — but `run("a | b", shell=True)` runs the whole string through a resolved interpreter, so pipes, redirects, globs, and variables work. `shell=True` follows the project's shell policy (`[shell] default`: `posix` by default — bash, then plain sh, git bash on Windows and Homebrew bash on macOS; `native`; `pwsh`; or a concrete shell); a string names a concrete shell (`bash`/`zsh`/`sh`/`fish`/`nu`/`pwsh`/`cmd`) or a strategy. A missing or wrong-platform shell is a taught error, and a shell-free `run("a | b")` now teaches instead of passing the operator as a literal argument. Two flags harden a run: **`strict=True`** fails on the first error and a masked pipe stage (`set -eo pipefail` for bash/zsh; `set -e` with a note on plain sh; `$ErrorActionPreference='Stop'` for pwsh; a taught error where there is no errexit), and **`clean=True`** runs the interpreter without the user's startup files, so a task's shell behaves the same on every machine. On Windows, the shown/`--verbose` command line now quotes the way cmd and PowerShell can read (stdlib `subprocess.list2cmdline`). The curated shell tools gain a sixth, **`tools.cmd`** (`cmd /c …`), so all of `bash`/`zsh`/`fish`/`pwsh`/`nu`/`cmd` read consistently. - **`run()` returns a `Result`.** `run()` — and every `tools.*` call — now returns a `Result` instead of a bare exit code. A `Result` *is* the exit code (it subclasses `int`, so `code = run(...)`, `if run(...)`, and `== 0` are all unchanged), and it also carries the captured output split by stream, so `run("git rev-parse HEAD").stdout.strip()` reads the hash without stderr glued on. `.stdout` and `.stderr` are separated for both subprocess and in-process tools, and `.ok`, `.command`, `.raw`, and `.duration` round it out. The typed tool stubs return `Result` too, so `.stdout` is checked at the call site. - **Single-dash long flags for Go-style tools.** `Tool(single_dash=True)` spells every long flag with one dash — `tools.eclint(fix=True)` → `eclint -fix` — for tools whose Go `flag` package rejects the `--fix` form. - **`djlint`** joins the curated tools — the HTML/Django/Jinja template linter-formatter, with a typed `tools.djlint(...)` stub. - **`@group.default` takes `@task`'s policy options.** A group default can now be parameterised — `@lint.default(pre=[bootstrap], keep_going=True, confirm="…", atomic=True)` and the rest — the same orchestration surface a task has, minus `name` (the group already names it). `interactive=True` on an **empty-body** default is a load-time error: an empty body fans the group's tasks out in parallel, so there is no single body to own the terminal. ### Changed - **A tool's `.opts()` is now footman run-control; a tool's own globals move to `.flags()`.** A `tools.*` call is pure flags and positionals again: run-control no longer rides reserved call kwargs. `.opts(nofail=…, in_process=…, capture=…, title=…)` is a closed vocabulary that rides *beside* the call and never becomes a tool flag — `git.opts(nofail=True).push()` — mirroring a task's policy-vs-work `.opts()`. A tool's own global options (bound before a verb) move from `.opts(host=…)` to **`.flags(host=…)`** — `docker.flags(host="x").ps()`. Two consequences: `capture` and `title` reach the bridge for the first time (`pytest.opts(capture=False)` streams a run live), and a tool that really has a `--capture` (pytest) can now be spelled in the call, `pytest(capture="no")`, since footman's `capture` lives on `.opts()`. Migration: `tools.x(…, nofail=True)` → `tools.x.opts(nofail=True)(…)`; `tools.x.opts(host=…)` → `tools.x.flags(host=…)`. - **The test-harness result is renamed `InvokeResult`.** `Runner.invoke()` now returns `footman.testing.InvokeResult` (was `Result`), freeing the prominent `footman.Result` to name the run-step `Result` above. Test code that calls `Runner().invoke(...)` and reads `.ok`/`.stdout`/`.exit_code` is unaffected — only the type's own name changed. - **A trailing underscore is stripped from a CLI flag.** A task or parameter named with Python's keyword-escape underscore (`sync_`, `import_`) now maps to `--sync` / `--import`, not `--sync-` — matching the `tools.*` bridge, which already stripped it. - **`fm --json` step entries carry `stdout` and `stderr`** separately, in place of the previous merged `output` field. - **`help` is now a reserved task parameter name.** A flag or option parameter named `help` maps to `--help`, which footman intercepts anywhere on the line to render help and never run a task — so the option could never bind. Instead of silently shadowing the parameter, footman now rejects it at manifest-build time with a taught error that names the fix (rename to e.g. `show_help`). The check is precise: a required positional `` or a variadic `*help` never produces `--help`, so both stay legal. This is the only reserved name — every other global must precede the first task, so a task parameter may reuse it. ### Fixed - **`include()` carries a group's default and finalizers.** Grafting a module with `include()` kept its tasks but silently dropped the group's `@group.default` — breaking the bare-group command and hiding its options — and its `@finalize` hooks; both now come across, so a `tasks.py` composed from per-module `include()`s behaves the same as one that defines them directly. - **`parallel()` collects a `SystemExit`.** A thunk that calls `sys.exit()` or `raise SystemExit(...)` — a common "fail this task" idiom — is now collected like any other failure instead of escaping and crashing the whole pool. - **A task's `sys.exit("reason")` shows its reason.** A task body that raises `SystemExit` with a string (Python's "print this message, exit 1" idiom) used to fail with a bare `exited with code 1`, the message swallowed; the debugging tax was re-running the task under Python to read the traceback. The reason now surfaces in the failure line and the `--json` `error` field, rendered verbatim (no `SystemExit:` prefix). An int/`None` code (`sys.exit(2)` — the fail-with-a-code idiom) is unchanged. (A reason raised inside a `parallel()` thunk is still normalised to a code-only failure — the fan-out deliberately converts a `SystemExit` to a catchable `RunFailed`.) - **`run("a | b")` teaches instead of mis-running.** A string command with a bare shell operator (`|`, `>`, `&&`, …) now raises a taught error: `run()` uses no shell, so the operator would otherwise ride along as a literal argument and the pipeline would silently not happen. Reach for a shell explicitly (`tools.bash("-c", …)`) or split into steps. - **`fm footman tools provision`** skips the hand-written shell drivers instead of printing a spurious `uv tool install` failure for each. - **`fm --help "group task"` accepts a quoted or dotted path.** A path handed as one shell token (`"docs serve"`) or dotted (`docs.serve`) is split into its components and resolved, instead of failing with a self-referential "did you mean 'docs serve'?". A genuinely unknown quoted path now names its first bad component and suggests a real neighbour. - **A missing `include()` module names the call, not the file.** `include("x.y")` for a module that can't be imported raised "failed to import ``", blaming the tasks file; it now raises a taught error naming the `include()` call and the reason (`include('x.y'): failed to import (ModuleNotFoundError: …)`), the same shape `plugin()` already gives. ### Docs - Added an abbreviations glossary with site-wide hover tooltips for footman's coined vocabulary (manifest, cascade, chain, taught error, …), split the execution-model pages (new **Asking for input** and **Progress & timing** guide pages), moved `@finalize`/`TaskView` onto the composing page, added mermaid diagrams for the dependency graph and completion refresh, and added docs-drift test guards so undocumented public symbols and stale version pins fail the gate. - Documented the plain-prose output convention (task docstrings/`doc()` render as plain text in `--help` and export cleanly to markdown; footman paints no rich markup in the terminal — colour is the one styling it applies), and recorded an optional post-1.0 rich-terminal renderer in the roadmap. ## [0.19.0] — 2026-07-23 ### Added - **Per-subtree keep-going scoping.** The failure policy is now resolved *per node*, not run-wide: a keep-going gate keeps its own prerequisites going with it, while an independent task in the same run keeps its own policy. So `fm check deploy` — `check` keep-going, `deploy` fail-fast — surfaces every `check` failure *and* still bails `deploy` on the first, where before one task's `keep_going=True` forced the whole run to keep going. A command-line `-k`/`--fail-fast` still overrides every scope at once, and a task's own (or `.opts()`-set) policy wins over one inherited from a gate above it. True fail-fast reaps only the *fail-fast* subprocess trees still in flight on a failure, so a keep-going task's long-running child keeps going while a doomed fail-fast branch dies at once. - **Per-use option overrides — `.opts()`.** A task or runnable group carries an `.opts(...)` that overrides its orchestration options *for one use* without touching the registered task: `pre=[fmt.opts(atomic=True)]`, `pre=[lint.opts(keep_going=True)]`, or a body call `deploy.opts(atomic=True)("prod")`. It takes the policy options — `keep_going`, `atomic`, `interactive`, `progress`, `confirm`, `infinite` — and rejects a task parameter with a taught error, because work goes in the call and policy rides beside it, the same split `tools.*` draw with their `.opts()`. Keep-going resolution now spans the whole dependency graph, so a declared or opted `keep_going` on a `pre`/`post` prerequisite counts, not only a task named in the chain. As a side benefit, `@task` now **forwards the wrapped function's signature** in the type system (parameters and return type), where a decorated task used to be typed `Callable[..., Any]` — so a body call with a wrong or missing argument is now a type error, not silently `Any`. - **`TaskView` round-out for finalizers.** A `@finalize` hook's `TaskView` now reads a task's owning `group` (or `None` at top level), its policy flags (`keep_going`, `atomic`, `infinite`, `interactive`, `timed`, `confirm`) and its **cascade provenance** — `defining_dir` (the folder it was defined in), `shadowed` (the task it overrides one cascade level up), `shadow_chain` (it and everything it shadows), and `source_file` — so a finalizer can make decisions by *where* a task came from and *what* it overrode (e.g. gate every task defined under an `infra/` folder). New `set_opts(**overrides)` sets a task's policy for every use of it — the permanent, tree-wide counterpart to `.opts()`, taking the same options and rejecting a task parameter the same way. A command-line `-k`/`--fail-fast` still wins over a set `keep_going`. ### Changed - **Homebrew resolution for host-read tools on macOS (stub generation only).** The tools footman reads straight off the host — git, docker, uv, never provisioned into an isolated prefix — resolve their Homebrew **keg** (`opt//bin/`, which survives `brew unlink`) before falling back to `PATH`, so on macOS the stub describes the newest build (Homebrew git over Apple's older `/usr/bin/git`). Every **provisioned** tool (ruff, mkdocs, pytest, gh, …) still resolves on plain `PATH`, so a `provision --sync` prefix and a venv win and no stale `/opt/homebrew/bin` console-script shim can shadow them. Only ` --help`/`--version` parsing is affected; running a `tools.*` task resolves on `PATH` as before. ### Fixed - **`fm footman tools provision --sync` no longer strips plugin flags.** A provisioned prefix is a bare install, so reading pytest's stub from it dropped every `--cov*` flag (they come from pytest-cov). A driver can now name extra wheels to install alongside a tool — `Provision(plugins=("pytest-cov",))` — which `provision` adds with `uv --with`, so the prefix holds a plugin-complete pytest and the sync reads its full flag surface. - **A `v`-prefixed `0.x` version is read whole.** The version a stub records is scraped from ` --version`, and a tool that printed `v0.23.1` was recorded as `0.23.1`'s tail, `23.1`, because the match required a word boundary the `v` removed. It now reads `0.23.1` (markdownlint-cli2, and any tool that glues `v` to a `0.` version). ## [0.18.0] — 2026-07-22 ### Added - **Tri-state failure policy and true fail-fast.** Keep-going is now three-state: an explicit command-line choice wins, otherwise a task can declare its own (`@task(keep_going=True/False)`), otherwise the built-in fail-fast — so "unspecified" means *the code decides*, not a silent default. The new `--fail-fast` global forces fail-fast when a task declares keep-going, the mirror of `--keep-going`. And fail-fast now actually *is* fast: on the first failure it stops launching new work **and terminates the subprocess trees still running** — each child *and its own children*, so a tool's workers (pytest-xdist, `make -j`, a script's background jobs) die with it instead of orphaning — escalating SIGTERM to SIGKILL for anything that ignores the first signal. A task cut off this way reports as *cancelled*, kept distinct from a genuine failure; the run's exit code follows the real failure. `Ctrl-C` reaps in-flight trees the same way. `@task(atomic=True)` opts a task's subprocesses out of the kill — they run to completion, so a mid-write can't be truncated — and an `interactive` task's child stays attached to the terminal it owns. In-process runs are never killed (there's no child to signal). - **Parameter forwarding.** A parameter marked `Annotated[T, forward]` (or the shorthand `Forward[T]`, like `Many[T]`) passes its value to every task this one dispatches — its `pre`/`post` prerequisites and a runnable group's surfaces — that declares a parameter of the same name; the rest run on their own defaults. So `@task(pre=[format, lint]) def check(fix: Forward[bool])` reaches `--fix` into the tasks that support it and lets the ones that don't just run, and the value chains through a callee that re-declares the marker. Precedence is CLI > forwarded > default, and a forwarded value overrides a default without rescuing a required parameter (a prerequisite stays runnable on its own). Two dispatchers sending different values to a shared prerequisite is a taught error, not a silent last-wins. `NoSplit[T]`, `Exists`, `IsFile`, and `IsDir` join `Many`/`Forward` as terse aliases for the bare markers. - **Runnable groups.** A group gains a default action with `@group.default` — a typed function whose signature is the group's own options — so `fm lint` runs it while `fm lint markdown` still runs one surface. An empty-body default fans out the group's own tasks (`fm lint --fix` fixes what's fixable and lints the rest); a custom body is the escape hatch. A positional parameter on a default is a load-time error, because a bare word after a group names a child. The group tab-completes and self-documents like a first-class command, and is **callable from a task body** — `check` can call `lint(fix=fix)` the way it calls any task, running the default's action (or fanning out) in order. - **Discovery hooks (`@finalize`).** A function decorated `@footman.finalize` runs once on the fully-merged task tree, after the whole `tasks.py` cascade is assembled but before dispatch — footman's `pytest_collection_modifyitems`. Use it to edit the tree in bulk: add a `pre` to every task whose name matches a pattern, switch a set of tasks off by policy, and so on. Because it runs at discovery the edits are part of the plan — an added `pre` runs and shows in `--dry-run`, a disabled task drops from listings — not a runtime surprise. The hook is handed a `Tasks` view of the tree; iterate it or index it by command-line name for a `TaskView` that reads (`pre`, `post`, `disabled`) and edits (`add_pre`, `add_post`, `disable`) each task through a defined interface, never footman's private attributes. Hooks run in cascade order — root's first, the folder nearest your cwd last, each seeing the previous edits. - **Availability by `@requires` decorators.** Task availability moved from `@task(when=/requires=/reason=)` to stacked decorators: `@requires_dep` (a Python module importable), `@requires_tool` (a command on `PATH`), `@requires_env` (an environment variable set), and the generic `@requires` (a live predicate) they build on. Each carries its own `reason=`, so a task gated on both a missing package and a missing variable can say *both* — and `availability()` now collects **every** failing gate instead of stopping at the first, each in its own words. Gates are still re-checked live on every run, and a failing one lists the task with its reason rather than hiding it. **Breaking (pre-1.0):** `@task`'s `when=`, `requires=`, and `reason=` are removed; stack the decorators above `@task` instead. ## [0.17.0] — 2026-07-22 ### Added - **`check()` validators can read the other inputs.** A `check` callable that declares a second parameter receives the parameters to its left at their effective values (a provided value, else the default), read-only — so a version can be validated against the current release of the package named in an earlier argument, or an end-date against a start-date, without hardcoding a bound that drifts out of sync with the signature. - **Interactive input, typed and CI-safe.** A parameter marked `Annotated[T, ask()]` prompts for its value when the CLI and env don't supply it, coercing the answer through the same pipeline as a flag — a `Literal` is a typed choice, a bad value re-asks — with precedence CLI > env > default > prompt. Off a terminal, under `--no-input`, or in `--json` it errors naming the flag rather than hanging. `prompt()`, `confirm()`, and `select()` are public primitives for asking mid-run, but **guarded**: called inside an ordinary task they raise a taught error, because the prompt would be swallowed by the capture buffer or race a parallel sibling — a task that genuinely owns the terminal declares `@task(interactive=True)` (it runs sequentially, uncaptured, with sole stdio). New globals: `--yes` (auto-answer confirms) and `--no-input` (never prompt). - **Dynamic completions are recomputed fresh at Tab, not served stale.** A `suggest(fn)` completer queries live state (git branches, release candidates, deploy targets), so footman now runs it fresh in a bounded, isolated subprocess when you complete its value — rather than serving the snapshot baked into the manifest, which is exactly wrong for a build-critical answer. A slow or failing completer degrades to no candidates, never the old values; task names, options, and `Literal` choices still answer instantly from the cache. - **The first Tab in a fresh directory builds the manifest instead of answering empty.** A cold completion cache used to stay blank until your first real `fm` run; now the first Tab builds it once (bounded, and out of the import-free hot path) and answers accurately. A slow `tasks.py` degrades to empty with the build finishing in the background, so the next Tab is warm — never a hung keystroke. - **Tab completes file paths for path-valued arguments.** The path-valued globals (`-f`/`--tasks-file`, `-C`/`--directory`, `--config`) and any task option annotated `Path` now hand off to your shell's own file completion — `_files` in zsh, readline's filename completion in bash, and the fish/pwsh/nushell equivalents. A plain `str`/`int` value still completes nothing, so files are offered only where a path is actually wanted. - **`fm -f ` completes that file's tasks.** A one-file run reads its own tasks, so its completion now does too — cached under a key pairing the file with the cwd, separate from (and never overwriting) the plain-cwd cache. `-f` and `--config` are documented as orthogonal: each disables only its own cascade. - **`fm footman tools provision`** — fetch the latest of every curated tool into one throwaway prefix, without polluting the machine. Almost every tool ships an installable PyPI wheel (the Rust and C++ ones included), so `uv tool install` into an isolated `UV_TOOL_DIR`/`UV_TOOL_BIN_DIR` covers most of them; bun comes from its own GitHub release (first, since the node tier runs through it), the node CLIs via `bun add`, and the Go CLIs (gh, eclint) from a release asset matched off the release's own asset list. `--sync` then rewrites the stubs against the prefix; `--clean` deletes it, and deleting the prefix is the whole undo. - **Ten more curated tools, with generated stubs:** `gh`, `eclint`, `mypy`, `ty`, `twine`, `git-changelog`, `git-cliff`, `build`, `cmake`, `ninja`. - **A sixth help dialect — Go's stdlib `flag`** — single-dash long options (`-color`) under `Usage of :` with descriptions on the next line, so a Go tool like `eclint` reads as fully as a clap or cobra one. ### Changed - **python, pytest, and the shells are first-class tools.** `tools.python` and `tools.pytest` are `Tool` instances with generated stubs, not bespoke functions — `Tool` gained `path=` (so `tools.python` targets `sys.executable`) and `entry=` (so `tools.pytest` runs the arg-accepting `pytest.main` and stays parallel). The shells footman completes for — `tools.bash`/`zsh`/`fish`/`pwsh`/`nu` — run a command string through a real shell (`bash -c "…"`). `sh` is removed: it never used a shell, so `run("…")` is the honest spelling. A per-tool short-option policy (`none`/`only`/`all`, default `only`) controls whether a stub keys on a short flag, so python's `-m`/`-c` are complete without cluttering other tools. - **footman's first-party tasks are now two plugins, `footman.docs` and `footman.tools`**, each opt-in on its own — a project can mount the end-user-facing doc generator without the maintainer-facing stub toolkit. A plugin's name is its command path, so a dotted name nests one group per segment (`["footman.docs"]` → `fm footman docs …`), and plugins that share a prefix meet under one namespace group without either owning it. ### Fixed - **The tools reference sidebar is generated from the drivers, not hand-maintained** — `fm footman tools pages` regenerates the docs nav (alphabetically, between markers) and a test fails when a tool is added without it, so the stale "13 tools" sidebar can't recur. - **The `--help` parser no longer swallows a flag's trailing punctuation** — clap's repeatable `--verbose...` and a manual's `--merge.` ending a sentence had become the keywords `verbose___` / `merge_`; a dot is now read only inside a name. - **Bare lowercase value placeholders are read** (gh's `--assignee login`, docker's `--memory bytes`) from `--help`, while a man page's prose reference (`the --patch option.`) is left as the switch it is. - **Bulleted option lists are read** — markdownlint-cli2 prints its options as `- --fix …`, and the leading bullet no longer hides the flag. - **A backslash in a tool's help** (mypy's `--exclude '\.py$'`) is escaped in the generated docstring instead of becoming an invalid escape sequence. ## [0.16.0] — 2026-07-21 ### Added - **The command line footman shows is now separate from the one it runs.** `run()` renders a normalised, syntax-highlighted invocation — options in their readable separated form, values shell-quoted, coloured by role the way `--help` colours a usage line — while execution takes whatever spelling the tool needs. `StepResult` carries both: `.command` (what `recording()` asserts and the terminal shows) and `.raw` (the exact executed bytes, what `--verbose` prints). One translation feeds both, so they can never disagree about what a call means. - **The `tools.*` stubs are generated from the installed tools.** The bridge never went stale, because it transcribes nothing — but its stub could, because a stub describes a tool at a version. Now it is read from the tool: one file per tool under `footman/_stubs/`, carrying each flag's own help text as a docstring, the values it accepts as a `Literal`, and the one fact a bridge can never infer — how that tool spells "off" (`clean=off` → `mkdocs build --dirty`). Five help dialects are understood: click and argparse structurally, plus clap, cobra, commander and git's own `--[no-]flag` notation read from `--help`. - **`fm footman tools …`** — `list` (what is curated and installed), `spec` (what a tool says about itself right now), `sync` (rewrite the stubs) and `audit` (fail when a stub and its tool disagree). Tools that are not installed are skipped *and named*, so a check can't quietly cover three of thirteen. - **git's stubs are read from its manual, not its terse `-h`.** `git commit -h` lists 19 flags; the manual lists 37, and git is exactly the tool where autocomplete earns its keep. footman now reads `git help ` for each git verb — twice the options, each with its own help, and a clean per-form `SYNOPSIS` that gives `git clone` its required `repository` while multi-form verbs (`git branch` lists *and* creates) stay permissive. The manual is read only when regenerating stubs, so it never becomes a runtime dependency; the extraction folds the manual's typographic punctuation to ASCII and keeps one sentence per flag. - **git's globals reach `.opts()`, and every multi-command tool's `.opts()` keeps the chain typed.** `tools.git.opts(git_dir="…", work_tree="…").commit(…)` now completes git's global options — read from the `git help git` manual — and places them before the verb, where git requires them (`git -C x commit` runs in x; `git commit -C x` reuses a commit). Every tool with subcommands declares a self-returning `opts()`, so the chain after it stays typed even for a tool footman found no globals for. - **`tools..opts(...)` binds a tool's global options before the subcommand** — `tools.docker.opts(host="tcp://x").compose.up(detach=True)` runs `docker --host=tcp://x compose up --detach`. Some options belong to the tool, not the verb, and must precede it (cobra tools like docker reject a global after the subcommand); `opts` places them correctly and keeps chaining, typed per tool and returning the tool so the rest of the chain stays checked. A generic untyped `opts()` is available on any tool. - **The stubs know each verb's positional shape.** Read from the tool's own usage line (or click's declared arguments): `mkdocs build` takes only options, so a stray positional is now a type error; `docker run` requires an image positionally, so `docker.run(image="x")` is caught. The parser is deliberately conservative — anything ambiguous stays permissive, so it never forbids a call the tool would accept, and git's idiosyncratic multi-form `-h` grammar is trusted for nothing. - **A reference page per tool**, in a new **Tools** section of the docs. mkdocstrings renders each one straight from that tool's stub, so every flag arrives with the tool's own help text, its accepted values as a `Literal`, and the `off` spelling where one applies. The index table states the version each stub was read from and whether the tool can run in footman's process — built from the checked-in stubs, so the docs build needs nothing on PATH. - **A type-level test for the stubs** (`tests/typecheck_tools.py`): a file of tool calls that is never executed and never collected, only type-checked. Its negative cases are the real assertions — since `**flags: Any` swallows an unknown keyword, a call that is *required to fail* is what proves a flag is declared and typed. ### Changed - **Valued long options are executed attached** (`select="E"` → `--select=E`). This is invisible in what footman shows you — the shown line stays separated and readable — but it fixes two silent failures: an optional-value option whose value was read as a positional (`--abbrev 4` → `--abbrev=4`), and a dash-leading value read as another option (`--format -%h` → `--format=-%h`). The rule covers every tool, including undeclared ones. `recording()` assertions on `.command` are unaffected; assert on `.raw` for the exact spelling. ### Fixed - **A wrapper verb's flags no longer leak into the wrapped command.** `tools.uv.run("pytest", "-q", frozen=True)` emitted `uv run pytest -q --frozen` — and uv never saw `--frozen`, because everything after `run`'s arguments belongs to pytest. The bridge now knows which verbs wrap a command (`uv run`, `uv tool run`, `coverage run`, `docker run`/`exec`, `docker compose run`/`exec`) and places their flags first: `uv run --frozen pytest -q`. The wrapper set is read from each verb's usage line and checked by `fm footman tools audit`. - **Optional-value options are no longer mistyped as switches.** A tool that glues its placeholder to the flag — git's `--gpg-sign[=]`, `--untracked-files[=]`, ruff's `--add-noqa[=]` — was read as taking no value, so the stub rejected `gpg_sign="KEY"`, which is valid. These now type as `_ValuedFlag`: usable bare (`gpg_sign=True`, sign with the default key) *or* with a value, both spelling a valid command. - **`off` now speaks each tool's own dialect.** It assumed the negation of a default-on flag is `--no-`, which is wrong often enough to break real commands: `mkdocs build --no-clean` is rejected outright — the flag is `--dirty` — and five of mkdocs' eight negatable options disagree with the convention. The spelling is per-flag data only the tool knows, so footman asks: the new `footman._toolspec` reads click's `secondary_opts` (with defaults, types, and help text for the stubs and reference pages to come), and the exceptions ride in a table `off` consults. `clean=off` emits `--dirty`; `strict=off` still emits `--no-strict`; other tools are untouched. A test diffs the table against the installed tools, so a tool that changes its spelling fails a check instead of quietly producing a command it refuses. ## [0.15.0] — 2026-07-20 ### Added - **Counted progress: `progress(done, total)` and `track(iterable)`.** Work that knows how far along it is — 23 of 150 migrations, bytes of a download — is better evidence than any duration history, so a reported count now drives the live bar directly and outranks the estimator. That makes the bar honest on a task's *first* run, where the estimator is still gathering samples. A reporting task contributes a fractional unit to the run (three done and a fourth halfway is 3.5/4), so a chain of reporters fills smoothly and a mixed chain is smooth where it can be. `track()` is the ergonomic form — total from `len()`, `total=` for generators, report cleared if you break out early. Both are no-ops outside a run. - **`fetch(url)` — download into footman's cache.** Cached by URL under `footman_cache_dir()` (so `FOOTMAN_CACHE_DIR` relocates it and the daily collector tends it), revalidated with ETag / `If-Modified-Since` rather than re-downloaded, optionally verified with `sha256=`, and copied anywhere with `into=`. A fetch is a **step**: `--dry-run` prints it without touching the network, `recording()` asserts on it, `--json` carries it, and it lands in the step lines beside `run()`. Byte counts feed the new progress bar. A cached copy survives a failed refresh, so a warm cache still builds offline. **Backends**: stdlib `urllib` by default — zero dependencies, deterministic, and the only one that can report bytes as they arrive — with `curl` (in Windows' System32 since build 17063, and on every POSIX box), `httpx`, and `requests` available when named, plus an explicit `auto`. Choose per call or set `[fetch] backend` anywhere on the config ladder: a machine behind a corporate proxy names curl once in `~/.config/footman/config.toml` and every project follows. Deliberately never automatic — a download that silently changed engine when an unrelated dependency appeared would change its TLS trust store and proxy semantics with it; a urllib failure instead raises a taught error naming that exact config line. - **`inherited()` — extend an overridden task instead of replacing it.** A nearer `tasks.py` overriding a task by name usually means *and also*, not *instead of*. Inside the overriding task, `inherited()` hands you the task you shadow as the plain function it is: `inherited()(fix=fix)`. Forwarding is deliberately manual — the two signatures are independent, so automatic forwarding could only drop arguments silently or fail at run time, where spelling the call out shows the mismatch as you type it — and it chains through a cascade of any depth. Two discovery surfaces come with it: `fm --where ` now lists the whole shadow chain (winner first, each shadowed definition after), and `fm --help ` shows the inherited task's usage line, so the forwarding call can be read straight off it (additive `shadows` key in the manifest, present only when something is shadowed). Calling it where nothing is shadowed is a taught error naming `--where`. - **`@task(infinite=True)` — tasks that run until you stop them.** A dev server or follow-mode tail isn't late, it's intentional: `infinite` implies `progress=False`, the status line yields to a one-time dim hint (`serve runs until you stop it — Ctrl-C`), and listings and `--help` carry a `(runs until Ctrl-C)` note (additive `infinite` key in the manifest). Distinguishing "don't time this" from "this never ends" came out of reading the cookbook's dev-server recipe. - **Brands can rename the tasks file.** `App(..., tasks_file="acme.py")` sets the default filename a branded CLI looks for; per-project config (`tasks`) still overrides it, and the filename is baked into the cached manifest (additive) so the background completion refresh — a child that cannot know the brand — reads it back and rebuilds with the right file. ### Fixed - **Completion output is LF on every platform.** Windows text-mode stdout translated the resolver's newlines to CRLF, so a shell reading lines literally — git-bash's `read` — kept the carriage return and completed `--fix\r`, planting a stray CR at the cursor. The resolver now writes bytes straight to the underlying buffer, which skips the translation and pins UTF-8 besides. Found by driving the real git-bash on a Windows runner, not by reading the code. - **git-bash on Windows is detected and installed correctly.** A bare `fm --install-completion` inside git-bash used to answer "pwsh", because PowerShell's `PSModulePath` is machine-level environment and is set there too — so the user got a hook their shell would never read. Detection now checks the `MSYSTEM` variable git-bash exports first, and the `source` line written into `~/.bashrc` uses the MSYS spelling (`/c/Users/…`); a backslashed Windows path in a bash rc is a string of escapes that silently sources nothing. Install and uninstall build that line through the same helper, so uninstall can't strand it. The Windows CI job now drives the real git-bash to prove detection. - **Recordings no longer depend on what's installed beside footman.** fish's autosuggestion drew on the build machine's PATH, so the same script recorded `factor` on macOS and `f77` — the Fortran compiler — on the Linux runner, which read as stray characters at the prompt. Autosuggestions are off in the recording's scratch config now: a cast should show footman's completion, not the host's toolchain. - **Casts render dim text as dim.** pyte spells the bright ANSI colours `brightblack`; rich spells them `bright_black` and silently ignores a style it cannot parse — so anything dim was drawn in the normal foreground. That is the whole story behind the stray "77" in the fish recording: it was fish's own autosuggestion (`f77`, a real Fortran command on the Linux build machine; `factor` on macOS) drawn in white instead of grey, so it read as characters typed into the prompt rather than a suggestion. - **Casts no longer type the terminal's own answers into the prompt.** The recorder answers cursor-position queries because PSReadLine and reedline paint nothing without one — but fish asks *mid-session* and then inserts the reply at the cursor, so `fm che` recorded as `fm ch77e`. Cursor replies are now sent only to the shells that need them (pwsh, nushell); bash and zsh never cared, and fish is visibly happier without. Verified by re-recording all five. - **Casts no longer flash the shell's terminal queries.** pyte doesn't consume DCS sequences, so fish's XTGETTCAP capability probe rendered its hex payload as screen text for one frame before the prompt painted. Those sequences are terminal protocol, not screen content, and are now stripped before the emulator sees them; recordings also skip any blank frames before the first paint, so they open on the prompt. - **The 0.12.0 changelog entry had a second `Changed` section** holding the merged-coverage note, which shipped in 0.13.0 — it now sits under the release that carried it. ### Docs - **The `serve` examples use `@task(infinite=True)`** on the home page, the README, and getting-started, matching what the runner now offers. - **The cookbook.** Seventeen recipes across the whole surface — the parallel gate, passthrough, stacking validators, git-branch TAB completion via `suggest()`, build matrices, monorepo overrides, tasks that return data for `jq`, the coding-agent loop, testing recipes, and a branded CLI — closing the last open docs item from the original v0.4.0 audit. ## [0.14.0] — 2026-07-20 ### Added - **Install once, run anywhere: the uv handoff.** A globally-installed `fm` (`uv tool install footman`) now hands the invocation to `uv run` when the project's `uv.lock` pins footman and the running interpreter isn't already inside the project's environment — so plain `fm check` works from any uv project, at the project's pinned footman version, with the project's tools on PATH, no `uv run` prefix. The rule is one sentence: the lockfile declaring footman is what makes it fire. POSIX replaces the process (`execvp`); Windows spawns and waits, because `exec` there lies about exit codes. `--version`, completion management, and the TAB hot path never hand off; `uv = false` in `[tool.footman]` or `FOOTMAN_NO_UV=1` opts out for purists, and `-v` says when a handoff happened. uv only for now — poetry/pdm handoffs will be considered if there's a want for them. - **A user-level config file completes the precedence ladder.** `~/.config/footman/config.toml` (honouring `XDG_CONFIG_HOME`; move it with `FOOTMAN_CONFIG`) now seeds every merge: personal defaults — a purist's `uv = false`, a permanent `progress = false` — that every project layer cascades over. The ladder, weakest to strongest: defaults, the user file, the root-to-cwd cascade (standalone `footman.toml` beating `pyproject.toml` within a folder, as is customary), `--config`, environment, flags. The docs gain a dedicated [Configuration](https://willemkokke.github.io/footman/configuration/) page for all of it. - **The cache cleans up after itself.** At most once a day, a run spawns a detached collector that removes cache pairs whose project directory no longer exists (manifests now bake in the `cwd` they describe — additive) and pairs idle for 90 days. A fresh cache only plants tomorrow's stamp, so short-lived caches — a test suite's tmp dirs — never spawn anything; the invoking directory's own files are never touched; and every deletion is safe by construction, because the cache is derived state that rebuilds on the next run. It runs after the uv handoff, so a pinned project's own footman collects. `gc = false` disables it — from the user-level config file only, since per-project switches for a shared cache would lie (a `-v` run notes and ignores them); `FOOTMAN_NO_GC=1` is the blunt override. ### Changed - **`--config` now replaces all discovered configuration** — the global file and the cascade both — instead of overlaying the cascade. With a user-level file in the ladder, "the named file is exactly what applies" is the only rule that stays one sentence; an explicit `--config` is total control by intent. ## [0.13.0] — 2026-07-19 ### Added - **Keyword-only parameters are options — required options without a default.** Python's `*` already says "must be named": a parameter after `*` (or `*args`) now maps to `--name`, and without a default it is a *required* option — the shape defaultless dicts and flags always had. Previously a defaultless keyword-only parameter was silently treated as a positional, which its own signature then refused at call time. - **`fm footman docs shots` — terminal screenshots that cannot lie.** Runs a command on a real pseudo-terminal (colours, receipts, taught errors, exactly as a terminal renders them), collapses live rewrites to their final frame, and saves a macOS-style framed SVG via rich. Everything after `--` is the command line to capture; `--width`, `--title`, and `--cmd` shape the frame (the default executable is the invoking CLI, so branded CLIs screenshot themselves). rich is *not* a dependency: the task is gated with footman's own `@task(requires="rich")` and lists as unavailable without it — the availability machinery, dogfooded. The docs site now embeds these, regenerated on every build. - **Both engines dress step lines identically.** A chain's buffered blocks (`fm lint format`) rendered plain `ok` lines while the same work inside a task-body `parallel()` (`fm check`) rendered the full terminal treatment — ✓ marks, bold names, dim commands, cyan times. Captured children now style for the terminal they replay onto, exactly like `parallel()` children always did; in-place rewrites and the announce line stay live-only, so no control bytes ever land in a capture buffer (or the `--json` envelope). One look, both engines, finishing the 0.12.0 unification. - **Captured blocks no longer start with the `→ running` line.** The arrow announces what is running *now*, which is only worth a line while output is live — a TTY rewrites it in place, a streamed CI log may wait minutes under it, and both keep it. A buffered block (chains of two or more, `parallel()` in a task body) flushes when the task is already done, where "starting X" directly above "finished X" said nothing — those blocks now open straight with the completion line. Surfaced by the first `docs shots` screenshot, which faithfully photographed the redundancy. - **`fm footman docs cast` — animated terminal recordings, no JavaScript.** Boots a real interactive shell — zsh, bash, fish, pwsh, or nushell — from a scratch config with footman's completion hook loaded via `--setup-completion`, types a keystroke script (`"fm che"`, ``, ``, ``…), and replays the capture through a terminal emulator into one self-contained SVG animated by CSS keyframes with the session's own timing — an `` plays it. **Every completion page now opens with its shell's own recording**: zsh's `_describe` menu (and a real `fm check` run to its receipts), fish's pager, PSReadLine's MenuComplete grid with tooltips, nushell's completion menu, bash's candidate list — re-recorded from live shells on every docs build. The session answers terminal interrogations (capability, cursor-position, and colour queries) like a plain xterm, because modern shells refuse to paint a prompt into silence, and it makes the pty its child's controlling terminal, because fish, nushell, and PSReadLine refuse interactive mode without one. Needs rich + pyte (the `shots` group), gated with `@task(requires=…)` like its sibling; the scratch HOME hands the invoker's completion cache through `FOOTMAN_CACHE_DIR`, so TAB answers exactly as it would at your prompt. - **`fm footman docs globals` — the runner's global options as a markdown table.** Rendered straight from the CLI grammar: the same rows, in the same order, with the same words `--help` prints, with `{prog}` speaking a branded CLI's own name. `footman.markdown.globals_table(prog=…)` is the function behind it. This site's CLI reference now regenerates its table on every docs build, so it can never drift from the runner again (it had, three ways, which is how this feature earned its place). ### Changed - **The published coverage report is the merged matrix picture.** The docs site's embedded report used to be re-measured on one ubuntu-only run, understating the number CI actually gates on. The merge job now renders the combined HTML — every OS, every Python, the real-shell jobs, and the docs build itself, which runs the whole taskdocs pipeline (five shell casts included) under coverage and merges in like any other job — and both docs builds embed that artifact instead of measuring their own slice. ## [0.12.0] — 2026-07-19 ### Added - **A progress bar that earns its confidence.** On a TTY, every run keeps one live status line on stderr: green runs teach footman how long each exact invocation shape takes (last 50 wall totals per chain + values + passthrough + serial/parallel, per directory), and once five recent runs agree closely enough, the line becomes a real bar — filling against the history's 90th percentile, clamped at 98% so it never claims done early, labelled with elapsed vs. typical time. Sparse or erratic history renders an honest bouncing pulse with elapsed time instead. Both parallel engines feed the same line, so a chain and a `parallel()` inside a task body finally present identically, running names appearing the moment each unit starts. Without a TTY, a confident estimate prints once as `eta ~5.8s` on stderr — CI still records, still learns. Off switches at every level: `--no-progress` for a run, `progress = false` in `[tool.footman]` for good, and `@task(progress=False)` for tasks whose duration has no rhyme (runs containing one never record and only pulse). Failed runs are never recorded; a missing, corrupt, or read-only history never fails a run. - **`FOOTMAN_CACHE_DIR`** relocates every footman cache — completion manifests and timing history alike — in one variable; the XDG rules stay unchanged beneath it, and the completion hot path honours it with no re-install. - **`-j/--jobs N` and `jobs = N` in `[tool.footman]` cap the parallel width** — in both engines: the scheduler's pool and `parallel()` inside task bodies. Unset, the default is now cores - 1 (never below 2) instead of effectively unbounded — the machine stays responsive while fan-outs stay real. The width is part of the timing key, so `-j2` runs build their own duration history. - **Receipts are task-shaped: `✓ check (5.2s)`.** The end-of-run summary speaks the same grid as the step lines — mark, name, time — with the name in bold cyan (same family as the steps, one rank up) and durations humanised. A single task's receipt *is* the total, so the separate `took` line only appears for chains of two or more, dimmed, where the wall total genuinely adds information. `--timings` keeps millisecond precision on the receipts. The `--json` envelope carries the total as an additive top-level `total_ms`. - **One palette across the whole CLI.** `--help`, `--list`, `--tree`, the `--dry-run` plan, and error messages now speak the same visual language as the step lines and receipts: names and headers bold, groups bold cyan, mechanics and optional syntax dim, required placeholders cyan, the `fm:` error prefix red. Usage lines and synthesised examples are painted from one token grammar (prog/group/task/required/optional), so every command line footman prints is lit the same way. Colour is gated per stream on its own TTY — piped output, `--json`, `--where`, and `NO_COLOR`/`--no-color`/`TERM=dumb` runs stay byte-identical to before. ### Changed - **Development Status: Alpha → Beta.** The PyPI classifier now says what the last few releases have shown: the surface is settling, the test bed is broad, and coverage is enforced. Pre-1.0 minors may still include breaking changes, as the header above says. ### Fixed - **`-s/--sequential` now reaches inside task bodies.** It serialised the scheduler's tasks but `parallel()` inside a body still fanned out — so `fm -s check` ran just as parallel as ever. The user's sequential request now rides the task context (`ctx.sequential`) and `parallel()` honours it: `-s` means no concurrency anywhere. Serial runs already kept their own timing history (the flag is part of the chain key), so their estimates stay honest too. - **A single-task invocation now streams live, with colour.** The default scheduler treated even one task as a parallel plan, so `fm check` — the most common shape there is — buffered everything into one uncoloured block flushed at the end, and `run()`'s TTY mode (green ✓ / red ✗, the in-place step rewrite) never fired. One node has nothing to parallelise: it now takes the sequential-live path, so steps appear as they happen and the TTY treatment applies. Chains of two or more keep the buffered non-interleaving contract unchanged. ## [0.11.0] — 2026-07-19 ### Added - **Parameter docs come straight from your docstrings.** Google (`Args:`), NumPy (`Parameters` + underline), and Sphinx (`:param x:`) styles are auto-detected per docstring; entries fill each parameter's help in `fm --help `, in completion menus that show descriptions, and in the `--json --list` catalog — everywhere a `doc("…")` marker reaches, and the marker still wins for the same parameter. The body between the summary and the section becomes the task's **long help**, rendered by `--help` and carried as an additive `long` key. A docstring entry that names no real parameter warns, the same loudness a broken annotation gets. - **`footman.docstrings` — the parser behind it, public and standalone.** Stdlib-only with no footman imports (lift the file into any project): `parse(text)` returns a frozen `Docstring` with `summary`, `long`, and `params`, tolerant of tabs, CRLF, uneven indentation, and unusual section orders. - **The docs site follows your system's colour scheme by default**, with a three-state auto → light → dark toggle. - **`fm footman docs page` / `site` — your tasks, documented.** A first-party plugin (mount with `[tool.footman] plugins = ["footman"]` — the two-line demo of the plugin system) renders a project's task tree as markdown: one page (scoped to the tree, a group, or a task, headings nestable for snippet includes, pipeable to pandoc) or a linked site (one file per task, an `index.md` per group) for zensical/mkdocs navs. Two flavors: portable CommonMark, or `material` with anchors and example admonitions. Content is phrased by the same code as `--help` — names, params, docstring help, defaults, synthesized examples — so pages can't drift from the CLI. Usage lines carry the CLI you invoked — a branded `acme` documents itself with no flag (`ctx.prog`, new on the task context, carries the invoking brand); `--prog` overrides. The renderer is public (`footman.markdown`), the manifest gains an additive `default` key, and footman's own docs dogfood both modes: the Task reference section and the embedded sample on the "Your tasks, documented" page are regenerated on every docs build. ### Changed - **Step lines are columns now: mark · task name · command · time.** Every `run()` line carries the task it belongs to, padded so siblings align; on a colour terminal the name is bold, the command dimmed, and the `(time)` cyan, aligned to the widest command — the width rides the timing history, so a warm run aligns from its very first line (a cold one learns as it streams). Anonymous `parallel()` thunks show `…` — pass a named function or a `functools.partial` (its callee's name is used) for a real label. Durations everywhere now humanise past seconds: `4.1s`, `42s`, `1m10s`, `4h35m` — step lines included, which used to print raw seconds forever. - **The run summary and live progress line moved to stderr.** One rule now governs the streams: *stdout is the answer, stderr is the commentary*. Task output — and footman's own answers (listings, help, `--json` envelopes) — stays on stdout; the `ok`/`FAIL` summary, `--timings`, and the live status line join warnings and errors on stderr. So `fm task > file` captures exactly what the task produced, and piping stdout keeps the live line visible on the terminal. Behavioral: anything that parsed the summary from stdout should read stderr (or use `--json`); wrappers that treat stderr bytes as failure can pass `-q`. ## [0.10.0] — 2026-07-19 ### Added - **`doc("…")` — per-parameter help, in the established `Annotated` marker idiom.** One line of the author's words per parameter, and it pays three times: it leads the option's line in `fm --help `, it becomes the option's completion description in shells that render one (zsh, fish, nushell, PowerShell tooltips — options used to complete bare), and it rides in the `--json --list` catalog as an additive `doc` key. Inert at run time, like every marker. - **An AI agents page and a generated `llms.txt`.** docs/agents.md ships a paste-ready CLAUDE.md/AGENTS.md snippet (the discovery loop, grammar, envelope, exit codes) plus edit-time and stop-gate hook recipes for Claude Code and Cursor. The docs build now generates `llms.txt` and `llms-full.txt` from the nav — an agent-readable index and full text of the site — and the Pages workflow builds through `fm docs build --check`, the same task devs run. - **Tasks can return JSON.** A task's return value now lands in its `--json` entry under `returned`: return a dict (or list, string, bool, …) and a machine consumer gets it verbatim; return `None` and the key is absent. An `int` return keeps its existing meaning — the exit code, never data. The types footman coerces *in* (`Path`, `Enum`, `datetime`, `UUID`, `Decimal`, dataclasses, sets) serialise symmetrically on the way *out*; any other type is dropped loudly — a `returned_error` note in the entry, a warning on stderr, and the run's exit code untouched. The envelope stays `schema: 1` (additive only). `Runner.invoke(...).results[n].returned` already exposed the same value for tests. - **`--json` now means: stdout is exactly one JSON document, whatever happened.** New envelopes cover every surface that used to fall back to text: a refusal (typo'd task, bad flag, broken tasks file, `--config` error, Ctrl-C) emits `{"schema": 1, "error": {"code", "message"}, "results": []}` alongside the stderr message; `--list`/`--tree`/bare `fm` emit the full task tree with parameter specs (`{"schema": 1, "tree": …}`) — the machine catalog agents were missing; `--dry-run` emits the parsed plan (`{"schema": 1, "globals": …, "plan": …}`); `--version` emits `{"schema": 1, "name": …, "version": …}`. The one exception is `--help`, which stays human — its machine twin is `fm --json --list`. - **`--uninstall-completion [shell]` reverses the installer exactly**: the script file goes, the rc/profile line goes (UTF-16 profiles stay UTF-16, one BOM), and both directions are idempotent. When the shell itself has vanished from PATH, the script is still removed and the leftover rc line is printed for hand-removal. - **A completion page per shell.** bash, zsh, fish, PowerShell, and nushell each get their own docs page: what installs where, the session-only form, what the completion menu shows, and — new — how to customise its colours and appearance with copy-paste snippets (`zstyle list-colors`, `fish_pager_color_*`, PSReadLine `-Colors`, nushell's `completion_menu` style block), each verified against the real shell. - **`--setup-completion ` prints the completion hook to stdout**, for enabling completion in the current shell only — no rc file touched: `eval "$(fm --setup-completion zsh)"` (bash/zsh), `fm --setup-completion fish | source`, or `| Out-String | Invoke-Expression` for PowerShell. A bare `--setup-completion` detects the shell, with the note on stderr so stdout stays clean for `eval`. - **`fm`'s own global options now complete.** Typing a flag before the first task — `fm --`, `fm --inst`, `fm -` — offers the globals (`--help`, `--list`, `--install-completion`, `-C`, …); a bare `fm ` still lists tasks only. Resolver-side, so no re-install is needed. - **Python 3.14 is tested in CI**, including the free-threaded (no-GIL) build — footman runs tasks in real parallel threads, and the suite passes with the GIL disabled. ### Changed - **nushell completions now carry descriptions.** The external-completer hook returns `{value, description}` records, so task and group names show their one-line docstring in nushell's menu instead of being stripped to bare names. Re-run `fm --install-completion nushell` to pick it up. - **zsh completions now use the native `_describe` builtin.** The rich- description hook right-aligns descriptions into a column and honours your completion styling (`list-colors`, `descriptions` `format`) — the same look `_git` and `_npm` produce — instead of the hand-formatted `name -- desc`. Re-run `fm --install-completion zsh` to pick it up. ### Fixed - **The completion-latency headline is now the number users actually get.** The docs quoted ~19/20/23 ms in different places; the honest figure for the installed hook path (`fm --complete` via the console script) is **~25 ms**, now measured directly by `scripts/bench_completion.py` and quoted consistently everywhere. The ~15× multiplier vs re-importing runners is unchanged. - **`fm --help ` now refuses with a suggestion** (exit 2, `unknown task or group 'nope' — did you mean …?`) instead of silently printing the global help with exit 0. With a real target on the line (`fm --help deploy prod`), extra words are still tolerated as argument values. - **A misplaced global option is taught by position, not treated as unknown.** `fm check --json` now says ``--json is a global option — it goes before the first task name`` instead of `unknown option`; same for short aliases (`fm lint -k` names `--keep-going`). A task parameter that shares a global's name still wins by position, as before. - **Bare `fm` now ends with the same `--help ` pointer the help screen shows** — the no-argument path is exactly where a newcomer lands. ## [0.9.0] — 2026-07-18 ### Changed - **In-process tools import only when they actually execute.** Resolving a tool's `[console_scripts]` entry point is now pure metadata; the `.load()` that imports the tool's module is deferred into the callable footman runs. So a `--dry-run`, a `recording()` test, or a branch you never take costs zero tool imports — the property that made duty's lazy design nice, now without the build-vs-run split (a call is still always a call). One behaviour change: a console-scripts entry that exists but fails to import now surfaces as a task failure with the real error, instead of silently falling back to a subprocess. - **Exit codes now follow the documented contract.** A binding refusal — a bad coercion, an out-of-bounds value, an unknown option — exits **2**, not a flat `1`; a `run()` command that fails propagates the command's own exit code; and a failing `parallel()` thunk propagates too. `fm` mirrors what it ran. - **`--no-color` / `NO_COLOR` / `TERM=dumb` drop the live progress line entirely**, matching piped output, instead of rewriting it without escapes. - **In-process tools honour cwd and env.** They run from the folder that defined the task and see its environment overlay — the run-from-defining-folder contract the subprocess path already obeyed — and `run(..., capture=False)` streams output live instead of buffering it. ### Added - **`@task(requires=...)` — gate a task on optional dependencies, import-free.** Names Python modules a task needs, checked with `importlib.util.find_spec` (which locates without importing), so a shared library can carry release tasks with heavy third-party deps: keep the `import` in the body (paid only when the task runs), and a missing package lists the task as `(unavailable: )` and refuses to run cleanly, instead of a raw `ModuleNotFoundError`. Reuses the `when=` availability machinery — shown in `--list`/`--help`, re-checked live, a `pre`/`post` on it fails hard. New docs: *A shared library with heavy or optional dependencies* in Composing tasks. - **`off` — disable a flag a tool turns on by default.** `False`/`None` mean *omit* (so a task parameter's default flows through), which left no way to spell a negation. `strict=off` → `--no-strict` fills the gap and completes the boolean story (`True` → `--flag`, `off` → `--no-flag`); it's the same as naming the negation directly (`no_strict=True`) but reads as intent and lets a variable drive it (`directory_urls=pretty or off`). Typed in the stubs, so it autocompletes and a garbage value is still a type error. - Filled a real gap in the `ruff.check` stub — `exit_zero`, `exit_non_zero_on_fix`, `quiet`, `silent`, `verbose`, `isolated`, `cache_dir` now autocomplete, so you're guided to the right flag instead of guessing a name like `exit=` that `**flags: Any` silently accepts and a `False` value quietly omits. Docs now spell out that escape hatch: an unknown flag either errors at the tool (truthy) or is dropped (`False`/ `None`), and a literal `"--flag"` positional always sidesteps it. - **Tool autocompletion via stubs — zero runtime cost.** `tools.pyi` gives IDEs and type checkers typed verbs and common flags for the curated tools (`tools.ruff.check(` completes `fix=`, `select=`, …; `fix="yes"` is a type error), while the runtime bridge stays a few mechanical lines the stub never touches. Every stubbed verb ends in `**flags: Any` and unknown verbs fall through to `Tool`, so the stub can suggest but never forbid — drift degrades a hint, not a run. `None` is typed as the omit sentinel everywhere, matching the translation rules. - **The tools bridge runs Python tools in-process.** `Tool(..., in_process=True)` (or `in_process=True` per call) resolves the tool's own `[console_scripts]` entry point and calls it with `sys.argv` patched — the no-transcription contract, minus the interpreter spawn. `mkdocs`, `zensical`, and `coverage` default to it. Beyond speed this is a correctness fix on macOS, where SIP strips `DYLD_*` from child processes: a tool needing Homebrew's native libraries (mkdocs + cairo) only works in-process. Preferences fall back to a subprocess when no entry point exists; per-call demands error with a taught message. And parallelism survives: capture routes through the per-task stdout router (thread-confined — also fixing a pre-existing race where the global redirect could cross-contaminate concurrent in-process captures), and argument-accepting entries (click commands, `main(argv=None)` — nearly all of them) are called directly. Only a legacy zero-arg `main()` gets the `sys.argv`-patching fallback, and only those serialise. - **Completions that teach.** In zsh and fish, task and group descriptions render next to the candidates; `--help` ends with a synthesised `Example:` invocation built straight from the signature; and a "did you mean?" hint fires at every not-found site (unknown task, option, choice, or `--where` target). Bare `fm` now lists the tasks instead of erroring. - **Completions that stay fresh.** A stale-while-revalidate background refresh rebuilds a directory's cached manifest once it ages past `[tool.footman] completion.max_age` (default 10 min; `off`/`0` disables) — the Tab returns the cached answer instantly and never blocks on the rebuild. - **`--opt=value` completes in every shell**, and value-bearing globals (`-C`, `--config`, `--tasks-file`, …) no longer send the completion walk descending as if their value were a task. - **`capture`, `Runner`, `Result`, and `recording`** import straight from `footman` (previously only from `footman.testing`). ### Fixed - **PowerShell completion after a space.** Windows PowerShell 5.1 and pwsh 7.0–7.2 silently drop an empty-string argument to a native command, so pressing Tab after a space re-completed the previous word instead of the fresh position. The hook now flags the empty position with `--empty-partial` and the resolver supplies the `""` itself. **Re-run `fm --install-completion pwsh`** to pick up the new hook. - **`--help` never touches the filesystem.** `fm --install-completion fish --help` used to write rc files before printing anything; and `fm --help` with no tasks file now shows the global help (so a stuck newcomer sees `-f`/`-C`), not a bare one-liner. - **`-C/--directory` restores the working directory** afterwards, so an in-process caller (a test runner) is no longer left in the changed folder. - **`-f/--tasks-file` no longer poisons** the directory's cached completion — a one-off `-f` run leaves Tab describing the real cascade. - **Plugins and the cascade are sturdier.** A plugin that fails to import is taught at exit 2 instead of dumping a traceback on every invocation; `availability()` never crashes on a `requires=` whose parent package raises; a cascade file that registers tasks and then raises no longer leaves ghost tasks behind; each `tasks.py` gets its own copy of a sibling `import helpers`; and provider trees are isolated per project so one project's tasks can't leak into another. - **Completion install is more robust.** bash `COMPREPLY` is glob-safe (`printf %q`), rc-file edits sniff BOM/encoding so a UTF-16 Windows PowerShell profile no longer crashes the install, and installs target the rc files shells actually read (`$ZDOTDIR` for zsh; the login profile alongside `.bashrc` for macOS bash). - **Loud errors where footman used to stay silent** — a missing or typo'd `--config` file, a `**kwargs` task, `=value` on a flag-shaped global, and a `--` handed to an option as its value. - A broad correctness pass across type coercion (strict env and variadic values, unions that carry both choices and types, dict value-type markers), the scheduler (each explicit chain segment runs; `parallel()` steps surface in `--json`), and the tools surface (`tools.run`/`tools.sys` resolve to Tools; `installed_version()` decodes UTF-8). ## [0.8.0] — 2026-07-17 ### Added - **PowerShell completion installer.** `fm --install-completion pwsh` (alias: `powershell`) writes a `Register-ArgumentCompleter` hook and dot-sources it from the profile PowerShell itself reports (`$PROFILE`), for PowerShell 7+ and Windows PowerShell alike. Idempotent, branded, and covered by a functional test that drives PowerShell's own completion engine on every CI platform. - **nushell completion installer.** `fm --install-completion nushell` (alias: `nu`) writes an external-completer hook sourced from the config nushell itself reports (`$nu.config-path`). The hook *wraps* any existing external completer (carapace, …) — it answers for `fm` and passes every other command through. Verified against a real nushell. Every shell footman promised is now installed with one command. - **`tools.*` became a bridge, not a transcription.** Every executable on PATH is a tool with no declaration (`tools.terraform("plan")`), attribute access chains subcommands (`tools.docker.compose.up(detach=True)`), and keyword arguments translate mechanically (`fix=True` → `--fix`, lists repeat, single letters go short, trailing `_` escapes keywords). This is a deliberate answer to the drift in hand-transcribed wrappers — duty's `ruff.check(show_source=True)` emits a flag modern ruff rejects; a bridge has nothing to go stale. `tool.installed_version()` (cached, resolved outside the task context) covers the rare version-dependent branch. Curated spellings for ruff, uv, git, docker, bun, mkdocs, zensical, coverage, cspell, prek, markdownlint (-cli2), basedpyright; pytest keeps its in-process path. A tools *plugin* mechanism was considered and rejected: tools are plain objects, so publishing them is publishing Python — an import already beats an entry point. - **A live progress line for parallel runs.** On a TTY, the scheduler keeps one status line (`/ 2/5 (1 failed) running: lint, test`) between the finished tasks' output blocks. Event-driven (no timer thread), always cleared before a block lands so output stays non-interleaved, red only when something failed, plain under `NO_COLOR`/`--no-color`, and absent entirely under `--quiet`, `--json`, or a pipe. The last item on the README's original roadmap besides `tools.*` growth. - **Bare `--install-completion` detects your shell.** No argument needed: footman walks the parent-process tree (the way typer's `shellingham` dependency does — without the dependency, and correctly skipping over `uv run`), with the `PSModulePath` tell on Windows and `$SHELL` as the last resort. Undetectable → a taught error naming the five options. Verified through a real shell with `$SHELL` deliberately lying. ### Docs - **The README is a front door now** — what footman is, why it exists, one taste, and pointers into the site — instead of a 460-line hand-maintained copy of the documentation that drifted on every change. - Two new pages: **CI & automation** (the `--json` envelope contract, exit codes, keep-going/sequential in CI, agents) and **Troubleshooting** — a catalogue of every taught error, generated against real output, with the standing invitation that a raw traceback is a footman bug. ### CI - **Every completion hook is now functionally tested against its real shell.** New tests drive bash (`COMP_WORDS`/`COMPREPLY`), zsh (the hook's exact expansion idiom), and fish (its own `complete -C` engine) alongside the existing pwsh and nushell tests — and a dedicated `shells` CI job installs zsh, fish, and a pinned nushell so none of them can skip silently. The bash 3.2 slice bug taught us: a hook that hasn't met its shell isn't tested. ### Fixed - The pwsh installer now writes its hook into **every** PowerShell profile present — PowerShell 7 and Windows PowerShell keep *different* `$PROFILE` files, so on a machine with both, completion previously landed in only one of them (and not necessarily the one the user asked for). The hook runs on both shells unchanged (`Register-ArgumentCompleter` exists since PS 5.0), so whichever PowerShell opens, TAB works. - Completion no longer re-offers an option the segment already has — `fm lint --fix ` suggests what can still bind, not `--fix` again. Repeatable (`list`/`dict`) options rightly stay on offer, and a fresh segment starts with a clean slate. ## [0.7.0] — 2026-07-17 ### Removed - `--refresh-manifest` — it was parsed and never read; the manifest already rebuilds on every execution-path run, so the flag had no job to do. - `manifest.is_stale` and the manifest's `sources` block — scaffolding for a staleness check no live path ever consulted. - `reset()` is no longer re-exported from the package root (it remains in `footman.registry` for test suites); it was a test-suite helper living on the public namespace. ### Changed - `footman.tools` is now a real public export (`__all__`, lazy) — it was load-bearing in the docs and footman's own tasks file while officially not existing. - The `import footman` vs `import typer` cost claim is now backed by a committed script (`scripts/bench_import.py`), and the comparison page's repro commands include the required `--group comparison`. ### Added - **Shell completion installers.** `fm --install-completion bash|zsh|fish` writes the hook and (bash/zsh) one guarded `source` line into your rc file; fish needs no rc edit at all. Idempotent, branded (`acme --install-completion zsh` installs for `acme`), and the generated hook stays on the cached stdlib-only fast path. The bash hook survives macOS's bash 3.2 (whose quoted array slices collapse to a single word — found the hard way, tested for keeps). - **Chain-aware completion.** The resolver now walks segments the way the splitter does — exact positional arity, then a trailing `Many`/variadic consumer, then the next word starts a new segment — so `fm format lint --fi` completes *lint's* options, a satisfied task offers the next task names, `+` resets, and after `--` nothing is offered (it's the passthrough's). Latency is unchanged: same one-file-read walk. - **Composable task surfaces.** Three mechanisms, one contract (resolve at import time, re-check availability live): `@task(when=…, reason=…)` disables-but-lists a task that can't run here (pytest-skip semantics — shown in `--list`/`--help`, refuses to run with the reason, a `pre`/`post` dependency on it is a hard failure); `include(source, into=…, only=…, exclude=…, override=…)` grafts another module's tasks into your tree (loud on collisions and typos, provider imported under a registry capture so nothing leaks, adopted tasks run from *your* directory); and packages advertise a `Group` under the `footman.tasks` entry point that projects opt into via `[tool.footman] plugins = ["name"]` — never auto-loaded, user names shadow plugin groups, missing plugins are crisp errors naming what *is* installed. New docs page: *Composing tasks*. - `registry.capture()` — the public seam for importing task-defining modules without touching the live registry. ## [0.6.0] — 2026-07-17 ### Added - **A first-party testing story.** `footman.testing` ships `Runner.invoke` (drive a full command line in-process: exit code, stdout/stderr, structured `TaskResult`s, isolated completion cache), `recording()` (capture the commands a block *would* run, silently, without executing), and re-exports the new public `use_context()`. Three pytest fixtures — `fm`, `fm_project`, `fm_record` — auto-load via a `pytest11` entry point; pytest is still not a dependency (only pytest itself imports the module). footman's own suite dogfoods them. New docs page: *Testing your tasks*. - **Validation markers**, all in the `Annotated` idiom: `exists` / `isfile` / `isdir` path requirements and `between(lo, hi)` numeric bounds (a bare `range` works for ints), both validated eagerly with taught errors; `env("VAR")` fallbacks (CLI > env > default, the env value flowing through the same coercion/bounds/checks as a CLI token); and `check(fn)` custom validators, run post-coercion, per element for collections. `env()` on a parameter without a default (or on a dict) is a taught build-time error. - **Opaque annotations warn.** A parameter whose annotation resolves to nothing footman can coerce (an unresolved name, a value) now emits a `UserWarning` instead of silently treating every value as text. ### Docs - Fixed the dynamic-completion examples: the documented `suggest[str, fn]` syntax never existed — the real form is `Annotated[str, suggest(fn)]`. ## [0.5.0] — 2026-07-17 ### Added - **A real help story.** `fm --help` documents the runner itself (usage grammar plus the full global-options table, generated from the same table the parser reads). `fm --help ` shows a group's tasks, and `fm --help ` renders per-task usage, docstring, and typed positional/option tables from the manifest. `-h`/`--help` anywhere before `--` turns the whole line into a read-only help request — `fm deploy --help` can never execute `deploy`. - **`bool` is now a real token type.** `dict[str, bool]` values and `list[bool]` elements parse `true/false/1/0/yes/no/on/off` (eagerly validated with a taught error) instead of collapsing to a flag or silently reading every value as `True`. - **Dependency-cycle detection.** A cyclic `pre`/`post` graph is a taught error naming the cycle; previously it ran nothing and exited 0. - **`py.typed` marker** — downstream type checkers now see footman's inline types (the `Typing :: Typed` classifier was already claiming they could). - **Ctrl-C is handled**: pending tasks are cancelled, the run reports `interrupted`, and the exit code is 130 — no more raw traceback. ### Changed - **Comma-splitting is now the default for collections.** A `list` / `dict` parameter splits a single token on commas (`--tag a,b,c` → `["a", "b", "c"]`) out of the box, in addition to the repeatable form (`--tag a --tag b`). The old opt-*in* `csv` marker is replaced by an opt-*out* `nosplit` marker, for the parameters whose values may themselves contain a comma. - **`--json` output is now enveloped**: `{"schema": 1, "results": [...]}` instead of a bare list, so post-1.0 additions never break consumers. This is the blessed machine surface; future changes will be additive. - **Errors name their culprit.** A failing tasks-file import names the file; a duplicate task name is reported as the user error it is (not "failed to import"); a malformed discovered config TOML warns and is skipped; a malformed `--config` file is a hard error; a *strict* `suggest()` completer that raises now fails the run (it used to silently disable the validation it promised). - Dry-run now records `StepResult`s (and honours `quiet`), so tests can assert which commands *would* run without executing anything. ### Fixed - `fm --help ` used to **execute the task**. - `run("...")` string commands are no longer `shlex`-split on Windows — backslash paths survive; the string goes to `CreateProcess` whole. - Non-UTF-8 subprocess output no longer crashes `run()` (decoded with `errors="replace"`). - Digit-lookalike tokens (`"²"`) are taught type errors instead of an `int()` traceback. - An exception escaping a worker thread in a parallel run (including a `KeyboardInterrupt` raised inside a task) now propagates instead of being silently dropped and reading as success. ### Docs - Docstrings converted from reStructuredText to Markdown (renders natively via mkdocstrings). ### CI - Releases are gated: `release.yml` now runs the full CI suite on the tagged commit and refuses to publish unless the tag, `pyproject.toml`, `__version__`, and the changelog all agree on the version (and the wheel ships `py.typed`). - Coverage is enforced (`fail_under = 92`), and the strict docs build runs on every PR instead of only after merge. ## [0.4.0] — 2026-07-16 ### Added - **Custom-branded CLIs.** A public `App(name, prog, version)` carries your project's names and version and threads them through every user-facing string (the `--version` banner, the `prog:` error prefix, the completion hint) — so you can ship an internal tool under its own name while it stays footman underneath. footman's own `fm`/`footman` are now just the default-branded `App()`. - **API reference** on the docs site, generated from docstrings via [mkdocstrings](https://mkdocstrings.github.io/). - **Coverage report** embedded directly in the docs via an inline `