One interface over every runner in your repo.
tsr is a lightweight, polyglot, repo-aware task runner. It wraps the native runners you already have — npm, bun, cargo, go, uv — adds a task dependency graph and opt-in parallelism, and delegates caching to Turbo/Nx instead of reinventing it.
Why tsr
A thin unifying layer — predictable by default, delegate by design.
No startup tax
Metachar-free run strings are spawned directly (execvp-style) — no npm run / Node boot to pay on the common path.
Polyglot
One entry point across every ecosystem. A bare [tasks.test]auto-detects each package's runner: cargo, go, npm/bun, uv.
Dependency graph
Declare deps and get a DAG. Sequential by default; opt into concurrency with parallel = true. Fail-fast, always.
Three task forms
delegate to a backend, run a command directly, or let tsr auto-detect the native runner — resolved by precedence.
Safe mini-shell
In-process $VAR expansion, && || ;, quoting, and globs (*, **) plus cross-platform builtins (rm, cp, mkdir). Pipes & redirects are rejected up front.
Delegate caching
Content-hash and remote caching are ceded to Turbo/Nx — never reimplemented. tsr stays a lightweight command runner.
Guarded by default
A task runner runs the repo's code — that is the job. What it should not do is let a config reach past the commands it visibly declares. tsr guards the parts it performs itself, with no flag to turn on.
- Nothing tsr touches leaves the workspace. The in-process builtins (
rm,cp,mv) refuse operands outside it — symlinks included — anddir,env_fileandpackagesare rejected at load. - No environment injection. A config or a committed
.envcannot setLD_PRELOAD,NODE_OPTIONSorGIT_SSH_COMMAND, andPATHmay be extended but never replaced. - Discovery stops at your repo. The walk up to
tasks.tomlnever climbs past the git root, your home directory, or a filesystem boundary — and a world-writable config is refused outright. - Nothing outlives the run. A failure or a Ctrl-C tears down the whole process group, so a killed
npm run devnever leavesviteholding the port.
Deny by default
Every guard is on out of the box. Widening workspace confinement takes an explicit [security] allow_paths; lifting the environment guards takes --allow-unsafe-env on the command line.
No config self-service
The env guards exist for the case where the tasks.toml is what you're wary of — so there is deliberately no config key that can switch them off. Only the person typing the command can.
Inspect before you run
tsr <task> --dry-run prints every command a run would execute, in order — before $VAR expansion, so the plan can't leak what your .env holds.
How it compares
tsr is a command runner, not a build system — it unifies the runners you have and cedes caching to the tools built for it. Grouped by what each tool is for, because a package runner and a build system aren't answering the same question.
| Task runners | Package runners | Build systems | ||||||
|---|---|---|---|---|---|---|---|---|
| Capability | tsr | just | go-task | mise | npm | bun | deno | Turbo/Nx |
| Auto-detects each package's runnercargo / go / npm / bun / uv from a bare task | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Dependency graph (DAG)declared task-to-task edges, resolved before running | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
| Opt-in parallelism🟡 = concurrency via a helper (npm-run-all, concurrently), not the runner | ✅ | ❌ | ✅ | ✅ | 🟡 | 🟡 | 🟡 | ✅ |
| Monorepo workspace fan-out🟡 = runs across workspaces, but without dependency-aware order | ✅ | ❌ | ❌ | ❌ | 🟡 | 🟡 | 🟡 | ✅ |
| Resolves node_modules/.bincall vite / eslint like npm run | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
| Built-in shell & coreutilscross-platform rm, cp, mkdir, $VAR, globs — no system shell needed | ✅ | ❌ | 🟡 | ❌ | ❌ | 🟡 | 🟡 | ❌ |
| Built-in commands confined to the workspacethe runner's own rm/cp can't reach outside it | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Declarative env vars & .env🟡 = one of the two: a .env is read, or vars come from the shell / .npmrc | ✅ | ✅ | ✅ | ✅ | 🟡 | 🟡 | 🟡 | ✅ |
| Config can't inject into other programsno LD_PRELOAD / NODE_OPTIONS from [env] or .env | ✅ | ❌ | ❌ | 🟡 | ❌ | ❌ | ❌ | ❌ |
| Native binary, no runtime bootthe runner itself — 🟡 = it is the runtime it boots | ✅ | ✅ | ✅ | ✅ | ❌ | 🟡 | 🟡 | ❌ |
| Single static binary | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ |
| Content-hash / remote cachingtsr delegates this to Turbo/Nx by design | 🔀 | ❌ | 🟡 | ❌ | ❌ | ❌ | ❌ | ✅ |
None of these sandbox what a task spawns — tsr included.
One file, every task
tasks.toml is both the config and the workspace-root anchor. Run tsr <task> from anywhere in the repo.
run— spawn a command directly.packages— fan out across a monorepo (glob or manifest name).delegate— hand off to Turbo, Make, or any binary.deps+parallel— the graph, opt-in concurrency.
# tasks.toml [workspace] members = ["apps/*", "packages/*"] [tasks.dev] run = "vite" dir = "apps/web" [tasks.test] packages = ["apps/*"] # auto-detect [tasks.build] delegate = "turbo" # → turbo run build [tasks.ci] deps = ["lint", "test", "build"] parallel = true