Skip to content

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 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 or the first build in a fresh directory 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 (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.

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]:

[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:

fm workspace mount --share <TAB>   # main  scratch  archive
fm format lint --fix <TAB>         # completes within the chain

Group names, task names, flags, options, and both static and dynamic 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:

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:

fm -f tasks/<TAB>            # your shell's own file completion
fm build --out dist/<TAB>    # a Path option
fm deploy dist/<TAB>         # 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:

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 — (bash has no description column) script + rc line eval "$(fm --setup-completion bash)"
zsh aligned column (_describe) script + rc line eval "$(fm --setup-completion zsh)"
fish aligned column, native one auto-loaded file fm --setup-completion fish \| source
PowerShell tooltip (menu completion) script + $PROFILE(s) … \| Out-String \| Invoke-Expression
nushell 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.