feat: host lifecycle as verbs (setup-host/teardown-host/migrate-host) + .box/ convention

Two things:

1. The host scripts are first-class verbs now — 'box setup-host',
   'box teardown-host [--purge-incus]', 'box migrate-host --box <n>'.
   Nobody should have to run ~/.local/share/box/host/<script>.sh; that
   read like an external script and exposed an install path. Each verb
   execs the installed script with its flags passed through (same
   pattern as 'box doctor'). README, doctor hints, and the uninstall
   section point at the verbs now.

2. The repo-runbook convention is '.box/', not '.claudebox/'. Renamed
   across docs and the templates' agent briefing; the briefing tells
   the agent to read either, and box-recipe.md notes the rename, so
   repos still shipping '.claudebox/' keep working through the
   transition. (Consuming repos rename their own folder — tracked
   separately.)

Also widened the help command column for the longer verb names, and
fixed one sed-casualty where a broad '.claudebox'→'.box' pass had
turned the README's legacy user.claudebox tag into user.box.
This commit is contained in:
claude-hdb 2026-07-14 18:01:34 +00:00
parent 4eb6b35a7b
commit 455fbc656e
8 changed files with 90 additions and 36 deletions

View file

@ -18,7 +18,7 @@ live in a shared profile no template can touch, so `blank` is a box with
nobody home — not a box with the safety off.
**The tool knows nothing about your projects.** You just `git clone` inside a
box. A repo can ship an optional [`.claudebox/`](docs/box-recipe.md)
box. A repo can ship an optional [`.box/`](docs/box-recipe.md)
runbook that Claude Code reads and acts on — there is no `install` step and no
host-run setup. See [docs/box-design.md](docs/box-design.md) for the
design rationale.
@ -44,7 +44,7 @@ retires the old `claudebox` symlink. (No `git clone` needed.)
## One-time host setup (Ubuntu 24.04 / Debian 13)
```sh
~/.local/share/box/host/setup-host.sh # run twice if it adds you to incus-admin (re-login between)
box setup-host # run twice if it adds you to incus-admin (re-login between)
```
Idempotent. Installs Incus and creates the isolation stack: the `boxnet` NAT
@ -70,7 +70,7 @@ claude # then run /login — copy the URL (press c), o
# in YOUR browser, paste the code back. No host CLI needed.
gh auth login # or drop a PAT in — your git credentials, your call
git clone https://github.com/you/project && cd project
claude # if the repo has .claudebox/, Claude reads it and sets up
claude # if the repo has .box/, Claude reads it and sets up
```
## Templates
@ -158,7 +158,8 @@ dev/test only.
## Boxes are just Incus instances
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
carry `user.claudebox=1`, honored forever). box wraps the box lifecycle and the isolation model — not all of Incus. It owns a command
carry `user.claudebox=1`, honored forever). box wraps the box lifecycle and
the isolation model — not all of Incus. It owns a command
when it must enforce something Incus can't see: that tag (it will not stop,
rename or delete an instance it didn't mint), the isolation stack, or the
creds-free snapshot workflow. For everything else, there's the door:
@ -227,9 +228,9 @@ diagnoses the host faults that have actually happened: a wedged Incus daemon,
a dnsmasq that silently isn't serving, a VPN resolver that boxes would
inherit.
## Recipes: the `.claudebox/` convention
## Recipes: the `.box/` convention
A repo that wants to be easy to stand up in a box ships an optional `.claudebox/`
A repo that wants to be easy to stand up in a box ships an optional `.box/`
folder — a runbook Claude reads and follows (install deps, start services,
template env, seed data, smoke-test). It is agent-facing documentation, not a
host-executed script. See [docs/box-recipe.md](docs/box-recipe.md).
@ -237,9 +238,9 @@ host-executed script. See [docs/box-recipe.md](docs/box-recipe.md).
## Uninstall
```sh
~/.local/share/box/host/teardown-host.sh # boxes, network, ACL, profile, firewall
~/.local/share/box/host/teardown-host.sh --purge-incus # ...and Incus itself
rm -rf ~/.local/share/box ~/.local/bin/box # the CLI
box teardown-host # boxes, network, ACL, profile, firewall
box teardown-host --purge-incus # ...and Incus itself
rm -rf ~/.local/share/box ~/.local/bin/box # the CLI itself
```
## Non-goals

51
bin/box
View file

@ -53,6 +53,9 @@ CMDS=(
"expose^<box> <port> [<host-port>] | --list | --remove <port>^box^Forward a box port to the host's loopback — see a dev server^fn:cmd_expose^"
"incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^"
"doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^"
"setup-host^^^One-time host setup: Incus, the boxnet stack, the profile, the firewall^fn:cmd_setup_host^"
"teardown-host^[--purge-incus]^^Remove the box host stack (both name generations)^fn:cmd_teardown_host^"
"migrate-host^--box <n> | --all-boxes | --retire-legacy^^Move a host from the pre-0.4.0 stack onto box^fn:cmd_migrate_host^"
"status^^^Deprecated alias for 'list'^fn:cmd_status^"
"help^[<command>]^^This help, or 'box help <command>' for one command^fn:cmd_help^"
)
@ -119,7 +122,7 @@ EOF
local r v sum
for r in "${CMDS[@]}"; do
IFS='^' read -r v _ _ sum _ _ <<<"$r"
printf ' %-9s %s\n' "$v" "$sum"
printf ' %-13s %s\n' "$v" "$sum"
done
cat <<'EOF'
@ -371,6 +374,37 @@ cloud-init error that names none of them.
Exit 0 = clean; 1 = problems found (each printed with its fix). Read-only
unless --fix or --pin-dns is given.
EOF
;;
setup-host) cat <<'EOF'
Prepare this host to mint boxes — one time. Installs Incus and builds the
isolation stack: the boxnet NAT bridge (resolver pinned), the box-isolate
ACL, the box-net profile, and the firewall rules, all re-applied at boot.
Idempotent — safe to re-run after a box upgrade to pick up stack changes.
If it has to add you to the incus-admin group it will say so and exit; log
back in (or 'sg incus-admin') and run it again.
box setup-host
EOF
;;
teardown-host) cat <<'EOF'
Remove the box host stack — all boxes, the boxnet/claudenet networks, the
ACLs, the profiles, and the firewall rules of BOTH name generations (current
and pre-0.4.0). Asks first.
box teardown-host # the stack; leaves Incus installed
box teardown-host --purge-incus # ...and uninstall Incus too
EOF
;;
migrate-host) cat <<'EOF'
Move a host from the pre-0.4.0 'claudebox' stack onto 'box'. Re-homing
preserves a box's authed state (no re-login) — it only re-tags and reassigns
the profile, then verifies the box works on its new network leg.
box migrate-host --box <name> # re-home one legacy box
box migrate-host --all-boxes # re-home every legacy box
box migrate-host --retire-legacy # remove the old stack (once no legacy box remains)
EOF
;;
status) cat <<'EOF'
Deprecated alias for 'box list'. It ignored the <box> argument it
@ -424,6 +458,8 @@ while [ $# -gt 0 ]; do
if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi
# expose's own flags (--list, --remove) are positional to it, not box's
if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi
# the host verbs delegate their flags to the scripts they wrap
case "$cmd" in setup-host|teardown-host|migrate-host) args+=("$1"); shift; continue ;; esac
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
fi
@ -843,6 +879,19 @@ cmd_doctor() {
exec bash "$script" "${args[@]}"
}
# The host lifecycle scripts, as first-class verbs — nobody should have to know
# where the install tree keeps its scripts. Each execs the installed script
# with its flags passed through; the script owns its own behavior (setup-host's
# incus-admin re-login dance, teardown's confirmation, migrate's per-box work).
host_script() { # $1 = script basename under host/
local script="$root/host/$1"
[ -f "$script" ] || die "$1 not found at $script — re-run install.sh"
exec bash "$script" "${args[@]}"
}
cmd_setup_host() { host_script setup-host.sh; }
cmd_teardown_host() { host_script teardown-host.sh; }
cmd_migrate_host() { host_script migrate-host.sh; }
cmd_help() { show_help "${args[0]:-}"; }
# The escape hatch. The box is resolved and tag-checked; everything else is

View file

@ -10,7 +10,7 @@ design decisions.
- **The tool** mints isolated boxes with Claude installed but **unauthenticated**.
It knows nothing about projects, secrets, recipes, or memory.
- **The agent** (Claude Code, inside the box) reads an optional `.claudebox/`
- **The agent** (Claude Code, inside the box) reads an optional `.box/`
runbook in a cloned repo and acts on it. The recipe's consumer is the
reasoning agent, not host machinery.
@ -45,10 +45,10 @@ Log in once → snapshot → spin up authed boxes from it.
cloud-init installs a global `~/.claude/CLAUDE.md` in every box telling Claude it
is running in a box (trust-less, ephemeral, creds-free) and to treat a
repo's `.claudebox/` folder as its bootstrap runbook. No "tell it" step, no host
repo's `.box/` folder as its bootstrap runbook. No "tell it" step, no host
execution.
## `.claudebox/` is optional, agent-facing documentation
## `.box/` is optional, agent-facing documentation
Not host-executed shell. A repo that wants to be easy to stand up in a sandbox
ships a runbook (prose + optional scripts the agent may run). A repo that does
@ -56,12 +56,12 @@ not, you set up by hand. The tool enforces no contract; there is no `install`.
## What box owns, and what it doesn't
Boxes are ordinary Incus instances, tagged `user.claudebox=1`. That makes every
Boxes are ordinary Incus instances, tagged `user.box=1`. That makes every
Incus verb a candidate feature request — `rename`, `info`, `file push`, on
forever — and wrapping them one at a time grows a worse `incus`. The rule:
> **box owns a command when it must enforce an invariant Incus cannot see:**
> the `user.claudebox=1` boundary (never touch an instance we didn't mint), the
> the `user.box=1` boundary (never touch an instance we didn't mint), the
> isolation stack (`claude-dev` profile + `claudenet` + ACL), or the creds-free
> snapshot→clone workflow. Everything else is Incus's job.

View file

@ -1,4 +1,4 @@
# The `.claudebox/` convention
# The `.box/` convention
`box` mints trust-less, creds-free, isolated VMs with Claude Code already
installed (`box new/shell/snapshot/restore/exec/down/start/rm/status`). The
@ -6,13 +6,17 @@ tool knows **nothing** about your project. There is no `install` step and no
host-run setup script.
A project makes itself easy to stand up inside a box by shipping an optional
`.claudebox/` folder. This folder is **agent-facing documentation** — read and
`.box/` folder. This folder is **agent-facing documentation** — read and
acted on by Claude Code (the reasoning agent) running inside the box. It is not
shell that the host executes.
> The folder was named `.claudebox/` before the 0.5.0 rename. Repos that still
> ship `.claudebox/` keep working — the agent is told to read either — but new
> projects should use `.box/`.
## What it is / what it is not
- **Optional.** No `.claudebox/` is a perfectly valid state.
- **Optional.** No `.box/` is a perfectly valid state.
- **Agent-facing.** You are writing instructions to a reasoning agent, not a
machine. Prose is fine; the agent adapts.
- **Not a host contract.** The host never parses, sources, or runs anything in
@ -23,34 +27,34 @@ shell that the host executes.
## How it's consumed
Every box ships a global `~/.claude/CLAUDE.md` telling Claude it is inside a
box and to treat a repo's `.claudebox/` folder as its bootstrap runbook.
box and to treat a repo's `.box/` folder as its bootstrap runbook.
So the whole flow is:
```
box new # get a box
box shell # get in
git clone <repo> && cd <repo>
claude # Claude reads .claudebox/ and brings the project up
claude # Claude reads .box/ and brings the project up
```
The operator can also just say: *"set this project up per .claudebox"*.
The operator can also just say: *"set this project up per .box"*.
## Suggested contents (all optional)
Author everything here for a reasoning agent.
- **`.claudebox/SETUP.md`** — the prose runbook. Prerequisites, how to install
- **`.box/SETUP.md`** — the prose runbook. Prerequisites, how to install
deps, how to start services, how to template the env, how to seed data, and
how to smoke-test. Written as instructions to Claude.
- **Helper scripts** (e.g. `.claudebox/dev-up.sh`) that the runbook tells Claude
- **Helper scripts** (e.g. `.box/dev-up.sh`) that the runbook tells Claude
to run. Claude decides to run them; the host never does.
- **`.claudebox/env.template`** — example env the runbook explains how to fill.
- **`.box/env.template`** — example env the runbook explains how to fill.
Staging values the operator pastes in. **Never commit real secrets.**
- **`.claudebox/compose.yml`** — optional services the runbook starts.
- **`.box/compose.yml`** — optional services the runbook starts.
## Worked example
A minimal `.claudebox/SETUP.md` for a Node + Postgres app:
A minimal `.box/SETUP.md` for a Node + Postgres app:
```markdown
# Setup
@ -58,8 +62,8 @@ A minimal `.claudebox/SETUP.md` for a Node + Postgres app:
This is a Node service backed by Postgres.
1. Install deps: `npm ci`
2. Start Postgres: `docker compose -f .claudebox/compose.yml up -d`
3. Create the env file: copy `.claudebox/env.template` to `.env` and ask the
2. Start Postgres: `docker compose -f .box/compose.yml up -d`
3. Create the env file: copy `.box/env.template` to `.env` and ask the
operator to fill in `DATABASE_URL` and `API_KEY` (staging values).
4. Run migrations: `npm run migrate`
5. Start the app: `npm run dev`
@ -72,7 +76,7 @@ That's it — Claude reads it top to bottom and adapts if reality differs.
- **Keep it declarative and resilient.** State intent and steps; let the agent
adapt when the repo has drifted. Don't hard-code brittle assumptions.
- **Never put real credentials in `.claudebox/`.** Templates and staging
- **Never put real credentials in `.box/`.** Templates and staging
placeholders only. The operator pastes real values at runtime.
- **No `.claudebox/` is fine.** The operator can stand the project up by hand,
- **No `.box/` is fine.** The operator can stand the project up by hand,
or let Claude infer the steps from the repo's `README` / `CLAUDE.md`.

View file

@ -49,7 +49,7 @@ if incus network show boxnet >/dev/null 2>&1; then
ok "dns.mode = none — a box cannot enumerate its siblings by name"
else
no "dns.mode = ${dns:-<unset>} — a box can RESOLVE its siblings' names and addresses"
inf "fix: re-run ~/.local/share/box/host/setup-host.sh"
inf "fix: re-run: box setup-host"
[ "$FIX" = 1 ] && { incus network set boxnet dns.mode=none && inf "set: dns.mode=none"; }
fi
inf "ipv4.address = $(incus network get boxnet ipv4.address 2>/dev/null)"
@ -110,7 +110,7 @@ if [ -n "$PROFILES" ]; then
no "$p: security.port_isolation is NOT set — BOXES CAN REACH EACH OTHER"
inf "an L3 ACL cannot do this: two boxes on one bridge are on the same L2"
inf "segment, so their frames are switched, never routed past the ACL."
inf "fix: re-run ~/.local/share/box/host/setup-host.sh"
inf "fix: re-run: box setup-host"
fi
for k in security.mac_filtering security.ipv4_filtering; do
v="$(incus profile device get "$p" eth0 "$k" 2>/dev/null)"
@ -192,7 +192,7 @@ else
no "the host resolves via a CGNAT/Tailscale resolver ($hostns), and boxes INHERIT it — see issue #33"
inf "· box DNS breaks whenever the tailnet's resolver does (this is what kills cold mints)"
inf "· and tailnet names RESOLVE from inside a box, though its ACL blocks connecting to them"
inf "fix: re-run ~/.local/share/box/host/setup-host.sh (it pins the resolver)"
inf "fix: re-run: box setup-host (it pins the resolver)"
inf " or quick-test the pin alone: bash drill/doctor.sh --pin-dns"
else
inf "boxes inherit the host's resolver (unpinned — setup-host.sh pins this now; re-run it)"

View file

@ -25,7 +25,7 @@ write_files:
- **Disposable.** Nothing here is backed up. State is discarded when the box is
removed; the operator persists work via git push and via `box snapshot`.
- **Bootstrap runbook.** If the repository you are working in contains a
`.claudebox/` folder, read it as your setup runbook — how to install
`.box/` folder (older repos may use `.claudebox/`), read it as your setup runbook — how to install
dependencies, start services, template environment files, seed data, and
smoke-test — and follow it. It is documentation for you, not a script the
host runs.

View file

@ -26,7 +26,7 @@ write_files:
- **Disposable.** Nothing here is backed up. State is discarded when the box
is removed; the operator persists work via git push and via `box snapshot`.
- **Bootstrap runbook.** If the repository you are working in contains a
`.claudebox/` folder, read it as your setup runbook — how to install
`.box/` folder (older repos may use `.claudebox/`), read it as your setup runbook — how to install
dependencies, start services, template environment files, seed data, and
smoke-test — and follow it. It is documentation for you, not a script the
host runs.

View file

@ -26,7 +26,7 @@ write_files:
- **Disposable.** Nothing here is backed up. State is discarded when the box
is removed; the operator persists work via git push and via `box snapshot`.
- **Bootstrap runbook.** If the repository you are working in contains a
`.claudebox/` folder, read it as your setup runbook — how to install
`.box/` folder (older repos may use `.claudebox/`), read it as your setup runbook — how to install
dependencies, start services, template environment files, seed data, and
smoke-test — and follow it. It is documentation for you, not a script the
host runs.