From 5ce2a0ae8d86b18abdb4b7ece7a8152531844d1f Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Mon, 13 Jul 2026 20:26:44 +0000 Subject: [PATCH 1/2] feat: add `claudebox list` and `claudebox info` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `status` was the only lister: misnamed, silently ignoring the `` its own help advertised, and unable to show a snapshot label — the one thing the README's snapshot→clone flow requires you to know. Typing the obvious `claudebox list` printed the help and exited 0. - `list` — a table of your boxes (name, state, type, snapshot count). - `info ` — detail, the snapshot labels, and the `--from` line to clone one. - `list ` is a wrong guess we can answer: it points at `info`. - `--json` passthrough; `--remote` honored; a real error on an unknown box. - `status` stays as a deprecated alias for `list` so muscle memory keeps working. Reads are filtered by the `user.claudebox=1` tag, so an Incus instance claudebox didn't mint is never reported on or touched. Closes #7 --- README.md | 7 +++- bin/claudebox | 102 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1b2bb25..49cb4ac 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,15 @@ claudebox new --name feature --from work/authed # clone the authed state into preserving isolation. You can also `claudebox new --name x --from work` to clone a box's live state, or roll a box back with `claudebox restore work authed`. +Forgotten what you called a checkpoint? `claudebox info work` prints the box's +snapshot labels and the `--from` line to clone one. + ## Commands ``` claudebox new --name [--from [/]] [--vm|--container] [--remote r] +claudebox list # list your boxes +claudebox info # one box: state, IP, snapshot labels claudebox shell # enter as the claude user claudebox exec -- # run a command in the box claudebox snapshot [label] # checkpoint (label defaults to manual-) @@ -80,7 +85,7 @@ claudebox restore # roll back to a snapshot claudebox down # stop (state kept; `start` resumes) claudebox start # start a stopped box claudebox rm # delete the box (irreversible; snapshot first) -claudebox status # list boxes +claudebox status # deprecated alias for `list` ``` `new` fresh-launches from cloud-init, or with `--from` clones an existing box or diff --git a/bin/claudebox b/bin/claudebox index 47c2d3b..43714a8 100755 --- a/bin/claudebox +++ b/bin/claudebox @@ -1,7 +1,9 @@ #!/usr/bin/env bash # claudebox — trust-less, isolated Incus VMs with Claude Code, creds-free. # claudebox new --name [--from [/]] [--remote r] [--vm|--container] -# claudebox shell|down|start|status|rm +# claudebox list [--json] — all your boxes +# claudebox info — one box: state, IP, snapshot labels +# claudebox shell|down|start|rm # claudebox exec -- # claudebox snapshot [label] # claudebox restore @@ -10,7 +12,7 @@ set -euo pipefail root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)" -remote=""; mode="auto"; name=""; from=""; force=0 +remote=""; mode="auto"; name=""; from=""; force=0; json=0 cmd="${1:-help}"; shift || true args=() while [ $# -gt 0 ]; do @@ -21,6 +23,7 @@ while [ $# -gt 0 ]; do --vm) mode=vm; shift ;; --container) mode=container; shift ;; --force) force=1; shift ;; + --json) json=1; shift ;; --) shift; args+=("$@"); break ;; *) args+=("$1"); shift ;; esac @@ -76,6 +79,94 @@ new() { echo "claudebox: ready — 'claudebox shell $name'. Log into Claude inside: run 'claude' then /login." } +# Boxes are ordinary Incus instances tagged user.claudebox=1 — that tag is the +# only thing that makes them ours, so every read below is filtered by it and we +# never report on (or touch) an instance claudebox didn't mint. +# Emits: name,state,type,snapshot-count — none of which can contain a comma or a +# newline, so a plain -F, split is safe. (IPv4 can: a box running docker has +# several addresses and Incus quotes them across lines. It's fetched separately.) +boxes_csv() { + incus list ${remote:+"$remote"} "user.claudebox=1" --format csv --columns nstS +} + +box_ipv4() { # first address only; strips Incus's " (iface)" suffix. "-" if none. + incus list "$(iname_of "$1")" --format csv --columns 4 2>/dev/null \ + | tr -d '"' | sed 's/ (.*//' | grep -v '^[[:space:]]*$' | head -n1 \ + | grep . || echo "-" +} + +# VIRTUAL-MACHINE is a mouthful in a table; anything unexpected passes through. +short_type() { + case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in + virtual-machine|virtualmachine) echo VM ;; + container) echo CT ;; + *) echo "$1" ;; + esac +} + +list_all() { + local rows; rows="$(boxes_csv)" + if [ -z "$rows" ]; then + echo "claudebox: no boxes yet — create one with: claudebox new --name work" >&2 + return 0 + fi + { + echo "NAME,STATE,TYPE,SNAPSHOTS" + while IFS=, read -r n s t snaps; do + [ -n "$n" ] || continue + echo "$n,${s:--},$(short_type "$t"),${snaps:-0}" + done <<<"$rows" + } | awk -F, ' + { for (i = 1; i <= NF; i++) { cell[NR, i] = $i; if (length($i) > w[i]) w[i] = length($i) } n = NR } + END { for (r = 1; r <= n; r++) { line = "" + for (i = 1; i <= 4; i++) line = line sprintf("%-*s ", w[i], cell[r, i]) + sub(/ +$/, "", line); print line } }' +} + +info() { + local box="$1" row + # Same tagged set as list_all: an untagged instance is not a box, it's someone + # else's VM, and we say "no such box" rather than reaching into it. + row="$(boxes_csv | awk -F, -v b="$box" '$1 == b { print; exit }')" + [ -n "$row" ] || die "no such box: $box (see 'claudebox list')" + + local state type snaps + IFS=, read -r _ state type snaps <<<"$row" + printf '%-11s%s\n' NAME "$box" STATE "${state:--}" TYPE "$(short_type "$type")" \ + IPV4 "$(box_ipv4 "$box")" + + echo + case "${snaps:-0}" in + ''|0) + echo "SNAPSHOTS (none)" + echo + echo "Take one: claudebox snapshot $box authed" + return 0 ;; + esac + echo "SNAPSHOTS" + local first="" + while IFS=, read -r sname taken _; do + [ -n "$sname" ] || continue + [ -n "$first" ] || first="$sname" + printf ' %-14s%s\n' "$sname" "$taken" + done < <(incus snapshot list "$(iname_of "$box")" --format csv 2>/dev/null) + echo + echo "Clone one: claudebox new --name --from $box/${first:-}" +} + +# '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. +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 +} + case "$cmd" in new) new ;; snapshot) need_name; label="${args[1]:-manual-$(date +%s)}"; incus snapshot create "$(iname_of "${args[0]}")" "$label"; echo "$label" ;; @@ -85,6 +176,9 @@ case "$cmd" in down) need_name; incus stop "$(iname_of "${args[0]}")" ;; start) need_name; incus start "$(iname_of "${args[0]}")" ;; rm) need_name; incus delete -f "$(iname_of "${args[0]}")"; echo "claudebox: removed $(iname_of "${args[0]}")" ;; - status) incus list ${remote:+"$remote"} "user.claudebox=1" ;; - help|*) sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//' ;; + list) list ;; + info) need_name + if [ "$json" -eq 1 ]; then incus list "$(iname_of "${args[0]}")" --format json; else info "${args[0]}"; fi ;; + status) echo "claudebox: 'status' is deprecated — use 'claudebox list'." >&2; list_all ;; + help|*) sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//' ;; esac -- 2.45.2 From 8636d1abcbaec27dec243f10d6bf37afa878d268 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Mon, 13 Jul 2026 20:31:01 +0000 Subject: [PATCH 2/2] fix: standard help, honest flags, and an `rm` that asks first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` and `claudebox --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 `-- `). - shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone. Closes #8 --- README.md | 7 +- VERSION | 1 + bin/claudebox | 301 +++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 290 insertions(+), 19 deletions(-) create mode 100644 VERSION diff --git a/README.md b/README.md index 49cb4ac..6fee60e 100644 --- a/README.md +++ b/README.md @@ -84,10 +84,15 @@ claudebox snapshot [label] # checkpoint (label defaults to manual- # roll back to a snapshot claudebox down # stop (state kept; `start` resumes) claudebox start # start a stopped box -claudebox rm # delete the box (irreversible; snapshot first) +claudebox rm [--force] # delete the box + its snapshots (asks first) claudebox status # deprecated alias for `list` +claudebox help [] # full help, or one command's page ``` +Every command takes `--help`, and options come after the command +(`claudebox list --json`). Exit status: `0` ok, `1` it went wrong, `2` you asked +wrong. + `new` fresh-launches from cloud-init, or with `--from` clones an existing box or snapshot. VM mode (`--vm`, the default where `/dev/kvm` exists) is the trust-less target; container mode (auto-fallback, `security.nesting=true`) is for hosts diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.2.0 diff --git a/bin/claudebox b/bin/claudebox index 43714a8..1e1017d 100755 --- a/bin/claudebox +++ b/bin/claudebox @@ -1,38 +1,298 @@ #!/usr/bin/env bash # claudebox — trust-less, isolated Incus VMs with Claude Code, creds-free. -# claudebox new --name [--from [/]] [--remote r] [--vm|--container] -# claudebox list [--json] — all your boxes -# claudebox info — one box: state, IP, snapshot labels -# claudebox shell|down|start|rm -# claudebox exec -- -# claudebox snapshot [label] -# claudebox restore -# Boxes carry NO secrets: log into Claude interactively inside ('claude' then -# /login). Reuse an authenticated box via 'snapshot' + 'new --from'. +# The help text lives in usage()/help_cmd(), not in this comment. set -euo pipefail root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)" -remote=""; mode="auto"; name=""; from=""; force=0; json=0 +remote=""; mode="auto"; name=""; from=""; force=0; json=0; want_help=0 + +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)"; } + +COMMANDS="new list info shell exec snapshot restore down start rm status help" +is_command() { case " $COMMANDS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } + +synopsis_of() { + case "$1" in + new) echo "claudebox new --name [--from [/]] [--vm|--container]" ;; + list) echo "claudebox list [--json]" ;; + info) echo "claudebox info [--json]" ;; + shell) echo "claudebox shell " ;; + exec) echo "claudebox exec -- " ;; + snapshot) echo "claudebox snapshot [