01Install
The Homebrew tap is katspaugh/homebrew-machine; each release is pinned to a tagged tarball plus its SHA256.
shell$ brew install katspaugh/machine/machine
The formula pulls in lima and python@3.12. To verify the release before installing, read the formula or follow the runbook at docs/TAP.md.
Dev mode (run from a clone)
Skip Homebrew and run machine directly out of a checkout. Useful when you want to edit profiles or contribute back.
shell$ git clone git@github.com:katspaugh/machine.git ~/Sites/machine $ ~/Sites/machine/bin/machine doctor
In dev mode projects.toml lives at the repo root. Under Homebrew it lives at ~/.config/machine/projects.toml. Override the directory with MACHINE_CONFIG_DIR.
02Prerequisites
Three host-side things need to be in place before the first machine up:
- An SSH key on the host, served by an agent the VM can forward. Either the macOS Keychain (default —
ssh-add --apple-use-keychain ~/.ssh/id_ed25519) or 1Password (Settings → Developer → Use the SSH agent, then run withMACHINE_USE_1PASSWORD=1). - That key registered as a signing key on GitHub (Settings → SSH and GPG keys → New SSH key → Key type: Signing).
- Host
git config --global user.nameanduser.emailset, or override viaGIT_NAME/GIT_EMAIL.
Run machine doctor to verify everything resolves before you create a VM.
03Setup & projects.toml
shell$ machine init # writes ~/.config/machine/projects.toml from the bundled example $ $EDITOR ~/.config/machine/projects.toml
In dev mode: cp projects.toml.example projects.toml && $EDITOR projects.toml from the repo root.
Example projects.toml
projects.tomldefault_profile = "cypress" # applied when a project omits `profiles` [blog] repos = ["git@github.com:you/blog.git"] # Multi-repo: sibling-clones in one VM. The first is the "primary" — # `machine ssh wallet` opens at its directory. [wallet] profiles = ["cypress"] repos = [ "git@github.com:you/safe-wallet-dev-env.git", "git@github.com:you/safe-wallet-monorepo.git", "git@github.com:you/safe-client-gateway.git", ] # Multiple profiles stack. [playground] profiles = ["cypress", "supabase-fly"] repos = ["git@github.com:you/playground.git"]
Inside the VM, each repo lands at ~/code/<repo-basename>/. JavaScript deps install automatically on first clone — yarn, pnpm, or npm, picked from packageManager in package.json. For environment variables, drop a .env in the project and Node's dotenv (or your framework) reads it directly. For secrets you'd rather not put on disk, see §10.
04Quickstart
shell$ machine up blog # create + start + provision VM "blog", clone the repo $ machine ssh blog # interactive shell, cwd = ~/code/blog
Host browser → VM web app: ports 3000-3010, 4200, 5173-5180, 8080-8099 are forwarded to 127.0.0.1 automatically. The first up is the slow one — subsequent boots are warm restarts.
Claude Code comes pre-installed with the official marketplace and these plugins enabled: frontend-design, superpowers, github, typescript-lsp, security-guidance, commit-commands, chrome-devtools-mcp, supabase. Permission defaultMode is set to auto.
05CLI reference
Every command takes a project name (the TOML table key) where applicable. The CLI is idempotent — running up twice is safe and re-converges drift.
| command | what it does |
|---|---|
| machine list | List projects defined in projects.toml. |
| machine ps | List projects with live VM status. |
| machine doctor | Preflight host checks: lima, git config, SSH agent, signing key, op CLI. |
| machine validate | Schema-check projects.toml and referenced profiles (no VM touched). |
| machine init | Write a starter projects.toml to ~/.config/machine/. |
| machine up <p> | Create if needed, start, provision (base + profiles), clone the repo(s). Idempotent. --dry-run prints steps without executing. |
| machine down <p> | Stop the VM (preserves disk). |
| machine ssh <p> | Interactive shell, cwd = ~/code/<primary-repo>. |
| machine run <p> <cmd>… | Non-interactive command in the VM. |
| machine secrets <p> [<repo>] | Render 1Password Environment(s) into VM tmpfs. See §10. |
| machine secrets --clear <p> [<repo>] | Wipe rendered secrets from the VM's tmpfs. |
| machine status <p> | limactl list for that VM. |
| machine update <p> | Refresh in-place: apt upgrade, npm globals, claude installer. --reprovision also re-applies TOML configs. |
| machine rebuild <p> | Destroys the VM and rebuilds from scratch (the reproducibility test). -y skips confirmation. |
| machine destroy <p> | Delete the VM. -y skips confirmation. |
06IDE integration
machine up <project> (and rebuild/destroy) maintains a sentinel-delimited block in ~/.ssh/config, so any IDE that reads SSH config sees the VM directly:
~/.ssh/configHost machine-<project> HostName 127.0.0.1 Port <lima-port> User <vm-user> IdentityFile <lima-key> IdentitiesOnly yes StrictHostKeyChecking no UserKnownHostsFile /dev/null ForwardAgent yes
In VS Code → Remote-SSH: open the host picker, pick machine-<project>, then open /home/<vm-user>/code/<repo>. Same flow in Cursor and JetBrains Gateway. ForwardAgent yes means commit signing and gh work in the IDE's integrated terminal just like in machine ssh.
machine doctor reports drift — missing block, stale ports, orphan entries, loose permissions. The block is rewritten end-to-end on every up/rebuild/destroy, so running machine up <any-project> is the recovery path if it ever goes out of sync.
07Provisioning
The provisioning system is declarative: tools are listed in TOML, and a small Python dispatcher applies them inside the VM.
provision.toml— the base config, always applied. Lists apt packages, third-party apt repos (Docker, GitHub CLI, NodeSource for Node),curl|bashinstallers (Claude Code), npm globals (Codex, TypeScript),/etc/profile.dsnippets, Claude marketplace + plugins.profiles/<name>.toml— opt-in add-ons (see §08).provision/run.py— reads the base + selected profiles, merges, and runs them in fixed step order. Idempotent via sentinels under/var/lib/dev-vm/provisioned/.
Adding a tool that fits a typed section (apt package, npm global, apt repo, curl|bash installer, GitHub release tarball, Claude plugin) is one line of TOML. The [[shell]] section is an escape hatch for genuinely shell-shaped one-offs (writing config files, chsh, etc.). Schema reference is in the comments at the top of provision.toml.
What happens on machine up <p>
- If the VM doesn't exist,
limactl create --name=<p>againstlima.yaml, thenlimactl start <p>. - Push
provision.toml, the project'sprofiles/<name>.tomlfiles,provision/run.py, and thefiles/tree into/opt/dev-vm/on the VM. - Render
files/git/*.tplon the host (substituting your name, email, and SSH signing pubkey), and push the results to the same location. - Run
sudo python3 /opt/dev-vm/provision/run.py provision.toml <profiles…>inside the VM. - Clone the listed
reposinto~/code/<basename>/in parallel.
GitHub auth and commit signing both use the forwarded SSH agent — private keys never leave the host; the VM only sees signatures and ssh -A proxied auth.
08Profiles
Profiles stack. Pick a base, layer extras in projects.toml, or write your own. Ship with machine:
| profile | what's in it |
|---|---|
| cypress | Cypress runtime libs + Chrome (amd64) or Chromium (arm64), Xvfb, GTK/NSS dependencies. |
| supabase-fly | Supabase CLI (release tarball) + flyctl (curl|bash). Docker comes from the base image. |
| python | uv (package + project manager) + ruff + pyright. |
| rust | rustup with the stable toolchain (minimal profile). |
| go | Pinned Go from go.dev. Edit the version in profiles/go.toml to bump. |
A profile is one TOML file. Copy an existing one, change three lines, commit it.
09SSH agent
By default the VM forwards whatever the host's SSH_AUTH_SOCK points at — on macOS that's launchd's agent, which serves keys you loaded with ssh-add --apple-use-keychain (passphrase cached in Keychain).
To use 1Password's agent instead — keys never touch ~/.ssh, every signature prompts for Touch ID:
shell$ brew install 1password-cli # only needed for OP_SIGNING_KEY_REF # In 1Password: Settings → Developer → "Use the SSH agent" $ export MACHINE_USE_1PASSWORD=1 # for the current shell, or your shell rc $ machine up <project>
Resolution order for the git signing pubkey
GIT_SIGNING_KEY=<literal pubkey string>OP_SIGNING_KEY_REF=op://Vault/Item/public_key— fetched viaop read(requiresopCLI; triggers Touch ID once atuptime).GIT_SIGNING_PUBKEY_FILE=<path>- Host
git config --global user.signingkey— literal pubkey or path to a.pubfile (default; whatever you sign host commits with).
101Password secrets
For project secrets you don't want to write to disk, drop a .envrc in the repo referencing a 1Password Environment ID:
shell$ echo 'use op_env <environment-id>' > .envrc $ direnv allow
Then on the host:
shell$ machine secrets <project> # syncs every .envrc using `use op_env` in that VM $ machine secrets <project> <repo> # narrow to one repo within a multi-repo project
machine secrets reads the Environment from 1Password (Touch ID), pipes the rendered KEY=value pairs into the VM,
and writes them to $XDG_RUNTIME_DIR/dev-secrets/<env-id>.env — tmpfs, mode 600, gone on reboot. The op_env direnv
helper loads that cache when you cd into the project. No host-side disk path is involved.
Create an Environment in 1Password desktop: Developer → Environments → New. Copy its ID via Manage environment → Copy ID.
11Threat model
No host filesystem is mounted. Each project gets its own VM, so a compromise of one project can't reach another's code or env. The host exposes two narrow channels:
- The forwarded SSH agent — auth and signing only; private keys stay on the host; the VM can only request signatures while it's running.
machine secrets— pushes rendered 1Password Environments into tmpfs (no disk persistence).
A fully compromised VM cannot read the 1Password vault — only the secrets a repo explicitly rendered, and only while that tmpfs lives. For a visual breakdown of what the VM can and cannot reach, see Ghost in the machine on the landing page.
The VM still reaches the network. npm install can exfiltrate anything inside its blast radius — your project's source, anything you typed into its shell, any secret currently rendered in tmpfs. The point of the VM is that the radius stops at the VM boundary, not that there is no radius.
12Shell completion
Bash, zsh, and fish completions ship under completions/:
shell# bash $ echo 'source /path/to/machine/completions/machine.bash' >> ~/.bashrc # zsh (somewhere in $fpath) $ ln -s "$PWD/completions/_machine" /usr/local/share/zsh/site-functions/_machine # fish $ ln -s "$PWD/completions/machine.fish" ~/.config/fish/completions/machine.fish
13Override knobs
Every override is an environment variable read at up time. Set them in your shell rc, your direnv, or inline.
| env var | default / purpose |
|---|---|
| GIT_NAME / GIT_EMAIL | From host git config --global. |
| GIT_SIGNING_PUBKEY_FILE | Path to a .pub file (overrides host user.signingkey). |
| GIT_SIGNING_KEY | Literal pubkey string (overrides everything). |
| OP_SIGNING_KEY_REF | 1Password secret reference for the signing pubkey, e.g. op://Personal/SSH/public key. |
| MACHINE_USE_1PASSWORD | Set =1 to forward 1Password's SSH agent instead of macOS Keychain. |
| ONEPASS_SOCK | ~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock |
| PROJECTS_FILE | <repo>/projects.toml in dev mode; ~/.config/machine/projects.toml under Homebrew. |
| MACHINE_CONFIG_DIR | Overrides the config-directory location (~/.config/machine by default). |
14Verifying
shell$ bash tests/run-all.sh <project> # full VM smokes (lint + boot + docker + node + git-sign + …) $ bash tests/unit.sh # host-side Python unit tests (no VM) $ machine validate # schema-check the TOMLs $ machine doctor # preflight host environment
tests/run-all.sh requires a provisioned VM (set MACHINE_NAME=<project> or pass the project as arg 1). tests/unit.sh runs offline.