One CLI for agents to install, set up, test, and verify software across any reachable target.
vmlab is a transport-agnostic orchestrator for cross-platform verify loops. It does not replace crabbox, abx, guiport, adb, idb, or Maestro — it composes them so a single command works whether the target is a Hetzner Linux VM, a Parallels Windows guest, a Pixel phone, an iOS simulator, a Mac mini, or a ChromeOS box.
It provisions the machines too. vmlab up scales an instance up on
Parallels, Hetzner, AWS, Azure, GCP, or Tart; you run the verify loop on it;
vmlab down scales it back down (suspend / poweroff / destroy) — with
per-instance budget caps and an orphans sweep so nothing is left billing.
See docs/architecture.md for the design and
docs/providers.md for the lifecycle layer that scales
Parallels and cloud instances up and down.
Homebrew tap (macOS/Linux):
brew install edihasaj/tap/vmlabWindows (PowerShell) — installs the latest release .zip into
%LOCALAPPDATA%\Programs\vmlab and adds it to your user PATH:
irm https://raw.githubusercontent.com/edihasaj/vmlab/main/scripts/install.ps1 | iexgo install (Go ≥1.24, any OS):
go install github.com/edihasaj/vmlab/cmd/vmlab@latestBuild from source (Go ≥1.24):
git clone https://github.com/edihasaj/vmlab && cd vmlab
make install # → $GOPATH/bin/vmlab
PREFIX=$HOME/.local make install # → $HOME/.local/bin/vmlabPre-built binaries (darwin/linux × amd64/arm64 as .tar.gz, windows ×
amd64/arm64 as .zip) ship on every tagged release — grab one from
GitHub Releases.
Host support: vmlab runs as the driver on macOS, Linux, and Windows.
From a Windows host you can drive ssh (Linux), ssh-windows, and local
targets; parallels-guest and the macOS GUI transports (guiport, abx,
ssh-mac) require a macOS host. Cancelling a run on Windows force-terminates
the process (no POSIX signals), so SIGINT cleanup hooks do not fire there.
# 1. set up dirs and a starter flow
vmlab init
# 2. add a target — local, crabbox, adb, idb, simctl, maestro, abx, guiport
vmlab target add --name dev-mac --transport local --tags local,mac
vmlab target add --name ubuntu-local --transport crabbox --tags linux,vm \
--set crabbox.id=ubuntu-local
# 3. verify health across every transport
vmlab doctor
# 4. run a one-off command
vmlab run dev-mac -- uname -a
# 5. run a flow against everything tagged @linux, in parallel
vmlab run @linux flows/install.yaml --max-parallel 4
# 6. browse the evidence bundle from the last run
vmlab evidence ls
vmlab evidence show <run-id>| Command | Purpose |
|---|---|
vmlab init |
Create user dirs and a starter .vmlab.yaml + flows/install.yaml. |
vmlab target add/ls/show/rm |
Manage targets (YAML files under ~/.vmlab/targets/). |
vmlab doctor [selector] |
Check transport binaries on PATH and per-target reachability. |
vmlab run <selector> <flow.yaml> |
Run a flow against the selected targets. |
vmlab run <selector> -- <cmd...> |
Run a shell command across targets. |
vmlab verify [project] |
Run a project's saved flow against its target — auto-detected from the working directory, or by name. |
vmlab shell <target> |
Open an interactive shell on a target. |
vmlab web <target> -- <abx-args...> |
Drive a web target via abx. |
vmlab gui <target> --kind click --selector ... |
Drive a desktop target via guiport. |
vmlab screenshot <target> <out-path> |
Capture a screenshot from any transport that supports it. |
vmlab cp <target> <local-path> <remote-path> |
Copy a small file into a guest without relying on a pre-existing share. |
vmlab evidence ls/show/bundle/prune |
Inspect or zip per-run evidence directories. |
vmlab provider ls/doctor |
List/health-check registered VM providers. |
vmlab instance add/ls/show/rm/status/restart |
Manage provider instances under ~/.vmlab/instances/. |
vmlab up <instance> |
Ensure an instance is running and ready (idempotent). |
vmlab down <instance> [--dispose=…] |
Dispose of an instance — keep|suspend|poweroff|destroy. |
vmlab restart <instance> |
Reboot an instance and wait for ready — recovers a wedged guest agent. |
vmlab with <instance> -- <cmd> |
Up → run → restore prior state. Honours disposition.only_if_we_started. |
vmlab sync <instance> |
Wire host-to-guest shares (parallels) / rsync (ssh) per the instance's mounts:. |
vmlab snapshot save/restore/ls/rm |
Manage instance snapshots (parallels today; Snapshotter is a per-provider opt-in). |
vmlab wait <instance> |
Re-poll provider readiness (useful after a guest reboot mid-flow). |
vmlab orphans [--destroy] |
List (and optionally clean) cloud resources tagged vmlab=*. |
vmlab serve --mcp |
Speak Model Context Protocol over stdio for agent integration. |
Every read-style command supports --json. Run-style commands return non-zero
on any target failure and write a single evidence bundle (including a
junit.xml summary for CI consumption).
Extra run flags:
--dry-runprints the resolved plan (targets + steps) without executing.--no-evidenceskips writing the bundle.--max-parallel N,--fail-fast,--continue-on-errorcontrol fan-out.
So you don't have to remember which flow/target pairs with which repo, save a
project profile at ~/.vmlab/projects/<name>.yaml (or .vmlab/projects/ in
a repo):
name: acme
path: ~/Projects/acme/acme # cwd at/under this auto-selects the profile
target: win11-ssh # any run selector
flow: ~/Projects/agent-scripts/vmlab/flows/acme-win-verify.yamlThen from inside the repo just run vmlab verify — it detects the project from
the working directory (deepest matching path: wins), resolves the target +
flow, and runs it through the same engine as vmlab run. Use vmlab verify <name> to run it from anywhere, vmlab verify --list to see configured
projects, and --dry-run to preview the plan.
Global:
-v/--verboseenables DEBUG slog output for transport + flow steps.
ubuntu-local # exact name
@linux # tag
@linux,@vm # AND
not:@ci # exclusion (chained with previous selector)
@linux,not:@ci # AND + exclusion
@linux;@mobile # union
all # everything
Multiple top-level args are union: vmlab doctor a @mobile.
| Transport | Notes |
|---|---|
local |
Run on the dev machine itself. Useful for testing flows. |
crabbox |
Shells out to crabbox for SSH-reachable hosts (Linux/Windows/macOS VMs). |
ssh |
Direct SSH to Linux hosts (with optional ssh.display for X11/Xvfb desktop UI). |
ssh-windows |
Windows over SSH. Set ssh.guiSession: interactive for real desktop UI and screenshots. |
parallels-guest |
Parallels guest (Windows/macOS) — read-mostly verbs via prlctl. |
abx |
Headless browser actions. Tagged for web capability. |
guiport |
Native macOS desktop UI driving via Accessibility + OCR fallback. |
adb |
Android devices and AVDs. |
idb |
iOS devices via the idb-companion stack. |
simctl |
iOS Simulator via xcrun simctl. |
maestro |
Declarative mobile flows. |
Adding a new transport = ~200 LOC adapter implementing the Transport
interface. See docs/architecture.md.
vmlab doesn't just drive targets that already exist — it provisions them. A provider owns an instance's lifecycle: scale it up, hand back a ready target, and scale it down afterwards. So one command can spin a cloud VM up, run the verify loop on it, and tear it back down.
# one-shot: up → run → restore prior state
vmlab with gpu-burst -- vmlab run gpu-burst flows/verify.yaml
# or drive the lifecycle by hand
vmlab up gpu-burst # scale up: create/boot, wait until ready
vmlab run gpu-burst flows/verify.yaml
vmlab down gpu-burst --dispose=destroy # scale down: keep|suspend|poweroff|destroy- Idempotent.
upis a no-op when the instance is already ready;downhonoursonly_if_we_started, so vmlab never suspends a VM you were using. - Budget caps. Set
budget.hourlyUSDand vmlab refuses to scale up when the provider quotes a higher rate — a guard against a misconfigured region or instance type. - Orphan sweep.
vmlab orphans --destroycleans up any cloud resource taggedvmlab=*that outlived its run. - Snapshots. Providers that support it expose
vmlab snapshot save/restore.
| Provider | Backend | Default transport | Scale-down default |
|---|---|---|---|
parallels |
prlctl (local or over SSH) |
parallels-guest |
suspend |
hetzner |
hcloud |
ssh |
destroy |
aws |
aws CLI |
ssh |
destroy |
azure |
az CLI |
ssh |
destroy |
gcp |
gcloud |
ssh |
destroy |
tart |
tart (Apple silicon) |
ssh |
keep |
windows |
local / Hyper-V | ssh-windows |
keep |
Instances live in ~/.vmlab/instances/<name>.yaml. See
docs/providers.md for the instance schema, host→guest
mounts, snapshots, and per-provider pricing sources.
For a freshly created Ubuntu VM in Parallels, make it vmlab/crabbox-ready from the host:
vmlab instance setup-linux \
--vm "Ubuntu 24.04.3 ARM64" \
--host 10.211.55.7 \
--prefix ubuntu \
--share farm=$HOME/Projects/farmThe command creates or reuses ~/.ssh/vmlab_<prefix>, adds the public key to
the guest, enables SSH + avahi, installs baseline Linux tooling, creates a
writable crabbox work root, configures Parallels shared folders, appends an
SSH host alias with a timestamped backup, and writes repo-local smoke targets
under vmlab/targets/ plus flows under vmlab/flows/.
Intentionally minimal — push complexity into your shell scripts.
# flows/install.yaml
name: install
steps:
- run: ./scripts/setup.sh
- run: ./scripts/install.sh
- assert: ./scripts/verify.shA gui: step dispatches a structured desktop action through the target's
GUI-capable transport (today: guiport on macOS). Useful for verifying
desktop apps end-to-end without dropping back to free-form shell.
# examples/flows/guiport-e2e.yaml
steps:
- run: osascript -e 'tell application "TextEdit" to activate'
- gui: { kind: wait, extra: { milliseconds: 600 } }
- gui: { kind: observe }
- gui: { kind: type, text: "hello vmlab" }
- gui: { kind: screenshot, path: /tmp/shot.png }
- assert: 'test -s /tmp/shot.png'Supported kinds map to guiport verbs: click, click-text, click-at,
type, hotkey, screenshot, observe, tree, wait (host-side sleep),
run (replay a guiport YAML). See examples/flows/guiport-e2e.yaml for a
runnable demo and examples/flows/recall-cross-os.yaml for a cross-OS
flow (linux + windows + mac, single junit.xml).
when: now accepts env=NAME and env!=NAME clauses alongside os= and
arch=. Use this to make a step opt-in via an env var — handy for actions
that need a TCC grant the rest of the flow doesn't:
# Only runs when invoked as VMLAB_GUI_SCREENSHOT=1 vmlab run ...
- when: env=VMLAB_GUI_SCREENSHOT
gui: { kind: screenshot, path: /tmp/shot.png }
- when: env=VMLAB_GUI_SCREENSHOT
assert: 'test -s /tmp/shot.png'vmlab serve --mcp # read-only tools
vmlab serve --mcp --allow-write # adds vmlab_run, vmlab_web, vmlab_guiExposed tools: vmlab_targets, vmlab_doctor, vmlab_evidence,
vmlab_run, vmlab_web, vmlab_gui. Each returns JSON inside an MCP text
content block.
Wire up Claude Code:
$VMLAB_HOME/config.yaml— user-level defaults whenVMLAB_HOMEis set.~/.vmlab/config.yaml— user-level defaults otherwise.<repo>/.vmlab.yaml— repo overrides.$VMLAB_HOME/targets/*.yamlor~/.vmlab/targets/*.yaml— user targets; repo.vmlab/targets/*.yamlshadow them.$VMLAB_HOME/runs/<run-id>/or~/.vmlab/runs/<run-id>/— evidence bundles (kept 30 days by default).
vmlab is one corner of an agent fleet. Each ships standalone; vmlab composes
them when you point a target or instance at one. Full diagram in
docs/agent-fleet.md.
| Project | Role |
|---|---|
| vmlab | Cross-OS orchestrator. Transports + flows + evidence bundles. You are here. |
| guiport | Local macOS desktop driver. AX + OCR fallback. Standalone CLI/MCP; vmlab's guiport transport drives it. |
| shotport | Token-cheap screenshot capture for agents. Text first, budgeted pixels only when needed — cheap visual evidence for verify loops. |
| recall | Local repo-memory compiler for coding agents. vmlab verifies it cross-OS via examples/flows/recall-cross-os.yaml. |
MIT. See LICENSE.
Author: Edi Hasaj.