Two boxes cloned from one snapshot came up holding the SAME IP address — 10.87.0.128, on the same dnsmasq lease, matching to the second on the lease timer. Incus does regenerate the MAC (they differed), but /etc/machine-id rides along inside the disk image, and systemd derives its DHCP client identifier from it. Same client-id, same lease. This breaks the workflow claudebox exists for: log in once, snapshot, clone forever. Every clone of a snapshot is, to the network, the same machine as its source and as its siblings. Truncating /etc/machine-id makes systemd mint a fresh one at next boot, so the clone path now resets it and restarts before handing the box over. Costs one reboot; the alternative is colliding boxes. Found by the drill on a live host (#26) — and it also explains why the sibling-isolation probe (#15 A3) could never work: it was aiming box A at box A's own address. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
609 lines
22 KiB
Bash
Executable file
609 lines
22 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# claudebox — trust-less, isolated Incus VMs with Claude Code, creds-free.
|
|
# The command surface is the CMDS table below: it is the single source of truth
|
|
# for what exists, what it looks like, what the help says, and what runs. The
|
|
# help cannot drift from the code, because it is rendered from the same rows.
|
|
set -euo pipefail
|
|
|
|
root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
|
|
remote=""; mode="auto"; name=""; from=""; force=0; json=0; want_help=0
|
|
inst="" # the resolved Incus instance, set by the 'box' precondition
|
|
|
|
die() { echo "claudebox: $*" >&2; exit 1; } # 1 = it went wrong
|
|
usage_error() { echo "claudebox: $*" >&2; echo "try 'claudebox help'." >&2; exit 2; } # 2 = you asked wrong
|
|
version() { echo "claudebox $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The command table.
|
|
#
|
|
# verb ^ synopsis args ^ preconditions ^ one-line summary ^ action ^ ok message
|
|
#
|
|
# Fields are ^-separated because a synopsis may contain '|' ([--vm|--container]).
|
|
#
|
|
# preconditions (comma-separated):
|
|
# box first positional is a box: resolve it, and REFUSE if the instance
|
|
# isn't tagged user.claudebox=1 — the boundary, enforced, not assumed
|
|
# arg2 a second positional is required
|
|
# stopped the box must not be running
|
|
# confirm destructive: prompt unless --force
|
|
#
|
|
# action:
|
|
# incus:<subcommand> run `incus <subcommand> <instance> [rest...]`
|
|
# fn:<function> call a shell function (it has real work to do)
|
|
#
|
|
# ok message: printed on success; {} = the box, {1} = the second positional.
|
|
#
|
|
# Adding a thin verb is one row. If a request can't be expressed as a row and
|
|
# doesn't enforce a claudebox invariant, it is incus's job, not ours — that is
|
|
# what `claudebox incus` is for.
|
|
CMDS=(
|
|
"new^--name <box> [--from <src>[/<snap>]] [--vm|--container]^^Mint a box: fresh from cloud-init, or --from an existing box/snapshot^fn:cmd_new^"
|
|
"list^[--json]^^List your boxes^fn:cmd_list^"
|
|
"info^<box> [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^"
|
|
"shell^<box>^box^Open a shell in a box, as the claude user^fn:cmd_shell^"
|
|
"exec^<box> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^"
|
|
"snapshot^<box> [<label>]^box^Checkpoint a box (label defaults to manual-<epoch>)^fn:cmd_snapshot^"
|
|
"restore^<box> <snapshot>^box,arg2^Roll a box back to one of its snapshots^incus:restore^restored {} to {1}"
|
|
"rename^<box> <new-name>^box,arg2,stopped^Rename a box (it must be stopped first)^incus:rename^renamed {} to {1}"
|
|
"down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}"
|
|
"start^<box>^box^Start a stopped box^incus:start^started {}"
|
|
"rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}"
|
|
"incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^"
|
|
"status^^^Deprecated alias for 'list'^fn:cmd_status^"
|
|
"help^[<command>]^^This help, or 'claudebox help <command>' for one command^fn:cmd_help^"
|
|
)
|
|
|
|
cmd_row() { local r; for r in "${CMDS[@]}"; do case "$r" in "$1^"*) echo "$r"; return 0 ;; esac; done; return 1; }
|
|
verbs() { local r; for r in "${CMDS[@]}"; do echo "${r%%^*}"; done; }
|
|
is_command() { cmd_row "$1" >/dev/null 2>&1; }
|
|
# locals matter here: dispatch holds $pre/$action/$ok, and field() is called from
|
|
# error paths inside it — a global read would clobber the row being dispatched.
|
|
field() {
|
|
local r f_syn f_pre f_sum f_act f_ok
|
|
r="$(cmd_row "$1")" || return 1
|
|
IFS='^' read -r _ f_syn f_pre f_sum f_act f_ok <<<"$r"
|
|
case "$2" in
|
|
syn) echo "$f_syn" ;; pre) echo "$f_pre" ;; sum) echo "$f_sum" ;;
|
|
act) echo "$f_act" ;; ok) echo "$f_ok" ;;
|
|
esac
|
|
}
|
|
synopsis_of() { local s; s="$(field "$1" syn)"; echo "claudebox $1${s:+ $s}"; }
|
|
|
|
# Nearest command by edit distance — a typo should point somewhere, not just fail.
|
|
suggest() {
|
|
verbs | awk -v w="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" '
|
|
function dist(a, b, la, lb, i, j, c, prev, cur) {
|
|
la = length(a); lb = length(b)
|
|
for (j = 0; j <= lb; j++) prev[j] = j
|
|
for (i = 1; i <= la; i++) {
|
|
cur[0] = i
|
|
for (j = 1; j <= lb; j++) {
|
|
c = (substr(a, i, 1) == substr(b, j, 1)) ? 0 : 1
|
|
cur[j] = prev[j] + 1
|
|
if (cur[j - 1] + 1 < cur[j]) cur[j] = cur[j - 1] + 1
|
|
if (prev[j - 1] + c < cur[j]) cur[j] = prev[j - 1] + c
|
|
}
|
|
for (j = 0; j <= lb; j++) prev[j] = cur[j]
|
|
}
|
|
return prev[lb]
|
|
}
|
|
BEGIN { best = 99 }
|
|
{ d = dist(w, $0); if (d < best) { best = d; hit = $0 } }
|
|
END { if (best <= 2) print hit }'
|
|
}
|
|
|
|
unknown_command() {
|
|
local hint; hint="$(suggest "$1")"
|
|
if [ -n "$hint" ]; then
|
|
echo "claudebox: unknown command: $1 — did you mean '$hint'?" >&2
|
|
else
|
|
echo "claudebox: unknown command: $1" >&2
|
|
fi
|
|
echo "try 'claudebox help' for the command list." >&2
|
|
exit 2
|
|
}
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
claudebox — trust-less, network-isolated Incus VMs with Claude Code, creds-free.
|
|
|
|
USAGE
|
|
claudebox <command> [<args>] [options]
|
|
|
|
COMMANDS
|
|
EOF
|
|
local r v sum
|
|
for r in "${CMDS[@]}"; do
|
|
IFS='^' read -r v _ _ sum _ _ <<<"$r"
|
|
printf ' %-9s %s\n' "$v" "$sum"
|
|
done
|
|
cat <<'EOF'
|
|
|
|
OPTIONS
|
|
--name <box> Name for the new box (new)
|
|
--from <src>[/<snap>] Clone from box <src>, or from its snapshot (new)
|
|
--vm Force VM mode: the trust-less target (new)
|
|
--container Force container mode: weaker isolation, (new)
|
|
dev/test only. Default where /dev/kvm is absent.
|
|
--json Emit Incus JSON instead of a table (list, info)
|
|
--force, -f Delete without the confirmation prompt (rm)
|
|
--remote <r> Act on Incus remote <r> (any)
|
|
--help, -h Help; after a command, help for that command
|
|
--version, -V Print the claudebox version
|
|
|
|
Options come after the command: 'claudebox list --json', not 'claudebox --json list'.
|
|
|
|
EXAMPLES
|
|
# mint a box and log in inside it — the tool never handles your token
|
|
claudebox new --name work
|
|
claudebox shell work # then: run 'claude', then /login
|
|
|
|
# log in once, reuse forever: checkpoint the authed box, clone from it
|
|
claudebox snapshot work authed
|
|
claudebox new --name feature --from work/authed
|
|
|
|
# what have I got, and what can I clone?
|
|
claudebox list
|
|
claudebox info work
|
|
|
|
# run something without opening a shell
|
|
claudebox exec work -- git -C project pull
|
|
|
|
# anything claudebox doesn't wrap: boxes are plain Incus instances
|
|
claudebox incus work -- config show
|
|
|
|
EXIT STATUS
|
|
0 ok
|
|
1 it went wrong (Incus failed, no such box, aborted at a prompt)
|
|
2 you asked wrong (unknown command, bad flag, destructive act without --force)
|
|
|
|
THE MODEL
|
|
A box carries NO credentials. You authenticate interactively inside it
|
|
('claude' then /login; 'gh auth login'); claudebox never stores or injects a
|
|
secret. A box reaches the public internet and nothing else — there is no
|
|
inbound path. Destroying a box loses nothing you didn't push.
|
|
|
|
claudebox owns a command when it must enforce something Incus cannot see: the
|
|
user.claudebox=1 boundary, the isolation stack, or the creds-free snapshot
|
|
workflow. Everything else is Incus's job — and 'claudebox incus' is the door.
|
|
|
|
Docs: https://github.com/heavy-duty/claudebox
|
|
EOF
|
|
}
|
|
|
|
# The synopsis and the summary come from the table; only prose lives here, and
|
|
# only where a command has something to say beyond its summary.
|
|
help_cmd() {
|
|
echo "usage: $(synopsis_of "$1")"
|
|
echo
|
|
case "$1" in
|
|
new) cat <<'EOF'
|
|
Mint a box. Without --from, launches a fresh Debian 13 box from cloud-init
|
|
(~10 min cold) with Claude Code installed and NO credentials. With --from,
|
|
clones an existing box or one of its snapshots — Claude login, git creds and
|
|
clones carry over, isolation is preserved.
|
|
|
|
--name <box> Required. The box's name.
|
|
--from <src>[/<snap>] Clone src's live state, or its snapshot <snap>.
|
|
--vm | --container Force the mode. VM is the trust boundary and the
|
|
default wherever /dev/kvm exists; container mode
|
|
(security.nesting=true) is the fallback for hosts
|
|
without nested virt — weaker isolation, dev/test only.
|
|
|
|
claudebox new --name work
|
|
claudebox new --name feature --from work/authed
|
|
EOF
|
|
;;
|
|
list) cat <<'EOF'
|
|
List the boxes claudebox minted on this host: name, state, type, snapshot count.
|
|
Takes no box — for one box, that's 'claudebox info <box>'.
|
|
|
|
--json Incus's JSON, straight through, for scripting.
|
|
|
|
claudebox list
|
|
EOF
|
|
;;
|
|
info) cat <<'EOF'
|
|
Show one box: state, type, IP address, and — the reason this exists — the
|
|
labels of its snapshots, with the --from line to clone one.
|
|
|
|
--json Incus's JSON, straight through, for scripting.
|
|
|
|
claudebox info work
|
|
EOF
|
|
;;
|
|
shell) cat <<'EOF'
|
|
Open an interactive shell in a running box as the 'claude' user. This is the
|
|
only entry path — there is no SSH and no inbound route to a box.
|
|
|
|
claudebox shell work
|
|
EOF
|
|
;;
|
|
exec) cat <<'EOF'
|
|
Run a command inside a box as the 'claude' user. Everything after -- is passed
|
|
through untouched; the -- is required, or claudebox will read your command's
|
|
flags as its own.
|
|
|
|
claudebox exec work -- git -C project pull
|
|
claudebox exec work -- claude --version
|
|
EOF
|
|
;;
|
|
snapshot) cat <<'EOF'
|
|
Checkpoint a box. Snapshots are how an authenticated box is reused: log in
|
|
once, snapshot, then 'new --from <box>/<label>' as often as you like. The
|
|
label defaults to manual-<epoch>; 'claudebox info <box>' shows the labels you
|
|
have.
|
|
|
|
claudebox snapshot work authed
|
|
EOF
|
|
;;
|
|
restore) cat <<'EOF'
|
|
Roll a box back to one of its snapshots, in place. Anything in the box since
|
|
that snapshot is lost. 'claudebox info <box>' lists the labels.
|
|
|
|
claudebox restore work authed
|
|
EOF
|
|
;;
|
|
rename) cat <<'EOF'
|
|
Rename a box. Incus cannot rename a running instance, so stop it first:
|
|
|
|
claudebox down work
|
|
claudebox rename work archive
|
|
claudebox start archive
|
|
|
|
Snapshots and Claude auth follow the box; anything referring to the old name by
|
|
hand (a --from line, a script) does not.
|
|
EOF
|
|
;;
|
|
rm) cat <<'EOF'
|
|
Delete a box and every snapshot it has. This cannot be undone, so it asks for
|
|
confirmation first; --force (-f) skips the prompt. With no TTY to confirm on
|
|
(a script, a pipe), it refuses unless --force is given.
|
|
|
|
claudebox rm work
|
|
claudebox rm work --force
|
|
EOF
|
|
;;
|
|
incus) cat <<'EOF'
|
|
The door out. claudebox wraps the box lifecycle and the isolation model, not
|
|
all of Incus — so when you need something it doesn't wrap, run Incus through
|
|
here and keep the safety rail that matters: the box name is resolved and
|
|
checked against the user.claudebox=1 tag, so you cannot aim it at an instance
|
|
claudebox didn't mint.
|
|
|
|
Everything after -- is passed to incus verbatim. A literal {} is replaced with
|
|
the resolved instance name; with no {}, the instance is appended at the end.
|
|
The command that will run is echoed before it runs.
|
|
|
|
claudebox incus work -- config show
|
|
claudebox incus work -- config device add {} extra disk source=/data path=/data
|
|
|
|
Changing the profile, the network, a device or a security.* key can take a box
|
|
outside the isolation stack. claudebox warns and then does as you asked — from
|
|
there, the trust boundary is yours to keep.
|
|
EOF
|
|
;;
|
|
status) cat <<'EOF'
|
|
Deprecated alias for 'claudebox list'. It ignored the <box> argument it
|
|
advertised, so it was split into 'list' (all boxes) and 'info <box>' (one). It
|
|
still works, and forwards to 'list'.
|
|
EOF
|
|
;;
|
|
help) cat <<'EOF'
|
|
Print the general help, or the help for one command.
|
|
|
|
claudebox help
|
|
claudebox help rename
|
|
EOF
|
|
;;
|
|
*) field "$1" sum ;; # no prose: the table's summary is the help
|
|
esac
|
|
}
|
|
|
|
show_help() { # "" → general help
|
|
case "${1:-}" in
|
|
""|help) usage ;;
|
|
*) is_command "$1" || unknown_command "$1"; help_cmd "$1" ;;
|
|
esac
|
|
}
|
|
|
|
cmd="${1:-help}"; shift || true
|
|
case "$cmd" in
|
|
-h|--help) usage; exit 0 ;;
|
|
-V|--version) version; exit 0 ;;
|
|
-*) usage_error "options come after the command — try 'claudebox <command> $cmd ...'" ;;
|
|
esac
|
|
|
|
args=()
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--name) [ $# -ge 2 ] || usage_error "--name needs a value"; name="$2"; shift 2 ;;
|
|
--from) [ $# -ge 2 ] || usage_error "--from needs a value"; from="$2"; shift 2 ;;
|
|
--remote) [ $# -ge 2 ] || usage_error "--remote needs a value"; remote="$2:"; shift 2 ;;
|
|
--vm) mode=vm; shift ;;
|
|
--container) mode=container; shift ;;
|
|
--force|-f) force=1; shift ;;
|
|
--json) json=1; shift ;;
|
|
--help|-h) want_help=1; shift ;;
|
|
--version|-V) version; exit 0 ;;
|
|
--) shift; args+=("$@"); break ;;
|
|
# An unrecognized flag used to be swallowed as a positional — so a typo'd
|
|
# --labl silently became a snapshot's label. Say so instead.
|
|
-*)
|
|
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
|
|
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
|
|
fi
|
|
usage_error "unknown option: $1 (see 'claudebox help $cmd')" ;;
|
|
*) args+=("$1"); shift ;;
|
|
esac
|
|
done
|
|
|
|
if [ "$want_help" -eq 1 ]; then show_help "$cmd"; exit 0; fi
|
|
|
|
# --- preconditions ---------------------------------------------------------
|
|
|
|
iname_of() { echo "$remote$1"; } # instance name = box name
|
|
|
|
# The boundary, enforced: a box is an Incus instance WE tagged. Anything else is
|
|
# somebody's VM, and claudebox will not stop, rename or delete it by accident.
|
|
resolve_box() {
|
|
local box="$1" i tag
|
|
i="$(iname_of "$box")"
|
|
tag="$(incus config get "$i" user.claudebox 2>/dev/null || true)"
|
|
[ "$tag" = "1" ] || die "no such box: $box (see 'claudebox list')"
|
|
echo "$i"
|
|
}
|
|
|
|
box_state() { incus list "$1" --format csv --columns s 2>/dev/null | head -n1; }
|
|
|
|
require_stopped() {
|
|
local i="$1" box="$2" st; st="$(box_state "$i")"
|
|
case "$st" in
|
|
STOPPED|Stopped|stopped) return 0 ;;
|
|
*) die "box '$box' is ${st:-not stopped} — Incus needs it stopped for this. Stop it: claudebox down $box" ;;
|
|
esac
|
|
}
|
|
|
|
need_name() {
|
|
if [ "${#args[@]}" -lt 1 ] || [ -z "${args[0]}" ]; then
|
|
usage_error "usage: $(synopsis_of "$cmd")"
|
|
fi
|
|
}
|
|
|
|
need_arg2() {
|
|
if [ "${#args[@]}" -lt 2 ] || [ -z "${args[1]}" ]; then
|
|
usage_error "usage: $(synopsis_of "$cmd")"
|
|
fi
|
|
}
|
|
|
|
confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse.
|
|
if [ "$force" -eq 1 ]; then return 0; fi
|
|
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)"
|
|
local reply
|
|
printf 'claudebox: %s? this cannot be undone. [y/N] ' "$1"
|
|
read -r reply
|
|
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
|
|
}
|
|
|
|
# --- commands with real work -----------------------------------------------
|
|
|
|
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
|
|
}
|
|
|
|
# A clone must not BE its source. Incus regenerates the MAC, but /etc/machine-id
|
|
# rides along inside the disk — and systemd derives its DHCP client identifier
|
|
# (DUID) from it. Same client-id, same dnsmasq lease: two boxes, one IP address,
|
|
# to the second on the lease timer. Every box cloned from one snapshot collided
|
|
# on the network, which is exactly the workflow claudebox exists for (log in
|
|
# once, snapshot, clone forever).
|
|
#
|
|
# Truncating /etc/machine-id makes systemd mint a fresh one on the next boot, so
|
|
# the reset costs one reboot. Do it before handing the box over, never after.
|
|
reset_identity() {
|
|
local i="$1"
|
|
echo "claudebox: giving the clone its own identity (machine-id, DHCP lease)..."
|
|
incus exec "$i" -- sh -c '
|
|
: >/etc/machine-id
|
|
rm -f /var/lib/dbus/machine-id
|
|
ln -sf /etc/machine-id /var/lib/dbus/machine-id
|
|
' || die "could not reset the clone's machine-id"
|
|
incus restart "$i"
|
|
wait_agent "$i"
|
|
}
|
|
|
|
cmd_new() {
|
|
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
|
|
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"
|
|
reset_identity "$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 "$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 } }'
|
|
}
|
|
|
|
# '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.
|
|
cmd_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
|
|
}
|
|
|
|
cmd_info() {
|
|
local box="${args[0]}" row
|
|
if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi
|
|
|
|
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 "$inst")"
|
|
|
|
echo
|
|
case "${snaps:-0}" in
|
|
''|0)
|
|
echo "SNAPSHOTS (none)"
|
|
echo
|
|
echo "Take one: claudebox snapshot $box authed"
|
|
return 0 ;;
|
|
esac
|
|
echo "SNAPSHOTS"
|
|
local first="" sname taken
|
|
while IFS=, read -r sname taken _; do
|
|
[ -n "$sname" ] || continue
|
|
[ -n "$first" ] || first="$sname"
|
|
printf ' %-14s%s\n' "$sname" "$taken"
|
|
done < <(incus snapshot list "$inst" --format csv 2>/dev/null)
|
|
echo
|
|
echo "Clone one: claudebox new --name <new> --from $box/${first:-<snapshot>}"
|
|
}
|
|
|
|
cmd_shell() { incus exec "$inst" -- sudo -u claude -i; }
|
|
cmd_exec() { incus exec "$inst" -- sudo -u claude -i "${args[@]:1}"; }
|
|
|
|
cmd_snapshot() {
|
|
local label="${args[1]:-manual-$(date +%s)}"
|
|
incus snapshot create "$inst" "$label"
|
|
echo "$label"
|
|
}
|
|
|
|
cmd_status() {
|
|
echo "claudebox: 'status' is deprecated — use 'claudebox list'." >&2
|
|
list_all
|
|
}
|
|
|
|
cmd_help() { show_help "${args[0]:-}"; }
|
|
|
|
# The escape hatch. The box is resolved and tag-checked; everything else is
|
|
# yours. {} is the instance name; without it, the instance goes last.
|
|
warn_isolation() {
|
|
case " $* " in
|
|
*" profile "*|*" network "*|*" device "*|*security.*|*" nic "*)
|
|
echo "claudebox: warning: this can move the box off the isolation stack" >&2
|
|
echo "claudebox: (profile / network / device / security.*). The trust boundary is yours from here." >&2 ;;
|
|
esac
|
|
}
|
|
|
|
cmd_incus() {
|
|
local rest=("${args[@]:1}") out=() a replaced=0
|
|
[ "${#rest[@]}" -gt 0 ] || usage_error "usage: $(synopsis_of incus)"
|
|
for a in "${rest[@]}"; do
|
|
case "$a" in
|
|
*"{}"*) out+=("${a//\{\}/$inst}"); replaced=1 ;;
|
|
*) out+=("$a") ;;
|
|
esac
|
|
done
|
|
[ "$replaced" -eq 1 ] || out+=("$inst")
|
|
warn_isolation "${out[@]}"
|
|
echo "claudebox: incus ${out[*]}" >&2 # no magic: show what runs
|
|
incus "${out[@]}"
|
|
}
|
|
|
|
# --- dispatch: driven by the table, not by a hand-written case --------------
|
|
|
|
row="$(cmd_row "$cmd")" || unknown_command "$cmd"
|
|
IFS='^' read -r _ _ pre _ action ok <<<"$row"
|
|
|
|
case ",$pre," in *,box,*) need_name; inst="$(resolve_box "${args[0]}")" ;; esac
|
|
case ",$pre," in *,arg2,*) need_arg2 ;; esac
|
|
case ",$pre," in *,stopped,*) require_stopped "$inst" "${args[0]}" ;; esac
|
|
case ",$pre," in *,confirm,*) confirm "delete $inst and all its snapshots" ;; esac
|
|
|
|
case "$action" in
|
|
fn:*)
|
|
"${action#fn:}"
|
|
;;
|
|
incus:*)
|
|
sub="${action#incus:}"
|
|
# word-split intentionally: a subcommand may carry a flag ("delete -f")
|
|
# shellcheck disable=SC2086
|
|
incus $sub "$inst" "${args[@]:1}"
|
|
if [ -n "$ok" ]; then
|
|
msg="${ok//\{\}/${args[0]}}"; msg="${msg//\{1\}/${args[1]:-}}"
|
|
echo "claudebox: $msg"
|
|
fi
|
|
;;
|
|
esac
|