The coppice CLI

Cube ships a cubemastercli binary that covers the operator’s daily surface area: create a sandbox, list what’s running, kill the one that ate all the RAM, build a template from a directory, warm a pool. We didn’t have that. We had curl | jq and coppice-pool-ctl. This page is about closing that gap with a single Rust binary that ships from the same Cargo workspace as the gateway and speaks the gateway’s JSON wire format directly.

The shape

The binary is called coppice. It lives at e2b-compat/src/bin/coppice.rs in the same crate as the gateway, so it compiles against whatever the gateway’s Cargo.lock says — no protocol drift between client and server. Install with mise run coppice:install, which shells out to cargo install —path e2b-compat —bin coppice —locked and drops the binary in ~/.cargo/bin.

The subcommand surface mirrors Cube’s CLI deliberately, so an operator who’s been doing Cube for a year finds what they expect:

commandgateway routenote
coppice login —token <t>GET /auth/whoamiVerifies the token when gateway auth is enabled, then stores the token and URL in the OS keyring when available (secret-tool or macOS security) with a 0600 file fallback for headless CI/honor.
coppice whoami [—json]GET /auth/whoamiReads the stored token if no env token is present and prints the tenant/scopes accepted by the gateway.
coppice logoutlocal credential storeClears the stored token from the keyring or fallback credentials file.
coppice sandbox create [—template] [—ttl] [—cpu] [—mem] [—json]POST /sandboxesPrints the id on stdout (or full JSON with —json). —ttl follows up with POST /sandboxes/:id/timeout.
coppice sandbox list [—json]GET /sandboxesAligned columns by default: id, template, state, started, end.
coppice sandbox kill <id>DELETE /sandboxes/:idExit 2 on 404, so shell pipelines can branch on it.
coppice sandbox exec <id> — <argv…>POST /sandboxes/:id/execRelays stdout verbatim; the separator is mandatory when argv has flags.
coppice sandbox logs <id>GET /sandboxes/:id/logsSnapshot only; —follow is advertised-but-unimplemented and exits 3.
coppice tpl list [—json]GET /templatesAligned columns by default (NAME / PATH / DESCRIPTION). Registry is discovered once at gateway startup from <templates_root>/*-template.
coppice tpl show <name> [—json]GET /templates/:nameKey=value by default. Unknown template → exit 2 (same as sandbox-not-found).
coppice tpl reload [—json]POST /templates/reloadRe-runs the startup discovery pass against —templates-root and swaps the entry list atomically. Prints ok: registry reloaded; count=N. Picks up a newly zfs clone’d <name>-template dataset without a gateway restart.
coppice tpl create-from-dir <path> —name <n>POST /templates → 501Endpoint is shaped but build-from-dir is future work; gateway returns 501 and the CLI relays as unimpl (exit 3) so scripts keep their existing branch semantics.
coppice pool status [—json]GET /poolTEMPLATE / WARM / ALLOCATED columns plus a total-entries footer. Gateway shells out to coppice-pool-ctl.sh list —json.
coppice pool warm <t> —count N [—json]POST /pool/:template/warmLoops coppice-pool-ctl.sh checkout N times. Prints ok: <t> warmed; new_warm_count=N.
coppice pool drain <t> [—json]POST /pool/:template/drainReleases every entry whose pool_entry matches the template. Prints ok: <t> drained; released=N. On dev hosts without coppice-pool-ctl.sh the gateway returns 503 and the CLI surfaces that as a generic HTTP error (exit 1) rather than unimpl.

Gateway URL and token resolution

URL sources, in priority order: —url <u>, $COPPICE_URL, $E2B_API_URL, the URL saved by coppice login, then http://localhost:3000. The $E2B_API_URL rung matters — it’s what the official Python SDK honours, and picking it up means an operator can point their shell at a remote gateway once and have both coppice and any SDK scripts agree without re-exporting. The flag strips trailing slashes so —url http://localhost:3000/ and —url http://localhost:3000 are the same thing.

Token sources are —token <t>, $COPPICE_TOKEN, $E2B_API_KEY, then the saved credential. coppice login accepts —token, —token-stdin, or either env token, verifies the token against /auth/whoami unless —no-verify is set, and stores it for later commands. In tests or headless setups set COPPICE_CREDENTIALS_FILE=/path/to/credentials.json to force the file store; otherwise the CLI tries the OS keyring first and falls back to ~/.config/coppice/credentials.json.

A worked session

From a laptop, tunnelled into honor via ssh -L 3000:localhost:3000 -L 49999:localhost:49999 -L 49983:localhost:49983 honor, talking to the live gateway rc.d service (2026-04-22, verbatim):

$ coppice sandbox create --template python --json | jq -r .sandboxID
609a21703c314940b66532095ac74ad9

$ coppice sandbox list
SANDBOX_ID                        TEMPLATE  STATE    STARTED                         END
609a21703c314940b66532095ac74ad9  python    running  2026-04-22T23:57:06.487003909Z  2026-04-23T00:12:06.487004230Z

$ coppice sandbox exec 609a21703c314940b66532095ac74ad9 -- python3 -c 'print("hello from CLI")'
hello from CLI

$ coppice sandbox kill 609a21703c314940b66532095ac74ad9
$ echo $?
0

Note the interpreter is python3, not python: the FreeBSD python template pkg-installs python3.11 as python3 with no python alias. The E2B Python SDK’s run_code path is unaffected (it reaches the in-jail ipykernel via /execute); this only bites on ad-hoc coppice sandbox exec invocations.

That’s the critical path. Creating with resource limits is one flag each: —cpu 50 —mem 256 forwards cpuCount=50 (rctl pcpu in percent) and memoryMB=256 to the same handler path the SDK uses, so the limits bind before jexec’s first syscall — same story as the feature-audit row.

Against cubemastercli

Cube’s CLI is bigger than ours: cluster-admin subcommands and log streaming with cursor state. We don’t yet have clusters or streaming logs. The CLI surface reflects that honestly — the endpoints we have are fully wired, the ones we don’t exit 3 with a named reason. An operator running coppice pool warm python —count 20 is told explicitly that the route isn’t in the gateway yet, not given a silent failure or a fake success.

The rows we cover outright, though, cover the day-to-day: sandbox create / list / kill / exec is 90% of what hits a CLI in anger. examples/10-cli-roundtrip.sh in this repo runs that full cycle as a single shell script and is what we point at in CI and in demo scripts.

Template + pool operations

After #68 (2026-04-22), the six previously-stubbed subcommands all reach the gateway. A worked session on honor, talking to the rc.d service:

$ coppice tpl list
NAME    PATH                             DESCRIPTION
node    /zroot/jails/node-template       Node 22 LTS
python  /zroot/jails/python-template     Python 3.11 + numpy + pandas

$ coppice tpl show python --json | jq .
{
  "name": "python",
  "path": "/zroot/jails/python-template",
  "description": "Python 3.11 + numpy + pandas"
}

$ coppice pool status
TEMPLATE  WARM  ALLOCATED
python    3     3
(total_entries=3)

$ coppice pool warm python --count 2
ok: python warmed; new_warm_count=5

$ coppice pool drain python
ok: python drained; released=5

The template registry is now re-scannable without a gateway restart: coppice tpl reload hits POST /templates/reload, which re-walks --templates-root and swaps the entry list atomically. The common workflow — zfs clone zroot/jails/_template@base-dns-… zroot/jails/foo-template, then coppice tpl reload — surfaces the new template in the very next coppice tpl list. coppice tpl create-from-dir still exits 3: the endpoint shape is in place (POST /templates), but the handler returns 501 today. The lone surviving exit-3 case.

Gaps the CLI surfaced

Writing the CLI flushed out two small gateway rough edges that weren’t visible from the SDK’s happy path:

  1. DELETE /sandboxes/:id on a nonexistent id returns HTTP 500, not 404. The backend error wrapper treats “jail not found” as a generic backend error rather than translating it to the shape the SDK already handles for other 404 paths. The CLI handles either (exit 1 on 500, exit 2 on 404) so it’s not a blocker; just worth fixing the next time someone’s in routes.rs.
  2. /templates and /pool endpoint families landed under #68. coppice tpl list / show and coppice pool status / warm / drain all hit the gateway directly now. The only exit-3 path left on the CLI is tpl create-from-dir, whose endpoint returns 501 (build-from-dir is future work; see e2b-compat/src/templates.rs). Dev hosts without coppice-pool-ctl.sh installed get 503 from the pool handlers — the gateway stays up, only the /pool routes degrade.

Receipts

One more row closed in the feature audit, one more binary that new operators can run on day one instead of learning the gateway’s JSON by hand.