feat: make the command surface a table, add rename and an escape hatch #13

Merged
dan-claude-bot merged 1 commit from refactor/command-table into main 2026-07-13 20:56:46 +00:00
dan-claude-bot commented 2026-07-13 20:51:17 +00:00 (Migrated from github.com)

Closes #11. Branches off main with #9 and #10 merged — not stacked.

Every Incus verb is a candidate feature request. This lands the rule instead of
the next wrapper.

The rule (now in docs/claudebox-design.md)

claudebox owns a command when it must enforce an invariant Incus cannot see:
the user.claudebox=1 boundary, the isolation stack, or the creds-free
snapshot→clone workflow. Everything else is Incus's job.

1. The table

CMDS in bin/claudebox is now the single source of truth — existence,
synopsis, preconditions, help line, what runs, what it prints:

verb ^ synopsis args ^ preconditions ^ summary ^ action ^ ok message

"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 {}"

Preconditions are declarative: box (resolve + tag-check), arg2, stopped,
confirm (the rm prompt). Dispatch and help are both rendered from these
rows
, so the help can no longer describe a command that doesn't exist — the
drift that produced #8 is now impossible by construction rather than fixed by
hand. Adding a thin verb is one line; if a request can't be a row and enforces no
invariant of ours, it isn't ours.

2. rename — one row, and not a proxy after all

Incus refuses to rename a running instance, so the wrapper has real work to do:

$ claudebox rename work archive
claudebox: box 'work' is RUNNING — Incus needs it stopped for this. Stop it: claudebox down work

3. claudebox incus <box> -- <args...> — the door out

$ claudebox incus work -- config show
claudebox: incus config show work

$ claudebox incus work -- config device add {} extra disk source=/data path=/data
claudebox: warning: this can move the box off the isolation stack
claudebox: (profile / network / device / security.*). The trust boundary is yours from here.
claudebox: incus config device add work extra disk source=/data path=/data

Box resolved and tag-checked, the rest passed to Incus verbatim, {} substituted
(appended if absent), command echoed before it runs — no magic. "That's Incus's
job" is never "you can't do that".

⚠️ The boundary is now enforced, not assumed

Previously only list/info filtered on the tag — rm, down, start,
shell and exec would act on any Incus instance you named. They now all
resolve through user.claudebox=1:

$ claudebox rm payroll --force        # a real VM, but not one claudebox minted
claudebox: no such box: payroll (see 'claudebox list')

Breaking in one case: a box created before the tag existed (pre-#4) is now
refused as well as invisible. Re-tag it and it's back:

incus config set <box> user.claudebox=1

Testing

Same caveat as #9 and #10, and it matters more here: no real Incus — I work
inside a claudebox, which has neither incus nor nested virt. Everything was
driven against a stubbed incus, including an untagged instance (payroll) for
the boundary, a running box for rename's precondition, and the multi-address
docker box. 21/21 cases pass with expected exit codes; shellcheck and bash -n
clean.

Two things I could not verify, and would smoke first:

  1. That incus config get <inst> user.claudebox returns 1 for boxes minted by
    new. It's the same key new sets, but this read path is new — and it is now
    on the path of every box command. If it's wrong, everything fails closed
    with "no such box", which is loud rather than dangerous.
  2. That Incus's rename really does refuse a running instance rather than
    silently working. If it doesn't, the precondition is merely conservative.

🤖 Generated with Claude Code

Closes #11. Branches off `main` with #9 and #10 merged — not stacked. Every Incus verb is a candidate feature request. This lands the rule instead of the next wrapper. ## The rule (now in `docs/claudebox-design.md`) > claudebox owns a command when it must enforce an invariant Incus cannot see: > the `user.claudebox=1` boundary, the isolation stack, or the creds-free > snapshot→clone workflow. Everything else is Incus's job. ## 1. The table `CMDS` in `bin/claudebox` is now the single source of truth — existence, synopsis, preconditions, help line, what runs, what it prints: ``` verb ^ synopsis args ^ preconditions ^ summary ^ action ^ ok message "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 {}" ``` Preconditions are declarative: `box` (resolve + tag-check), `arg2`, `stopped`, `confirm` (the `rm` prompt). **Dispatch and help are both rendered from these rows**, so the help can no longer describe a command that doesn't exist — the drift that produced #8 is now impossible by construction rather than fixed by hand. Adding a thin verb is one line; if a request can't be a row and enforces no invariant of ours, it isn't ours. ## 2. `rename` — one row, and not a proxy after all Incus refuses to rename a *running* instance, so the wrapper has real work to do: ```console $ claudebox rename work archive claudebox: box 'work' is RUNNING — Incus needs it stopped for this. Stop it: claudebox down work ``` ## 3. `claudebox incus <box> -- <args...>` — the door out ```console $ claudebox incus work -- config show claudebox: incus config show work $ claudebox incus work -- config device add {} extra disk source=/data path=/data claudebox: warning: this can move the box off the isolation stack claudebox: (profile / network / device / security.*). The trust boundary is yours from here. claudebox: incus config device add work extra disk source=/data path=/data ``` Box resolved and tag-checked, the rest passed to Incus verbatim, `{}` substituted (appended if absent), command echoed before it runs — no magic. "That's Incus's job" is never "you can't do that". ## ⚠️ The boundary is now enforced, not assumed Previously only `list`/`info` filtered on the tag — `rm`, `down`, `start`, `shell` and `exec` would act on **any** Incus instance you named. They now all resolve through `user.claudebox=1`: ```console $ claudebox rm payroll --force # a real VM, but not one claudebox minted claudebox: no such box: payroll (see 'claudebox list') ``` **Breaking in one case:** a box created before the tag existed (pre-#4) is now refused as well as invisible. Re-tag it and it's back: ```sh incus config set <box> user.claudebox=1 ``` ## Testing Same caveat as #9 and #10, and it matters more here: **no real Incus** — I work inside a claudebox, which has neither `incus` nor nested virt. Everything was driven against a stubbed `incus`, including an untagged instance (`payroll`) for the boundary, a running box for `rename`'s precondition, and the multi-address docker box. 21/21 cases pass with expected exit codes; `shellcheck` and `bash -n` clean. **Two things I could not verify, and would smoke first:** 1. That `incus config get <inst> user.claudebox` returns `1` for boxes minted by `new`. It's the same key `new` sets, but this read path is new — and it is now on the path of *every* box command. If it's wrong, everything fails closed with "no such box", which is loud rather than dangerous. 2. That Incus's `rename` really does refuse a running instance rather than silently working. If it doesn't, the precondition is merely conservative. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/box#13
No description provided.