Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
/builds
node_modules
.wrangler
.clave.local.json
95 changes: 90 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ curl -fsSL https://clave.run | sh
- macOS with [Tart](https://tart.run/) installed
- A [Claude Code](https://claude.ai/code) account

## VM Guest OS Support

Clave supports both **Linux** and **macOS** guest VMs. The guest OS is auto-detected from the base image name — images
matching `macos`, `sequoia`, `ventura`, `sonoma`, `monterey`, `bigsur`, or `darwin` are treated as macOS guests;
everything else defaults to Linux.

macOS guests use a separate provisioning pipeline that installs dependencies via [pkgx](https://pkgx.sh) rather than
apt. To use a macOS guest, set the `base_image` in your `.clave.json`:

```json
{
"base_image": "ghcr.io/cirruslabs/macos-sequoia-base:latest",
"user": "admin"
}
```

> **Note:** macOS guests typically use `admin` as the default user. Set the `user` option in `.clave.json` to match
> your image's user account.

## Usage

### Working Directory Mode
Expand All @@ -25,10 +44,16 @@ cd /path/to/your/project
clave
```

Clave will spin up a fresh VM, mount your current working directory into the VM, and launch an interactive Claude Code session
inside it. When you exit, the VM and clone are torn down automatically. This lets you work on the local project similar to running
`claude`, but without having to worry about the agent accidentally deleting your home directory or getting tricked into shipping
your SSH private key somewhere.
Clave will spin up a VM, mount your current working directory into it, and launch an interactive Claude Code session.
When you exit, the VM is **suspended** automatically, so subsequent runs resume where you left off — no re-cloning or
re-provisioning needed. This lets you work on the local project similar to running `claude`, but without having to worry
about the agent accidentally deleting your home directory or getting tricked into shipping your SSH private key somewhere.

To discard suspended state and start clean:

```shell
clave --fresh
```

### Isolate Mode

Expand All @@ -39,7 +64,7 @@ cd /path/to/your/project
clave --isolate
```

Before spinning up a fresh VM, clave will create a local git clone of your working directory. (This is similarly efficient as a
Before spinning up a VM, clave will create a local git clone of your working directory. (This is similarly efficient as a
worktree, but has a copy of your full git history.) Clave will then mount that clone into the VM, keeping any changes made inside
the VM completely isolated from your project until you quit. Once you quit, you will be prompted to decide what to do with the
changes before the clone is cleaned up.
Expand Down Expand Up @@ -76,6 +101,10 @@ Add a `.clave.json` file to your project root to customize VM setup for your pro
| `provision` | string[] | Bash commands to run during VM provisioning |
| `env` | string[] | Environment variable names to pass through from your host into the VM |
| `shims` | string[] | Host commands to proxy into the VM (see [Host Proxy Shims](#host-proxy-shims)) |
| `model` | string | Claude model to use (e.g. `opus`, `sonnet`) |
| `user` | string | SSH user for the VM. Defaults to `admin`. Useful for macOS guest images |
| `ephemeral` | boolean | Use ephemeral VMs that are deleted on exit (default `false`) |
| `tunnels` | integer[]| Host ports to reverse-tunnel into the VM (e.g. `[5432, 9222]`) |

See the [`tart` documentation](https://tart.run/quick-start/#vm-images) for a list of available base images.

Expand Down Expand Up @@ -114,6 +143,62 @@ Clave always forwards these environment variables into the VM when present on th

Any additional variables listed in `.clave.json`'s `env` array are forwarded as well.

## Persistent vs Ephemeral Mode

### Persistent Mode (default)

By default, Clave **suspends** VMs when you exit instead of destroying them. The next time you run `clave` in the same
project, it resumes the suspended VM — skipping the clone and boot steps entirely. This makes subsequent sessions start
significantly faster.

In persistent mode, Claude Code authentication and configuration **persist inside the VM**. On first boot, Clave writes
initial config files (onboarding bypass, settings) but does not inject auth credentials — you authenticate inside the VM
on first use (e.g. `claude login`), and that auth carries over to subsequent sessions. MCP servers should be configured
inside the VM using `claude mcp add`.

If a resume fails (e.g. a suspended VM becomes corrupted), Clave automatically stops the VM and retries with a fresh
boot. If the base VM configuration changes (new image, updated provisioning), Clave replaces the suspended VM with a
fresh clone.

Use `clave --fresh` to force a clean start, discarding any suspended state.

If you start a second session while one is already running for the same project, Clave creates an ephemeral VM that is
fully torn down on exit.

### Ephemeral Mode

Ephemeral mode creates a fresh VM every session and deletes it on exit. Auth and configs are injected from the host
each run, similar to how Clave originally worked.

Enable ephemeral mode via CLI flag or `.clave.json`:

```shell
clave --ephemeral
```

```json
{
"ephemeral": true
}
```

In ephemeral mode, Clave requires host-side authentication (via environment variable or stored token) and will prompt
you to set one up if missing. MCP servers configured in your host `~/.claude.json` are automatically forwarded into
the VM via SSH tunnels.

### Host Tunnels

Use the `tunnels` option to reverse-tunnel host ports into the VM. This is useful for host-side services like databases
or MCP servers that need to be reachable from inside the VM:

```json
{
"tunnels": [5432, 9222]
}
```

These ports are tunneled in both persistent and ephemeral modes.

## Global Configuration

Clave reads the following environment variables for global defaults:
Expand Down
27 changes: 15 additions & 12 deletions app/Agents/ClaudeCode.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,24 @@ class ClaudeCode
'TZ',
'VISUAL',
];

public function __construct(
protected SshExecutor $ssh,
protected AuthManager $auth,
) {
}

public function __invoke(SessionContext $context): void
{
$env = $this->env($context);
$model = $context->project_config->model;

$flags = '--dangerously-skip-permissions';

if ($model) {
$flags .= ' --model '.escapeshellarg($model);
}

foreach ($context->claude_flags as $flag) {
$flags .= ' '.escapeshellarg($flag);
}
Expand All @@ -68,21 +68,24 @@ protected function env(SessionContext $context): string
}
}

if ($resolved = $this->auth->resolve()) {
$env_var = match ($resolved['type']) {
'api_key' => 'ANTHROPIC_API_KEY',
'oauth' => 'CLAUDE_CODE_OAUTH_TOKEN',
};
$env[] = $env_var.'='.escapeshellarg($resolved['value']);
if ($context->ephemeral) {
if ($resolved = $this->auth->resolve()) {
$env_var = match ($resolved['type']) {
'api_key' => 'ANTHROPIC_API_KEY',
'oauth' => 'CLAUDE_CODE_OAUTH_TOKEN',
};
$env[] = $env_var.'='.escapeshellarg($resolved['value']);
}
}

if ($context->ide) {
$env[] = 'CLAUDE_CODE_SSE_PORT='.escapeshellarg((string) $context->ide->port);
$env[] = 'ENABLE_IDE_INTEGRATION=true';
}

if ($context->proxy_socket_path !== null) {
$env[] = 'CLAVE_PROXY_SOCKET=/home/admin/.clave/proxy.sock';
$home = $context->project_config->homeDir();
$env[] = "CLAVE_PROXY_SOCKET={$home}/.clave/proxy.sock";
}

return $env ? implode(' ', $env).' ' : '';
Expand Down
17 changes: 11 additions & 6 deletions app/Commands/DefaultCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@
namespace App\Commands;

use App\Agents\ClaudeCode;
use function App\clear_screen;
use App\Data\OnExit;
use App\Data\SessionContext;
use App\Exceptions\AbortedPipelineException;
use function App\heading;
use App\Pipelines\SessionSetup;
use App\Support\SessionTeardown;
use Illuminate\Support\Str;
use Laravel\Prompts\Concerns\Colors;
use function Laravel\Prompts\error;
use LaravelZero\Framework\Commands\Command;
use function App\clear_screen;
use function App\heading;
use function Laravel\Prompts\error;

class DefaultCommand extends Command
{
Expand All @@ -22,7 +22,8 @@ class DefaultCommand extends Command
protected $signature = 'default
{--on-exit= : Action on exit: keep, merge, discard}
{--isolate : Fork the repo into an isolated clone for this session}
{--fresh : Start with a fresh VM, discarding any suspended state}';
{--fresh : Start with a fresh VM, discarding any suspended state}
{--ephemeral : Use an ephemeral VM that is deleted on exit}';

protected $description = 'Start a Clave session in the current project';

Expand All @@ -41,6 +42,10 @@ public function handle(
$version = config('app.version');
$context = $this->newContext();

if ($this->option('ephemeral')) {
$context->ephemeral = true;
}

heading("Clave {$version} session {$this->cyan($context->session_id)} in project {$this->cyan($context->project_name)}");

$this->trap([SIGINT, SIGTERM], static fn() => $teardown($context));
Expand Down Expand Up @@ -69,7 +74,7 @@ public function handle(
protected function newContext(): SessionContext
{
$project_dir = getcwd();

return new SessionContext(
session_id: Str::random(8),
project_name: basename($project_dir),
Expand All @@ -81,7 +86,7 @@ protected function newContext(): SessionContext
claude_flags: $this->parsePassthroughFlags(),
);
}

protected function parsePassthroughFlags(): array
{
// TODO: our initial implementation of this doesn't work
Expand Down
73 changes: 66 additions & 7 deletions app/Commands/ProvisionCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

namespace App\Commands;

use App\Data\OsType;
use App\Support\MacOsProvisioningPipeline;
use App\Support\ProvisioningPipeline;
use App\Support\SshExecutor;
use App\Support\TartManager;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Str;
use LaravelZero\Framework\Commands\Command;
use RuntimeException;
use Throwable;
use function App\checklist;
use function Laravel\Prompts\note;
Expand All @@ -18,6 +21,7 @@ class ProvisionCommand extends Command
{--force : Re-provision even if base image exists}
{--image= : OCI image to pull}
{--base-vm= : Name for the provisioned base VM}
{--user= : SSH username for the VM}
{--provision= : JSON array of extra provisioning commands}';

protected $description = 'Provision the base VM image for Clave sessions';
Expand All @@ -40,6 +44,7 @@ public function handle(
$tmp_id = Str::random(12);
$tmp_name = "clave-tmp-{$tmp_id}";
$script_dir = null;
$os_type = OsType::Linux;

$this->trap([SIGINT, SIGTERM], function() use ($checklist, $tart, $tmp_name) {
$checklist->item('Cleaning up...')
Expand All @@ -58,23 +63,30 @@ public function handle(
->run(fn() => $tart->clone($image, $tmp_name));

$checklist->item('Configuring VM...')
->run(function() use ($tart, $ssh, $fs, $tmp_name, $tmp_id, &$script_dir) {
->run(function() use ($tart, $ssh, $fs, $image, $tmp_name, $tmp_id, &$script_dir, &$os_type) {
$tart->set(
name: $tmp_name,
cpus: config('clave.vm.cpus'),
memory: config('clave.vm.memory'),
display: config('clave.vm.display')
);

$user = $this->option('user') ?? config('clave.ssh.user');
$ssh->setUser($user);
$ssh->usePassword(config('clave.ssh.password'));

$extra_provision = $this->option('provision')
? json_decode($this->option('provision'), true) ?? []
: [];

$os_type = OsType::fromImage($image);
$pipeline_class = $os_type === OsType::MacOS
? MacOsProvisioningPipeline::class
: ProvisioningPipeline::class;

$script_dir = sys_get_temp_dir().'/clave-provision-'.$tmp_id;
$fs->ensureDirectoryExists($script_dir, 0700);
$fs->put("{$script_dir}/provision.sh", ProvisioningPipeline::toScript($extra_provision));
$fs->put("{$script_dir}/provision.sh", $pipeline_class::toScript($extra_provision, $user));
});

$checklist->item('Booting VM...')
Expand All @@ -84,9 +96,15 @@ public function handle(
->run(fn() => $tart->waitForReady($tmp_name, $ssh, 120));

$checklist->item('Provisioning (may take a while)...')
->run(function() use ($ssh) {
$ssh->run('sudo mkdir -p /mnt/provision && sudo mount -t virtiofs com.apple.virtio-fs.automount /mnt/provision');
$ssh->run('sudo bash /mnt/provision/provision.sh', 600);
->run(function() use ($ssh, &$script_dir, &$os_type) {
if ($os_type === OsType::MacOS) {
$script_path = '/Volumes/My Shared Files/provision.sh';
$this->waitForPath($ssh, $script_path);
$ssh->run("bash '{$script_path}'", 600);
} else {
$ssh->run('sudo mkdir -p /mnt/provision && sudo mount -t virtiofs com.apple.virtio-fs.automount /mnt/provision');
$ssh->run('sudo bash /mnt/provision/provision.sh', 600);
}
});

$checklist->item('Stopping VM...')
Expand All @@ -105,11 +123,52 @@ public function handle(

throw $e;
} finally {
if ($script_dir) {
$fs->deleteDirectory($script_dir);
try {
if ($script_dir) {
$fs->deleteDirectory($script_dir);
}
} catch (Throwable) {
// Temp dir cleanup is best-effort — macOS may create
// permission-restricted entries like .Trashes
}
}

return self::SUCCESS;
}

protected function waitForPath(SshExecutor $ssh, string $path, int $timeout = 30): void
{
$escaped = escapeshellarg($path);
$start = time();

while (time() - $start < $timeout) {
try {
$ssh->run("test -f {$escaped}");

return;
} catch (Throwable) {
sleep(2);
}
}

$diag = '';
try {
$result = $ssh->run(implode("\n", [
'echo "=== /Volumes ==="',
'ls -la /Volumes/ 2>&1',
'echo "=== /Volumes/My Shared Files ==="',
'ls -la "/Volumes/My Shared Files/" 2>&1 || echo "(not found)"',
'echo "=== mount ==="',
'mount 2>&1',
'true',
]));
$diag = $result->output();
} catch (Throwable) {
}

throw new RuntimeException(
"Timed out after {$timeout}s waiting for '{$path}' to appear in VM"
.($diag ? "\nDiagnostics:\n{$diag}" : '')
);
}
}
Loading