2026-07-10 15:00:36 +00:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# claudebox — trust-less, isolated Incus VMs with Claude Code, creds-free.
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
# 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.
|
2026-07-10 15:00:36 +00:00
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
remote=""; mode="auto"; name=""; from=""; force=0; json=0; want_help=0
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
inst="" # the resolved Incus instance, set by the 'box' precondition
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
|
|
|
|
|
die() { echo "claudebox: $*" >&2; exit 1; } # 1 = it went wrong
|
|
|
|
|
usage_error() { echo "claudebox: $*" >&2; echo "try 'claudebox help'." >&2; exit 2; } # 2 = you asked wrong
|
|
|
|
|
version() { echo "claudebox $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; }
|
|
|
|
|
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# The command table.
|
|
|
|
|
#
|
|
|
|
|
# verb ^ synopsis args ^ preconditions ^ one-line summary ^ action ^ ok message
|
|
|
|
|
#
|
|
|
|
|
# Fields are ^-separated because a synopsis may contain '|' ([--vm|--container]).
|
|
|
|
|
#
|
|
|
|
|
# preconditions (comma-separated):
|
|
|
|
|
# box first positional is a box: resolve it, and REFUSE if the instance
|
|
|
|
|
# isn't tagged user.claudebox=1 — the boundary, enforced, not assumed
|
|
|
|
|
# arg2 a second positional is required
|
|
|
|
|
# stopped the box must not be running
|
|
|
|
|
# confirm destructive: prompt unless --force
|
|
|
|
|
#
|
|
|
|
|
# action:
|
|
|
|
|
# incus:<subcommand> run `incus <subcommand> <instance> [rest...]`
|
|
|
|
|
# fn:<function> call a shell function (it has real work to do)
|
|
|
|
|
#
|
|
|
|
|
# ok message: printed on success; {} = the box, {1} = the second positional.
|
|
|
|
|
#
|
|
|
|
|
# Adding a thin verb is one row. If a request can't be expressed as a row and
|
|
|
|
|
# doesn't enforce a claudebox invariant, it is incus's job, not ours — that is
|
|
|
|
|
# what `claudebox incus` is for.
|
|
|
|
|
CMDS=(
|
|
|
|
|
"new^--name <box> [--from <src>[/<snap>]] [--vm|--container]^^Mint a box: fresh from cloud-init, or --from an existing box/snapshot^fn:cmd_new^"
|
|
|
|
|
"list^[--json]^^List your boxes^fn:cmd_list^"
|
|
|
|
|
"info^<box> [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^"
|
|
|
|
|
"shell^<box>^box^Open a shell in a box, as the claude user^fn:cmd_shell^"
|
|
|
|
|
"exec^<box> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^"
|
|
|
|
|
"snapshot^<box> [<label>]^box^Checkpoint a box (label defaults to manual-<epoch>)^fn:cmd_snapshot^"
|
|
|
|
|
"restore^<box> <snapshot>^box,arg2^Roll a box back to one of its snapshots^incus:restore^restored {} to {1}"
|
|
|
|
|
"rename^<box> <new-name>^box,arg2,stopped^Rename a box (it must be stopped first)^incus:rename^renamed {} to {1}"
|
|
|
|
|
"down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}"
|
|
|
|
|
"start^<box>^box^Start a stopped box^incus:start^started {}"
|
|
|
|
|
"rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}"
|
|
|
|
|
"incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^"
|
|
|
|
|
"status^^^Deprecated alias for 'list'^fn:cmd_status^"
|
|
|
|
|
"help^[<command>]^^This help, or 'claudebox help <command>' for one command^fn:cmd_help^"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
cmd_row() { local r; for r in "${CMDS[@]}"; do case "$r" in "$1^"*) echo "$r"; return 0 ;; esac; done; return 1; }
|
|
|
|
|
verbs() { local r; for r in "${CMDS[@]}"; do echo "${r%%^*}"; done; }
|
|
|
|
|
is_command() { cmd_row "$1" >/dev/null 2>&1; }
|
|
|
|
|
# locals matter here: dispatch holds $pre/$action/$ok, and field() is called from
|
|
|
|
|
# error paths inside it — a global read would clobber the row being dispatched.
|
|
|
|
|
field() {
|
|
|
|
|
local r f_syn f_pre f_sum f_act f_ok
|
|
|
|
|
r="$(cmd_row "$1")" || return 1
|
|
|
|
|
IFS='^' read -r _ f_syn f_pre f_sum f_act f_ok <<<"$r"
|
|
|
|
|
case "$2" in
|
|
|
|
|
syn) echo "$f_syn" ;; pre) echo "$f_pre" ;; sum) echo "$f_sum" ;;
|
|
|
|
|
act) echo "$f_act" ;; ok) echo "$f_ok" ;;
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
esac
|
|
|
|
|
}
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
synopsis_of() { local s; s="$(field "$1" syn)"; echo "claudebox $1${s:+ $s}"; }
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
|
|
|
|
|
# Nearest command by edit distance — a typo should point somewhere, not just fail.
|
|
|
|
|
suggest() {
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
verbs | awk -v w="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" '
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
function dist(a, b, la, lb, i, j, c, prev, cur) {
|
|
|
|
|
la = length(a); lb = length(b)
|
|
|
|
|
for (j = 0; j <= lb; j++) prev[j] = j
|
|
|
|
|
for (i = 1; i <= la; i++) {
|
|
|
|
|
cur[0] = i
|
|
|
|
|
for (j = 1; j <= lb; j++) {
|
|
|
|
|
c = (substr(a, i, 1) == substr(b, j, 1)) ? 0 : 1
|
|
|
|
|
cur[j] = prev[j] + 1
|
|
|
|
|
if (cur[j - 1] + 1 < cur[j]) cur[j] = cur[j - 1] + 1
|
|
|
|
|
if (prev[j - 1] + c < cur[j]) cur[j] = prev[j - 1] + c
|
|
|
|
|
}
|
|
|
|
|
for (j = 0; j <= lb; j++) prev[j] = cur[j]
|
|
|
|
|
}
|
|
|
|
|
return prev[lb]
|
|
|
|
|
}
|
|
|
|
|
BEGIN { best = 99 }
|
|
|
|
|
{ d = dist(w, $0); if (d < best) { best = d; hit = $0 } }
|
|
|
|
|
END { if (best <= 2) print hit }'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
unknown_command() {
|
|
|
|
|
local hint; hint="$(suggest "$1")"
|
|
|
|
|
if [ -n "$hint" ]; then
|
|
|
|
|
echo "claudebox: unknown command: $1 — did you mean '$hint'?" >&2
|
|
|
|
|
else
|
|
|
|
|
echo "claudebox: unknown command: $1" >&2
|
|
|
|
|
fi
|
|
|
|
|
echo "try 'claudebox help' for the command list." >&2
|
|
|
|
|
exit 2
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
usage() {
|
|
|
|
|
cat <<'EOF'
|
|
|
|
|
claudebox — trust-less, network-isolated Incus VMs with Claude Code, creds-free.
|
|
|
|
|
|
|
|
|
|
USAGE
|
|
|
|
|
claudebox <command> [<args>] [options]
|
|
|
|
|
|
|
|
|
|
COMMANDS
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
EOF
|
|
|
|
|
local r v sum
|
|
|
|
|
for r in "${CMDS[@]}"; do
|
|
|
|
|
IFS='^' read -r v _ _ sum _ _ <<<"$r"
|
|
|
|
|
printf ' %-9s %s\n' "$v" "$sum"
|
|
|
|
|
done
|
|
|
|
|
cat <<'EOF'
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
|
|
|
|
|
OPTIONS
|
|
|
|
|
--name <box> Name for the new box (new)
|
|
|
|
|
--from <src>[/<snap>] Clone from box <src>, or from its snapshot (new)
|
|
|
|
|
--vm Force VM mode: the trust-less target (new)
|
|
|
|
|
--container Force container mode: weaker isolation, (new)
|
|
|
|
|
dev/test only. Default where /dev/kvm is absent.
|
|
|
|
|
--json Emit Incus JSON instead of a table (list, info)
|
|
|
|
|
--force, -f Delete without the confirmation prompt (rm)
|
|
|
|
|
--remote <r> Act on Incus remote <r> (any)
|
|
|
|
|
--help, -h Help; after a command, help for that command
|
|
|
|
|
--version, -V Print the claudebox version
|
|
|
|
|
|
|
|
|
|
Options come after the command: 'claudebox list --json', not 'claudebox --json list'.
|
|
|
|
|
|
|
|
|
|
EXAMPLES
|
|
|
|
|
# mint a box and log in inside it — the tool never handles your token
|
|
|
|
|
claudebox new --name work
|
|
|
|
|
claudebox shell work # then: run 'claude', then /login
|
|
|
|
|
|
|
|
|
|
# log in once, reuse forever: checkpoint the authed box, clone from it
|
|
|
|
|
claudebox snapshot work authed
|
|
|
|
|
claudebox new --name feature --from work/authed
|
|
|
|
|
|
|
|
|
|
# what have I got, and what can I clone?
|
|
|
|
|
claudebox list
|
|
|
|
|
claudebox info work
|
|
|
|
|
|
|
|
|
|
# run something without opening a shell
|
|
|
|
|
claudebox exec work -- git -C project pull
|
|
|
|
|
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
# anything claudebox doesn't wrap: boxes are plain Incus instances
|
|
|
|
|
claudebox incus work -- config show
|
|
|
|
|
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
EXIT STATUS
|
|
|
|
|
0 ok
|
|
|
|
|
1 it went wrong (Incus failed, no such box, aborted at a prompt)
|
|
|
|
|
2 you asked wrong (unknown command, bad flag, destructive act without --force)
|
|
|
|
|
|
|
|
|
|
THE MODEL
|
|
|
|
|
A box carries NO credentials. You authenticate interactively inside it
|
|
|
|
|
('claude' then /login; 'gh auth login'); claudebox never stores or injects a
|
|
|
|
|
secret. A box reaches the public internet and nothing else — there is no
|
|
|
|
|
inbound path. Destroying a box loses nothing you didn't push.
|
|
|
|
|
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
claudebox owns a command when it must enforce something Incus cannot see: the
|
|
|
|
|
user.claudebox=1 boundary, the isolation stack, or the creds-free snapshot
|
|
|
|
|
workflow. Everything else is Incus's job — and 'claudebox incus' is the door.
|
|
|
|
|
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
Docs: https://github.com/heavy-duty/claudebox
|
|
|
|
|
EOF
|
|
|
|
|
}
|
|
|
|
|
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
# 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.
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
help_cmd() {
|
|
|
|
|
echo "usage: $(synopsis_of "$1")"
|
|
|
|
|
echo
|
|
|
|
|
case "$1" in
|
|
|
|
|
new) cat <<'EOF'
|
|
|
|
|
Mint a box. Without --from, launches a fresh Debian 13 box from cloud-init
|
|
|
|
|
(~10 min cold) with Claude Code installed and NO credentials. With --from,
|
|
|
|
|
clones an existing box or one of its snapshots — Claude login, git creds and
|
|
|
|
|
clones carry over, isolation is preserved.
|
|
|
|
|
|
|
|
|
|
--name <box> Required. The box's name.
|
|
|
|
|
--from <src>[/<snap>] Clone src's live state, or its snapshot <snap>.
|
|
|
|
|
--vm | --container Force the mode. VM is the trust boundary and the
|
|
|
|
|
default wherever /dev/kvm exists; container mode
|
|
|
|
|
(security.nesting=true) is the fallback for hosts
|
|
|
|
|
without nested virt — weaker isolation, dev/test only.
|
|
|
|
|
|
|
|
|
|
claudebox new --name work
|
|
|
|
|
claudebox new --name feature --from work/authed
|
|
|
|
|
EOF
|
|
|
|
|
;;
|
|
|
|
|
list) cat <<'EOF'
|
|
|
|
|
List the boxes claudebox minted on this host: name, state, type, snapshot count.
|
|
|
|
|
Takes no box — for one box, that's 'claudebox info <box>'.
|
|
|
|
|
|
|
|
|
|
--json Incus's JSON, straight through, for scripting.
|
|
|
|
|
|
|
|
|
|
claudebox list
|
|
|
|
|
EOF
|
|
|
|
|
;;
|
|
|
|
|
info) cat <<'EOF'
|
|
|
|
|
Show one box: state, type, IP address, and — the reason this exists — the
|
|
|
|
|
labels of its snapshots, with the --from line to clone one.
|
|
|
|
|
|
|
|
|
|
--json Incus's JSON, straight through, for scripting.
|
|
|
|
|
|
|
|
|
|
claudebox info work
|
|
|
|
|
EOF
|
|
|
|
|
;;
|
|
|
|
|
shell) cat <<'EOF'
|
|
|
|
|
Open an interactive shell in a running box as the 'claude' user. This is the
|
|
|
|
|
only entry path — there is no SSH and no inbound route to a box.
|
|
|
|
|
|
|
|
|
|
claudebox shell work
|
|
|
|
|
EOF
|
|
|
|
|
;;
|
|
|
|
|
exec) cat <<'EOF'
|
|
|
|
|
Run a command inside a box as the 'claude' user. Everything after -- is passed
|
|
|
|
|
through untouched; the -- is required, or claudebox will read your command's
|
|
|
|
|
flags as its own.
|
|
|
|
|
|
|
|
|
|
claudebox exec work -- git -C project pull
|
|
|
|
|
claudebox exec work -- claude --version
|
|
|
|
|
EOF
|
|
|
|
|
;;
|
|
|
|
|
snapshot) cat <<'EOF'
|
|
|
|
|
Checkpoint a box. Snapshots are how an authenticated box is reused: log in
|
|
|
|
|
once, snapshot, then 'new --from <box>/<label>' as often as you like. The
|
|
|
|
|
label defaults to manual-<epoch>; 'claudebox info <box>' shows the labels you
|
|
|
|
|
have.
|
|
|
|
|
|
|
|
|
|
claudebox snapshot work authed
|
|
|
|
|
EOF
|
|
|
|
|
;;
|
|
|
|
|
restore) cat <<'EOF'
|
|
|
|
|
Roll a box back to one of its snapshots, in place. Anything in the box since
|
|
|
|
|
that snapshot is lost. 'claudebox info <box>' lists the labels.
|
|
|
|
|
|
|
|
|
|
claudebox restore work authed
|
|
|
|
|
EOF
|
|
|
|
|
;;
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
rename) cat <<'EOF'
|
|
|
|
|
Rename a box. Incus cannot rename a running instance, so stop it first:
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
|
|
|
|
|
claudebox down work
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
claudebox rename work archive
|
|
|
|
|
claudebox start archive
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
Snapshots and Claude auth follow the box; anything referring to the old name by
|
|
|
|
|
hand (a --from line, a script) does not.
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
EOF
|
|
|
|
|
;;
|
|
|
|
|
rm) cat <<'EOF'
|
|
|
|
|
Delete a box and every snapshot it has. This cannot be undone, so it asks for
|
|
|
|
|
confirmation first; --force (-f) skips the prompt. With no TTY to confirm on
|
|
|
|
|
(a script, a pipe), it refuses unless --force is given.
|
|
|
|
|
|
|
|
|
|
claudebox rm work
|
|
|
|
|
claudebox rm work --force
|
|
|
|
|
EOF
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
;;
|
|
|
|
|
incus) cat <<'EOF'
|
|
|
|
|
The door out. claudebox wraps the box lifecycle and the isolation model, not
|
|
|
|
|
all of Incus — so when you need something it doesn't wrap, run Incus through
|
|
|
|
|
here and keep the safety rail that matters: the box name is resolved and
|
|
|
|
|
checked against the user.claudebox=1 tag, so you cannot aim it at an instance
|
|
|
|
|
claudebox didn't mint.
|
|
|
|
|
|
|
|
|
|
Everything after -- is passed to incus verbatim. A literal {} is replaced with
|
|
|
|
|
the resolved instance name; with no {}, the instance is appended at the end.
|
|
|
|
|
The command that will run is echoed before it runs.
|
|
|
|
|
|
|
|
|
|
claudebox incus work -- config show
|
|
|
|
|
claudebox incus work -- config device add {} extra disk source=/data path=/data
|
|
|
|
|
|
|
|
|
|
Changing the profile, the network, a device or a security.* key can take a box
|
|
|
|
|
outside the isolation stack. claudebox warns and then does as you asked — from
|
|
|
|
|
there, the trust boundary is yours to keep.
|
|
|
|
|
EOF
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
;;
|
|
|
|
|
status) cat <<'EOF'
|
|
|
|
|
Deprecated alias for 'claudebox list'. It ignored the <box> argument it
|
|
|
|
|
advertised, so it was split into 'list' (all boxes) and 'info <box>' (one). It
|
|
|
|
|
still works, and forwards to 'list'.
|
|
|
|
|
EOF
|
|
|
|
|
;;
|
|
|
|
|
help) cat <<'EOF'
|
|
|
|
|
Print the general help, or the help for one command.
|
|
|
|
|
|
|
|
|
|
claudebox help
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
claudebox help rename
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
EOF
|
|
|
|
|
;;
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
*) field "$1" sum ;; # no prose: the table's summary is the help
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
esac
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
show_help() { # "" → general help
|
|
|
|
|
case "${1:-}" in
|
|
|
|
|
""|help) usage ;;
|
|
|
|
|
*) is_command "$1" || unknown_command "$1"; help_cmd "$1" ;;
|
|
|
|
|
esac
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 15:00:36 +00:00
|
|
|
cmd="${1:-help}"; shift || true
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
case "$cmd" in
|
|
|
|
|
-h|--help) usage; exit 0 ;;
|
|
|
|
|
-V|--version) version; exit 0 ;;
|
|
|
|
|
-*) usage_error "options come after the command — try 'claudebox <command> $cmd ...'" ;;
|
|
|
|
|
esac
|
|
|
|
|
|
2026-07-10 15:00:36 +00:00
|
|
|
args=()
|
|
|
|
|
while [ $# -gt 0 ]; do
|
|
|
|
|
case "$1" in
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
--name) [ $# -ge 2 ] || usage_error "--name needs a value"; name="$2"; shift 2 ;;
|
|
|
|
|
--from) [ $# -ge 2 ] || usage_error "--from needs a value"; from="$2"; shift 2 ;;
|
|
|
|
|
--remote) [ $# -ge 2 ] || usage_error "--remote needs a value"; remote="$2:"; shift 2 ;;
|
2026-07-10 15:00:36 +00:00
|
|
|
--vm) mode=vm; shift ;;
|
|
|
|
|
--container) mode=container; shift ;;
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
--force|-f) force=1; shift ;;
|
2026-07-13 20:26:44 +00:00
|
|
|
--json) json=1; shift ;;
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
--help|-h) want_help=1; shift ;;
|
|
|
|
|
--version|-V) version; exit 0 ;;
|
2026-07-10 15:00:36 +00:00
|
|
|
--) shift; args+=("$@"); break ;;
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
# An unrecognized flag used to be swallowed as a positional — so a typo'd
|
|
|
|
|
# --labl silently became a snapshot's label. Say so instead.
|
|
|
|
|
-*)
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
|
|
|
|
|
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
fi
|
|
|
|
|
usage_error "unknown option: $1 (see 'claudebox help $cmd')" ;;
|
2026-07-10 15:00:36 +00:00
|
|
|
*) args+=("$1"); shift ;;
|
|
|
|
|
esac
|
|
|
|
|
done
|
|
|
|
|
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
if [ "$want_help" -eq 1 ]; then show_help "$cmd"; exit 0; fi
|
|
|
|
|
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
# --- preconditions ---------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
iname_of() { echo "$remote$1"; } # instance name = box name
|
|
|
|
|
|
|
|
|
|
# The boundary, enforced: a box is an Incus instance WE tagged. Anything else is
|
|
|
|
|
# somebody's VM, and claudebox will not stop, rename or delete it by accident.
|
|
|
|
|
resolve_box() {
|
|
|
|
|
local box="$1" i tag
|
|
|
|
|
i="$(iname_of "$box")"
|
|
|
|
|
tag="$(incus config get "$i" user.claudebox 2>/dev/null || true)"
|
|
|
|
|
[ "$tag" = "1" ] || die "no such box: $box (see 'claudebox list')"
|
|
|
|
|
echo "$i"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
box_state() { incus list "$1" --format csv --columns s 2>/dev/null | head -n1; }
|
|
|
|
|
|
|
|
|
|
require_stopped() {
|
|
|
|
|
local i="$1" box="$2" st; st="$(box_state "$i")"
|
|
|
|
|
case "$st" in
|
|
|
|
|
STOPPED|Stopped|stopped) return 0 ;;
|
|
|
|
|
*) die "box '$box' is ${st:-not stopped} — Incus needs it stopped for this. Stop it: claudebox down $box" ;;
|
|
|
|
|
esac
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 15:00:36 +00:00
|
|
|
need_name() {
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
if [ "${#args[@]}" -lt 1 ] || [ -z "${args[0]}" ]; then
|
|
|
|
|
usage_error "usage: $(synopsis_of "$cmd")"
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
need_arg2() {
|
|
|
|
|
if [ "${#args[@]}" -lt 2 ] || [ -z "${args[1]}" ]; then
|
|
|
|
|
usage_error "usage: $(synopsis_of "$cmd")"
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse.
|
|
|
|
|
if [ "$force" -eq 1 ]; then return 0; fi
|
|
|
|
|
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)"
|
|
|
|
|
local reply
|
|
|
|
|
printf 'claudebox: %s? this cannot be undone. [y/N] ' "$1"
|
|
|
|
|
read -r reply
|
|
|
|
|
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
|
2026-07-10 15:00:36 +00:00
|
|
|
}
|
|
|
|
|
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
# --- commands with real work -----------------------------------------------
|
|
|
|
|
|
2026-07-10 15:00:36 +00:00
|
|
|
pick_mode() {
|
|
|
|
|
if [ "$mode" != auto ]; then echo "$mode"; return; fi
|
|
|
|
|
if [ -n "$remote" ] || [ -e /dev/kvm ]; then echo vm; else
|
|
|
|
|
echo "claudebox: no /dev/kvm — using container mode (weaker isolation, dev/test only)" >&2
|
|
|
|
|
echo container
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wait_agent() {
|
|
|
|
|
local n="$1" i
|
|
|
|
|
echo "claudebox: waiting for instance agent..."
|
|
|
|
|
for i in $(seq 1 90); do
|
|
|
|
|
if incus exec "$n" -- true >/dev/null 2>&1; then return; fi
|
|
|
|
|
[ "$i" -eq 90 ] && die "instance agent never came up (incus console $n to inspect)"
|
|
|
|
|
sleep 2
|
|
|
|
|
done
|
|
|
|
|
}
|
|
|
|
|
|
fix: a clone must not inherit its source's identity
Two boxes cloned from one snapshot came up holding the SAME IP address —
10.87.0.128, on the same dnsmasq lease, matching to the second on the
lease timer. Incus does regenerate the MAC (they differed), but
/etc/machine-id rides along inside the disk image, and systemd derives
its DHCP client identifier from it. Same client-id, same lease.
This breaks the workflow claudebox exists for: log in once, snapshot,
clone forever. Every clone of a snapshot is, to the network, the same
machine as its source and as its siblings.
Truncating /etc/machine-id makes systemd mint a fresh one at next boot,
so the clone path now resets it and restarts before handing the box
over. Costs one reboot; the alternative is colliding boxes.
Found by the drill on a live host (#26) — and it also explains why the
sibling-isolation probe (#15 A3) could never work: it was aiming box A
at box A's own address.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:31:32 +00:00
|
|
|
# A clone must not BE its source. Incus regenerates the MAC, but /etc/machine-id
|
|
|
|
|
# rides along inside the disk — and systemd derives its DHCP client identifier
|
|
|
|
|
# (DUID) from it. Same client-id, same dnsmasq lease: two boxes, one IP address,
|
|
|
|
|
# to the second on the lease timer. Every box cloned from one snapshot collided
|
|
|
|
|
# on the network, which is exactly the workflow claudebox exists for (log in
|
|
|
|
|
# once, snapshot, clone forever).
|
|
|
|
|
#
|
|
|
|
|
# Truncating /etc/machine-id makes systemd mint a fresh one on the next boot, so
|
|
|
|
|
# the reset costs one reboot. Do it before handing the box over, never after.
|
|
|
|
|
reset_identity() {
|
|
|
|
|
local i="$1"
|
|
|
|
|
echo "claudebox: giving the clone its own identity (machine-id, DHCP lease)..."
|
fix: the clone identity reset could never reboot, so it never took effect
The reset truncated /etc/machine-id and called 'incus restart'. But
systemd needs a VALID machine-id to shut down cleanly — so the graceful
stop hung, incus timed out ("Failed shutting down instance, status is
Running: context deadline exceeded"), and the reboot never happened. The
clone kept its source's machine-id, hence its DUID, hence its DHCP
lease: two boxes on one address, which is the exact bug the reset exists
to prevent. Worse, the duplicate address then broke the box's networking
outright ("box cannot reach the internet"), and poisoned the isolation
run downstream.
Write a fresh VALID id with systemd-machine-id-setup instead of emptying
the file — in a VM it derives from the DMI product UUID, which Incus
makes unique per instance. Then restart with a real timeout and a forced
fallback: a clone that keeps its source's lease is worse than an unclean
stop of a box that booted thirty seconds ago.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:03:21 +00:00
|
|
|
# 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.
|
fix: a clone must not inherit its source's identity
Two boxes cloned from one snapshot came up holding the SAME IP address —
10.87.0.128, on the same dnsmasq lease, matching to the second on the
lease timer. Incus does regenerate the MAC (they differed), but
/etc/machine-id rides along inside the disk image, and systemd derives
its DHCP client identifier from it. Same client-id, same lease.
This breaks the workflow claudebox exists for: log in once, snapshot,
clone forever. Every clone of a snapshot is, to the network, the same
machine as its source and as its siblings.
Truncating /etc/machine-id makes systemd mint a fresh one at next boot,
so the clone path now resets it and restarts before handing the box
over. Costs one reboot; the alternative is colliding boxes.
Found by the drill on a live host (#26) — and it also explains why the
sibling-isolation probe (#15 A3) could never work: it was aiming box A
at box A's own address.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:31:32 +00:00
|
|
|
incus exec "$i" -- sh -c '
|
fix: the clone identity reset could never reboot, so it never took effect
The reset truncated /etc/machine-id and called 'incus restart'. But
systemd needs a VALID machine-id to shut down cleanly — so the graceful
stop hung, incus timed out ("Failed shutting down instance, status is
Running: context deadline exceeded"), and the reboot never happened. The
clone kept its source's machine-id, hence its DUID, hence its DHCP
lease: two boxes on one address, which is the exact bug the reset exists
to prevent. Worse, the duplicate address then broke the box's networking
outright ("box cannot reach the internet"), and poisoned the isolation
run downstream.
Write a fresh VALID id with systemd-machine-id-setup instead of emptying
the file — in a VM it derives from the DMI product UUID, which Incus
makes unique per instance. Then restart with a real timeout and a forced
fallback: a clone that keeps its source's lease is worse than an unclean
stop of a box that booted thirty seconds ago.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:03:21 +00:00
|
|
|
rm -f /etc/machine-id /var/lib/dbus/machine-id
|
|
|
|
|
systemd-machine-id-setup >/dev/null 2>&1 || dbus-uuidgen > /etc/machine-id
|
fix: a clone must not inherit its source's identity
Two boxes cloned from one snapshot came up holding the SAME IP address —
10.87.0.128, on the same dnsmasq lease, matching to the second on the
lease timer. Incus does regenerate the MAC (they differed), but
/etc/machine-id rides along inside the disk image, and systemd derives
its DHCP client identifier from it. Same client-id, same lease.
This breaks the workflow claudebox exists for: log in once, snapshot,
clone forever. Every clone of a snapshot is, to the network, the same
machine as its source and as its siblings.
Truncating /etc/machine-id makes systemd mint a fresh one at next boot,
so the clone path now resets it and restarts before handing the box
over. Costs one reboot; the alternative is colliding boxes.
Found by the drill on a live host (#26) — and it also explains why the
sibling-isolation probe (#15 A3) could never work: it was aiming box A
at box A's own address.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:31:32 +00:00
|
|
|
ln -sf /etc/machine-id /var/lib/dbus/machine-id
|
fix: the clone identity reset could never reboot, so it never took effect
The reset truncated /etc/machine-id and called 'incus restart'. But
systemd needs a VALID machine-id to shut down cleanly — so the graceful
stop hung, incus timed out ("Failed shutting down instance, status is
Running: context deadline exceeded"), and the reboot never happened. The
clone kept its source's machine-id, hence its DUID, hence its DHCP
lease: two boxes on one address, which is the exact bug the reset exists
to prevent. Worse, the duplicate address then broke the box's networking
outright ("box cannot reach the internet"), and poisoned the isolation
run downstream.
Write a fresh VALID id with systemd-machine-id-setup instead of emptying
the file — in a VM it derives from the DMI product UUID, which Incus
makes unique per instance. Then restart with a real timeout and a forced
fallback: a clone that keeps its source's lease is worse than an unclean
stop of a box that booted thirty seconds ago.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:03:21 +00:00
|
|
|
test -s /etc/machine-id
|
fix: a clone must not inherit its source's identity
Two boxes cloned from one snapshot came up holding the SAME IP address —
10.87.0.128, on the same dnsmasq lease, matching to the second on the
lease timer. Incus does regenerate the MAC (they differed), but
/etc/machine-id rides along inside the disk image, and systemd derives
its DHCP client identifier from it. Same client-id, same lease.
This breaks the workflow claudebox exists for: log in once, snapshot,
clone forever. Every clone of a snapshot is, to the network, the same
machine as its source and as its siblings.
Truncating /etc/machine-id makes systemd mint a fresh one at next boot,
so the clone path now resets it and restarts before handing the box
over. Costs one reboot; the alternative is colliding boxes.
Found by the drill on a live host (#26) — and it also explains why the
sibling-isolation probe (#15 A3) could never work: it was aiming box A
at box A's own address.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:31:32 +00:00
|
|
|
' || die "could not reset the clone's machine-id"
|
fix: the clone identity reset could never reboot, so it never took effect
The reset truncated /etc/machine-id and called 'incus restart'. But
systemd needs a VALID machine-id to shut down cleanly — so the graceful
stop hung, incus timed out ("Failed shutting down instance, status is
Running: context deadline exceeded"), and the reboot never happened. The
clone kept its source's machine-id, hence its DUID, hence its DHCP
lease: two boxes on one address, which is the exact bug the reset exists
to prevent. Worse, the duplicate address then broke the box's networking
outright ("box cannot reach the internet"), and poisoned the isolation
run downstream.
Write a fresh VALID id with systemd-machine-id-setup instead of emptying
the file — in a VM it derives from the DMI product UUID, which Incus
makes unique per instance. Then restart with a real timeout and a forced
fallback: a clone that keeps its source's lease is worse than an unclean
stop of a box that booted thirty seconds ago.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:03:21 +00:00
|
|
|
# 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"
|
fix: a clone must not inherit its source's identity
Two boxes cloned from one snapshot came up holding the SAME IP address —
10.87.0.128, on the same dnsmasq lease, matching to the second on the
lease timer. Incus does regenerate the MAC (they differed), but
/etc/machine-id rides along inside the disk image, and systemd derives
its DHCP client identifier from it. Same client-id, same lease.
This breaks the workflow claudebox exists for: log in once, snapshot,
clone forever. Every clone of a snapshot is, to the network, the same
machine as its source and as its siblings.
Truncating /etc/machine-id makes systemd mint a fresh one at next boot,
so the clone path now resets it and restarts before handing the box
over. Costs one reboot; the alternative is colliding boxes.
Found by the drill on a live host (#26) — and it also explains why the
sibling-isolation probe (#15 A3) could never work: it was aiming box A
at box A's own address.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:31:32 +00:00
|
|
|
wait_agent "$i"
|
|
|
|
|
}
|
|
|
|
|
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
cmd_new() {
|
|
|
|
|
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
|
2026-07-10 15:00:36 +00:00
|
|
|
local instance; instance="$(iname_of "$name")"
|
|
|
|
|
if [ -n "$from" ]; then
|
|
|
|
|
local src="${from%%/*}" snap="" srcref
|
|
|
|
|
case "$from" in */*) snap="${from#*/}" ;; esac
|
|
|
|
|
srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
|
|
|
|
|
incus copy "$srcref" "$instance"
|
|
|
|
|
incus start "$instance"
|
|
|
|
|
wait_agent "$instance"
|
fix: a clone must not inherit its source's identity
Two boxes cloned from one snapshot came up holding the SAME IP address —
10.87.0.128, on the same dnsmasq lease, matching to the second on the
lease timer. Incus does regenerate the MAC (they differed), but
/etc/machine-id rides along inside the disk image, and systemd derives
its DHCP client identifier from it. Same client-id, same lease.
This breaks the workflow claudebox exists for: log in once, snapshot,
clone forever. Every clone of a snapshot is, to the network, the same
machine as its source and as its siblings.
Truncating /etc/machine-id makes systemd mint a fresh one at next boot,
so the clone path now resets it and restarts before handing the box
over. Costs one reboot; the alternative is colliding boxes.
Found by the drill on a live host (#26) — and it also explains why the
sibling-isolation probe (#15 A3) could never work: it was aiming box A
at box A's own address.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:31:32 +00:00
|
|
|
reset_identity "$instance"
|
2026-07-10 15:00:36 +00:00
|
|
|
echo "claudebox: cloned $srcref — isolation and Claude auth carry over from the source."
|
|
|
|
|
else
|
|
|
|
|
local m extra=(); m="$(pick_mode)"
|
|
|
|
|
# shellcheck disable=SC2054 # "root,size=60GiB" is a single incus argument
|
|
|
|
|
if [ "$m" = vm ]; then extra+=(--vm --device root,size=60GiB); else extra+=(--config security.nesting=true); fi
|
|
|
|
|
incus launch images:debian/13/cloud "$instance" --profile claude-dev \
|
2026-07-10 17:09:45 +00:00
|
|
|
--config user.claudebox=1 \
|
2026-07-10 15:00:36 +00:00
|
|
|
--config cloud-init.user-data="$(cat "$root/cloud-init/user-data.yaml")" \
|
|
|
|
|
"${extra[@]}"
|
|
|
|
|
wait_agent "$instance"
|
|
|
|
|
echo "claudebox: waiting for phase-1 (cloud-init)..."
|
fix: a failed cold mint must say why, and the doctor must find the cause
Two cold mints in a row died with cloud-init 'status: error' on a host
the doctor had just certified clean — so the earlier "leftover mutations
poisoned the network" theory is dead, and the DNS failure is
reproducible rather than transient.
'claudebox new' printed four hundred dots and the word "error", leaving
the user with nothing to act on: the reason was in the box's own log and
nobody was told the log existed. It now prints cloud-init's status, the
fetch/resolve errors from the box's log, and how to inspect the box —
which is left running, because a box that failed to build is evidence,
not garbage. It also names the usual culprit: the host's resolver.
doctor.sh gains the diagnosis that keeps being done by hand:
· what the HOST resolves through, and whether that is a CGNAT/Tailscale
resolver the boxes inherit (issue #33);
· whether claudenet's resolver is pinned;
· and inside a box, the question that settles it — DNS is broken, but
can it still reach 1.1.1.1 BY ADDRESS? If yes, egress is fine and the
fault is purely the inherited forwarder.
· --pin-dns applies the #33 fix (raw.dnsmasq: no-resolv + public
servers) so the hypothesis can be TESTED rather than argued.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:55:39 +00:00
|
|
|
# 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.
|
|
|
|
|
if ! incus exec "$instance" -- cloud-init status --wait; then
|
|
|
|
|
echo >&2
|
|
|
|
|
echo "claudebox: cloud-init FAILED in $name. What it says:" >&2
|
|
|
|
|
incus exec "$instance" -- cloud-init status --long 2>&1 | sed 's/^/ /' >&2
|
|
|
|
|
echo >&2
|
|
|
|
|
echo "claudebox: 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" \
|
|
|
|
|
2>/dev/null | sed 's/^/ /' >&2
|
|
|
|
|
echo >&2
|
|
|
|
|
echo "claudebox: '$name' is still up — inspect it, then delete it:" >&2
|
|
|
|
|
echo " claudebox incus $name -- exec {} -- tail -50 /var/log/cloud-init-output.log" >&2
|
|
|
|
|
echo " claudebox rm $name" >&2
|
|
|
|
|
echo "claudebox: a box that cannot resolve DNS is usually the HOST's resolver" >&2
|
|
|
|
|
echo " (a VPN/Tailscale resolver the box inherits — see issue #33)." >&2
|
|
|
|
|
die "cloud-init failed — the box is incomplete, so refusing to hand it over"
|
|
|
|
|
fi
|
2026-07-10 15:00:36 +00:00
|
|
|
fi
|
|
|
|
|
echo "claudebox: ready — 'claudebox shell $name'. Log into Claude inside: run 'claude' then /login."
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 20:26:44 +00:00
|
|
|
# Boxes are ordinary Incus instances tagged user.claudebox=1 — that tag is the
|
|
|
|
|
# only thing that makes them ours, so every read below is filtered by it and we
|
|
|
|
|
# never report on (or touch) an instance claudebox didn't mint.
|
|
|
|
|
# Emits: name,state,type,snapshot-count — none of which can contain a comma or a
|
|
|
|
|
# newline, so a plain -F, split is safe. (IPv4 can: a box running docker has
|
|
|
|
|
# several addresses and Incus quotes them across lines. It's fetched separately.)
|
|
|
|
|
boxes_csv() {
|
|
|
|
|
incus list ${remote:+"$remote"} "user.claudebox=1" --format csv --columns nstS
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
box_ipv4() { # first address only; strips Incus's " (iface)" suffix. "-" if none.
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
incus list "$1" --format csv --columns 4 2>/dev/null \
|
2026-07-13 20:26:44 +00:00
|
|
|
| tr -d '"' | sed 's/ (.*//' | grep -v '^[[:space:]]*$' | head -n1 \
|
|
|
|
|
| grep . || echo "-"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# VIRTUAL-MACHINE is a mouthful in a table; anything unexpected passes through.
|
|
|
|
|
short_type() {
|
|
|
|
|
case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
|
|
|
|
|
virtual-machine|virtualmachine) echo VM ;;
|
|
|
|
|
container) echo CT ;;
|
|
|
|
|
*) echo "$1" ;;
|
|
|
|
|
esac
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
list_all() {
|
|
|
|
|
local rows; rows="$(boxes_csv)"
|
|
|
|
|
if [ -z "$rows" ]; then
|
|
|
|
|
echo "claudebox: no boxes yet — create one with: claudebox new --name work" >&2
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
{
|
|
|
|
|
echo "NAME,STATE,TYPE,SNAPSHOTS"
|
|
|
|
|
while IFS=, read -r n s t snaps; do
|
|
|
|
|
[ -n "$n" ] || continue
|
|
|
|
|
echo "$n,${s:--},$(short_type "$t"),${snaps:-0}"
|
|
|
|
|
done <<<"$rows"
|
|
|
|
|
} | awk -F, '
|
|
|
|
|
{ for (i = 1; i <= NF; i++) { cell[NR, i] = $i; if (length($i) > w[i]) w[i] = length($i) } n = NR }
|
|
|
|
|
END { for (r = 1; r <= n; r++) { line = ""
|
|
|
|
|
for (i = 1; i <= 4; i++) line = line sprintf("%-*s ", w[i], cell[r, i])
|
|
|
|
|
sub(/ +$/, "", line); print line } }'
|
|
|
|
|
}
|
|
|
|
|
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
# 'list' lists them all; 'info' shows one. A box name handed to 'list' is a wrong
|
|
|
|
|
# guess we can answer, not a surprise: point at the command that does want one.
|
|
|
|
|
cmd_list() {
|
|
|
|
|
if [ "${#args[@]}" -ge 1 ] && [ -n "${args[0]}" ]; then
|
|
|
|
|
die "list takes no box — for one box, use: claudebox info ${args[0]}"
|
|
|
|
|
fi
|
|
|
|
|
if [ "$json" -eq 1 ]; then
|
|
|
|
|
incus list ${remote:+"$remote"} "user.claudebox=1" --format json
|
|
|
|
|
else
|
|
|
|
|
list_all
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cmd_info() {
|
|
|
|
|
local box="${args[0]}" row
|
|
|
|
|
if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi
|
|
|
|
|
|
2026-07-13 20:26:44 +00:00
|
|
|
row="$(boxes_csv | awk -F, -v b="$box" '$1 == b { print; exit }')"
|
|
|
|
|
[ -n "$row" ] || die "no such box: $box (see 'claudebox list')"
|
|
|
|
|
|
|
|
|
|
local state type snaps
|
|
|
|
|
IFS=, read -r _ state type snaps <<<"$row"
|
|
|
|
|
printf '%-11s%s\n' NAME "$box" STATE "${state:--}" TYPE "$(short_type "$type")" \
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
IPV4 "$(box_ipv4 "$inst")"
|
2026-07-13 20:26:44 +00:00
|
|
|
|
|
|
|
|
echo
|
|
|
|
|
case "${snaps:-0}" in
|
|
|
|
|
''|0)
|
|
|
|
|
echo "SNAPSHOTS (none)"
|
|
|
|
|
echo
|
|
|
|
|
echo "Take one: claudebox snapshot $box authed"
|
|
|
|
|
return 0 ;;
|
|
|
|
|
esac
|
|
|
|
|
echo "SNAPSHOTS"
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
local first="" sname taken
|
2026-07-13 20:26:44 +00:00
|
|
|
while IFS=, read -r sname taken _; do
|
|
|
|
|
[ -n "$sname" ] || continue
|
|
|
|
|
[ -n "$first" ] || first="$sname"
|
|
|
|
|
printf ' %-14s%s\n' "$sname" "$taken"
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
done < <(incus snapshot list "$inst" --format csv 2>/dev/null)
|
2026-07-13 20:26:44 +00:00
|
|
|
echo
|
|
|
|
|
echo "Clone one: claudebox new --name <new> --from $box/${first:-<snapshot>}"
|
|
|
|
|
}
|
|
|
|
|
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
cmd_shell() { incus exec "$inst" -- sudo -u claude -i; }
|
|
|
|
|
cmd_exec() { incus exec "$inst" -- sudo -u claude -i "${args[@]:1}"; }
|
|
|
|
|
|
|
|
|
|
cmd_snapshot() {
|
|
|
|
|
local label="${args[1]:-manual-$(date +%s)}"
|
|
|
|
|
incus snapshot create "$inst" "$label"
|
|
|
|
|
echo "$label"
|
2026-07-13 20:26:44 +00:00
|
|
|
}
|
|
|
|
|
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: 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 workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
cmd_status() {
|
|
|
|
|
echo "claudebox: 'status' is deprecated — use 'claudebox list'." >&2
|
|
|
|
|
list_all
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cmd_help() { show_help "${args[0]:-}"; }
|
|
|
|
|
|
|
|
|
|
# The escape hatch. The box is resolved and tag-checked; everything else is
|
|
|
|
|
# yours. {} is the instance name; without it, the instance goes last.
|
|
|
|
|
warn_isolation() {
|
|
|
|
|
case " $* " in
|
|
|
|
|
*" profile "*|*" network "*|*" device "*|*security.*|*" nic "*)
|
|
|
|
|
echo "claudebox: warning: this can move the box off the isolation stack" >&2
|
|
|
|
|
echo "claudebox: (profile / network / device / security.*). The trust boundary is yours from here." >&2 ;;
|
|
|
|
|
esac
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cmd_incus() {
|
|
|
|
|
local rest=("${args[@]:1}") out=() a replaced=0
|
|
|
|
|
[ "${#rest[@]}" -gt 0 ] || usage_error "usage: $(synopsis_of incus)"
|
|
|
|
|
for a in "${rest[@]}"; do
|
|
|
|
|
case "$a" in
|
|
|
|
|
*"{}"*) out+=("${a//\{\}/$inst}"); replaced=1 ;;
|
|
|
|
|
*) out+=("$a") ;;
|
|
|
|
|
esac
|
|
|
|
|
done
|
|
|
|
|
[ "$replaced" -eq 1 ] || out+=("$inst")
|
|
|
|
|
warn_isolation "${out[@]}"
|
|
|
|
|
echo "claudebox: incus ${out[*]}" >&2 # no magic: show what runs
|
|
|
|
|
incus "${out[@]}"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# --- dispatch: driven by the table, not by a hand-written case --------------
|
|
|
|
|
|
|
|
|
|
row="$(cmd_row "$cmd")" || unknown_command "$cmd"
|
|
|
|
|
IFS='^' read -r _ _ pre _ action ok <<<"$row"
|
|
|
|
|
|
|
|
|
|
case ",$pre," in *,box,*) need_name; inst="$(resolve_box "${args[0]}")" ;; esac
|
|
|
|
|
case ",$pre," in *,arg2,*) need_arg2 ;; esac
|
|
|
|
|
case ",$pre," in *,stopped,*) require_stopped "$inst" "${args[0]}" ;; esac
|
|
|
|
|
case ",$pre," in *,confirm,*) confirm "delete $inst and all its snapshots" ;; esac
|
|
|
|
|
|
|
|
|
|
case "$action" in
|
|
|
|
|
fn:*)
|
|
|
|
|
"${action#fn:}"
|
|
|
|
|
;;
|
|
|
|
|
incus:*)
|
|
|
|
|
sub="${action#incus:}"
|
|
|
|
|
# word-split intentionally: a subcommand may carry a flag ("delete -f")
|
|
|
|
|
# shellcheck disable=SC2086
|
|
|
|
|
incus $sub "$inst" "${args[@]:1}"
|
|
|
|
|
if [ -n "$ok" ]; then
|
|
|
|
|
msg="${ok//\{\}/${args[0]}}"; msg="${msg//\{1\}/${args[1]:-}}"
|
|
|
|
|
echo "claudebox: $msg"
|
|
|
|
|
fi
|
|
|
|
|
;;
|
2026-07-10 15:00:36 +00:00
|
|
|
esac
|