name: vm4a-cli
description: |
Use this skill when the user wants to create, run, stop, clone, push/pull,
SSH into, or otherwise manage VM4A virtual machines via the vm4a CLI.
Triggers include any mention of VM4A + "VM", the commands vm4a create,
vm4a run, vm4a pull, vm4a ssh, "linux VM on mac", "macOS VM via CLI",
or requests to automate VM lifecycle (CI, scripting, batch clones). Both
Linux and macOS guests are supported; macOS first-boot needs one manual
Setup Assistant click-through per fresh IPSW (Apple doesn't expose a
scriptable skip).
Do NOT use this skill for: the SwiftUI app itself (open Xcode instead), non-VM4A VM tools (UTM/VirtualBuddy/tart have their own CLIs), or questions about the Virtualization.framework itself.
VM4A CLI
VM4A ships a single binary vm4a that creates and runs Apple
Virtualization.framework VMs on Apple Silicon from the command line. Each
VM is a directory ("bundle") containing config.json, state.json, a disk
image, and platform identity files. One binary handles the lifecycle, OCI
distribution, and agent channel.
Prerequisites (check first, don't assume)
Before running anything:
Apple Silicon + macOS 13+. x86 Macs are not supported.
CLI must be codesigned with the CLI entitlements, otherwise
runfails silently:swift build codesign --force --sign - \ --entitlements Sources/VM4ACLI/VM4ACLI.entitlements \ ./.build/debug/vm4aBridged networking and Rosetta share need the CLI entitlements file specifically — not the App one.
macOS 26 (Tahoe) gotcha:
com.apple.vm.networkingis a restricted entitlement. Ad-hoc signing (--sign -) a binary that carries it makes AMFI SIGKILL the process at launch — everyvm4acommand dies with no output (exit137). If that happens, re-sign with an entitlements file that dropscom.apple.vm.networking(NAT-only; keepscom.apple.security.virtualization):cp Sources/VM4ACLI/VM4ACLI.entitlements /tmp/nat.entitlements /usr/libexec/PlistBuddy -c "Delete :com.apple.vm.networking" /tmp/nat.entitlements codesign --force --sign - --entitlements /tmp/nat.entitlements ./.build/debug/vm4a ./.build/debug/vm4a --version # must print, not get killedAd-hoc NAT covers spawn/exec/cp/run-code/expose-port/OCI — but NOT bridged networking and NOT snapshots. Snapshots on macOS 26 (verified):
saveMachineStateTois gated on hardened runtime + a real signing identity. Ad-hoc signing fails withVZErrorSave "permission denied"(sosnapshot save,run --save-on-stop, andresetall fail).validateSaveRestoreSupport()is YES — it's purely a signing issue. Re-sign with--options runtime+ an Apple Development / Developer ID identity:codesign --force --options runtime \ --sign "Apple Development: YOUR NAME (TEAMID)" \ --entitlements /tmp/nat.entitlements ./.build/debug/vm4aBridged mode additionally needs an identity provisioned for the managed
com.apple.vm.networkingentitlement (ad-hoc cannot grant it).For bridged mode:
vm4a network listshould print at least one interface. If empty, re-check codesigning.
Command map (when to reach for each)
Agent-first primitives (prefer these for any agent-driven flow):
| Intent | Command |
|---|---|
| One-shot create+start, wait for SSH | vm4a spawn NAME --from ghcr.io/you/img:tag --wait-ssh --output json |
| Run a command in the guest, get JSON | vm4a exec /path/to/bundle --output json -- python3 -c 'print(1+1)' |
| Copy a file host→guest | vm4a cp /path/to/bundle ./local.txt :/work/remote.txt |
| Copy a file guest→host | vm4a cp /path/to/bundle :/var/log/syslog ./syslog.txt |
| Fork a bundle, auto-start, wait for SSH | vm4a fork SRC DST --auto-start --from-snapshot clean.vzstate --wait-ssh |
| Reset to a saved snapshot for retry | vm4a reset /path/to/bundle --from clean.vzstate --wait-ip |
| Save a running VM as a named snapshot (stops it) | vm4a snapshot save /path/to/bundle clean |
| Restore a VM to a named snapshot (~1s) | vm4a restore /path/to/bundle clean (alias for vm4a snapshot restore) |
| List / delete named snapshots | vm4a snapshot list /path/to/bundle [--output json], vm4a snapshot rm /path/to/bundle clean |
Classic lifecycle:
| Intent | Command |
|---|---|
| Create a Linux VM bundle | vm4a create NAME [--image ISO.iso] [--network bridged --bridged-interface en0] [--rosetta] |
| Install a macOS VM from IPSW (10–20 min, drives VZMacOSInstaller) | vm4a create NAME --os macOS --image macos-15.ipsw --cpu 4 --memory-gb 8 --disk-gb 80 |
| List bundles in a directory | vm4a list --storage /tmp/vm4a [--output json] |
| Start a VM in the background | vm4a run /path/to/bundle |
| Start in foreground (logs to stdout) | vm4a run /path/to/bundle --foreground |
| Boot macOS recovery | vm4a run /path/to/bundle --recovery |
| Restore from a VZ snapshot (macOS 14+) | vm4a run /path/to/bundle --restore state.vzstate |
| Save state on clean shutdown (macOS 14+) | vm4a run /path/to/bundle --save-on-stop state.vzstate |
| Stop a VM (SIGTERM, then SIGKILL if stuck) | vm4a stop /path/to/bundle --timeout 20 |
| Clone a bundle (APFS clonefile when possible) | vm4a clone SRC DST |
| Show Linux ARM64 ISO catalog | vm4a image list |
| Show host bridged interfaces | vm4a network list |
| Look up VM's NAT IP | vm4a ip /path/to/bundle [--output json] |
| SSH into running VM (NAT) | vm4a ssh /path/to/bundle --user root |
| Push bundle to OCI registry | vm4a push /path/to/bundle ghcr.io/you/name:tag |
| Pull bundle from OCI registry | vm4a pull ghcr.io/you/name:tag --storage /tmp/vm4a |
| Check guest agent heartbeat | vm4a agent status /path/to/bundle |
| Ping guest agent | vm4a agent ping /path/to/bundle |
| Run as MCP server (stdio JSON-RPC) | vm4a mcp |
| Run HTTP API server on localhost | vm4a serve --port 7777 |
| Tag operations into a session for replay/inspection | vm4a exec ... --session run-42 |
| List/show recorded sessions | vm4a session list, vm4a session show <id> |
| Define a per-task spawn template | vm4a pool create py --base /path/to/golden --snapshot clean.vzstate |
| Mint a fresh task VM from a pool | vm4a pool spawn py --wait-ssh |
Key workflows
Agent loop (recommended — uses v2 primitives)
# 1. First-time bootstrap: pull, start (arm save-on-stop), wait for SSH.
vm4a spawn dev --from ghcr.io/yourorg/python-dev-arm64:latest \
--storage /tmp/vm4a \
--save-on-stop /tmp/vm4a/dev/clean.vzstate \
--wait-ssh --output json
# 2. Install whatever, then stop — VM saves state on shutdown.
vm4a exec /tmp/vm4a/dev -- bash -lc "apt-get install -y ripgrep"
vm4a stop /tmp/vm4a/dev
# 3. Per-task: fork the golden bundle, push code, run, parse JSON.
vm4a fork /tmp/vm4a/dev /tmp/vm4a/task-$JOB_ID \
--auto-start --from-snapshot /tmp/vm4a/dev/clean.vzstate --wait-ssh
vm4a cp /tmp/vm4a/task-$JOB_ID ./step.py :/work/step.py
vm4a exec /tmp/vm4a/task-$JOB_ID --output json --timeout 120 \
-- python3 /work/step.py
# 4. Bad task state? Reset back to the snapshot in <1s.
vm4a reset /tmp/vm4a/task-$JOB_ID --from /tmp/vm4a/dev/clean.vzstate --wait-ip
Spin up a disposable Ubuntu VM (manual / classic flow)
ISO=~/Downloads/ubuntu-24.04-live-server-arm64.iso
vm4a create demo --storage /tmp/vm4a --image "$ISO" \
--cpu 4 --memory-gb 8 --disk-gb 64
vm4a run /tmp/vm4a/demo
vm4a ip /tmp/vm4a/demo # needs VM to have booted + DHCP'd
vm4a ssh /tmp/vm4a/demo # after cloud-init / user setup
vm4a stop /tmp/vm4a/demo
Distribute a pre-baked VM through GHCR
# Push (requires a PAT with write:packages)
export VM4A_REGISTRY_USER=youruser
export VM4A_REGISTRY_PASSWORD=ghp_xxx
vm4a push /tmp/vm4a/base-ubuntu ghcr.io/youruser/base-ubuntu:24.04
# Pull on another machine (anonymous if package is public)
vm4a pull ghcr.io/youruser/base-ubuntu:24.04 --storage /tmp/vm4a
vm4a run /tmp/vm4a/base-ubuntu
Fast fork of a golden image for CI
vm4a clone /tmp/vm4a/golden /tmp/vm4a/job-$CI_JOB_ID
vm4a run /tmp/vm4a/job-$CI_JOB_ID
trap "vm4a stop /tmp/vm4a/job-$CI_JOB_ID && rm -rf /tmp/vm4a/job-$CI_JOB_ID" EXIT
clone uses APFS clonefile(2) on the same volume so it's O(directory
entries), not O(disk image size).
Non-obvious behaviors
- NAT IP only works for VMs using the default NAT attachment. Bridged
VMs don't land in
/var/db/dhcpd_leases; users must pass--host <ip>tovm4a sshor use their router's DHCP view. --rosettais Linux only and requiressoftwareupdate --install-rosetta --agree-to-licensebefore first run. The CLI warns but doesn't block.- macOS guests work end-to-end via the CLI, with one caveat: after
vm4a create --os macOS --image foo.ipswfinishes (10–20 min), the VM lands at Apple's Setup Assistant on first boot. Apple does not expose a scriptable skip path, so the user needs to click through it once in VM4A.app: pick region/keyboard, skip Apple ID, create a user account, then System Settings → General → Sharing → Remote Login: ON. After that single click-through, every CLI / MCP / HTTP / SDK operation works on the macOS bundle exactly like on Linux, and pulling a published macOS bundle from GHCR skips Setup Assistant entirely (the user account + Remote Login are baked in). - Config JSON format starts at
schemaVersion: 1. Old bundles without the field still load — tolerant decoding treats missing as 1. When adding new fields, make them optional in Core.swift decoding. vm4a stoprequires a running pid. Ifvm4a listshowsstoppedbut stale files exist, just re-runvm4a run; the CLI cleans stale PID files on the next list.--output jsonis available oncreate,list,ip,agent status, and on every v2 primitive (spawn,exec,cp,fork,reset). Output is one JSON object/array per command invocation (not JSONL).vm4a execreturns JSON withexit_code,stdout,stderr,duration_ms,timed_out. The exit code is also the process exit code (soif vm4a exec ... ; then ...works in shell). With--timeoutand a timed-out command, the agent getstimed_out: trueand a non-zero exit.vm4a cpuses:to mark guest paths, nothost:like docker cp. So./local.txt :/work/file.txtis host→guest. Both sides being host or both being guest is rejected.vm4a forkre-randomisesMachineIdentifierAND the NIC MAC automatically. Don't hand-roll a clone+identity flow —forkis whatcloneshould have been for agent loops, with optional--auto-startand--from-snapshot. (Re-randomising the MAC is what stops parallel forks from colliding on the NAT lease table.)vm4a ipmatches the lease by the bundle's persisted NIC MAC, not by hostname. New bundles store a fixed MAC inconfig.json; if no lease matches it yet,ipreturns empty / exit 1 (it no longer dumps the whole lease table). Legacy bundles without a stored MAC still fall back to hostname matching.vm4a snapshot save/restore/list/rm(+ top-levelvm4a restore) are the simple path — named.vzstatesnapshots stored in<bundle>/.vm4a-snapshots/, no path juggling.savecaptures the running VM then stops it (it signals the worker via SIGUSR1);restorereboots to that state in ~1s. Prefer these over hand-managingrun --save-on-stop/run --restore/resetpaths. Needs the hardened-runtime + real-identity signing (see Prerequisites #2).vm4a execparses its options in any position (the command goes after--): bothvm4a exec /b --output json -- cmdandvm4a exec --output json /b -- cmdwork. Same forcluster execandsshextra args.vm4a resetrequires a.vzstatefile, which means macOS 14+. On older hosts the agent has to fall back toclone+ reinstall.- Guest agent is a scaffold. Only
pingis implemented. Don't promise clipboard/shutdown/script execution yet.
Error exit codes (for scripting)
1 VM4AError.message Generic failure (legacy path)
2 VM4AError.notFound Bundle or file missing
3 VM4AError.alreadyExists Destination already exists
4 VM4AError.invalidState VM running when it should be stopped (or vice versa)
5 VM4AError.hostUnsupported macOS version / hardware capability missing
5 VM4AError.rosettaNotInstalled
When the user is stuck
- Every
vm4acommand exits with code137/ no output (macOS 26): AMFI killed the ad-hoc-signed binary because of the restrictedcom.apple.vm.networkingentitlement. Re-sign NAT-only (see Prerequisites #2). runsilently exits (but the binary itself runs): check.vm4a-run.login the bundle root.- "No bridged interfaces available": CLI is not signed with
com.apple.vm.networking. Re-run the codesign command from the top. - "Rosetta is not supported on this host": CPU doesn't expose VMX for Rosetta translation. Not fixable in software.
pushreturns HTTP 401: setVM4A_REGISTRY_USER/VM4A_REGISTRY_PASSWORD(a PAT for GHCR, a Docker token for Docker Hub).sshhangs: VM hasn't DHCP'd yet (vm4a ipreturns empty). Wait 10-30s aftervm4a runfor first-boot initialization.snapshot save/--save-on-stop/resetfail withVZErrorSave "permission denied"(macOS 26): the binary is ad-hoc signed. VZ state save needs hardened runtime + a real identity — re-sign with--options runtimeand an Apple Development / Developer ID identity (see Prerequisites #2).
When NOT to use the CLI
Redirect to the GUI app for:
- Walking the user through Setup Assistant interactively (open VM4A.app, pick the bundle, click Run, do the GUI steps; come back to CLI afterward).
- Changing graphics resolution / audio config (GUI has device editors)
- First-time users who want a wizard
What's new vs older guides
Most recent:
Named snapshots:
snapshot save NAME/restore NAME/snapshot list/snapshot rm, plus top-levelvm4a restore— named.vzstatesnapshots stored inside the bundle, no manual path juggling. Requires hardened-runtime + real-identity signing on macOS 26 (see Prerequisites #2).
v2.0 P1 — MCP server:
mcp(stdio JSON-RPC 2.0 server; register in.mcp.jsonto expose every primitive as an MCP tool to Claude Code / Cursor / Cline)
v2.0 P0 — agent-first primitives:
spawn(one-shot create+start with optional--from <oci-ref>,--wait-ssh)exec(SSH-driven command runner with structured JSON return)cp(SCP host ↔ guest with:prefix convention)fork(clone + re-identify + optional auto-start with snapshot restore)reset(stop + restart from snapshot for retry loops)
For any agent / scripted flow, prefer these over the manual
create → run → ssh chain. They handle PID management,
snapshot wiring, and JSON output by default.
Earlier additions (still relevant):
push/pull(OCI registry support, tart-style)network list,image list(host introspection)ip,ssh(NAT convenience)agent status,agent ping(guest-agent channel, scaffold)create --rosetta,create --bridged-interfacerun --restore/run --save-on-stop(macOS 14+ snapshots)