run & the mini-shell
A run string executes one of two ways, chosen by scanning for shell metacharacters:
No metacharacters → the string is split and the command is spawned directly (
execvp-style). Fast, fully cross-platform. This is the common path and wheretsrbeatsnpm run.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 (&&on0,||on non-zero,;always).Quoting —
'single'(literal, no expansion) and"double"(expansion applies).
[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:
| Construct | Instead |
|---|---|
| pipes | use delegate or a script file |
> >> 2>&1 redirection | use a script file |
* ? [...] globs | pass an explicit path |
$(...) / backtick substitution | use a script file |
Unsupported metacharacters are caught when the config loads — before any task runs — so a broken run string never executes half-way.
Detection order
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:
[tasks.pipeline] delegate = { bin = "sh", args = ["-c", "cat x | grep y > z"] } [tasks.build] run = "./scripts/build.sh"