Design notes¶
The decisions behind limabean, with the trade-offs considered. For usage, see the home page and the other pages of these docs.
Reproducible Lima VMs that act as personal dev environments, one per purpose, usable by anyone on macOS. Claude, or any SSH client, connects to them over SSH and works inside them.
Main goal: easy to configure and maintain. If a decision makes it harder to add an environment or change a tool once for all of them, it's the wrong decision.
Goals¶
- Defining a new environment means writing about 10 lines of YAML.
- Each tool, dotfile or setting is defined in exactly one place.
- VMs can be thrown away. Rebuilding one is one command and nothing is lost:
~/codeis on its own disk. - Claude, running on the Mac, can reach any environment over SSH by a stable host name.
- It works with Lima as it ships (2.0 or newer). No extra layers such as Nix or Ansible unless they clearly earn their place.
- It isn't tied to one person's machine. Host-specific values are template params with sensible defaults, and host prerequisites are documented in getting started.
Non-goals¶
- Supporting other operating systems or hypervisors (macOS host, Lima only).
- Managing the user's dotfiles. limabean reads host config read-only and never owns it.
- Production or CI images.
Host prerequisites¶
These are documented for users in getting started:
| Prerequisite | Why |
|---|---|
| Lima 2.0 or newer | base: template composition and param: |
Include ~/.lima/*/ssh.config at the top of ~/.ssh/config |
every VM is reachable as ssh lima-<name>, which survives port changes |
A key loaded in the host ssh-agent (AddKeysToAgent/UseKeychain to persist it) |
agent forwarding is the only way the VM can authenticate as the user |
Git config in a directory (~/.config/git by default, or the gitConfigDir param) |
Lima mounts directories, not files |
Architecture: layered templates¶
Lima templates can inherit from other templates through base:. Use that to
build three layers:
templates/
layer-os.yaml # layer 1: Ubuntu 26.04 LTS, CPU/mem/disk, ssh agent forwarding, zsh
layer-base.yaml # layer 2: base: layer-os + apt + Docker + mise tools + git + zsh + editorconfig
scratch.yaml # layer 3: base: layer-base, nothing else (throwaway sandbox)
features/ # idempotent shell scripts, one per capability
zsh-install.sh apt-common.sh mise.sh completions.sh git.sh docker.sh shell.sh
files/ # static files written into the VM (zshrc, env.sh, editorconfig)
packs/ # tool packs: <pack>.toml (pinned mise tools) + optional <pack>.apt
core.toml go.toml node.toml rust.toml ...
cmd/, internal/ the limabean CLI (Go; see go-cli.md)
Everything sits under templates/ because Lima rejects relative paths that
contain .. and base templates whose file names start with _.
Languages and tools come in packs, chosen per VM rather than per
environment. limabean new adds each pack beyond core with
--set '.provision += [{"mode": "data", "path": "/etc/limabean/mise/50-<pack>.toml", "content": …}]',
so an environment template stays tiny and any environment can have any
pack. The base layer carries everything that is shared:
# templates/layer-base.yaml
base:
- ./layer-os.yaml
provision:
- mode: system
file: ./features/apt-common.sh
- mode: data
path: /etc/limabean/mise/00-core.toml
file: ./packs/core.toml
- mode: system
file: ./features/packs-apt.sh # apt packages of the VM's packs, /etc/limabean/apt/*.list
- mode: user
file: ./features/mise.sh # installs mise, links /etc/limabean/mise/*.toml, mise install
Why this layout¶
- Feature scripts plus thin YAML. Logic lives in plain shell scripts. The YAML only says which features an environment gets. Adding a tool to every environment means editing one file.
- mise for toolchains (decided). Each pack is a declarative
packs/<pack>.tomlwith pinned versions, so upgrading Go is a one-line diff. apt covers system packages: the shared ones, and a pack's.aptlist. - Nothing from the host is mounted.
file:references are embedded into the instance when it is created, andmode: datawrites are guaranteed to happen before any provisioning script runs. That means provision order across layers doesn't matter:mise.shinstalls every TOML that is present. Changing a template takes effect onrebuild. - Scripts run on every boot, so each one is idempotent and exits quickly when there's nothing to do.
- PATH for SSH commands.
files/env.sh(PATH with~/.local/binand the mise shims, plus env vars) is sourced from~/.zshenv, which zsh reads even for non-interactive commands, and from the top of~/.bashrc. So commands run over SSH (ssh lima-<name> go test ./...) see the same tools as a login. - Shell. zsh is the login shell from the moment the account exists:
user.shellinlayer-os.yaml, with zsh installed by amode: dependencyscript before the first login. Changing the shell later (usermod) doesn't work. Lima's persistent SSH connection keeps the shell and groups it saw at login, and closing that connection from inside the guest breaks Lima's readiness checks.files/zshrcholds plain zsh config with avcs_infoprompt. There's deliberately no prompt framework or plugin manager: two apt plugins (autosuggestions, syntax highlighting) and fzf's built-in keybindings. Managed files are rewritten on every boot; personal additions go in~/.zshrc.local. - Completions are generated once per boot by
features/completions.shinto a single fpath directory, not witheval "$(tool completion zsh)"in zshrc. That keeps shell startup fast. Tools without zsh output (aws, terraform, go through gocomplete) usebashcompinit.
Dotfiles: deferred¶
The first version covers provisioning only. When dotfiles come back, the plan is
to mount a dotfiles directory (a param, like gitConfigDir) read-only and
apply a Linux-safe subset in a
features/dotfiles.sh script. This needs D3 (shell config portability) to be
settled first.
The CLI¶
limabean started as a bash script and is now a Go binary built with Cobra
and Viper. It drives limactl, embeds the templates, and adds secrets with a
creation form. Its design is in go-cli.md; the behaviors below
carried over unchanged:
- Config file (
~/.config/limabean/config,KEY="value") instead of Lima'soverride.yaml, which can't set mount paths. doctorturns silent failures into explicit ones: a missing or misplacedInclude, an empty ssh-agent, a missing git config, broken secret references.rmguards unsaved work in~/code(uncommitted changes, stashes, commits on no remote), and so doesrebuildfor a VM without a code disk.--forceskips the check.- Claude Desktop registration in
~/.claude/settings.jsonsshConfigs, touching onlylimabean-ids, keeping other keys and their order, leaving invalid JSON alone, and writing in place so symlinks survive. - The environment is remembered in
~/.local/state/limabean/<name>.env, sorebuildrecreates from the same template.
rebuild is the main maintenance path: change a template, rebuild, done.
Claude over SSH¶
Claude is not provisioned into the VMs. Claude runs from the Mac and connects
over SSH, using the lima-<name> aliases that ~/.ssh/config already includes
from ~/.lima/*/ssh.config. Because Lima regenerates that file on every start,
the alias keeps working when the VM's SSH port changes.
- Connecting.
limabean newregisters the VM in Claude Desktop (see Wrapper CLI). It appears as<name> (limabean)under SSH in the environment dropdown, and connects to thelima-<name>alias. - What the VM must provide: a working SSH login, plus the toolchain on
PATH for non-interactive commands.
mise.shhandles PATH; Lima handles SSH. - Git push from the VM. See Git and SSH keys below.
- Isolation. Code lives in the VM (D1), so anything Claude runs over SSH can only change the VM, never the host.
Git and SSH keys¶
- Keys stay on the Mac.
ssh.forwardAgent: trueinlayer-os.yamlforwards the Mac's ssh-agent. Git in the VM signs with keys held there.~/.sshis deliberately not mounted, so nothing in the VM, Claude included, can read a private key. Requirement: the key must be loaded in the Mac's agent (ssh-add --apple-use-keychain ~/.ssh/id_rsa, plusAddKeysToAgent yesandUseKeychain yesin~/.ssh/configso it survives a reboot). - gitconfig comes from the host.
layer-base.yamlmounts thegitConfigDirparam (default~/.config/git, git's XDG location) read-only at/mnt/host/git. Override it per VM with--param gitConfigDir=…. Lima's globaloverride.yamlchanges the param value but not the already-expanded mount path, so it can't be used for this.features/git.shwrites a VM~/.gitconfigthat[include]s the first config it finds there (configor.gitconfig), then overrides values that only make sense on the host (core.editor) and pointsexcludesfileandcommit.templateat files in the mount. Edits on the Mac apply at once.batcomes frompacks/core.tomlbecause the host config uses it as the pager. - GitHub host keys are written to
known_hostsfromapi.github.com/meta(fetched over TLS), so the first push doesn't stop at a prompt.
Where code lives (decision D1)¶
| Option | Pros | Cons |
|---|---|---|
A. Code lives in the VM (~/code, cloned there) |
Strong isolation, fast native FS, a natural fit for letting Claude run freely | Code goes away with the VM unless pushed. Rebuild means re-clone |
B. Mount ~/code writable |
Same files as the host, survives rebuilds | Claude in the VM can change host files. virtiofs is slower for node_modules and similar |
C. Per-VM extra disk (limactl disk create) at ~/code |
Survives rebuilds, still isolated | One more thing to manage |
Decided: A, then C. Code is cloned into ~/code inside the VM with ghq
(~/code/<host>/<owner>/<repo>; the codeDir param), and since re-cloning
after every rebuild hurt, it lives on a per-VM Lima disk:
limabean newcreatescode-<6 hex of sha256(vm)>(CODE_DISKGiB, raw and sparse) and attaches it with--set .additionalDisksand thecodeDiskparam.rebuildreattaches the same disk;rmdeletes it.- Lima formats a disk on any boot that can't find the label
lima-<name>, and ext4 labels hold 16 bytes. A name over 11 characters would be truncated and the disk erased on every boot, hence the hash. - Lima mounts the disk at
/mnt/lima-<name>, which isn't configurable, sofeatures/code-disk.sh(system, every boot) bind-mounts it overcodeDirbefore the user scripts run. limactl delete --forcekills a running VM, sorebuildstops it first and pending writes reach the disk.
An opt-in --mount of a host directory (B) is still open.
Other decisions to make¶
- D2 Base distro. Decided: Ubuntu 26.04 LTS (
template:_images/ubuntu-26.04), pinned rather thanubuntu-ltsso rebuilds stay reproducible. Moved from 24.04, whose systemd 255 only gets patches and is too old for Varlink tooling such as vlx (needs v257+). 26.04 ships systemd 259 withvarlinkctland theio.systemd.*Varlink interfaces, and Docker's apt repository supports it. Upgrading systemd within 24.04 isn't possible. - D3 Shell config portability. For when dotfiles return: shell configs
often assume macOS (
/opt/homebrew,pbcopy). They need[[ $OSTYPE == darwin* ]]guards to work in both places. - D4 Container runtime. Decided: Docker Engine (
features/docker.sh, from Docker's apt repository, with the buildx and compose plugins), and Lima's containerd/nerdctl turned off (containerd.user: false). An earlier version used Lima's rootless nerdctl withdockersymlinked to it. That was less to maintain, but it couldn't run the full Docker CLI and plugin ecosystem, custom BuildKit frontends, or kind's default provider. The VM user ownsdocker.socketthrough a systemdSocketUserdrop-in, because a docker group added during first boot doesn't reach Lima's already-open SSH session. - D6 Package updates. Decided: full upgrade on first boot.
features/apt-upgrade.shrunsapt-get full-upgradeonce, systemd included, and runssystemctl daemon-reexecso PID 1 uses the new systemd.needrestartis suspended during provisioning. When/var/run/reboot-requiredexists (a new kernel, say),limabean newrestarts the VM before handing it over. Later boots skip the upgrade, so restarts stay fast;rebuildis how a VM gets newer packages. Packages held back by Ubuntu's phased updates stay held, which is intended. - D7 GitHub token. Decided: a scoped token in the macOS Keychain, copied in
by limabean. Copying the host's
gh auth tokenwould hand every VM the host's broadest credential, typicallyrepo,workflowandread:org. It would also pick the wrong host whenghdefaults to GitHub Enterprise, and go stale when the host re-authenticates. Per-VMgh auth loginis repeated after every rebuild. Solimabean github loginstores one fine-grained token in the Keychain and writes it to~/.config/limabean/github-token(mode 600) in each VM, whereenv.shexportsGITHUB_TOKEN. It never goes into a template, because those are plain text under~/.lima. It's passed on stdin, never in argv. git usesgh auth git-credentialforhttps://github.com. A token served on demand from the Mac over a forwarded socket was considered and rejected: any process in the VM could still request it, so it adds moving parts without real protection. - D8 Interface. Superseded by D11. The bash CLI used gum, with a plain
fallback. The Go CLI keeps the same behavior (spinners, pickers, forms,
confirmations, tables; plain text and no prompts without a terminal or with
--plain) using Charm's Go libraries, so gum is no longer needed. - D9 GitHub push transport. Decided: https with the token when one is
installed, SSH otherwise. Claude Desktop's helper in the VM runs without
SSH_AUTH_SOCK, so SSH pushes can't work in Claude sessions.limabean-github-gitwrites~/.config/limabean/github.gitconfig: with a token,insteadOfrules send GitHub SSH remotes over https; without one,pushInsteadOfsends https pushes over SSH. - D10 AWS. Decided: mount
~/.awsread-only when it exists. A writable mount would let the VM edit~/.aws/config, whosecredential_processruns commands on the Mac. Read-only means SSO logins happen on the Mac. limabean adds the mount at creation (--set .mounts += …) only when the directory exists, so the templates stay host-independent. - D11 CLI language. Decided: Go, driving
limactl. Bash had reached its limits: bash 3.2 compatibility, quoting, a SIGPIPE false negative, and no good way to build a secrets form. The Go CLI uses Cobra and Viper, one command per file, with every command's help enforced by a test. It uses huh and lipgloss for the interface, embeds the templates, and ships as one universal binary built with GoReleaser. It doesn't import Lima's Go packages, which make no promise of a stable API;limactlandlimactl list --format jsonare the boundary. - D12 Secrets. Decided: references on the Mac, values only in VMs.
secrets.envandsecrets.<vm>.envholdNAME=referencelines (keychain:,op://,env:,cmd:or a plain value).secrets syncresolves them on the Mac and writesexportlines (mode 600) into each VM. The GitHub token is one of these secrets. Values never reach argv or a Lima template. - D13 Snapshots. Decided: APFS clones of the disk files. Lima 2.2's
vzdriver returns "unimplemented" forlimactl snapshot, which only works with QEMU. With the VM stopped, limabean clonesdisk(ordiffdisk),vz-efiand the code disk'sdatadiskwithcp -cinto<instance>/limabean-snapshots/<tag>/. Restores clone each file beside its original and rename it into place, so a failure never leaves a half-written disk. Live snapshots (memory included) would need Virtualization.framework's save/restore, which Lima doesn't expose. - D15 Network allowlists. Decided: dnsmasq and nftables in the VM, plus
an opt-in lock. dnsmasq answers only allowed domains (
address=/#/makes the rest NXDOMAIN, which also closes DNS as a way out) and adds the addresses it returns to nftables sets (nftset=); aninet limabeantable drops all other output and forwarded traffic, so containers are covered, and only dnsmasq's user may reach the upstream DNS server. The policy file is written on the Mac and mounted read-only, so the VM can't edit it;limabean-networkapplies it at boot (after the mount) and onsystemctl start limabean-network. Inside the VM this isn't a boundary on its own: Lima gives the user passwordless sudo, and the Docker socket is root. Solockedreplaces cloud-init's sudoers entry with two commands (Lima's host agent runssudo caton its param file at every start; re-applying the policy only reads the Mac's copy), drops the sudo/docker groups and the socket ownership, and is re-applied every boot because cloud-init and docker.sh would restore them. Enforcing on the Mac (a filtering proxy plus pf rules) would hold against root in the VM too, but needs admin rights on the Mac; it's the next step if the lock isn't enough. - D14 MCP server. Decided:
limabean mcp, stdio, VM-scoped tools. Built on the official Go SDK. Each tool runs limabean's own command in-process, with a plain UI capturing its output andssh/get's terminal handoff disabled (stdout carries the protocol), so every guard applies. Threat model: the client is an agent that may have read hostile content (a README, an issue, a web page) and been steered by it. So: - nothing runs on the Mac:
run_in_vmandclone_repogo over ssh into a running VM, and secrets aren't exposed at all, since acmd:reference is a command on the Mac; - names and snapshot tags are validated before reaching the command line,
where
--forcewould be a flag, and a repository can't start with-; - tools that destroy work exist only with
--allow-destructive, carry_meta["anthropic/requiresUserInteraction"](Claude Code then asks before every call, in any permission mode) anddestructiveHintfor other clients, anddelete_vmkeeps rm's unsaved-work check; - one limabean command runs at a time;
run_in_vmhas a timeout (10 minutes by default, an hour at most) and returns the last 100 KiB. What a VM can reach (its secrets, GitHub with its token, the network) an agent in it can reach too; scoping that is per-VM secrets' job. - D16 Fast creation. Decided: clone a provisioned image. Provisioning
(full upgrade, Docker, every pack's tools) took minutes per VM; booting
takes seconds. An image is a VM provisioned exactly like any other but
with an empty network policy, no code disk, no AWS mount and no secrets,
then cleaned (
apt-get clean, an empty/etc/machine-idso each copy gets its own, as cloud images do) and stopped. A new VM islimactl cloneof it, with--setreplacing the per-VM params and mounts (git config, network policy, code dir), adding its code disk and AWS mount, and setting its size; then a normal boot, whose provisioning scripts find everything installed. Lima regenerates the cloud-init instance ID, SSH host keys and MAC address per instance, and networking usesdhcp-identifier: mac, so copies don't collide. - The key is a hash of the image format, every file under the templates directory (user templates and packs included), the environment and the packs. Anything that would change a VM changes the key, and outdated images are pruned rather than patched.
- No image yet means the old path plus a detached
images build --if-needed, so the first VM is no slower than before. Images older than a week are used and replaced in the background, bounding how stale a copy's packages are. - flock guards: one build per key (
.build), and copies hold.useshared while the builder takes it exclusively to delete the image it replaced. - Considered: snapshotting the first real VM before its secrets were installed (saves one provision, but risks carrying VM-specific state into the image), and Lima's own image caching (only the download).
- D17 Guardrails in VMs. Superseded by D18: plugins come from a
project's
.limabean.yaml, so no plugin is in every VM. Originally: declare the plugin in the VM's user settings.features/guardrails.shadds therossedman/guardrailsmarketplace andguardrails@guardrailsto~/.claude/settings.jsonon every boot, only where they're missing, sofalsesticks. Claude Code fetches a plugin enabled in user settings when a session starts, with no trust prompt (project settings have one), and Claude Desktop's SSH copy and theclaudepack's both read that file. The packs carry the formatters and linters guardrails runs, so it doesn't download them. - Considered:
claude plugin installduring provisioning (needs Claude Code installed and the network at that moment; Claude Desktop's copy arrives only on first connect). - D18 Claude plugins from a project file. Decided: user settings
written in the VM by
up..limabean.yaml'sclaude.marketplacesis a list of entries, each a marketplace (GitHubowner/repoor an https git URL) and theplugin@marketplaces to turn on from it; the name after the@is the settings key, so one entry's plugins must agree on it.project.Loadchecks every entry against a strict pattern, the plan lists them, andupsends them to a python script in the VM as base64 JSON on stdin, so the file's text never reaches a command line on either side. The script merges them into~/.claude/settings.json, and Claude Code fetches them at session start (as in D17), under the VM's network policy. - Skills aren't listed separately: plugins ship them, along with agents, hooks and MCP servers.
- Considered:
claude plugin install(needs the claude pack, and misses Claude Desktop's copy), and leaving it to a repository's own.claude/settings.json(needs a trusted folder; nothing for headless runs or other folders). - D19 Recipes. Decided: embedded starter project files, tested by
bringing each one up.
recipes/*.yamlare.limabean.yamlfiles with comments, embedded by a smallrecipespackage so the binary carries them;limabean recipeslists them (the first comment line is the description) and writes one into the current directory without replacing a project file unless forced. They live outsidetemplates/so editing one doesn't change the templates hash and outdate every image.TestRecipesBringVMsUprunsupon each, so a recipe naming a missing pack or preset fails CI, and the docs include the files themselves. - D20 Packs. Decided: none by default; a project lists all of its own.
VMs used to get Go, Python, Node, Kubernetes, infra, container and
database packs unless told otherwise, and most recipes were lists of
packs to remove. Now
PACKSis empty: a VM hascore, Docker and Python, and gets whatnew's form picks (PACKSpre-checked), what--withnames, or what its.limabean.yamllists. A project's packs are exact, ignoringPACKS, soupbuilds the same VM for everyone;withoutstill loads, with a warning, so older files work. Configs written byinitbefore keep the old list inPACKS, so existing users see no change until they clear it. Smaller default images build faster too. - D21 up and down. Decided: up makes an existing VM match its file;
down stops it. Like
docker compose, the file is the source of truth:upon an existing VM compares the file with the VM as it is (saved packs and environment, Lima's size, the saved network policy, the VM's secrets file), not with a stored copy, so it also works for VMs made before it. It lists the differences and asks (--yesfor scripts), then rebuilds for packs or environment (keeping the~/codedisk; refused without one while work is unpushed), stops, edits and starts for size, adds secrets and Claude plugins, and applies a tighter network. It never widens a network or lifts a lock: those stay with the user's ownlimabean networkcommands, whichupprints. Setup runs once, or again with--setup.downonly stops the VM, keeping everything, rather than deleting it as Compose does: a VM holds more than a container, andrmalready deletes with its checks. - D22 Two verbs. Decided: up and down make, start and stop every VM.
There were four ways to bring a VM up (
new/create,start,up, and the commands that start one on the way) and two to stop one. Nowupworks from a project file when there is one and the name (if any) is the project's, and otherwise starts the named VM or creates it withnew's form and flags. Flags that only mean something when creating are refused for an existing VM or a project, rather than ignored.downstops the project's VM or the named one, and takesstop's--idle.new,create,startandstopstay as hidden commands: scripts keep working, and autostop agents installed earlier runstop --idle.upapplies its size flags itself, since a setting can bind only one flag andnewkeeps its binding. - D23 start and logs. Decided: start runs detached after every up, and
is approved like a change.
setupruns once; what should be running whenever the VM is (services, a dev server) goes instart, whichuplaunches withsetsid nohupso it outlives the ssh session, keeping a pid file and skipping the launch while the last one still runs (a zombie doesn't count, for inits that don't reap). Its output and setup's (throughtee, keeping the exit status) go to~/.local/state/limabean/in the VM, whichlimabean logsreads, along with cloud-init's log (needs sudo, so not in a locked VM) and Lima's host log on the Mac. Because start runs again on everyupand the file can change with agit pull, the last approved commands are kept on the Mac; different ones are a changeupshows and asks about, and until then neither old nor new run.up --dry-runprints the plan or the changes and stops. - D24 vars, ps and project tools over MCP. Decided: the project
file stays the one description, for people and for Claude.
vars:writes plain variables to~/.config/limabean/project.env, whichenv.shsources beforesecrets.envso secrets win; names the shell or linker reads first (PATH,LD_*, …) and names that are also secrets are refused. The applied set is kept on the Mac so changes are shown and applied live.pscombines the VM's status, start, ports andup's pending changes. Over MCP,project_plan(read-only) andproject_up(withrequiresUserInteraction, so the user approves each call after seeing the plan) runupitself, so every rule holds: networks only tighten, start changes need approval, and a rebuild needs--allow-destructive. MCP input picks only a directory, whose own regular.limabean.yamlis the one file read (no parents, no symlinks, so a call can't turn limabean into a file reader), and the origin comes from parsing.git/config, so git never runs on the Mac for a tool call. Setup, which needs a terminal in the CLI, runs without one over MCP and its output goes into the reply. - D25 Following cli.style. Decided: adopt its
rules on streams, exit codes, flag names and discoverability, keep
limabean's verbs. stdout carries only data, and status goes to stderr.
Exit codes are fixed (2 usage, 4 not found, 5 in the way, 130
cancelled), and
--jsonfailures are JSON.help --jsonandversion --jsondescribe the CLI to scripts and agents. Flags are named for their outcome (--preview,--yes,--overwrite), with the old names kept and hidden.configshows where each setting comes from. limabean's own files are written atomically, and a credential typed on the command line gets a warning. Not adopted: noun-verb grammar (upanddownstay, D22); dropping success lines (they moved to stderr, and-qhides them); macOS~/Librarypaths (~/.configand XDG stay); and editing~/.ssh/configonly on request (initasks first, and limabean can't connect without theInclude). The hiddennew,startandstopprint a note naming what replaced them. No removal date is set. - D26 Project tools and a schema. Decided: a project file names its own
tools, and editors check it.
tools:(mise,tool: version) andapt:cover what no pack has, without a pack on every Mac. They're written into the VM next to the packs' lists at creation (90-project.toml,_project.list), so the same boot scripts install them and VMs copied from an image get them; a change rebuilds, as a pack change does, since a locked VM has no sudo to install them live. Only names and versions are accepted, so the file can't break out of the TOML or the apt list, and nothing runs on the Mac. The JSON Schema published with the docs is generated from Go, usingproject.Load's own patterns; tests fail if a key is missing from it, if the published copy is stale, or if a recipe doesn't validate. - D27 Writing and checking project files. Decided:
init --projectguesses, anddoctorchecks what only the Mac knows.init --projectreads manifests, lockfiles, framework hints, compose files, tool-version files and.env.examplein the repository's root and the directories just below it, and writes a commented draft that must pass the same checks asupbefore it's written. It's part ofinitrather than a new verb, and explicit (--project) rather than switched on by runninginitin a checkout, so first-time setup never writes into a repository by surprise. It only reads files, never runs repository code, doesn't follow symlinks, and skips directories whose names would change whatsetup'scdmeans.doctorgains a Project section for what the schema can't know: whether the packs, presets and secrets exist on this Mac, and whether the VM matches the file. - D28 The project's VM by default, and update notices. Decided: commands
that connect or read default to the project's VM; ones that destroy
don't. Inside a checkout,
ssh,code,get,open,ports,network lsandsnapshot save/lsuse its VM when none is named, whilerm,rebuildandsnapshot restorestill need a name, so a script run in a checkout never deletes or rolls back a VM it didn't name. Once a day, a command in a terminal startsupdate-checkin the background, which asks GitHub for the latest release; the next command says when it's newer. It never blocks a command, never prints in scripts,--json,-qor MCP, andUPDATE_CHECK=noturns it off. The reply is data: a tag that isn't a version is refused before it's stored or shown.project_initgives Claudeinit --project --printfor a git checkout only, so it can't read around the Mac. - D29 Working in the project's clone. Decided: in a checkout,
sshand Claude'sproject_runwork in the VM's clone. The clone is where the project's code is in the VM, solimabean ssh -- make testruns there, and a plainlimabean sshstarts the shell there; a--before anything means "no VM name, this is the command".project_runfinds the clone asupdoes (the origin read from.git/configas a file, the clone located with ghq in the VM) and runs the command there; likerun_in_vm, it runs anything in the VM, so it carries the destructive hint. The Mac's checkout and the VM's clone stay separate copies, and the docs say so where it matters. - D30 Workflows as MCP prompts. Decided: a few whole tasks, written out
as prompts.
test_in_sandbox,triage_repo,try_a_change,run_untrustedandtidy_vmsshow up as slash commands in Claude Code and in Claude Desktop's menu. A prompt is only text, so it adds no power: the tools' guards decide what happens. Each prompt also ends with the same rules (plans first, never widen a network, deleting and restoring left to the user), and a test checks that every tool a prompt names exists, so the prompts can't drift from the tools. - D31 What a VM knows about itself. Decided: a file and a script, no
service. limabean writes
~/.config/limabean/vm.jsonin the VM (its name, environment, packs, tools and, for a project, the repository, clone, ports, var names, setup and start) when it creates or rebuilds a VM and on every projectup, andlimabean-infoprints it. A service answering queries was the alternative; a file needs no daemon, no port and nothing to secure, and an agent reads it withcatorjq. It's in the user's home rather than/etcbecause a locked VM has no sudo for later writes. The network policy and the secrets' names aren't stored:limabean-inforeads them live, the policy from the read-only mount (so a process in the VM can't make the file lie about its own limits) and the names fromsecrets.env. No secret value or reference goes in the file. It's only a description, so failing to write it warns and carries on. - D32 Getting work out of a VM. Decided: the VM's clone as a git remote
of the Mac's checkout. The VM's clone and the Mac's checkout are
separate copies, and pushing through GitHub just to review a change is
slow and needs the VM to reach GitHub.
limabean remoteaddslima-<vm>:<clone>as a remote, sogit fetchcopies the VM's branches over SSH, aslimabean sshreaches it, whatever the VM's network allows. It runs git on the Mac, so it's a command only the user runs, never an MCP tool. The clone's path comes from the VM and is data: it must be a plain absolute path before it goes into the Mac's git config, the remote's name can't be a flag, and the remote fetches no tags, so a VM can add branches underlimabean/and nothing else. Fetching copies objects and runs nothing from the VM; checking a branch out is the user's choice, as with any remote. A remote of the same name that isn't a VM's is never changed or removed. - D33 What a VM knows, from the Mac. Decided:
limabean infoand the MCPvm_infotool printlimabean-info(D31). Claude on the Mac reads the same description an agent in the VM does, instead of working out the clone's path and tools by running commands. The VM's output is data:--jsonpasses it on only if it's JSON. A VM made before limabean-info says it needs a rebuild. - D34 Claude Code in a VM. Decided: a status line and a note, through
Claude Code's own settings, not a plugin. A Claude Code mod (#37)
showed the same kind of thing and was taken out (#40): its settings
depended on Claude Code's version and on running in a terminal.
statusLineis a plain setting that runs a script, solimabean-statuslineworks across versions; it reads the policy from the read-only mount,vm.json, git andss, and sends nothing. A managed block in~/.claude/CLAUDE.mdtells Claude how a limabean VM works (limabean-info, blocked hosts, ports, the clone, secrets).features/claude.shsets both at each boot, only filling in what the user hasn't set, and turns off nonessential traffic a locked network would block. Permissions are left as Claude Code's defaults: skipping them is the user's call. - D5 vmType. Decided:
vz, with Rosetta.layer-os.yamlsetsvmOpts.vz.rosettawithbinfmt: true, so x86_64 binaries andlinux/amd64containers run on Apple Silicon without a prefix (Lima registers it with theFflag, which Docker needs). On Intel Macs Lima logs that Rosetta is unavailable and boots normally. QEMU user-mode emulation was the alternative: slower, and one more package to install.
Environments¶
The base layer is deliberately broad: shell tools, Go, Python, containers, Kubernetes and Terraform tooling in every VM. That keeps a single manifest to maintain. Environment templates exist for what really differs: resources, or heavy extras most VMs don't need.
| Name | Adds on top of base |
|---|---|
scratch |
nothing: a throwaway sandbox |
(future) node |
node LTS, pnpm |
(future) k8s-big |
more CPU/memory for kind clusters |
Milestones¶
- ✅
layer-os.yaml+layer-base.yaml+ theapt-common,mise,gitanddocker/shellfeatures, plus the global editorconfig.scratchboots, andssh lima-scratchhas every tool. - ✅ Tool packs (
--with/--without,PACKS) instead of per-environment mise TOML files. - ✅ The CLI:
init,doctor,new,ls,ssh,get,stop,rebuildandrm, with the unsaved-work guard. - Dotfiles (
features/dotfiles.sh). ✅ Code-location option C (persistent disk). - ✅
limabean bump(rewrite pinned versions inpacks/*.toml, monthly inbump.yml), ✅ CI (limactl tmpl validate+ shellcheck), and ✅ a persistent~/codedisk so rebuilds keep repositories. - ✅ Agent workflows: project files (
.limabean.yaml,up), throwaway VMs (--ttl,run --rm), copy-on-write clones (clone, overlimactl clone), learning allowlists from DNS lookups (network learn/log),cp, and aclaudepack. - ✅ Images: new VMs are
limactl clones of a provisioned, cleaned and stopped VM, one per environment, pack set and templates hash, built in the background and replaced weekly (limabean images).