mk_

A command runner whose tasks are Python. The command line comes from the function signature, so there is no second schema to keep in sync.

# install the runner -- one command, `mk`
$ uv tool install mkrun

# then, in any repository with a Makefile.py
$ mk app.test --fast
$ cargo test --lib

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.