run & the mini-shell
A run string executes one of two ways, decided by parsing it:
Every word static → the string is split and the command is spawned directly (
execvp-style). Fast, fully cross-platform. This is the common path and wheretsrbeatsnpm run.Variables, globs or operators 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.
On Windows these tools are batch shims — npm is really npm.cmd, and node_modules/.bin holds vite.cmd — so tsr also applies PATHEXT when it resolves a bare name. Write run = "vite" and it works the same everywhere; the extension is never something you have to spell out.
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). Quoted text is never globbed.Globs —
*,?,[...],**match real files, relative to the task'sdir.
[tasks.deploy] run = "build && deploy --target $TARGET || notify 'deploy failed'"
Parameter expansion — ${VAR:-default}, ${#VAR} and friends — is not implemented, and says so rather than failing later with a confusing "not defined". Set the default in [env] instead.
Globbing
Patterns follow sh: * does not cross a / and does not match a leading dot, ** spans directories (including zero of them, so a/**/*.js matches a/x.js), and a pattern that matches nothing stays literal. Two rules keep it predictable:
Expanded values are never rescanned. If
$FILESholds*.js, it stays the literal string*.js. Only therunstring itself can hold a pattern.A glob resolves when its command runs, not when the task is planned — so in
build && rm dist/*.mapthe pattern sees the filesbuildjust produced.Matches always use
/, Windows included, so a pattern produces the same arguments on every platform.
Variables are the other way round: they resolve before the sequence starts, so an undefined $VAR fails the task cleanly instead of half-way through.
Built-in commands
rm -rf dist has to mean the same thing everywhere — but the coreutils it names are Unix binaries that don't exist on Windows. So tsr implements the file operations tasks actually use, in-process:
| Builtin | Options |
|---|---|
rm | -r/-R/--recursive, -f/--force |
cp | -r/-R/--recursive |
mv | — |
mkdir | -p/--parents |
touch | — |
cat | — (stdin when given no files) |
echo | leading -n |
pwd | — |
true / false | — (operands ignored) |
[tasks.clean] run = "rm -rf dist/*" [tasks.build] run = "mkdir -p dist && vite build && cp public/*.ico dist"
Short options bundle (-rf), -- ends option parsing, and relative paths resolve against the task's dir. A builtin exits 0 on success, 1 on a failed operation, and 2 on a usage error. As in POSIX, rm -f with nothing to remove succeeds silently — which is what makes rm -rf dist/* idempotent once dist is already empty.
true and false are here for a reason: cmd || true is the standard "this step must not fail the build" idiom, and on Windows there is no /bin/true to fall back on.
On every platform, these names resolve to tsr's own implementation, never to a binary of the same name on PATH. That's the point: one run string, one behaviour. Builtins apply to run only — never to delegate or an auto-detected native runner. Use delegate when a task genuinely needs the platform's own tool.
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 |
$(...) / backtick substitution | use a script file |
& background, ( ) subshells | use delegate for real shell control |
{a,b} brace expansion | list the paths explicitly, or quote the braces |
Unsupported constructs are caught when the config loads — before any task runs — so a broken run string never executes half-way.
Detection order
parse → every word static → direct spawn → variables / globs / ops → mini-shell → any unsupported construct → error 64 at load
Classification is a property of the parsed string, and metacharacters inside quotes are literal — so run = "echo 'a | b'" prints a | b, it does not trip the pipe rule, and run = "echo '*.txt'" prints *.txt rather than globbing.
Brace expansion is rejected only when it would actually mean something — a {...} group holding a comma. {} and {json} are ordinary text in sh too, so find . -exec rm {} + and --define:{} stay legal.
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"