Ephemeral, container-like development environments on Apple Silicon.
curl -L -o cortl.zip https://github.com/rudavko/cortl/releases/download/v0.2.2/Cortl-0.2.2-darwin-arm64.zip && unzip -o cortl.zip && ./cortl --helpcortl uses a two-layer model:
- Images (
~/.cortl/images/{distro}-{version}): shared upstream OS artifacts managed by Cortl - Bases (
~/.cortl/bases/{name}): your VM definition (Cortlfile) plus per-base artifacts (base disk clone, templates, metadata)
Typical flow:
- Run
cortl runfor the default Alpine VM, or create a named base (cortl base create <name>) - Edit
~/.cortl/bases/<name>/Cortlfilewhen you want a custom base - Build custom bases with
cortl base build <name>to create/update image + base artifacts - Run a custom base with
cortl run <name> [path]
Pipeline at runtime:
Validation -> FetchImage -> PrepareStorage -> ConfigInjection -> Hook (Linux only) -> Boot
cortl run Boot the default Alpine VM in the current directory
cortl run <path> Boot the default Alpine VM with a workspace path
cortl run <path> --distro macos:latest Boot the default macOS VM with a workspace path
cortl run <path> --distro macos:<version> Boot a version-pinned default macOS VM with a workspace path
cortl run <base> [path] Boot VM from a named base
cortl run --auto-snapshot <base> [path] Boot and save snapshot on shutdown
cortl run --save-template <base> [path] Boot and capture a warm-boot template
cortl ls List running sessions
cortl stop <session> Stop a session
cortl clean Clean stopped sessions
cortl clean --session <session> Clean one stopped session by ID/prefix
cortl clean --all Remove all sessions + cached images + snapshots
cortl base create <name> Scaffold a new base (Cortlfile)
cortl base build <name> Build base (golden master + base disk)
cortl base build <name> --force Rebuild even if golden master exists
cortl base ls List available bases
cortl base rm <name> Delete a base
cortl snapshot ls List saved snapshots
cortl snapshot run <name> [path] Boot from a snapshot
cortl snapshot rm <name...> Delete one or more snapshots
cortl agent screenshot <session> Capture a VM frame for agent workflows
cortl agent observe <session> Return current VM observation JSON
cortl agent click <session> <x> <y> Send verified input to a macOS VM
cortl agent key <session> <key> Send a verified key to a macOS VM
cortl agent type-text <session> Send verified text from stdin
cortl agent wait <session> <condition> Wait for display or VM state events
cortl agent shutdown <session> Ask a macOS VM session to stop
cortl run
cortl run .
cortl run . --distro macos:latest
cortl run . --distro macos:26.2
cortl base create alpine-dev --distro alpine:3.19
cortl base create ubuntu-dev --distro ubuntu:24.04
cortl base create mac-dev --distro macos:latest
cortl base build alpine-dev
cortl run --save-template alpine-dev .
cortl run mac-dev ~/projects/app
cortl snapshot ls
cortl snapshot run shutdown-2026-...
cortl clean --session <session-id>Each base is driven by a Cortlfile at:
~/.cortl/bases/<base-name>/Cortlfile
Quick start:
# 1) Create a base scaffold
cortl base create mydev --distro alpine:3.19
# 2) Edit the generated Cortlfile
$EDITOR ~/.cortl/bases/mydev/Cortlfile
# 3) Build base artifacts
cortl base build mydev
# 4) Boot VM from that base
cortl run mydev .Cortlfile format:
# Required
BASE alpine:3.19
# Optional
MEMORY 4G
CPUS 4
HOOK worktree
# Optional install script: everything after --- runs during base build
---
chroot /mnt apk add --no-cache jq
echo 'hello from install script' > /mnt/root/hello.txt
Supported directives:
BASE <distro:version>(required): currentlyalpine:3.19,ubuntu:24.04,macos:latest, or a version-pinnedmacos:<version>MEMORY <nM|nG>(optional): VM memory override, e.g.512M,4GCPUS <n>(optional): VM vCPU count (>=1, up to host CPU count)HOOK <name>(optional): hook script name in~/.cortl/hooks/IPSW </absolute/path/file.ipsw>(macOS): optional formacos:latest; required for version-pinnedmacos:<version>refs when no matching golden image already exists in~/.cortl/images/---(optional separator): everything after this is treated as install script content. For Linux builds, the target disk is mounted at/mnt, so install packages withchroot /mnt ...and write target files under/mnt.
Default Linux bases are intentionally minimal. Install project-specific tools in the Cortlfile install script instead of relying on built-in image contents.
Alpine example:
BASE alpine:3.19
MEMORY 8G
CPUS 6
HOOK passthrough
---
chroot /mnt apk add --no-cache ripgrep
Alpine example with Bun and Claude Code:
BASE alpine:3.19
MEMORY 4G
---
echo 'https://dl-cdn.alpinelinux.org/alpine/v3.19/community' >> /mnt/etc/apk/repositories
apk add --no-cache curl unzip
chroot /mnt apk update
chroot /mnt apk add --no-cache bash git libstdc++ libgcc nodejs npm ripgrep
chroot /mnt npm install -g @anthropic-ai/claude-code
if curl -fsSL "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-aarch64-musl.zip" -o /tmp/bun.zip; then
unzip -o /tmp/bun.zip -d /tmp
mv /tmp/bun-linux-aarch64-musl/bun /mnt/usr/local/bin/bun
chmod +x /mnt/usr/local/bin/bun
rm -rf /tmp/bun.zip /tmp/bun-linux-aarch64-musl
fi
macOS example:
BASE macos:latest
MEMORY 12G
CPUS 6
Notes:
cortl run <base>reads that base'sCortlfileto resolve distro, CPU/memory, hook, and runtime behavior.cortl base build <base>also reads the sameCortlfile; changes toBASE, resources, hook, or install script are not applied to already-running sessions.- Current Linux install scripts run during shared image build. If a shared image already exists, rebuild behavior is tied to
cortl base build --force; per-base install-script application is tracked separately. runexpects an already-built base/image. If missing, build first withcortl base build <base>.runrestores an existing compatible warm-boot template, but it only captures a new template when--save-templateis passed.
┌──────────────────────────────────────────────────────────────────────────┐
│ Pipeline │
│ Validation → FetchImage → PrepareStorage → ConfigInjection → Hook → Boot│
└──────────────────────────────────────────────────────────────────────────┘
- FetchImage - Resolves shared image artifacts: Alpine netboot files, Ubuntu cloud-image files with signed checksum verification, or macOS restore-image state.
- PrepareStorage - Creates session disks from base disks and copies macOS auxiliary storage when needed.
- ConfigInjection - Builds Linux cloud-init ISO state or macOS VM platform configuration, and configures VirtioFS where supported.
- Hook - Runs user-defined hook scripts for Linux sessions.
- Boot - Starts Linux VMs with warm boot or cold boot through the serial console, or starts macOS VMs through the GUI window path.
bun run dev # Build + sign
bun run release # Release build + signRequires macOS 15+ and Apple Silicon.
From source, use the signed binary directly:
bun run release
./.build/release/Cortl --helpFor first-time use, cortl run is a guided Alpine path. It uses the default
alpine-3.19 base, creates and builds that base if needed, then captures a
warm-boot template the first time no compatible template exists.
During template setup, Cortl opens the guest shell before saving the template.
Install packages, edit files, and customize the environment there. Press
Ctrl+] when ready; Cortl saves the warm-boot template and exits. Future
cortl run calls restore that template when compatible.
Optionally add your own shell alias or symlink after reviewing where you want the binary installed.
GitHub Release assets are built by .github/workflows/release.yml. Push a
version tag to run public validation, build the release binary, sign it
ad-hoc, generate a SHA256 file, and create or update the GitHub Release:
git tag v0.2.0-rc1
git push origin main v0.2.0-rc1Tags with a suffix, such as v0.2.0-rc1, are published as prereleases. The
uploaded asset name is Cortl-<version>-darwin-arm64; for example,
Cortl-0.2.0-rc1-darwin-arm64.
bun run test # Run all local tests (with stage skipping)
bun run test:full # Force all local staged tests to run
bun run test:public # CI-safe build + unit/lightweight e2e checks
bun run test:status # Show which local stages will run/skipLocal VM tests use staged testing - source files are hashed per stage. If sources haven't changed since the last successful run, that stage is skipped. Current stages are build, netboot, fetch, cortlfile-claude, storage, config, and boot.
The checked-in GitHub Actions workflow at .github/workflows/ci.yml runs
bun run test:public on a GitHub-hosted macos-15 runner with HOME
redirected to an empty temporary directory. GitHub-hosted runners are VMs, and
GitHub documents nested virtualization as experimental and unsupported. Treat
this lane as build, unit, crash-report, CLI, hook, and dry-run validation only.
Full Cortl runtime validation requires launching guest VMs through Apple's
Virtualization.framework. Run bun run test or bun run test:full on a
dedicated physical Apple Silicon Mac, preferably as a fresh CI user or with an
isolated HOME, so existing ~/.cortl bases, restore images, hooks, crash
reports, and GUI/TCC state do not pollute the test bench.
The intended clean-clone direction is to make VM stages create temporary bases
from the same default Cortlfile templates used by cortl base create, instead
of depending on pre-existing ~/.cortl/bases/* state.
For full VM coverage in GitHub Actions, use a self-hosted runner on a physical
Apple Silicon Mac and run bun run test:full from the dedicated CI macOS user's
active console login session.
Release-candidate onboarding is a manual end-user smoke test. Use a separate physical Apple Silicon Mac or a fresh macOS user account on that Mac. Download the GitHub Release binary asset, not GitHub's auto-generated source archive:
- Open the release page for the tag, for example
https://github.com/OWNER/REPO/releases/tag/v0.2.0-rc1. - Download
Cortl-0.2.0-rc1-darwin-arm64andCortl-0.2.0-rc1-darwin-arm64.sha256. - In the fresh macOS user's GUI session, open Terminal and run:
cd ~/Downloads
shasum -a 256 -c Cortl-0.2.0-rc1-darwin-arm64.sha256
chmod +x ./Cortl-0.2.0-rc1-darwin-arm64
./Cortl-0.2.0-rc1-darwin-arm64 --version
mkdir -p ~/cortl-smoke
./Cortl-0.2.0-rc1-darwin-arm64 base create smoke-alpine --distro alpine:3.19
./Cortl-0.2.0-rc1-darwin-arm64 base build smoke-alpine
./Cortl-0.2.0-rc1-darwin-arm64 run smoke-alpine ~/cortl-smoke
./Cortl-0.2.0-rc1-darwin-arm64 base create smoke-macos --distro macos:latest
./Cortl-0.2.0-rc1-darwin-arm64 base build smoke-macos
./Cortl-0.2.0-rc1-darwin-arm64 run smoke-macos ~/cortl-smokeThe manual smoke test verifies the released binary, checksum file, first-run base creation, Alpine artifact download/build, and macOS restore-image download/build through the same entry point an end user runs.
References:
- GitHub-hosted runner model and nested virtualization note: https://docs.github.com/en/actions/concepts/runners/github-hosted-runners
- Apple restore-image discovery API: https://developer.apple.com/documentation/virtualization/vzmacosrestoreimage/latestsupported
Cortl downloads Linux boot artifacts over HTTPS. New Ubuntu cloud-image artifact
downloads verify Canonical's SHA256SUMS.gpg signature with the Ubuntu cloud
image signing key before trusting SHA256SUMS, then verify the artifact hash.
Install GnuPG first with brew install gnupg.
Alpine netboot artifacts and package repositories are fetched over HTTPS. The
guest apk package manager verifies signed repository indexes and packages.
macOS guests install from a local restore image. If no IPSW directive is
present for macos:latest, Cortl uses Apple's Virtualization.framework
restore-image discovery, downloads the latest supported restore image to
~/.cortl/images/, and installs from that local file. Version-pinned
macos:<version> refs reuse a matching golden image in ~/.cortl/images/ when
one exists; otherwise they require an IPSW path so Cortl does not silently
install a different macOS release.
PolyForm Noncommercial License 1.0.0. See LICENSE.