A command runner, not a build system

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.

~/app — tsr ci
$ tsr ci
├─ lint   → cargo clippy
├─ test   → npm run test
└─ build  → turbo run build
 
✓ lint    ok    1.2s
✓ test    ok    3.4s
✓ build   ok    0.9s
 
✓ ci passed — exit 0

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 — and dir, env_file and packages are rejected at load.
  • No environment injection. A config or a committed .env cannot set LD_PRELOAD, NODE_OPTIONS or GIT_SSH_COMMAND, and PATH may be extended but never replaced.
  • Discovery stops at your repo. The walk up to tasks.toml never 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 dev never leaves vite holding the port.
~/app — guards
$ tsr clean # run = "rm -rf ../../build"
rm: refusing to touch '/home/you/build':
    outside the workspace at '/home/you/app'
 
$ tsr build # .env sets LD_PRELOAD
✗ config error: the root '.env' sets 'LD_PRELOAD',
  which decides what code an unrelated program loads
 
$ tsr ci --dry-run # read it before you run it
· lint   dir: .  cmd: eslint .
· build  dir: packages/ui  cmd: vite build
✓ nothing executed — exit 0
🛡️

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 runnersPackage runnersBuild systems
Capabilitytsrjustgo-taskmisenpmbundenoTurbo/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🔀🟡
✅ yes🟡 partial — see the row note🔀 delegated by design❌ no
See the speed numbers

None of these sandbox what a task spawns — tsr included. What tsr does and doesn't guard →

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