Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

Reference

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 go and node.
--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
  • pre and post
  • for_each
  • workdir
  • 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 help text
  • typed argument metadata
  • values providers for the active argument

Supported Behavior

  • shadowtree <TAB> completes core commands and resolved recipes.
  • shadowtree te<TAB> completes matching recipe names such as test.
  • shadowtree help <TAB> completes recipe names.
  • shadowtree help test <TAB> completes color=false.
  • shadowtree --profile <TAB> completes go and node.
  • shadowtree build <TAB> completes configured recipe arguments such as project=.
  • shadowtree benchmark preset=<TAB> completes recipe-local preset names.
  • shadowtree build[<TAB> completes bracket-style arguments such as build[project=.
  • shadowtree test race=<TAB> completes true and false for bool arguments.
  • path arguments complete relative paths, absolute paths, and ~/ paths.
  • rel_path arguments complete relative paths only.
  • path_kind filters 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 for go or node.
  • shell: script shell for shell command strings; supported values are sh and bash.
  • shell_prelude: shell code prepended to every script command.
  • env: environment values applied to recipe commands and top-level var_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.

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:

  • profile
  • shell
  • shell_prelude
  • vars
  • var_commands
  • env
  • recipes

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 as foo -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:

  • 0 on success.
  • the failing exit code when available.
  • 1 for 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 by shadowtree 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 after pre or the main command, including after failures.
  • sandboxed: true by default. Set false only 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 and pre.
  • 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 with preset=<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:

  1. Create a temporary workspace with namespace overlayfs, or copy the source tree if namespace overlayfs is unavailable.
  2. Open or truncate the configured log file when log is set.
  3. Run pre commands in order.
  4. Run the resolved main command, or once per for_each candidate when set.
  5. Run post commands in order.
  6. If all phases succeeded, sync configured or requested paths back.
  7. 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 pre command fails, the main command is skipped.
  • post commands still run after a pre or main command failure.
  • post commands 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:

  • pre
  • cmd
  • post

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:

  • enum
  • glob
  • go-main-packages
  • go-modules
  • go-packages
  • lines
  • recipes
  • vars

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 by shadowtree help <recipe> and shell completion.
  • type: argument type. Defaults to string.
  • path_kind: completion filter for path and rel_path arguments.
  • position: 1-based positional index.
  • required: whether the user must supply a value. Defaults to false.
  • 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:

  • string
  • int
  • float
  • bool
  • path
  • rel_path
  • duration
  • duration: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>help format.
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 list in the config-directory module and in go.work modules 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 in cmd.
  • In shell command strings, it must occupy a whole shell word.
  • It is not available in pre, post, or sync_out.
  • For typed recipes, positional values and known key=value values are consumed by typed arguments and excluded from {@}.
  • Unknown identifier key=value tokens remain errors.
  • Command flags such as -run=TestName pass 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:

  1. typed argument defaults
  2. selected recipe preset defaults
  3. 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.

Profile Selection

Profiles provide built-in recipes. Supported profiles are go and node.

Profile selection precedence is:

  1. explicit --profile
  2. config profile
  3. 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.json selects node.
  • go.mod or go.work selects go.
  • 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 go is provided
  • config has profile = "go"
  • no config is loaded and Shadowtree detects go.mod or go.work upward 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-modules
  • command: required positional rel_path argument completed 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.

Node Profile

The Node profile is selected when:

  • --profile node is provided
  • config has profile = "node"
  • no config is loaded and Shadowtree detects the nearest package.json upward 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:

  1. packageManager prefix: pnpm, yarn, bun, or npm
  2. lockfiles: pnpm-lock.yaml, yarn.lock, bun.lockb, bun.lock, package-lock.json, npm-shrinkwrap.json
  3. 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> ... {@}, or bunx <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 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 test wins.
  • Bun projects use installed vitest first, otherwise bun test.
  • Other projects use installed vitest, then jest, then playwright test when @playwright/test is installed.

Lint Inference

  • Script lint wins.
  • ESLint markers: eslint dependency, eslint.config.*, .eslintrc*, or package eslintConfig; command eslint ..
  • Oxlint markers: oxlint dependency, oxlint.config.*, .oxlintrc.json, or .oxlintrc.jsonc; command oxlint.
  • Biome markers: @biomejs/biome dependency, biome.json, or biome.jsonc; command biome lint ..

Format Inference

  • Script fmt wins, then script format.
  • Prettier markers: prettier dependency, prettier.config.*, .prettierrc*, or package prettier; command prettier --write ..
  • Oxfmt markers: oxfmt dependency, oxfmt.config.*, .oxfmtrc.json, or .oxfmtrc.jsonc; command oxfmt.
  • Biome markers: @biomejs/biome dependency, biome.json, or biome.jsonc; command biome format --write ..

Typecheck Inference

  • Script typecheck wins, then script type-check.
  • Otherwise Shadowtree runs every detected checker in stable order: vue-tsc --noEmit, svelte-check, then tsc --noEmit.
  • tsc --noEmit is included when typescript is installed or tsconfig.json exists.

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 values commands.
  • 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 PATH mirrors selected paths back after a successful recipe.
  • Recipe-level sync_out mirrors selected paths back after a successful recipe.
  • --sync-out-all copies 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 example foo -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. false runs the recipe directly in the source checkout and skips sync-out.
cmd
Required shell string or @recipe reference for the main command.
for_each
Optional value-provider command. When set, cmd runs once per candidate value. It accepts the same value-provider forms as argument values, 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 cmd and optional timeout.
post
Commands run after the main command, in order. May be an array of command strings or one structured table with cmd and optional timeout.
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 mode 0755, and the log file is opened/truncated with mode 0644 before recipe commands start.
log_stages
Optional list of stages to write to log. Valid values are pre, cmd, and post; omitting the field logs all three. The cmd stage includes every for_each main command item. The for_each value-provider command itself is not cmd stage output.
log_tee
Optional boolean. Defaults to true, which preserves terminal stdout/stderr while also writing selected stage output to the log. When false, 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 pre command. Requirements are static declarations and are not placeholder-expanded. An included or overriding recipe that specifies requires replaces the previous requires block 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 on PATH; it does not run go install. Missing entries fail with guidance such as stringer (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 on PATH; it does not install packages. Missing entries use the detected package manager to suggest installing the CLI, such as npm install -g eslint@^9, pnpm add --global eslint@^9, yarn global add eslint@^9, or bun 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, and duration:seconds. The default is string. path accepts absolute and relative paths. rel_path accepts relative paths only and rejects absolute paths and ~ home paths. duration accepts Go duration strings parsed by time.ParseDuration, such as 10s, 1500ms, and 1m30s, and preserves the configured or CLI text when expanded. duration:seconds accepts 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 path and rel_path arguments. Supported values are any, file, dir, and executable. The default is any. file and executable still 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, and duration:seconds arguments. 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, and duration:seconds arguments. max must be greater than or equal to min when 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 @recipe reference, 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:

  1. Create a temporary workspace with namespace overlayfs, or copy the source tree if namespace overlayfs is unavailable.
  2. Open/truncate the configured log file, when log is set.
  3. Run pre commands in order.
  4. Run the resolved main command, or once per for_each candidate when set.
  5. Run post commands in order.
  6. If all phases succeeded, sync configured/requested paths back.
  7. 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 pre command fails, the main command is skipped.
  • post commands still run after a pre or main command failure.
  • post commands 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:

  1. explicit --profile;
  2. config profile;
  3. 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.json selects node.
  • go.mod or go.work selects go.
  • If Go and Node markers are in the same directory, Go wins.

Built-In Go Profile

The Go profile is selected when:

  • --profile go is provided, or
  • config has profile = "go", or
  • no config is loaded and Shadowtree detects go.mod or go.work upward 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 node is provided, or
  • config has profile = "node", or
  • no config is loaded and Shadowtree detects the nearest package.json upward 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:

  1. packageManager prefix: pnpm, yarn, bun, or npm;
  2. lockfiles in order: pnpm-lock.yaml, yarn.lock, bun.lockb, bun.lock, package-lock.json, npm-shrinkwrap.json;
  3. default npm.

Command forms:

  • Package scripts run <pm> run <script> -- {@}.
  • Tool commands run npm exec -- <bin> ... {@}, pnpm exec <bin> ... {@}, yarn exec <bin> ... {@}, or bunx <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 test wins.
  • Bun projects use installed vitest first, otherwise bun test.
  • Other projects use installed vitest, then jest, then playwright test when @playwright/test is installed.

Lint inference:

  • Script lint wins.
  • ESLint markers: eslint dependency, eslint.config.*, .eslintrc*, or package eslintConfig; command eslint ..
  • Oxlint markers: oxlint dependency, oxlint.config.*, .oxlintrc.json, or .oxlintrc.jsonc; command oxlint.
  • Biome markers: @biomejs/biome dependency, biome.json, or biome.jsonc; command biome lint ..

Format inference:

  • Script fmt wins, then script format.
  • Prettier markers: prettier dependency, prettier.config.*, .prettierrc*, or package prettier; command prettier --write ..
  • Oxfmt markers: oxfmt dependency, oxfmt.config.*, .oxfmtrc.json, or .oxfmtrc.jsonc; command oxfmt.
  • Biome markers: @biomejs/biome dependency, biome.json, or biome.jsonc; command biome format --write ..

Typecheck inference:

  • Script typecheck wins, then script type-check.
  • Otherwise Shadowtree runs every detected checker in stable order: vue-tsc --noEmit, svelte-check, then tsc --noEmit.
  • tsc --noEmit is included when typescript is installed or tsconfig.json exists.

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 configured values:
  • 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: false for 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 help text

Supported completion behavior:

  • shadowtree <TAB> completes core commands and resolved recipes.
  • shadowtree te<TAB> completes matching recipe names such as test.
  • shadowtree help <TAB> completes recipe names.
  • shadowtree help test <TAB> completes color=false.
  • shadowtree --profile <TAB> completes go and node.
  • shadowtree build <TAB> completes configured recipe arguments such as project=.
  • shadowtree benchmark preset=<TAB> completes recipe-local preset names such as stable and stress.
  • shadowtree build[<TAB> completes bracket-style arguments such as build[project=.
  • shadowtree test race=<TAB> completes true and false for bool arguments.
  • path arguments complete relative paths, absolute paths, and ~/ paths. rel_path arguments complete relative paths only. path_kind filters path candidates to files, directories, or executable files.
  • Arguments with values complete 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, and values strings
  • recipe-reference completion, diagnostics, and semantic tokens for literal command-position @recipe in sh/bash script strings, including shell_prelude, for_each, and pre/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 shadowtree on PATH
  • installs fish completion when fish is available
  • appends one guarded bash completion eval line to ~/.bashrc
  • appends one guarded zsh completion eval line to ~/.zshrc when zsh is 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.