CLI Reference¶
All Beetroot subcommands. Every verb accepts --help for full usage. The
CLI is built on Typer, so --help renders
as boxed sections with color (via Rich); flag and argument tables in this
reference mirror the same shape.
After uv tool install, invocations are plain beetroot <verb> — the tool venv puts beetroot directly on your PATH. (Contributors hacking on Beetroot from an editable uv sync checkout use uv run beetroot <verb> instead; see CLAUDE.md.)
Beetroot's path model is Docker-inspired: an instance is any directory on disk containing a beetroot.yaml. The CLI discovers the current instance by walking up from cwd like git walks up to find .git. The cross-instance registry — name → absolute path — lives at ~/.config/beetroot/instances.json (respects XDG_CONFIG_HOME).
Top-level flags¶
| Flag | Description |
|---|---|
--install-completion |
Install shell completion for the current shell (auto-detected). Run once per shell. |
--show-completion |
Print the completion script without installing it. |
--help |
Render the top-level help. |
See Installation → Shell completion for the recommended setup.
Exit codes¶
This is the stable v1.0 contract. Scripts wrapping the CLI can rely on these codes:
| Code | Meaning |
|---|---|
0 |
Success |
1 |
Not found / domain error (instance not in registry, file missing, network error, etc.) |
2 |
Capability error — the backend does not support the requested verb (e.g. up against an adb-adopted device) |
create¶
Initialize a new instance.
| Argument / Flag | Type | Description |
|---|---|---|
name |
positional | Instance name (used as the Docker project name and the default directory name) |
--path |
path | Where to create the instance directory. Default: ./<name>. Resolved against cwd. |
--from-data |
path | Copy an existing data directory as the new instance's /data. |
--lifecycle |
durable | ephemeral |
Stamp the persistence intent into the generated beetroot.yaml. Omitted → the key isn't written and the instance is durable by default. See lifecycle — it's a label + guardrails, not a runtime switch (down never wipes /data for either). |
What it does:
- Validates the name isn't already registered.
- Creates the instance directory and writes a minimal
beetroot.yamlinto it (api_version+android.version, pluslifecyclewhen--lifecycleis passed; every other field falls back to schema defaults). To start from a richer baseline, copy one of the example YAMLs over the generated file and runbeetroot apply <name>. - Allocates the lowest free port index.
- Registers
name → absolute_pathin~/.config/beetroot/instances.json. - If
--from-datais given, copies the directory into<instance>/data/. - Renders
.env, downloads the Frida binary, downloads + stages modules.
Output:
[beetroot] created alpha at /home/you/alpha (index 0, ADB localhost:5555, Frida localhost:27042)
[beetroot] next: beetroot up alpha
register¶
Adopt an existing instance directory under the global registry.
| Argument / Flag | Type | Description |
|---|---|---|
path |
positional | Path to a directory containing beetroot.yaml |
--name |
string | Registry name (default: basename of the path) |
Useful for picking up an instance dir cloned from a teammate, or after recovering from a corrupted registry. Allocates a port index just like create.
adopt¶
Adopt a rooted Android device (real phone, third-party emulator, adb connect-ed network device) that's already reachable via the host adb CLI. Unlike create/register, no on-disk instance directory is made — the device is managed outside Beetroot. The adopted instance gets its own port index, so a follow-up beetroot frida-addr <name> reports the same port a redroid instance with the same index would have got.
| Argument / Flag | Type | Description |
|---|---|---|
serial |
positional | adb serial (e.g. emulator-5554, 192.168.1.10:5555) |
--name |
string | Registry name. Defaults to adb-<serial> (lowercased, colons folded to hyphens, truncated to 24 chars). Required for IPv4-shaped serials (the default-name builder leaves dots in place and the registry-name grammar rejects them). |
--verify, -V |
flag | Check that the serial is listed in adb devices as device before writing the registry row. If not found, exits 1 without registering. Default: off (allows registering a device before it connects). |
Verbs that need an on-disk container (up, down, restart, apply, destroy, snapshot) raise BackendCapabilityError against an adopted device and exit with code 2 — distinct from the standard "instance not found" exit 1, so wrapping scripts can distinguish. Use beetroot shell <name> / beetroot install-frida <name> / beetroot frida-addr <name> / beetroot module <name> for the universal verbs.
Adopted devices show up in beetroot ls like any other instance — KIND is adb, the ADB column shows the serial, and PATH is - (no on-disk directory). See ls.
apply¶
Re-render .env and re-stage Frida + modules from an edited beetroot.yaml.
| Argument | Type | Description |
|---|---|---|
name |
positional | Instance name |
Run this after editing the instance's beetroot.yaml. It re-downloads any modules whose URLs or sha256s changed, re-downloads Frida if the version changed, and re-renders .env. Idempotent — safe to run multiple times.
After apply, restart to pick up the changes:
up¶
Start one or more instances.
| Argument / Flag | Type | Description |
|---|---|---|
names |
positional (one or more) | Instance names to start |
--all |
flag | Act on every registered instance. |
Runs docker compose -p <name> -f <bundled-template> --project-directory <instance-dir> --env-file <instance-dir>/.env up -d for each instance. The bundled template lives inside the beetroot wheel, not at any project root.
No auto-rebuild
beetroot up does not rebuild the Docker image. To rebuild before starting, run beetroot build explicitly first — the verbs are decoupled so up stays fast and predictable.
Output:
down¶
Stop one or more instances. Data is preserved.
| Argument / Flag | Type | Description |
|---|---|---|
names |
positional (one or more) | Instance names to stop |
--all |
flag | Act on every registered instance. |
Runs docker compose down. The instance's data/ directory is untouched.
When using --all, instances backed by non-Lifecycle backends (e.g. adb-adopted devices) are skipped with a one-line advisory to stderr — only redroid instances are stopped. Orphan or unresolvable registry rows (a redroid instance whose beetroot.yaml was deleted, or an unknown backend kind) are likewise skipped with a one-line advisory rather than aborting the whole fan-out. Explicit single-name invocations still raise a clear error.
Output:
restart¶
Stop then start one or more instances. Useful for picking up Magisk-module changes or a fresh apply.
| Argument / Flag | Type | Description |
|---|---|---|
names |
positional (one or more) | Instance names to restart |
--all |
flag | Act on every registered instance. |
Equivalent to running beetroot down <name> followed by beetroot up <name> for each named instance, but issued as a single verb.
Output:
destroy¶
Stop and permanently delete an instance and all its data.
| Argument / Flag | Type | Description |
|---|---|---|
name |
positional | Instance name |
-y, --yes |
flag | Skip the confirmation prompt |
Destructive
This deletes the entire instance directory including /data. There is no undo. Use beetroot down to stop without deleting.
Steps:
- (Optional) Prompts for confirmation unless
-y. - Runs
docker compose down -vto remove the container and any named volumes. - Deletes the instance directory with
shutil.rmtree. - Removes the entry from the registry, freeing the port index.
reset¶
Drop an instance's /data (accumulated app state) while keeping the instance itself — the gated, first-class form of the old "rm -rf data/" fresh-start recipe.
| Argument / Flag | Type | Description |
|---|---|---|
name |
positional | Instance name |
-y, --yes |
flag | Skip the confirmation prompt |
Unlike destroy, reset keeps the instance's identity (registry row, port index) and its staged tooling — frida-server and modules/ live outside /data, so they survive. It's the explicit counterpart to the silent boot_cache /data revert.
Steps:
- (Optional) Prompts for confirmation unless
-y. - Stops the container (
docker compose down, idempotent). - Wipes and recreates the bind-mounted
data/directory. redroid regenerates a clean/datafrom the base image on the nextup.
The instance is left stopped — run beetroot up <name> afterwards for a fresh /data.
redroid only (for now)
reset is a redroid-backend verb. binder: vm keeps /data inside the guest (pending the split-data-disk work) and adb-adopted devices have no host-side /data, so both report a capability error. Manage state on those backends directly (a real device via its UI; a vm via a rebuild).
forget¶
Deregister an instance from the registry without touching its host directory.
| Argument / Flag | Type | Description |
|---|---|---|
name |
positional | Instance name to deregister |
Removes the registry row and frees its port index. No host-directory teardown, no docker compose down, no data deletion. Works for both redroid and adb-backed instances.
This is the companion to beetroot adopt — the safe way to remove an adb-adopted device from the registry when you no longer want Beetroot to track it. For redroid instances where you want to destroy the data too, use beetroot destroy instead.
Output:
ls¶
List all known instances — every backend kind, so adb-adopted devices appear next to redroid containers.
| Flag | Description |
|---|---|
--json |
Emit JSON instead of a table. Suitable for piping to jq or Python. |
Status is queried live, never cached: redroid rows from docker compose ps, adb rows from adb devices (available when the serial is listed in state device, unavailable otherwise).
Table output:
NAME KIND IDX ADB FRIDA STATUS PATH
alpha redroid 0 localhost:5555 localhost:27042 running /home/you/alpha
bravo redroid 1 localhost:5565 localhost:27052 exited /tmp/scratch/bravo
phone adb 2 emulator-5554 localhost:27062 available -
For adb rows the ADB column shows the device serial verbatim (the value adb -s targets — there is no host:port form), FRIDA shows the allocated host forward port for the row's index, and PATH is - because an adopted device has no on-disk instance directory.
JSON output (abbreviated):
{
"alpha": {
"kind": "redroid",
"path": "/home/you/alpha",
"index": 0,
"adb": "localhost:5555",
"frida": "localhost:27042",
"adb_address": "localhost:5555",
"frida_address": "localhost:27042",
"status": "running",
"created_at": "2025-01-15T10:30:00+00:00"
},
"phone": {
"kind": "adb",
"index": 2,
"serial": "emulator-5554",
"adb_address": "emulator-5554",
"frida_address": "localhost:27062",
"is_available": true,
"created_at": "2025-01-15T10:30:00+00:00"
}
}
Adb-kind rows use the same shape as beetroot status for an adopted device: serial plus the Protocol-surface fields (adb_address, frida_address, is_available) and an always-empty stealth_paths ({}). They omit the entire ports dict and the redroid-only fields; the v0.3 back-compat keys (path / adb / frida) exist only on redroid rows.
logs¶
Tail an instance's logs. The source depends on the backend:
- redroid (
binder: host|auto) — passes through todocker compose logs. - micro-VM (
binder: vm) — prints the guest's persisted QEMU serial console (<instance>/qemu-console.log): the kernel boot trace,guest-initoutput, and the in-guest redroid container's stdout. The console is captured bybeetroot upand survives after it returns, so this is how you watch — or post-mortem — a slow TCG boot.
| Argument / Flag | Type | Description |
|---|---|---|
name |
positional | Instance name |
-f, --follow |
flag | Follow log output (docker compose logs -f for redroid; tail -f of the console for the micro-VM) |
Useful for watching entrypoint.sh output during boot:
For a binder: vm instance, the same command surfaces the emulated boot (it's only supported once the VM has been started at least once, so the console log exists):
shell¶
Open an interactive ADB shell into an instance, or run a one-shot command.
| Argument | Type | Description |
|---|---|---|
name |
positional | Instance name |
args |
remainder | Optional extra args forwarded to adb shell. Use -c 'cmd' for non-interactive invocation. |
Calls adb connect localhost:<adb_port> then adb -s localhost:<adb_port> shell [args...]. Requires adb on your PATH.
Examples:
status¶
Print a JSON snapshot of a single instance.
| Argument | Type | Description |
|---|---|---|
name |
positional | Instance name |
Output is JSON to stdout (v0.4 has no human-readable mode — pipe to jq). Exits 0 on success; exits 1 if name is not in the registry.
Redroid-kind rows include name, kind, index, created_at, ports (a dict with adb / frida / frida_control keys), status, adb_address, frida_address, stealth_paths, plus the v0.3 back-compat keys (path, adb, frida).
Adb-kind rows omit the entire ports dict and the redroid-only fields (path, status, adb, frida); they include serial and the Protocol-surface fields (adb_address, frida_address, is_available). They still carry a stealth_paths key (always empty: {}).
doctor¶
Run aggregated health checks for an instance.
| Argument | Type | Description |
|---|---|---|
name |
positional | Instance name |
Output is one <check>: pass|fail|skip [reason] line per check. Exits 0 if every check passes; otherwise the exit code is the count of fail results (capped at 255). skip rows do not count toward the exit code.
Redroid checks: compose.status, host.binder, adb.connect, frida.handshake, magisk.zygisk, magisk.denylist.com.google.android.gms (skipped if the package isn't in magisk.denylist).
Adb checks: adb.serial, frida.handshake, magisk.zygisk, magisk.denylist.com.google.android.gms. compose.status is not applicable.
VM (binder: vm) checks: vm.process (is QEMU alive?), vm.accel (KVM vs the slow-TCG note), vm.qemu (the QEMU emulator is on PATH), vm.artifacts (the guest kernel + rootfs exist — else a beetroot build --vm-kernel hint), and adb.connect (connect-then-verify against the forwarded loopback port, not the USB-style adb.serial). The Frida and Magisk rows are omitted — the network-isolated guest has no Frida and boots a plain redroid image with no Magisk.
modes¶
Survey the host and report which Beetroot run-modes it supports — before you create an instance or pick a binder mode. Host-level and instance-independent, unlike doctor <name> (which health-checks one existing instance).
| Option | Type | Description |
|---|---|---|
--json |
flag | Emit the support matrix as JSON instead of a table. |
Probes the host binder driver, KVM, and the QEMU / Docker / adb binaries, then reports each mode as supported / needs-setup / unsupported / unknown with a reason and remedy. Always exits 0 — it reports, it does not gate.
The modes reported are redroid (binder: host / auto), redroid (binder: vm, KVM accel), redroid (binder: vm, TCG accel), and adb backend (adopt remote device). See Binder & run-modes for what each one needs and why.
frida-addr¶
Print an instance's Frida address (localhost:<frida_port>) to stdout, so you can drive the native frida CLI yourself.
| Argument | Type | Description |
|---|---|---|
name |
positional | Instance name |
frida-addr does the one thing the old beetroot frida passthrough verb existed for — resolving an instance's stride-allocated Frida port — without wrapping frida. That matters: a passthrough wrapper forwards arguments opaquely, which silently breaks frida's own shell completion, --help, and flag validation. Emitting just the address lets you invoke native frida directly, keeping all of its ergonomics (see issue #109). The emitter needs nothing installed; to actually attach you need the host frida CLI (uv tool install 'beetroot[frida]' or uv tool install frida-tools).
Examples:
beetroot frida-addr alpha
# → localhost:27042
frida -H "$(beetroot frida-addr alpha)" -n com.target.app
frida -H "$(beetroot frida-addr alpha)" -f com.target.app --no-pause -l script.js
frida -H "$(beetroot frida-addr alpha)" -ps # list processes
The same value is also the frida_address field of beetroot status <name> (JSON).
install-frida¶
Push and launch frida-server on an adb-adopted device. Downloads the requested frida release, adb pushes the binary, launches it as root, and forwards the host Frida port so frida -H "$(beetroot frida-addr <name>)" reaches it. This is the CLI path the adopt hint advertises — it wraps the AdbDevice.install_frida() API.
| Argument / Flag | Type | Description |
|---|---|---|
name |
positional | Instance name |
--version |
string, required | frida release tag to push and launch (e.g. 16.4.10) |
--version is required: an adb-adopted device has no beetroot.yaml to fall back to for a default, so omitting it exits with a friendly error: line. A missing adb on PATH likewise surfaces as error: + exit 1.
Example:
beetroot install-frida phone --version 16.4.10
frida -H "$(beetroot frida-addr phone)" -n com.target.app
module¶
Install a Magisk module — append + re-stage (redroid), push (adb), or auto-install via root (adb, --auto-install).
| Argument | Type | Description |
|---|---|---|
name |
positional | Instance name |
source |
positional, repeatable | Redroid instances: https:// or http:// URL, or a path to a .zip (relative paths resolve against the instance directory). adb-adopted devices: path to an existing .zip on the host filesystem — URLs are not accepted, and relative paths resolve against your current working directory. Multiple sources are allowed with --auto-install only. |
--sha256 |
option, repeatable | Expected sha256 hex digest of the zip. With --auto-install, repeat once per source (or omit entirely); a mismatching zip is never pushed. Without the flag, ignored for adb pushes (verify the hash yourself) and verified at staging time for redroid instances. |
--auto-install |
flag | adb backend only: install via root (su -c magisk --install-module) instead of the safe push-to-Downloads default. |
Redroid instances: the module is appended to the instance's beetroot.yaml and immediately staged into its modules/ directory. Restart to flash:
adb-adopted devices (default): the zip is pushed to /sdcard/Download/<name>.zip and a one-line "install via the Magisk app → Modules tab" instruction is printed — no root interaction.
adb-adopted devices (--auto-install): each zip is pushed to a synthesized temp name under /data/local/tmp/ (beetroot-module-<N>.zip — the local filename never reaches the device shell) and installed with su -c magisk --install-module <zip> (Magisk stages it into /data/adb/modules_update/<id>/ for the next reboot); the temp zip is removed afterwards. Every module gets its own ok: (stdout) or failed: (stderr) report line; a failed module doesn't stop the rest, and the verb exits 1 if any module failed. Before anything is pushed, a cheap pre-flight probe diagnoses whole-device problems with a single friendly error: ... line + exit 1 instead of one identical failed row per module: an offline / disconnected / unauthorized device (reconnect, accept the USB-debugging prompt, and check adb devices), no usable root (su missing or denied root), or root without a usable magisk binary. Connectivity is decided by re-running adb devices for the serial, never by matching the probe's error text, so untrusted module stderr can't be mistaken for a disconnect. A device that genuinely drops offline mid-batch aborts the remaining modules with the same offline diagnosis (which names how many were skipped; rows completed before the abort are still reported). Host-side validation failures (missing/non-zip path, sha256 mismatch) always stay per-module failed: rows and never abort the batch. Redroid instances don't support the flag and exit 2. See the modules guide for examples.
build¶
Build the redroid base image and Beetroot layer for a chosen GApps intent. One-time bootstrap; re-run when you want a fresh image.
beetroot build [<gapps>] [--gapps-vendor <vendor>] [--vm-kernel] [--from-source] [--check] [--android-version <N>]
| Argument / Flag | Type | Description |
|---|---|---|
gapps |
positional, optional | GApps intent to bake into the base image. One of none, minimal (default), full. |
--gapps-vendor |
enum, optional | Pin a specific GApps vendor for app compatibility instead of letting the intent pick: litegapps, opengapps, mindthegapps. Cannot be combined with the none intent. |
--vm-kernel |
flag | Build the binder: vm micro-VM guest kernel + rootfs instead of the redroid base image (for hosts with no kernel binder). Prints the resulting vm.kernel / vm.rootfs paths. |
--from-source |
flag | With --vm-kernel: build both guest artifacts locally — compile the kernel from source (~7 min) instead of fetching the matching prebuilt bzImage, and bake the rootfs locally (needs the Docker daemon + busybox/socat/iptables toolchain) instead of fetching the matching prebuilt image. |
--check |
flag | With --vm-kernel: only run the host-prerequisite preflight (the full superset both paths could need) and report what's missing — don't build. Exits 0 when the host is ready, 1 otherwise. |
--build-context |
path, optional | Path to a source checkout whose docker/ tree supplies the build assets. Overrides BEETROOT_BUILD_CONTEXT. Defaults to the assets bundled in the installed wheel. |
--android-version |
int, optional | With --vm-kernel: Android major version to bake into the guest rootfs (must match the instance's android.version). Defaults to 14 — the same default a fresh beetroot create uses, so an unflagged build and a default instance agree. Bake a different version (e.g. --android-version 11) when your instance pins one. |
--vm-kernel is fetch-first, so the default path needs almost nothing on the host. A real build first checks only the lightweight fetch prerequisites (curl + tar, which the kernel source-compile fallback would need) and aborts early if those are missing. The heavyweight bake-only toolchain — the static busybox/socat/iptables-legacy binaries, ldd/mke2fs, and a responsive Docker daemon — is enforced only when a local rootfs bake actually runs (a prebuilt miss, --from-source, or a bake-override env var), not on the default fetch path. So a fresh, dockerless, busybox-less host that the prebuilt targets builds fine: it never touches the bake toolchain. When a bake is required and the host is missing those prerequisites, the build reports everything missing in one pass, each with the apt package (or command) that fixes it, instead of failing one tool at a time. Run beetroot build --vm-kernel --check to see the report for the full superset both paths could need, without starting a build. Setting any of REDROID_TAR, REDROID_IMAGE, IMAGE_SIZE_MB, or DOCKER_URL forces a local bake (those change the baked bytes but are not part of the prebuilt fingerprint, so the prebuilt would silently ignore them); on that bake path, if the redroid pull hits a Docker Hub rate limit, point REDROID_TAR at a docker saved image tarball or use a registry mirror (the daemon check is then skipped, since the bake loads from the tarball instead of pulling).
With --vm-kernel, the guest kernel is fetched prebuilt by default: a ~12 MiB bzImage is downloaded from the repo's vm-kernel GitHub release, matched to the pinned kernel version and a fingerprint of the bundled kernel.config (shipped as package data at src/beetroot/templates/vm/kernel.config), and verified against its .sha256. If no asset matches (you edited the config, bumped the version, the release isn't published yet, or the network is blocked), it falls back to compiling from source. So a fresh host skips the ~7-minute compile, and the vendored config stays authoritative — you can never boot a stale prebuilt kernel. The rootfs is also fetched prebuilt by default (issue #79): a zstd-compressed ext4 image is downloaded from the repo's vm-rootfs GitHub release, matched per Android version and a composite fingerprint over the three rootfs-determining inputs (Android version, Docker static-bundle version, guest-init.sh), verified against its .sha256, and decompressed locally — so a fresh host skips the ~2 GB redroid pull + local bake and needs no Docker daemon. If no asset matches (any input changed, the release isn't published yet, or the network is blocked), it falls back to baking the rootfs locally. --from-source forces a local bake of the rootfs too.
The source-compile fallback is self-contained: it fetches the pinned linux-<version>.tar.xz from cdn.kernel.org, extracts it into a throwaway scratch tree, and compiles there (merging make defconfig with the vendored kernel.config). You don't need to be sitting inside an extracted kernel tree for it to work — so a prebuilt miss on a fresh host degrades to a slow-but-working compile rather than a hard No rule to make target 'defconfig' error.
The kernel.config, guest-init.sh and adbprobe.c build assets ship inside the wheel, so --vm-kernel works from a plain uv tool install with no source checkout. To build against a working tree's assets instead, pass --build-context <path-to-checkout> (or export BEETROOT_BUILD_CONTEXT=<path-to-checkout>); Beetroot then reads them from <context>/docker/vm.
The guest rootfs bakes the plain upstream redroid image for the chosen Android version (e.g. redroid/redroid:14.0.0-latest) and records that version in a marker beside the image. If an instance's android.version later disagrees with the baked rootfs, beetroot up / beetroot apply print a one-line warning (the guest boots the baked version regardless) telling you to rebuild with a matching --android-version or change android.version. A pre-existing rootfs without the marker is treated as a match (no warning) for backward compatibility.
The verb:
- Clones
ayasa520/redroid-scriptinto~/.cache/beetroot/redroid-script(the per-user cache dir, respecting$XDG_CACHE_HOME). - Runs the patcher to produce a local Docker image (e.g.
redroid/redroid:14.0.0_litegapps_houdini_magisk). - Runs
docker compose buildto layerentrypoint.shandstealth.rcon top.
beetroot up no longer accepts a --build flag — to rebuild before starting, run beetroot build explicitly first.
snapshot¶
Pack an instance's host-side state into a .tar.zst archive.
| Argument | Type | Description |
|---|---|---|
name |
positional | Instance name to snapshot. |
-o, --output |
path | Archive path (default: ./<name>.tar.zst). The .tar.zst extension is appended automatically if you omit it. |
Stop the instance first (beetroot down <name>) — tar-ing live data/ produces an inconsistent archive. The archive excludes .env (it's regenerated on the next apply). See Snapshots for the round-trip workflow and the path_layout forward-compat story.
restore¶
Unpack a snapshot archive into a new instance and register it.
| Argument | Type | Description |
|---|---|---|
archive |
positional | Path to a .tar.zst snapshot archive. |
--name |
string | Registry name for the restored instance (default: the name recorded in the manifest). |
--path |
path | Directory to extract into (default: ./<name>). |
--force |
flag | Wipe a non-empty destination directory before extracting. |
A fresh port index is allocated — the source's index is never reused, so the original and the restored instance can run concurrently if both directories still exist. After restore, run beetroot apply <new-name> to regenerate .env, then beetroot up <new-name>.