box/bin/claudebox
claude-hdb 4e2c6c9caf fix: the clone identity reset could never reboot, so it never took effect
The reset truncated /etc/machine-id and called 'incus restart'. But
systemd needs a VALID machine-id to shut down cleanly — so the graceful
stop hung, incus timed out ("Failed shutting down instance, status is
Running: context deadline exceeded"), and the reboot never happened. The
clone kept its source's machine-id, hence its DUID, hence its DHCP
lease: two boxes on one address, which is the exact bug the reset exists
to prevent. Worse, the duplicate address then broke the box's networking
outright ("box cannot reach the internet"), and poisoned the isolation
run downstream.

Write a fresh VALID id with systemd-machine-id-setup instead of emptying
the file — in a VM it derives from the DMI product UUID, which Incus
makes unique per instance. Then restart with a real timeout and a forced
fallback: a clone that keeps its source's lease is worse than an unclean
stop of a box that booted thirty seconds ago.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:03:21 +00:00

618 lines
23 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)..."
# Do NOT truncate machine-id and reboot: systemd needs a valid one to shut
# down cleanly, so the graceful stop hangs and the reboot never happens —
# leaving the clone on its source's identity, which is the bug we are here to
# fix. 'systemd-machine-id-setup' writes a fresh VALID id instead; in a VM it
# derives from the DMI product UUID, which Incus makes unique per instance.
incus exec "$i" -- sh -c '
rm -f /etc/machine-id /var/lib/dbus/machine-id
systemd-machine-id-setup >/dev/null 2>&1 || dbus-uuidgen > /etc/machine-id
ln -sf /etc/machine-id /var/lib/dbus/machine-id
test -s /etc/machine-id
' || die "could not reset the clone's machine-id"
# The new id only takes effect at boot. Ask nicely, then insist — a clone that
# keeps its source's DHCP lease is worse than an unclean stop of a box that
# booted 30 seconds ago.
incus restart --timeout 60 "$i" >/dev/null 2>&1 || incus restart -f "$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