// the machinist's manual · v0.1

One ready-to-work 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

machine runs on macOS 13 (Ventura) or newer — Apple Silicon or Intel. The VMs boot through Lima's vz driver (Apple's Virtualization framework), so macOS is the only supported host; Linux and Windows hosts won't work. Guests are Ubuntu 24.04, arm64 or amd64 to match your CPU.

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.

Nix

The repo is also a flake — no Homebrew needed. It pins its own Lima (≥ 2.0) and Python from nixpkgs-unstable.

shell
$ nix profile install github:katspaugh/machine $ nix run github:katspaugh/machine -- doctor

02Prerequisites

Four host-side things need to be in place before the first machine up:

  • A Mac on macOS 13 or newer, Apple Silicon or Intel (see §1).
  • 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 — detected and forwarded automatically).
  • 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

No setup needed — machine up <name> for a new name runs the machine create wizard, which asks for repos, profiles, and shell, writes the entry to projects.toml, and continues straight into provisioning. A bare machine up does the same on first run; once a default VM (or [default] entry) exists, it boots that non-interactively. machine up default always skips the wizard and gives a config-less scratch VM. Re-running machine create <name> on an existing project edits its entry — every prompt defaults to the current value. To author the config by hand instead:

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
# Projects without `profiles` get the base VM only. # To opt every project into a profile: default_profile = "cypress" [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 # new name: wizard writes [blog], then create + start + provision, clone the repo $ machine up default # zero-config: create + start a base VM named "default", no wizard $ machine ssh blog # interactive shell, cwd = ~/code/blog

Host browser → VM web app: Lima auto-forwards any listening guest port to 127.0.0.1 on the host. The first up bakes the base disk and is the slow one — subsequent boots reuse it.

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 up [p]Create if needed, start, provision (base + profiles), clone the repo(s). Idempotent. New names (and a bare up with no default VM yet) run the create wizard first; up default stays a config-less base VM.
machine create [p]Wizard: add a project entry to projects.toml (repos, profiles, shell, agent forwarding) — or edit an existing one; prompts default to current values, comments in the file are preserved.
machine down <p>Stop the VM (preserves disk). Re-provision in place with machine up afterwards.
machine ssh <p>Interactive shell, cwd = ~/code/<primary-repo>.
machine claude <p>Launch claude in a tmux session, cwd = ~/code/<primary-repo>. Detach with ctrl-b d — claude keeps running; re-run to reattach.
machine run <p> <cmd>…Non-interactive command in the VM.
machine listList VMs (limactl list) plus configured-but-not-yet-created projects.
machine destroy <p>Delete the VM. -y skips confirmation.
machine bakeBuild/refresh the cached base disk in ~/.cache/machine used by up. --force rebuilds even if the cache hash is fresh.
machine secrets <p> [--repo <r>]Render 1Password Environment(s) into VM tmpfs. --clear wipes them. See §10.
machine initWrite a starter projects.toml to ~/.config/machine/.
machine doctorPreflight host checks: lima, SSH agent keys, git identity, signing-key resolution, op CLI note, projects.toml presence.

06IDE integration

Lima maintains a per-VM SSH config at ~/.lima/<project>/ssh.config. Add a single include to ~/.ssh/config once, and any IDE that reads SSH config sees every VM:

~/.ssh/config
Include ~/.lima/*/ssh.config

The host alias for a project is lima-<project>. In VS Code → Remote-SSH: open the host picker, pick lima-<project>, then open /home/<vm-user>.linux/code/<repo>. Same flow in Cursor and JetBrains Gateway. Lima's config sets ForwardAgent yes, so commit signing and gh work in the IDE's integrated terminal just like in machine ssh.

Because Lima owns the config file, it stays correct across up/down/destroy automatically — there is no host ~/.ssh/config block for machine to manage.

07Provisioning

Provisioning is Lima-native. machine up generates .build/<p>/lima.yaml, a base: stack of the project's templates/<profile>.yaml (reversed) plus templates/base.yaml last. Lima merges the stack, and cloud-init applies the declared provisioning inside the VM on every boot:

  • mode: data dotfiles — ~/.zshrc, fish config, /etc/profile.d snippets, pre-seeded known_hosts, and the git identity/signing config rendered from Lima params.
  • provision/*.sh scripts — base.sh (apt repos + packages, Docker, Node, Claude Code + plugins, npm globals) runs first; then one script per selected profile.

Because everything re-runs on boot, re-provisioning in place is just machine down && machine up — the scripts are idempotent. To start truly fresh, machine destroy && machine up. Changing your git identity or signing key requires a recreate (params are fixed when the VM is created). A provisioned base disk is baked once into ~/.cache/machine and reused, auto-invalidated by a content hash over templates/base.yaml, provision/, and files/.

What happens on machine up <p>

  • If no fresh base disk is cached, bake one into ~/.cache/machine (a provisioned base VM exported once per template/provision change).
  • Render .build/<p>/lima.yaml — the base: stack of profile templates plus base.yaml, with the cached disk prepended as the top-priority image.
  • If the VM doesn't exist, limactl create --name=<p> --set '.param.gitName=…' … against that template, then limactl start <p> — which blocks until the provisioning probe passes.
  • cloud-init applies the mode: data dotfiles and runs the provision scripts inside the VM.
  • Clone the listed repos into ~/code/<basename>/.

GitHub auth and commit signing both use the forwarded SSH agent. Private keys never leave the host — but forwarding cuts both ways: while a session is open, anything inside the VM can ask the agent to sign and authenticate with every key it holds, not just for this project's repos. See the threat model for what that grants and Restricting the forwarded agent for stricter setups.

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.
playwrightOS deps for Playwright's browsers (playwright install-deps). Browser binaries stay per-repo via npx playwright install — no sudo needed once the deps exist.
supabase-flySupabase CLI (GitHub .deb release) + flyctl (vendor installer). Docker comes from the base.

A profile is a templates/<name>.yaml + provision/<name>.sh pair. Copy an existing one, point it at a new script, and reference the profile name in projects.toml.

09SSH agent

machine picks the agent to forward automatically: if 1Password's SSH agent socket exists (Settings → Developer → Use the SSH agent), it forwards that — keys never touch ~/.ssh, every signature prompts for Touch ID. Otherwise it 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 force the Keychain agent while 1Password's agent is enabled, point ONEPASS_SOCK at a non-socket path (e.g. ONEPASS_SOCK=/dev/null 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).

Restricting the forwarded agent

Forwarding is the convenience default, and it is a real grant: an SSH agent performs arbitrary auth operations, so while a forwarded connection is open, anything inside the VM — a compromised dependency, a prompt-injected agent — can authenticate and sign as you with every key the agent holds. git push to any repo your key can access is in scope, not just this project's. Pick the friction that matches the trust level of the project:

  • Per-use approval (1Password agent). 1Password can require approval / Touch ID for each agent request and lets you choose how long an approval lasts (Settings → Developer → Security). Keep the window short for untrusted work, and an injected git push becomes a prompt you get to decline.
  • Confirmation-gated key (Keychain agent). Load the key with ssh-add -c ~/.ssh/id_ed25519 — the agent then asks for confirmation on every use. macOS needs an askpass helper to show the dialog (brew install theseal/ssh-askpass/ssh-askpass).
  • No forwarding + a per-project deploy key (strictest). Set forward_agent = false for the project in projects.toml — the VM gets no channel to your agent at all. Generate a key inside the VM (ssh-keygen -t ed25519) and add it to the repo as a deploy key; a compromised VM can then reach only that repo. Register the VM key as a signing key on GitHub (or disable commit.gpgsign in the VM) for commit signing. Takes effect on recreate, like the git params.

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 <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 — private keys stay on the host and the VM can't read them, but it can use them while a forwarded connection is open: signing and auth with every key the agent holds, so a compromised VM could push to any repo your key authorizes, not just this project's. The isolation is read-protection of the key material, not a per-project scope — narrow the grant with per-use approval, ssh-add -c, or forward_agent = false + a deploy key (see Restricting the forwarded agent).
  • 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.
ONEPASS_SOCK1Password agent socket path (default ~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock); auto-forwarded when it exists.
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 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.

15Troubleshooting

Run machine doctor first — it catches most of these before a VM is involved. Each entry below is a symptom, what it means, and the fix.

machine up fails before the VM boots with a template error

Errors like unknown field or failed to unmarshal at create time mean you're on Lima 1.x. machine needs Lima ≥ 2.0 — base: template composition and mode: data provisioning don't exist in 1.x. Check limactl --version, then brew upgrade lima.

git clone / push inside the VM: Permission denied (publickey)

The forwarded agent has no usable keys. On the host, ssh-add -l must list at least one: for the Keychain agent run ssh-add --apple-use-keychain ~/.ssh/id_ed25519; for 1Password, unlock the app — a locked 1Password agent serves nothing. If the agent was empty or locked when the VM connection was first opened, just re-run machine ssh: it detects a connection that forwards a dead agent and rebuilds it against the live one.

Pushes work, but commits show "Unverified" on GitHub

GitHub registers SSH keys for authentication and signing separately. Add the same public key a second time — Settings → SSH and GPG keys → New SSH key → Key type: Signing. The reverse confusion (key added only as Signing) makes signing work and git clone fail.

git commit fails with agent refused operation

The agent declined to sign. With 1Password that means the app is locked or you dismissed the Touch ID prompt — unlock it, and expect one Touch ID prompt per signature.

Provisioning fails, or machine up times out

Read the cloud-init log: limactl shell <vm> sudo tail -100 /var/log/cloud-init-output.log. Then re-run machine up <p> — provisioning is idempotent, and transient apt or network failures usually clear on the second pass.

The VM hangs at boot after the host disk filled up

A full host disk can leave the guest's ext4 filesystem flagged with errors, and the VM never comes up. Free host space first; then machine destroy <p> && machine up <p> — the cached base disk makes the rebuild fast. If you need data out of the old VM, its disk image (under ~/.lima/<p>/) can be repaired offline with e2fsck before you destroy it.

Changed git identity or signing key, but the VM still uses the old one

Identity flows in as Lima params, fixed when the VM is created. machine destroy <p> && machine up <p> re-creates it with the new values.

machine secrets fails to read an Environment

It needs the op CLI (brew install 1password-cli) and the desktop-app integration enabled: 1Password → Settings → Developer → Integrate with 1Password CLI.

1Password's agent is enabled, but you want the Keychain agent

Point ONEPASS_SOCK at a non-socket path: ONEPASS_SOCK=/dev/null machine up <p>. See §9.

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