A developer-friendly Zig CLI for creating and managing isolated Incus VM sandboxes for agentic software development.
Cage is a thin orchestration layer around Incus.
Each sandbox is a full VM in a dedicated Incus project named cage.
Working. Implemented: create, list, shell, ssh, ssh-config, start,
stop, remove, uninstall, base provisioning (including OpenCode), GitHub
authentication, and JetBrains/ACP setup. See src/ for details.
- Incus with the QEMU driver (
incus,qemu-system-x86_64,edk2-ovmf) - Membership in the
incus-admingroup - Zig 0.17.0 — only needed to build from source (see below)
curl -fsSL https://raw.githubusercontent.com/mehdizebhi/cage/main/install.sh | shThe installer detects your OS/architecture, downloads the matching release,
verifies its checksum, and installs cage to ~/.local/bin.
Options:
./install.sh --version v0.1.0 # install a specific tag
./install.sh --dir /usr/local/bin # custom install directory
./install.sh --helpEnvironment equivalents: CAGE_VERSION, CAGE_INSTALL_DIR.
Supported: Linux on x86_64 and aarch64.
git clone https://github.com/mehdizebhi/cage.git
cd cage
zig build # produces zig-out/bin/cage
zig build test # run unit tests
zig build run -- list # run without installingReleases are built by GitHub Actions (.github/workflows/release.yml). Pushing
a v* tag builds Linux x86_64 and aarch64 binaries, generates checksums,
and publishes a GitHub Release.
git tag v0.1.0
git push origin v0.1.0The tag must match cage version output (src/cli.zig).
# Create a sandbox with defaults (4 vCPU, 8 GiB RAM, debian-13)
cage create my-agent
# Custom resources / disable the SSH + JetBrains setup
cage create my-agent --cpu 4 --memory 8G
cage create my-agent --no-ssh
# Manage
cage list
cage shell my-agent
cage ssh my-agent
cage start my-agent
cage stop my-agent
cage remove my-agentcage uninstall reverses the installation. It deletes every sandbox in the
cage project and the project itself, the ~/.ssh/cage_* keys, the managed
Host cage-* blocks in ~/.ssh/config, the cage config directory, and finally
the cage binary.
cage uninstall # asks for confirmation first
cage uninstall --yes # no prompt
cage uninstall --dry-run # print what would be removed
cage uninstall --keep-binary # leave the cage binary in placeIt does not touch Incus itself, your storage pools, or the default bridge network.
Cage provisions each sandbox for IntelliJ: it installs openssh-server, adds a
per-sandbox SSH key, and seeds ~/.jetbrains/acp.json so JetBrains AI Assistant
can drive OpenCode over the Agent Client Protocol — with the integrated
IntelliJ MCP server enabled for Java/Kotlin code intelligence.
-
Add the SSH host. Print a ready-to-use block and append it to
~/.ssh/config:cage ssh-config my-agent # -> Host cage-my-agent ...Verify with
cage ssh my-agent. -
Connect JetBrains Gateway. Remote Development → SSH → New Connection, choose the
cage-my-agenthost, pick a project directory, and connect. The headless IDE backend runs inside the VM. -
Install ACP agents on the Host. In the connected window: Settings → Plugins (Host) — install AI Assistant (and JetBrains Air). ACP agents need no JetBrains AI subscription.
-
Use OpenCode as an agent. AI Assistant reads
~/.jetbrains/acp.jsonon the backend host (the VM). Cage already wrote:{ "default_mcp_settings": { "use_custom_mcp": true, "use_idea_mcp": true }, "agent_servers": { "OpenCode (Sandbox)": { "command": "/usr/local/bin/opencode", "args": ["acp"], "env": {} } } }Select OpenCode (Sandbox) in AI Chat. Authenticate the agent once by running
opencode auth logininside the sandbox (cage ssh my-agent).If
GEMINI_API_KEYis set on the host when you runcage create, Cage injects it into the ACP config'senv(the file lives inside the VM).
Note: JetBrains connects to the sandbox's bridge IP, which Incus assigns via DHCP. If a restarted sandbox gets a new address, rerun
cage ssh-config.
Cage reads a global config file, then applies per-sandbox CLI flags on top.
Search order:
$CAGE_CONFIG$XDG_CONFIG_HOME/cage/config.json~/.config/cage/config.json
Missing file means built-in defaults. See config.example.json.
{
"image": "debian-13",
"resources": { "cpu": 4, "memory": "8G" },
"stacks": { "java": false, "node": false, "python": false, "go": false, "rust": false }
}Short image names are mapped for the Incus images: remote: debian-13 becomes
images:debian/13.
On create, Cage runs provisioning/bootstrap.sh inside the VM. The script is
embedded into the binary at build time, so it works no matter where cage
is installed. It installs the base tooling (git, gh, curl, jq, openssh-server,
...), OpenCode, and any enabled development stacks.
OpenCode v2 is installed from the npm registry (@opencode/cli-linux-*), which
is more reliable than the opencode.ai endpoint on restricted networks.
Override the script with CAGE_BOOTSTRAP=/path/to/script.sh.
| Variable | Effect |
|---|---|
GITHUB_TOKEN |
Configures gh inside the sandbox (via stdin) |
GEMINI_API_KEY |
Injected into the sandbox's ~/.jetbrains/acp.json |
CAGE_SSH_KEY |
Reuse an existing private key instead of generating ~/.ssh/cage_<name> |
CAGE_BOOTSTRAP |
Use a custom provisioning script |
CAGE_CONFIG |
Path to the config file |
Export a token before creating a sandbox:
export GITHUB_TOKEN=ghp_...
cage create my-agentCage runs gh auth login --with-token inside the VM, feeding the token on
stdin. The token never appears in argv, shell history, VM configuration, or
process listings.
cage (Zig CLI)
config · lifecycle · auth
│
▼
incus CLI
│
▼
Debian 13 VM (project: cage)
Cage shells out to incus and parses --format json output. It does not
implement its own virtualization or provisioning system.
build.zig build + test steps
install.sh release installer
src/main.zig entry point, wiring
src/cli.zig argument parsing and dispatch
src/commands.zig lifecycle command implementations
src/config.zig config model and loader
src/incus.zig incus CLI wrapper
src/ssh.zig SSH keys, connections, and ssh-config blocks
provisioning/ in-VM bootstrap script (embedded at build time)
.github/workflows/ CI + release automation