The complete perch guide¶
Everything you need to get started AND ship a serious project with perch. One document, end-to-end. Read in 30โ45 minutes; skim the index to jump.
Contents¶
Onboarding 1. TL;DR โ first run in 60 seconds 2. Install 3. The mental model
Reference
- Anatomy of a
.perchfile - Commands โ args, modifiers, defaults
- Ops โ the language vocabulary
- State and data flow
- Block ops โ
if,parallel,retry,timeout,with_env,with_cwd,cache,sandbox,for_each - Error handling and recovery
- Templates โ code reuse
- Imports โ multi-file projects
- The capability model โ restrictions, allowlists, audit
- Cross-platform patterns
- Bundles โ ship one binary
- WASM modules โ sandbox by construction
- Testing โ
perch test - Pre-flight โ
--check,--scan,simulate - Observability โ
--trace,--audit,--report - Debugging your perch programs
- The five surfaces โ CLI, web UI, REPL, MCP, binary
Patterns
Walkthroughs (10 end-to-end examples)
- Walkthrough 1 โ Replace your Makefile
- Walkthrough 2 โ Ship a self-installing tool
- Walkthrough 3 โ Internal ops backend (web UI + MCP)
- Walkthrough 4 โ Plugin host with WASM
- Walkthrough 5 โ CI gate with
simulate - Walkthrough 6 โ Database backup + restore tool
- Walkthrough 7 โ SSL certificate rotation
- Walkthrough 8 โ Multi-environment deployer (dev / staging / prod)
- Walkthrough 9 โ File-processing pipeline (ETL-style)
- Walkthrough 10 โ Git workflow wrapper
Production
- Distribution โ getting your binary to users
- Production deployment โ logs, monitoring, hardening
- Common pitfalls
- FAQ
- Honest limits
- Quick reference card
TL;DR โ first run in 60 seconds¶
# 1. Install
go install github.com/olivierdevelops/perch@latest
# 2. Make a file
cat > commands.perch <<'EOF'
name "myapp"
command hello
description "Greet someone"
arg who
type string
default "world"
description "Who to greet"
end
do
print "hello ${who}"
end
end
EOF
# 3. Run it
perch hello # โ hello world
perch hello -who=perch # โ hello perch
perch --help # โ lists every command
perch hello --help # โ per-command help (args, defaults, examples)
That's perch. Everything else in this doc is depth on top of these three commands.
Install¶
| Platform | Command |
|---|---|
| Go users (any OS) | go install github.com/olivierdevelops/perch@latest |
| macOS / Linux (binary) | curl -fsSL https://raw.githubusercontent.com/olivierdevelops/perch/main/scripts/install.sh \| sh |
| Windows (PowerShell) | irm https://raw.githubusercontent.com/olivierdevelops/perch/main/scripts/install.ps1 \| iex |
| Homebrew (macOS) | See repo for tap status |
| Manual | Download from the releases page |
Optional companions (also installed via Go):
go install github.com/olivierdevelops/perch/cmd/perch-mcp@latest # MCP server for AI agents
go install github.com/olivierdevelops/perch/cmd/perch-lsp@latest # LSP for editors
Or use the built-in installers:
perch --install-lsp # installs perch-lsp
perch --install-vscode # installs perch-lsp + the VS Code extension
The mental model¶
A
.perchfile is a typed CLI representation of an operational toolset. Once you have one, every other surface โ web UI, REPL, MCP, binary โ is a different rendering of the same declarations. The file is the abstraction; the surfaces are downstream.
Concretely:
- A
.perchfile declares commands (typed verbs with named args). - Each command's body is a list of ops (
shell,mkdir,http_get,assert_eq, โฆ) โ ~140 built-ins, identical on macOS / Linux / Windows. - Capability declarations (
--no-shell,wasm_run's body declarations) say what the program is allowed to do. - Auto-bound variables (
${os},${home_dir},${script_dir}, ~30 of them) give cross-platform context withoutif uname. - The whole thing compiles to a single Go binary you can
scp.
A .perch file with N commands gives you, for free:
perch CMD [args] # CLI for command CMD
perch --help # list of every command
perch CMD --help # per-command typed help
perch --server # web UI with a form per command
perch --shell # REPL
perch-mcp -f file.perch # MCP server exposing every command as a tool
perch --build -o myapp # portable binary with the program embedded
Add a new command โ all six surfaces get it. No code generation, no schemas to update, no integration glue.
Anatomy of a .perch file¶
Every .perch file has the same shape. Most projects use 3โ5 of these sections; the rest are optional.
# 1. Identity & metadata
name "myapp"
about "Build, test, ship myapp"
version "1.2.0"
# 2. Globals โ values shared by every command
BUILD_DIR = "./builds"
GIT_OK = true
# 3. Requires โ what this file needs from the host (declared + enforced)
requires
bin "go"
bin "git"
end
# 4. Bundle โ what gets embedded into the fat binary at `perch --build` time
bundle
include "./modules" # whole dir
include "./policy.wasm" as policy_wasm # one file, alias for wasm_run
end
# 5. Templates โ reusable parameter-substitution stamps (optional)
template ensure_dir
arg path
type string
end
do
if not exists "${path}"
mkdir "${path}"
end
end
end
# 6. Commands โ the typed verbs
command build
description "Compile myapp"
arg target
type string
default "${os}"
description "Target OS"
end
do
ensure_dir "${BUILD_DIR}/${target}"
go build -o ${BUILD_DIR}/${target}/myapp ./cmd/myapp
end
end
# 7. Catch โ runs when the user types an unknown command (optional)
catch unknown
description "Forward unknown verbs to git, like `gh foo` would"
proxy_args # required to bind ${proxy_args} in the catch body
do
shell "git ${proxy_args}"
end
end
Top-level sections, ranked by how often you'll need them:
| Section | When you need it |
|---|---|
name, version |
Always. Identifies the program. |
command NAME ... end |
Always. The thing you're declaring. |
NAME = value (top-level) |
Often. Shared paths / flags / defaults. |
about "..." |
Usually. One-sentence description for --help. |
requires ... end |
When you want the file to declare (and enforce) its external needs โ bins (+ SHA-256 hash pins), env vars, hosts, filesystem read/write scopes, OS/arch. Every external op then verifies the manifest before it runs; perch --check proves feasibility without running. See capability-gating.md. |
bundle ... end |
When you want a portable binary with files embedded. |
template NAME ... end |
When you have repeated body shapes. |
import "PATH" |
Multi-file projects. |
catch NAME ... end |
When you want a fallback / proxy verb. |
Commands โ args, modifiers, defaults¶
A command is the unit of execution. Every command has:
- A name (the CLI verb)
- A description (shown in
--help) - Zero or more args (typed CLI flags or positional)
- Optional modifiers (
private,detached,test, etc.) - A
do ... endbody (the ops that run)
Arg block โ full shape¶
arg NAME
type string # string | int | float | bool
default "fallback" # used when the user omits the flag
description "Human help" # shown in --help
index 0 # positional: 0 = first positional arg
optional # may be empty without a default
rest # absorb every remaining positional arg
end
| Field | Required | Effect |
|---|---|---|
type |
yes | One of string, int, float, bool. Determines CLI parsing + the value type when interpolated. |
default |
no | Value when the user doesn't pass the flag. Same type as type. |
description |
no (but helpful) | Shown in perch CMD --help and the web UI's input placeholder. |
index N |
no | Bind to positional argument N (0-indexed). Without index, the arg is a flag (-name=value). |
optional |
no | Permit the arg to be absent even without a default. The body sees the empty value "" / 0 / false. |
rest |
no | Absorb every remaining positional arg. Must be the LAST declared arg, type string, no default. The value is newline-joined; a sibling ${NAME_count} int binding gives the count. |
Modifiers (between command NAME and do)¶
command stop_serve
description "Stop the dev server"
private # hidden from --help / MCP / suggestions
detached # spawned processes don't block the command
proxy_args # bind ${proxy_args} = full argv after the verb
require_os "darwin" "linux" # refuse to run on other OSes
require_arch "arm64" "amd64" # refuse to run on other archs
dir "./subproject" # cwd for the body
on_signal handler_name # run HANDLER on SIGINT/SIGTERM
env DEPLOY_TARGET "staging" # per-command env var
test # marks this as a `perch test` test
test_allow_shell # opt out of test-mode shell denial
test_allow_network # opt out of test-mode network denial
test_keep_cwd # opt out of the test-mode temp-cwd
test_timeout 30 # max seconds for `perch test`
do
pkill -f cmd/server
end
end
Calling commands¶
# Flag form (any arg)
perch build -target=linux
# Positional form (args declared with `index`)
perch deploy us-east-1 # if -region has index 0
perch deploy us-east-1 -bake=15
# Per-command help (rendered from the arg specs)
perch build --help
# Cross-command call (inside a body โ bare command name, no `run` keyword)
other_command "-arg1=x" "-arg2=y"
Ops โ the language vocabulary¶
There are ~140 built-in ops, every one of them cross-platform. Catalog by category:
| Category | Examples |
|---|---|
| Output | print, println, eprintln |
| Shell | shell, shell_output, try_shell, shell_detached, pkg_install |
| Files | mkdir, cp, mv, rm, touch, chmod, exists, read_file, write_file, append_file, file_size, file_mtime, list_dir, walk_dir, mktemp, mktemp_dir, ensure_dir, symlink, copy_dir |
| Strings | trim, lower, upper, replace, split, join, contains, has_prefix, has_suffix, slice, capitalize, length, format, pad_left, pad_right, repeat |
| Hash | md5, sha1, sha256, md5_file, sha256_file, crc32, adler32, fnv32 |
| Encoding | base64_encode/decode, hex_encode/decode, url_encode/decode, json_parse, json_stringify, json_get, csv_parse, xml_parse |
| HTTP | http_get, http_post, http_put, http_delete, http_status, download |
| Network | dns_lookup, port_check, get_ip, get_hostname |
| Compression | gzip, ungzip, tar_create, tar_extract, zip_create, zip_extract |
| Time | now, format_time, parse_time, unix_to_iso, iso_to_unix, time_diff, sleep |
| Regex | regex_match, regex_replace, regex_find_all |
| System | get_os, get_arch, get_env, set_env, app_data_dir, cache_dir, temp_dir, home_dir, cwd, pid, hostname, user, has_bin, bin_version, os_version |
| Bundle | bundle_hash, bundle_dir, bundle_extract, link_into_path |
| Assert (for tests) | assert_eq, assert_neq, assert_contains, assert_not_contains, assert_exists, assert_not_exists, assert_match |
| Block ops | if, parallel, retry, timeout, with_env, with_cwd, sandbox, cache, for_each, wasm_run |
Full reference: op-reference.md.
Op shape¶
Most ops are one of:
print "hi" # bare op with positional string
mkdir "${BUILD_DIR}/${target}" # ditto
n = file_size "./build/out" # let-capture: result bound to ${n}
body = http_get "https://api.x/health"
print "got ${body}"
Some block ops take a named arg + a body:
Interpolation โ ${NAME}¶
Every string arg can reference variables:
Sources, in resolution order:
X = ...captures in the current body- Command args (declared via
arg NAME ...) - Top-level bindings (declared bare as
NAME = value) - Auto-bound vars (always present:
${os},${arch},${home_dir},${script_dir},${cwd}, etc.) - Host env vars (subject to
--envallowlist if set)
Unknown names cause an error at op-dispatch time (and a warning at perch --check time).
Auto-bound variables¶
These ~30 names are always available โ no let, no if uname:
| Group | Examples |
|---|---|
| OS / arch | ${os} (darwin/linux/windows), ${arch} (amd64/arm64), ${is_macos}, ${is_linux}, ${is_windows}, ${is_unix}, ${is_arm64}, ${is_amd64} |
| Paths | ${script_dir}, ${script_path}, ${cwd}, ${home_dir}, ${temp_dir}, ${cache_dir}, ${app_data_dir} |
| Identity | ${user}, ${hostname}, ${pid} |
| Bundle | ${bundle_dir} (auto-extract on first reference; falls back to ${script_dir} when not running from a built binary) |
State and data flow¶
perch is stringly-typed by design. Every value an op produces is a string; types are recovered at op boundaries (e.g. if size > 1000 parses the string as int). This keeps interpolation uniform: ${anything} always works.
Passing data between ops¶
The four mechanisms, ranked by how often you'll use them:
# 1. `X = OP โฆ` โ capture an op's return value into a local binding
rev = git rev-parse HEAD
print "deploying ${rev}"
# 2. Globals โ read-only after parse, visible everywhere
APP_DIR = "/opt/myapp"
# 3. Args โ values from the caller (CLI flag, MCP arg, web UI form)
arg target type string default "darwin" end
# 4. Files โ write at one step, read at another (persists across commands)
write_file "${temp_dir}/state.json" "${rev}"
prior = read_file "${temp_dir}/state.json"
Things let does NOT do:
- It doesn't leak across commands. Each command's body has its own binding scope. To pass state between commands, write to a file or use
cache. - It doesn't survive across
parallelsiblings. Eachparallelchild runs in its own clone; capturingX = ...inside one branch doesn't make${X}visible in another. - It doesn't survive invoking another command by name. The callee runs with a fresh binding table.
Working with JSON¶
The most common shape โ fetch a JSON API, extract a field:
command status
do
body = http_get "https://api.example.com/status"
value = json_get "${body}" ".version"
svc = json_get "${body}" ".service.name"
print "${svc} is at version ${value}"
end
end
json_get uses dot-path syntax: .foo.bar.baz, .items.0.name, .tags (the whole list, comma-joined). For more complex shapes, pipe through a WASM module that uses your language's real JSON library.
Working with shell output¶
lines = find . -name *.go -type f
count = length "${lines}" # newline-counting
print "${count} files"
shell_output captures stdout (stderr still goes to your screen). For exit-code-sensitive flows, use try_shell which doesn't fail the command on non-zero:
File-backed state (the only durable kind)¶
For state that needs to survive across perch invocations:
state_file = format "${cache_dir}/myapp/lastrun"
command deploy
do
now = now
write_file "${state_file}" "${now}"
# ... do deploy ...
end
end
command since_lastrun
do
if not exists "${state_file}"
print "never deployed"
exit 0
end
then = read_file "${state_file}"
diff = time_diff "${then}" "${now}"
print "last deploy was ${diff} ago"
end
end
This pattern is enough for: deployment markers, idempotency tokens, cache invalidation keys, "are we set up?" flags.
Block ops¶
if EXPR ... end โ unified conditional¶
if os == "linux"
apt-get install -y jq
end
if has_bin "kubectl"
kubectl get pods
end
if not has_bin "docker"
fail "docker is required"
end
if exists "./Cargo.toml"
cargo build --release
end
if size > 1000000
print "file is large"
end
Forms supported:
- Comparisons:
NAME == "x",NAME != "x",NAME > N,NAME < N - Truthy / falsy:
if has_bin,if not has_bin - Predicate calls:
if exists "PATH",if has_bin "NAME",if not exists "PATH"
parallel max=N ... end¶
Run children concurrently up to N at a time:
retry max=N delay=Ns ... end¶
Re-run children on failure with exponential backoff:
timeout secs=N ... end¶
Kill children if they exceed N seconds:
with_env KEY1=val1,KEY2=val2 ... end¶
Overlay env vars for the body:
with_cwd "PATH" ... end¶
Run body in another directory:
sandbox flags="no_shell,no_network" ... end¶
Tighten capabilities for the body:
sandbox flags="no_shell,no_network"
body = read_file "./input.json"
parsed = json_parse "${body}"
write_file "./output.json" "${parsed}"
end
cache key="KEY" ttl="1h" ... end¶
Skip the body if a recent identical run cached its let bindings:
for_each NAME in LIST ... end¶
Loop body N times:
wasm_run โ WebAssembly under WASI¶
See WASM modules below.
Error handling and recovery¶
By default, any op that errors halts the command and the process exits non-zero. This is the right default for CI gates and deploy scripts. The ladder of "I want different behavior":
General error handling โ try / rescue / finally + match¶
Status: the
matchop (including barematch err.kind), the error-kind enum, and thetry / rescue / finallyblock all work today โ the block is built on capyblock_sections. Afinally-onlytryre-raises after cleanup; only a non-emptyrescuearm swallows the error.
try
body = http_get "${url}"
rescue err
match "${err.kind}"
case http_5xx
throw "${err.message}" # let an outer retry handle it
case http_4xx
print "bad request: ${err.code}"
case http_ssrf_blocked
alert "-msg=security: ${err.detail}"
else
throw "${err.message}" # unknown โ re-raise
end
finally
rm "${tmpfile}"
end
Inside rescue err, five bindings are populated: ${err.kind} (enum), ${err.message}, ${err.code}, ${err.op}, ${err.detail}. The full enum (30 kinds โ shell_exit_nonzero, http_5xx, http_ssrf_blocked, wasm_module_exited, file_not_found, โฆ) lives in docs/errors.md, which is also the reference for every composition rule and pattern.
finally runs unconditionally โ both on success and failure. Errors in finally override the original (so cleanup failures aren't silently swallowed).
Use throw "msg" inside rescue to re-raise โ semantically the same as fail "msg" but spelled to read clearly as "I caught this and decided to re-raise."
Why
rescueinstead ofcatch? perch already usescatch unknown ... endat file scope for declaring catch-all CLI commands. That's a different concept from exception handling. To keep both clear, perch borrows Ruby'stry / rescue / finallynaming.
Recoverable shell calls โ try_shell¶
result = try_shell "ping -c 1 ${host}"
if "${result}" == ""
print "${host} unreachable, falling back"
end
try_shell captures stdout AND swallows non-zero exit codes. Use when you want to branch on success/failure rather than abort.
Retry with backoff โ retry block¶
retry max=5 delay=2s
body = http_get "https://api.example.com/health"
assert_contains "${body}" "ok"
end
Exponential backoff (2s, 4s, 8s, 16s, 32s in this example). The whole body re-runs; partial side effects persist. For idempotent ops only.
Explicit failure โ fail¶
if not has_bin "kubectl"
fail "kubectl required โ install from https://kubernetes.io/docs/tasks/tools/"
end
Halt immediately with a readable message. The message goes to stderr; exit code is 1.
Cleanup with on_signal¶
For long-running commands (servers, watch loops) that need to clean up on Ctrl-C:
command serve
description "Dev server with auto-cleanup"
detached
on_signal stop_serve
do
go run ./cmd/server
end
end
command stop_serve
private
do
pkill -f cmd/server
rm "${temp_dir}/server.pid"
end
end
When serve receives SIGINT/SIGTERM, perch dispatches to stop_serve before exiting.
Detect-and-degrade pattern¶
command full_setup
do
if has_bin "brew"
brew install jq
end
if not has_bin "brew"
if has_bin "apt-get"
sudo apt-get install -y jq
end
end
if not has_bin "jq"
fail "couldn't install jq via brew or apt-get โ install manually"
end
end
end
Three escalating attempts; only fails if none worked. Useful for cross-platform tool installation.
Cleanup-after-error idiom¶
perch doesn't have try/finally. Use a wrapper command that always runs the cleanup, even after fail:
command release
do
_release_inner
_release_cleanup # always runs (release halts โ cleanup doesn't)
end
end
# Better pattern: do cleanup OUTSIDE the failing region.
command release_with_cleanup
do
tmp = mktemp_dir
# Set up state...
build-and-test ${tmp} # might fail
# If we get here, build succeeded:
_release_publish "-target_dir=${tmp}"
rm "${tmp}"
end
end
For truly defensive cleanup (delete the temp dir even on build failure), wrap in a with_cwd block or move the cleanup into a wrapper command and use a separate --report audit log to detect failures post-hoc.
Exit codes¶
- 0 โ success
- 1 โ
failop, or any op error - non-zero from shell โ propagates by default;
try_shellto suppress - 128 + N โ caught signal N (rare; perch usually shuts down cleanly via
on_signal)
Templates โ code reuse¶
When the same body shape appears in two commands, lift it into a template. Templates are parse-time stamps โ they expand inline at every bare NAME ... invocation.
template require_bin
arg name string
do
if not has_bin "${name}"
fail "${name} is required โ install it first"
end
end
end
command up
description "Start the dev stack"
do
require_bin "docker"
require_bin "docker-compose"
docker-compose up -d
end
end
command down
do
require_bin "docker-compose"
docker-compose down
end
end
Templates can't recurse, can't declare commands, and don't appear in --help / MCP.
Imports โ multi-file projects¶
Big projects span multiple .perch files. Imports merge:
# main.perch
import "recipes/_lib.perch"
import "recipes/redis.perch" as redis # namespaced; verbs become `redis.up`, etc.
command full_stack
do
require_docker
redis.up
./scripts/seed-data.sh
end
end
- Flat import (
import "PATH") โ imported commands + templates merge into the host's namespace. Last definition wins. - Namespaced import (
import "PATH" as NAME) โ imported verbs becomeNAME.verb. Useful for libraries you want to keep distinct.
Imports are resolved at parse time relative to the importing file's directory.
The capability model¶
perch programs declare what they need; restrict at run time what they can do. Two layers:
1. CLI restriction flags (outer policy)¶
perch --no-shell # refuse every shell call
perch --no-network # refuse every HTTP / network call
perch --no-subprocess # refuse spawn (pkg_install, kill_by_name, etc.)
perch --no-write # filesystem is read-only
perch --env HOME,PATH # only listed env vars resolve via ${NAME}
perch --allow-bin docker,git # only listed binaries can be the first token of `shell`
perch --allow-host api.gh.com # network allowlist (composes with default SSRF guard)
perch --no-shell-metachars # reject shell commands with pipes / && / `;` etc.
perch --max-runtime 600 # wall-clock cap, in seconds
perch --no-redirects # HTTP redirects refused
perch --allow-private-ips # opt out of the default SSRF guard
Compose freely:
2. In-language sandbox blocks (inner policy)¶
command parse_user_input
do
sandbox flags="no_shell,no_network,no_subprocess"
raw = read_file "./untrusted.json"
parsed = json_parse "${raw}"
write_file "./safe.json" "${parsed}"
end
end
end
Inner sandboxes can ONLY tighten. They can't grant capability the outer policy denied.
Always-on defaults¶
- SSRF guard โ every HTTP call refuses private / loopback / link-local IPs unless
--allow-private-ips. Defense includes redirect-hop checks and DNS-rebinding (multi-A) handling. - Scheme downgrade guard โ
https โ httpredirects refused unless--allow-scheme-downgrade. privatecommands โ never callable from the CLI (only by name from another command); excluded from--helpand MCP.
Audit log¶
Writes one NDJSON line per op (kind, args after interpolation, duration, result/error). Useful for: forensics, debugging, feeding into observability pipelines.
Cross-platform patterns¶
perch's ~140 built-in ops behave identically on macOS / Linux / Windows. The places you still need OS-aware logic are:
Path separators¶
perch's path-shaped ops normalize separators internally โ mkdir "./a/b/c" works on Windows. But if you're passing paths into shell "...", the shell sees what you typed:
# WRONG on Windows: cmd.exe doesn't understand forward slashes for cd
shell "cd ./subproject && go test ./..."
# RIGHT: use with_cwd (cross-platform)
with_cwd "./subproject"
go test ./...
end
# OR use the platform-correct sep
sep = "/"
if os == "windows"
sep = "\\"
end
Most of the time with_cwd is the cleaner answer.
Line endings¶
read_file returns the file's content as-is. If you're reading on Windows and got CRLF, downstream ops may produce different output than on Unix. Normalise explicitly when comparing:
Shell vs OS โ use os "PLATFORM" ... end for explicit OS context¶
The first token of exec X (or shell "X") is the binary you're invoking. Different OSes have different binaries. Use the os "PLATFORM" ... end block to declare which body is meant for which OS โ explicit beats hidden:
command setup
do
os "darwin"
brew install jq
end
os "linux"
apt-get install -y jq
end
os "windows"
choco install jq -y
end
end
end
Version checks¶
shell_output + version_extract + version_* comparators give you typed version gating without a per-binary parser table to maintain. The pattern:
command deploy
do
raw = kubectl version --client -o json
v = version_extract "${raw}"
ok = version_ge "${v}" "1.28.0"
if not ok
fail "kubectl ${v} < 1.28.0 โ upgrade and retry"
end
kubectl rollout restart deployment/api
end
end
version_extract STRING [PATTERN] pulls a version out of arbitrary text. With no PATTERN it uses a default heuristic (v?\d+(\.\d+)+(...optional pre-release tail...)) that matches most CLI version output. Supply a pattern with one capture group for unusual formats:
Comparators return "true" / "false":
| Op | Semantics |
|---|---|
version_eq A B |
A == B |
version_ne A B |
A != B |
version_gt A B, version_ge A B |
strict / inclusive greater |
version_lt A B, version_le A B |
strict / inclusive less |
version_compat A B |
same major version (~=-style compatibility) |
Infix assertion (recommended)¶
For halt-on-failure version gates, assert_version "X" OP "Y" reads like the math:
raw = kubectl version --client -o json
v = version_extract "${raw}"
assert_version "${v}" >= "1.28.0" # halt with assert_failed if too old
assert_version "${v}" < "2.0.0" # halt if too new
assert_version "${v}" ~ "1.0.0" # halt unless same major (PEP 440 ~= / Cargo caret)
Supported operators: >=, >, <=, <, ==, !=, ~ (same-major). Halts with err.kind = assert_failed, composes with try / rescue:
try
assert_version "${v}" >= "1.28.0"
rescue err
match "${err.kind}"
case assert_failed
print "kubectl too old โ using legacy path"
deploy_legacy
else
throw "${err.message}"
end
end
Inside an if¶
The standard if X OP Y ... end block does semver-aware comparison when both sides look like version strings (optional v prefix, dot-separated digit segments). No special keyword required:
v = version_extract "${raw}"
assert_version v >= "1.28.0" # hard gate
if v >= "1.28.0" # soft branch
kubectl rollout restart deployment/api
end
Crucially 1.29.3 > 1.9.0 is now true (semver order), where float comparison would say false (1.29 < 1.9 numerically). Plain numeric comparisons (file sizes, counts, etc.) still use float โ the auto-detection only flips when both operands look like versions.
For programmatic boolean checks where you want to capture the result, the prefix version_ge / version_gt / etc. ops also work and return "true" / "false" strings.
Ordering rules โ best-effort, no library dependency:
- Optional
vprefix is stripped on both sides - Numeric segments compared element-wise (
1.10.0 > 1.9.0โ numeric, not lex) - Missing segments treated as zero (
1.2 == 1.2.0) - Pre-release suffix (
-rc.1) sorts BEFORE the unsuffixed version (semver spec) - Build metadata after
+is ignored - Falls back to string compare if either side is unparseable (so
if ok ... endstill reaches a decision)
arch "ARCH" ... end is the architecture sibling โ runs the body only when ${arch} matches. Standard targets: "amd64", "arm64", "386", "arm", "riscv64" (Go GOARCH values). Compose with os for matrix builds:
command release
do
os "linux"
arch "amd64"
with_env "GOOS=linux,GOARCH=amd64"
go build -o app-linux-x64 ./cmd/app
end
end
arch "arm64"
with_env "GOOS=linux,GOARCH=arm64"
go build -o app-linux-arm64 ./cmd/app
end
end
end
os "darwin"
arch "amd64"
with_env "GOOS=darwin,GOARCH=amd64"
go build -o app-darwin-x64 ./cmd/app
end
end
arch "arm64"
with_env "GOOS=darwin,GOARCH=arm64"
go build -o app-darwin-arm64 ./cmd/app
end
end
end
end
end
perch simulate release --sim-os=linux --sim-arch=arm64 prunes everything except the matching leaf. No umbrellas for arch (matrix builds want exact pinning).
os "unix" is an umbrella that matches darwin / linux / freebsd / openbsd / netbsd โ handy for "any Unix; Windows needs its own":
Supported targets: "darwin", "linux", "windows", "freebsd", "openbsd", "netbsd", and the umbrella "unix". Mirror of the existing ${is_unix} auto-bound var (true on anything that isn't Windows).
Why prefer os "X" over if os == "X"?
- Same runtime semantics (body runs only when
${os}matches the declared platform). - Stronger static analysis.
simulate --sim-os=linuxknows theos "darwin"branch is dead code; the web UI can flag "incompatible with your current OS";--scancan give per-OS capability summaries. - Reads cleaner. "This body is for Linux" is the structural intent; an
ifreads as runtime branching.
If you genuinely want runtime branching (one body that needs to react to ${os} mid-flight), if os == "X" is still the right tool. For declaring "this command's body is OS-specific work," os "X" is the new shape.
Older cross-platform style still works (no behavior change):
For common cases, factor into a template:
template install_pkg
arg name string
do
if is_macos
brew install ${name}
end
if is_linux
sudo apt-get install -y ${name}
end
if is_windows
choco install ${name} -y
end
end
end
command setup
do
install_pkg "jq"
install_pkg "ripgrep"
end
end
Environment quirks¶
| Quirk | Solution |
|---|---|
| Windows env vars are case-insensitive | perch normalizes to uppercase internally |
${HOME} doesn't exist on Windows |
Use ${home_dir} (auto-bound, cross-platform) |
${PATH} separator is ; on Windows, : elsewhere |
link_into_path op handles this; for manual PATH manipulation, branch on ${os} |
Executable extension on Windows is .exe |
${exe_ext} is auto-bound (".exe" on Windows, "" elsewhere) |
| Default shell differs (sh vs cmd) | If you need bash specifically: shell "bash -c '...'" and require_bin bash up front |
What auto-bound vars give you¶
You almost never need to write OS-detection code from scratch. The big six:
${os} # darwin | linux | windows
${arch} # amd64 | arm64
${is_macos} # "true" | "false"
${is_linux} # "true" | "false"
${is_windows} # "true" | "false"
${is_unix} # "true" on macOS+Linux, "false" on Windows
Use them in if conditions:
Bundles โ ship one binary¶
perch --build produces a single self-contained executable. The .perch file gets compiled into Go, embedded into the runtime; an optional bundle ... end section embeds arbitrary files alongside.
name "myapp"
version "1.0.0"
bundle
include "./modules" # whole directory
include "./policy.wasm" as policy_wasm # one file, with alias
include "./templates/email.txt"
end
command run_plugin
do
wasm_run policy_wasm # bare ident โ resolved via bundle alias
wasm_arg "/ro/deploy"
end
end
end
Build:
perch --build -f myapp.perch -o myapp
# โ ./myapp is a single executable with .wasm + templates inside
Distribute:
Bundle behavior:
- Paths in
includeresolve relative to the.perchfile's directory. include "PATH" as NAMEregistersNAMEas a bare-identifier alias usable bywasm_run NAMEand friends.- CLI
--include PATHat--buildtime is additive (use for CI steps injecting generated files). - Recipients of the binary need only what your install commands themselves require โ no Go, no perch, no python (unless YOU shell out to python).
Worked example: demos/05-python-installer โ a single binary that drops a Python project into ~/.cache/perch/<hash>/, sets up a venv, and links into $PATH.
WASM modules โ sandbox by construction¶
shell is best-effort isolation (a sandbox is a fence around an open field). wasm_run is strict isolation by construction: the module sees ONLY the argv, env vars, mounts, and hosts you declared. Anything else does not exist in its execution environment.
command validate_manifest
do
wasm_run "./validate.wasm"
wasm_arg "/ro/manifest" # argv passed to module
wasm_env "GREETING,HOME" # only these env vars
wasm_mount_read "./manifest.yaml" # read-only mount โ /ro/manifest.yaml
wasm_mount_write "./out" # read-write โ /rw/out
wasm_allow_host "api.github.com" # HTTP allowlist (for the host-provided perch.http_get import)
end
end
end
Build a module (any language targeting wasm32-wasip1 works):
# Go
GOOS=wasip1 GOARCH=wasm go build -o validate.wasm .
# Rust
cargo build --target wasm32-wasip1 --release
# TinyGo
tinygo build -o validate.wasm -target=wasi .
Capability declarations¶
| Declaration | Effect |
|---|---|
wasm_arg "VALUE" |
Append to argv. Multiple OK. |
wasm_env "K1,K2" |
Env allowlist. Only listed names pass through. |
wasm_mount_read "PATH" |
Mount host PATH at /ro/<basename> (read-only). |
wasm_mount_write "PATH" |
Same but read-write at /rw/<basename>. |
wasm_allow_host "HOST" |
Per-module HTTP allowlist (needed for perch.http_get host import). |
HTTP from inside a module¶
WASI Preview 1 has no sockets. perch exposes HTTP via host imports under the module name perch:
// fetch.go โ compiled with GOOS=wasip1 GOARCH=wasm
import "github.com/olivierdevelops/perch/wasm-sdk/perchhttp"
body, status, err := perchhttp.Get("https://api.github.com/zen")
What's enforced:
- No wasm_allow_host declarations โ every http_get returns -1 (fail-closed).
- Module dials a host outside its allowlist โ refused at the host-function boundary.
- Outer --allow-host policy still applies (intersection wins).
- SSRF + redirect guards still run on every call AND every redirect hop.
Honest limits: only GET (no POST/PUT/DELETE yet), no custom headers, 32 MB response cap, no streaming. Roadmap: POST + headers next; sockets only if WASI Preview 2 stabilises in wazero.
The killer demo for wasm_run: demos/wasm-plugin-host โ a plugin runtime that runs 4 legitimate plugins + 1 deliberately malicious plugin trying 5 escape attempts; every escape fails because the runtime doesn't provide those operations.
Testing โ perch test¶
Mark a command with the test modifier and perch test discovers + runs it. Tests are sandboxed by default (--no-shell, --no-network, --no-subprocess, fresh temp cwd).
template suite_assert
arg got string
arg want string
do
assert_eq "${got}" "${want}"
end
end
command test_lower
description "lower op should match Unicode case folding"
test # marks this as a test
do
result = lower "HรLLO"
suite_assert "${result}" "hรฉllo"
end
end
command test_http_get
test
test_allow_network # opt out of network denial
do
body = http_get "https://api.github.com/zen"
assert_contains "${body}" "." # at least produced text
end
end
Run:
perch test # all tests
perch test --filter lower # name substring filter
perch test -v # verbose: show captured output even on pass
Exit code is non-zero if any test failed. Wire into pre-commit and CI.
Seven assert_* ops: assert_eq, assert_neq, assert_contains, assert_not_contains, assert_exists, assert_not_exists, assert_match. Each produces a readable failure message.
Pre-flight โ --check, --scan, simulate¶
Three tools that catch problems before execution. Wire any of them into pre-commit / CI.
--check (validate)¶
Syntactic + reference validation:
Catches: unknown op kinds, missing command references, typo'd arg types, duplicate args, colliding positional indexes, unresolved ${name} placeholders.
--scan (capability audit + risk score)¶
Static analysis: what capabilities does this program need? What risks are there?
Output now leads with a one-glance risk score:
RISK: ๐ข SAFE โ pure ops only
RISK: ๐ก LOW โ limited surface; review the capabilities below
RISK: ๐ MED โ multiple capabilities or shell metacharacters; review carefully
RISK: ๐ด HIGH โ sudo / proxy_args / privileged ops; read every command first
Plus the bullet list of WHY (uses sudo, executes shell, network access (3 hosts), writes the filesystem (2 roots), etc.). Then the full capability table, risk findings (with severity HIGH / MED / LOW / INFO), and a recommended hardened invocation.
Always run before executing a .perch file you didn't write. A ๐ด HIGH score isn't necessarily bad (production deploy scripts are usually HIGH) โ it's the signal that you should actually read the file before running it.
The web UI's Scan tab surfaces the score as a colored pill; the JSON /api/scan endpoint returns risk: {score: "HIGH", reasons: [...]} for downstream tools.
simulate (hypothetical-env analyzer)¶
Walk the program against a hypothetical host โ without running anything. Reports per-op outcomes: WILL_RUN โ / WILL_FAIL โ / MIGHT_FAIL ?.
perch simulate deploy --sim-os=linux --sim-have-bin=kubectl,docker \
--sim-allow-host=api.example.com
For multi-scenario "what if X / what if Y" analysis, supply a JSON fixture:
{
"os": "linux",
"bins": ["docker", "kubectl"],
"oracles": {
"shell_output": { "git rev-parse HEAD": "abc123" },
"http": { "https://api.example.com/health": {"status": 200} }
},
"scenarios": [
{"name": "happy", "overrides": {}},
{"name": "api-down", "overrides": {
"http": {"https://api.example.com/health": {"status": 500}}
}}
]
}
Each scenario runs as an independent walk with its own report. Drop into CI as a multi-environment gate. See docs/simulate.md for the full fixture format.
Observability โ --trace, --audit, --report¶
Same hook order, three audiences:
| Flag | Audience | Output |
|---|---|---|
--trace |
Human, live | Streams โธ kind argsโฆ to stderr as each op fires; indents block-op children. |
--audit FILE.ndjson |
Machines | One JSON line per op (kind, args, duration, result/error). Feeds into observability pipelines. |
--report |
Human, post-run | Renders the span tree after the run with full error context. |
All three derive from the same Tracer interface, so timing + nesting + ordering are consistent across them.
perch --trace deploy # stream live
perch --trace=trace.log deploy # โ file
perch --audit /var/log/perch.ndjson deploy # JSON for downstream
perch --report deploy # post-run tree
--trace and --report are mutually exclusive (they share a slot); --audit composes with either.
Debugging your perch programs¶
Tools to figure out why a command did what it did:
--dry-run โ walk the plan, skip execution¶
Prints every op with its arguments post-interpolation, in the order it would execute, without running anything. Catches: wrong ${var} resolution, surprise control-flow path, missing arg. The dry-run output is also legitimate documentation โ if you can't read what --dry-run prints, you have a code-clarity problem in the .perch file.
--ask โ interactive op-by-op confirmation¶
y = run this op, n = skip, a = run everything from here without asking, q = quit. Best for first-time runs of an unfamiliar .perch file (especially community recipes).
--trace โ live stream of every op¶
perch --trace deploy 2>trace.log
# stderr now has timestamped "โธ kind argsโฆ โ (dur)" lines for every op
Shows execution as it happens, properly indented for block-op nesting. Useful when you suspect the program is "stuck" somewhere โ --trace will show you the last op that fired.
--audit FILE.ndjson โ structured event log¶
One JSON line per op:
Pipe into jq to inspect:
perch --audit /tmp/run.ndjson deploy
jq 'select(.error)' < /tmp/run.ndjson # every failing op
jq 'select(.dur_ms > 1000) | .kind' < /tmp/run.ndjson # slow ops
jq -r '.kind' < /tmp/run.ndjson | sort | uniq -c # op-kind histogram
--report โ post-run span tree¶
After the run, prints a tree of every op call, indented by block nesting, annotated with timings and error context. Perfect for "the command failed โ what was the exact path?"
Reading common errors¶
| Error | What it means | Fix |
|---|---|---|
unknown op kind "foo" |
The op isn't a built-in and isn't a template you imported. |
perch --check lists all unknown kinds. Check spelling. |
unresolved ${name} |
You referenced ${name} but nothing binds it. |
Add an arg, let, or a top-level NAME = value binding. |
command "deploy" not found (with "did you meanโฆ?") |
Typo. | The suggestion is fuzzy-matched; usually right. |
host "X" not in --allow-host allowlist |
Tighter outer policy than the command needs. | Either widen the launch flag or add the host to a per-command allowlist. |
module closed with exit_code(N) |
A wasm_run module called os.Exit(N). |
The exit code is the module's own decision โ check the module's source. |
redirect refused: host "X" not in allowlist |
HTTP got 3xx to a host you didn't allow. | Add the redirect destination to --allow-host, OR investigate (could be SSRF/phishing). |
interpreter: --max-runtime exceeded |
Wall-clock cap hit. | Either raise --max-runtime or look at the audit log for the slow op. |
Reading the audit log practically¶
The audit log is the single most useful artifact for production triage. Standard pipeline:
# CI step: always emit audit
perch --audit /tmp/perch-$$.ndjson deploy
# On failure, archive the audit log alongside the test reports
if [ $? -ne 0 ]; then
cp /tmp/perch-$$.ndjson "${CI_ARTIFACT_DIR}/audit.ndjson"
fi
# Later: investigate
jq -r '"\(.ts) \(.kind) \(.args.cmd // .args)" + (if .error then " โ ERROR: \(.error)" else "" end)' \
< audit.ndjson | tail -30
You're aiming for: the last ~30 ops before the failure, in order, with their post-interpolation arguments. That's almost always enough to reproduce.
The five surfaces¶
Same .perch file, five front-ends:
1. CLI (default)¶
2. Web UI¶
Five tabs (โถ Run / ๐งช Simulate / ๐ Scan / โ Check / โน About). Type-aware form inputs, live output streaming, dark mode. Single-tenant + localhost-bound by default โ put behind a reverse proxy for shared access. See docs/web-ui.md.
3. REPL¶
Each input wrapped as an ad-hoc command body and run. Bindings persist across lines.
4. MCP server (for AI agents)¶
# Claude Desktop / Claude Code / Cursor / Zed:
{
"perch": {
"command": "perch-mcp",
"args": ["-f", "/abs/path/commands.perch"]
}
}
Every visible command becomes an MCP tool with a JSON-schema arg surface. Capability flags inherit from the launch (perch-mcp --no-shell --no-network --env KUBECONFIG -f ops.perch). Live progress streaming via notifications/progress. See docs/mcp.md.
5. Portable binary¶
Recipients run one file. No Go, no perch, no source clone.
Patterns and idioms¶
Eighteen things you'll reach for on every serious perch project. Most are 5-line idioms โ copy-paste them, adapt the variable names.
1. Require a binary (with a useful error)¶
template require_bin
arg name string
do
if not has_bin "${name}"
fail "${name} is required โ install it first"
end
end
end
# Use:
require_bin "kubectl"
require_bin "docker"
2. Idempotent setup ("install if missing")¶
marker = format "${cache_dir}/myapp/.setup-${version}"
if not exists "${marker}"
# ... do expensive one-time setup ...
touch "${marker}"
end
3. Wait for a port¶
template wait_for_port
arg host string
arg port string
arg timeout_s string
do
retry max=30 delay=1s
if not port_check "${host}" "${port}"
fail "${host}:${port} not yet ready"
end
end
end
end
# Use:
wait_for_port "localhost" "5432" "30"
4. Capture and reuse stdin / piped input¶
command count
description "Count lines on stdin"
do
raw = read_file "/dev/stdin"
n = length "${raw}"
print "${n}"
end
end
# usage: cat file.txt | perch count
5. Cache an expensive shell call¶
cache key="${file_hash}" ttl="24h"
cost = ./expensive-analyzer ${file}
end
print "${cost}" # populated whether cache hit or miss
The cache key should incorporate every input that affects the output (file hashes, args, tool version).
6. Validate args with regex¶
arg host
type string
description "Target host"
end
do
if not regex_match "${host}" "^[a-z0-9.-]+\.internal$"
fail "invalid host: ${host}"
end
# ... safe to use ${host} downstream ...
end
Always validate user-supplied identifiers BEFORE interpolating into shell strings. This is the #1 injection-vector pattern to know.
7. Forward unknown verbs (catch-all)¶
catch unknown
description "Forward to gh, like a typo-correcting alias"
proxy_args # required to bind ${proxy_args}
do
shell "gh ${proxy_args}"
end
end
Now perch repo view โ gh repo view, etc. Useful for wrapping an existing CLI. The proxy_args modifier is required โ without it, ${proxy_args} is unbound in the catch body and referencing it errors. This makes the catchโshell forwarding pattern explicit instead of accidental.
8. Parallel fan-out with limit¶
parallel max=4
for_each region in "us-east-1,us-west-2,eu-west-1,ap-south-1"
deploy-region.sh ${region}
end
end
max=4 caps concurrency. Without it the loop runs all branches at once, which is rarely what you want.
9. Build then verify¶
command release
do
build
test # tests run after a successful build
sign
publish # only if everything above passed
end
end
Invoking a command by name halts the parent on a non-zero from the callee. Chain commands as transactions.
10. Time-bounded operation¶
Wraps in a context.WithDeadline-style cap. The shell op can't be interrupted mid-syscall, but anything still pending in the next op after timeout returns ErrTimeout.
11. Atomic file write¶
tmp = mktemp_dir
write_file "${tmp}/out.json" "${data}"
mv "${tmp}/out.json" "${target}"
rm "${tmp}"
The mv is atomic on the same filesystem โ readers never see a partial file.
12. Detect-and-degrade tool fallback¶
if has_bin "rg"
matches = rg -l ${pattern} ./src
end
if not has_bin "rg"
matches = grep -r -l ${pattern} ./src
end
Prefer ripgrep; fall back to grep. Same pattern for fd/find, bat/cat, eza/ls.
13. Bundled assets pattern¶
bundle
include "./templates" as templates
include "./schemas" as schemas
end
command render
do
tmpl = read_file "${bundle_dir}/templates/email.html"
# ...
end
end
${bundle_dir} lazily extracts the embedded archive on first reference; falls back to ${script_dir} when running from a .perch file (not a built binary). Same code path in dev + production.
14. Secrets from environment, never the file¶
# WRONG: secret in source
API_KEY = "sk-abc123"
# RIGHT: read from env at runtime
command call_api
do
key = get_env "API_KEY"
if "${key}" == ""
fail "API_KEY env var not set"
end
# ... use ${key} ...
end
end
Combine with --env API_KEY to restrict which env vars resolve via ${NAME}. Audit logs redact env vars whose names contain KEY, TOKEN, SECRET, PASSWORD (case-insensitive).
15. Test mode for destructive ops¶
command delete_old_backups
arg dry_run
type bool
default true # SAFE DEFAULT
end
do
old = find /backup -mtime +30
if "${dry_run}" == "true"
print "would delete:"
print "${old}"
end
if "${dry_run}" == "false"
for_each f in "${old}"
rm "${f}"
end
end
end
end
# Default-safe: `perch delete_old_backups` is a dry run.
# Real run requires `perch delete_old_backups -dry_run=false`.
16. Reproducible "what version are we on"¶
command version
do
rev = git rev-parse --short HEAD
dirty = git status --porcelain
suffix = ""
if "${dirty}" != ""
suffix = "-dirty"
end
print "${rev}${suffix}"
end
end
Use as a build-id seed: buildid = version โ bake into binary.
17. JSON pipeline (no jq shell-out)¶
body = http_get "https://api.example.com/users"
count = json_count "${body}" ".items"
first_name = json_get "${body}" ".items.0.name"
print "got ${count} users; first: ${first_name}"
Pure perch โ no jq dependency, works on Windows, no quoting issues.
18. Service-style command with cleanup¶
command serve
detached
on_signal stop_serve
do
write_file "${temp_dir}/myapp.pid" "${pid}"
go run ./cmd/server
end
end
command stop_serve
private
do
if exists "${temp_dir}/myapp.pid"
pid = read_file "${temp_dir}/myapp.pid"
kill ${pid}
rm "${temp_dir}/myapp.pid"
end
end
end
Detached + on_signal + PID-file. The standard "Ctrl-C cleans up gracefully" shape.
Walkthrough 1 โ Replace your Makefile¶
Goal: Replace a 200-line Makefile that breaks on Windows / Apple Silicon / the new intern's laptop. One file, three surfaces (CLI for local dev, CI for builds, --server for support).
# build.perch
name "myapp"
version "1.0.0"
about "Build, test, release myapp"
APP_NAME = "myapp"
OUT_DIR = "./dist"
template ensure_clean
arg dir string
do
rm "${dir}"
mkdir "${dir}"
end
end
command build
description "Compile for the current platform"
do
ensure_clean "${OUT_DIR}/${os}-${arch}"
go build -o ${OUT_DIR}/${os}-${arch}/${APP_NAME} ./cmd/${APP_NAME}
size = file_size "${OUT_DIR}/${os}-${arch}/${APP_NAME}"
print "โ built ${size} bytes"
end
end
command release
description "Cross-compile for darwin/linux/windows ร amd64/arm64"
do
parallel max=4
with_env GOOS=darwin,GOARCH=arm64
go build -o ${OUT_DIR}/darwin-arm64/${APP_NAME} ./cmd/${APP_NAME}
end
with_env GOOS=darwin,GOARCH=amd64
go build -o ${OUT_DIR}/darwin-amd64/${APP_NAME} ./cmd/${APP_NAME}
end
with_env GOOS=linux,GOARCH=amd64
go build -o ${OUT_DIR}/linux-amd64/${APP_NAME} ./cmd/${APP_NAME}
end
with_env GOOS=windows,GOARCH=amd64
go build -o ${OUT_DIR}/windows-amd64/${APP_NAME}.exe ./cmd/${APP_NAME}
end
end
print "โ all 4 targets built"
end
end
command test
description "Run the test suite"
do
go test -race ./...
if exists "./integration"
go test -tags=integration ./integration/...
end
end
end
command clean
description "Remove build artifacts"
do
rm "${OUT_DIR}"
end
end
command ci
description "What CI runs"
do
test
release
end
end
Wire it up:
# Local dev
perch test
perch build
perch release
# Pre-commit
perch --check # statically validate
perch test # unit tests sandboxed
# CI
perch ci # full pipeline
# Support (non-devs)
perch --server --port 8080
# Support engineers click verbs in a browser instead of typing kubectl
Same file. Five entry points. Zero CSS, zero Cobra/Click boilerplate, zero CI YAML duplication.
โ Full tutorial: tutorials/01-replace-your-makefile.md.
Walkthrough 2 โ Ship a self-installing tool¶
Goal: Distribute an internal CLI (Python project, or Node, or anything) as one binary. Recipients run one file. No pip install, no npm install, no toolchain on the target.
# stt.perch โ Speech-to-Text CLI, distributed as one binary
name "stt"
version "1.0.0"
bundle
include "./src" # the Python source tree
include "./requirements.txt"
end
template ensure_dir
arg path string
do
if not exists "${path}"
mkdir "${path}"
end
end
end
command install
description "Set up stt on this machine (idempotent)"
do
if not has_bin "python3"
fail "stt needs python3 (3.10+) โ install via your OS package manager"
end
install_dir = format "${cache_dir}/stt/${bundle_hash}"
ensure_dir "${install_dir}"
if not exists "${install_dir}/.installed"
print "โ extracting source to ${install_dir}"
bundle_extract "${install_dir}"
print "โ creating venv"
python3 -m venv ${install_dir}/.venv
print "โ installing dependencies"
${install_dir}/.venv/bin/pip install -r ${install_dir}/requirements.txt
touch "${install_dir}/.installed"
end
# Drop a launcher into ~/.local/bin
ensure_dir "${home_dir}/.local/bin"
write_file "${home_dir}/.local/bin/stt" "#!/bin/sh\nexec ${install_dir}/.venv/bin/python ${install_dir}/src/main.py \"$@\"\n"
chmod "${home_dir}/.local/bin/stt" "755"
print "โ installed. Run: stt --help"
print " (make sure ~/.local/bin is on your PATH)"
end
end
command uninstall
description "Remove stt"
do
rm "${cache_dir}/stt"
rm "${home_dir}/.local/bin/stt"
print "โ removed."
end
end
Build + ship:
perch --build -f stt.perch -o stt
scp stt user@host:/tmp/
ssh user@host '/tmp/stt install && stt example.wav'
The recipient needs only python3 on PATH. No pip, no virtualenv setup, no internet at install time, no version skew.
โ Full demo: demos/05-python-installer.
Walkthrough 3 โ Internal ops backend (web UI + MCP)¶
Goal: Give your team a self-service ops surface. Support engineers click verbs in a browser; engineers run them from a terminal; AI agents (Claude, Cursor) execute them through MCP. One source of truth for everything.
# ops.perch โ internal ops + runbooks
name "ops"
about "Internal ops surface for the platform team"
version "1.3.0"
APPROVED_HOSTS = "api.example.com,deploy.example.com,*.s3.amazonaws.com"
command restart_service
description "Restart a service on a remote host (validated)"
arg host
type string
description "Target host (must match /^[a-z0-9.-]+\.internal$/)"
end
arg service
type string
description "Service name (must be one of: web, worker, scheduler)"
end
do
if not regex_match "${host}" "^[a-z0-9.-]+\.internal$"
fail "invalid host: ${host}"
end
if not regex_match "${service}" "^(web|worker|scheduler)$"
fail "invalid service: ${service}"
end
ssh ops@${host} "systemctl restart ${service}"
end
end
command health_check
description "Check the API's /health endpoint"
do
body = http_get "https://api.example.com/health"
assert_contains "${body}" "ok"
print "โ healthy"
end
end
command list_pods
description "List pods in the prod namespace"
do
kubectl get pods -n prod
end
end
command tail_logs
description "Stream logs for a service"
arg service
type string
description "Service name"
end
do
kubectl logs -f deployment/${service} -n prod
end
end
# Hidden helper โ not visible to CLI / MCP / web UI
command _kube_context
private
do
kubectl config use-context prod
end
end
Launch surfaces¶
# CLI for engineers
perch -f ops.perch list_pods
# Web UI for support, locked down
perch -f ops.perch --server --port 8080 \
--no-shell-metachars \
--allow-bin ssh,kubectl \
--allow-host api.example.com,*.example.com \
--env KUBECONFIG \
--audit /var/log/perch-ops.ndjson
# MCP for AI agents, the strictest gates
perch-mcp -f /etc/perch/ops.perch \
--no-shell-metachars \
--allow-bin kubectl \
--env KUBECONFIG
The MCP server now lets Claude / Cursor / Zed call verbs like list_pods and tail_logs directly โ typed args, capability-gated, audit-logged. The agent can't shell out to anything other than kubectl; can't dial any host outside the allowlist; can't read env vars outside KUBECONFIG. All while the support team has a web UI for the same verbs.
โ Web UI deep dive: docs/web-ui.md. โ MCP deep dive: docs/mcp.md.
Walkthrough 4 โ Plugin host with WASM¶
Goal: Let third parties (or AI agents) write plugins that run inside your system, without granting them shell / network / fs access.
# plugin-host.perch
name "plugin-host"
version "1.0.0"
bundle
include "./plugins/tax.wasm" as tax
include "./plugins/discount.wasm" as discount
include "./plugins/shipping.wasm" as shipping
end
command run_plugin
description "Apply one declared plugin to an order"
arg plugin
type string
description "Plugin name (one of: tax, discount, shipping)"
end
do
if not exists "/tmp/order.json"
fail "no /tmp/order.json โ provide input first"
end
if "${plugin}" == "tax"
wasm_run tax
wasm_arg "/ro/input/order.json"
wasm_mount_read "/tmp"
end
end
if "${plugin}" == "discount"
wasm_run discount
wasm_arg "/ro/input/order.json"
wasm_mount_read "/tmp"
end
end
if "${plugin}" == "shipping"
wasm_run shipping
wasm_arg "/ro/input/order.json"
wasm_mount_read "/tmp"
wasm_allow_host "rates.example.com" # this one needs HTTP
end
end
end
end
command run_all
description "Apply every plugin in parallel"
do
parallel max=3
run_plugin "-plugin=tax"
run_plugin "-plugin=discount"
run_plugin "-plugin=shipping"
end
end
end
What's enforced (by the WASM runtime, not by perch's policy layer):
- A plugin trying to read
/etc/passwdโ file doesn't exist in its world - A plugin trying to spawn
curlโ no such syscall in WASI - A plugin trying to open a TCP socket โ not implemented
- A plugin trying to read
$AWS_KEYโ env var not in its allowlist - A plugin trying to write outside
/rwโ ENOENT
Plugins can be written in any language that targets WASI: Go, Rust, TinyGo, Zig, C++ via wasi-sdk, AssemblyScript. The contract is "read /ro/input/X, write JSON to stdout."
โ Killer demo with malicious plugin: demos/wasm-plugin-host. โ Five real-world WASM walkthroughs: docs/wasm-walkthroughs.md.
Walkthrough 5 โ CI gate with simulate¶
Goal: Before merging a change to your .perch file, prove it won't break on the production host AND won't break on the staging host AND won't be vulnerable to an API outage.
# .github/workflows/perch-gate.yml
- name: Validate perch program
run: perch --check -f deploy.perch
- name: Scan for risks
run: perch --scan -f deploy.perch
- name: Simulate against all production scenarios
run: perch -f deploy.perch simulate --sim-file ci/fixture.json
ci/fixture.json declares the scenarios CI cares about:
{
"os": "linux", "arch": "amd64",
"bins": ["docker", "kubectl", "git"],
"env": {"HOME": "/home/runner", "KUBECONFIG": "/etc/kube/config"},
"fs_write": ["/tmp", "/var/log/deploy"],
"network": ["api.github.com", "*.s3.amazonaws.com"],
"oracles": {
"shell_output": {
"git rev-parse HEAD": "abc123",
"kubectl get nodes -o name": "node/prod-1\nnode/prod-2"
},
"http": {
"https://api.github.com/health": {"status": 200, "body": "OK"}
},
"has_bin": {"docker": true, "kubectl": true}
},
"scenarios": [
{"name": "happy-path", "overrides": {}},
{"name": "github-down", "overrides": {
"http": {"https://api.github.com/health": {"status": 500}}
}},
{"name": "kubectl-missing", "overrides": {
"has_bin": {"kubectl": false}
}},
{"name": "github-redirect-to-evil", "overrides": {
"http": {"https://api.github.com/health":
{"status": 302, "redirect": "https://evil.com/payload"}}
}}
]
}
Each scenario runs as an independent walk. Exit code is non-zero if any scenario has a WILL_FAIL. So you can prove things like:
- "If the deploy verb fires while api.github.com returns 500, every downstream op that doesn't degrade gracefully will fail."
- "If a future change introduces a
--allow-hostfor *.evil.com via a malicious redirect, simulate catches it before merge." - "If
kubectlisn't on the host, our verb fails fast instead of silently skipping the deploy."
โ Simulate deep dive: docs/simulate.md.
Walkthrough 6 โ Database backup + restore tool¶
Goal: A single db.perch file that backs up Postgres, MySQL, or SQLite, encrypts the dump, ships it to S3, and can restore from any backup. Schedulable from cron, callable from CI, drivable by AI agent through MCP.
name "db"
version "1.0.0"
about "Database backup + restore โ Postgres / MySQL / SQLite"
BACKUP_DIR = "${home_dir}/.cache/db-backups"
S3_BUCKET = "my-backups"
template ensure_dir
arg path string
do
if not exists "${path}"
mkdir "${path}"
end
end
end
command backup
description "Snapshot a database to local file + ship to S3"
arg engine
type string
description "Engine: postgres | mysql | sqlite"
end
arg conn
type string
description "Connection string OR file path (sqlite)"
end
arg label
type string
default "manual"
description "Tag for the backup filename"
end
do
ensure_dir "${BACKUP_DIR}"
stamp = format_time "now" "2006-01-02_150405"
out = format "${BACKUP_DIR}/${engine}-${label}-${stamp}.sql"
if "${engine}" == "postgres"
shell "pg_dump ${conn} > ${out}"
end
if "${engine}" == "mysql"
shell "mysqldump --single-transaction ${conn} > ${out}"
end
if "${engine}" == "sqlite"
shell "sqlite3 ${conn} .dump > ${out}"
end
size = file_size "${out}"
print "โ dumped ${size} bytes โ ${out}"
# Encrypt + compress
gzip ${out}
key = get_env "BACKUP_GPG_KEY"
if "${key}" != ""
gpg --batch --yes --recipient ${key} --encrypt ${out}.gz
rm "${out}.gz"
final = format "${out}.gz.gpg"
end
if "${key}" == ""
final = format "${out}.gz"
end
# Ship to S3 (if aws cli is present)
if has_bin "aws"
aws s3 cp ${final} s3://${S3_BUCKET}/
print "โ uploaded to s3://${S3_BUCKET}/"
end
if not has_bin "aws"
print "โน aws CLI not found โ backup stays local at ${final}"
end
# Local retention: keep 14 most recent
prune_local "-engine=${engine}" "-keep=14"
end
end
command restore
description "Restore from a local or S3 backup file"
arg engine type string description "Engine" end
arg conn type string description "Connection string (target db)" end
arg path type string description "Backup file path (.sql, .sql.gz, .sql.gz.gpg)" end
do
if not exists "${path}"
fail "backup file not found: ${path}"
end
tmp = mktemp_dir
stage = format "${tmp}/restore.sql"
# Decrypt if needed
if has_suffix "${path}" ".gpg"
shell "gpg --batch --decrypt ${path} > ${stage}.gz"
gunzip ${stage}.gz
end
if has_suffix "${path}" ".gz"
shell "gunzip -c ${path} > ${stage}"
end
if not has_suffix "${path}" ".gz"
cp "${path}" "${stage}"
end
# Restore
if "${engine}" == "postgres"
shell "psql ${conn} < ${stage}"
end
if "${engine}" == "mysql"
shell "mysql ${conn} < ${stage}"
end
if "${engine}" == "sqlite"
shell "sqlite3 ${conn} < ${stage}"
end
rm "${tmp}"
print "โ restored"
end
end
command prune_local
description "Remove old local backups, keeping the N most recent"
arg engine type string end
arg keep type int default 14 end
do
# Sort by mtime, drop oldest. Implementation per OS:
if is_unix
shell "ls -1t ${BACKUP_DIR}/${engine}-*.sql.gz* 2>/dev/null | tail -n +$((${keep}+1)) | xargs -r rm"
end
if is_windows
shell "Get-ChildItem ${BACKUP_DIR}\\${engine}-*.sql.gz* | Sort-Object LastWriteTime -Desc | Select-Object -Skip ${keep} | Remove-Item"
end
end
end
command list_backups
description "Show backups available locally and in S3"
do
print "โโ Local โโ"
if exists "${BACKUP_DIR}"
ls -lh ${BACKUP_DIR}/
end
if has_bin "aws"
print "โโ S3 โโ"
aws s3 ls s3://${S3_BUCKET}/
end
end
end
# Test mode
command test_roundtrip
test
test_allow_shell
description "Backup + restore + verify integrity on a tiny SQLite DB"
do
tmp = mktemp_dir
shell "sqlite3 ${tmp}/src.db 'CREATE TABLE t(x); INSERT INTO t VALUES(42);'"
backup "-engine=sqlite" "-conn=${tmp}/src.db" "-label=test"
# Most recent backup wins:
latest = shell_output "ls -1t ${BACKUP_DIR}/sqlite-test-*.sql.gz* | head -1"
restore "-engine=sqlite" "-conn=${tmp}/dst.db" "-path=${latest}"
got = shell_output "sqlite3 ${tmp}/dst.db 'SELECT x FROM t;'"
assert_eq "${got}" "42"
rm "${tmp}"
end
end
Schedule from cron:
# /etc/cron.d/db-backup
0 3 * * * appuser /usr/local/bin/perch -f /etc/db.perch backup engine:postgres conn:'host=db.internal dbname=prod' label:nightly
Hand to an agent via MCP:
{"perch_db": {"command": "perch-mcp", "args": ["-f", "/etc/db.perch", "--env", "BACKUP_GPG_KEY,AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY"]}}
The agent now has typed backup, restore, list_backups, prune_local verbs. It can't shell out to anything other than the database tools and aws (constrain further with --allow-bin).
Walkthrough 7 โ SSL certificate rotation¶
Goal: Automate the cert lifecycle โ request from Let's Encrypt, install to nginx, reload, monitor expiry. One file replaces an Ansible playbook + a cron job + a Slack-alerting script.
name "cert"
version "1.0.0"
CERT_DIR = "/etc/letsencrypt/live"
NGINX_CONF = "/etc/nginx/conf.d"
ALERT_WEBHOOK = "https://hooks.slack.com/services/REPLACEME"
command issue
description "Get a new cert from Let's Encrypt for a domain"
arg domain type string description "Domain (e.g. example.com)" end
arg email type string description "Contact email for LE registration" end
do
require_bin "certbot"
certbot certonly --non-interactive --agree-tos --email ${email} --webroot -w /var/www/html -d ${domain}
install_to_nginx "-domain=${domain}"
alert "-msg=issued cert for ${domain}"
end
end
command renew
description "Try to renew every cert; reload nginx only if anything changed"
do
before = shell_output "ls -lT ${CERT_DIR}/*/fullchain.pem 2>/dev/null | md5sum"
certbot renew --quiet
after = shell_output "ls -lT ${CERT_DIR}/*/fullchain.pem 2>/dev/null | md5sum"
if "${before}" != "${after}"
nginx -s reload
alert "-msg=renewed cert(s); reloaded nginx"
end
if "${before}" == "${after}"
print "nothing to renew"
end
end
end
command install_to_nginx
description "Generate nginx conf for a domain; symlink cert paths; reload"
arg domain type string end
do
conf = format "${NGINX_CONF}/${domain}.conf"
write_file "${conf}" "server {
listen 443 ssl http2;
server_name ${domain};
ssl_certificate ${CERT_DIR}/${domain}/fullchain.pem;
ssl_certificate_key ${CERT_DIR}/${domain}/privkey.pem;
location / { proxy_pass http://127.0.0.1:8080; }
}
"
nginx -t
nginx -s reload
end
end
command check_expiry
description "Find certs expiring within N days; alert if any"
arg threshold_days type int default 14 end
do
found = ""
for_each cert in "${CERT_DIR}/*/cert.pem"
days = shell_output "openssl x509 -in ${cert} -enddate -noout | awk -F= '{print $2}' | xargs -I{} date -d '{}' +%s | awk -v now=$(date +%s) '{print int(($1-now)/86400)}'"
if days < threshold_days
domain = shell_output "basename $(dirname ${cert})"
found = format "${found}${domain} (${days}d)\n"
end
end
if "${found}" != ""
alert "-msg=certs expiring soon:\n${found}"
end
end
end
command alert
description "Post a message to the Slack webhook"
private
arg msg type string end
do
body = format '{"text":"[cert.perch] ${msg}"}'
curl -sS -X POST -H "Content-Type: application/json" -d ${body} ${ALERT_WEBHOOK}
end
end
template require_bin
arg name string
do
if not has_bin "${name}"
fail "${name} is required"
end
end
end
Schedule:
# /etc/cron.d/cert
17 3 * * * root /usr/local/bin/perch -f /etc/cert.perch renew
0 9 * * 1 root /usr/local/bin/perch -f /etc/cert.perch check_expiry threshold_days:14
One file. Replaces three tools (the Ansible cert role, the cron entry, the alert script). Operators run perch -f /etc/cert.perch issue domain:foo.com email:ops@foo.com for new domains. CI smoke-tests it with simulate before merge.
Walkthrough 8 โ Multi-environment deployer¶
Goal: Same deploy.perch deploys to dev / staging / prod with environment-appropriate guards. Prod requires confirmation. All envs use the same verbs.
name "deploy"
version "2.0.0"
PROD_CONFIRM_TOKEN = "yes-deploy-prod"
command deploy
description "Deploy to a target environment"
arg env
type string
description "Environment: dev | staging | prod"
end
arg image
type string
description "Container image (full ref with tag)"
end
arg confirm
type string
default ""
description "(prod only) Type 'yes-deploy-prod' to authorise"
end
do
# 1. Validate env name
if not regex_match "${env}" "^(dev|staging|prod)$"
fail "env must be one of: dev, staging, prod (got ${env})"
end
# 2. Validate image ref
if not regex_match "${image}" "^[a-z0-9./_-]+:[a-z0-9._-]+$"
fail "image must be in the form name:tag โ got ${image}"
end
# 3. Prod confirmation gate
if "${env}" == "prod"
if "${confirm}" != "${PROD_CONFIRM_TOKEN}"
fail "prod deploys require -confirm=${PROD_CONFIRM_TOKEN}"
end
end
# 4. Capability gate by env
_set_kube_context "-env=${env}"
# 5. Diff before apply
print "โโ Pending changes for ${env} โโ"
helm diff upgrade myapp ./chart --set image=${image} -n ${env}
# 6. Apply
if "${env}" == "prod"
_alert "-msg=deploying ${image} to PROD by ${user}"
end
helm upgrade --install myapp ./chart --set image=${image} -n ${env} --wait
# 7. Smoke test
smoke "-env=${env}"
# 8. Record the deploy
stamp = format_time "now" "2006-01-02T15:04:05Z"
append_file "${cache_dir}/deploys.ndjson" "{\"env\":\"${env}\",\"image\":\"${image}\",\"by\":\"${user}\",\"at\":\"${stamp}\"}\n"
end
end
command rollback
description "Rollback an environment to the previous release"
arg env type string end
arg confirm type string default "" end
do
if "${env}" == "prod"
if "${confirm}" != "${PROD_CONFIRM_TOKEN}"
fail "prod rollbacks require -confirm=${PROD_CONFIRM_TOKEN}"
end
end
_set_kube_context "-env=${env}"
helm rollback myapp -n ${env}
smoke "-env=${env}"
_alert "-msg=rolled back ${env} (by ${user})"
end
end
command smoke
description "Quick health check against an env's API"
arg env type string end
do
host = format "api-${env}.example.com"
if "${env}" == "prod"
host = "api.example.com"
end
retry max=10 delay=3s
body = http_get "https://${host}/health"
assert_contains "${body}" "ok"
end
print "โ ${env} healthy"
end
end
command _set_kube_context
private
arg env type string end
do
kubectl config use-context ${env}-cluster
end
end
command _alert
private
arg msg type string end
do
url = get_env "SLACK_WEBHOOK"
if "${url}" != ""
shell "curl -sS -X POST -H 'Content-Type: application/json' -d '{\"text\":\"${msg}\"}' ${url}"
end
end
end
# Test that the env validator rejects garbage
command test_env_validator
test
description "deploy with bogus env must fail"
do
# We can't actually call `deploy` here without side effects.
# Test the regex in isolation instead:
if regex_match "not-an-env" "^(dev|staging|prod)$"
fail "regex accepted invalid env"
end
end
end
Invocations:
# Dev โ no confirmation required
perch deploy env:dev image:myapp:abc123
# Prod โ explicit confirmation
perch deploy env:prod image:myapp:abc123 confirm:yes-deploy-prod
# CI pipeline (staging on every push to main)
perch deploy env:staging image:myapp:${GITHUB_SHA}
Same file. Three environments. Confirmation gate that's IMPOSSIBLE to bypass via typo (you have to type the exact token). Audit log of every deploy in ~/.cache/perch/deploys.ndjson.
Walkthrough 9 โ File-processing pipeline¶
Goal: Read a CSV of customer records, normalize + enrich + validate, emit cleaned JSON. Classic ETL shape, scriptable from CI / cron / agent.
name "etl"
version "1.0.0"
SCHEMA_HASH = "v1"
command process
description "Run the full pipeline: extract โ transform โ load"
arg input type string description "Input CSV path" end
arg output type string description "Output JSON path" end
do
if not exists "${input}"
fail "input file not found: ${input}"
end
# 1. Hash inputs for cache invalidation
in_hash = sha256_file "${input}"
cache_key = format "${SCHEMA_HASH}:${in_hash}"
# 2. Skip if cached output matches
cache key:"${cache_key}" ttl:"24h"
_extract "-input=${input}"
_transform
_validate
_load "-output=${output}"
end
print "โ pipeline done; output at ${output}"
end
end
command _extract
private
arg input type string end
do
raw = read_file "${input}"
rows = csv_parse "${raw}"
n = json_count "${rows}" "."
print "extracted ${n} rows from ${input}"
write_file "${temp_dir}/etl-rows.json" "${rows}"
end
end
command _transform
private
do
rows = read_file "${temp_dir}/etl-rows.json"
# Hand off to a WASM module that does the real lift in Go/Rust:
wasm_run "${script_dir}/transform.wasm"
wasm_arg "/ro/etl-rows.json"
wasm_arg "/rw/etl-rows-clean.json"
wasm_mount_read "${temp_dir}"
wasm_mount_write "${temp_dir}"
end
end
end
command _validate
private
do
# In-perch sanity checks before declaring "ok":
rows = read_file "${temp_dir}/etl-rows-clean.json"
n = json_count "${rows}" "."
if n < 1
fail "transform produced 0 rows"
end
# Every row must have an `email` field:
for_each row in "${rows}"
email = json_get "${row}" ".email"
if "${email}" == ""
fail "row missing email field"
end
if not regex_match "${email}" "^[^@]+@[^@]+\.[^@]+$"
fail "invalid email: ${email}"
end
end
print "โ validated ${n} rows"
end
end
command _load
private
arg output type string end
do
mv "${temp_dir}/etl-rows-clean.json" "${output}"
end
end
command serve_pipeline
description "Watch a dir; process new CSVs as they arrive"
do
retry max=99999 delay=5s
in = shell_output "ls ./inbox/*.csv 2>/dev/null | head -1"
if "${in}" == ""
fail "no new files" # forces retry to wait
end
out = format "./outbox/${user}-$(basename ${in} .csv).json"
process "-input=${in}" "-output=${out}"
mv "${in}" "./done/"
end
end
end
Notes:
wasm_runfor the real work. The transform step is in Go/Rust compiled to WASM โ.wasmfiles travel with the binary viabundle. Bytewise-deterministic across OSes; no Python/Node toolchain on the target.cachekey includes the schema version. Any rev to the WASM module changesSCHEMA_HASH, invalidating every prior result.serve_pipelineis a "poll" pattern. Retry with a long delay + afailon "nothing to do" creates a soft daemon. For real production, run under systemd or supervisord and usedetached+on_signalfor clean shutdown.
Walkthrough 10 โ Git workflow wrapper¶
Goal: Replace your team's three different "how do I open a PR" wikis with one pr.perch file. Same verbs locally, in CI, and via the web UI for non-devs reviewing branches.
name "pr"
version "1.0.0"
about "Standardised git workflow โ branch, commit, push, PR"
DEFAULT_BASE = "main"
command branch
description "Create a branch from the latest base"
arg name
type string
description "Branch name (will be prefixed with username)"
end
arg base
type string
default "${DEFAULT_BASE}"
description "Base branch (default: main)"
end
do
# Validate name (no slashes; no spaces)
if not regex_match "${name}" "^[a-z0-9-]+$"
fail "branch name must be lowercase alphanumeric with hyphens"
end
git fetch origin
git switch ${base}
git pull --ff-only
git switch -c ${user}/${name}
print "โ on ${user}/${name}"
end
end
command commit
description "Stage and commit (conventional-commits format)"
arg type
type string
description "Commit type: feat | fix | refactor | docs | test | chore"
end
arg msg
type string
description "Commit message (without the type prefix)"
end
do
if not regex_match "${type}" "^(feat|fix|refactor|docs|test|chore)$"
fail "type must be one of: feat fix refactor docs test chore"
end
git add -A
git commit -m "${type}: ${msg}"
end
end
command pr
description "Open a PR for the current branch"
arg title
type string
default ""
description "PR title (defaults to last commit message)"
end
arg base
type string
default "${DEFAULT_BASE}"
end
do
branch = git symbolic-ref --short HEAD
if "${branch}" == "${base}"
fail "you're on ${base} โ make a feature branch first (`perch branch name:my-feature`)"
end
# Push
git push -u origin ${branch}
# Title fallback
if "${title}" == ""
title = git log -1 --pretty=%s
end
# Open the PR via gh
require_bin "gh"
url = gh pr create --base ${base} --title ${title} --body "Opened via perch pr."
print "โ PR opened: ${url}"
# Try to copy to clipboard
if has_bin "pbcopy"
shell "echo '${url}' | pbcopy"
print " (copied to clipboard)"
end
if has_bin "xclip"
shell "echo '${url}' | xclip -selection clipboard"
end
end
end
command land
description "Squash-merge the current PR + delete the branch + sync local"
do
require_bin "gh"
branch = git symbolic-ref --short HEAD
base = gh pr view --json baseRefName -q .baseRefName
gh pr merge --squash --auto --delete-branch
git switch ${base}
git pull --ff-only
print "โ landed; back on ${base}"
end
end
command sync
description "Pull the latest base; rebase the current branch"
do
branch = git symbolic-ref --short HEAD
git fetch origin ${DEFAULT_BASE}
git rebase origin/${DEFAULT_BASE}
print "โ rebased ${branch} on ${DEFAULT_BASE}"
end
end
command cleanup
description "Remove local branches whose remotes are gone"
do
git fetch --prune
shell "git branch -vv | grep ': gone]' | awk '{print $1}' | xargs -r git branch -D"
end
end
template require_bin
arg name string
do
if not has_bin "${name}"
fail "${name} is required โ install it first"
end
end
end
Now your team's workflow is:
perch branch name:fix-auth-bug
# ... edit code ...
perch commit type:fix msg:"validate auth header before decoding"
perch pr title:"Fix: validate auth header before JWT decode"
perch land
vs. the old way (three different shell aliases on three different laptops, none of which work on the new intern's Windows machine). And the same file works in CI:
Distribution โ getting your binary to users¶
Three patterns by audience:
a) Internal team, you control the hosts¶
b) External team / customers¶
Ship via GitHub Releases + a curl | sh install script:
# install.sh in your repo
#!/bin/sh
VERSION="${1:-latest}"
curl -fsSL "https://github.com/yourorg/myapp/releases/${VERSION}/download/myapp-$(uname -s)-$(uname -m)" \
-o /usr/local/bin/myapp
chmod +x /usr/local/bin/myapp
echo "installed myapp $(myapp --version)"
End users:
c) AI agents¶
Set up perch-mcp in the agent's config:
{
"mcpServers": {
"yourapp": {
"command": "perch-mcp",
"args": ["-f", "/etc/yourapp/commands.perch", "--no-shell-metachars", "--allow-bin", "kubectl"]
}
}
}
The agent now has typed access to every verb. See docs/mcp.md.
Production deployment¶
Running perch in production โ your binary is on the boxes, agents are calling it, CI is wiring it into pipelines. The operational concerns:
Log management¶
Every production invocation should emit an audit log. Rotate it:
# /etc/cron.daily/perch-audit-rotate
#!/bin/sh
cd /var/log/perch
gzip *.ndjson 2>/dev/null
find . -name '*.ndjson.gz' -mtime +30 -delete
Or use logrotate:
Monitoring perch itself¶
The audit log IS your monitoring surface. Useful queries:
# Failure rate (last hour)
find /var/log/perch -name '*.ndjson' -mmin -60 -exec cat {} \; \
| jq 'select(.error)' | wc -l
# Slowest 10 ops in the last day
find /var/log/perch -name '*.ndjson' -mtime -1 -exec cat {} \; \
| jq -s 'sort_by(-.dur_ms) | .[0:10] | .[] | {kind, dur_ms, cmd: .args.cmd}'
# Verbs called by AI agents (via perch-mcp)
grep -h '"mcp"' /var/log/perch/*.ndjson | jq -r '.args | keys[0]'
Ship to a real log aggregator (Loki, Datadog, ELK) by tailing the NDJSON into your existing pipeline.
Hardening the launch¶
Defense-in-depth: every perch invocation in production should layer restrictions:
# Strict shape โ no shell, no network, only the env vars the program needs
perch -f /etc/myapp.perch \
--env KUBECONFIG,HOME,PATH \
--no-shell-metachars \
--allow-bin kubectl,curl \
--allow-host api.example.com \
--max-runtime 300 \
--audit /var/log/perch/$(date +%Y-%m-%d).ndjson \
deploy
Each flag closes one class of misuse:
| Flag | Closes |
|---|---|
--no-shell |
Removes the shell op entirely (use when your program is all native ops) |
--no-network |
Refuses HTTP / DNS / socket ops |
--no-subprocess |
Refuses pkg_install, kill_by_name, etc. |
--no-write |
Filesystem is read-only |
--no-shell-metachars |
Refuses shell strings with |, &&, ;, `, $() (forces simple cmd args form) |
--env K,K,โฆ |
Restricts which env vars resolve via ${NAME} |
--allow-bin N,โฆ |
Whitelists the first token of every shell call |
--allow-host H,โฆ |
HTTP allowlist (composes with the always-on SSRF guard) |
--max-runtime SECS |
Wall-clock cap |
Pre-flight any production launch with perch --scan -f myapp.perch โ it prints the recommended invocation for your file based on the ops it uses.
Systemd service template¶
# /etc/systemd/system/myapp.service
[Unit]
Description=myapp (perch-driven)
After=network.target
[Service]
ExecStart=/usr/local/bin/perch -f /etc/myapp.perch \
--no-shell-metachars \
--allow-bin docker,kubectl \
--allow-host api.example.com \
--max-runtime 0 \
--audit /var/log/perch/myapp.ndjson \
serve
Environment=KUBECONFIG=/etc/kube/config
User=appuser
Group=appgroup
Restart=on-failure
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Secrets¶
Never put secrets in the .perch file. Read from env:
do
token = get_env "API_TOKEN"
if "${token}" == ""
fail "API_TOKEN env var required"
end
# ... use ${token} ...
end
Inject via systemd's Environment= (read from a chmod-600 file), or via your secret manager (Vault / AWS Secrets Manager / 1Password CLI) at launch:
The audit log redacts env vars whose names match *KEY*, *TOKEN*, *SECRET*, *PASSWORD* (case-insensitive) โ but don't rely on that as your primary defense; use --env to restrict what perch can see in the first place.
Distribution checksums¶
When shipping a binary via --build, publish + check sha256:
For GitHub Releases the CI workflow at .github/workflows/release.yml (in this repo) already does this. The bundle_hash op gives the same hash from inside the running binary, so you can audit "did the bundle I built match the bundle currently running?"
Versioning + compatibility¶
A .perch file's surface IS its commands. To evolve safely:
- Add args with
optionalordefaultโ never break existing callers. - Add new commands freely โ they're additive.
- Rename / remove commands โ bump major version, document migration in CHANGELOG.
- Globals are read-only โ changing their value is a breaking change (the binary picks them up at startup; users who pin to
--version=1.0.0get the old value).
Bake version "1.2.0" at the top; surface via perch --version. For complex programs, expose command version that prints both the program version AND bundle_hash:
command version
do
print "myapp ${version}" # parse-time bound
print "bundle ${bundle_hash}" # runtime
print "perch ${perch_version}"
end
end
Common pitfalls¶
${var} inside double-double quotes¶
Variables interpolate INSIDE strings, even literal-looking ones:
shell "echo \"${var}\""
# perch substitutes ${var} first; the shell then sees the value
# If ${var} contains a quote, the shell receives a malformed string
Fix: validate ${var} with regex_match before interpolating, OR use ops that don't pass through the shell (print, write_file).
X = OP doesn't survive parallel¶
parallel
result = http_get "https://a.com/health" # local to this branch
end
print "${result}" # ERROR: ${result} is unbound here
Fix: write to a file inside the branch and read it after; or restructure so each branch is a separate command invocation whose return value you don't need to consume.
Forgetting to validate ${proxy_args}¶
catch unknown
proxy_args # explicit opt-in; without this, ${proxy_args} is unbound
do
shell "git ${proxy_args}" # โ user-controlled string into shell!
end
end
The proxy_args modifier is now required on both command and catch blocks to bind ${proxy_args}. Without it, referencing ${proxy_args} errors with unresolved_var โ the forwarding intent has to be visible in the source. But even with the modifier declared, you still need to harden the launch: if the user runs perch '$(rm -rf /)', that string lands in ${proxy_args} verbatim. Always pair proxy_args with --no-shell-metachars at launch, or validate each segment with regex_match before shelling.
if exists "${path}" race¶
exists is a snapshot. If another process creates/deletes the file between your if exists and the op that uses it, you have a TOCTOU race. For high-frequency ops, prefer "do the thing, catch the error" (e.g. mkdir is idempotent โ perch's version doesn't fail if the dir exists).
cache on non-deterministic body¶
The cache will hit; ${now} won't update. Cache only deterministic body shapes โ same key + same inputs โ same outputs.
Shadowing top-level bindings with args¶
region = "us-east-1"
command deploy
arg region type string end
do
print "${region}" # which one wins?
end
end
Args win over top-level bindings within their command body. If you want the binding, rename one of them (g_region, or target_region for the arg).
for_each over unparsed string¶
for_each splits on commas (and newlines), not whitespace.
Fix:
Forgetting --allow-private-ips for localhost dev¶
perch -f dev.perch --allow-host localhost up
# Fails: localhost resolves to 127.0.0.1 which the SSRF guard blocks.
Fix: add --allow-private-ips to your dev invocation. (Production should NOT use this flag.)
Single-quotes inside shell args¶
Fix: write the query to a tempfile, then psql -f tmp.sql. Or use a database op via WASM if it exists for your engine.
--build with no name¶
perch --build will fall back to a generic name; users get perch-app instead of myapp. Always declare name and version at the top of files you intend to build.
Running an unknown .perch file¶
# DON'T:
curl http://example.com/random.perch | perch -f /dev/stdin do_stuff
# DO:
curl http://example.com/random.perch > /tmp/script.perch
perch --scan -f /tmp/script.perch # audit FIRST
# If the scan looks fine, then:
perch --no-shell-metachars --max-runtime 60 -f /tmp/script.perch do_stuff
Treat external .perch files the same as you'd treat external shell scripts. --scan is your friend.
FAQ¶
Why not just use bash / Make / Just / Task?¶
| bash | Make | Just | Task | perch | |
|---|---|---|---|---|---|
| Cross-platform (without Cygwin/WSL) | โ | โ | partial | partial | โ |
| Typed args + per-command help | โ | โ | partial | partial | โ |
| Capability restrictions | โ | โ | โ | โ | โ |
| Web UI without writing one | โ | โ | โ | โ | โ |
| MCP / AI-agent surface | โ | โ | โ | โ | โ |
| Single-binary distribution with embedded project | โ | โ | โ | โ | โ |
Static --check / --scan / simulate |
โ | โ | โ | โ | โ |
| Sandboxed test framework built in | โ | โ | โ | โ | โ |
When you only need a cross-platform runner without the other surfaces, Just or Task are fine. perch shines when you want any combination of: capability gating, agent integration, single-binary distribution, web UI, static analysis, or strict cross-platform behavior.
Is perch ready for production?¶
Yes, for the use cases documented here. Limits are listed in Honest limits. The DSL surface is still evolving โ major releases will document breaking changes in CHANGELOG.md.
How do I keep my .perch file maintainable as it grows?¶
Three patterns:
- Split by domain into multiple files, then
import "ops/redis.perch"etc. - Lift repeated bodies into
templates in a shared_lib.perch. - Use
privateaggressively to hide internal helpers (_set_kube_context,_alert) from the visible CLI surface.
Aim for โค 200 lines per top-level .perch, โค 30 lines per command body. Anything beyond that โ refactor or push the heavy lifting into a WASM module.
How do I handle long-running services?¶
Use the detached + on_signal pattern (see Patterns & idioms #18). For real production daemons, run perch under systemd / supervisord with --max-runtime=0 (no cap) and the audit log going to a real log aggregator.
Can I extend perch with my own ops?¶
For now: no, not without forking. The op set is fixed at compile time of the perch binary. The intended extension point is wasm_run โ write your custom logic in any WASM-targeting language, embed it via bundle, invoke from a wasm_run block. This gives you full language flexibility without modifying perch.
Can perch call itself / other perch programs?¶
Yes, via shell "perch -f other.perch other_command". For an in-process equivalent, use import + a bare command call. The fat-binary case is more interesting: a built binary can shell "${script_path} other_command" to re-invoke itself (since ${script_path} is the binary's own location when running embedded).
How do I version-pin perch in CI?¶
Pin to a tagged release. Major-version bumps may break the DSL surface; minor + patch are non-breaking by policy.
What's the largest .perch file you'd recommend?¶
Past ~500 lines, split into multiple files via import. Past ~3000 lines total across imports, you're probably building something that should be a real Go application calling perch via its embedded program (the infra/interpreter package is consumable as a library). Most users never approach those limits.
Can I use perch as a library from another Go program?¶
Yes โ infra/interpreter is a public Go package. Load a domain.Program via infra/capyloader.Load, build an interpreter, dispatch commands. The whole CLI is a thin shell over this layer. No stable API guarantee yet โ pin a tag if you embed.
Are MCP tools generated automatically?¶
Yes. perch-mcp -f file.perch introspects every visible command and exposes it as an MCP tool with a JSON-schema arg surface derived from the arg ... end blocks. No code generation, no schema file to maintain. Add a command โ it's instantly callable by Claude / Cursor / Zed.
How do I roll back a deployed binary?¶
perch --build is deterministic: same .perch + same bundle โ same binary bytes (modulo timestamps in the underlying Go binary). Keep the prior binary version available (e.g. myapp-1.2.0, myapp-1.3.0); rollback = ln -sf myapp-1.2.0 /usr/local/bin/myapp. The audit log in ~/.cache/perch/deploys.ndjson (if you used the multi-env deployer pattern) tells you which version was running when.
Honest limits¶
What perch does not do (be precise about scope so you can decide):
| Not supported | What to use instead / when it lands |
|---|---|
| Kernel-level process isolation (namespaces, cgroups) | Use Docker if you need that. perch's wasm_run is by-construction isolation; shell is plain host process. |
| Memory / CPU resource limits | Roadmap. Today: --max-runtime for wall-clock; per-op deadlines via timeout block. |
| Streaming HTTP from WASM modules | Roadmap. Today: 32 MB buffered response, GET only, no custom headers. |
| Sockets from WASM modules | Blocked on WASI Preview 2 in wazero. |
| Multi-stage builds | Bundle is one tar. Pre-process artifacts before --build if you need staging. |
| Container registry / image pull | None. Distribute the binary itself (scp / GitHub Release / curl). |
| Plugins implemented in non-WASM languages | All WASM-targeting languages work (Go, Rust, TinyGo, Zig, C, AssemblyScript). Native plugins via shell work but have no isolation. |
Cross-compile via perch --build (target โ host) |
Roadmap. Today: --build produces a binary for the host's OS/arch; for cross-targeting, build perch itself with GOOS/GOARCH set. |
| External contributions to this repo | See CONTRIBUTING.md. Forking is encouraged. |
Quick reference card¶
File structure¶
name "..." about "..." version "..." # identity
NAME = VALUE # shared bindings (bare, top level)
bundle include "PATH" [as NAME] ... end # embed at --build time
template NAME arg X string โฆ do โฆ end # reusable body
import "PATH" [as NAMESPACE] # multi-file projects
command NAME description "..." [modifiers]
arg NAME type T default V end
do
OP ARGS
X = OP ARGS # capture result
if EXPR ... end # control flow
BLOCK_OP ARGS ... end # parallel / retry / etc.
TEMPLATE "value"
OTHER_COMMAND "-arg=value"
fail "msg"
end
end
catch unknown ... end # fallback for unknown verbs
Common CLI flags¶
-f FILE load this .perch file
--check validate (no execution)
--scan capability + risk audit
simulate [CMD] [--sim-*] what-if analyzer
--server [--port N] web UI
--shell REPL
--build [-o OUT] [--include PATH] portable binary
test [--filter PAT] [-v] run tests
--dry-run walk the plan, skip execution
--trace[=PATH] live op stream
--audit PATH.ndjson JSON event log
--report post-run span tree
--no-shell refuse shell ops
--no-network refuse network ops
--no-subprocess refuse subprocess ops
--no-write fs is read-only
--no-shell-metachars refuse | && ; etc in shell
--env K,K,... env allowlist
--allow-bin N,N,... shell argv[0] allowlist
--allow-host H,H,... network host allowlist
--max-runtime SECS wall-clock cap
Mental shortcuts¶
- A perch program is a CLI by default. Everything else (web UI, MCP, binary) renders from the same file.
- Sandboxes nest. Outer
--no-shell+ innersandboxblocks compose; inner can only tighten. wasm_runis the strict lane.shellis best-effort.- State doesn't survive across commands unless you write to a file or use
cache. Commands are stateless transactions. letis the only assignment. Globals are read-only after parse.
Where to next¶
- Recipes (22 ready-to-run) โ copy-paste solutions for Postgres / Redis / observe / aistack / etc.
- Language reference โ exhaustive grammar
- Op catalog โ every built-in op
- Tutorials โ three step-by-steps
- Simulate guide โ multi-scenario what-if analysis
- Web UI guide โ non-dev surface
- WASM walkthroughs โ five real-world workflows
- The OS analogy โ the deeper claim
Bug reports โ open an issue. Ideas / show-and-tell โ Discussions.