diff --git a/README.md b/README.md index 0edc42f..761a9c8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bin/box b/bin/box index 0e840ec..dbfa4ac 100755 --- a/bin/box +++ b/bin/box @@ -53,6 +53,9 @@ CMDS=( "expose^ [] | --list | --remove ^box^Forward a box port to the host's loopback — see a dev server^fn:cmd_expose^" "incus^ -- ^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 | --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^[]^^This help, or 'box help ' 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 # 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 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 diff --git a/docs/box-design.md b/docs/box-design.md index 416d308..efc41ad 100644 --- a/docs/box-design.md +++ b/docs/box-design.md @@ -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. diff --git a/docs/box-recipe.md b/docs/box-recipe.md index cf65476..6d4f345 100644 --- a/docs/box-recipe.md +++ b/docs/box-recipe.md @@ -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 && cd -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`. diff --git a/drill/doctor.sh b/drill/doctor.sh index 1b25fda..df91ebc 100755 --- a/drill/doctor.sh +++ b/drill/doctor.sh @@ -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:-} — 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)" diff --git a/templates/claude/user-data.yaml b/templates/claude/user-data.yaml index 25eb51a..970d898 100644 --- a/templates/claude/user-data.yaml +++ b/templates/claude/user-data.yaml @@ -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. diff --git a/templates/codex/user-data.yaml b/templates/codex/user-data.yaml index 1aa115c..550e28a 100644 --- a/templates/codex/user-data.yaml +++ b/templates/codex/user-data.yaml @@ -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. diff --git a/templates/grok/user-data.yaml b/templates/grok/user-data.yaml index 1de6646..535e5d0 100644 --- a/templates/grok/user-data.yaml +++ b/templates/grok/user-data.yaml @@ -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.