#!/usr/bin/env bash
# box — 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=""; template=""; force=0; json=0; want_help=0
inst=""   # the resolved Incus instance, set by the 'box' precondition

die() { echo "box: $*" >&2; exit 1; }         # 1 = it went wrong
usage_error() { echo "box: $*" >&2; echo "try 'box help'." >&2; exit 2; }  # 2 = you asked wrong
version() { echo "box $(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.box=1 (or the legacy 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 box invariant, it is incus's job, not ours — that is
# what `box incus` is for.
CMDS=(
  "new^--name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container]^^Mint a box from a template (default: blank), or --from an existing box/snapshot^fn:cmd_new^"
  "templates^^^List the templates this install can mint^fn:cmd_templates^"
  "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 its template's user^fn:cmd_shell^"
  "exec^<box> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^"
  "tmux^<box> [<session>]^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^"
  "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^"
  "doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^"
  "status^^^Deprecated alias for 'list'^fn:cmd_status^"
  "help^[<command>]^^This help, or 'box 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 "box $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 "box: unknown command: $1 — did you mean '$hint'?" >&2
  else
    echo "box: unknown command: $1" >&2
  fi
  echo "try 'box help' for the command list." >&2
  exit 2
}

usage() {
  cat <<'EOF'
box — trust-less, network-isolated Incus VMs with Claude Code, creds-free.

USAGE
  box <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)
  --template <t>        Template to mint from (default: blank)        (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 box version

  Options come after the command: 'box list --json', not 'box --json list'.

EXAMPLES
  # mint a claude box and log in inside it — the tool never handles your token
  box new --name work --template claude
  box shell work                    # then: run 'claude', then /login

  # log in once, reuse forever: checkpoint the authed box, clone from it
  box snapshot work authed
  box new --name feature --from work/authed

  # the default: a blank box — same isolation, nobody home
  box new --name scratch
  box templates

  # what have I got, and what can I clone?
  box list
  box info work

  # run something without opening a shell
  box exec work -- git -C project pull

  # anything box doesn't wrap: boxes are plain Incus instances
  box 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'); box 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.

  box owns a command when it must enforce something Incus cannot see: the
  user.box=1 boundary, the isolation stack, or the creds-free snapshot
  workflow. Everything else is Incus's job — and 'box incus' is the door.

  Docs: https://github.com/heavy-duty/box
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 box from a template (default:
blank — bare Debian 13, nobody home; --template claude gets Claude Code
installed, creds-free, ~10 min cold). With --from, clones an existing box or
one of its snapshots — login state, git creds and clones carry over,
isolation is preserved, and the clone knows its template's user without
being told.

  --name <box>           Required. The box's name.
  --template <t>         Template to mint from; 'box templates' lists them.
                         A template sets image, user and resources — never
                         the network: every template gets the same isolation.
  --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.

Resources come from the template's box.env; BOX_CPU / BOX_MEMORY / BOX_DISK
environment variables override them at mint time (a small host shrinks a box
without editing a template it doesn't own).

  box new --name scratch                     # blank, the default
  box new --name work --template claude
  box new --name feature --from work/authed
EOF
;;
    templates) cat <<'EOF'
List the templates this install can mint, with their descriptions. A template
is a directory under templates/: a box.env (image, user, resources — parsed
against an allowlist, never sourced) and a user-data.yaml (cloud-init, passed
to Incus verbatim). Templates cannot touch the network or security flags —
the shared box-net profile is the placement contract, so every template gets
the same isolation.

  box templates
  box new --name scratch --template blank
EOF
;;
    list) cat <<'EOF'
List the boxes box minted on this host: name, state, type, snapshot count.
Takes no box — for one box, that's 'box info <box>'.

  --json   Incus's JSON, straight through, for scripting.

  box 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.

  box 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.

  box 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 box will read your command's
flags as its own.

  box exec work -- git -C project pull
  box exec work -- claude --version
EOF
;;
    tmux) cat <<'EOF'
A shell that survives you. 'shell' is a child of the exec connection — if your
terminal or SSH session drops, everything running in it is SIGHUP'd, and a
long Claude run dies with it. This attaches a tmux session instead
(new-session -A): created if new, reattached if it exists — so starting work
and resuming after a disconnect are the same command, with no state to
remember.

The session name (default: main) buys parallel streams in one box:

  box tmux work            # attach or create 'main'
  box tmux work run-1      # a second, independent stream, same box

Detach with Ctrl-b d; 'exit' ends the session. For a plain shell with none of
tmux's semantics, 'box shell' is unchanged.
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>; 'box info <box>' shows the labels you
have.

  box 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. 'box info <box>' lists the labels.

  box restore work authed
EOF
;;
    rename) cat <<'EOF'
Rename a box. Incus cannot rename a running instance, so stop it first:

  box down work
  box rename work archive
  box 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.

  box rm work
  box rm work --force
EOF
;;
    incus) cat <<'EOF'
The door out. box 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.box=1 tag (or its legacy spelling), so you cannot aim it at an instance
box 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.

  box incus work -- config show
  box 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. box warns and then does as you asked — from
there, the trust boundary is yours to keep.
EOF
;;
    doctor) cat <<'EOF'
Answer "is this host fit to mint boxes?" from ground truth, not config claims:
is the Incus daemon answering, is a dnsmasq actually serving boxnet, does
the kernel's bridge port say 'isolated on', is the resolver pinned or is a
host VPN's DNS leaking into boxes, can a box actually resolve names. Every
check exists because its fault has happened — most kill a cold mint with a
cloud-init error that names none of them.

  --fix      also revert what a drill run may have left behind
  --pin-dns  pin boxnet's resolver to public upstreams and re-test
             (setup-host.sh now pins by default; this is the quick test)

  box doctor
  box doctor --fix

Exit 0 = clean; 1 = problems found (each printed with its fix). Read-only
unless --fix or --pin-dns is given.
EOF
;;
    status) cat <<'EOF'
Deprecated alias for 'box 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.

  box help
  box 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 'box <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 ;;
    --template) [ $# -ge 2 ] || usage_error "--template needs a value"; template="$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.
    -*)
      # doctor's flags belong to the doctor script, not to box
      if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi
      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 'box 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 box 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.box 2>/dev/null || true)"
  # A pre-rename box carries user.claudebox=1 and nothing else. Snapshots of
  # old boxes outlive the release that minted them — the legacy tag is honored
  # forever, or an old box stops being a box at all.
  [ "$tag" = "1" ] || tag="$(incus config get "$i" user.claudebox 2>/dev/null || true)"
  [ "$tag" = "1" ] || die "no such box: $box (see 'box 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: box 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 'box: %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 "box: no /dev/kvm — using container mode (weaker isolation, dev/test only)" >&2
    echo container
  fi
}

# Five minutes, not three: the first VM launch on a fresh pool unpacks the
# image into a pool volume and takes the coldest possible boot — measured
# live, an agent can need past the 3-minute mark exactly once per pool while
# every later boot answers in seconds. And when it still fails, ship the
# forensics: the VM's console says why, and the box is torn down by whoever
# called us before anyone can read it.
wait_agent() {
  local n="$1" i
  echo "box: waiting for instance agent..."
  for i in $(seq 1 150); do
    if incus exec "$n" -- true </dev/null >/dev/null 2>&1; then return; fi
    if [ "$i" -eq 150 ]; then
      echo "box: instance agent never came up. The VM's console log:" >&2
      timeout -k 5 15 incus console "$n" --show-log 2>/dev/null | tail -15 | sed 's/^/  /' >&2
      die "agent unreachable after 5 minutes (incus console $n to inspect live)"
    fi
    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 box 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 "box: 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
  ' </dev/null || 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"
}

# Templates set image, user, resources and cloud-init — NOTHING else. The
# box.env file is parsed against this allowlist, never sourced: sourcing would
# hand every template arbitrary bash execution on the HOST at mint time. And
# there is deliberately no key for a network or a security flag — the shared
# box-net profile is the placement contract, so no template can weaken
# isolation. 'blank' is a box with nobody home, not a box with the safety off.
load_template() {
  local t="$1" dir line key val
  dir="$root/templates/$t"
  [ -d "$dir" ] || die "no such template: $t (see 'box templates')"
  [ -f "$dir/box.env" ] || die "template '$t' has no box.env"
  T_DESC=""; T_IMAGE=""; T_USER=""; T_CPU=""; T_MEMORY=""; T_DISK=""
  while IFS= read -r line || [ -n "$line" ]; do
    case "$line" in ''|\#*) continue ;; esac
    case "$line" in
      *=*) key="${line%%=*}"; val="${line#*=}" ;;
      *) die "template '$t': not a KEY=\"value\" line: $line" ;;
    esac
    val="${val#\"}"; val="${val%\"}"
    case "$key" in
      BOX_DESCRIPTION) T_DESC="$val" ;;
      BOX_IMAGE)       T_IMAGE="$val" ;;
      BOX_USER)        T_USER="$val" ;;
      BOX_CPU)         T_CPU="$val" ;;
      BOX_MEMORY)      T_MEMORY="$val" ;;
      BOX_DISK)        T_DISK="$val" ;;
      *) die "template '$t': unknown key '$key' — a template sets image, user and resources, nothing else (there is no key for a network, on purpose)" ;;
    esac
  done <"$dir/box.env"
  [ -n "$T_IMAGE" ] && [ -n "$T_USER" ] || die "template '$t': BOX_IMAGE and BOX_USER are required"
  # Environment overrides beat the file — this is how a small host (or the
  # drill) shrinks a box without editing a template it doesn't own.
  T_CPU="${BOX_CPU:-${T_CPU:-4}}"
  T_MEMORY="${BOX_MEMORY:-${T_MEMORY:-8GiB}}"
  T_DISK="${BOX_DISK:-${T_DISK:-60GiB}}"
}

cmd_templates() {
  local d t desc
  echo "TEMPLATES"
  for d in "$root/templates"/*/; do
    t="$(basename "$d")"
    desc="$(grep -m1 '^BOX_DESCRIPTION=' "$d/box.env" 2>/dev/null | cut -d= -f2- | tr -d '"')"
    printf '  %-10s %s\n' "$t" "$desc"
  done
  echo
  echo "mint one:  box new --name <box> --template <template>"
}

cmd_new() {
  [ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
  local instance; instance="$(iname_of "$name")"
  if [ -n "$from" ]; then
    [ -z "$template" ] || usage_error "--from clones an existing box; its template rides along (drop --template)"
    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 "box: cloned $srcref — isolation and auth state carry over from the source."
  else
    local t="${template:-blank}" m extra=()
    load_template "$t"
    m="$(pick_mode)"
    # shellcheck disable=SC2054 # "root,size=..." is a single incus argument
    if [ "$m" = vm ]; then extra+=(--vm --device "root,size=$T_DISK"); else extra+=(--config security.nesting=true); fi
    # The template's identity is stamped ONTO the instance: which template,
    # which user. 'incus copy' preserves user.* keys (audit B2), so a clone
    # knows what it is without ever consulting the template again.
    incus launch "$T_IMAGE" "$instance" --profile box-net \
      --config user.box=1 \
      --config user.box.template="$t" \
      --config user.box.user="$T_USER" \
      --config limits.cpu="$T_CPU" \
      --config limits.memory="$T_MEMORY" \
      --config cloud-init.user-data="$(cat "$root/templates/$t/user-data.yaml")" \
      "${extra[@]}"
    wait_agent "$instance"
    echo "box: waiting for phase-1 (cloud-init)..."
    echo "box: (its full narration, live:  incus exec $name -- tail -f /var/log/cloud-init-output.log)"
    # A failed cloud-init used to print a screen of dots and the word "error",
    # with nothing to act on — the box's own log holds the reason, and nobody
    # was told it existed. Show it, and leave the box up to inspect.
    # Every non-interactive exec pins stdin. With a TTY on stdin, 'incus exec'
    # goes interactive — and when box's own output is redirected (a script, the
    # drill), the session can wedge open after the remote command has exited,
    # blocking forever on a websocket that will never close. Caught live: a
    # mint stuck at 'status: done'. Only shell/exec/tmux may own the terminal.
    # PYTHONUNBUFFERED: cloud-init's progress dots are block-buffered the
    # moment stdout is not a tty — a redirected mint (a script, the drill)
    # shows NOTHING for the whole install and then one burst at the end,
    # which reads exactly like a hang. Unbuffered, the dots arrive as dots.
    if ! incus exec "$instance" -- env PYTHONUNBUFFERED=1 cloud-init status --wait </dev/null; then
      echo >&2
      echo "box: cloud-init FAILED in $name. What it says:" >&2
      incus exec "$instance" -- cloud-init status --long </dev/null 2>&1 | sed 's/^/  /' >&2
      echo >&2
      echo "box: the errors, from the box's log:" >&2
      incus exec "$instance" -- sh -c \
        "grep -iE '^(E:|Err:)|Temporary failure|Could not resolve|Unable to fetch' /var/log/cloud-init-output.log | tail -8" \
        </dev/null 2>/dev/null | sed 's/^/  /' >&2
      echo >&2
      echo "box: '$name' is still up — inspect it, then delete it:" >&2
      echo "  box incus $name -- exec {} -- tail -50 /var/log/cloud-init-output.log" >&2
      echo "  box rm $name" >&2
      echo "box: a failed mint is usually the HOST's fault (a wedged daemon, a dnsmasq" >&2
      echo "  not serving, a VPN resolver the box inherits). Diagnose it:  box doctor" >&2
      die "cloud-init failed — the box is incomplete, so refusing to hand it over"
    fi
  fi
  # The login hint belongs to the claude template — read the EFFECTIVE
  # template off the instance, so a clone of a claude box gets it too and a
  # blank box is not told to run a binary it doesn't have.
  local eff; eff="$(incus config get "$instance" user.box.template 2>/dev/null || true)"
  [ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude
  if [ "$eff" = claude ]; then
    echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
  else
    echo "box: ready — 'box shell $name'."
  fi
}

# Boxes are ordinary Incus instances tagged user.box=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 box didn't mint. Pre-rename boxes
# carry user.claudebox=1 instead and are ours forever; a box can't hold both
# tags via any path we mint, but the dedupe costs nothing.
# 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.box=1" --format csv --columns nstS
    incus list ${remote:+"$remote"} "user.claudebox=1" --format csv --columns nstS
  } 2>/dev/null | awk -F, '!seen[$1]++'
}

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 "box: no boxes yet — create one with: box 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: box info ${args[0]}"
  fi
  if [ "$json" -eq 1 ]; then
    incus list ${remote:+"$remote"} "user.box=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 'box 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:   box 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:  box new --name <new> --from $box/${first:-<snapshot>}"
}

# Which user does a shell land in? The template stamped it on the instance at
# mint time (user.box.user), and 'incus copy' carries user.* keys — so a clone
# knows without consulting the template. Two subtleties, both from the audit:
# 'incus config get' prints EMPTY + exit 0 for an unset key (B4), hence ${u:-},
# never '||'; and a pre-rename box has no metadata but is always a Claude box,
# so the legacy tag maps to 'claude'. The root fallback is effectively
# unreachable (every template sets a user) — anything that truly needs root
# goes through the 'box incus' escape hatch.
box_user() {
  local u
  u="$(incus config get "$1" user.box.user 2>/dev/null || true)"
  if [ -z "$u" ] && [ "$(incus config get "$1" user.claudebox 2>/dev/null || true)" = 1 ]; then
    u=claude
  fi
  echo "${u:-root}"
}

cmd_shell() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i; }
cmd_exec()  { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i "${args[@]:1}"; }

# A shell is a child of the exec connection: drop the terminal and everything
# in it is SIGHUP'd — a long Claude run dies with it. tmux 'new-session -A'
# attaches when the session exists and creates it when it doesn't, so starting
# work and reattaching after a disconnect are the same command. 'shell' stays
# bare on purpose — two verbs, two contracts.
cmd_tmux() {
  local session="${args[1]:-main}"
  case "$session" in
    *[!A-Za-z0-9_-]*) usage_error "session names are letters, digits, '-' and '_' — got '$session'" ;;
  esac
  incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i tmux new-session -A -s "$session"
}

cmd_snapshot() {
  local label="${args[1]:-manual-$(date +%s)}"
  incus snapshot create "$inst" "$label"
  echo "$label"
}

cmd_status() {
  echo "box: 'status' is deprecated — use 'box list'." >&2
  list_all
}

# The host-health checks live in drill/doctor.sh — grown by the drill, but
# every fault they diagnose (a wedged daemon, a dnsmasq that isn't serving,
# a VPN resolver boxes inherit, isolation off in the kernel) is a USER's
# fault first: each one has killed a cold mint or silently weakened a
# boundary. The install tree ships the whole repo, so delegate — one
# hardened script, two audiences.
cmd_doctor() {
  local script="$root/drill/doctor.sh"
  [ -f "$script" ] || die "doctor script not found at $script — re-run install.sh"
  exec bash "$script" "${args[@]}"
}

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 "box: warning: this can move the box off the isolation stack" >&2
      echo "box: (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 "box: 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 "box: $msg"
    fi
    ;;
esac
