What it is
A task file is Python. A task is a function with a docstring; its parameters
become the command line, its docstring becomes the help, and its module becomes a namespace.
Nothing is interpolated into a string, so a value with a space, a quote or a $
stays data.
# Makefile.py from make import task, sh @task(group="app", requires=["cargo"]) def test(*, fast: bool = False) -> None: """Run the test suite.""" sh("cargo", "test", *(["--lib"] if fast else []))
Three names, deliberately different. The distribution is
mkrun, the import is make, the command is mk.
Nothing installs a make command — that would shadow GNU make on the
PATH of every Unix machine.
Typed arguments
int, Path, Literal, list[str] —
parsed, validated and documented from the signature alone.
No quoting hazard
sh() takes an argv list. Shell is opt-in, through
sh.pipe() and sh.bash(), because it is the hazard.
A dry run that is dry
--dry-run suppresses every command, every file write through
fs, every poll, HTTP call and process kill.
Tasks you can share
A task package is a uv dependency, resolved and locked — not a directory each
repository git cloned and then quietly diverged from.
Namespaces, not prefixes
Groups give web.start and box.ls; a group alias makes
dx.start the same task, and override= replaces one you
inherited.
Startup is a feature
~30 ms to a task list, with a 150 ms budget enforced by a test. Groups import lazily so an unused one costs nothing.
Is this for you?
You have a repository with a handful of commands worth remembering — build,
test, run the dev server, cut a release, reset the database — and today they live in a
scripts/ folder, a package.json, a shell history or a wiki page.
That works until one of them needs a loop, a condition, or three values that must agree,
and until a second repository needs the same command and you copy it.
This is for that moment. A task is a Python function, so when a task outgrows one line you already have the language you need — you did not have to rewrite it to get there.
Worth it if…
your tasks take arguments; more than one repository runs the same commands; a mistake in a task deploys, kills or deletes something; your team is polyglot and only the tasks are Python.
Probably not if…
your repository has one command and it is cargo test, or you cannot have
Python on the machines that run the tasks.
Why it is worth it
The command line is the signature
Types, defaults, required-ness, help text and completions all come from the parameters. There is no second description to keep in sync, because there is no second description.
Values stay values
No interpolation step exists. A filename with a space, a commit message with a quote, a
password with a $ is data and cannot become syntax — not because it was escaped,
but because there is no parser downstream to escape it from. Where shell genuinely is the
right tool, sh.pipe(…) says so in the source.
Tasks are code, so they are testable
The bugs that hurt are never in the dispatch. They are in the twelve lines that decide which process to kill or which file to read — and those lines are an importable function here, with a recorder that captures what a task would have run.
def test_the_teardown_reaps_the_lock_holder(recorder): stop() assert recorder.commands == [["kill", "-TERM", "4711"]]
The dry run is real
--dry-run suppresses every command, every file write, every poll, HTTP call
and process signal — it does not print an expansion and then still write the file. Reads are
untouched, so a dry run takes the same branches the real run does.
Configuration fails with instructions
A shared task declares what it needs as a typed section. A missing value stops before
anything runs, and the error names the field, its type, and all three places it can be set:
the task file, make.toml, or the environment.
Sharing is a dependency, not a copy
Tasks ship as ordinary Python packages. mk --sync writes a lock file and
upgrading is a version bump in a diff — instead of a directory cloned into every repository
at whatever HEAD happened to be, quietly diverging.
It stays fast
About 30 ms to a task list, with a 150 ms budget enforced by a test. Groups import lazily, so a task package you are not using costs nothing.
The full reasoning, decision by decision, is in
docs/design.md.
If you are coming from a justfile, the mapping is in
docs/from-just.md.
What it costs, and where it is going
It needs a runtime — Python 3.11+, and uv for shared task
packages — and it starts in ~30 ms rather than instantly. It is alpha: the authoring
API is stable in practice, the internals still move.
The authoring API settles first
@task, sh, fs, config,
env and testing are what everything is built on. A 1.0 means
those stopped moving.
A runner, not a build system
File targets and staleness graphs were tried and removed. Compilers already track their own inputs; a second, worse graph on top produces confident wrong answers. Tasks depend on tasks.
The runner stays generic
Tasks that wrap a tool belong beside that tool, as its own
<project>-make package. Nothing tool-specific ships inside
mkrun.
Bodies stay Python
No DSL, no template language, no configuration format that slowly grows conditionals. And startup stays under budget.
Get started
$ uv tool install mkrun # installs one command: mk $ cd your-repo $ mk # offers to create a Makefile.py $ mk --list # every task, with its help $ mk --doctor # where each task package resolved from
Releases are tagged in the repository and published to PyPI from CI.
The full authoring API — @task, sh, fs,
config, env, testing — is in the
README. The step-by-step tutorial is on
academy.