Task forms

A task takes one of three forms, chosen by precedence when tsr <task> runs:

  1. delegate present → hand off to a backend.

  2. run present → spawn the command directly.

  3. Neither → auto-detect the package's ecosystem and map the task name to its native runner.

Precedence, not merging

The first matching form wins. If a task has both delegate and run, delegate is used and run is ignored.

Form 1 — delegate

Hand execution (and caching) to a specialist backend.

TOML
# String form → `<bin> run <task>`
[tasks.build]
delegate = "turbo"          # → turbo run build

# Table form → full control over bin + args
[tasks.bundle]
delegate = { bin = "make", args = ["bundle"] }
  • String delegate = "turbo" expands to turbo run <task>, using the task's own name.

  • Table { bin, args } runs exactly bin args… — nothing is inferred.

It's also the escape hatch for full control over a shell:

TOML
[tasks.pipeline]
delegate = { bin = "bash", args = ["-c", "set -o pipefail; cat x | grep y > z"] }

For most shell needs, shell = true on a run task is shorter (see below).

Form 2 — run

Spawn a command directly — the npm run replacement, with no Node startup tax.

TOML
[tasks.dev]
run = "vite"
dir = "apps/web"            # optional; defaults to workspace root
args = ["--host"]          # prepended before CLI passthrough

How the string is executed (direct spawn vs. the mini-shell) is covered in run & the mini-shell. Add shell = true to hand the string to the platform shell instead (sh -c, or cmd /c on Windows), so pipes and redirects work, at the cost of portability:

TOML
[tasks.report]
run = "cargo test 2>&1 | tee test.log"
shell = true

Form 3 — auto-detect

With neither delegate nor run, tsr detects the package's ecosystem from its marker file and maps the task name to that ecosystem's native runner:

Marker fileEcosystemtsr test runs
deno.json / deno.jsoncdenodeno task test
package.json (+ bun lockfile)bunbun run test
package.json (+ pnpm-lock.yaml)pnpmpnpm run test
package.json (+ yarn.lock)yarnyarn run test
package.jsonnpmnpm run test
Cargo.tomlcargocargo test
go.modgogo test
pyproject.tomluvuv run test, only if test is a .py file or a [project.scripts] entry
build.gradle(.kts) / settings.gradle(.kts)Gradle./gradlew test, or gradle test without a wrapper
pom.xmlMaven./mvnw test, or mvn test without a wrapper
build.sbtsbtsbt test
mix.exsElixirmix test
composer.jsonPHPcomposer run test
Rakefile / GemfileRubybundle exec rake test (rake test without a Gemfile)
*.sln / *.csproj / *.fsproj.NETdotnet test
pubspec.yamlDart / Flutterdart test, or flutter test for a Flutter project
Package.swiftSwiftswift test
build.zigZigzig build test
stack.yamlStackstack test
*.cabal / cabal.projectCabalcabal test
compose.yaml / docker-compose.ymlComposedocker compose test (think tsr up, tsr down)
*.tfTerraformterraform test (think tsr plan, tsr apply)
Makefile (and nothing else above)makemake test

The table is also the order of precedence. In a directory with several markers, the first row that matches wins, so a Rails or Laravel app with a root package.json runs npm scripts. Give a mixed repo a tasks.toml to say which tool each task means.

Gradle and Maven use the project's wrapper (gradlew, mvnw) when there is one, looking from the package up to the workspace root, which is where a multi-project build keeps it. A Ruby project with no Rakefile has no task to map onto, so a bare task is an error that suggests a run line instead.

Recognised, but not auto-detected

Some build systems and languages have no <tool> <task-name> convention to map onto: CMake (CMakeLists.txt), Meson (meson.build), Bazel (MODULE.bazel, WORKSPACE), a bare Dockerfile, R (DESCRIPTION), Julia (Project.toml) and Perl (cpanfile, Makefile.PL). tsr recognises them, and instead of reporting that nothing was found, its error names the tool and gives a run line to start a tasks.toml from:

TEXT
✗ config error: found CMakeLists.txt in '/work/engine' — CMake has no task-name
convention for `tsr build` to map onto, so give it a task in tasks.toml
(`tsr --init` creates one), e.g.

  [tasks.build]
  run = "cmake -B build && cmake --build build"

For a Node package, the nearest lockfile between the package and the workspace root decides the runner. A monorepo keeps one lockfile at its root, so every member runs under it.

Python has no <runner> run <script-name> convention like npm's, so a bare name only runs when uv run can find it: a .py file, or a console script the project declares in [project.scripts] (or [tool.poetry.scripts]). Anything else is an error. Give Python tasks a command instead:

TOML
[tasks.test]
run = "uv run pytest"

For Go, tsr run runs go run ., since a bare go run names no package and always fails.

This is the core "wrapper" behaviour: a bare [tasks.test] just works across a polyglot repo with no per-package config.

TOML
# Fanned out across every matching package, each using its own native runner.
[tasks.test]
packages = ["apps/*", "packages/*"]

Aggregator tasks

A task with deps but no run, delegate, or packages is a pure aggregator: it runs its dependencies and nothing of its own.

TOML
[tasks.ci]
deps = ["lint", "test", "build"]
parallel = true