#!/usr/bin/env bash # claudebox — trust-less, isolated Incus VMs with Claude Code, creds-free. # claudebox new --name [--from [/]] [--remote r] [--vm|--container] # claudebox list [--json] — all your boxes # claudebox info — one box: state, IP, snapshot labels # claudebox shell|down|start|rm # claudebox exec -- # claudebox snapshot [label] # claudebox restore # Boxes carry NO secrets: log into Claude interactively inside ('claude' then # /login). Reuse an authenticated box via 'snapshot' + 'new --from'. set -euo pipefail root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)" remote=""; mode="auto"; name=""; from=""; force=0; json=0 cmd="${1:-help}"; shift || true args=() while [ $# -gt 0 ]; do case "$1" in --name) name="$2"; shift 2 ;; --from) from="$2"; shift 2 ;; --remote) remote="$2:"; shift 2 ;; --vm) mode=vm; shift ;; --container) mode=container; shift ;; --force) force=1; shift ;; --json) json=1; shift ;; --) shift; args+=("$@"); break ;; *) args+=("$1"); shift ;; esac done die() { echo "claudebox: $*" >&2; exit 1; } iname_of() { echo "$remote$1"; } # instance name = box name; claudebox tags them with user.claudebox=1 need_name() { [ "${#args[@]}" -ge 1 ] && [ -n "${args[0]}" ] || die "usage: claudebox $cmd " } pick_mode() { if [ "$mode" != auto ]; then echo "$mode"; return; fi if [ -n "$remote" ] || [ -e /dev/kvm ]; then echo vm; else echo "claudebox: no /dev/kvm — using container mode (weaker isolation, dev/test only)" >&2 echo container fi } wait_agent() { local n="$1" i echo "claudebox: waiting for instance agent..." for i in $(seq 1 90); do if incus exec "$n" -- true >/dev/null 2>&1; then return; fi [ "$i" -eq 90 ] && die "instance agent never came up (incus console $n to inspect)" sleep 2 done } new() { [ -n "$name" ] || die "usage: claudebox new --name [--from [/]]" local instance; instance="$(iname_of "$name")" if [ -n "$from" ]; then local src="${from%%/*}" snap="" srcref case "$from" in */*) snap="${from#*/}" ;; esac srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap" incus copy "$srcref" "$instance" incus start "$instance" wait_agent "$instance" echo "claudebox: cloned $srcref — isolation and Claude auth carry over from the source." else local m extra=(); m="$(pick_mode)" # shellcheck disable=SC2054 # "root,size=60GiB" is a single incus argument if [ "$m" = vm ]; then extra+=(--vm --device root,size=60GiB); else extra+=(--config security.nesting=true); fi incus launch images:debian/13/cloud "$instance" --profile claude-dev \ --config user.claudebox=1 \ --config cloud-init.user-data="$(cat "$root/cloud-init/user-data.yaml")" \ "${extra[@]}" wait_agent "$instance" echo "claudebox: waiting for phase-1 (cloud-init)..." incus exec "$instance" -- cloud-init status --wait fi echo "claudebox: ready — 'claudebox shell $name'. Log into Claude inside: run 'claude' then /login." } # Boxes are ordinary Incus instances tagged user.claudebox=1 — that tag is the # only thing that makes them ours, so every read below is filtered by it and we # never report on (or touch) an instance claudebox didn't mint. # Emits: name,state,type,snapshot-count — none of which can contain a comma or a # newline, so a plain -F, split is safe. (IPv4 can: a box running docker has # several addresses and Incus quotes them across lines. It's fetched separately.) boxes_csv() { incus list ${remote:+"$remote"} "user.claudebox=1" --format csv --columns nstS } box_ipv4() { # first address only; strips Incus's " (iface)" suffix. "-" if none. incus list "$(iname_of "$1")" --format csv --columns 4 2>/dev/null \ | tr -d '"' | sed 's/ (.*//' | grep -v '^[[:space:]]*$' | head -n1 \ | grep . || echo "-" } # VIRTUAL-MACHINE is a mouthful in a table; anything unexpected passes through. short_type() { case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in virtual-machine|virtualmachine) echo VM ;; container) echo CT ;; *) echo "$1" ;; esac } list_all() { local rows; rows="$(boxes_csv)" if [ -z "$rows" ]; then echo "claudebox: no boxes yet — create one with: claudebox new --name work" >&2 return 0 fi { echo "NAME,STATE,TYPE,SNAPSHOTS" while IFS=, read -r n s t snaps; do [ -n "$n" ] || continue echo "$n,${s:--},$(short_type "$t"),${snaps:-0}" done <<<"$rows" } | awk -F, ' { for (i = 1; i <= NF; i++) { cell[NR, i] = $i; if (length($i) > w[i]) w[i] = length($i) } n = NR } END { for (r = 1; r <= n; r++) { line = "" for (i = 1; i <= 4; i++) line = line sprintf("%-*s ", w[i], cell[r, i]) sub(/ +$/, "", line); print line } }' } info() { local box="$1" row # Same tagged set as list_all: an untagged instance is not a box, it's someone # else's VM, and we say "no such box" rather than reaching into it. row="$(boxes_csv | awk -F, -v b="$box" '$1 == b { print; exit }')" [ -n "$row" ] || die "no such box: $box (see 'claudebox list')" local state type snaps IFS=, read -r _ state type snaps <<<"$row" printf '%-11s%s\n' NAME "$box" STATE "${state:--}" TYPE "$(short_type "$type")" \ IPV4 "$(box_ipv4 "$box")" echo case "${snaps:-0}" in ''|0) echo "SNAPSHOTS (none)" echo echo "Take one: claudebox snapshot $box authed" return 0 ;; esac echo "SNAPSHOTS" local first="" while IFS=, read -r sname taken _; do [ -n "$sname" ] || continue [ -n "$first" ] || first="$sname" printf ' %-14s%s\n' "$sname" "$taken" done < <(incus snapshot list "$(iname_of "$box")" --format csv 2>/dev/null) echo echo "Clone one: claudebox new --name --from $box/${first:-}" } # 'list' lists them all; 'info' shows one. A box name handed to 'list' is a wrong # guess we can answer, not a surprise: point at the command that does want one. list() { if [ "${#args[@]}" -ge 1 ] && [ -n "${args[0]}" ]; then die "list takes no box — for one box, use: claudebox info ${args[0]}" fi if [ "$json" -eq 1 ]; then incus list ${remote:+"$remote"} "user.claudebox=1" --format json else list_all fi } case "$cmd" in new) new ;; snapshot) need_name; label="${args[1]:-manual-$(date +%s)}"; incus snapshot create "$(iname_of "${args[0]}")" "$label"; echo "$label" ;; restore) need_name; [ -n "${args[1]:-}" ] || die "usage: claudebox restore "; incus restore "$(iname_of "${args[0]}")" "${args[1]}" ;; shell) need_name; incus exec "$(iname_of "${args[0]}")" -- sudo -u claude -i ;; exec) need_name; incus exec "$(iname_of "${args[0]}")" -- sudo -u claude -i "${args[@]:1}" ;; down) need_name; incus stop "$(iname_of "${args[0]}")" ;; start) need_name; incus start "$(iname_of "${args[0]}")" ;; rm) need_name; incus delete -f "$(iname_of "${args[0]}")"; echo "claudebox: removed $(iname_of "${args[0]}")" ;; list) list ;; info) need_name if [ "$json" -eq 1 ]; then incus list "$(iname_of "${args[0]}")" --format json; else info "${args[0]}"; fi ;; status) echo "claudebox: 'status' is deprecated — use 'claudebox list'." >&2; list_all ;; help|*) sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//' ;; esac