run & the mini-shell

A run string executes one of two ways, chosen by scanning for shell metacharacters:

  1. No metacharacters → the string is split and the command is spawned directly (execvp-style). Fast, fully cross-platform. This is the common path and where tsr beats npm run.

  2. Supported metacharacters present → the string runs through tsr's own minimal shell.

Local binaries (node_modules/.bin)

For run = "vite" to actually replace npm run dev, a directly-spawned command must find locally-installed binaries. Before spawning, tsr prepends node_modules/.bin to PATH — walking up from the task's directory to the workspace root, nearest first — exactly the lookup npm/bun/yarn/pnpm do. So run = "eslint ." or run = "vite build" resolves the local tool, with no Node startup tax. In a non-JS package it's a no-op.

Supported — the entire feature set

  • $VAR / ${VAR} — expansion from the merged env.

  • && || ; — sequencing with correct exit-code semantics (&& on 0, || on non-zero, ; always).

  • Quoting'single' (literal, no expansion) and "double" (expansion applies).

TOML
[tasks.deploy]
run = "build && deploy --target $TARGET || notify 'deploy failed'"

Rejected — never attempted

These require OS-level plumbing outside the tool's scope, so they are rejected at load time with a specific error and exit code 64:

ConstructInstead
| pipesuse delegate or a script file
> >> 2>&1 redirectionuse a script file
* ? [...] globspass an explicit path
$(...) / backtick substitutionuse a script file
Rejection happens up front

Unsupported metacharacters are caught when the config loads — before any task runs — so a broken run string never executes half-way.

Detection order

TEXT
scan → no metachars        → direct spawn
     → supported-only      → mini-shell
     → any unsupported     → error 64 at load

Metacharacter detection always runs before rejection, and metacharacters inside quotes are literal — so run = "echo 'a | b'" prints a | b, it does not trip the pipe rule.

The escape hatch

When a run string genuinely needs a real shell, opt in explicitly with delegate, or point run at a script file:

TOML
[tasks.pipeline]
delegate = { bin = "sh", args = ["-c", "cat x | grep y > z"] }

[tasks.build]
run = "./scripts/build.sh"