What perch is for¶
This is the long-form answer to "why would I use this?" Each section below is a real category of work that perch is good at, with a concrete sketch and an honest take on why it beats the alternatives. There's also a section at the end on when not to use perch.
If you only have 30 seconds: perch is good wherever you'd otherwise hand-roll a CLI with subcommands, a Makefile, a bash script, or a YAML-driven runner β and you want it to work on macOS / Linux / Windows out of the box, and you'd like to ship the result as a single binary, web UI, or MCP server without writing extra code.
The two leverage points that recur¶
Before the application catalog, two perch capabilities show up in almost every use case. Worth holding in your head.
1. One file β four frontends. A single commands.perch is callable as:
- a CLI (
perch <cmd>or your-named-binary after--build) - a web UI (
perch --server) - a REPL (
perch --shell) - an MCP tool surface for AI agents (
perch-mcp)
You write the file once. Which frontend matters depends on who's using it: developers want the CLI, operators want the web UI, AI agents want MCP. Same source.
2. perch --build produces a self-contained binary. The output is a fat binary with your program JSON appended; no Go toolchain or perch install needed on the recipient's machine. This collapses the "make a CLI tool" problem from "write a Cobra app, set up packaging, write a release pipeline" down to a single command.
Most applications below lean on one or both of these.
Third recurring property: HTTP is hardened by default. Every application that uses http_get, http_post, download, or http_status inherits perch's default-on protections β no SSRF to AWS metadata or local services, no https β http redirect downgrade, max 5 redirect hops, DNS-rebinding defense (multi-A check). Layer --allow-host api.github.com,*.docker.io,... to pin which hosts the script can reach (initial URLs AND every redirect target re-validated). Critical for the AI-agent applications below where the agent picks the URL. See sandbox Β§0c.
Application catalog¶
1. Replace your Makefile¶
The base case. Every make target: becomes a command NAME ... end. Cross-cutting variables are declared bare at the top level (NAME = value). The if os == "darwin" form covers the platform-conditional code you'd normally write with three Makefiles.
BIN_DIR = "./bin"
APP_NAME = "myapp"
command build
arg target
type string
default "darwin"
description "GOOS to build for"
end
do
mkdir "${BIN_DIR}/${target}"
with_env "GOOS=${target}"
go build -o ${BIN_DIR}/${target}/${APP_NAME} ./cmd/${APP_NAME}
end
end
end
command test
description "Run tests"
do
go test -race ./...
end
end
command ci
description "Lint + test + release"
do
test
go vet ./...
end
end
Why perch wins: Make doesn't work on Windows. make ci requires you to call go vet with the right quoting in three different OSes. perch's ops are first-class and cross-platform. --help is auto-generated and your commands.perch is also a CI script β run: perch ci in .github/workflows/ci.yml is the whole job definition.
β Worked example: demos/03-go-project and tutorials/01-replace-your-makefile.
2. Ship an internal team CLI as one binary¶
Your team has a folder of bash scripts: bin/backup, bin/restart-api, bin/check-disk. Half the team has the folder cloned, half doesn't. Onboarding takes 20 minutes of "git clone, add to PATH, sudo this, run that."
Fold the whole thing into one commands.perch, then:
The recipient runs ./ops backup immediately. No git clone. No Python venv. No node_modules.
name "ops"
about "Platform team operations CLI"
version "1.0.0"
command backup
description "Snapshot the primary DB to S3"
do
stamp = now "unix"
shell "pg_dump -h db-primary.internal -Fc > /tmp/db-${stamp}.dump"
aws s3 cp /tmp/db-${stamp}.dump s3://backups/db-${stamp}.dump
rm "/tmp/db-${stamp}.dump"
end
end
command restart_api
description "Roll-restart the api deployment"
do
kubectl rollout restart deployment/api -n prod
kubectl rollout status deployment/api -n prod
end
end
catch unknown
do
print "ops doesn't know '${unknown}'."
print "Try: ops backup | ops restart_api"
exit 1
end
end
Why perch wins: every alternative requires more steps. Cobra requires you to write argument parsing, help text, and a release pipeline. A bash distribution requires the recipient to install at least bash + the same set of dependencies you used. Click/Python requires Python on the host.
β Worked example: tutorials/02-ship-a-tool.
3. Wrap a clunky CLI as a friendly tool¶
Probably the highest-leverage application β and the one most teams have a use for.
Many tools have a good engine, bad UX. Docker is the canonical example: enormously powerful, but the day-to-day flow ("pull the image, run with these mounts and ports, stream logs, stop, clean up") requires memorising six different invocations. Same story for ffmpeg, kubectl, AWS CLI, openssl, rsync, gh, tar, git β every team has a handful of "I always have to look up the flags" tools.
perch lets you wrap any of these with a sane verb-driven CLI in 30 lines. The shippable binary doesn't require the recipient to learn the underlying tool at all.
Concrete example β a friendly Redis-via-Docker wrapper:
name "redis"
about "Friendly wrapper around the official Redis Docker image"
version "0.1.0"
IMAGE = "redis:7-alpine"
CONTAINER = "my-redis"
requires
bin "docker"
end
command install
description "Pull the Redis Docker image"
do
docker pull ${IMAGE}
end
end
command run
description "Start Redis in the background on the host port"
arg port
type int
default 6379
description "Host port to expose Redis on"
end
do
docker run -d --rm --name ${CONTAINER} -p ${port}:6379 ${IMAGE}
print "Redis running on port ${port}. Use 'redis cli' to connect."
end
end
command cli
description "Open a redis-cli session into the running container"
do
docker exec -it ${CONTAINER} redis-cli
end
end
command logs
description "Stream container logs"
do
docker logs -f ${CONTAINER}
end
end
command status
description "Is Redis running?"
do
docker ps --filter name=${CONTAINER}
end
end
command stop
description "Stop the running container"
do
docker stop ${CONTAINER}
end
end
command uninstall
description "Stop and remove the image"
do
stop
docker rmi ${IMAGE}
end
end
Then:
perch --build -o redis
./redis install
./redis run -port=6380
./redis cli
./redis logs
./redis stop
./redis uninstall
The recipient never types docker once. ./redis --help is the discoverable command list. ./redis run --help shows the typed arg with its default.
Pair it with --server and someone non-technical clicks buttons:
A designer who wants Redis locally for testing now opens a browser, clicks "run", and never thinks about Docker. The wrapper is the user-facing interface; the underlying tool is an implementation detail.
Pair it with MCP and an AI agent uses the same surface:
The agent sees redis_install / redis_run / redis_cli / redis_stop β semantic verbs, not Docker syntax. It can reliably orchestrate complex sequences without learning Docker.
Other "good engine, bad UX" candidates where this pattern shines:
| Underlying tool | What you wrap | Example commands |
|---|---|---|
| Docker / Podman | Image lifecycle for a specific service | install / run / cli / logs / stop / uninstall |
| kubectl | Your team's deploy / rollback / status flows | deploy / rollback / status / logs / exec |
| ffmpeg | Specific recipes you actually use | to_mp4 / extract_audio / make_gif / compress / concat |
| AWS CLI | Operations your team does | list_instances / rotate_key / fetch_logs / tail_cloudwatch |
| gh (GitHub) | Team-specific workflows | start_feature / open_pr / rebase_main / release_notes |
| openssl | Cert lifecycle | generate_cert / verify_cert / convert_pem / inspect |
| rsync | Curated backup / sync recipes | backup_docs / sync_dotfiles / mirror |
| tar / unzip | Project-specific archive flows | pack_release / unpack_release |
| git | Internal workflow conventions | start_feature / ship / fixup / cleanup_branches |
| terraform | Plan/apply/destroy with team guardrails | plan_staging / apply_staging / destroy_staging |
| psql / pg_dump | DB ops | db_backup / db_restore / db_psql / db_migrate |
| gpg | Encrypt/decrypt with sane defaults | encrypt_for_team / decrypt / verify_signature |
| systemctl / launchctl | Service management for your services | start / stop / restart / tail_logs |
Every one of these is something a team has a "wiki page of incantations" for. Each wiki page β a commands.perch β a shippable binary that turns the wiki page into a discoverable, type-safe CLI.
Why perch wins for this case specifically:
- The wrapper is 30 lines, not a 500-line Cobra app. No flag-parsing scaffolding, no
--helpboilerplate. - It ships as one binary. The recipient doesn't install perch, doesn't need Go, doesn't need the recipe file.
- Three frontends from one source. CLI for developers, web UI for non-engineers, MCP for AI agents. You write the recipe once.
--checkkeeps the wrapper honest. If you typo a flag indocker run -i β¦,perch --checkwon't catch the typo (it's inside a shell string), but typo'd command refs, missing args, broken command invocations all surface before users hit them.--helpis auto-generated from thedescriptionfields, with typed args and defaults. No "documentation drift."
This is the application that most teams will discover first. Wrapping a known-clunky tool is the gateway use case; from there, the same team finds they can do all the other applications below.
4. Extend an existing tool with team conventions (catch passthrough)¶
A close cousin of section 3, but a distinct pattern with its own use case. Instead of replacing a tool's UX, you extend it: add your team's high-value shortcuts on top while still letting users (and muscle memory) reach the underlying tool for anything else.
Inside catch, declare the proxy_args modifier to bind the full unknown invocation as ${proxy_args}. Without the modifier, ${proxy_args} is unbound β the forwarding pattern has to be explicit in the source. Forward it to the real binary and you have a drop-in superset.
name "git"
command ship
description "Commit all changes, push HEAD, open a PR"
arg msg
type string
description "Commit message"
end
do
git add -A
git commit -m ${msg}
git push -u origin HEAD
gh pr create --fill
end
end
command fixup
description "Amend the most recent commit with current changes"
do
git add -A
git commit --amend --no-edit
end
end
command cleanup
description "Delete branches already merged into main"
do
shell "git branch --merged main | grep -v '^[* ]*main$' | xargs -r git branch -d"
end
end
catch passthrough
description "Forward unknown commands to real git"
proxy_args # required to bind ${proxy_args}
do
shell "git ${proxy_args}"
end
end
Then perch --build -o git-team:
| Invocation | What happens |
|---|---|
git-team ship -msg="fix bug" |
Your custom multi-step workflow |
git-team fixup |
Amend + no-edit |
git-team cleanup |
Prune merged branches |
git-team status |
Falls through to real git status |
git-team log --oneline -10 |
Falls through; args preserved |
git-team rebase -i HEAD~3 |
Falls through |
Why perch wins: you get a superset of the underlying tool, not a replacement. Users keep their muscle memory; your team's conventions become first-class. --help lists only the additions, so people discover the new commands; familiar commands still work without docs.
Other tools that benefit from extension over replacement:
kubectlβ addkc deploy <svc>,kc rollback <svc>,kc tail <svc>; passthrough forkc get pods,kc describe, etc.terraformβ addtf plan-staging,tf apply-staging,tf destroy-stagingwith safety guards; passthrough fortf init,tf fmt.dockerβ addd up,d down,d shell; passthrough ford ps,d inspect.npmβ addn release,n bump-major,n publish-canary; passthrough forn install,n run.
5. LLM-safe wrapper around dangerous tools¶
When you give an AI agent access to a powerful tool, the grammar perch enforces becomes the security boundary. Instead of letting Claude / Cursor / Zed call kubectl or aws or terraform directly via a shell tool β where any prompt injection could cause arbitrary damage β you expose only the operations you've vetted, with explicit confirmation tokens for destructive ones.
name "prod-kubectl"
about "AI-safe surface for production kubectl ops"
# ββ Read-only ops β agents can call these freely βββββββββββββββββββ
command get_pods
description "List pods in prod"
do
kubectl get pods -n prod
end
end
command logs
description "Tail recent logs of one pod"
arg pod
type string
description "Pod name"
end
arg lines
type int
default 100
description "How many lines"
end
do
kubectl logs -n prod ${pod} --tail=${lines}
end
end
command status
description "Cluster snapshot"
do
kubectl get all -n prod
end
end
# ββ Mutating ops β require an explicit confirmation token βββββββββ
command restart_api
description "Roll-restart the api deployment (mutating)"
arg confirm
type string
description "Must be 'YES' β proves the agent meant to mutate"
end
do
if confirm != "YES"
fail "restart_api requires -confirm=YES (got: '${confirm}')"
end
print "Restarting api in prodβ¦"
kubectl rollout restart deployment/api -n prod
kubectl rollout status deployment/api -n prod
end
end
# ββ No catch-all β anything else is rejected hard βββββββββββββββββ
Wire it into the MCP client:
{
"mcpServers": {
"prod-k8s": {
"command": "perch-mcp",
"args": ["-f", "/abs/prod-kubectl.perch"]
}
}
}
The agent sees get_pods, logs, status, restart_api. It cannot call kubectl delete pod, kubectl drain node, kubectl scale --replicas=0. The grammar is exhaustive: every callable operation is declared.
Multi-layer safety the wrapper enforces:
- Whitelist by declaration. Unlisted operations are unreachable. No catch-all, no shell escape.
- Typed args.
arg lines int default 100β the agent can't sneak in a; rm -rf /(the value goes into a structured op, not a shell string). - Confirmation tokens. Mutating ops require
-confirm=YES(or "TYPED_OUT_REASON" for stricter cases). The agent must produce the literal string, which means the LLM has to "intend" the mutation. - Audit log for free.
perch --serverstreams every op as NDJSON. Pipe to your observability stack and you have a complete audit trail of every agent action. --checkkeeps the policy honest. As you add ops, the validator catches typo'd arg references / unresolved placeholders / unreachable branches before they ship.
Compare to giving the agent raw kubectl: prompt injection or hallucination β cluster damage. With perch's curated surface, the worst the agent can do is something you decided was safe to expose.
Same pattern for other "dangerous engine" tools:
rm/ file-system ops β only the deletes you've vetted; everything else absentterraform applyβ only against pre-named environments; only afterterraform plan(encoded as a bareplan_firstinvocation)aws/ cloud APIs β only the read/write surfaces your security team approves- DB clients β only the queries you've parameterised; no raw SQL
The grammar is the policy. The schema is the boundary.
6. Unify multiple binaries under one tool¶
Your dev environment depends on 10 tools: node, postgres, redis, kubectl, helm, terraform, jq, ripgrep, gh, docker. New hires spend half a day installing them, often wrong, with version drift. Status checks ("which version of helm do I have?") are a folder full of one-liners.
A single perch binary becomes your team's meta-tool:
name "devtools"
about "Install / status / update the team's dev toolkit"
version "0.1.0"
NODE_VER = "20"
PG_VER = "15"
HELM_VER = "3.14"
TF_VER = "1.7"
command install_all
description "Install everything"
do
install_node
install_postgres
install_redis
install_kubectl
install_helm
install_terraform
install_jq
install_ripgrep
install_gh
install_docker
print "All tools installed."
end
end
command install_node
do
if exists "/usr/local/bin/node"
print "node already installed"
end
if not exists "/usr/local/bin/node"
if os == "darwin"
brew install node@${NODE_VER}
end
if os == "linux"
shell "curl -fsSL https://deb.nodesource.com/setup_${NODE_VER}.x | sudo bash -"
sudo apt-get install -y nodejs
end
end
end
end
command install_postgres
do
if os == "darwin"
brew install postgresql@${PG_VER}
end
if os == "linux"
sudo apt-get install -y postgresql-${PG_VER}
end
end
end
# β¦ one install command per tool β¦
command status
description "What's installed, what's missing"
do
n = exists "/usr/local/bin/node"
pg = exists "/usr/local/bin/psql"
r = exists "/usr/local/bin/redis-cli"
k = exists "/usr/local/bin/kubectl"
h = exists "/usr/local/bin/helm"
tf = exists "/usr/local/bin/terraform"
print "node: ${n}"
print "postgres: ${pg}"
print "redis: ${r}"
print "kubectl: ${k}"
print "helm: ${h}"
print "terraform: ${tf}"
end
end
command update_all
description "Re-run installers; package managers handle the upgrade"
do
install_all
end
end
command uninstall_all
description "Remove everything (irreversible β asks for confirm)"
arg confirm
type string
description "Must be YES"
end
do
if confirm != "YES"
fail "Pass -confirm=YES to actually uninstall"
end
if os == "darwin"
brew uninstall node@${NODE_VER} postgresql@${PG_VER} redis kubectl helm terraform jq ripgrep gh
end
if os == "linux"
sudo apt-get remove -y nodejs postgresql-${PG_VER} redis kubectl jq ripgrep gh
end
end
end
perch --build -o devtools and now your team's dev-environment setup, status, update, and teardown all live behind one binary:
./devtools install_all # one-time onboarding
./devtools status # which tools are present?
./devtools install_helm # add one missing tool
./devtools update_all # bump everything
./devtools uninstall_all -confirm=YES # before reimaging the machine
Why perch wins: the alternative is an install.sh per tool, a README#installation section that lies, and a Slack channel where people ask "what version of helm do you have?" The unified binary collapses all of that into one auditable source.
Add --server and your laptop-provisioning team gets a web UI. Add MCP and the AI agent helping a new hire can install missing tools on demand.
Variations of "multi-binary unifier":
- Polyglot language manager β wraps asdf / mise / pyenv / nvm / rustup under one set of commands (
langs install node 20,langs use python 3.11). - Cloud provider unifier β same verb-set across AWS/GCP/Azure (
cloud list-instances,cloud start-vm). - Service stack starter β
stack upbrings up postgres + redis + the app, withif existsguards for already-running services. - Backup destination router β
backup s3,backup b2,backup rsyncβ same recipe, different targets. - Editor/IDE plugin manager β install / update / list plugins for vim, vscode, neovim from one CLI.
7. Distribute a Python / JS / non-native project as one binary¶
This is genuinely a new packaging model. The user pays no Python/Node/Ruby tax: they download one file, run one command, and your project is installed.
perch --build --include <path> embeds an arbitrary file tree inside the produced binary as a gzipped tarball. At runtime, three ops give your install command access to the archive:
| Op | Returns | Use |
|---|---|---|
bundle_hash |
string | SHA-256 of the embedded archive β the canonical "version" id |
bundle_extract DST |
string | Extract to DST (idempotent) |
bundle_dir |
string | Lazy-extract to an OS temp dir; cached for the process lifetime |
The pattern: extract the project to $HOME/.cache/perch/<tool>/<hash>/, run its native install (venv + pip, npm, gem, build), drop a launcher into $HOME/.local/bin/<tool>. New hash β new install dir, old versions coexist, pruning is rm -rf.
Worked example β stt_bin, a single binary that installs a Python project:
name "stt_bin"
about "Ship a Python project as one self-installing binary"
version "0.1.0"
INSTALL_BASE = "${HOME}/.cache/perch/stt_bin"
LAUNCHER = "${HOME}/.local/bin/stt"
command install
do
h = bundle_hash
installed = exists "${INSTALL_BASE}/${h}/.installed"
if installed
print "β Already installed at ${INSTALL_BASE}/${h}"
end
if not installed
print "β Extracting"
bundle_extract "${INSTALL_BASE}/${h}"
cd "${INSTALL_BASE}/${h}"
python3 -m venv .venv
.venv/bin/pip install --quiet -r requirements.txt
write_file "${INSTALL_BASE}/${h}/.installed" "ok\n"
mkdir "${HOME}/.local/bin"
# Compose the launcher line-by-line β capy strings are
# single-line; for multi-line files we use append_line.
write_file "${LAUNCHER}" "#!/usr/bin/env bash"
append_line "${LAUNCHER}" 'exec ${INSTALL_BASE}/${h}/.venv/bin/python ${INSTALL_BASE}/${h}/main.py "$@"'
exec chmod +x ${LAUNCHER}
print "β Installed. Run: stt example.wav"
end
end
end
command run
description "Run the embedded program directly (skips the launcher)"
proxy_args
do
dir = bundle_dir
shell "python3 ${dir}/main.py ${proxy_args}"
end
end
command uninstall
do
rm "${INSTALL_BASE}"
rm "${LAUNCHER}"
end
end
Build + use:
perch --build -f commands.perch --include ./src -o stt_bin
./stt_bin install # β ~/.local/bin/stt now exists
stt example.wav # use it like a native binary
./stt_bin run example.wav # bypass the launcher (no install needed)
./stt_bin uninstall # rm the install + the launcher
The ~12 MB stt_bin carries: the perch runtime + your parsed commands.perch + a tarball of ./src/. The recipient needs only python3 (or whatever your install command requires). No pipx, no virtualenv setup, no pip --user permission drama, no internet access at install time.
Same pattern, other ecosystems:
| Language / kind | What --include ships |
What install does |
|---|---|---|
| Python (the example above) | main.py + requirements.txt + data files |
python3 -m venv + pip install + write launcher |
| Node / TypeScript | package.json + compiled dist/ |
npm install --omit=dev (or skip if pre-bundled) + launcher |
| Ruby | sources + Gemfile |
bundle install + launcher |
| Pre-compiled native binary | the binary itself | just cp into PATH; no install step needed |
| Static assets (HTML/CSS/JS site) | the whole site/ tree |
extract to ~/.local/share/<tool>/; --server exposes it as a local web app |
| Configuration / dotfiles | your dotfiles repo | extract to $HOME; run stow or symlink-write |
| A whole monorepo | src/ containing Go + Python + JS |
per-language sub-install commands chained from install |
Why this pattern is unusually good:
- No package registry, no broken dependencies. What you
--includeis what the user gets β hash-addressed, byte-identical. - Reproducible. Same
--buildβ same hash β same install β byte-identical files on disk. Compare withpip install x==1.2.3, where the resolved dependency set depends on the day of the week. - Multiple versions coexist. Different
bundle_hashβ different install dir. Switching is just rewriting the launcher. - Offline-friendly. No
pip installfrom PyPI at install time (you can pre-vendor wheels into the bundle andpip install --no-index --find-links). - Cross-platform
if os ==in the install command β one bundle, OS-correct install steps. - Re-use the same
--server/ MCP frontends. Non-engineers click "install" in a browser; an AI agent callsstt_bin_installover MCP.
Other applications that fall out of the same primitive:
- Self-updating bundles. Your binary has an
updatecommand thatdownloads a newer version of itself, verifies sha256, atomically replaces$0. - Plugin / extension installers. Each plugin ships as a perch binary. The host app shells out to it for install/uninstall.
- Internal package registry replacement. Skip npm/PyPI for internal tools: serve perch-built binaries at
https://internal/tools/. Each is one self-contained installer. - Lambda / serverless deployers. The Lambda's code is
--include-d.mytool deployzips it and uploads. - Game mods. The mod's files travel inside a perch binary that knows the host game's directory layout per OS.
- Reproducible dev environment.
--includeyour.tool-versions+ a curated set of tarballs.mydev installlays them out at exact versions, no asdf/nix install required.
β Worked demo: demos/05-python-installer.
8. Cross-platform machine setup / new-hire onboarding¶
A new engineer joins. They need to install ~10 packages, clone two private repos, set five env vars, and run make init. Today that's a README with instructions that drift, or a setup.sh that doesn't work on Windows.
command setup
description "Bootstrap a fresh dev machine"
do
if os == "darwin"
brew install jq ripgrep watchexec gh
end
if os == "linux"
sudo apt-get install -y jq ripgrep gh
end
if os == "windows"
choco install jq ripgrep watchexec gh -y
end
if not exists "${HOME}/.team-config"
git clone git@github.com:org/team-config ${HOME}/.team-config
end
write_file "${HOME}/.zshrc.team" "export TEAM_CONFIG=${HOME}/.team-config\n"
print "Done. Add 'source ~/.zshrc.team' to your shell rc."
end
end
Then perch --build -o team-bootstrap and your onboarding doc shrinks to:
Why perch wins: one file covers three OSes with first-class branching. if not exists is the conditional you'd otherwise express with [ -d X ] || .... The result ships as a single binary, so the user doesn't need to install Node, Python, or perch first.
β Worked example: tutorials/03-cross-platform-installer and demos/02-cross-platform-setup.
9. Safe operational surface for non-engineers¶
Your support team needs to flush a customer's cache, regenerate an invoice, or rotate an API key. Today they Slack you and you do it from your laptop. Or worse, you give them shell access.
perch's web UI mode turns commands.perch into a real internal tool:
command flush_cache
description "Flush the cache for one customer"
arg customer_id
type string
description "Customer UUID"
end
do
redis-cli DEL cache:${customer_id}
print "Cache cleared for ${customer_id}."
end
end
command rotate_key
description "Rotate the API key for one customer"
arg customer_id
type string
description "Customer UUID"
end
do
new_key = sha256_file "/dev/urandom"
shell `psql -c "UPDATE customers SET api_key='${new_key}' WHERE id='${customer_id}'"`
print "New key: ${new_key}"
end
end
The support team gets a form for each command, with arg validation. Output streams as NDJSON to the browser. They never touch a shell. You expose exactly the operations you want, no more.
Why perch wins: writing this as a Flask/Express app is a multi-day project. perch makes it minutes. The private modifier hides helper commands that shouldn't be on the dashboard.
10. CI pipeline as code (one source for local + remote)¶
The classic problem: your .github/workflows/ci.yml and your local make test slowly diverge. Six months in, "it passes locally but fails in CI" is the team's most-said phrase.
command ci
description "What CI runs end-to-end"
do
lint
test
release
end
end
command lint
do
go vet ./...
if exists "${HOME}/go/bin/staticcheck"
${HOME}/go/bin/staticcheck ./...
end
end
end
command test
do
go test -race -cover ./...
end
end
command release
do
with_env "GOOS=darwin"
go build -o bin/darwin/myapp ./cmd/myapp
end
with_env "GOOS=linux"
go build -o bin/linux/myapp ./cmd/myapp
end
with_env "GOOS=windows"
go build -o bin/windows/myapp.exe ./cmd/myapp
end
end
end
The whole CI definition shrinks:
Why perch wins: the matrix lives in commands.perch, not in YAML you also have to maintain. Developers run the exact same perch ci locally. Drift becomes impossible.
11. Data pipeline / ETL orchestration¶
You have 10 shell scripts that run nightly via cron. They download, decompress, transform, upload. Half of them broke once when curl's flag syntax changed; the other half broke when someone renamed a JSON field.
command nightly_etl
description "Pull the daily report, transform, upload"
do
stamp = now "date"
download "https://reports.example.com/${stamp}.json.gz" "/tmp/r.json.gz"
ungzip "/tmp/r.json.gz" "/tmp/r.json"
body = read_file "/tmp/r.json"
users = json_get "${body}" "users"
count = length users
if count == 0
fail "report has no users"
end
payload = json_stringify users
http_post "https://warehouse.example.com/ingest" payload
rm "/tmp/r.json"
rm "/tmp/r.json.gz"
print "ETL complete: ${count} users."
end
end
Why perch wins: download, ungzip, json_get, http_post are first-class ops with consistent error handling. No shell quoting drama. --check flags typos before the cron fires. If you need this to be more reliable, drop in if exists "X" β¦ end guards and fail checks.
Not a full Airflow replacement, but for the "I have 10 shell scripts and 5 of them have bit-rotted" tier, it's a substantial upgrade.
12. AI-agent tooling (curated MCP surface)¶
Give an AI agent (Claude Desktop, Claude Code, Cursor, Zed) the ability to do things in your environment β restart services, query metrics, fetch logs β without giving it shell access.
perch-mcp exposes the program as two MCP tools (perch_list, perch_run). Configure it in your MCP client:
The agent now sees only the operations you declared. It can call them with structured args. Output streams back.
Why perch wins: the grammar is the security boundary. An MCP server that wraps bash -c is a footgun; one that wraps a fixed list of typed commands is auditable. Mark helper commands private and they're not even visible to the agent.
This is a sweet spot: curated, safe, structured execution for AI agents. A small but real industry trend.
β Setup: docs/mcp.md.
13. Self-service runbooks¶
Every on-call engineer has a folder of runbooks. They're markdown files with shell snippets. Half the snippets stop working when the cluster gets upgraded.
Convert each runbook to a command:
command runbook_db_failover
description "Failover the primary DB to the standby"
do
print "Step 1: drain connections"
kubectl exec -n prod db-primary -- pg_drain
print "Step 2: promote standby"
kubectl exec -n prod db-standby -- pg_ctl promote -D /var/lib/postgresql/data
print "Step 3: update DNS"
aws route53 change-resource-record-sets --hosted-zone-id Z123 ...
print "Done. Verify with: perch runbook_db_status"
end
end
command runbook_db_status
description "Read replication lag from each replica"
do
kubectl exec -n prod db-primary -- psql -c "SELECT * FROM pg_stat_replication"
end
end
perch --help becomes your runbook index. perch runbook_db_failover is the runbook itself. CI runs perch --check against the file weekly to catch bit-rot before an incident does.
Why perch wins: runbooks become executable. Markdown is a lie that the runbook still works; perch's static validator + the fact that the command is the runbook means there's a single source of truth.
14. Configuration management (Ansible-lite)¶
For small jobs that don't justify Ansible/Chef/Puppet:
command provision_box
description "Bring a fresh VM up to spec"
do
if os == "linux"
sudo apt-get update -qq
sudo apt-get install -y nginx postgresql-client
end
write_file "/etc/nginx/sites-available/myapp" "
server {
listen 80;
server_name myapp.local;
location / {
proxy_pass http://localhost:3000;
}
}
"
sudo ln -sf /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo systemctl reload nginx
end
end
Run it locally for the first time, then perch --build -o provision and SCP to the target. The target machine doesn't need anything pre-installed.
Why perch wins: Ansible is great but overkill for a 20-line setup. Bash works but needs set -euo pipefail and quoting discipline. perch sits in between: declarative, typed args, op handlers that don't break on whitespace.
15. Multi-target build orchestration for game / native development¶
Game studios + native-app teams have weird per-platform build dances: Xcode for iOS, Gradle for Android, MSBuild for Windows, plus signing + notarization + uploads to four stores.
command release
description "Cross-platform release"
do
build_ios
build_android
build_macos
build_windows
upload_all
end
end
command build_ios
require_os "darwin"
do
xcodebuild -scheme MyGame -configuration Release archive
xcodebuild -exportArchive -archivePath ... -exportPath ./build/ios
end
end
# ...
require_os "darwin" enforces that iOS builds can only run on a Mac. The rest of the matrix branches via if os ==.
Why perch wins: the platform branching is structural, not buried in shell case statements. CI failures point at the exact step. --build lets you ship a release-cli binary to designers who need to do trial builds without learning the build system.
16. Personal "me" CLI (dotfiles, side projects)¶
Personal automation. The kind of thing where you'd otherwise have ~/bin/blog-deploy, ~/bin/backup, ~/bin/cleanup:
command deploy_blog
do
cd "${HOME}/projects/blog"
hugo --minify
rsync -av public/ user@host:/var/www/blog/
end
end
command backup
do
stamp = now "date"
tar_create "${HOME}/Documents" "/tmp/docs-${stamp}.tar.gz"
rclone copy /tmp/docs-${stamp}.tar.gz dropbox:Backups/
rm "/tmp/docs-${stamp}.tar.gz"
end
end
command cleanup
do
rm "${HOME}/Library/Caches/Google/Chrome"
rm "${HOME}/Library/Developer/Xcode/DerivedData"
print "Cleared caches."
end
end
Build it once (perch --build -o ~/bin/me) and now me deploy_blog is your tool.
Why perch wins: you'd otherwise be looking up tar flag syntax for the 50th time. perch's ops are stable, autocompleted (via LSP), and --check-able.
17. Scaffold / template generator¶
You have a "create a new microservice" recipe that does ~20 things: clone a template, rename files, set up CI, register with service discovery, β¦
command new_service
arg name
type string
description "Service name (kebab-case)"
end
do
git clone git@github.com:org/service-template ${name}
cd "${name}"
rm ".git"
git init -q
# find/replace placeholders
find . -type f -exec sed -i "" s/__NAME__/${name}/g {} +
shell "echo '${name}' > .servicename"
gh repo create org/${name} --private --source .
print "Created ${name}. cd into it and start hacking."
end
end
Why perch wins: every team has this recipe. Today it's a shell script with a set -e at the top and prayer. With perch, args are typed, descriptions show in --help, and --check catches typos before you run it on a new repo.
18. Documentation site / blog tooling¶
The kind of thing that should be Make but ends up being five separate scripts:
command preview
do
shell_detached "mkdocs serve"
sleep 2
if os == "darwin"
open http://127.0.0.1:8000
end
if os == "linux"
xdg-open http://127.0.0.1:8000
end
end
end
command publish
do
mkdocs build --strict
rsync -av site/ user@host:/var/www/docs/
end
end
Why perch wins: the cross-platform open / xdg-open branching is automatic. shell_detached for the background mkdocs serve is a real op, not a & you have to remember.
19. Quick API smoke tests / health probes¶
You want a "is the system OK?" command that pings five services and reports.
command health
description "Probe core services and report"
do
api = http_get "https://api.example.com/health"
auth = http_get "https://auth.example.com/health"
db_ok = port_check "db-primary.internal" "5432"
cache = port_check "cache.internal" "6379"
print "api: ${api}"
print "auth: ${auth}"
print "db: ${db_ok}"
print "cache: ${cache}"
end
end
Pair with a cron, or expose via --server for a real-time dashboard.
Why perch wins: http_get + port_check are first-class. No curl --fail-and-grep gymnastics. Combine with if not api for failure exits.
20. AI-assisted refactor / migration tools¶
Internal tools that combine human-driven and AI-driven operations:
command migrate_to_v2
description "Run the v2 migration on this repo"
do
go mod edit -droprequire github.com/old/lib
go mod edit -require github.com/new/lib@latest
find . -name *.go -exec sed -i "" s/old.Foo/new.Foo/g {} +
go mod tidy
go vet ./...
print "Migration applied. Review the diff and run tests."
end
end
Expose via perch-mcp and Claude can drive the migration command-by-command, checking output after each step. The grammar restricts what the agent can do; the migration logic is in your code.
Why perch wins: the typed surface is exactly what an LLM needs. Agents handle perch's structured tools far more reliably than bash -c with prose.
21. Embedded scripting for your own Go program¶
Less obvious but real: you can import perch's capy loader + interpreter as a Go library to give your program a scriptable interface. Think: vim's vimscript, or Emacs's elisp, but for a Go application.
import (
"github.com/olivierdevelops/perch/infra/capyloader"
"github.com/olivierdevelops/perch/infra/interpreter"
"github.com/olivierdevelops/perch/infra/ops"
)
func main() {
prog, err := capyloader.LoadFromString(userScript)
if err != nil { /* show error */ }
i := interpreter.New(ops.AllHandlers(), prog)
i.Run("user_cmd", nil)
}
Your users can write .perch files that drive your program. You control which op handlers are available β register custom ones for your domain.
Why perch wins: if you'd otherwise embed Lua or Starlark, perch gives you a smaller surface, a real LSP for users, and an MCP server for free.
22. Workshops, tutorials, demos¶
If you teach DevOps / CI / cross-platform tooling, perch is genuinely a useful classroom tool. The DSL is small enough that students learn it in 20 minutes. The op catalog gives them real capability without "first install jq, brew install β¦". A commands.perch is a small, complete, runnable artifact you can hand them.
Why perch wins: alternatives are either too complex (Ansible) or too low-level (bash) to teach in one sitting.
23. Internal-tools framework for low-engineering teams¶
Marketing / design / ops / product orgs sometimes want a "button" that triggers something:
- "Recompute weekly metrics"
- "Refresh the staging data from prod"
- "Generate the monthly invoice PDFs"
You write a commands.perch with those commands. Run perch --server --host 0.0.0.0 --port 8080 on a VM. Hand the URL to the team. They click buttons; they fill in args; they see streamed output. No engineer in the loop after setup.
Why perch wins: writing this UI in Retool / internal-tool platforms costs $$. perch costs zero. The buttons are command declarations.
When not to use perch¶
This part matters. perch isn't always the right tool.
| Don't reach for perch when⦠| Use this instead |
|---|---|
| You need full Turing-complete control flow (deep loops, complex data structures, custom types). | A real programming language. perch's let + if EXPR covers ~80% of scripting needs but isn't the right tool for serious logic. |
| You need a workflow engine with retries, scheduling, observability, backfills. | Airflow, Prefect, Dagster, Temporal. |
| You're doing infrastructure as code at scale (declarative diffs, drift detection). | Terraform, Pulumi, OpenTofu. |
| Multi-host configuration with idempotent diffs. | Ansible, Chef, Puppet. |
| Performance-critical hot paths. | perch's interpreter is fine for a thousand ops; it's not a 10K-ops-per-second engine. |
| You need a real templating / code-generation engine. | capy (perch's underlying DSL engine) or a project-specific generator. |
| Highly dynamic command sets (commands defined at runtime by external data). | Build a real Go program; use perch's interpreter as a library. |
The honest framing: perch wins for the middle 80% of small tooling tasks. Below it (one-line find . -name '*.tmp' -delete style stuff), bash is fine. Above it (real production pipelines with monitoring + retries), you want a workflow engine. The middle band is where perch is happiest, and that band turns out to be most of the daily-friction tooling work in any organisation.
Combining frontends β compound applications¶
The four frontends (CLI / server / shell / build / MCP) compose. Some patterns:
Pattern: "developer tool that ships to ops."
You build a commands.perch you use locally as perch foo. Once it's stable, perch --build -o ops and hand it to the ops team. They use the same commands; you ship updates by rebuilding.
Pattern: "AI-assisted ops dashboard."
Same commands.perch runs three ways: ops team uses perch --server in a browser; the on-call engineer uses perch <cmd> from a terminal; an LLM uses perch-mcp to suggest and execute fixes from a chat window. Same commands, three audiences.
Pattern: "self-replicating dev environment."
Your team's dev-setup.perch does the new-machine setup. Build it (perch --build -o dev-setup). Commit the binary to a private bucket. Onboarding doc says curl β¦ && ./dev-setup setup. The recipient is now running your perch program β and inside it, they dev-setup ship-this to make THEIR project shippable the same way.
Pattern: "audit log of every op."
perch --server streams every op as NDJSON to clients. Pipe that stream into your observability stack. You get a free audit log of every operation anyone ran against the operational surface β without writing logging into each command.
A few opinionated patterns¶
Some things that have shaken out as best-practice from real use:
Put everything stable in top-level bindings. Paths, names, build dirs, common env. If you reference it from two commands, hoist it.
Use private aggressively for helpers. Anything that isn't meant as a top-level operation gets private so --help stays clean and the MCP surface stays minimal.
catch for human-friendly errors. If you ship the binary to non-engineers, a catch unknown that lists valid commands is the difference between "looks broken" and "looks polished."
Group commands by verb-first naming. db_backup / db_restore / db_migrate reads better than backup_db / restore_db and groups in --help.
Treat perch --check as part of CI. Catch typos, missing command invocations, unresolved ${name} placeholders before they hit prod.
Build for the slowest target first. A perch --build -o myapp on macOS produces a Mach-O arm64 binary. For wider distribution you need to wait for cross-compile support (roadmap), or run --build on each target's CI runner.
Where to go next¶
- Five-minute tour: getting-started.md
- Full grammar: language.md
- Built-in op catalog: op-reference.md
- The fat-binary format: embedding.md
- AI integration: mcp.md
- Editor support: lsp.md
- Demos: github.com/olivierdevelops/perch/tree/main/demos
If your use case fits a category above and you've built something interesting, open a PR adding a demos/ folder for it. The catalog grows from real examples.