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, cargo, go, uv, gradle, dotnet and more — 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 — npm, pnpm, yarn, bun, deno, cargo, go, uv, Gradle, Maven, .NET, Ruby, PHP, Elixir, Swift, Zig and more.

🔗

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, globs (*, **), cd, exit and NAME=value prefixes, plus cross-platform builtins (rm, cp, mkdir). Pipes & redirects need an explicit shell = true.

🚦

Built for CI

Cap concurrency with --jobs, run one package with --filter, bound flaky steps with per-task timeout and retries, and parse results from an NDJSON reporter file.

📖

Self-documenting

desc on every task, a default for bare tsr, --list that shows your scripts even without a config, "did you mean…?" on typos, and bash/zsh/fish completions.

✍️

Edit safely

Author tasks in the tsr --config TUI or by hand — comments, ordering and keys tsr doesn't know all survive every save.

📦

Delegate caching

Content-hash and remote caching are ceded to Turbo/Nx — never reimplemented. tsr stays a lightweight command runner.

Works with your stack

24 ecosystems, detected from the files already in your repo. A bare tsr <task> runs the native tool — no tasks.toml required.

JavaScript & TypeScript

  • npmpackage.jsonnpm run <task>
  • pnpmpnpm-lock.yamlpnpm run <task>
  • Yarnyarn.lockyarn run <task>
  • Bunbun.lockbun run <task>
  • Denodeno.jsondeno task <task>

Systems

  • RustCargo.tomlcargo <task>
  • Gogo.modgo <task>
  • Zigbuild.zigzig build <task>
  • SwiftPackage.swiftswift <task>
  • MakeMakefilemake <task>

JVM

  • Gradlebuild.gradle(.kts)./gradlew <task>
  • Mavenpom.xml./mvnw <task>
  • Scalabuild.sbtsbt <task>

Scripting & web

  • Pythonpyproject.tomluv run <script>
  • RubyRakefile / Gemfilebundle exec rake <task>
  • PHPcomposer.jsoncomposer run <task>
  • Elixirmix.exsmix <task>

.NET, mobile & Haskell

  • .NET*.sln / *.csprojdotnet <task>
  • Dartpubspec.yamldart <task>
  • Flutterpubspec.yamlflutter <task>
  • Stackstack.yamlstack <task>
  • Cabal*.cabalcabal <task>

Infrastructure

  • Terraform*.tfterraform <task>
  • Composecompose.yamldocker compose <task>

Also recognised: CMake, Meson, Bazel, Dockerfile, R, Julia and Perl. They have no task-name convention, so tsr names the tool and suggests a run line for your tasks.toml.

Full detection order

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 runnernpm / pnpm / cargo / go / uv / gradle / dotnet / mix / … 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.
  • desc, timeout, retries, shell — per-task polish when you need it.
# tasks.toml
[workspace]
members = ["apps/*", "packages/*"]
default = "dev"             # bare `tsr` runs it

[tasks.dev]
desc = "Start the web app"
run = "vite"
dir = "apps/web"

[tasks.test]
packages = ["apps/*"]        # auto-detect
timeout = "10m"
retries = 1

[tasks.build]
delegate = "turbo"           # → turbo run build

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