Task forms
A task takes one of three forms, chosen by precedence when tsr <task> runs:
delegatepresent → hand off to a backend.runpresent → spawn the command directly.Neither → auto-detect the package's ecosystem and map the task name to its native runner.
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.
# 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 toturbo run <task>, using the task's own name.Table
{ bin, args }runs exactlybin args…— nothing is inferred.
It's also the escape hatch for full control over a shell:
[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.
[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:
[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 file | Ecosystem | tsr test runs |
|---|---|---|
deno.json / deno.jsonc | deno | deno task test |
package.json (+ bun lockfile) | bun | bun run test |
package.json (+ pnpm-lock.yaml) | pnpm | pnpm run test |
package.json (+ yarn.lock) | yarn | yarn run test |
package.json | npm | npm run test |
Cargo.toml | cargo | cargo test |
go.mod | go | go test |
pyproject.toml | uv | uv 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.xml | Maven | ./mvnw test, or mvn test without a wrapper |
build.sbt | sbt | sbt test |
mix.exs | Elixir | mix test |
composer.json | PHP | composer run test |
Rakefile / Gemfile | Ruby | bundle exec rake test (rake test without a Gemfile) |
*.sln / *.csproj / *.fsproj | .NET | dotnet test |
pubspec.yaml | Dart / Flutter | dart test, or flutter test for a Flutter project |
Package.swift | Swift | swift test |
build.zig | Zig | zig build test |
stack.yaml | Stack | stack test |
*.cabal / cabal.project | Cabal | cabal test |
compose.yaml / docker-compose.yml | Compose | docker compose test (think tsr up, tsr down) |
*.tf | Terraform | terraform test (think tsr plan, tsr apply) |
Makefile (and nothing else above) | make | make 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:
✗ 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:
[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.
# 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.
[tasks.ci] deps = ["lint", "test", "build"] parallel = true