Shadowtree Manual
Shadowtree is a project-local development task runner. Commands are declared as
recipes in .shadowtree.toml, resolved into inspectable plans, and run in a
sandbox by default so ordinary checks and builds do not mutate the host
checkout.
Start Here
- Getting Started - install Shadowtree, create a config, run recipes, and inspect plans.
- Sandboxing and Sync-Out - understand isolated execution, direct checkout edits, and copying generated files back.
- CLI Inspection - inspect help, recipe listings, printed plans, expanded plans, and dry checks.
- Configuration Files - understand config discovery and the top-level TOML shape.
- Recipe Fields - see the recipe fields that define a workflow.
- Typed Arguments - define positional and named recipe inputs with validation and completion.
- Profile Selection - choose Go or Node built-ins explicitly or through marker detection.
- Editor Support - JSON Schema, Zed, VS Code, and
shadowtree-lsp. - Development - recipes used by the Shadowtree repository itself.
Feature Areas
- Configuration: includes, variables and environment, shell commands, placeholders, recipe references, and recipe resolution.
- Recipes: lifecycle, fan-out and workdirs, logging, tool requirements, and reserved names.
- Arguments: typed arguments, value providers, variadic args, and presets.
- Profiles: Go and Node.
- Integrations: shell completion and editor support.
Reference
- Full behavior spec
- Known limits
- All-features example
- All-features base include
- Benchmark presets example
Getting Started
Install
go install github.com/yusing/shadowtree/cmd/shadowtree@latest
Shell Completion
Bash completion:
command -v shadowtree >/dev/null 2>&1 && eval "$(shadowtree completion bash)"
The install recipe appends the same guarded eval line to ~/.bashrc.
Fish completion:
shadowtree completion fish > ~/.config/fish/completions/shadowtree.fish
Zsh completion:
command -v shadowtree >/dev/null 2>&1 && eval "$(shadowtree completion zsh)"
Completion is dynamic: it uses configured recipes plus recipes from the selected profile. Without a config file, Shadowtree detects the nearest Go or Node project marker upward from the current directory and exposes matching built-ins.
Create a Config
Create a default TOML config in a project:
shadowtree init
Shadowtree discovers .shadowtree.toml upward from the current directory.
Discovery stops at the git root when the current directory is inside a Git
repository.
Run Recipes
In a project with Shadowtree config:
shadowtree config
shadowtree recipes
shadowtree help test
shadowtree help test color=false
shadowtree test
shadowtree build
shadowtree lint
Run one-off commands without adding a recipe:
shadowtree exec -- go test ./...
shadowtree exec -- npm test
Inspect Plans
Inspect the resolved plan before running a recipe:
shadowtree --print test
shadowtree --print --expanded test
shadowtree --check --shell test
shadowtree --verbose build
--verbose prints compact stage boundaries such as == cmd: @build == before
commands run. Multiline scripts are shown as <script>, so verbose headers do
not dump script bodies.
Built-in Go workflow recipes run once per discovered Go module:
shadowtree --print test
prints the module fan-out:
for_each: @go-modules
workdir: {item}
main: go test ./... {@}
Sandboxing and Sync-Out
Sandboxed recipe writes stay inside the temporary workspace. The host checkout is unchanged unless sync-out is requested.
On Linux, Shadowtree uses overlayfs in a user and mount namespace by default. When namespace overlayfs is unavailable, it warns and falls back to a copied workspace with the same isolation contract.
Edit the Host Checkout Directly
Recipes that intentionally edit the checkout can opt out:
[recipes.tidy]
sandboxed = false
for_each = "@go-modules"
workdir = "{item}"
cmd = "go mod tidy"
post = ["if test -f go.work; then go work sync; fi"]
Sync Selected Outputs
Use sync-out when a sandboxed recipe should copy selected results back:
shadowtree --sync-out internal/generated generate
shadowtree --sync-out dist --sync-out schema.json build-assets
Recipe-level sync-out:
[recipes.generate]
cmd = "go generate ./..."
sync_out = ["internal/generated"]
A selected path missing from the sandbox is mirrored as a deletion on the host.
Prefer narrow --sync-out PATH or recipe sync_out over --sync-out-all.
Global Flags
Global flags are parsed before the command or recipe name.
shadowtree --profile go --print test
shadowtree --config ./ci.shadowtree.toml recipes
shadowtree --sync-out internal/generated generate
Arguments after the recipe name are passed to the recipe’s main command or parsed as typed recipe arguments.
Flags
--config PATH- Use an explicit config file instead of discovery.
--profile PROFILE- Use a profile. Supported profiles are
goandnode. --sync-out PATH- Copy a path back after a successful sandboxed recipe. Repeat the flag or use comma-separated paths.
--sync-out-all- Copy the entire sandbox workspace back after success.
--print- Print the resolved plan without running it.
--expanded- With
--print, include expanded scripts and resolved values. --check- Validate the resolved recipe without running commands.
--shell- With
--check, parse expanded shell scripts. --verbose- Show workspace information and compact command boundaries during execution.
--help- Show basic CLI help.
--version- Print the version.
Position Matters
Put global flags before the command or recipe name:
shadowtree --verbose test ./...
Flags after the recipe name are recipe args:
shadowtree test -v ./...
Related topics:
CLI Inspection
Shadowtree exposes recipe behavior before execution. Use inspection commands before running unfamiliar recipes, writes, deletes, installs, regeneration, or sync-out.
Help
shadowtree help prints CLI usage, active config/profile, and resolved recipes
with their help text.
shadowtree help
shadowtree help test
shadowtree help test color=false
shadowtree help <recipe> prints these fields when present or applicable:
- recipe name and help text
- command
- sandboxed marker for unsandboxed recipes
- tool requirements
preandpostfor_eachworkdir- typed arguments with help, info, and configured values
- sync-out paths for sandboxed recipes
Recipe Listing
shadowtree recipes prints resolved recipe names and help text. If a recipe
has no help, Shadowtree falls back to a compact command summary.
shadowtree recipes
Plan Printing
--print prints the resolved execution plan without running it:
shadowtree --print test ./internal/runner
shadowtree --print --expanded test ./internal/runner
The plan includes fields such as recipe name, profile, config path,
unsandboxed marker, declared requirements, stages, for_each, workdir, main
command, post commands, and sync-out paths.
--print --expanded also prints normalized defaults for absent fields,
expanded script bodies, the selected preset, resolved typed arguments, leftover
variadic args, computed vars, recipe-local env, expanded log settings, and
expanded sync-out paths.
Checks
--check validates the selected resolved recipe without running commands:
shadowtree --check test
shadowtree --check --shell test
It validates nested @recipe and @path:recipe references, reports missing
references, rejects reference cycles, and validates resolved log and workdir
paths.
--check does not check host tool availability declared in requires; those
are checked only before real execution.
--check --shell additionally parses expanded sh and bash script bodies
after placeholder expansion and shell prelude insertion.
Shell Completion
Shadowtree can generate bash, fish, and zsh completion.
Install Completion
Bash:
command -v shadowtree >/dev/null 2>&1 && eval "$(shadowtree completion bash)"
Fish:
shadowtree completion fish > ~/.config/fish/completions/shadowtree.fish
Zsh:
command -v shadowtree >/dev/null 2>&1 && eval "$(shadowtree completion zsh)"
The repository install recipe appends guarded eval lines for bash and zsh and
installs fish completion when fish is available.
Dynamic Completion
Generated shell scripts call back into Shadowtree:
shadowtree __complete fish <words...>
shadowtree __complete bash <cursor> <line> [current]
shadowtree __complete zsh <words...>
Completion uses:
- configured recipes
- built-in recipes from the selected profile
- built-ins from a detected profile when no config is loaded
- recipe
helptext - typed argument metadata
valuesproviders for the active argument
Supported Behavior
shadowtree <TAB>completes core commands and resolved recipes.shadowtree te<TAB>completes matching recipe names such astest.shadowtree help <TAB>completes recipe names.shadowtree help test <TAB>completescolor=false.shadowtree --profile <TAB>completesgoandnode.shadowtree build <TAB>completes configured recipe arguments such asproject=.shadowtree benchmark preset=<TAB>completes recipe-local preset names.shadowtree build[<TAB>completes bracket-style arguments such asbuild[project=.shadowtree test race=<TAB>completestrueandfalsefor bool arguments.patharguments complete relative paths, absolute paths, and~/paths.rel_patharguments complete relative paths only.path_kindfilters path candidates to files, directories, or executable files.
Completion parses config, checks profile markers, and runs only argument
values commands needed for the active argument.
Configuration Files
Shadowtree config is TOML. By default, Shadowtree discovers the nearest
.shadowtree.toml by walking upward from the current directory.
Discovery stops at the git root when the current directory is inside a Git
repository. An explicit --config PATH bypasses discovery.
shadowtree --config ./ci.shadowtree.toml recipes
Minimal Config
[recipes.test]
help = "Run tests."
cmd = "go test ./..."
Run it with:
shadowtree test
Top-Level Fields
include = ["./common.shadowtree.toml"]
profile = "go"
shell = "sh"
shell_prelude = '''
shared_function() {
echo ok
}
'''
[env]
KEY = "value"
[vars]
NAME = "static value"
[var_commands]
DYNAMIC_NAME = "printf dynamic"
Field summary:
include: merge shared Shadowtree TOML files before the current config.profile: opt into built-in recipes forgoornode.shell: script shell for shell command strings; supported values areshandbash.shell_prelude: shell code prepended to every script command.env: environment values applied to recipe commands and top-levelvar_commands.vars: static placeholder values.var_commands: commands that compute placeholder values during recipe resolution.recipes: named workflow definitions.
Recipe Tables
Recipes live under [recipes.<name>]:
[recipes.build]
help = "Build the CLI."
cmd = 'go build -o "bin/{binary}" "{pkg}" {@}'
sync_out = ["bin/{binary}"]
[recipes.build.arguments.binary]
type = "string"
default = "shadowtree"
[recipes.build.arguments.pkg]
type = "string"
default = "./cmd/shadowtree"
values = "@go-main-packages"
Use Recipe Fields for recipe field details and Typed Arguments for argument definitions.
Related Topics
Includes
Use include to share Shadowtree config across projects or subprojects.
include = ["./tools.shadowtree.toml", "./ci.shadowtree.toml"]
Include paths are resolved relative to the config file that contains them. Included files are merged before the including file; later includes override earlier includes, and the including file overrides all included files.
What Merges
Includes are global mixins. These surfaces participate in the effective config:
profileshellshell_preludevarsvar_commandsenvrecipes
Included recipes appear in shadowtree help, shadowtree recipes, shell
completion, and editor completion as if they were defined in the current config.
Shell Prelude Order
When multiple files define shell_prelude, Shadowtree concatenates included
preludes first, then the including file’s prelude.
If app.shadowtree.toml includes tools.shadowtree.toml, recipes in both
files can use shell functions from tools.shadowtree.toml. The included
prelude cannot read variables that are assigned later by the including prelude
while the prelude is being evaluated.
Recipe Overrides
Same-name recipes from includes are field-merged, with the including file winning for fields it sets. There is no deletion syntax for inherited recipes, arguments, vars, or env keys.
Use Recipe References with @path:recipe instead of
include when a recipe should stay isolated and run from another config’s
directory.
Variables and Environment
Shadowtree exposes reusable values through placeholders. Static values come
from vars; dynamic values come from var_commands.
[vars]
ldflags = "-buildvcs=false"
[var_commands]
git_sha = "git rev-parse --short HEAD"
[recipes.build]
cmd = 'go build -ldflags="{ldflags}" -o "bin/app-{git_sha}" ./cmd/app'
Static Vars
Top-level vars are available to every recipe. Recipe-level vars override
top-level values for that recipe.
[vars]
mode = "dev"
[recipes.release.vars]
mode = "release"
Static vars may reference other static or dynamic vars with the same
{name} placeholder syntax.
Dynamic Vars
var_commands run when recipes are resolved for execution, printing, or help.
They run from the source checkout. Surrounding whitespace is trimmed from
stdout and the result becomes a placeholder value.
[var_commands]
version = "git describe --tags --always"
Dynamic vars are useful for values such as versions, commit IDs, detected
paths, or generated labels that should be visible in --print --expanded.
Environment
Top-level env applies to recipe commands and top-level var_commands.
Recipe-level env overrides top-level values for that recipe.
[env]
GOFLAGS = "-mod=readonly"
[recipes.test.env]
CGO_ENABLED = "0"
Environment values use raw placeholder expansion. Only {name} and
{name:raw} are valid there; shell-specific modes such as {name:shell} are
for shell command strings.
Reserved Names
run_id is built in and cannot be declared in vars, var_commands, recipe
vars, or recipe arguments.
Shell Commands
Shadowtree recipes use shell command strings for process execution.
shell = "bash"
[recipes.example]
cmd = '''
set -euo pipefail
echo "hello"
'''
If shell is not set, Shadowtree uses sh. Supported config shells are sh
and bash; fish is supported only as a generated CLI completion shell.
Command Strings
Command strings run through the configured shell after placeholder expansion.
A string that is exactly @recipe or @path:recipe invokes another recipe;
other strings run in the shell.
[recipes.test]
cmd = 'go test "{pkg}" {@}'
Use Typed Arguments and placeholders for validated inputs. Put defaults in argument definitions rather than by parsing raw shell args yourself.
Shell Prelude
Top-level shell_prelude is prepended to every script command:
shell_prelude = '''
require_tool() {
command -v "$1" >/dev/null 2>&1 || {
echo "$1 is required" >&2
exit 1
}
}
'''
Recipe-level shell_prelude is appended after the top-level prelude for that
recipe.
Multiline Scripts
Use multiline shell strings when a workflow needs shell functions,
conditionals, pipes, or multiple statements. Shadowtree shows multiline scripts
as <script> in help, completion, verbose boundaries, and log boundaries, so
large scripts do not flood inspection output.
For quoting and placeholder modes, see Placeholders.
Placeholders
Placeholders insert recipe arguments, vars, and built-in values into recipe fields.
[recipes.build]
cmd = 'go build -o "bin/{binary}" "{pkg}" {@}'
Shell Command Expansion
Default {name} expansion is raw text when unquoted, before the shell parses
the script. Inside single-quoted or double-quoted shell context, {name} is
escaped for that quote context:
cmd = 'printf "%s\n" "{name}"'
cmd = "printf '%s\n' '{name}'"
Both forms keep the placeholder value as one shell word even when the value contains quote characters.
Explicit Modes
{name:shell}expands as one shell-escaped word and is valid only outside shell quotes. Use it when a value must be embedded in an unquoted shell word, such asfoo -xxx{name:shell}.{name:dq}expands as double-quote-safe content and is valid only inside double quotes.{name:raw}expands raw text and documents intentional unsafe shell text or word splitting.
Prefer normal shell quotes around free string or path values, such as
foo "{bar}".
Non-Shell Fields
Fields such as env, vars, workdir, sync_out, and log use raw string
placeholder expansion. Only {name} and {name:raw} are valid in those
fields.
Built-In Placeholders
{run_id} is generated once for each top-level invocation. It is a
filesystem-safe lowercase hex value reused through pre, cmd, post,
for_each, and nested recipe references.
cmd commands can use {status:pre}. post commands can use {status:pre}
and {status:cmd}. Status placeholders expand to:
0on success.- the failing exit code when available.
1for non-exit failures such as timeouts.- an empty string when that stage did not run.
For leftover CLI args, see Variadic Args.
Recipe References
Recipe references compose Shadowtree recipes without starting another Shadowtree process.
[recipes.generate]
cmd = "go generate ./..."
[recipes.test]
pre = ["@generate"]
cmd = "go test ./..."
Referenced recipes run in the current workspace. They do not create a nested sandbox and do not run their own sync-out. Sync-out belongs to the top-level invoked recipe.
Direct References
A command string that is exactly @recipe or @path:recipe invokes another
recipe:
cmd = "@build"
cmd = "@webui:gen-schema"
pre = ["@generate"]
In command-list fields such as pre and post, only strings that are exactly
@recipe or @path:recipe are direct references. Bracket-style arguments are
also part of the reference form, such as @build[mode=dev] or
@webui:gen-schema[mode=dev]. Other strings run in the shell.
Command-Position Dispatch
In sh and bash script commands, a literal @recipe command word also
dispatches a recipe:
[recipes.test]
cmd = '''
if [ -f schema.json ]; then
@generate mode=dev
fi
'''
Leading assignment prefixes apply to that recipe command’s environment:
FOO=bar @generate mode=dev
Assignment values, variables, quoted text, and ordinary command arguments do not dispatch recipes:
FOO="@generate"
$FOO
echo @generate
Cross-Config References
Use @path:recipe to invoke a recipe from another Shadowtree config:
[recipes.gen-schema]
cmd = "@webui:gen-schema"
The path is resolved relative to the referencing config file. Shadowtree loads
path/.shadowtree.toml, and the referenced recipe runs from that path.
Arguments
Use bracket-style arguments to pass named or positional args:
pre = ["@build[component=api, mode=dev]"]
cmd = "@test[package=./internal/recipe]"
post = ["@webui:gen-schema[mode=dev]"]
Comma separators split the argument list, and surrounding whitespace is
ignored. Static references such as @generate and @webui:gen-schema can be
validated by the editor; dynamic references such as @{target} are resolved at
runtime after placeholder expansion.
Recipe Resolution
Shadowtree resolves a recipe by combining built-ins, config, flags, and recipe arguments.
Resolution order:
built-in recipes for the selected profile, or for a detected profile when no
config is loaded
then config recipe overrides
then CLI flags
then trailing recipe args
Profile Built-Ins
Built-in recipes come from the selected Go Profile or Node Profile. Profile selection is described in Profile Selection.
Configs that omit profile do not receive detected built-ins. This keeps local
config recipes exact unless the config opts into a profile.
Overriding Built-Ins
Config recipes with the same name as a built-in recipe override only specified
fields, except for_each and workdir. Those scheduling fields are not
inherited from the built-in recipe; set them in the override when the custom
recipe should keep or replace built-in fan-out behavior.
profile = "go"
[recipes.test]
help = "Run generated-code tests."
workdir = "."
pre = ['go generate "{pkg}"']
cmd = 'go test "{pkg}" {@}'
[recipes.test.arguments.pkg]
type = "rel_path"
position = 1
required = true
values = "@go-packages"
The Go built-in test normally runs once per module:
for_each = @go-modules
workdir = {item}
cmd = "go test ./... {@}"
The override above runs once from the root workdir and parses CLI args as typed arguments:
shadowtree test ./internal/recipe
That run executes:
go generate ./internal/recipe
go test ./internal/recipe
Use {@} when a typed recipe should forward leftover CLI args after typed
argument values.
Recipe Fields
Recipes define named project workflows under [recipes.<name>].
[recipes.generate]
help = "Regenerate checked-in code."
pre = ["@clean-generated"]
cmd = "go generate ./..."
post = ["git diff --stat"]
sync_out = ["internal/generated"]
Core Fields
help: short text shown byshadowtree recipes,shadowtree help, and shell completion.cmd: required main command. Use a shell command string or a recipe reference such as@build.pre: commands that run before the main command.post: commands that run afterpreor the main command, including after failures.sandboxed:trueby default. Setfalseonly for recipes that should edit the host checkout directly.sync_out: sandboxed paths copied back to the host checkout after success.for_each: value provider that runs the main command once per candidate.workdir: relative working directory for the main command.log,log_stages,log_tee: recipe log output.requires: host tool checks performed before sandbox setup andpre.env: recipe-specific environment overrides.vars: recipe-specific placeholder values.shell: recipe-specific shell override.shell_prelude: recipe-specific shell code appended after top-level prelude.arguments: typed argument definitions.presets: recipe-local argument default sets selected withpreset=<name>.
One Workflow Per Recipe
Keep a recipe focused on one workflow. Use pre and post for setup and
cleanup that belongs to that workflow. Use Recipe References
to compose separate workflows without hiding them in a large shell script.
For execution order and failure behavior, see Recipe Lifecycle.
Recipe Lifecycle
Recipes run through pre, cmd, post, and optional sync-out in a fixed
order.
For a sandboxed recipe:
- Create a temporary workspace with namespace overlayfs, or copy the source tree if namespace overlayfs is unavailable.
- Open or truncate the configured log file when
logis set. - Run
precommands in order. - Run the resolved main command, or once per
for_eachcandidate when set. - Run
postcommands in order. - If all phases succeeded, sync configured or requested paths back.
- Remove the temporary workspace.
For an unsandboxed recipe, Shadowtree skips the temporary workspace and runs directly from the source checkout. Sync-out is not performed because command writes already target the host checkout.
Failure Behavior
- If a
precommand fails, the main command is skipped. postcommands still run after apreor main command failure.postcommands run as cleanup after the run context is canceled, such as by SIGINT.- Sync-out does not run after failure.
- The process exits with the first failing command’s exit code when available.
- Recursive recipe references fail with a cycle error.
Structured Stages
Use a structured pre or post table when a stage command needs execution
controls:
[recipes.benchmark.pre]
cmd = "benchmark_prepare"
timeout = "120s"
timeout is parsed as a Go duration and must be greater than zero. It limits
that one stage command. Timeout failure follows normal stage-order rules: a
failing pre skips the main command, and post commands still run.
Retry
Use @retry in a shell command position to retry flaky setup or readiness
checks:
pre = "@retry[count=30,delay=1s] benchmark_prepare"
count is the maximum number of attempts and delay is the duration between
failed attempts. Omitted values default to count=3 and delay=1s.
@retry can wrap external commands, shell functions, or literal recipe
references:
pre = "@retry[count=5] @prepare"
When retrying a shell function under set -e, return failures explicitly, for
example cleanup_step || return $?; shells suppress errexit while command
status is tested by retry logic.
Fan-Out and Workdirs
for_each runs one recipe main command once per value candidate.
[recipes.lint]
help = "Run golangci-lint in every Go module."
for_each = "@go-modules"
workdir = "{item}"
cmd = "golangci-lint run ./..."
for_each uses the same candidate format and builtins as argument values.
Builtin providers can be composed with semicolons without running a shell:
for_each = "@go-modules; @enum all='all modules'"
See Value Providers for provider details.
Item Placeholders
Per iteration, these placeholders are available to cmd and workdir:
{item}: candidate value.{item_help}: candidate help text, or empty string.{item_index}: zero-based item index.
Workdir
workdir can be used with or without for_each. It makes the main command run
from a relative path under the recipe workspace.
With for_each, workdir is expanded for each item:
for_each = "@go-modules"
workdir = "{item}"
Execution Order
pre commands run once before candidate resolution. post commands run once
after the loop, even if pre, candidate resolution, or an item command fails.
Items run sequentially; the first failing item stops later items.
For sandboxed recipes, sync_out runs once after all items and post commands
succeed. sync_out does not accept {item} placeholders.
Recipe Logging
Use recipe logging when a run should keep a copy of selected stage output.
[recipes.test]
cmd = "go test ./..."
log = "logs/test-{run_id}.log"
log_stages = ["pre", "cmd", "post"]
log_tee = true
Log Path
log is expanded with recipe placeholders, including {run_id}. It must be a
relative local path under the active config file directory, or under the source
checkout when no config path exists.
Parent directories are created with mode 0755, and the log file is opened or
truncated with mode 0644 before recipe commands start.
Logged Stages
log_stages selects which stages to write to the log. Valid values are:
precmdpost
If log_stages is omitted, Shadowtree logs all three stages. The cmd stage
includes every for_each main command item. The for_each value-provider
command itself is not cmd stage output.
Tee Behavior
log_tee defaults to true, preserving terminal output while writing selected
stage output to the log.
Set log_tee = false to send selected stage stdout and stderr only to the log.
Boundaries
Each selected logged command is preceded by a compact boundary:
== pre[0]: <script> ==
== cmd: @build ==
== post[0]: <script> ==
Long one-line commands are truncated in the boundary. Multiline script bodies
are shown as <script> and are not dumped into boundary lines.
When a selected parent stage invokes a nested recipe, nested output is written
through the parent stage writers. Nested recipe log settings do not open
another file during a recipe reference.
Tool Requirements
Declare recipe-local tool requirements when a command should fail before any recipe phase runs.
[recipes.benchmark]
cmd = "go test -bench ."
[recipes.benchmark.requires]
commands = ["docker", "openssl", "go"]
optional_commands = ["h2load"]
go_commands = { stringer = "golang.org/x/tools/cmd/stringer@latest" }
node_commands = { eslint = "eslint@^9", playwright = "@playwright/test@latest" }
Requirements are checked before sandbox setup and before pre. They are static
declarations and are not placeholder-expanded. An included or overriding recipe
that specifies requires replaces the previous requires block as a whole.
Required Commands
commands lists required executable names, not paths. Missing entries fail the
recipe in one grouped error.
recipe "benchmark" missing required tools: docker, openssl
Optional Commands
optional_commands lists executable names that should warn but not fail.
shadowtree: recipe "benchmark" optional tools not found: h2load
Go Commands
go_commands maps executable names to Go package install strings. Shadowtree
checks only for the executable on PATH; it does not run go install.
Missing tools include guidance such as:
stringer (go install golang.org/x/tools/cmd/stringer@latest)
Node Commands
node_commands maps executable names to Node package install strings.
Shadowtree checks only for the executable on PATH; it does not install
packages.
Missing tools use the detected package manager to suggest installing the CLI, for example:
npm install -g eslint@^9
pnpm add --global eslint@^9
yarn global add eslint@^9
bun add --global eslint@^9
Validation Rules
Requirement command names must be non-empty executable basenames without path
separators or surrounding whitespace. Required lists cannot duplicate names.
Duplicate executable names across commands, go_commands, and
node_commands are rejected. Overlap between required and optional names is
rejected.
Reserved Names
Some names are reserved and cannot be used as recipe names.
recipes
init
config
exec
completion
enum
glob
go-main-packages
go-modules
go-packages
help
lines
retry
vars
version
__complete
The argument-values builtins are reserved:
enumglobgo-main-packagesgo-modulesgo-packageslinesrecipesvars
Command helpers such as retry are also reserved. Future built-in @ command
identifiers are reserved as well.
run is a valid recipe name. Use shadowtree exec -- <cmd> [args...] for the
explicit-command form.
Typed Arguments
Typed arguments define validated recipe inputs under
[recipes.<name>.arguments.<arg-name>].
[recipes.build]
cmd = 'go build -o "bin/{binary}" "{pkg}" {@}'
[recipes.build.arguments.pkg]
help = "Go main package to build."
type = "string"
position = 1
default = "./cmd/shadowtree"
values = "@go-main-packages"
[recipes.build.arguments.binary]
help = "Output binary name under bin/."
type = "string"
default = "shadowtree"
Fields
help: short help text shown byshadowtree help <recipe>and shell completion.type: argument type. Defaults tostring.path_kind: completion filter forpathandrel_patharguments.position: 1-based positional index.required: whether the user must supply a value. Defaults tofalse.default: default value, type-checked before use.min: inclusive lower bound for numeric and duration arguments.max: inclusive upper bound for numeric and duration arguments.values: command or builtin that produces completion candidates.
Types
Supported type values:
stringintfloatboolpathrel_pathdurationduration:seconds
path accepts absolute and relative paths. rel_path accepts relative paths
only and rejects absolute paths and ~ home paths.
duration accepts Go duration strings such as 10s, 1500ms, and 1m30s.
duration:seconds accepts the same format only when it is an exact whole
number of seconds, and expands as base-10 integer seconds.
path_kind can be any, file, dir, or executable. The default is
any. file and executable still include directories as traversal
candidates.
Passing Arguments
Arguments can be provided positionally:
shadowtree build ./cmd/shadowtree
Arguments can be provided by name:
shadowtree build pkg=./cmd/shadowtree binary=shadowtree-dev
Arguments can also be provided with bracket-style syntax:
shadowtree 'build[pkg=./cmd/shadowtree,binary=shadowtree-dev]'
Bracket-style syntax is especially useful for recipe references and shell completion.
Validation
Resolved argument values are type-checked, range-checked, and checked against
safely checkable values builtins before any recipe command runs.
Argument values are exposed through placeholders:
cmd = 'go test "{pkg}"'
Related topics:
Value Providers
Value providers produce completion candidates for typed arguments and
for_each.
[recipes.test.arguments.pkg]
type = "rel_path"
values = "@go-packages"
Each candidate is a value, optionally followed by a tab and help text:
api API service
worker Background worker
Builtins
@enum- Literal values from arguments.
values = '@enum api worker "admin ui"'
values = "@enum api='API service' worker='Worker service'"
@lines- Reads candidates from a text file using the same
value<TAB>helpformat.
values = "@lines examples/all-features-values.txt"
@glob- Returns filesystem matches.
values = '@glob "cmd/*"'
@go-modules- Returns directories containing
go.mod, with.representing the config directory module and help from the module directive. @go-packages- Runs
go listin the config-directory module and ingo.workmodules when present. It returns package arguments such as./internal/recipe, with help from import paths. @go-main-packages- Returns package arguments for directories containing non-test Go files with
package main, with help from package comments when available. @recipes- Returns resolved recipe names.
@vars- Returns recipe placeholder and argument names.
Composition
Multiple builtin value commands can be separated with ;; their candidates are
concatenated without running a shell:
values = "@go-modules; @enum all='all modules'"
for_each = "@go-modules; @enum all='all modules'"
Resolution Paths
Relative @lines paths, @glob patterns, and Go discovery walks resolve from
the config file directory when available.
Validation
When values is safely checkable without running arbitrary commands or walking
the filesystem, explicit CLI and recipe-reference argument values must match
one of its candidates before Shadowtree runs the recipe.
Safely checkable examples include:
@enum@recipes@vars
Filesystem discovery and command-backed values remain completion and help
providers and are not run as part of argument validation.
Variadic Args
{@} forwards leftover recipe CLI args to the recipe cmd.
[recipes.test]
cmd = 'go test "{pkg}" {@}'
[recipes.test.arguments.pkg]
type = "rel_path"
position = 1
required = true
values = "@go-packages"
Run it with:
shadowtree test ./internal/recipe -run=TestResolve -count=1
The typed positional value supplies {pkg}. The leftover flags are spliced at
{@} as separate shell-quoted words.
Rules
{@}is supported only incmd.- In shell command strings, it must occupy a whole shell word.
- It is not available in
pre,post, orsync_out. - For typed recipes, positional values and known
key=valuevalues are consumed by typed arguments and excluded from{@}. - Unknown identifier
key=valuetokens remain errors. - Command flags such as
-run=TestNamepass through.
Literal Pass-Through
Use -- after typed recipe arguments to pass the following argv literally to
{@}, including option values that contain =:
shadowtree test pkg=./internal/recipe -- --flag NAME=value
Presets
Recipe-local presets set multiple typed argument defaults at once.
[recipes.benchmark]
cmd = "run-benchmark --connections {connections} --requests {requests} --runs {runs}"
[recipes.benchmark.arguments.connections]
type = "int"
default = 32
[recipes.benchmark.arguments.requests]
type = "int"
default = 1000
[recipes.benchmark.arguments.runs]
type = "int"
default = 1
[recipes.benchmark.presets.stable.arguments]
connections = 64
requests = 20000
runs = 5
Select a preset with preset=<name> after the recipe name:
shadowtree benchmark preset=stable runs=3
Explicit CLI arguments still win, so the example above uses connections=64,
requests=20000, and runs=3.
Rules
Presets are declared under:
[recipes.<name>.presets.<preset-name>.arguments]
Preset names use identifier syntax:
[A-Za-z_][A-Za-z0-9_]*
Preset argument keys must name typed arguments declared by the same recipe. Preset values are converted and type-checked the same way as argument defaults.
A recipe with presets reserves the preset recipe argument name for preset
selection.
Resolution Order
Argument values resolve in this order:
- typed argument defaults
- selected recipe preset defaults
- explicit positional or
key=valueCLI arguments
The preset=<name> selector is consumed like a typed argument and is excluded
from {@}. Tokens after -- are not preset selectors.
Profile Selection
Profiles provide built-in recipes. Supported profiles are go and node.
Profile selection precedence is:
- explicit
--profile - config
profile - marker detection only when no config is loaded
shadowtree --profile go recipes
shadowtree --profile node --print build
profile = "go"
Marker Detection
When no config file is loaded, Shadowtree walks upward from the current directory and compares the nearest profile markers:
package.jsonselectsnode.go.modorgo.workselectsgo.- If Go and Node markers are in the same directory, Go wins.
Configs that omit profile suppress detected built-ins. This preserves exact
configured recipe sets unless a config opts into a profile.
Inspecting Profiles
Use inspection commands to see exact built-ins for the current checkout:
shadowtree recipes
shadowtree --print test
shadowtree --print --expanded test
shadowtree --check --shell test
Profile-specific behavior:
Go Profile
The Go profile is selected when:
--profile gois provided- config has
profile = "go" - no config is loaded and Shadowtree detects
go.modorgo.workupward from the current directory
Built-In Recipes
build for each @go-modules: go build ./...
check @vet && @test
fix for each @go-modules when go > 1.26: go fix ./...
fmt for each @go-modules: go fmt ./...
generate for each @go-modules: go generate ./...
lint for each @go-modules: golangci-lint run ./... if available, otherwise go vet ./...
run go -C {cwd} run {command}
test for each @go-modules: go test ./...
test-race for each @go-modules: go test -race ./...
tidy for each @go-modules: go mod tidy; if go.work exists, go work sync
vet for each @go-modules: go vet ./...
Sandboxing
Built-in fix, fmt, and tidy are unsandboxed by default, so go fix,
go fmt, go mod tidy, and go work sync update the host checkout directly.
Other built-in Go recipes are sandboxed unless project config overrides them.
Module Fan-Out
Module-wide Go built-ins use:
for_each = "@go-modules"
workdir = "{item}"
The ./... package pattern is evaluated inside each module directory, not at
the repo root.
Arguments and Completion
Built-in build exposes an optional positional pkg argument with completion
from @go-main-packages.
Other package-style Go built-ins expose pkg completion from @go-packages.
fix is available when the most common go.mod directive is greater than
1.26.
fmt exposes an optional positional target from @go-packages plus
@glob "*.go".
Built-in run has:
cwd: named argument defaulting to., completed from@go-modulescommand: required positionalrel_pathargument completed from@go-main-packagesplus@glob "*.go"
command is interpreted by go run after go -C {cwd}, so non-default cwd
values use paths relative to that directory.
Node Profile
The Node profile is selected when:
--profile nodeis provided- config has
profile = "node" - no config is loaded and Shadowtree detects the nearest
package.jsonupward from the current directory
Node built-ins resolve the nearest package.json directory and generate shell
commands that cd there before invoking the package manager or tool. This
makes subdirectory invocation run against the package root.
Every Node built-in recipe has sandboxed = false by default because package
manager and framework commands commonly mutate lockfiles, dependency state,
caches, and generated outputs.
Package Manager Detection
Detection order:
packageManagerprefix:pnpm,yarn,bun, ornpm- lockfiles:
pnpm-lock.yaml,yarn.lock,bun.lockb,bun.lock,package-lock.json,npm-shrinkwrap.json - default
npm
Built-In Recipes
install npm|pnpm|yarn|bun install
dev package script dev, or inferred framework dev command
build package script build, or inferred framework build command
start package script start, or inferred framework start/preview command
test package script test, or Vitest/Jest/Playwright/Bun fallback
lint package script lint, or ESLint/Oxlint/Biome
fmt package script fmt/format, or Prettier/Oxfmt/Biome
typecheck package script typecheck/type-check, or detected type checkers
check available lint, typecheck, and test recipes in that order
Package scripts fill gaps without overriding predefined Node recipe names.
Command Forms
- Package scripts run
<pm> run <script> -- {@}. - Tool commands run
npm exec -- <bin> ... {@},pnpm exec <bin> ... {@},yarn exec <bin> ... {@}, orbunx <bin> ... {@}. - Bun projects without a test script use
bun test {@}when Vitest is not installed.
Framework Inference
For dev, build, and start:
next:next dev,next build,next startvite:vite,vite build,vite previewnuxt:nuxt dev,nuxt build,nuxt previewastro:astro dev,astro build,astro preview@sveltejs/kit:vite,vite build,vite preview
Test Inference
- Script
testwins. - Bun projects use installed
vitestfirst, otherwisebun test. - Other projects use installed
vitest, thenjest, thenplaywright testwhen@playwright/testis installed.
Lint Inference
- Script
lintwins. - ESLint markers:
eslintdependency,eslint.config.*,.eslintrc*, or packageeslintConfig; commandeslint .. - Oxlint markers:
oxlintdependency,oxlint.config.*,.oxlintrc.json, or.oxlintrc.jsonc; commandoxlint. - Biome markers:
@biomejs/biomedependency,biome.json, orbiome.jsonc; commandbiome lint ..
Format Inference
- Script
fmtwins, then scriptformat. - Prettier markers:
prettierdependency,prettier.config.*,.prettierrc*, or packageprettier; commandprettier --write .. - Oxfmt markers:
oxfmtdependency,oxfmt.config.*,.oxfmtrc.json, or.oxfmtrc.jsonc; commandoxfmt. - Biome markers:
@biomejs/biomedependency,biome.json, orbiome.jsonc; commandbiome format --write ..
Typecheck Inference
- Script
typecheckwins, then scripttype-check. - Otherwise Shadowtree runs every detected checker in stable order:
vue-tsc --noEmit,svelte-check, thentsc --noEmit. tsc --noEmitis included whentypescriptis installed ortsconfig.jsonexists.
Script Recipe Names
Before a package script becomes a recipe name, Shadowtree replaces : and
every character outside [A-Za-z0-9._-] with -, collapses repeated -, trims
leading and trailing -, and skips empty or reserved names.
If multiple scripts normalize to the same recipe name, the script whose
original name already equals the normalized name wins; otherwise the
lexicographically first original script name wins. For example, package script
lint:fix becomes recipe lint-fix, but the generated command still runs the
original script key lint:fix.
Editor Support
Shadowtree includes a shared JSON Schema for Shadowtree TOML config files plus
editor integration files for Zed and VS Code under editors/.
The Zed extension provides a dedicated Shadowtree TOML language, syntax
highlighting, Shadowtree-specific highlighting, shell semantic highlighting for
script-valued fields, and LSP completion, diagnostics, and semantic tokens.
The bundled Zed language association covers .shadowtree.toml; TOML files
under .shadowtree/ need the user file_types setting below.
The VS Code extension binds the shared schema to Shadowtree TOML files through Even Better TOML. Completion, hover, and validation come from that extension.
Install the Zed language server with:
go install github.com/yusing/shadowtree/cmd/shadowtree-lsp@latest
See the full spec, the Zed extension README, and the VS Code extension README for implementation details.
VS Code Config
"files.associations": {
".shadowtree.toml": "toml",
"**/.shadowtree.toml": "toml",
"*.shadowtree.toml": "toml",
"**/*.shadowtree.toml": "toml",
".shadowtree/*.toml": "toml",
"**/.shadowtree/*.toml": "toml"
},
"evenBetterToml.schema.associations": {
"^file://.*/[^/]*\\.shadowtree\\.toml$": "https://raw.githubusercontent.com/yusing/shadowtree/main/schemas/shadowtree.schema.json",
"^file://.*/\\.shadowtree/.*\\.toml$": "https://raw.githubusercontent.com/yusing/shadowtree/main/schemas/shadowtree.schema.json"
}
Zed Config
Use this when Zed classifies a Shadowtree config as plain TOML, or when you
keep Shadowtree config fragments under .shadowtree/*.toml.
"file_types": {
"Shadowtree TOML": [
".shadowtree.toml",
"**/.shadowtree.toml",
"*.shadowtree.toml",
"**/*.shadowtree.toml",
".shadowtree/*.toml",
"**/.shadowtree/*.toml"
]
},
"languages": {
"TOML": {
"language_servers": ["shadowtree-lsp", "..."]
}
}
Development
This project uses Shadowtree for its own development tasks. Before installing a
shadowtree binary, run the local CLI with go run:
go run ./cmd/shadowtree recipes
go run ./cmd/shadowtree test
go run ./cmd/shadowtree check
go run ./cmd/shadowtree build
go run ./cmd/shadowtree install
After installing or building shadowtree, use the shorter form:
shadowtree test
shadowtree check
shadowtree build
shadowtree install
shadowtree fmt
shadowtree tidy
shadowtree install-skill
Recipes that intentionally change the host checkout set sandboxed = false in
.shadowtree.toml.
The install recipe uses default go install, honors FISH_CONFIG_DIR and
FISH_COMPLETIONS_DIR, generates completion from shadowtree on PATH,
installs fish completion when fish is available, and appends single guarded
eval lines to ~/.bashrc and ~/.zshrc when those shells are available.
The install-skill recipe installs every local agent skill from
.agents/skills/ to ${AGENTS_SKILLS_DIR:-$HOME/.agents/skills}.
Shadowtree Spec
Shadowtree is a small project-local development task runner for repeatable checks, builds, generation, cleanup, install workflows, and other project commands that benefit from one inspectable recipe interface. Sandboxed recipes run in a disposable workspace so commands do not mutate the host checkout unless the recipe or invocation explicitly asks for that.
This document describes the behavior currently implemented by the project.
Goals
- Run common development tasks through a simple recipe interface.
- Keep command writes isolated from the host checkout unless explicitly synced.
- Avoid triggering editor/LSP reindexing for generated or temporary files.
- Provide useful defaults for Go and Node projects.
- Keep configuration small and exact, using shell strings for commands and typed arguments plus placeholders for validated defaults and CLI forwarding.
- Support dynamic shell completion from resolved recipes.
- Provide editor-facing schema and syntax support for TOML configuration.
- Let the project use Shadowtree for its own development tasks.
Non-Goals
- Shadowtree is not a complete untrusted-code security sandbox.
- Shadowtree does not require reflinks.
- Shadowtree does not currently provide Docker, remote execution, matrix jobs, watch mode, or persistent named sessions.
- Shadowtree does not try to cover every language-specific workflow. Supported
profiles provide focused defaults, and projects can add more dynamic argument
completion with recipe
valuescommands. - Shadowtree’s editor integrations do not replace runtime config validation. The CLI loader remains authoritative.
Isolation Model
For each sandboxed run, Shadowtree creates a temporary workspace:
/tmp/shadowtree-*/workspace
On Linux, Shadowtree uses overlayfs inside a user and mount namespace by
default. Commands run at the source checkout path inside that namespace, so
tools such as go test see a stable working directory while writes land in the
overlay upperdir instead of the host checkout. Shadowtree hides metadata entries
from the lower tree. When namespace overlayfs is unavailable, Shadowtree warns
and falls back to copying the current source directory into the temporary
workspace and running commands there. On filesystems that support it, fallback
copy may use reflinks as an optimization.
By default:
- Files written by commands stay in the sandbox workspace.
- The temporary workspace is removed after the run.
- The host checkout is not changed.
Exceptions for sandboxed runs:
--sync-out PATHmirrors selected paths back after a successful recipe.- Recipe-level
sync_outmirrors selected paths back after a successful recipe. --sync-out-allcopies the whole workspace back after a successful recipe.
Unsandboxed recipes set sandboxed = false and run directly in the host
checkout. --sync-out, sync_out, and --sync-out-all only apply to sandboxed
execution.
Shadowtree intentionally skips .git, .shadowtree, and .shadowtree.* while
preparing sandboxed workspaces. Because .git is skipped, Go build recipes that
require VCS stamping should use -buildvcs=false.
CLI
shadowtree [flags] <recipe> [args...]
shadowtree [flags] exec -- <cmd> [args...]
shadowtree help [recipe [color=false]]
shadowtree recipes
shadowtree config
shadowtree init [path]
shadowtree completion bash|fish|zsh
shadowtree __complete fish <words...>
shadowtree __complete bash <cursor> <line> [current]
shadowtree __complete zsh <words...>
__complete is internal and used by generated shell completion.
Global Flags
--config PATH use an explicit config file
--profile PROFILE use a profile; supported profiles are go and node
--sync-out PATH copy path back after success; repeatable or comma-separated
--sync-out-all copy the entire workspace back after success
--print print the resolved plan without running
--expanded with --print, include expanded scripts and resolved values
--check validate the resolved recipe without running
--shell with --check, parse expanded shell scripts
--verbose show workspace and compact command boundaries
--help show basic CLI help
--version print the version
Global flags are parsed before the command or recipe name. Arguments after the recipe name are passed to the recipe’s main command.
Config Discovery
Shadowtree discovers config upward from the current directory until the git root or filesystem root.
Discovery order:
.shadowtree.toml
An explicit --config PATH bypasses discovery.
shadowtree init writes .shadowtree.toml by default. A custom path can be
provided:
shadowtree init ./ci/shadowtree.toml
Config Schema
Top-level fields:
include = ["./common.shadowtree.toml"]
profile = "go"
shell = "sh"
shell_prelude = '''
shared_function() {
echo ok
}
'''
[env]
KEY = "value"
[vars]
NAME = "static value"
[var_commands]
DYNAMIC_NAME = "cmd arg"
[recipes.<name>]
help = "Short recipe help text."
sandboxed = true
for_each = "cmd arg"
workdir = "{item}"
pre = ["cmd arg"]
cmd = "cmd {placeholders}"
post = ["cmd arg"]
sync_out = ["path/from/project/root"]
log = "logs/{run_id}.log"
log_stages = ["pre", "cmd", "post"]
log_tee = true
[recipes.<name>.requires]
commands = ["go"]
optional_commands = ["h2load"]
go_commands = { stringer = "golang.org/x/tools/cmd/stringer@latest" }
node_commands = { eslint = "eslint@^9" }
[recipes.<name>.vars]
NAME = "recipe override"
[recipes.<name>.env]
KEY = "value"
[recipes.<name>.arguments.<arg-name>]
help = "Short argument help text."
type = "string"
position = 1
required = false
default = "value"
values = "cmd arg"
include entries are TOML config file paths resolved relative to the config
file that contains them. Included files are merged before the including file;
later includes override earlier includes, and the including file overrides all
included files. Includes are global mixins: top-level profile, shell,
shell_prelude, vars, var_commands, env, and recipes all participate
in the effective config.
When multiple files define shell_prelude, Shadowtree concatenates included
preludes first, then the including file’s prelude. If a.shadowtree.toml
includes b.shadowtree.toml, a’s prelude and all recipes can use shell
variables and functions created by b’s prelude. b’s prelude body cannot read
variables assigned later by a while the prelude is being evaluated.
Same-name recipes from includes are field-merged, with the including file
winning for fields it sets. There is no deletion syntax for inherited recipes,
arguments, vars, or env keys in this version. Use @path:recipe instead of
include when a recipe should stay isolated and run from another config’s
directory.
Recipe Fields
Commands are shell strings, recipe reference strings, or command-list entries containing those strings. Shell strings are executed with the configured shell:
shell = "bash"
[recipes.example]
cmd = '''
set -euo pipefail
echo "hello"
'''
If shell is not set, Shadowtree uses sh.
Supported config shells are sh and bash; fish remains supported only as a
generated CLI completion shell.
Command strings run through the configured shell after placeholder expansion.
A string that is exactly @recipe or @path:recipe invokes another recipe;
other strings run in the shell. Defaults belong in typed arguments,
referenced from cmd.
Default {name} expansion is raw text when unquoted, before the shell parses
the script. In single-quoted or double-quoted shell context, {name} is escaped
for that quote context, so "{name}", '{name}', and "https://{host}"
remain one shell word. Escaping is context-aware, not type-aware.
Explicit placeholder modes are available in shell script strings. Prefer normal
shell quotes for free string or path values, for example foo "{bar}".
{name:shell}expands as one shell-escaped word and is valid only outside shell quotes. Use it when the value must be embedded in an unquoted shell word, for examplefoo -xxx{name:shell}.{name:dq}expands as double-quote-safe content and is valid only inside double quotes.{name:raw}expands raw text and documents intentional unsafe shell text or word splitting.
In non-shell fields such as env, vars, workdir, sync_out, and log,
{name} and {name:raw} use raw string substitution; shell-specific modes are
invalid. {@} is only special in cmd, where it splices leftover recipe CLI
args as separate shell-quoted words and must occupy a whole shell word.
Example:
[recipes.gen-swagger]
cmd = "go generate ./internal/api"
[recipes.test]
pre = ["@gen-swagger"]
cmd = 'go test "{pkg}" {@}'
[recipes.test.arguments.pkg]
type = "rel_path"
required = true
values = "@go-packages"
[recipes.build.arguments.project]
values = "@list-build-targets"
The text after @ is the referenced recipe name. Bracket-style arguments pass
CLI arguments to the referenced recipe:
pre = ["@build-api[service=public]"]
For sh and bash script commands, including cmd, pre, post,
for_each, argument values, and shell_prelude, a literal @recipe command
word also invokes the referenced recipe directly. This works anywhere a normal
shell command can run, including conditionals:
[recipes.test]
cmd = '''
if [ -f schema.json ]; then
@generate mode=dev
fi
'''
Only literal command-position references dispatch recipes. Leading assignment prefixes apply to that recipe command’s environment. Assignment values, expanded variables, quoted text, and ordinary command arguments do not dispatch recipes:
FOO=bar @generate mode=dev # recipe dispatch with FOO in @generate's environment
FOO="@generate" # assignment only
$FOO # normal shell command lookup, not a recipe dispatch
echo @generate # normal argument text
Use @path:recipe to invoke a recipe from another Shadowtree config. The path
is resolved relative to the directory containing the referencing
.shadowtree.toml, and the target config is loaded from
path/.shadowtree.toml. The referenced recipe runs from that target directory:
[recipes.gen-schema]
cmd = "@webui:gen-schema"
In command-list fields such as pre and post, only strings that are exactly
@recipe are direct recipe references:
pre = ["echo 123", "@gen-swagger"]
Non-reference strings in pre and post still run in the shell, so a literal
command-position @recipe inside them also dispatches a recipe:
post = ["if [ -f schema.json ]; then @publish-schema; fi"]
Recipe references can use bracket-style arguments. Comma separators split the argument list, and surrounding whitespace is ignored:
pre = ["@build[component=godoxy, mode=dev]"]
cmd = "@test[package=./internal/recipe]"
post = ["@webui:gen-schema[mode=dev]"]
pre and post may also be structured stage command tables when a command
needs execution controls:
[recipes.benchmark.pre]
cmd = "benchmark_prepare"
timeout = "120s"
timeout is parsed as a Go duration and must be greater than zero. It limits
that one stage command. Timeout failure follows the same stage-order rules as
other command failures: a failing pre skips the main command, and post
commands still run after pre or main failure.
In sh and bash script commands, @retry is a built-in command helper for
flaky readiness checks:
pre = "@retry[count=30,delay=1s] benchmark_prepare"
count is the maximum number of attempts and delay is the duration to sleep
between failed attempts. Defaults are count=3 and delay=1s. The helper runs
the remaining command words again until they succeed or attempts are exhausted.
It can wrap external commands, shell functions, or literal recipe references
such as @retry[count=5] @prepare. Because @retry, @recipe, and built-in
recipe references remain normal shell commands inside script strings, they can
be composed with operators such as && and ||.
When retrying a shell function under set -e, the function must return
failures explicitly, for example cleanup_step || return $?. This follows shell
semantics: errexit is suppressed while command status is being tested by
retry, if, &&, or ||.
Placeholders can be used in recipe references. Static references such as
@gen-swagger and @webui:gen-schema can be validated by the editor; dynamic
references such as @{target} and @{target_path}:{target_recipe} are
resolved at run time after placeholder expansion.
Top-level shell_prelude is prepended to every script command. It is intended
for shared shell functions and variables:
shell_prelude = '''
require_tool() {
command -v "$1" >/dev/null 2>&1 || {
echo "$1 is required" >&2
exit 1
}
}
'''
Top-level vars are static placeholder values shared by every recipe.
var_commands are evaluated from the source checkout when recipes are resolved
for execution, printing, or help; surrounding whitespace is trimmed from stdout
and the result becomes a shared placeholder value. Recipe-level vars override
top-level vars. Static vars may reference other static or dynamic vars with the
same {name} placeholder syntax.
Top-level and recipe-specific env values are expanded with the same
placeholders when a recipe is resolved. shell_prelude is expanded with the
same shell-aware placeholder escaping before it is prepended to script
commands.
help- Short human-facing help text. Used by
shadowtree help,shadowtree recipes, and shell completion. sandboxed- Whether to run the recipe in a temporary workspace. Defaults to
true.falseruns the recipe directly in the source checkout and skips sync-out. cmd- Required shell string or
@recipereference for the main command. for_each- Optional value-provider command. When set,
cmdruns once per candidate value. It accepts the same value-provider forms as argumentvalues, including@enum,@lines,@glob,@go-modules,@go-packages,@go-main-packages,@recipes,@vars, command output, and recipe references. workdir- Optional relative workspace path used as the working directory for the main
command. With
for_each, it is expanded per item and can use{item},{item_help}, and{item_index}. pre- Commands run before the main command, in order. May be an array of command
strings or one structured table with
cmdand optionaltimeout. post- Commands run after the main command, in order. May be an array of command
strings or one structured table with
cmdand optionaltimeout. env- Recipe-specific environment overrides.
sync_out- Paths mirrored back to the host checkout after a successful sandboxed recipe.
If a selected path is deleted in the sandbox, it is deleted from the host
checkout. Ignored when
sandboxed = false. log- Optional recipe log file path. The path is expanded with recipe placeholders,
including
{run_id}. It must be a relative local path under the active config file directory, or under the source checkout when no config path exists. Parent directories are created with mode0755, and the log file is opened/truncated with mode0644before recipe commands start. log_stages- Optional list of stages to write to
log. Valid values arepre,cmd, andpost; omitting the field logs all three. Thecmdstage includes everyfor_eachmain command item. Thefor_eachvalue-provider command itself is notcmdstage output. log_tee- Optional boolean. Defaults to
true, which preserves terminal stdout/stderr while also writing selected stage output to the log. Whenfalse, selected stage stdout/stderr are written only to the log.
Each selected logged command is preceded by a boundary line of the form
== stage: command ==, for example == pre[0]: <script> ==,
== cmd: @build ==, or == post[0]: <script> ==. Long one-line commands are
truncated in the boundary. Multiline scripts are shown as <script>; full
multiline script bodies are never dumped into boundary lines.
requires- Optional recipe-local tool requirements checked before sandbox setup and
before any
precommand. Requirements are static declarations and are not placeholder-expanded. An included or overriding recipe that specifiesrequiresreplaces the previousrequiresblock as a whole. requires.commands- Required executable names, not paths. Missing entries fail the recipe in one
grouped error such as
recipe "benchmark" missing required tools: docker, openssl. requires.optional_commands- Optional executable names. Missing entries print one stderr warning and the
recipe continues, for example
shadowtree: recipe "benchmark" optional tools not found: h2load. requires.go_commands- Required Go-installable tools keyed by executable name with package strings
as values, for example
stringer = "golang.org/x/tools/cmd/stringer@latest". Shadowtree checks only for the executable onPATH; it does not rungo install. Missing entries fail with guidance such asstringer (go install golang.org/x/tools/cmd/stringer@latest). requires.node_commands- Required Node-installable CLI tools keyed by executable name with package
strings as values, for example
eslint = "eslint@^9". Shadowtree checks only for the executable onPATH; it does not install packages. Missing entries use the detected package manager to suggest installing the CLI, such asnpm install -g eslint@^9,pnpm add --global eslint@^9,yarn global add eslint@^9, orbun add --global eslint@^9.
Requirement command names must be non-empty executable basenames without path
separators or surrounding whitespace. commands and optional_commands cannot
contain duplicates within each list. go_commands and node_commands keys use
the same executable basename rules, excluding run_id, and package values must
be non-empty strings without surrounding whitespace. Duplicate executable names
across commands, go_commands, and node_commands are rejected. Overlap
between required and optional names is rejected.
Recipe Arguments
Recipes can define typed arguments under:
[recipes.<name>.arguments.<arg-name>]
Argument fields:
help- Short help text used by
shadowtree help <recipe>and shell completion. type- Optional type. Supported values are
string,int,float,bool,path,rel_path,duration, andduration:seconds. The default isstring.pathaccepts absolute and relative paths.rel_pathaccepts relative paths only and rejects absolute paths and~home paths.durationaccepts Go duration strings parsed bytime.ParseDuration, such as10s,1500ms, and1m30s, and preserves the configured or CLI text when expanded.duration:secondsaccepts the same duration format only when it is an exact whole number of seconds, and expands as base-10 integer seconds. path_kind- Optional completion filter for
pathandrel_patharguments. Supported values areany,file,dir, andexecutable. The default isany.fileandexecutablestill include directories as traversal candidates. position- Optional 1-based positional index. Arguments with a position can be supplied positionally.
required- Whether the argument must be supplied by the user. Defaults to
false. default- Optional default value. Defaults are type-checked.
min- Optional inclusive lower bound for
int,float,duration, andduration:secondsarguments. Values are converted and type-checked the same way as argument values. Duration bounds use Go duration strings. max- Optional inclusive upper bound for
int,float,duration, andduration:secondsarguments.maxmust be greater than or equal tominwhen both are set. values- Optional command that produces completion candidates for this argument. Each
output line is a value, optionally followed by a tab and help text. The
command is a shell string. It can be an
@recipereference, or an argument-values builtin:
values = '@enum api worker "admin ui"'
values = "@enum api='API service' worker='Worker service'"
values = '@lines config/targets.txt'
values = '@glob "cmd/*"'
values = "@go-modules; @enum all='all modules'"
values = '@go-packages'
values = '@go-main-packages'
values = '@recipes'
values = '@vars'
@enum returns literal values from its arguments. Enum arguments in
value=help text form attach help when the help side contains whitespace;
quote the help side, for example @enum all='all modules'.
Single-token values such as GOOS=linux remain
literal values. @lines reads candidates from a text file, using the same
value<TAB>help line format. @glob returns filesystem matches. @go-modules
returns directories containing go.mod, with . representing the config
directory module and help from the module directive. @go-packages runs
go list in the config-directory module and, when go.work is present, in
modules listed by the workspace. It returns package arguments such as
./internal/recipe, with help from import paths. @go-main-packages returns
package arguments for directories containing non-test Go files with
package main, with help from package comments when available. Multiple builtin
value commands in a values field can be separated with ;; their candidates
are concatenated without running a shell. @recipes returns resolved recipe
names.
@vars returns recipe placeholder and argument names. Relative @lines paths,
@glob patterns, and Go discovery walks resolve from the config file directory
when available.
When values is a builtin that can be checked without running arbitrary
commands or walking the filesystem, such as @enum, @recipes, or @vars,
explicit CLI and recipe-reference argument values must match one of its
candidates. Filesystem discovery and command-backed values remain completion
and help providers and are not run as part of argument validation.
Example:
[recipes.build]
help = "Build a Go package."
cmd = 'go build -o "bin/{binary}" "{project}" {@}'
sync_out = ["bin/{binary}"]
[recipes.build.arguments.project]
help = "Go main package to build."
type = "string"
position = 1
default = "./cmd/shadowtree"
values = "@go-main-packages"
[recipes.build.arguments.binary]
help = "Output binary name under bin/."
type = "string"
default = "shadowtree"
Arguments can be provided positionally:
shadowtree build ./cmd/shadowtree
Arguments can be provided by name:
shadowtree build project=./cmd/shadowtree binary=shadowtree-dev
Arguments can also be provided with bracket-style syntax:
shadowtree 'build[project=./cmd/shadowtree,binary=shadowtree-dev]'
Bracket-style syntax is preferred for shell completion, especially in fish.
Recipe-local presets can set multiple typed argument defaults. They are declared under:
[recipes.<name>.presets.<preset-name>.arguments]
<arg-name> = <scalar>
Preset names use identifier syntax ([A-Za-z_][A-Za-z0-9_]*). Preset
argument keys must name typed arguments declared by the same recipe, and values
are converted and type-checked the same way as argument default values. A
recipe with presets reserves the preset recipe argument name for preset
selection.
Select a preset with preset=<preset-name> after the recipe name:
[recipes.benchmark]
cmd = "run-benchmark --connections {connections} --requests {requests} --runs {runs}"
[recipes.benchmark.arguments.connections]
type = "int"
default = 32
[recipes.benchmark.arguments.requests]
type = "int"
default = 1000
[recipes.benchmark.arguments.runs]
type = "int"
default = 1
[recipes.benchmark.presets.stable.arguments]
connections = 64
requests = 20000
runs = 5
shadowtree benchmark preset=stable runs=3
Argument values are resolved in this order: typed argument defaults, selected
recipe preset defaults, then explicit positional or key=value CLI arguments.
The preset=<name> selector is consumed like a typed argument and is excluded
from {@}. Tokens after -- are not preset selectors.
Resolved argument values are type-checked, range-checked, and, for safely
checkable values builtins, checked against the accepted candidate set before
any recipe command runs.
Argument values are exposed to recipe commands through {name} placeholders.
Shared vars are exposed through the same placeholder syntax. Placeholders are
expanded in vars, env, cmd, pre, post, for_each, shell_prelude,
workdir, sync_out, and log.
Shell parameter expansion such as ${HOME} is not treated as a Shadowtree
placeholder.
In shell command strings, placeholders inside single or double quotes are
escaped for that quote context, so "{name}" and '{name}' stay one shell word
even when the value contains quote characters.
{run_id} is built in. Shadowtree generates one filesystem-safe lowercase hex
run ID for each top-level invocation and reuses it for pre, cmd, post,
for_each expansion, and nested @recipe calls. The name run_id is reserved
and cannot be declared in top-level vars, top-level var_commands, recipe
vars, or recipe arguments.
cmd commands can use {status:pre}, and post commands can use
{status:pre} and {status:cmd} to inspect prior stage status. They expand to
0 when the stage succeeds, the failing exit code when available, 1 for
non-exit failures such as timeouts, and an empty string when that stage did not
run.
{@} is a variadic placeholder for leftover recipe CLI args. It must be a
whole argument item in argv-style cmd values, or a whole shell word in script
cmd values; Shadowtree splices each leftover CLI arg at that position. It is
supported only in cmd, not in pre, post, or sync_out. For recipes with typed
arguments, positional argument values and known key=value argument values
are consumed by those arguments and excluded from {@}. Unknown identifier
key=value tokens remain errors; command flags such as -run=TestName pass
through. Use -- after typed recipe arguments to pass the following argv
literally to {@}, including option values that contain =:
[recipes.test]
cmd = 'go test "{pkg}" {@}'
[recipes.test.arguments.pkg]
type = "rel_path"
position = 1
required = true
values = "@go-packages"
shadowtree test ./internal/recipe -run=TestResolve -count=1
shadowtree test pkg=./internal/recipe -- --flag NAME=value
Fan-Out Recipes
for_each runs a recipe’s main command once per value candidate:
[recipes.lint]
help = "Run golangci-lint in every Go module."
for_each = "@go-modules"
workdir = "{item}"
cmd = "golangci-lint run ./..."
for_each uses the same candidate format and builtins as argument values.
Builtin providers can be composed with semicolons without running a shell:
for_each = "@go-modules; @enum all='all modules'"
Per iteration, these placeholders are available to cmd and workdir:
{item}: candidate value.{item_help}: candidate help text, or empty string.{item_index}: zero-based item index.
workdir can be used without for_each; it makes the main command run from a
relative path under the recipe workspace. With for_each, workdir is
expanded for each item. pre commands run once before candidate resolution.
post commands run once after the loop, even if pre, candidate resolution,
or an item command fails. Items run sequentially; the first failing item stops
later items. For sandboxed recipes, sync_out runs once after all items and
post commands succeed. sync_out does not accept {item} placeholders.
Recipe Resolution
Recipe resolution order:
built-in recipes for the selected profile, or for a detected profile when no
config is loaded
then config recipe overrides
then CLI flags
then trailing recipe args
Config recipes with the same name as a built-in recipe override only specified
fields, except for_each and workdir. Those scheduling fields are not
inherited from the built-in recipe; set them in the override when the custom
recipe should keep or replace built-in fan-out behavior.
Example:
[recipes.test]
help = "Run generated-code tests."
workdir = "."
pre = ['go generate "{pkg}"']
cmd = 'go test "{pkg}" {@}'
[recipes.test.arguments.pkg]
type = "rel_path"
position = 1
required = true
values = "@go-packages"
The built-in test recipe normally uses module fan-out from the Go profile:
for_each = @go-modules
workdir = {item}
cmd = "go test ./... {@}"
The built-in test recipe runs once per module. In each module workdir it runs:
go test ./...
With the override above, the recipe runs once from the root workdir and CLI args are parsed by the custom typed argument:
shadowtree test ./internal/recipe
runs:
go generate ./internal/recipe
go test ./internal/recipe
For recipes with typed arguments, CLI args are parsed as argument values
instead. Placeholders in cmd expand from argument defaults and supplied
values. Use {@} in cmd when a typed recipe should also forward leftover CLI
args after its typed argument values.
Execution Semantics
For a sandboxed recipe:
- Create a temporary workspace with namespace overlayfs, or copy the source tree if namespace overlayfs is unavailable.
- Open/truncate the configured log file, when
logis set. - Run
precommands in order. - Run the resolved main command, or once per
for_eachcandidate when set. - Run
postcommands in order. - If all phases succeeded, sync configured/requested paths back.
- Remove the temporary workspace.
When a command is an @recipe or @path:recipe reference, Shadowtree runs the
referenced recipe’s pre, main command, and post directly in the current
workspace. It does not start another Shadowtree process, create a nested
sandbox, or perform the referenced recipe’s sync-out. Cross-config references
load path/.shadowtree.toml relative to the referencing config and run from the
target path inside the current host checkout or current sandbox. Sync-out is
performed only for the top-level invoked recipe after all phases succeed.
When a selected parent log stage invokes a nested recipe, all nested output is
written through the parent stage writers. Nested recipe log settings do not
open another file during a recipe reference.
Recursive recipe references fail with a cycle error.
Failure behavior:
- If a
precommand fails, the main command is skipped. postcommands still run after apreor main command failure.postcommands run as cleanup after the run context is canceled, such as by SIGINT.- Sync-out does not run after failure.
- The process exits with the first failing command’s exit code when available.
With namespace overlayfs, commands run from the source checkout path inside the namespace. With copy fallback, commands run from the copied temporary workspace root.
For an unsandboxed recipe, Shadowtree skips the temporary workspace and runs
pre, main, and post commands directly from the source checkout. Sync-out is
not performed because command writes already target the host checkout.
Reserved Recipe Names
The following names are reserved and cannot be used as recipes:
recipes
init
config
exec
completion
enum
glob
go-main-packages
go-modules
go-packages
help
lines
retry
vars
version
__complete
The argument-values builtins (enum, glob, go-main-packages, go-modules,
go-packages, lines, recipes, and vars) and command helpers such as
retry are reserved as recipe names. Future built-in @ command identifiers
are also reserved.
Built-In Profiles
Supported profiles are go and node. Profile selection precedence is:
- explicit
--profile; - config
profile; - marker detection only when no config is loaded.
Configs that omit profile suppress detected built-ins. This preserves exact
configured recipe sets unless a config opts into a profile.
When marker detection is active, Shadowtree walks upward from the current directory and compares the nearest profile markers:
package.jsonselectsnode.go.modorgo.workselectsgo.- If Go and Node markers are in the same directory, Go wins.
Built-In Go Profile
The Go profile is selected when:
--profile gois provided, or- config has
profile = "go", or - no config is loaded and Shadowtree detects
go.modorgo.workupward from the current directory.
Built-in Go recipes:
build for each @go-modules: go build ./...
check @vet && @test
fix for each @go-modules when go > 1.26: go fix ./...
fmt for each @go-modules: go fmt ./...
generate for each @go-modules: go generate ./...
lint for each @go-modules: golangci-lint run ./... if available, otherwise go vet ./...
run go -C {cwd} run {command}
test for each @go-modules: go test ./...
test-race for each @go-modules: go test -race ./...
tidy for each @go-modules: go mod tidy; if go.work exists, go work sync
vet for each @go-modules: go vet ./...
Built-in fix, fmt, and tidy are unsandboxed by default, so go fix,
go fmt, go mod tidy, and go work sync update the host checkout directly.
Other built-in Go recipes are sandboxed unless project config overrides them. Module-wide Go built-ins use
for_each = "@go-modules" and workdir = "{item}"; the ./... package pattern
is evaluated inside each module directory, not at the repo root. Built-in
build exposes an optional positional pkg argument with shell completion from
@go-main-packages; other package-style Go built-ins expose pkg completion
from @go-packages; fix is available when the most common
go.mod directive is greater than 1.26. fmt exposes an optional positional target
from @go-packages plus @glob "*.go". Built-in run has a named cwd
argument defaulting to . and a required positional command argument with
rel_path type. cwd completes from @go-modules; command completes from
@go-main-packages plus @glob "*.go". command is interpreted by go run
after go -C {cwd}, so non-default cwd values use paths relative to that
directory.
Built-In Node Profile
The Node profile is selected when:
--profile nodeis provided, or- config has
profile = "node", or - no config is loaded and Shadowtree detects the nearest
package.jsonupward from the current directory.
Node built-ins resolve the nearest package.json directory and generate shell
commands that cd there before invoking the package manager or tool. This
makes subdirectory invocation run against the package root. Every Node built-in
recipe has sandboxed = false by default because package-manager and framework
commands commonly mutate lockfiles, dependency state, caches, and generated
outputs.
Package manager detection:
packageManagerprefix:pnpm,yarn,bun, ornpm;- lockfiles in order:
pnpm-lock.yaml,yarn.lock,bun.lockb,bun.lock,package-lock.json,npm-shrinkwrap.json; - default
npm.
Command forms:
- Package scripts run
<pm> run <script> -- {@}. - Tool commands run
npm exec -- <bin> ... {@},pnpm exec <bin> ... {@},yarn exec <bin> ... {@}, orbunx <bin> ... {@}. - Bun projects without a test script use
bun test {@}when Vitest is not installed.
Default Node recipes:
install npm|pnpm|yarn|bun install
dev package script dev, or inferred framework dev command
build package script build, or inferred framework build command
start package script start, or inferred framework start/preview command
test package script test, or Vitest/Jest/Playwright/Bun fallback
lint package script lint, or ESLint/Oxlint/Biome
fmt package script fmt/format, or Prettier/Oxfmt/Biome
typecheck package script typecheck/type-check, or detected type checkers
check available lint, typecheck, and test recipes in that order
Framework inference for dev, build, and start:
next:next dev,next build,next start.vite:vite,vite build,vite preview.nuxt:nuxt dev,nuxt build,nuxt preview.astro:astro dev,astro build,astro preview.@sveltejs/kit:vite,vite build,vite preview.
Test inference:
- Script
testwins. - Bun projects use installed
vitestfirst, otherwisebun test. - Other projects use installed
vitest, thenjest, thenplaywright testwhen@playwright/testis installed.
Lint inference:
- Script
lintwins. - ESLint markers:
eslintdependency,eslint.config.*,.eslintrc*, or packageeslintConfig; commandeslint .. - Oxlint markers:
oxlintdependency,oxlint.config.*,.oxlintrc.json, or.oxlintrc.jsonc; commandoxlint. - Biome markers:
@biomejs/biomedependency,biome.json, orbiome.jsonc; commandbiome lint ..
Format inference:
- Script
fmtwins, then scriptformat. - Prettier markers:
prettierdependency,prettier.config.*,.prettierrc*, or packageprettier; commandprettier --write .. - Oxfmt markers:
oxfmtdependency,oxfmt.config.*,.oxfmtrc.json, or.oxfmtrc.jsonc; commandoxfmt. - Biome markers:
@biomejs/biomedependency,biome.json, orbiome.jsonc; commandbiome format --write ..
Typecheck inference:
- Script
typecheckwins, then scripttype-check. - Otherwise Shadowtree runs every detected checker in stable order:
vue-tsc --noEmit,svelte-check, thentsc --noEmit. tsc --noEmitis included whentypescriptis installed ortsconfig.jsonexists.
Package scripts fill gaps without overriding predefined Node recipe names.
Before a package script becomes a recipe name, Shadowtree replaces : and every
character outside [A-Za-z0-9._-] with -, collapses repeated -, trims
leading and trailing -, and skips empty or reserved names. If multiple scripts
normalize to the same recipe name, the script whose original name already equals
the normalized name wins; otherwise the lexicographically first original script
name wins. For example, package script lint:fix becomes recipe lint-fix, but
the generated command still runs the original script key lint:fix.
Help
shadowtree help prints CLI usage, active config/profile, and resolved recipes
with their help text.
shadowtree help <recipe> prints a sectioned recipe page with ANSI color by
default. Pass color=false after the recipe name to disable color.
It prints these fields when present or applicable:
- recipe name
- recipe help text
- command section
- sandboxed section for unsandboxed recipes
- requires section when recipe-local tool requirements are declared
- pre command section
- post command section
- for_each section
- workdir section
- argument section with
name - help,info:, and configuredvalues: - sync-out section for sandboxed recipes
Multi-line command arguments are summarized as <script> in help, completion,
verbose boundary, and log boundary output.
Recipe Listing
shadowtree recipes prints resolved recipe names and help text. If a recipe has
no help, Shadowtree falls back to a compact command summary.
Plan Printing
--print prints the resolved execution plan without running it:
shadowtree --print test ./internal/runner
shadowtree --print --expanded test ./internal/runner
The plan includes these fields when present or applicable:
- recipe name
- profile
- config path
sandboxed: falsefor unsandboxed recipes- declared requirements without checking the host
- pre commands
- for_each command
- workdir
- main command
- post commands
- sync-out paths for sandboxed recipes
--print --expanded also prints normalized defaults for absent fields,
expanded script bodies for pre, cmd, post, and for_each, the selected
preset, resolved typed arguments, leftover variadic args, computed vars,
recipe-local env, expanded log settings, and expanded sync-out paths.
--check validates the selected resolved recipe without running commands. It
checks the same command forms that resolution would run, validates nested
@recipe and @path:recipe references, reports missing references, rejects
reference cycles, and validates resolved log and workdir paths. It does not
check host tool availability declared in requires; those are checked only
before real execution. --check --shell additionally parses expanded sh/bash
script bodies after placeholder expansion and shell prelude insertion.
Shell Completion
Shadowtree can generate bash, fish, and zsh completion:
command -v shadowtree >/dev/null 2>&1 && eval "$(shadowtree completion bash)"
The repository install recipe appends the same guarded eval line to
~/.bashrc.
shadowtree completion fish > ~/.config/fish/completions/shadowtree.fish
command -v shadowtree >/dev/null 2>&1 && eval "$(shadowtree completion zsh)"
The generated shell scripts call back into Shadowtree:
shadowtree __complete fish <words...>
shadowtree __complete bash <cursor> <line> [current]
shadowtree __complete zsh <words...>
Completion is dynamic and uses:
- configured recipes
- built-in recipes from the selected profile, or from a detected profile when no config is loaded
- recipe
helptext
Supported completion behavior:
shadowtree <TAB>completes core commands and resolved recipes.shadowtree te<TAB>completes matching recipe names such astest.shadowtree help <TAB>completes recipe names.shadowtree help test <TAB>completescolor=false.shadowtree --profile <TAB>completesgoandnode.shadowtree build <TAB>completes configured recipe arguments such asproject=.shadowtree benchmark preset=<TAB>completes recipe-local preset names such asstableandstress.shadowtree build[<TAB>completes bracket-style arguments such asbuild[project=.shadowtree test race=<TAB>completestrueandfalsefor bool arguments.patharguments complete relative paths, absolute paths, and~/paths.rel_patharguments complete relative paths only.path_kindfilters path candidates to files, directories, or executable files.- Arguments with
valuescomplete dynamic values produced by the configured command.
Completion parses config, checks profile markers, and runs only argument
values commands needed for the active argument.
Editor Support
Shadowtree ships editor integration files, but the CLI does not depend on them.
Shared schema:
schemas/shadowtree.schema.json
Zed support:
editors/zed-shadowtree/
The Zed extension defines a Shadowtree TOML language backed by the pinned
tree-sitter-toml grammar. Its queries provide:
- base TOML highlighting
- Shadowtree-specific key, recipe, argument, and variable highlighting
- semantic shell highlighting for script-valued
cmd,pre,post,for_each,shell_prelude, andvaluesstrings - recipe-reference completion, diagnostics, and semantic tokens for literal
command-position
@recipeinsh/bashscript strings, includingshell_prelude,for_each, andpre/post
Shell semantic highlighting supports shell = "bash" and shell = "sh".
Zed completion, diagnostics, and semantic tokens are provided by
shadowtree-lsp.
The Zed extension starts shadowtree-lsp from PATH; when developing inside
the Shadowtree checkout, it runs go run ./cmd/shadowtree-lsp so local LSP
changes take effect before an installed binary on PATH.
The bundled Zed language association covers .shadowtree.toml; TOML files
under .shadowtree/ require user file_types settings because extension
path_suffixes do not support glob patterns.
VS Code support:
editors/vscode-shadowtree/
The VS Code companion manifest contributes tomlValidation rules for
*.shadowtree.toml files and TOML files under .shadowtree/. Even Better TOML
consumes those rules and provides schema-backed validation, hover, and
completion.
Install Recipe Convention
This repository’s own .shadowtree.toml includes an install recipe.
It:
- installs the binaries with default
go install - honors
FISH_CONFIG_DIR - honors
FISH_COMPLETIONS_DIR - generates completion from
shadowtreeonPATH - installs fish completion when
fishis available - appends one guarded bash completion eval line to
~/.bashrc - appends one guarded zsh completion eval line to
~/.zshrcwhenzshis available
Current Project Recipes
Shadowtree currently uses itself for development through .shadowtree.toml.
build Build the shadowtree binary into bin/.
check Run vet and tests.
fix Update Go source with go fix.
fmt Format Go source files.
generate Run go generate.
install Install the Shadowtree CLI and language server.
install-skill Install local agent skills.
lint Run Go lint checks.
run Run a Go command.
test Run Go tests.
test-race Run Go tests with the race detector.
tidy Tidy Go module files.
vet Run go vet.
Recipes that intentionally mutate the host checkout set sandboxed = false:
fix
fmt
tidy
Known Limits
- Workspace isolation uses namespace overlayfs only when the host supports it.
- Large repositories may be slower when Shadowtree falls back to copying files.
- Commands can still intentionally read or write absolute host paths.
- Configured commands are shell strings; direct process argv arrays are only an internal representation used by built-in recipes and resolved execution.
- Built-in language profiles currently cover Go and Node.
Known Limits
Shadowtree intentionally keeps the feature set small and explicit.
- Shadowtree is not a complete untrusted-code security sandbox.
- Shadowtree does not require reflinks.
- Shadowtree does not currently provide Docker, remote execution, matrix jobs, watch mode, or persistent named sessions.
- Built-in language profiles currently cover Go and Node.
- Editor integrations complement runtime validation; the CLI loader remains authoritative.
For the full behavioral reference, see Behavior Spec.