// the machinist's manual · v0.1

One isolated Lima VM per project, from install to teardown.

machine drives Lima to give each GitHub project its own reproducible sandboxed VM — Docker, Node, agent CLIs (Claude Code, Codex), GitHub CLI, signed git, and opt-in tool profiles. No host filesystem is mounted; one project can't see another. This manual is the reference for everything machine exposes.

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 with MACHINE_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.name and user.email set, or override via GIT_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.toml
default_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.

i

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.

commandwhat it does
machine listList projects defined in projects.toml.
machine psList projects with live VM status.
machine doctorPreflight host checks: lima, git config, SSH agent, signing key, op CLI.
machine validateSchema-check projects.toml and referenced profiles (no VM touched).
machine initWrite 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/config
Host 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|bash installers (Claude Code), npm globals (Codex, TypeScript), /etc/profile.d snippets, 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> against lima.yaml, then limactl start <p>.
  • Push provision.toml, the project's profiles/<name>.toml files, provision/run.py, and the files/ tree into /opt/dev-vm/ on the VM.
  • Render files/git/*.tpl on 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 repos into ~/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:

profilewhat's in it
cypressCypress runtime libs + Chrome (amd64) or Chromium (arm64), Xvfb, GTK/NSS dependencies.
supabase-flySupabase CLI (release tarball) + flyctl (curl|bash). Docker comes from the base image.
pythonuv (package + project manager) + ruff + pyright.
rustrustup with the stable toolchain (minimal profile).
goPinned 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

  1. GIT_SIGNING_KEY=<literal pubkey string>
  2. OP_SIGNING_KEY_REF=op://Vault/Item/public_key — fetched via op read (requires op CLI; triggers Touch ID once at up time).
  3. GIT_SIGNING_PUBKEY_FILE=<path>
  4. Host git config --global user.signingkey — literal pubkey or path to a .pub file (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 vardefault / purpose
GIT_NAME / GIT_EMAILFrom host git config --global.
GIT_SIGNING_PUBKEY_FILEPath to a .pub file (overrides host user.signingkey).
GIT_SIGNING_KEYLiteral pubkey string (overrides everything).
OP_SIGNING_KEY_REF1Password secret reference for the signing pubkey, e.g. op://Personal/SSH/public key.
MACHINE_USE_1PASSWORDSet =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_DIROverrides 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.

// end of manual · v0.1 · katspaugh/machine ← Back to runmachine.dev