#!/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
cpu=""; memory=""; disk=""; instance_only=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)"; }

# Which tier is THIS PROCESS? Decided from live credentials (argless 'id -nG':
# what the kernel will present when incus opens the socket), never from the
# group database — the two disagree for exactly as long as a re-login is
# pending, and that window is where every wrong answer lives.
#   UID 0 / incus-admin  -> admin       (the full daemon socket)
#   incus (only)         -> restricted  (incus-user: your own project, nothing else)
#   neither              -> none        (no socket at all)
# host/setup-host.sh carries a byte-identical copy (it runs before any install
# tree exists); test/cli.sh diffs the two so they cannot drift.
box_tier() {
  [ "$(id -u)" -eq 0 ] && { printf 'admin\n'; return; }
  local groups; groups="$(id -nG 2>/dev/null | tr ' ' '\n')"
  if   printf '%s\n' "$groups" | grep -qx incus-admin; then printf 'admin\n'
  elif printf '%s\n' "$groups" | grep -qx incus;       then printf 'restricted\n'
  else printf 'none\n'
  fi
}

# ---------------------------------------------------------------------------
# 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>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--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:snapshot restore^restored {} to {1}"
  "export^<box> [<file>] [--instance-only]^box^Export a stopped box to one portable file — it survives 'box rm' and this host^fn:cmd_export^"
  "import^<file> [--name <box>]^^Mint a box from an exported file, re-stamped onto this host's stack^fn:cmd_import^"
  "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 {}"
  "expose^<box> <port> [<host-port>] | --list | --remove <port>^box^Forward a box port to the host's loopback — see a dev server^fn:cmd_expose^"
  "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^"
  "setup-host^^^One-time host setup: Incus, the boxnet stack, the profile, the firewall^fn:cmd_setup_host^"
  "grant^<user>^^Admin: give a host user the restricted tier — their own boxes, on the hardened boxnet^fn:cmd_grant^"
  "revoke^<user> [--purge]^^Admin: take the restricted tier back (--purge also deletes their boxes)^fn:cmd_revoke^"
  "teardown-host^[--purge-incus]^^Remove the box host stack (both name generations)^fn:cmd_teardown_host^"
  "migrate-host^--box <n> | --all-boxes | --retire-legacy^^Move a host from the pre-0.4.0 stack onto box^fn:cmd_migrate_host^"
  "versions^^^List the installed box versions — the current default and the running one^fn:cmd_versions^"
  "use^<version>^^Switch the default box version (refuses while boxes exist)^fn:cmd_use^"
  "uninstall^[<version>] [--all] [--purge-host]^^Remove one installed version, or the whole install — asks first, asserts the absence^fn:cmd_uninstall^"
  "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 '  %-13s %s\n' "$v" "$sum"
  done
  cat <<'EOF'

OPTIONS
  --name <box>          Name for the new box                  (new, import)
  --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)
  --instance-only       Export the live state only, no snapshots   (export)
  --force, -f           Delete without asking (rm); overwrite the file (export)
  --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

  # a box that outlives this host: one portable file, credentials inside
  box down work
  box export work                   # → work-<UTC stamp>.tar.gz — guard it like a credential
  box import work-<stamp>.tar.gz --name work2

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

  On a shared host, an admin hands out the restricted tier per user
  ('box grant <user>'): their own boxes, the same hardened network, and
  no view of anyone else's. 'box help grant' has the contract.

  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>.
  --cpu <n>              CPUs for this mint (limits.cpu, verbatim to Incus).
  --memory <size>        RAM for this mint, e.g. 3GiB (limits.memory).
  --disk <size>          Root disk size, e.g. 20GiB. VM mode only — a
                         container's root rides the storage pool.
  --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 resolve most-specific-first: these flags, then BOX_CPU /
BOX_MEMORY / BOX_DISK environment variables (the scripting form), then the
template's box.env, then defaults. Flags shape a fresh mint only — a --from
clone carries its source's resources. Resources are all a flag can touch:
there is no flag for a network or a security key, on purpose.

  box new --name scratch                     # blank, the default
  box new --name work --template claude
  box new --name lean --template claude --cpu 2 --memory 3GiB
  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
;;
    export) cat <<'EOF'
One portable file that outlives the box AND the host. 'box rm' deletes a box
and every snapshot it has; 'box new --from' clones, but the clone still lives
on the same host. Export is the way out (#70): it wraps 'incus export' into a
backup tarball of the whole instance — snapshots included by default, because
the reuse workflow (log in once, snapshot, clone forever) lives in them.

  box export <box> [<file>]   # default file: <box>-<UTC timestamp>.tar.gz
  --instance-only             # live state only, leave the snapshots behind
  --force                     # overwrite an existing <file> (refused otherwise)

The box must be stopped first ('box down <box>'). Incus can back up a running
instance, but a live root disk is a moving target — and this artifact's whole
job is to be trusted later, on a host that no longer has the box.

THE FILE IS A CREDENTIAL. A box's disk carries everything inside it — agent
logins, git PATs, SSH keys, shell history. Export scrubs nothing (a
"scrubbed" disk image is a promise tarball surgery cannot keep) and says so
loudly instead. Store and move the file like the secret it is.

The upgrade flow this unblocks (#66):

  box down work && box export work    # one file per box
  box rm work                         # nothing is lost anymore
  # ...upgrade box / rebuild the host / move machines...
  box import work-<stamp>.tar.gz      # the box is back, snapshots and all
EOF
;;
    import) cat <<'EOF'
Mint a box from a 'box export' file — on this host or any other that has the
box stack ('box setup-host' builds it). The name inside the tarball is used
unless --name picks another; either way the name must be free: import will
not occupy a name ANY existing instance holds, box or not.

Everything 'incus import' restores is the artifact's truth (disk, config,
snapshots). What box then re-stamps is THIS host's truth:

  · the user.box=1 boundary tag (a legacy user.claudebox=1 stays honored)
  · the box-net placement — re-assigned if the artifact's profile list
    differs, the same move migrate-host makes re-homing a legacy box
  · a fresh machine identity (reset_identity, exactly like a clone), so its
    DHCP lease can never collide with the box it was exported from

Auth state rides along by design — the artifact carries the box's whole disk,
logins included. That is the point (log in once, keep the file), and the same
trust boundary as cloning an authed snapshot.

  box import work-20260718T120000Z.tar.gz
  box import work-20260718T120000Z.tar.gz --name work2
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
;;
    expose) cat <<'EOF'
Open a deliberate, loopback-only door to a port inside a box — for when you
are coding in a box and want to see its dev server in your browser.

  box expose <box> <port> [<host-port>]   # forward 127.0.0.1:<host-port> → box:<port>
  box expose <box> --list                 # what doors are open
  box expose <box> --remove <port>        # close one

The host side ALWAYS listens on 127.0.0.1 — no other machine can reach the
box, only this host's loopback. There is no flag to widen that; if you need
LAN exposure you are leaving the tool's threat model, and 'box incus' is the
door (with its warning).

The in-box server must listen on 0.0.0.0:<port>, not only its own loopback —
a VM's forwarder connects to the box over the network. Inside an isolated box
that is safe: nothing but this proxy can reach the port.

  box new --name web --template claude
  box shell web        # inside: run a dev server on 0.0.0.0:3000
  box expose web 3000  # then open http://127.0.0.1:3000 in your browser
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
;;
    setup-host) cat <<'EOF'
Prepare this host to mint boxes — one time. Installs Incus and builds the
isolation stack: the boxnet NAT bridge (resolver pinned), the box-isolate
ACL, the box-net profile, and the firewall rules, all re-applied at boot.
Idempotent — safe to re-run after a box upgrade to pick up stack changes;
install.sh runs it for you, so this is for re-applying by hand.

One run is enough. If it has to add you to the incus-admin group it re-runs
itself under that group — no re-login, no second invocation.

  box setup-host

Multi-user hosts: setup-host builds the stack once, for everyone. An admin
then hands individual users the restricted tier with 'box grant <user>' —
their own boxes, on this same hardened network, seeing nobody else's.
EOF
;;
    grant) cat <<'EOF'
Give a host user the restricted tier. They get their own Incus project (via
incus-user), and every box they mint lands on the SAME hardened boxnet as an
admin's — the full isolation contract (ACL, DNS isolation, resolver pin,
port isolation, the box-to-box drop), with no view of anyone else's boxes.

What it converges, idempotently (safe to re-run, and re-run after upgrades):
  · puts the user in the 'incus' group (not incus-admin — that is the point)
  · creates their user-<uid> project by touching incus-user for them
  · points the project at boxnet and ONLY boxnet — the private incusbr-<uid>
    bridge incus-user auto-creates carries none of box's hardening, so it is
    unreferenced and unreachable, not just unused
  · allows snapshots and backups (incus-user blocks both; the clone workflow
    rides snapshots, 'box export' rides backups — #70)
  · installs the box-net profile into their project

The user's surface: new/list/info/shell/exec/tmux/snapshot/restore/export/
import/rm on their own boxes. Not theirs: expose (edits daemon-global
state), setup-host, grant. Admin boxes and other users' boxes are invisible to them, and the
existing box-to-box drop means even their instances cannot reach a sibling.

  box grant dev1
EOF
;;
    revoke) cat <<'EOF'
Take the restricted tier back from a user. Without --purge, this removes
them from the 'incus' group: their project and boxes stay (still running!)
and 'box grant' restores access untouched. Group membership is read at
LOGIN, so a session they already hold keeps the socket until it ends —
revoke says so and names the loginctl command when it happens. With
--purge, their sessions are terminated first (a stale session could quietly
recreate the project, unhardened, afterwards — measured, not theoretical),
then their boxes, images, project, private bridge and trust-store
certificate are removed — irreversible, so it asks first.

  box revoke dev1            # take the tier; their boxes keep running
  box revoke dev1 --purge    # ...or end their sessions and delete everything
EOF
;;
    teardown-host) cat <<'EOF'
Remove the box host stack — all boxes, the boxnet/claudenet networks, the
ACLs, the profiles, and the firewall rules of BOTH name generations (current
and pre-0.4.0). Asks first.

  box teardown-host                # the stack; leaves Incus installed
  box teardown-host --purge-incus  # ...and uninstall Incus too
EOF
;;
    migrate-host) cat <<'EOF'
Move a host from the pre-0.4.0 'claudebox' stack onto 'box'. Re-homing
preserves a box's authed state (no re-login) — it only re-tags and reassigns
the profile, then verifies the box works on its new network leg.

  box migrate-host --box <name>    # re-home one legacy box
  box migrate-host --all-boxes     # re-home every legacy box
  box migrate-host --retire-legacy # remove the old stack (once no legacy box remains)
EOF
;;
    versions) cat <<'EOF'
List the versions installed under this install root — install.sh lands each
one side by side at <root>/versions/<v>, and a 'current' symlink tracks the
default (what the box on your PATH runs). The default is marked (current);
the tree answering THIS command is marked (running) — they differ when your
PATH resolves a different install (say, a global /opt/box shadowing yours).

  box versions
  box use <version>        # switch the default
  # install another version side by side: re-run install.sh
EOF
;;
    use) cat <<'EOF'
Switch the default box version — repoint the 'current' symlink (and the PATH
symlink riding it) at an installed version. Refuses while ANY box exists:
never change versions under a user's boxes (#66) — 'box down' what you keep,
copy out what you need via 'box shell'/'box exec' (a portable 'box export'
is #70), 'box rm' each box, then switch. The flip is asserted afterwards:
current must resolve to the version you asked for, and the chain's
'box --version' must answer it.

  box versions             # what is installed
  box use 0.6.0
EOF
;;
    uninstall) cat <<'EOF'
Remove one installed version, or the whole install — the real uninstall,
replacing the old "rm -rf two paths by hand" instructions.

  box uninstall <version>     one NON-current version ('box use' another
                              first if you are on it)
  box uninstall               everything: every version, the current and
  box uninstall --all         PATH symlinks, and any legacy claudebox crumbs
  box uninstall --purge-host  run teardown-host first (all boxes, the boxnet
                              stack, the firewall — its own confirmation),
                              then remove the install

The full uninstall runs in the safe order: boxes first — it refuses while
any exist (and names them) unless --purge-host tears them down; then the
trees and symlinks; and it ENDS with an absence assert — every removed path
is re-checked, and anything still present makes it exit 1 naming the
leftovers instead of reporting a clean uninstall that wasn't (the same
discipline as 'box revoke --purge'). Asks before removing; --force or
BOX_YES=1 skips the prompt. On a multi-user host, revoke granted users
first: 'box revoke <user> --purge'.

  box uninstall 0.5.0
  box uninstall --all --purge-host
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 ;;
    --cpu) [ $# -ge 2 ] || usage_error "--cpu needs a value"; cpu="$2"; shift 2 ;;
    --memory) [ $# -ge 2 ] || usage_error "--memory needs a value"; memory="$2"; shift 2 ;;
    --disk) [ $# -ge 2 ] || usage_error "--disk needs a value"; disk="$2"; shift 2 ;;
    --remote) [ $# -ge 2 ] || usage_error "--remote needs a value"; remote="$2:"; shift 2 ;;
    --vm) mode=vm; shift ;;
    --container) mode=container; shift ;;
    --instance-only) instance_only=1; 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
      # expose's own flags (--list, --remove) are positional to it, not box's
      if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi
      # the host verbs delegate their flags to the scripts they wrap;
      # uninstall parses its own (--all, --purge-host) the same way
      case "$cmd" in setup-host|teardown-host|migrate-host|grant|revoke|uninstall) args+=("$1"); shift; continue ;; esac
      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; }

# $3, when given, replaces the default "why" — the table's 'stopped' rows are
# stopped because INCUS insists (rename), but export is stopped by OUR
# decision (a consistent artifact), and the refusal should say the true reason.
require_stopped() {
  local i="$1" box="$2" why="${3:-Incus needs it stopped for this}" st; st="$(box_state "$i")"
  case "$st" in
    STOPPED|Stopped|stopped) return 0 ;;
    *) die "box '$box' is ${st:-not stopped} — $why. Stop it: box down $box" ;;
  esac
}

# The placement contract must exist before a mint OR an import lands — in the
# DEFAULT project for an admin (setup-host builds it), in YOUR project for a
# restricted user (box grant converges it). Its absence has a different fix
# per tier, and incus's own "Profile not found" at launch time names neither.
require_stack() {
  if [ -z "$remote" ] && ! timeout 10 incus profile show box-net >/dev/null 2>&1 </dev/null; then
    # A missing profile and a daemon that is not answering are different
    # faults with different fixes — "run setup-host" at a wedged daemon
    # (the #26 shape) is wrong advice. Separate them before diagnosing.
    timeout 10 incus list >/dev/null 2>&1 </dev/null \
      || die "the incus daemon is not answering — diagnose it: box doctor"
    if [ "$(box_tier)" = restricted ]; then
      die "your project has no box-net profile — the restricted tier is granted per user, by an admin: box grant $(id -un)"
    fi
    die "no box-net profile — the host stack is missing. Build it: box setup-host"
  fi
}

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 clog
  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
      # The console log is FULL of terminal escape sequences (boot messages,
      # a firmware menu). Dumping it raw scrambles the operator's terminal —
      # and doubly so when it lands in a log someone is tail -f'ing. Capture
      # it to a file, STRIP everything but printable ASCII + tab/newline, and
      # print only a short sanitized tail. Nothing raw ever reaches a terminal.
      clog="/tmp/box-console-$n.log"
      timeout -k 5 15 incus console "$n" --show-log </dev/null >"$clog.raw" 2>/dev/null || true
      # Strip whole escape sequences FIRST (while the ESC byte is present), then
      # drop any residual control bytes — otherwise 'tr' alone leaves the visible
      # '[1m[37m' halves behind. Result is clean, readable text.
      sed -E $'s/\x1b\\[[0-9;:?]*[ -/]*[@-~]//g; s/\x1b[()#][0-9A-Za-z]//g; s/\x1b[=>PX^_].*?(\x1b\\\\|\x07)//g; s/\x1b.//g' \
        "$clog.raw" 2>/dev/null | tr -cd '\11\12\40-\176' >"$clog"
      rm -f "$clog.raw"
      echo "box: instance agent never came up after 5 minutes." >&2
      echo "box: sanitized console log → $clog   (last non-blank lines:)" >&2
      grep -v '^[[:space:]]*$' "$clog" 2>/dev/null | tail -6 | sed 's/^/  /' >&2
      # A box that never boots is NOT a slow box, and the console says which
      # failure it is. Each of these cost hours to diagnose by hand once; the
      # box that hits them next should be told the answer, not the symptom.
      if grep -qiE 'Failed to decompress kernel|efi_stub_entry\(\) failed' "$clog" 2>/dev/null; then
        echo "box: THE KERNEL WOULD NOT DECOMPRESS — the cached image is corrupt." >&2
        echo "box: (a truncated/bad image download does exactly this). Re-pull it:" >&2
        echo "box:   incus image list                 # find the fingerprint" >&2
        echo "box:   incus image delete <fingerprint> # the next mint re-downloads" >&2
      elif grep -qiE 'bad shim signature|prohibited by secure boot' "$clog" 2>/dev/null; then
        echo "box: SECURE BOOT rejected the kernel — but box mints VMs with" >&2
        echo "box: security.secureboot=false, so this box predates that fix or was" >&2
        echo "box: created by hand. Re-mint it with a current box." >&2
      elif grep -qiE 'GNU GRUB|Press enter to boot|UEFI Firmware Settings' "$clog" 2>/dev/null; then
        echo "box: the VM is stuck at the GRUB/firmware menu — it never booted." >&2
        echo "box: this is the IMAGE, not box. Re-pull it (incus image delete …)," >&2
        echo "box: or pin a known-good build in the template's BOX_IMAGE." >&2
      fi
      die "agent unreachable (inspect live: incus console $n)"
    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 instance 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%\"}"
    # T_DESC is parsed for symmetry with the other BOX_* keys, but cmd_templates
    # re-reads BOX_DESCRIPTION straight from the file (a box is listed without
    # ever loading its template), so the parsed value here is never read. Keep the
    # row — deleting it would turn box.env's own key into an "unknown key" error at
    # mint time. (SC2034 disabled for the branch below; the directive must sit on
    # the whole case, not an individual arm.)
    # shellcheck disable=SC2034
    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"
  # Not 'A && B || die': if T_IMAGE is set but T_USER is not, that idiom still
  # dies (which is what we want) — but it reads as an if-then-else it is not, so
  # spell the guard out (SC2015).
  if [ -z "$T_IMAGE" ] || [ -z "$T_USER" ]; then
    die "template '$t': BOX_IMAGE and BOX_USER are required"
  fi
  # Resolution, most specific wins: inline flag (--cpu/--memory/--disk, #57)
  # > BOX_* environment (how a small host or the drill shrinks every box it
  # mints) > the template's file > defaults. Values pass to Incus verbatim —
  # its units, its validation; box adds no parser of its own. Resources only:
  # there is still no flag for a network or a security.* key, on purpose.
  T_CPU="${cpu:-${BOX_CPU:-${T_CPU:-4}}}"
  T_MEMORY="${memory:-${BOX_MEMORY:-${T_MEMORY:-8GiB}}}"
  T_DISK="${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)"
  require_stack
  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)"
    [ -z "$cpu$memory$disk" ] || usage_error "--cpu/--memory/--disk shape a fresh mint; a clone carries its source's resources ('box incus' can change them afterwards)"
    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
    # security.secureboot=false: Incus defaults VMs to secureboot ON, and a
    # Debian cloud image whose shim is signed with a key the host's OVMF does
    # not trust dies with "bad shim signature / prohibited by secure boot
    # policy" and drops to the GRUB menu forever — the kernel never loads. It
    # is not part of a throwaway box's threat model (the VM boundary is), and
    # turning it off boots reliably across image rebuilds. Container mode has
    # no firmware, so it does not apply there.
    if [ "$m" = vm ]; then extra+=(--vm --device "root,size=$T_DISK" --config security.secureboot=false); else extra+=(--config security.nesting=true); fi
    # Root size is a VM launch concern; a container's root rides the pool. Say
    # so instead of silently dropping an explicit --disk.
    [ "$m" = vm ] || [ -z "$disk" ] || echo "box: note — --disk applies to VM mode only; this container's root rides the pool" >&2
    # 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 "-"
}

# The box's address ON BOXNET — which is NOT the same as "its first address".
# A box running docker also carries 172.17.0.1 (docker0), and Incus happily
# lists that FIRST. box_ipv4() hands you the decoy, and pointing anything at it
# is pointing at the wrong interface: 'box expose' did exactly that until Incus
# refused with `Connect IP "172.17.0.1" must be one of the instance's static
# IPv4 addresses`. The drill has known this trap since run 4; the CLI had not.
# Derive the prefix from the network rather than hardcoding it.
box_net_ip() {
  local pfx
  pfx="$(incus network get boxnet ipv4.address 2>/dev/null | cut -d/ -f1 | cut -d. -f1-3)"
  [ -n "$pfx" ] || return 1
  incus list "$1" --format csv --columns 4 2>/dev/null \
    | tr -d '"' | tr ' ,' '\n' | grep -E "^${pfx//./\\.}\.[0-9]+$" | head -n1 | grep .
}

# 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")"

  # A box with a hole says so — an exposure visible only to --list is a hole
  # info would deny. One line per open door.
  local d listen
  while IFS= read -r d; do
    case "$d" in expose-*) : ;; *) continue ;; esac
    listen="$(incus config device get "$inst" "$d" listen 2>/dev/null)"
    printf '%-11s%s → port %s\n' EXPOSED "${listen#tcp:}" "${d#expose-}"
  done < <(incus config device list "$inst" 2>/dev/null)

  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
}

# --- export / import (#70): state that survives the box and the host --------
#
# 'box rm' deletes a box and every snapshot it has; 'box new --from' clones,
# but the clone still lives on the same host under the same stack. So until
# now, nothing a box held could outlive a host teardown — and #66's installer
# refusal ("stop, export, remove every box before you upgrade") was only
# honest advice once a real export existed. 'incus export' is the primitive:
# a backup tarball of the instance and (by default) its snapshots.
cmd_export() {
  local box="${args[0]}" file="${args[1]:-}"
  # Down first, by OUR decision, not incus's necessity: incus can back up a
  # running instance, but a live root disk is a moving target, and this
  # artifact's whole job is to be trusted later, on a host that no longer
  # has the box (#70 settled on require-down over snapshot-then-export).
  require_stopped "$inst" "$box" "export wants a settled disk, so the artifact can be trusted later"
  # Default filename: the box's name plus a UTC stamp. Sortable, never
  # colliding across repeated exports, and it answers the question you will
  # actually ask the file six months from now — WHEN is this state from?
  [ -n "$file" ] || file="$box-$(date -u +%Y%m%dT%H%M%SZ).tar.gz"
  if [ -e "$file" ] && [ "$force" -ne 1 ]; then
    die "$file already exists — pick another name, or --force to overwrite"
  fi
  local extra=()
  # Snapshots ride along by DEFAULT (#70's call): the reuse workflow lives in
  # them (log in once, snapshot, clone forever), and an artifact that quietly
  # dropped the authed checkpoint would defeat its own purpose. --instance-only
  # is the explicit opt-out, passed through to incus verbatim.
  [ "$instance_only" -eq 1 ] && extra+=(--instance-only)
  echo "box: exporting $box → $file ..."
  incus export "$inst" "$file" "${extra[@]}"
  echo "box: exported $box → $file"
  [ "$instance_only" -eq 1 ] || echo "box: (snapshots included — 'box import' brings them back too)"
  # #70's credential decision: scrub or SHOUT — and box shouts. Scrubbing a
  # disk image is a promise no tarball surgery can keep (dotfiles, keychains,
  # tokens in shell history, deleted-but-unwiped blocks); handing someone a
  # "sanitized" file that is not sanitized would be worse than the risk it
  # hides. So the artifact carries everything, and box says so every time.
  {
    echo "box: ============================== WARNING =============================="
    echo "box: this file contains the box's ENTIRE disk: agent logins (Claude,"
    echo "box: Codex, Grok), git credentials, SSH keys, shell history — everything"
    echo "box: that was inside the box. Nothing in it was scrubbed."
    echo "box: treat the file itself as a credential: private storage, trusted"
    echo "box: channels only."
    echo "box: ====================================================================="
  } >&2
}

# The way back in. Everything 'incus import' restores is the ARTIFACT's truth
# (disk, config, devices, snapshots); everything box then re-stamps is THIS
# host's truth (the boundary tag, the placement contract, a fresh machine
# identity). That split is the design (#70): state is portable, the trust
# boundary is not — it is re-established on the current stack, every time.
cmd_import() {
  local file="${args[0]:-}"
  [ -n "$file" ] || usage_error "usage: $(synopsis_of import)"
  [ -f "$file" ] || die "no such file: $file"
  # The artifact names its instance in backup/index.yaml — read it up front:
  # the collision check and the re-stamping both need the final name BEFORE
  # incus acts. GNU tar auto-detects the compression on read. '|| true'
  # because pipefail would otherwise kill the script on a non-tarball with
  # tar's status instead of reaching the die below that names the problem.
  local embedded
  embedded="$(tar -xOf "$file" backup/index.yaml 2>/dev/null | awk '$1 == "name:" { print $2; exit }' || true)"
  [ -n "$embedded" ] || die "$file is not an incus/box export (no backup/index.yaml inside)"
  local target="${name:-$embedded}"
  # The boundary resolve_box enforces, seen from the other side: box will not
  # occupy a name ANY existing instance holds — not a box's (import is not
  # restore), and not an unmanaged VM's (not ours to shadow or clobber).
  if incus config show "$target" >/dev/null 2>&1 </dev/null; then
    die "an instance named '$target' already exists — import under another name: box import $file --name <new>"
  fi
  # The stack this lands on must exist first (a fresh host runs setup-host
  # before it re-imports) — same pre-flight as a mint, same tier-aware fix.
  require_stack
  echo "box: importing $file as $target..."
  if [ -n "$name" ]; then incus import "$file" "$name"; else incus import "$file"; fi
  # Re-stamp the boundary tag. user.* keys ride inside the artifact, so a box
  # export brings its template/user stamps back on its own, and a legacy
  # user.claudebox=1 stays honored as it is everywhere else. Only an instance
  # carrying NEITHER tag is stamped user.box=1 now — importing is minting,
  # and a minted box is ours to manage.
  local tag
  tag="$(incus config get "$target" user.box 2>/dev/null || true)"
  [ "$tag" = 1 ] || tag="$(incus config get "$target" user.claudebox 2>/dev/null || true)"
  [ "$tag" = 1 ] || incus config set "$target" user.box=1
  # Placement: the artifact carries its profile list, but the isolation
  # contract is THIS host's box-net profile. A box export already says
  # box-net; anything else (a pre-0.4.0 artifact, a hand-rolled export) gets
  # re-assigned — the same move migrate-host makes re-homing a legacy box.
  # (An artifact naming a profile this host lacks fails inside 'incus import'
  # above, with incus's own error naming the profile — honest enough.)
  # Two traps in reading the list: a bare name filter PREFIX-matches (asking
  # for 'work' also returns 'work2'), so anchor it; and the P column joins
  # multiple profiles with newlines, so join the whole (quoted, multi-line)
  # cell back into one comparable token instead of trusting the first line.
  local profs
  profs="$(incus list "^${target}\$" --format csv --columns P 2>/dev/null | tr -d '" ' | paste -sd, -)"
  if [ "$profs" != box-net ]; then
    incus profile assign "$target" box-net
    echo "box: re-homed onto the box-net profile (the artifact said '${profs:-none}')"
  fi
  # The artifact's volatile.* config comes back verbatim too — including the
  # NIC's MAC address. Importing an artifact twice, or beside the box it was
  # exported from, then collides at start: "MAC address already defined on
  # another NIC" (measured live), and the second box is left half-imported.
  # 'incus copy' regenerates the MAC on clone; import does not — so unset
  # every volatile hwaddr and let incus mint fresh ones at start. Host-side
  # identity, the same reasoning as the in-guest machine-id reset below.
  local k
  for k in $(incus config show "$target" 2>/dev/null | awk -F: '/^ *volatile\..*\.hwaddr:/ { gsub(/ /, "", $1); print $1 }'); do
    incus config unset "$target" "$k"
  done
  incus start "$target"
  wait_agent "$target"
  # The same trust boundary as a clone: the artifact's machine-id rides in
  # its disk, and the box it was exported from may still exist somewhere. A
  # fresh identity (machine-id → DHCP client-id → lease) before handover,
  # always — reset_identity's comment has the collision this prevents.
  reset_identity "$target"
  echo "box: imported $target — auth state (agent logins, git creds) came back with it, by design."
  echo "box: ready — 'box shell $target'."
}

# 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"
  # The doctor's verdict depends on who is asking: a restricted user cannot
  # see the nft tables or the kernel's bridge state, and telling them the
  # host is broken because THEY cannot read it would be a wrong diagnosis.
  export BOX_TIER; BOX_TIER="$(box_tier)"
  exec bash "$script" "${args[@]}"
}

# The host lifecycle scripts, as first-class verbs — nobody should have to know
# where the install tree keeps its scripts. Each execs the installed script
# with its flags passed through; the script owns its own behavior (setup-host's
# incus-admin re-login dance, teardown's confirmation, migrate's per-box work).
host_script() {   # $1 = script basename under host/
  local script="$root/host/$1"
  [ -f "$script" ] || die "$1 not found at $script — re-run install.sh"
  exec bash "$script" "${args[@]}"
}
cmd_setup_host()    { host_script setup-host.sh; }
cmd_teardown_host() { host_script teardown-host.sh; }
cmd_migrate_host()  { host_script migrate-host.sh; }
cmd_grant()         { host_script grant-user.sh; }
cmd_revoke()        { host_script revoke-user.sh; }

# --- the versioned install (#66's stance, made livable in 0.7.0) ------------
# install.sh lands each version at <install-root>/versions/<v>, with a
# 'current' symlink naming the default and $BINDIR/box pointing through it.
# $root (readlink -f, line 8) already resolved the whole chain, so a versioned
# install always runs from .../versions/<v> — and a git checkout does not,
# which is how these verbs know to refuse instead of uninstalling somebody's
# working copy.
install_root() {
  local vdir; vdir="$(dirname "$root")"
  [ "$(basename "$vdir")" = versions ] || return 1
  dirname "$vdir"
}

# A version is a DIRECTORY NAME under versions/ — nothing else. One strict
# gate for every caller that builds a path from one (the installer's new_ver,
# migration's flat_ver, and bin/box's 'use'/single-version uninstall): only
# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes,
# spaces and option-lookalikes by construction — a crafted version dies HERE,
# never in an rm -rf or an ln. install.sh carries a byte-identical copy;
# test/cli.sh diffs the two so the gates cannot drift.
valid_version() {
  case "$1" in
    ''|.*|-*) return 1 ;;
    *[!A-Za-z0-9._+-]*) return 1 ;;
  esac
  return 0
}

# Which boxes exist on this host, at THIS caller's tier? Prints their names
# (both tag generations) and succeeds when at least one exists; fails when
# none are visible — including when incus is absent or not answering, because
# #66's stance protects BOXES from a version change, and a daemon that cannot
# answer has none to protect. install.sh carries a byte-identical copy (it
# runs before any install tree exists); test/cli.sh diffs the two so they
# cannot drift.
existing_boxes() {
  command -v incus >/dev/null 2>&1 || return 1
  { timeout 10 incus list user.box=1 --format csv --columns n </dev/null
    timeout 10 incus list user.claudebox=1 --format csv --columns n </dev/null
  } 2>/dev/null | awk -F, 'NF && !seen[$1]++ { print $1 }' | grep .
}

# #66, kept at flip time: never change (or remove) the version under existing
# boxes. Names every box and the remedy, then dies — a refusal that does not
# say which boxes block it sends the operator off to rediscover 'box list'.
die_under_boxes() {   # $1 = the act being refused, $2 = the retry command
  local names n
  names="$(existing_boxes)" || return 0
  {
    echo "box: this host has existing boxes:"
    while IFS= read -r n; do echo "box:   · $n"; done <<<"$names"
    echo "box: refusing to $1 under them (#66: never change versions under a user's boxes)."
    echo "box: preserve what you care about — 'box down <box>', copy out via 'box shell'/'box exec'"
    echo "box: (a portable 'box export' is #70) — then 'box rm <box>' each, and re-run: $2"
  } >&2
  exit 1
}

# The PATH symlinks that could ride this install: the one this invocation came
# in on, BOX_BIN's, and the tier default's. Candidates only — every consumer
# checks where a link actually points before touching it, so a symlink that is
# somebody else's (another install root, a hand-rolled wrapper) is never moved.
bin_links() {
  local c=()
  [ -L "${BASH_SOURCE[0]}" ] && c+=("${BASH_SOURCE[0]}")
  [ -n "${BOX_BIN:-}" ] && c+=("$BOX_BIN/box")
  if [ "$(id -u)" -eq 0 ]; then c+=(/usr/local/bin/box); else c+=("$HOME/.local/bin/box"); fi
  printf '%s\n' "${c[@]}" | awk '!seen[$0]++'
}

converge_bin_links() {   # $1 = install root: point our PATH symlinks through current
  local ir="$1" p t
  while IFS= read -r p; do
    [ -L "$p" ] || continue
    t="$(readlink -f "$p" 2>/dev/null || true)"
    [ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)"
    case "$t" in
      "$ir"/*) ln -sfn "$ir/current/bin/box" "$p" ;;
    esac
  done < <(bin_links)
}

cmd_versions() {
  local ir cur d v mark
  ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to list"
  cur="$(readlink -f "$ir/current" 2>/dev/null || true)"
  echo "VERSIONS  ($ir)"
  for d in "$ir/versions"/*/; do
    [ -d "$d" ] || continue
    v="$(basename "$d")"
    mark=""
    [ "$(readlink -f "$d")" = "$cur" ] && mark=" (current)"
    [ "$(readlink -f "$d")" = "$root" ] && mark="$mark (running)"
    printf '  %s%s\n' "$v" "$mark"
  done
  echo
  echo "switch the default:  box use <version>"
  echo "install another:     re-run install.sh (versions land side by side)"
}

cmd_use() {
  local v="${args[0]:-}" ir eff expect out
  [ -n "$v" ] || usage_error "usage: $(synopsis_of use)"
  ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to switch"
  valid_version "$v" || die "not a sane version name: '$v' (a version is a directory name under versions/ — see 'box versions')"
  [ -d "$ir/versions/$v" ] || die "no such version: $v (see 'box versions')"
  die_under_boxes "switch the default box version" "box use $v"
  # An atomic flip, not unlink+create: ln -sfn leaves a window where current
  # is missing; a rename over it does not.
  ln -sfn "versions/$v" "$ir/current.new.$$" && mv -Tf "$ir/current.new.$$" "$ir/current"
  converge_bin_links "$ir"
  # Assert the EFFECTIVE result, not the intent: current must resolve to the
  # version asked for, and the chain's own binary must answer that version —
  # a flip that "worked" while the operator's box still runs the old tree is
  # exactly the flakiness this verb exists to end.
  eff="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")"
  [ "$eff" = "$v" ] || die "the flip did not take — current resolves to '${eff:-nothing}', not $v"
  expect="$(cat "$ir/versions/$v/VERSION" 2>/dev/null || true)"
  if [ -n "$expect" ]; then
    out="$("$ir/current/bin/box" --version 2>&1 || true)"
    case "$out" in
      *"$expect"*) : ;;
      *) die "current/bin/box answers '$out', not version $expect — the symlink chain is broken" ;;
    esac
  fi
  echo "box: switched to $v (current -> versions/$v)"
}

# The uninstall's own confirmation. NOT confirm() above: that one is for box
# lifecycle verbs and must never auto-accept from the environment (the drill
# exports BOX_YES=1 for the installer and still expects 'box rm' to refuse
# without --force). Uninstalling is installer-family, and BOX_YES is the
# installer-family consent contract — same as install.sh and revoke --purge.
uninstall_confirm() {   # $1 = question. --force, or BOX_YES=1, or a TTY.
  [ "$force" -eq 1 ] && return 0
  [ -n "${BOX_YES:-}" ] && return 0
  [ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on; BOX_YES=1 also means yes)"
  local reply
  printf 'box: %s? [y/N] ' "$1"
  read -r reply
  case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
}

# 'box uninstall' — the real uninstall #66 left as two rm -rf lines of prose.
# The full removal runs in the documented order: boxes first (refuse while
# they exist, or --purge-host tears the stack down with them), then trees and
# symlinks, and it ENDS by PROVING the absence — like revoke --purge, the
# last word is a re-check, not a hope.
cmd_uninstall() {
  local ir a ver="" all=0 purge_host=0 cur p t granted leftover=""
  local targets=()
  for a in ${args[@]+"${args[@]}"}; do
    case "$a" in
      --all) all=1 ;;
      --purge-host) purge_host=1 ;;
      -*) usage_error "unknown option: $a (see 'box help uninstall')" ;;
      *) [ -z "$ver" ] || usage_error "usage: $(synopsis_of uninstall)"; ver="$a" ;;
    esac
  done
  ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to uninstall (a checkout is removed with plain rm)"
  [ -w "$ir" ] || die "cannot write $ir — a global install is uninstalled as root: sudo box uninstall"

  # -- one version -----------------------------------------------------------
  if [ -n "$ver" ] && [ "$all" -eq 0 ]; then
    [ "$purge_host" -eq 0 ] || usage_error "--purge-host goes with the full uninstall, not a single version"
    valid_version "$ver" || die "not a sane version name: '$ver' (a version is a directory name under versions/ — see 'box versions')"
    [ -d "$ir/versions/$ver" ] || die "no such version: $ver (see 'box versions')"
    cur="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")"
    # A broken current makes the CURRENT guard below unfireable (cur empty
    # when the link is missing; cur naming a non-directory when it dangles —
    # readlink -f resolves a link whose last component does not exist). Heal
    # first, then decide; never delete around a broken default.
    { [ -n "$cur" ] && [ -d "$ir/versions/$cur" ]; } \
      || die "current is dangling — 'box use <version>' to repoint the default first (refusing to remove versions while it is broken)"
    [ "$ver" != "$cur" ] || die "$ver is the CURRENT version — 'box use <other>' first, or 'box uninstall --all' for everything"
    uninstall_confirm "remove box version $ver from $ir"
    # rm's exit code is not the verdict — the absence re-check below is (a
    # half-removed tree must be reported as INCOMPLETE, not as a crash).
    rm -rf "$ir/versions/$ver" || true
    if [ -e "$ir/versions/$ver" ] || [ -L "$ir/versions/$ver" ]; then
      echo "box: uninstall INCOMPLETE — still present: $ir/versions/$ver" >&2
      exit 1
    fi
    echo "box: removed version $ver (the default stays $cur)"
    return 0
  fi
  [ -z "$ver" ] || usage_error "usage: $(synopsis_of uninstall) — a version and --all together is ambiguous"

  # -- everything ------------------------------------------------------------
  if [ "$purge_host" -eq 1 ]; then
    # Granted users' worlds are not ours to erase silently — name them first;
    # 'box revoke <user> --purge' is the clean path (and asserts its absence).
    granted="$(timeout 10 incus project list --format csv 2>/dev/null </dev/null | cut -d, -f1 | grep '^user-' | tr '\n' ' ' || true)"
    [ -n "${granted% }" ] && echo "box: NOTE — granted users still have projects (${granted% }) — 'box revoke <user> --purge' removes each world cleanly first" >&2
    # Consent forwards: --force and BOX_YES are this verb's installer-family
    # yes, and teardown-host must hear it too — otherwise a non-interactive
    # 'uninstall --all --purge-host --force' dies at teardown's own prompt
    # (EOF on read) with the tree untouched but the promise broken.
    if [ "$force" -eq 1 ] || [ -n "${BOX_YES:-}" ]; then
      bash "$root/host/teardown-host.sh" --yes \
        || die "teardown-host did not complete — stopping BEFORE removing the install (the tree is untouched; fix the error and re-run)"
    else
      bash "$root/host/teardown-host.sh" \
        || die "teardown-host did not complete — stopping BEFORE removing the install (the tree is untouched; fix the error and re-run)"
    fi
  else
    die_under_boxes "uninstall box" "box uninstall (or 'box uninstall --purge-host' to tear the host stack down with them)"
  fi
  uninstall_confirm "remove the ENTIRE box install at $ir (every version)"

  # The removal set, gathered BEFORE anything is deleted, so the absence
  # assert below re-checks exactly what was promised gone. PATH symlinks are
  # removed only when they resolve into (or dangle at) THIS install root.
  targets+=("$ir")
  while IFS= read -r p; do
    [ -L "$p" ] || continue
    t="$(readlink -f "$p" 2>/dev/null || true)"
    [ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)"
    case "$t" in "$ir"/*) targets+=("$p") ;; esac
  done < <(bin_links)
  # Legacy crumbs: the pre-0.4.0 command name and the pre-0.5.0 tree. A real
  # uninstall leaves neither generation behind.
  while IFS= read -r p; do
    p="$(dirname "$p")/claudebox"
    [ -L "$p" ] && targets+=("$p")
  done < <(bin_links)
  [ -d "$HOME/.local/share/claudebox" ] && targets+=("$HOME/.local/share/claudebox")
  mapfile -t targets < <(printf '%s\n' "${targets[@]}" | awk '!seen[$0]++')

  # rm's exit code is not the verdict — the absence assert below is (a
  # half-removed tree must be reported as INCOMPLETE by name, not as a crash).
  for p in "${targets[@]}"; do rm -rf "$p" || true; done

  # END WITH THE ABSENCE ASSERT: every path re-checked — file, dir OR symlink.
  # A leftover makes this exit 1 by name; "uninstalled" is a claim, and claims
  # get verified (the revoke --purge discipline).
  for p in "${targets[@]}"; do
    if [ -e "$p" ] || [ -L "$p" ]; then leftover="$leftover $p"; fi
  done
  if [ -n "$leftover" ]; then
    echo "box: uninstall INCOMPLETE — still present:$leftover" >&2
    echo "box: remove them by hand, and re-check each path is really gone." >&2
    exit 1
  fi
  echo "box: uninstalled — removed:"
  for p in "${targets[@]}"; do echo "box:   · $p"; done
  if [ "$purge_host" -eq 0 ]; then
    echo "box: note — the host stack (boxnet, firewall), if this host has one, was NOT touched:"
    echo "box: run teardown-host from a checkout (host/teardown-host.sh), or use --purge-host next time."
  fi
}

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[@]}"
}

# A deliberate, loopback-only door to a box's port — for the one workflow the
# "no inbound path" contract is too absolute for: you are coding in a box and
# want to open its dev server in your browser.
#
# Two decisions, both load-bearing:
#   · The listen side is ALWAYS 127.0.0.1. The network-facing contract stays
#     true — no other machine can reach the box; only THIS host's loopback gets
#     a door. There is no flag to widen it; that is the escape hatch's job, with
#     its warning.
#   · Each exposure is a named proxy device (expose-<port>), so 'box info' and
#     --list can see it and --remove can undo it. A box with a hole says so.
#
# Mechanism (VMs): an Incus 'proxy' device in NAT mode DNATs host
# 127.0.0.1:<hostport> to the box's <ip>:<port>. The traffic rides the
# network into the guest, so the in-box server must listen on 0.0.0.0 (not
# just its own loopback) — inside an isolated box that is safe: boxnet +
# port-isolation + the ingress drop mean only this door can reach it. Three
# pieces beside the device itself, each one a drill-found absence:
#   · a SCOPED ACL allow (this box's ip + this port only) — the ingress drop
#     that makes A7 true would eat the DNAT'd packet;
#   · route_localnet + a loopback masquerade on the host (box-firewall.sh) —
#     Incus installs only the DNAT, and a loopback-sourced packet can neither
#     leave the host nor be answered without them;
#   · a static ipv4.address pin on the NIC — NAT mode refuses to start
#     without one (see below).
exposure_dev() { echo "expose-$1"; }   # device name for a port

cmd_expose() {
  local box="${args[0]}" a2="${args[1]:-}" a3="${args[2]:-}"

  # Before ANY incus call: the door's plumbing (the box-isolate ACL, the
  # host firewall's route_localnet + masquerade) is daemon-global state a
  # restricted certificate cannot touch. Without this guard the failure is
  # a lie — box_net_ip cannot read boxnet's (redacted) config, so the
  # restricted user is told their running box "has no boxnet address yet".
  if [ "$(box_tier)" = restricted ]; then
    die "box expose edits the daemon-global ACL and firewall, which the restricted tier cannot modify — ask an incus-admin. (see #74)"
  fi

  # --list
  if [ "$a2" = "--list" ]; then
    local found=0 d listen connect
    while IFS= read -r d; do
      case "$d" in expose-*) : ;; *) continue ;; esac
      listen="$(incus config device get "$inst" "$d" listen 2>/dev/null)"
      connect="$(incus config device get "$inst" "$d" connect 2>/dev/null)"
      [ "$found" = 0 ] && echo "EXPOSURES for $box"
      found=1
      printf '  %-14s %s → %s\n' "${d#expose-}" "$listen" "$connect"
    done < <(incus config device list "$inst" 2>/dev/null)
    [ "$found" = 0 ] && echo "box: $box has no exposed ports"
    return 0
  fi

  # --remove <port>
  if [ "$a2" = "--remove" ]; then
    local port="$a3"; [ -n "$port" ] || usage_error "usage: box expose $box --remove <port>"
    local dev; dev="$(exposure_dev "$port")"
    incus config device get "$inst" "$dev" listen >/dev/null 2>&1 \
      || die "$box has no exposure on port $port (see 'box expose $box --list')"
    incus config device remove "$inst" "$dev" >/dev/null \
      && echo "box: closed the door on port $port"
    # Remove the scoped ACL allow, if one was added. Best-effort: its absence
    # is not an error (the drill may show the allow was never needed).
    local ip; ip="$(box_net_ip "$inst" || true)"
    [ -n "$ip" ] && incus network acl rule remove box-isolate ingress \
      action=allow "destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
    # If that was the last door, unpin the static address it required. Only
    # then — other exposures still lean on the pin. Best-effort, like the ACL.
    if ! incus config device list "$inst" 2>/dev/null | grep -q '^expose-'; then
      incus config device unset "$inst" eth0 ipv4.address >/dev/null 2>&1
    fi
    return 0
  fi

  # expose <port> [<host-port>]
  local port="$a2" hport="${a3:-$a2}"
  [ -n "$port" ] || usage_error "usage: $(synopsis_of expose)"
  case "$port$hport" in *[!0-9]*) usage_error "ports must be numbers — got port='$port' host-port='$hport'" ;; esac

  local ip; ip="$(box_net_ip "$inst")" \
    || die "$box has no boxnet address yet — is it running? (box info $box)"

  local dev; dev="$(exposure_dev "$port")"
  if incus config device get "$inst" "$dev" listen >/dev/null 2>&1; then
    die "$box already exposes port $port (change or remove it: box expose $box --remove $port)"
  fi

  # The scoped ACL allow FIRST, so the door is open by the time the proxy uses
  # it. Scoped to this box's IP and this port — it does not widen any other box.
  incus network acl rule add box-isolate ingress action=allow \
    "destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1 || true

  # A VM's proxy device must be NAT mode — Incus supports proxy on containers in
  # both modes, but on VMs "NAT mode only" (the userspace forkproxy is a
  # container thing). NAT mode DNATs host:port → instance:port in netfilter, and
  # it needs the host to be the instance's gateway, which boxnet makes true.
  #
  # And NAT mode needs a STATIC address. Incus resolves connect=0.0.0.0 to the
  # NIC's ipv4.address — the device config, not the lease — and refuses when it
  # is unset: `Instance has no static IPv4 address assigned to be used as the
  # connect IP` (the 0.5.0 drill). The first cut pinned an address but the
  # WRONG one (box_ipv4's docker0 decoy); the second cut removed the pin
  # instead of correcting it. Third cut: pin the box's current BOXNET lease.
  # Same address the box already holds, so nothing about its networking moves —
  # the lease just becomes official. Left in place across exposures; unpinned
  # when the last door closes.
  local err; err="$(mktemp)"
  if [ -z "$(incus config device get "$inst" eth0 ipv4.address 2>/dev/null)" ]; then
    # override copies the profile NIC into the instance with the key set; if a
    # local eth0 already exists, override refuses and set is the right verb.
    if ! incus config device override "$inst" eth0 "ipv4.address=$ip" >/dev/null 2>"$err" \
       && ! incus config device set "$inst" eth0 "ipv4.address=$ip" >/dev/null 2>"$err"; then
      echo "box: could not pin $box's boxnet address ($ip) as static — the NAT proxy requires one:" >&2
      sed 's/^/  /' "$err" >&2; rm -f "$err"
      incus network acl rule remove box-isolate ingress action=allow \
        "destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
      die "expose failed"
    fi
  fi
  # connect=0.0.0.0 is deliberate: Incus resolves it to the instance's static
  # IPv4 (the pin above). Naming an address here would work too, but 0.0.0.0
  # cannot repeat the docker0 mistake — there is nothing to get wrong.
  if incus config device add "$inst" "$dev" proxy \
       "listen=tcp:127.0.0.1:$hport" "connect=tcp:0.0.0.0:$port" \
       bind=host nat=true >/dev/null 2>"$err"; then
    rm -f "$err"
    echo "box: 127.0.0.1:$hport → $box:$port"
    echo "box: (the in-box server must listen on 0.0.0.0:$port, not only its own loopback)"
    # The device alone is not the door: the DNAT'd loopback packet also needs
    # route_localnet on the bridge (box-firewall.sh installs it, with the
    # masquerade). Readable without root — warn instead of handing over a
    # door that silently does not answer.
    if [ "$(cat /proc/sys/net/ipv4/conf/boxnet/route_localnet 2>/dev/null)" != 1 ]; then
      echo "box: WARNING — route_localnet is off on boxnet, so this door will NOT answer." >&2
      echo "box: the host firewall predates expose — apply it: sudo /usr/local/sbin/box-firewall" >&2
    fi
  else
    # NEVER swallow incus's reason — the first cut of this verb died with a bare
    # "could not add the proxy device" and told the drill nothing.
    echo "box: incus refused the proxy device:" >&2
    sed 's/^/  /' "$err" >&2; rm -f "$err"
    incus network acl rule remove box-isolate ingress action=allow \
      "destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
    die "expose failed"
  fi
}

# --- 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
