Skip to content

CLI reference

Global options

Global options bind to fm itself and go before the first task name (fm --json test, not fm test --json):

option effect
-h, --help help for fm, or the named group/task
-V, --version print the version and exit
-l, --list list tasks (flat)
--tree list tasks grouped by command group
--sort list tasks alphabetically (default: as defined)
--no-sort list tasks in definition order
-a, --all include hidden tasks in the listings
--where=TASK print the task's source file:line
--describe=[ADDR] print task contracts as JSON
--plugins list installed footman.tasks plugins, mounted or not
-n, --dry-run rehearse: bodies run, footman's work is faked
-k, --keep-going run every branch even if one fails
--fail-fast stop at the first failure
-s, --sequential run one at a time, parallel() blocks included
--no-sequential run in parallel (undo config)
-j, --jobs=N max parallel tasks; default: the machine's cores minus one, never below 2
-y, --yes assume yes to every confirm() gate
--no-input never prompt; error if input is required
--input allow prompting (undo config)
-q, --quiet suppress the per-task summary
-v, --verbose replay captured output even on success
--color=WHEN when to colour: always|never|auto; default: auto (or what NO_COLOR/FORCE_COLOR says)
--no-color disable ANSI colour (same as --color=never)
--no-progress no progress bar, eta, or timing capture
--progress progress bar, eta and timing (undo config)
--no-uv skip the uv handoffs for this run
--uv take the uv handoffs (undo config)
--json stdout is one JSON document (captures output)
--timings show per-task durations
-C, --directory=PATH run as if launched from PATH
-f, --tasks-file=PATH only this tasks file, no tasks cascade
--config=PATH only this config file, no config cascade
--install-completion=[SHELL] install shell completion
--setup-completion=[SHELL] print completion for eval
--uninstall-completion=[SHELL] remove the completion hook

--help is the one global allowed anywhere before --: fm deploy --help is a read-only help request, never an execution. fm --help documents the runner and its globals, fm --help docs a group, fm --help deploy a task. Because it wins wherever it lands, help is also the one parameter name a task can't use — it would map to --help, which is always intercepted (see Typed signatures).

Decorator surface

from footman import task, group, pre_tasks

@task                       # bare
def build(): ...

@task(name="ci-build")      # override the command name
def build(): ...

@task(pre=[fmt], post=[notify])   # dependencies (run before / after)
def check(): ...

@task(keep_going=True)      # surface every failure in the subtree, don't stop at the first
def check(): ...

@task(atomic=True)          # never kill this task's subprocesses mid-flight on a sibling failure
def format(): ...

@task(confirm="Deploy to prod?")  # a confirm gate before the task and its prereqs run
def deploy(): ...

@task(interactive=True)     # owns the real terminal; the run goes fully sequential
def scaffold(): ...

@task(progress=False)       # duration has no pattern: never timed, only pulses
def repl(): ...

@task(infinite=True)        # runs until Ctrl-C: hinted at start, never timed
def serve(): ...

@task(shared=False)         # never shared: every request for it runs
def notify(): ...

@task(hidden=True)          # out of the listings, callable and completed as ever
def ci_entry(): ...

@task(expose="project_only")  # outside a project: unlisted, uncompleted, refused
def deploy(): ...

fmt.opts(atomic=True)       # override a task's policy for one use (pre=/post=/body call)

release = group("release", help="Cut a release")
ci = group("ci", expose="project_only")   # the whole subtree needs a project

@release.task
def wheel(): ...

@pre_tasks                  # once per invocation, before anything runs
def gate(inv):              # (see Composing tasks)
    for t in inv.tasks:
        t.add_pre(inv.tasks["audit"])

@pre_bind                   # before binding: task.env reaches env() fallbacks
def creds(inv, task): ...

@pre_task                   # around every execution, on its worker thread
def opened(inv, task): ...  # post-bind: task.args, task.state, task.env

@post_task
def closed(inv, task, result): ...  # sealed, read-only; veto via fail()

@post_tasks                 # once per invocation, after the run
def digest(inv): ...        # inv.results / inv.skipped / inv.total_ms

@wrap_task                  # the pair as one generator
def span(inv, task):
    result = yield          # (wrap_bind: two yields, enters at bind)

Runtime helpers

Import Purpose
run(cmd, ...) run a command in the task context
run(..., recorded=False) run it, return the result, record and show nothing
run(..., timeout=30) kill the process tree after 30s; raises RunTimeout
parallel(*calls) fan tasks and step items out concurrently
with parallel() as p: the same fan-out written as plain calls; p.results
passthrough() arguments after -- on the command line
Context, use_context the task context; install one from your own code
Many[T], NoSplit[T] one-or-many; opt a collection out of comma-splitting (the nosplit marker, subscripted)
forward, Forward[T] thread a value to the tasks/groups this task dispatches — see Chaining & parallelism
hidden, Hidden[T] keep a parameter out of the listings — it still binds and completes; --all shows it
ask(prompt="…") prompt for a missing value (CLI > env > prompt > default); CI-safe
prompt(), confirm(), select() mid-task questions — only inside an interactive=True task; prompt(type=…) coerces and re-asks like a flag
.opts(**policy) per-use policy override (keep_going, atomic, …) on a task or group
Annotated[T, suggest(fn)] dynamic completion for a parameter
exists, isfile, isdir require a Path value to exist on disk
between(lo, hi) inclusive numeric bounds (a bare range works too)
env("VAR") fall back to an environment variable (CLI > env > default)
default(fn) compute the default when the task runs, not at import
given("name") did the caller supply this parameter, or did footman fill it in?
attended() would a prompt reach a human? — stdin is a terminal and no --no-input/--dry-run
tty(), colored() is a live terminal watching the output? / should output dress for colour?
check(fn) custom post-coercion validator (ValueError rejects)
doc("…") one-line parameter help — shown in --help, completion, the catalog
footman.testing Runner/Result + recording() — see Testing
include, plugin adopt tasks from modules/packages — see Composing
inherited() the task this one shadows in the cascade — call it to extend it
progress(done, total), track(iterable) report a task's own progress; the bar fills from it
fetch(url, …) download into footman's cache — revalidating, verifying, recorded as a step
@requires_dep/_tool/_env, @requires disable-but-list a task that can't run here
@expose("always"/"global_only"/"project_only") where a task is offered — the decorator spelling of @task(expose=…)

Configuration

Settings have their own page — the precedence ladder (user-level file, the project cascade, --config, environment, flags), the files, and every key: Configuration.