Guides

Practical recipes for everyday work. Each is self-contained; pick one below, or follow the links inside for the full reference on any concept.

Run without a config file

You don't need a tasks.toml to start. In any repo with a recognised marker (package.json, Cargo.toml, go.mod, pom.xml, Gemfile, …), tsr <task> runs the package's native script by auto-detecting the ecosystem:

Shell
tsr dev                # → npm run dev   (in a package.json repo)
tsr build              # → cargo build   (in a Cargo crate)
tsr test -- --watch    # passthrough works too → npm run test --watch

tsr walks up from the current directory to find the nearest package, just like npm. Add a tasks.toml (via tsr --init) when you want a dependency graph, monorepo fan-out, or a delegate — a present config always takes precedence.

Forward arguments to a task

Everything after -- is forwarded to the resolved command. If the task defines args, those are prepended before the passthrough:

TOML
[tasks.test]
run = "vitest"
args = ["--color"]
Shell
tsr test -- --watch        # → vitest --color --watch

This is how you pass ad-hoc flags (--watch, --filter, a file path) without editing tasks.toml. See the CLI reference for the full grammar.

List what's defined

Shell
tsr --list
TEXT
Available tasks:
  build          delegate: turbo
  dev (default)  Start the web app  (run: vite  ·  dir: apps/web)
  test           packages: apps/*  ·  timeout: 10m  ·  retries: 1

Each line shows the task's form at a glance — run, delegate, packages, or deps — so you can see how a task resolves without opening the file. Give a task a desc and it leads the line. A task with a config error still shows, marked ✗ with its error, so one typo never hides the rest of the list. Without a tasks.toml, --list shows your package.json scripts (or deno.json tasks, Make targets, composer scripts, Rakefile tasks) instead.

Mistype a name and tsr suggests the closest one:

TEXT
✗ error: unknown task 'tset' — did you mean 'test'?

Set a default task

TOML
[workspace]
default = "dev"

Now a bare tsr runs dev, and tsr -- --port 4000 forwards arguments to it.

Tab-complete task names

Shell
eval "$(tsr --completions bash)"     # ~/.bashrc
source <(tsr --completions zsh)      # ~/.zshrc, after compinit
tsr --completions fish | source      # ~/.config/fish/config.fish

Task names are read from the current directory each time, so new tasks complete straight away.

Run a task from anywhere in the repo

tasks.toml is the workspace-root anchor. tsr walks up from the current directory to the nearest tasks.toml, so the same command works from any subdirectory:

Shell
cd apps/web/src
tsr dev                    # still resolves the root tasks.toml

Migrate your npm scripts

A package.json script maps almost one-to-one to a run task — but without the npm run / Node startup tax on the common path, because a metacharacter-free run string is spawned directly.

JSONC
// package.json
{ "scripts": { "dev": "vite", "test": "vitest --run" } }
TOML
# tasks.toml
[tasks.dev]
run = "vite"

[tasks.test]
run = "vitest --run"

tsr resolves project-local binaries from node_modules/.bin exactly like npm/bun, so run = "vite" finds ./node_modules/.bin/vite. The usual script idioms work as written, on every platform:

TOML
[tasks.e2e]
run = "cd apps/web && NODE_ENV=test playwright test"

[tasks.clean]
run = "rm -rf dist/* || true"

See run & the mini-shell for what the mini-shell supports ($VAR, &&/||/;, quoting, globs, cd, exit, NAME=value prefixes). A script that pipes or redirects needs shell = true, which hands it to sh -c and gives up portability.

Keep a bare task when the runner is obvious

For a package whose ecosystem tsr can detect, a bare [tasks.test] with no run/delegate auto-detects the native runner (npm run test, cargo test, ./gradlew test, dotnet test, mix test, …). See task forms.

Chain tasks with a dependency graph

Declare deps to build a DAG. It runs sequentially by default; opt into concurrency with parallel = true. Execution is always fail-fast.

TOML
[tasks.ci]
deps = ["lint", "test", "build"]
parallel = true
Shell
tsr ci                     # lint, test, build — concurrently, fail-fast

Dependencies are de-duplicated (a diamond runs each task once). Full details in graph & parallelism.

Run one task across every package

Fan a single task out across a monorepo with packages — matched by path glob or exact manifest name:

TOML
[workspace]
members = ["apps/*", "packages/*"]

[tasks.test]
packages = ["apps/*"]      # auto-detect each package's runner
Shell
tsr test                   # runs the detected test task in every apps/* package
tsr test --filter web      # just one package, no extra config
tsr web#build              # or target one package's task directly

See monorepo & packages for matching rules and manifest names.

Keep CI under control

Shell
tsr ci --jobs 4            # at most four commands at once, however wide the graph
TOML
[tasks.e2e]
run = "playwright test"
timeout = "10m"            # kill the whole process tree → exit 124
retries = 2                # a flaky failure gets two more tries

A failure that a retry then fixes doesn't trip fail-fast; only the last attempt counts. Write results somewhere a script can read them with --reporter-file results.ndjson.

See exactly what a task will run

Shell
tsr deploy --dry-run           # the plan, as written: safe to paste
tsr deploy --dry-run --expand  # after $VAR and glob expansion, with the env

--expand prints variable values, .env secrets included, so keep its output to yourself.

Call a project-local tool (vite, eslint, …)

No wrapper needed — tsr prepends node_modules/.bin to PATH (walking up to the workspace root, nearest first), the same lookup npm/bun/yarn/pnpm do:

TOML
[tasks.lint]
run = "eslint ."

[tasks.dev]
run = "vite"
dir = "apps/web"           # resolves apps/web/node_modules/.bin first

Set environment variables

Env is merged — task env > task env_file > workspace [env] > root .env > process — and each value is $VAR-expanded after the merge:

TOML
[env]                      # applies to every task
NODE_ENV = "development"

[tasks.build.env]          # task-scoped, wins over [env]
NODE_ENV = "production"

A root .env is auto-loaded. Full precedence and expansion rules in environment.

Load a specific .env file per task

Use env_file to layer environment-specific files over the default .env — the canonical case is .env.test for a test task:

TOML
[tasks.test]
run = "vitest"
env_file = [".env.local", ".env.test"]   # later file wins; both override .env

Paths resolve relative to the task's dir (or the workspace root); listed order is increasing precedence (later overrides earlier); missing files are skipped, so an optional .env.local need not exist. See environment.

Delegate caching to Turbo or Nx

tsr never implements content-hash or remote caching — it hands the whole task to a backend that does:

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

The task name is passed through, so tsr build becomes turbo run build and Turbo's cache does its job. See task forms for the delegate table form and how precedence works.

Re-run on file changes

tsr doesn't watch files, and that's deliberate: a file watcher is a separate tool's job. Most dev tools have their own watch mode, so forward their flag:

Shell
tsr test -- --watch        # vitest / jest watch mode

For anything else, wrap tsr in a watcher such as watchexec:

Shell
watchexec -e rs,toml -- tsr test
watchexec -w src -- tsr build --filter web

Author tasks interactively

Instead of hand-editing TOML, open the terminal UI:

Shell
tsr --config

It opens on a menu — Add a task, Edit a task, Delegate a task, Delete a task, Preview graph — so you never face a blank screen. Changes autosave: press ⏎ in the form and the task is validated and written to tasks.toml right away, with no separate save step.

It edits through the same format-preserving parser used everywhere else, so comments and unknown keys survive, and every change is validated before it is written. The full key map is in the CLI reference.