Metadata-Version: 2.4
Name: caffeinated-whale-cli
Version: 3.5.0
Summary: A CLI tool to help manage Frappe Docker instances.
Author-email: Christopher McKay <mckay.christopher73@outlook.com>
License: MIT
Project-URL: Homepage, https://github.com/karotkriss/caffeinated-whale-cli
Project-URL: Issues, https://github.com/karotkriss/caffeinated-whale-cli/issues
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Topic :: System :: Systems Administration
Classifier: Topic :: Software Development :: Build Tools
Classifier: Intended Audience :: Developers
Classifier: Environment :: Console
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: docker>=7.1.0
Requires-Dist: typer>=0.16.0
Requires-Dist: click>=8.0.0
Requires-Dist: rich>=14.0.0
Requires-Dist: requests>=2.26.0
Requires-Dist: questionary>=2.1.0
Requires-Dist: toml>=0.10.2
Requires-Dist: peewee>=3.17.0
Requires-Dist: packaging>=24.0
Requires-Dist: cryptography>=42
Provides-Extra: dev
Requires-Dist: black>=25.1.0; extra == "dev"
Requires-Dist: ruff>=0.8.0; extra == "dev"
Requires-Dist: mypy>=1.13.0; extra == "dev"
Requires-Dist: pytest>=8.0.0; extra == "dev"
Requires-Dist: pytest-cov>=6.0.0; extra == "dev"
Requires-Dist: types-requests>=2.33.0.20260518; extra == "dev"
Requires-Dist: types-toml>=0.10.8.20260518; extra == "dev"
Provides-Extra: e2e
Requires-Dist: pexpect>=4.9.0; extra == "e2e"
Dynamic: license-file

# Caffeinated Whale CLI

A command-line interface (CLI) for managing Frappe/ERPNext Docker instances during local development.

## Features

- **One-Command Setup** - Initialize complete Frappe/ERPNext environments with `cwcli init`
- **Smart Port Management** - Automatic port conflict detection and resolution
- **Project Discovery** - Scan and list all Frappe Docker projects
- **Cross-Project Search** - Find apps and sites across all instances with `cwcli where`
- **Container Lifecycle** - Start, stop, and restart projects with ease
- **Scale Beyond Six Benches** - Widen an instance's published port range on demand with `cwcli scale` so more than six benches are reachable from the host - database-safe (only the frappe service is recreated)
- **Development Tools** - VS Code integration, log viewing, and command execution
- **Web UI** - A local browser dashboard over every instance, bench, site and app with `cwcli ui`: live health, logs, the `doctor` health check in Settings, and the everyday lifecycle actions
- **Cache System** - Fast project inspection with SQLite-based caching and configuration storage
- **Multi-Bench Support** - Address individual benches in a multi-bench instance by numeric index or a durable label with `--bench`
- **Default Site Support** - Optional `--site` flag when default site is configured
- **Backup & Restore** - Interactive site restoration with automatic file archive detection and P2P transfer support, plus a verified backup store outside the instances with `cwcli backups` (retention, clone and move, off-site S3 copies)
- **App Management** - List, install, uninstall, and update Frappe apps per bench and per site with `cwcli apps` (multi-site by default, `--json`, honest exit codes)
- **Private App Repos** - `apps install`/`apps update` authenticate private GitHub/GitLab app fetches through your host's already-signed-in `gh`/`glab`, with no token ever entering the container
- **Update Management** - App updates with automatic migrations and lock cleanup
- **Self-Update** - Upgrade cwcli itself to the latest release with `cwcli self-update` (install-method aware)
- **Environment Preflight** - Read-only checks for Docker, disk space, and tool dependencies with `cwcli doctor` (chainable exit code, `cwcli axi doctor` for agents)
- **Update Notices** - A passive, once/day "a newer cwcli is available" hint on stderr, shown only to a human at a TTY
- **Auto-Inspection** - Background process to keep project cache fresh automatically
- **System Integration** - Auto-start on system boot with platform-specific configurations
- **Contextual Tips** - Helpful tips displayed during long-running operations to help you discover features

## Installation

`cwcli` requires Python 3.10+.

`cwcli init` and `cwcli scale` also require the Docker CLI with the Docker Compose v2 plugin (`docker compose version`).
Docker Desktop includes the plugin; on a bare Docker Engine host, install the `docker-compose-plugin` package.
Both commands verify this prerequisite before changing project state and return an actionable precondition error when it is unavailable.

### With uv (recommended)

[uv](https://docs.astral.sh/uv/) installs `cwcli` into an isolated environment and puts the `cwcli` command on your `PATH`, without touching your system or project Python.

```bash
# Install (adds the `cwcli` command to your PATH)
uv tool install caffeinated-whale-cli

# Upgrade to the latest release
uv tool upgrade caffeinated-whale-cli

# Uninstall
uv tool uninstall caffeinated-whale-cli
```

Or let cwcli upgrade itself - `cwcli self-update` detects your install method and runs the right command (see [`self-update`](#self-update---upgrade-cwcli-itself)).

If this is your first `uv tool install`, uv may print a note about adding its tool-bin directory to your `PATH`; run `uv tool update-shell` (then restart your terminal) to do so.

To run a one-off command without installing, use `uvx`.
Because the command name (`cwcli`) differs from the package name, pass it via `--from`:

```bash
uvx --from caffeinated-whale-cli cwcli --version
uvx --from caffeinated-whale-cli cwcli ls
```

### Checking which build you have

`cwcli --version` identifies the build, not just the release number:

```bash
$ cwcli --version
Caffeinated Whale CLI Version: 1.0.0 (release build)
```

`(release build)` means the artifact was installed from an index (PyPI), so the
version number is the whole story: it maps to exactly one published tag.

```bash
$ cwcli --version
Caffeinated Whale CLI Version: 1.0.0 (source build, git 618dfa5, dirty)
```

`(source build, ...)` means it was built from a working tree or a VCS
reference, where the version number alone would mislead you - a tree can carry
any number of unreleased commits under the same number.
The commit is the durable identifier there, and `dirty` marks uncommitted
changes.
`(editable source build, ...)` is the same thing from an editable checkout.

Read this before concluding a feature is missing: a `(release build)` that
lacks a feature may simply predate it.

### With pip

```bash
pip install caffeinated-whale-cli
```

### Troubleshooting

After installation, if you see an error like `'cwcli' is not recognized...`, the installation directory is not in your system's `PATH`.

To fix this:

1. **uv:** Run `uv tool update-shell` and restart your terminal.
2. **pip:** Run `pip show -f caffeinated-whale-cli` and look for the location of `cwcli.exe` (or `cwcli` on macOS/Linux) - typically a `Scripts` or `bin` folder within your Python installation - then add that directory to your `PATH`.
3. **Restart your terminal:** Close and reopen your terminal for changes to take effect.

### Locked-down Windows (Application Control / SmartScreen)

On a Windows machine with Application Control, Smart App Control, or SmartScreen blocking unsigned executables, the `cwcli.exe`/`caffeinated-whale-cli.exe` launcher shim that `uv`/`pip` generates is unsigned and may be blocked.
`cwcli` must still be installed (the shim is written at install time; it's just not the thing you invoke), then run it through the signed Python interpreter instead:

```bash
python -m caffeinated_whale_cli --version
python -m caffeinated_whale_cli ls
```

This bypasses the shim entirely and behaves identically to `cwcli`/`caffeinated-whale-cli`.

## Shared (multi-user) install

By default cwcli's on-disk state is **per-user**: everything lives under `~/.cwcli`, the cache is `0700`, the config `0600`, and the credential-bridge daemon runs as you.
That is correct for one person per machine, and it is unchanged - if you do not opt in, nothing here applies to you.

When several people on **one Linux/UNIX box** need to drive the **same** Frappe instances (they already share the containers through the `docker` group), the per-user modes get in the way: the second person hits `Permission denied` on the cache, cannot edit the shared config, and the credential bridge either locks them out or hands them the first person's token.
Shared mode fixes this with an opt-in, admin-provisioned layout modelled on Docker's `root:docker` socket and Homebrew-on-Linux's shared tree: a dedicated `cwcli` group, a system state tree with setgid group-writable directories, and one machine-wide credential bridge behind a group-owned socket.

**Enable it (once, by an admin):**

```bash
sudo cwcli setup shared --users alice,bob            # create the group + tree, add members
sudo cwcli setup shared --users alice,bob --cred-bridge   # also stand up the machine-wide credential bridge
```

This creates the `cwcli` group and a `cwcli` service account, provisions `/var/lib/cwcli` (config, cache, projects, archive) as `root:cwcli` with setgid `2770` directories, and writes the marker `/etc/cwcli/shared.toml` that turns shared mode on.
It is idempotent - safe to re-run to add users or repair modes.
Members must log out and back in for the new group to take effect.
Windows is unaffected (shared mode is POSIX-only; cwcli stays per-user there).

**Consolidate existing per-user homes into the shared tree:**

```bash
sudo cwcli setup migrate --from /home/alice/.cwcli --from /home/bob/.cwcli
sudo cwcli setup migrate --discover          # find every /home/*/.cwcli and /root/.cwcli
```

`migrate` copies each user's project state into the shared tree with the correct group ownership and modes.
It **never deletes a source** and **refuses to overwrite an existing shared project** (a name clash is reported, not clobbered), so it is safe to re-run.
The per-user SQLite cache is regenerable (`cwcli inspect`) and deliberately not merged.

**Opting an invocation back to a private home:** `CWCLI_HOME` always wins over shared mode, so `CWCLI_HOME=~/.cwcli cwcli ...` uses your own home even on a shared box.

**Security consequences - state them plainly before enabling:**

- **`cwcli` group membership is the trust boundary.** Anyone in the group can read every instance's cache, read the rm/rm-site backups (which contain full database dumps), edit the shared config, and - with `--cred-bridge` - ask the machine-wide bridge for a credential. This is the same "docker group ≈ root-ish capability" trade every shared dev tool makes. Keep membership deliberate.
- **The credential bridge in shared mode serves one machine-owned, read-only service-account token** to any group member's container git, so its audit log records the host and project but **not which human** triggered a fetch (they all present as the service account). Provision that account with a read-only deploy token; rotate it in one place if it leaks.
- The group-owned socket (`0660 root:cwcli`) closes the world-connectable `0666` socket that could otherwise leak the first user's token to a second user on the box. A uid outside the `cwcli` group cannot reach it.

Run `cwcli doctor` to verify the shared install (group present, tree modes, service account).

## Quick Start

```bash
# Initialize a new Frappe project (downloads compose, starts containers, creates bench & site)
cwcli init my-project

# Initialize with ERPNext and custom port
cwcli init my-project --port 10000 --install-erpnext

# List all Frappe projects
cwcli ls

# Start a project (with automatic port conflict detection)
cwcli start my-project

# View recent bench logs (add -f to follow in real-time)
cwcli logs my-project

# Open project in VS Code
cwcli open my-project

# Update apps and migrate sites
cwcli apps update my-project erpnext
```

## Command Reference

For the variadic `start`, `stop`, `restart`, and `rm` commands, options may appear after project names.
Short flags can be clustered (`-vy` means `-v -y`), and a short option's value can be attached (`-pweb` means `--process web`).
A cluster containing an unknown short option is rejected as a whole.

### `init` - Initialize New Project

Creates a complete Frappe development environment in a single step. Downloads compose files, starts containers, initializes bench, creates a site, and starts the bench's dev services.

Blocks for roughly 10-20 minutes (image pulls, bench init, site creation). Running several `cwcli init` invocations in parallel can OOM-kill the host on a memory-constrained machine - serialize them instead. Adding a bench to an already-running instance (`cwcli init existing-project --bench another-bench`) never disturbs it or its other benches: the frappe container is never pulled, recreated, or restarted, so every already-serving bench keeps running throughout. Re-running init against a stopped instance starts its existing containers as they are, without pulling images or recreating them, so its benches keep their Python environments. If the re-run would change that instance's compose settings, such as a different `--port` on an instance still on the default port range (8000-8005), init refuses with exit 1 (`instance.settings_changed`), leaves the compose file unchanged, and names each change; remove the instance with `cwcli rm` (which backs it up) and run init again, or rerun init without the changed options. For an instance created on another `--port` or widened by `cwcli scale`, init does not compare `--port`. An instance whose frappe container has never started, such as one left by a first init that failed at `docker compose up`, has no bench to protect, so init neither refuses nor uses `--no-recreate` there: it pulls images and recreates the containers with the new options.

```bash
cwcli init [OPTIONS] [PROJECT_NAME]
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | Docker Compose project name. If not provided, will prompt interactively |

**Options:**

| Option | Description |
|--------|-------------|
| `-P`, `--port INTEGER` | Starting port for the project (default: 8000). Creates ports {port}-{port+5} for web servers and {port+1000}-{port+1005} for socketio |
| `-b`, `--bench TEXT` | Bench directory name inside the container (default: frappe-bench) |
| `-s`, `--site TEXT` | Primary site name: any hostname/FQDN or IPv4 address (default: development.localhost). `.localhost` names are recommended for local development - they resolve to the machine without DNS. A real domain or IP is accepted too; init prints a one-line note reminding you it must resolve to this machine for browser access |
| `--bench-parent TEXT` | Directory inside container where bench is created (default: /workspace); also the container mount point for the persisted host `data/` directory, fixed for the life of the instance - changing it on a re-init errors, naming the mounted directory |
| `--bench-image-tag TEXT` | Pin the `frappe/bench` Docker image to this exact tag (e.g. `v5.29.1`), skipping the Docker Hub latest-tag lookup entirely. Omit to use the short-lived cached tag or a fresh lookup |
| `--frappe-branch TEXT` | Frappe branch or tag for bench init, e.g. `version-16` or `v16.26.3` (default: version-16). Mutually exclusive with `--version` |
| `--version TEXT` | Frappe version for bench init, resolved by shape: a bare major (`16` → `version-16` branch) or a full semantic version (`16.26.3` → `v16.26.3` tag). Malformed values are rejected with a non-zero exit. Mutually exclusive with `--frappe-branch` |
| `--frappe-url TEXT` | Custom Frappe repository URL to build from (a fork), passed to `bench init --frappe-path`. Omit to use the default `frappe/frappe` repo. Pair with `--frappe-branch` to check out the fork's branch. A private fork authenticates through the same credential bridge as `apps install`/`apps update` (host `gh`/`glab`, no token in the container); sign in on the host first |
| `--db-root-password TEXT` | MariaDB root password. On a new instance MariaDB is created with it (handed over as a compose secret, never written into `docker-compose.yml`) and cwcli remembers it in `~/.cwcli/projects/{project_name}/conf/db-root-password` for later `rm-site`, `rm-bench`, `restore`, and re-run `init` commands. Omitted, the instance's remembered password is used, else the default `123`. An instance whose MariaDB already exists keeps the password it was created with |
| `--admin-password TEXT` | Administrator password for the site, used verbatim. If omitted, a strong password is generated and printed once (interactive runs only); a non-interactive run must supply this flag |
| `--install-erpnext` | Install ERPNext application after initialization |
| `--erpnext-branch TEXT` | ERPNext branch to use (default: version-16) |
| `--auto-start` | Automatically start containers if not running |
| `--start` / `--no-start` | After creating the bench+site, start its dev services (supervisord over `bench start`) so init leaves a running dev environment (default: `--start`). `--no-start` creates without starting, for automation/CI. Distinct from `--auto-start`, which only controls Docker container startup |
| `--reuse-bench` / `--no-reuse-bench` | Pre-answer the existing-bench question non-interactively: `--reuse-bench` reuses the bench and skips `bench init`; `--no-reuse-bench` requires a fresh `--bench` name and errors if it already exists. Default: ask interactively (a non-TTY without either flag refuses). Distinct from `--auto-start`, which controls container startup |
| `--uid INTEGER` | Override the uid/gid the container's `frappe` user is aligned to, instead of the host's own uid/gid. Mainly for a root-uid host (e.g. a CI runner running as uid 0), where a root host already falls back to a safe default automatically (see step 7 below); pass `--uid` to choose a specific uid instead. Must be a positive, non-root integer (`--uid 0`, negatives, and non-numeric values are rejected with a usage error before any container or file work). Applies only to this init invocation, including its end-of-init auto-start; it is never persisted, so later `cwcli start`/`restart` do not take it |
| `-v`, `--verbose` | Show verbose output with streaming command execution |

**What It Does:**

1. Creates project directory at `~/.cwcli/projects/{project_name}/conf/`
2. Downloads `docker-compose.yml` from frappe_docker GitHub repository
3. Customizes port mappings based on `--port` flag
4. Binds the bench workspace to a host `~/.cwcli/projects/{project_name}/data/` directory at `--bench-parent` inside the container (default `/workspace`), so bench files stay directly accessible on the host and survive container recreation
5. Resolves the `frappe/bench` image tag to pin (never uses `:latest`): `--bench-image-tag` is used verbatim and skips the lookup; otherwise a short-lived on-disk cache serves repeated inits and only a cache miss queries Docker Hub for the latest stable tag
6. Pulls Docker images and starts containers
7. On hosts that expose uid/gid information, aligns the container's `frappe` user to the host user so bench files written to the mounted workspace stay host-owned and removable by `cwcli rm` (no-op when the ids already match; platforms without `os.getuid`, such as Windows, cleanly skip alignment because Docker Desktop handles bind-mount ownership; a failed attempted remap degrades to a warning and bench creation still proceeds). On a **root host** (uid 0, a common CI-runner shape) the container `frappe` user is **not** remapped to 0 - that would collide with the container's own root user and break bench's `PATH` (`bench init` fails with exit 127) - so it falls back to the image's default uid (1000) and says so once in a warning; root can still remove those files, so `cwcli rm` keeps working. Pass `--uid <n>` to align to a specific uid instead
8. Pins the correct Python version via `PYENV_VERSION` for the branch (installs via pyenv if missing)
9. Pins the correct Node.js version via nvm for older branches (installs via nvm if missing)
10. Installs `yarn` globally for the activated Node.js version (older branches only)
11. Initializes Frappe bench with specified branch
12. Pins `setuptools<82` inside the bench virtualenv for `version-13` (retains `pkg_resources`)
13. Configures database and Redis connections
14. Creates site with admin credentials (admin password generated and printed once when `--admin-password` is omitted in an interactive run; required as a flag non-interactively)
15. Enables developer mode and server scripts
16. Optionally installs ERPNext
17. Starts the bench's dev services (supervisord over `bench start`), unless `--no-start` is given; a start failure degrades to a warning rather than a non-zero exit, since the bench was already created successfully

**Branch-Specific Runtime Setup:**

| Branch | Python | Node.js | Bench Image |
|--------|--------|---------|-------------|
| `version-16` (default) | Default | Default | Latest stable from Docker Hub |
| `version-15` | 3.12.x (via pyenv) | Default | Latest stable from Docker Hub |
| `version-14` | 3.10.x (via pyenv) | 16 (via nvm) + yarn | Latest stable from Docker Hub |
| `version-13` | 3.9.x (via pyenv) | 14 (via nvm) + yarn | Latest stable from Docker Hub |
| Other | Default | Default | Latest stable from Docker Hub |

Version gating keys on the major version parsed from the ref, so a semantic-version tag
(e.g. `v14.80.0` from `--version 14.80.0`) is gated the same way its branch equivalent
(`version-14`) is.

Missing Python or Node.js versions are automatically installed inside the container.
`version-13` also pins `setuptools<82` in the bench virtualenv after init.

Site creation picks the MariaDB flag per major version: `14` and older use `--no-mariadb-socket`,
while `15` and newer use `--mariadb-user-host-login-scope=%` (a flag that only exists in bench/Frappe 15+).

**Examples:**

```bash
# Initialize with interactive prompts
cwcli init

# Initialize with project name
cwcli init my-project

# Initialize with custom port (avoids conflicts with other projects)
cwcli init my-project --port 10000

# Initialize with ERPNext
cwcli init my-project --install-erpnext

# Site named a real domain or IP (must resolve to this machine for browser access)
cwcli init my-project --site erp.example.com
cwcli init my-project --site 192.168.1.50

# Pick a Frappe version by shape: a bare major -> version-N branch
cwcli init my-project --version 16

# ...or a full semantic version -> vX.Y.Z tag
cwcli init my-project --version 16.26.3

# Build from a Frappe fork, checking out its branch
cwcli init my-project --frappe-url https://github.com/me/frappe --frappe-branch my-feature

# Full customization
cwcli init my-project \
  --port 12000 \
  --bench custom-bench \
  --site myapp.localhost \
  --frappe-branch version-16 \
  --install-erpnext \
  --erpnext-branch version-16 \
  --admin-password secretpass

# Verbose mode for debugging
cwcli init my-project -v

# Create without starting dev services (automation/CI)
cwcli init my-project --no-start
```

**Example Output:**

```
✓ Successfully initialized bench 'frappe-bench' in 8m 32s
Bench path: /workspace/frappe-bench

✓ Dev services are running for 'my-project'.
Open:    http://development.localhost:8000  (or `cwcli open my-project`)
Logs:    cwcli logs my-project
Stop:    cwcli stop my-project
Restart: cwcli restart my-project

Administrator password (generated): 3sK9nQx7Lm-2pT4vWbY6Za
Shown once and not stored anywhere. To change it later, run `cwcli run my-project --site development.localhost set-admin-password <new-password>`.
```

The generated administrator password prints only when `--admin-password` is omitted in an interactive run; supply `--admin-password` to set it yourself (and to run non-interactively).

With `--no-start`, the bench is created but its dev services are left down:

```
✓ Successfully initialized bench 'frappe-bench' in 8m 32s
Bench path: /workspace/frappe-bench

Dev services are not running for 'my-project'.
Start them: cwcli start my-project
Then open http://development.localhost:8000 (or `cwcli open my-project`).
```

If the dev services fail to start, init still exits successfully (the bench was already created) and prints a warning telling you to run `cwcli start` yourself.
The start itself waits for the web server to answer on the bench's own configured port before declaring success; if that times out, init reports the same "not running" output even though the containers and supervisor did launch, since the web isn't actually serving yet.
If the port cannot be read, start skips the wait and warns instead of probing another bench's port.
If the web server is running inside the container but its port is not published to the host, init prints a warning naming the missing host binding and advises recreating the instance.

**Port Conflict Handling:**

Init retries occupied ports five times, one second apart, to allow bindings from a just-removed instance to finish releasing.
Each retry is announced in both interactive and non-interactive runs.
If the ports remain occupied, init exits nonzero before creating the instance and names every conflicting port:

```
Error: The following ports are already in use: 8000-8005

Tip: If an instance using these ports was just removed, they may still be releasing - wait a few seconds and retry.
Tip: Otherwise, use the --port flag to select a different starting port.
Example: cwcli init my-project --port 10000
```

**Reusing Existing Bench:**

If a bench already exists at the specified path (the devcontainer image ships a default `/workspace/frappe-bench`, so a fresh `init` usually hits this):

```
Bench 'frappe-bench' already exists at /workspace/frappe-bench.
? Reuse the existing bench 'frappe-bench' and continue with site setup? (Y/n)
```

Answer `Y` to reuse it as-is (bench initialization is skipped and site setup continues on the existing bench).
Answer `n` to set up a fresh bench instead; you're prompted for a different bench name and site setup continues there:

```
? Enter a different bench name to create (leave blank to cancel): my-bench
```

Leaving the name blank (or cancelling either prompt) makes no changes and exits cleanly:

```
No changes made.
```

**Non-interactive (agents/automation):** pass a flag so `init` never prompts.
Re-running `init` against the devcontainer's shipped bench, pass `--reuse-bench`:

```bash
# Reuse the existing bench and run site setup with zero prompts (skips bench init)
cwcli init my-project --reuse-bench --auto-start

# Require a brand-new bench; error out if the name already exists
cwcli init my-project --bench fresh-bench --no-reuse-bench
```

Without a reuse flag, a non-interactive session (non-TTY) where the bench already exists refuses honestly with exit 1 instead of hanging, and names `--reuse-bench`/`--no-reuse-bench`.

---

### `new-bench` - Add a Bench to an Instance

Adds a bench to an existing instance with the same provisioning `init` gives bench 0: `bench init` for the chosen version or fork, the bench configs, a first site, and optionally ERPNext.
It then starts the bench's processes and fetches and installs any extra apps on the first site.
The other benches in the instance keep running.

```bash
cwcli new-bench [OPTIONS] PROJECT_NAME BENCH_NAME
```

**Options:**

| Option | Description |
|--------|-------------|
| `--site TEXT` | The first site's name (default: `<BENCH_NAME>.localhost`) |
| `--version TEXT` | Frappe version: a major (`16`, the default) or a tag (`16.3.0`) |
| `--frappe-branch TEXT` | A raw Frappe git ref instead of `--version` |
| `--frappe-url TEXT` | A Frappe fork to build from (private forks use the credential bridge) |
| `--erpnext` | Install ERPNext on the first site |
| `-a`, `--app TEXT` | An extra app (git URL or name) to fetch and install on the first site (repeatable) |
| `--admin-password TEXT` | Administrator password for the first site |
| `--db-root-password TEXT` | MariaDB root password (default: the one remembered at init; env `CWCLI_DB_ROOT_PASSWORD`) |
| `-y`, `--yes` | Auto-start a stopped instance without prompting |
| `-v`, `--verbose` | Stream bench's output |

The Administrator password comes from `--admin-password`, else the `CWCLI_ADMIN_PASSWORD` environment variable, else a hidden prompt.
A non-interactive session with neither refuses with exit 2.
A bench name that already exists is refused, never reused.
The command exits 1 when an extra app fails to install or the bench's processes did not start, even though the bench was created.

```bash
CWCLI_ADMIN_PASSWORD=... cwcli new-bench my-project bench-v15 --version 15 --erpnext
cwcli new-bench my-project hrms-bench --app https://github.com/frappe/hrms
```

**Agent surface:** `cwcli axi new-bench` is the TOON spelling.

---

### `new-site` - Add a Site to a Bench

Creates a site on an existing bench: `bench new-site`, then `install-app` for each `--app`, `bench use` with `--default`, and developer mode with `--dev-mode`.

```bash
cwcli new-site [OPTIONS] PROJECT_NAME SITE
```

**Options:**

| Option | Description |
|--------|-------------|
| `--bench TEXT` | Which bench: its numeric index or label |
| `-a`, `--app TEXT` | An app already on the bench to install on the site (repeatable) |
| `--default` | Make it the bench's default site |
| `--dev-mode` | Turn developer mode on for the new site |
| `--admin-password TEXT` | Administrator password |
| `--db-root-password TEXT` | MariaDB root password (default: the one remembered at init; env `CWCLI_DB_ROOT_PASSWORD`) |
| `-y`, `--yes` | Auto-start a stopped instance without prompting |
| `-v`, `--verbose` | Stream bench's output |

The Administrator password is resolved the same way as for `new-bench`: the flag, then `CWCLI_ADMIN_PASSWORD`, then a hidden prompt, and a non-interactive session with neither refuses with exit 2.
A site name that already exists on the bench is refused, and every `--app` must already be in the bench's `apps/` folder (fetch one first with `cwcli apps install <project> <app> --fetch-only`).

```bash
CWCLI_ADMIN_PASSWORD=... cwcli new-site my-project shop.localhost --app erpnext --default
cwcli new-site my-project shop.localhost --bench 1 --dev-mode
```

**Agent surface:** `cwcli axi new-site` is the TOON spelling.

---

### `ls` - List Projects

Scans Docker for Frappe/ERPNext projects and displays their status and ports.

```bash
cwcli ls [OPTIONS]
```

**Options:**

| Option | Description |
|--------|-------------|
| `-v`, `--verbose` | Display all ports individually, without condensing them into ranges |
| `-q`, `--quiet` | Only display project names, one per line (useful for scripting) |
| `--json` | Output the list of instances as a raw JSON string |

**Example Output:**

```
┌──────────────┬─────────┬──────────────────┐
│ Project Name │ Status  │ Ports            │
├──────────────┼─────────┼──────────────────┤
│ frappe-one   │ running │ 8000-8005, 9000  │
│ frappe-two   │ exited  │                  │
└──────────────┴─────────┴──────────────────┘
```

---

### `where` - Search Apps and Sites

Searches all cached instances for apps or sites matching a string. Useful for finding which projects have a specific app installed or contain a particular site.

```bash
cwcli where [OPTIONS] SEARCH
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `SEARCH` | Search string to match against app or site names (case-insensitive) |

**Options:**

| Option | Description |
|--------|-------------|
| `-a`, `--apps` | Search only for apps |
| `-s`, `--sites` | Search only for sites |
| `-i`, `--installed` | Show only installed apps (not just available). Only applies to app search |
| `--json` | Output results as JSON |
| `--no-verify` | Skip the live check that each match's instance still exists |

**Examples:**

```bash
# Find all instances with 'erpnext'
cwcli where erpnext

# Find apps matching 'payments'
cwcli where payments --apps

# Find sites matching 'local'
cwcli where local --sites

# JSON output for scripting
cwcli where frappe --json
```

**Example Output:**

```
                          Apps matching 'frappe'
┏━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓
┃ Project    ┃ App    ┃ Version ┃ Branch     ┃ Site                  ┃ Instance        ┃
┡━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩
│ my-project │ frappe │ 15.93.0 │ version-15 │ development.localhost │ present         │
│ test       │ frappe │ 15.88.2 │ version-15 │ development.localhost │ absent (cached) │
└────────────┴────────┴─────────┴────────────┴───────────────────────┴─────────────────┘

Found 2 matches.
Cached results reference 1 instance(s) that no longer exist: test. Run `cwcli inspect <project>` to refresh the cache.
```

**Notes:**
- Searches use cached project data. Run `cwcli inspect <project>` to populate/refresh the cache.
- The cache outlives the instances it describes, so every match carries an **Instance** state (`project_state` in `--json`): `present` means the instance was confirmed live just now, `absent` means it is cached but gone, and `unverified` means the check did not run.
- The check is a single Docker project listing per invocation regardless of match count, and never execs into a bench, so it does not re-incur the cost the cache exists to avoid. `--no-verify` skips it and reports every row as `unverified`.
- When the Docker daemon is unreachable, results degrade to `unverified` with a warning rather than being presented as confirmed.
- A stale row is reported, never pruned. Refreshing the cache is `cwcli inspect`'s job; `where` only reads.
- When an app exists both as "available" and "installed" in the same project, only the installed version is shown (with version/branch info).
- Use `--installed` to exclude apps that are available but not yet installed on any site.

---

### `start` - Start Containers

Starts a project's containers and runs the bench under **supervisord**, with
**automatic port conflict detection and resolution**. It is **idempotent**:
re-running against an already-running bench reports "already running" and does
nothing - it never spawns a second supervisor stack. On first supervise it
installs `supervisor` into the bench environment; each Procfile process becomes a
supervised program that can be restarted or self-healed individually (see
[`restart`](#restart---restart-containers)).

```bash
cwcli start [OPTIONS] [PROJECT_NAME]...
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | The name(s) of the Frappe project(s) to start (can be piped from stdin) |

**Options:**

| Option | Description |
|--------|-------------|
| `--bench TEXT` | Which bench to run: its numeric index or label (see [Working with Multiple Benches](#working-with-multiple-benches)). On a multi-bench project with no `--bench` it prompts interactively and refuses (non-zero) on a non-TTY |
| `--autorestart` / `--no-autorestart` | Self-heal crashed processes (default on): supervisord restarts a program that crashes (not one that exits cleanly), surfacing a crash-loop as `FATAL`. `--no-autorestart` leaves a crashed program down until an explicit restart. Set at launch |
| `-y`, `--yes` | Auto-confirm stopping conflicting Frappe projects to free their ports (non-interactive) |
| `-v`, `--verbose` | Enable verbose diagnostic output |

**Features:**

- **Idempotent:** a re-run on an already-running bench is a clean no-op ("already running: N/N processes up"), never a second supervisor stack
- **Per-process supervision:** each Procfile process runs under supervisord, so one can be restarted or auto-healed without disturbing the others
- **Waits for the web server:** on a genuine launch, `start` blocks until the bench's **own** web port actually answers before reporting the bench as running, so a scripted `cwcli start && cwcli status` never catches a transient `degraded`. The port is read from that bench's `sites/common_site_config.json`, never assumed - waiting on a hardcoded `:8000` meant `--bench 1` watched bench 0's port and then warned that a healthy bench had not started. If the port cannot be read, the wait is **skipped** (`web_ready` stays unset) rather than spent on a guess. A timeout (60s) does not fail the start (the stack IS launched) - it prints a warning naming the real port and telling you to check `cwcli status` / `cwcli logs`
- **Host port verification:** on both a genuine launch and an idempotent no-op, `start` warns when the bench's web server has no live host port binding. The warning names the container port and advises recreating the instance with `cwcli init`
- **Recovers a missing compose network:** when a Docker or WSL crash has removed the instance's own network (`<project>_default`), `start` recreates it, reconnects the stopped containers with their service aliases, and prints a warning, so the instance starts without manual repair. No container is recreated. A missing network the project does not own, or a recreate that fails, stops the start with the exact `docker network` command to run
- **Port Conflict Detection:** Automatically checks if required ports are available
- **Interactive Resolution:** Offers to stop conflicting Frappe projects (use `--yes` to auto-confirm)
- **Process Identification:** Shows which processes are using ports (cross-platform)
- **Smart Error Messages:** Provides actionable guidance for resolution
- **Multi-Bench Aware:** On a multi-bench project with no `--bench`, it prompts which bench interactively and refuses non-interactively; pick one explicitly with `--bench <index|label>`

**Example:**

```bash
# Start a single project
cwcli start frappe-one

# Start multiple projects
cwcli start frappe-one frappe-two

# Pipe from ls
cwcli ls --quiet | cwcli start
```

**Port Conflict Example:**

```
Warning: Some ports needed by 'frappe-one' are in use by other Frappe projects:
  • Project 'frappe-two': 8000-8005

? Stop project 'frappe-two' to free up its ports? (Y/n)
```

---

### `stop` - Stop Containers

Stops a running project's containers, or with `--bench`, just one bench's dev
processes.

```bash
cwcli stop [OPTIONS] [PROJECT_NAME]...
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | The name(s) of the Frappe project(s) to stop (can be piped from stdin) |

**Options:**

| Option | Description |
|--------|-------------|
| `--bench TEXT` | Stop only this bench's dev processes (numeric index or label), leaving sibling benches and every container running. Omit to stop the whole instance |
| `-v`, `--verbose` | Enable verbose diagnostic output |

`--bench` is the inverse of `cwcli start --bench`: an instance is one container
holding sibling benches, so stopping a single bench ends that bench's own
supervisor rather than stopping any container. Sibling benches keep serving, the
containers stay up, and `cwcli start --bench` brings it back. Stopping a bench
that is already stopped is a success, not an error.

A whole-instance stop takes the database down last and gives it up to 60 seconds
to finish its shutdown. If the database exits non-zero (137 means it was killed
before it finished shutting down), `stop` says so and exits non-zero instead of
reporting a clean stop.

**Example:**

```bash
# Stop a single project
cwcli stop frappe-one

# Stop multiple projects
cwcli stop frappe-one frappe-two

# Stop just bench 1's dev processes; bench 0 keeps serving
cwcli stop frappe-one --bench 1
```

---

### `restart` - Restart Containers

Restarts a project. Without `--process` it restarts the whole stack (stop + start
the containers, relaunching the supervisor). With `--process <label>` it restarts
just that **one** supervised program while its siblings keep running - so a stuck
`web` or `worker` can be cycled without a full-stack bounce.

**Known limitation:** a whole-stack `cwcli restart` (and the restart `cwcli
restore` runs after a migration) always relaunches with the `--autorestart`
default (on). It does NOT persist a prior `cwcli start --no-autorestart`
choice - re-run `cwcli start --no-autorestart` afterward to reassert it.

```bash
cwcli restart [OPTIONS] [PROJECT_NAME]...
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | The name(s) of the Frappe project(s) to restart (can be piped from stdin) |

**Options:**

| Option | Description |
|--------|-------------|
| `-p`, `--process TEXT` | Restart ONE Procfile process (e.g. `web`, `worker`, `socketio`), leaving its siblings running. Omit to restart the whole stack |
| `--bench TEXT` | Which bench to act on: its numeric index or label (multi-bench projects). With `--process` it selects the process's bench; without it, the bench relaunched once the containers are back |
| `-v`, `--verbose` | Enable verbose diagnostic output |

On a multi-bench project a whole-stack restart takes every container down and
brings **one** bench back up; `--bench` names which. Omitted, it relaunches the
first bench and says so.

**Example:**

```bash
# Whole-stack restart
cwcli restart frappe-one

# Restart just the web process (siblings keep running)
cwcli restart frappe-one --process web

# Whole-stack restart, bringing bench 1 back up
cwcli restart frappe-one --bench 1
```

**Example Output:**

```
# whole stack
Attempting to restart 1 project(s)...
✓ Instance 'frappe-one' stopped.
✓ Instance 'frappe-one' started.
✓ Started bench (logs: /workspace/frappe-bench/logs)
View logs with: cwcli logs frappe-one

# single process
Instance 'frappe-one': restarted process web (pid 123 -> 456, RUNNING)
```

---

### `scale` - Widen the Published Port Range

An instance publishes a **fixed range of six web ports and six socketio ports** when it is created.
Because each bench is assigned its own web/socketio port by bench's own port allocator (it scans sibling benches in the shared workspace and counts up from 8000/9000), a **seventh** serving bench binds its port *inside* the container fine but is **silently unreachable from the host** - no crash, no warning.

`cwcli scale` fixes that: it reads each bench's assigned port from its own `sites/common_site_config.json` (the source of truth - cwcli never invents a competing port store), widens the frappe service's published range in `conf/docker-compose.yml` to cover every bench, and applies it by **recreating only the frappe service** (`docker compose up -d --no-deps frappe`).
This is **database-safe**: `--no-deps` leaves MariaDB, Redis, and the database volume untouched.

Recreation reverts the container's home directory to the image, which wipes the **runtime-installed** node/python of **v13/v14** benches (v15/v16 are baked into the image and survive).
`cwcli scale` repairs that automatically - it probes each bench's interpreter and re-runs cwcli's own idempotent installers only for a bench that actually broke - then relaunches every bench.

Because every bench shares one container's supervisor, expanding the range **restarts every serving bench in the instance** - this is unavoidable (the port map is fixed when the container is created), so `cwcli scale` confirms before proceeding unless `--yes` is given.

> **Note:** publishing more ports does not add memory. Several full benches serving at once can exhaust host RAM well before the port range does - memory, not ports, is the practical ceiling.

```bash
cwcli scale [OPTIONS] PROJECT_NAME
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | The Frappe instance to scale |

**Options:**

| Option | Description |
|--------|-------------|
| `--to INTEGER` | Ensure at least this many benches are reachable from the host (publish at least this many ports). Omit to auto-fit every existing bench |
| `-y`, `--yes` | Skip the whole-instance-restart confirmation |

**Example:**

```bash
# Auto-fit: widen the range to cover every bench that is currently past the ceiling
cwcli scale my-project

# Pre-provision headroom for eight benches (restarts the instance, no prompt)
cwcli scale my-project --to 8 --yes
```

**Example Output:**

```
Expanded 'my-project' from 6 to 8 published ports and restarted 2 bench(es).

Host port map:
  /workspace/frappe-bench: web localhost:16000 socketio localhost:17000 - reachable
  /workspace/bench2: web localhost:16006 socketio localhost:17006 - reachable
```

Re-running when the range already covers every bench is a safe no-op.

---

### `rm` - Remove Project

Removes a Frappe project: its containers, named Docker volumes, own Docker Compose network, and local project directory.

**WARNING:** This action is destructive and cannot be undone.
Before deleting anything, the command re-caches the project, backs up the databases and files for all sites across every bench (a live `bench backup --with-files`, run once per bench on a multi-bench project), and archives the `docker-compose.yml`, `site_config.json`, and the project's `conf/` directory into a timestamped folder under `~/.cwcli/archive/` (or `$CWCLI_HOME/archive/` when the `CWCLI_HOME` override is set).
The backup and the config archive are written first, and the named volumes and project directory are only deleted once both have succeeded.
Each site's verified backup is then moved into the [backup store](#backups---the-backup-store) as a `before-removal` set, which outlives the instance.
When volumes are being deleted (the default `--volumes`), a backup that cannot be fully created and verified blocks removal: each backup artifact is copied out of the container to the host archive, and the database dump must be present and non-empty.
If verification fails, the command aborts before any container is removed and exits non-zero, so data is never destroyed without a confirmed backup.
Aborting before removal keeps the whole project intact (containers, volumes, network, directory, and cache), so a retry can still take a live backup from the running container.
Under `--no-volumes` no volume data is destroyed, so a failed backup does not block the container, network, directory, and cache cleanup.

A live `bench backup` needs a running frappe and MariaDB, so when you remove a **stopped** project on the `--volumes` path, `cwcli rm` transiently **starts** it, takes a verified backup, and only then deletes it (start → back up → delete), reusing the same port-conflict handling as `cwcli start`.
The confirmation prompt discloses this before you confirm.
If the project cannot be started, or the backup fails, the removal is aborted: all data is kept, the project is returned to its stopped state, and the command exits non-zero - fix the problem and retry, or pass `--no-backup` to delete without a backup.
An orphan project with named volumes is refused on the default `--volumes` path because there is no running bench to back up.
The same fail-closed refusal applies when Docker cannot verify whether named volumes exist.
If Docker confirms that no named volumes remain, no backup is needed: `cwcli rm` cleans up the orphan's network and local directory under the default settings.
The volume state is checked again before cleanup, and any change or verification failure aborts instead of risking unbacked data.
Under `--no-volumes` no volume data is destroyed, so a stopped project is cleaned up without a backup as before.
A half-provisioned instance that holds no site on any bench has nothing to back up, so - like a confirmed volume-free orphan - the removal proceeds instead of refusing and sending you back for a second `--no-backup` run; this relaxes only on a *live* proof that every bench is site-less, and any ambiguity keeps the fail-closed refusal.

```bash
cwcli rm [OPTIONS] [PROJECT_NAME]...
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | The name(s) of the Frappe project(s) to remove (can be piped from stdin) |

**Options:**

| Option | Description |
|--------|-------------|
| `--volumes` / `--no-volumes` | Remove the named Docker volumes (databases, sites, files). Default: `--volumes`. Use `--no-volumes` to keep them; the project's own network is removed either way |
| `--no-backup` | Skip database backups before removal, including the backup safety gate (also skips recaching; faster but risky) |
| `-y`, `--yes` | Skip the confirmation prompt and proceed with removal |
| `-v`, `--verbose` | Enable verbose diagnostic output |

By default `cwcli rm` removes the containers, the project's named Docker volumes (where the databases, sites, and files live), the project's own Docker Compose network, the local project directory at `~/.cwcli/projects/{project_name}/`, and the project cache entry.

`--no-volumes` preserves the named Docker volumes so the project can be recreated from existing data, but still removes the containers, project network, local project directory, and cache entry.
Network removal is unconditional because the network holds no user data.
The project directory is always removed because it is cwcli configuration, not data.

If a project has no containers but still has a network, named volumes, or a local directory (for example after a partial removal), `cwcli rm` treats it as an orphan instead of reporting it as not found.
Confirmed volume-free orphans are cleaned under the defaults.
Orphans with named volumes, or an unknown volume state, remain protected by the backup gate.

An invalid project name (empty, blank/whitespace-only, `.`, `..`, an absolute path, or one containing a path separator) is rejected before anything is removed, since such a name could otherwise escape the projects directory or resolve to a phantom project; a blank name is reported as `(empty)` rather than an empty quote.
`cwcli rm` exits non-zero whenever any removal step fails, including a blocked backup, a rejected name, or a container, volume, network, or directory that could not be removed.
It does not print the success summary for a project that was not fully removed; the project stays in the cache (and `cwcli ls`) so the leftover state remains visible and can be retried.

**Examples:**

```bash
# Remove everything (with confirmation)
cwcli rm my-project

# Keep the named volumes; remove containers, project network, and directory
cwcli rm my-project --no-volumes

# Skip backups (not recommended)
cwcli rm my-project --no-backup

# Skip the confirmation prompt
cwcli rm my-project --yes

# Remove multiple projects via pipe
cwcli ls | cwcli rm
```

---

### `rm-site` - Drop One Site

Permanently drops ONE site from a running bench, leaving the instance and every other site on it running.
This is deliberately a separate, noun-scoped command rather than a `--site` flag on `cwcli rm`: `cwcli rm` destroys the whole instance (containers, volumes, the project directory), while `rm-site` only ever touches the one named site, so forgetting a flag can never turn a single-site cleanup into an instance-wide deletion.

**WARNING:** This action is destructive and cannot be undone.
`cwcli rm-site` backs the site up first, then runs `bench drop-site --no-backup`, which drops the site's database and deletes its files.

The backup is the same verified gate `cwcli rm` uses, taken by cwcli itself before anything is dropped: `bench --site <site> backup --with-files`, then this run's database dump and file archives are copied out to `~/.cwcli/archive/{project}_dropped_sites/{site}_{timestamp}/backups/` (or `$CWCLI_HOME/archive/...` when that override is set) and the dump is checked non-empty there.
If the backup fails or cannot be verified, nothing is dropped, and the command exits non-zero with bench's own error.
cwcli never relies on `bench drop-site`'s own backup, because with `--force` bench ignores a failed backup and drops the database anyway.
To drop a site whose backup cannot be taken, run `cwcli run <project> --path <bench> -i drop-site <site> --no-backup` yourself; the error names that command.

`bench drop-site` then archives the dropped site's full directory - including `site_config.json` (its database credentials and, if set, its encryption key) - inside the container's own `archived/sites/` folder.
Left there it would grow, unpruned, forever, for the rest of the bench's life.
So `cwcli rm-site` copies that one archive out beside the backup (as `{site}.tar` in the same directory) and, only once that copy is verified non-empty on disk, deletes the in-container copy.
If anything cannot be verified, the in-container archive is left in place rather than deleted unbacked, and the command exits non-zero so the leftover credentials are never silently missed.

Once the drop has finished, the verified backup files are moved into the [backup store](#backups---the-backup-store) as a `before-removal` set (the `{site}.tar` stays in the archive directory).
To restore the dropped site, run `cwcli backups restore <project>/<site>/<id> <project> --site <site>`; `cwcli backups list <project>` shows the set's key.
If the move into the store fails, the files stay in the archive's `backups/` directory and nothing is lost.

```bash
cwcli rm-site [OPTIONS] PROJECT_NAME SITE
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | The Docker Compose project name (required) |
| `SITE` | The site to permanently drop (required; there is no default-site fallback) |

**Options:**

| Option | Description |
|--------|-------------|
| `--bench TEXT` | Which bench to target: its numeric index or label (see [Working with Multiple Benches](#working-with-multiple-benches)) |
| `-p`, `--path TEXT` | Explicit bench directory inside the container (lower-level alternative to `--bench`; cannot be combined with it) |
| `--db-root-password TEXT` | MariaDB root password used by `bench drop-site` (default: the password remembered for the instance at `init`, else `123`) |
| `-y`, `--yes` | Skip the destructive confirmation and auto-start containers |
| `-v`, `--verbose` | Enable verbose output |

**Examples:**

```bash
# Drop a site (with confirmation)
cwcli rm-site my-project task-42.localhost

# Skip the confirmation prompt
cwcli rm-site my-project task-42.localhost --yes

# Target a specific bench in a multi-bench project
cwcli rm-site my-project task-42.localhost --bench 1 --yes
```

**Agent surface:** `cwcli axi rm-site <project> <site> --yes` emits the outcome as TOON, including `archived_host_path` (the directory holding the verified backup and the site archive) and `archive_pruned_in_container`.
`--yes` is required (never a prompt) and grants ONLY consent to drop the site - it never auto-starts a stopped project, exactly like every other bench-scoped `axi` verb.
See [For agents: the `cwcli axi` surface](#for-agents-the-cwcli-axi-surface).

---

### `rm-bench` - Remove One Bench

Permanently removes ONE bench from an instance, leaving every other bench and every container running.
Like `rm-site`, this is a separate, noun-scoped command rather than a `--bench` flag on `cwcli rm`: `cwcli rm` destroys the whole instance, while `rm-bench` only ever touches the one bench you name, so forgetting a flag can never turn a single-bench cleanup into an instance-wide deletion.

A bench is a directory on the instance's shared workspace bind mount, and its sites' data lives in the shared database - a bench has **no** dedicated container or named volume of its own.
So `cwcli rm-bench` backs up and drops every site on the target bench (deleting each site's database and files), then deletes the bench directory.

**WARNING:** This action is destructive and cannot be undone.
Every site is backed up BEFORE any site is dropped, with the same verified gate `cwcli rm-site` and `cwcli rm` use: each site's database dump and file archives are copied out to `~/.cwcli/archive/{project}_dropped_sites/{site}_{timestamp}/backups/` (or `$CWCLI_HOME/archive/...` when that override is set) and the dump is checked non-empty there.
If any site's backup fails, nothing is dropped and nothing is deleted, and the command exits non-zero with bench's own error.
Each site is then dropped with `bench drop-site --no-backup` and its archived site directory copied out beside its backup.
If any site's archive cannot be copied out, the bench directory is kept (so the trapped archive is never destroyed) and the command exits non-zero.

A bench that is still running is refused: stop it first with `cwcli stop <project> --bench <selector>`.
`cwcli rm-bench` never deletes a bench out from under its own running code.

```bash
cwcli rm-bench [OPTIONS] PROJECT_NAME
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | The Docker Compose project name (required) |

**Options:**

| Option | Description |
|--------|-------------|
| `--bench TEXT` | Which bench to remove: its numeric index or label (see [Working with Multiple Benches](#working-with-multiple-benches)) |
| `-p`, `--path TEXT` | Explicit bench directory inside the container (lower-level alternative to `--bench`; cannot be combined with it) |
| `--db-root-password TEXT` | MariaDB root password used by `bench drop-site` (default: the password remembered for the instance at `init`, else `123`) |
| `-y`, `--yes` | Skip the destructive confirmation and auto-start the container |
| `-v`, `--verbose` | Enable verbose output |

**Examples:**

```bash
# Remove a bench (with confirmation)
cwcli rm-bench my-project --bench 2

# Skip the confirmation prompt
cwcli rm-bench my-project --bench staging --yes
```

**Agent surface:** `cwcli axi rm-bench <project> --bench <index|label> --yes` emits the outcome as TOON, including `sites_dropped`, `archived_host_paths`, `dir_removed`, and `ok`.
`--yes` is required (never a prompt) and grants ONLY consent - it never auto-starts a stopped project, exactly like every other bench-scoped `axi` verb.
A running bench is a non-zero refusal naming `cwcli stop`.
See [For agents: the `cwcli axi` surface](#for-agents-the-cwcli-axi-surface).

---

### `logs` - View Bench Logs

View the bench's process logs. supervisord writes one log file per Procfile
program under `<bench>/logs/<program>.supervisor.log` on the workspace volume (so
they survive a container restart, with built-in rotation). With no `--process`,
`cwcli logs` synthesizes a combined view across every program; `--process <label>`
tails just one program's log.

**Not under cwcli supervision:** if a bench is running but was NOT started by
cwcli's supervisord - a plain `bench start`, or an instance already running before
cwcli's supervisor existed - `cwcli logs` does not falsely report no logs. It falls
back to discovering and tailing the bench's real `<bench>/logs/*.log` files
(honcho's log names differ from supervisord's per-process files), with `--process`
filtered by file stem. `cwcli logs` only reads - it never launches or installs
supervisord.

```bash
cwcli logs [OPTIONS] PROJECT_NAME
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | The name of the Frappe project to view logs for (required) |

**Options:**

| Option | Description |
|--------|-------------|
| `-f`, `--follow` / `--no-follow` | Follow log output in real-time (default: no-follow - print the tail and exit) |
| `-n`, `--lines INTEGER` | Number of lines to show from the end of the logs (default: 100) |
| `-p`, `--process TEXT` | Tail ONE process's log (e.g. `web`, `worker`, `socketio`). Omit for a combined view of every process |
| `--bench TEXT` | Which bench's logs to view: its numeric index or label (multi-bench projects) |
| `-y`, `--yes` | Auto-start stopped containers without prompting |
| `-v`, `--verbose` | Enable verbose diagnostic output |

**Examples:**

```bash
# Combined view: last 100 lines of every process, then exit (default)
cwcli logs frappe-one

# Tail just the web process's log
cwcli logs frappe-one --process web

# Show last 50 lines and exit
cwcli logs frappe-one --lines 50

# Follow logs in real-time
cwcli logs frappe-one --follow

# Show last 200 lines and follow
cwcli logs frappe-one -n 200 --follow
```

**Note:** Per-process logs are stored at `<bench>/logs/<program>.supervisor.log` on the workspace volume inside the container.

`cwcli logs` propagates `tail`'s own exit code rather than always reporting
success (a killed `tail` exits 137, and so does `cwcli logs`). Stopping with
Ctrl-C is not a failure: `tail` exiting 130 (docker forwards `^C` into the
container on the interactive path) and a direct `KeyboardInterrupt` (the
non-TTY path) both print `Stopped viewing logs.` and exit 0.

---

### `inspect` - Inspect Project Structure

Inspects a project to find all bench instances, sites, and apps within it. Results are cached for faster subsequent operations.

**Freshness:** When a project is already cached and its containers are running, `inspect` runs a lightweight, read-only freshness pass over the known benches (a quick `ls` of `apps/`/`sites/`) before showing results.
This means an app you just installed is picked up automatically, without a manual `cwcli inspect --update`.
If nothing changed, the cached data is served unchanged; if that pass detects a change, it transparently falls back to a full re-inspect so the per-site installed-app lists are refreshed too.
Use `--no-refresh` to skip the pass and return the cached data as-is (fastest, but possibly stale), or `--update` to force a full re-inspect.

**App-copy flags:** on a full inspect (not a cache-served read) `inspect` flags an app whose `apps/<app>` is a symlink (showing the real directory) and, more seriously, an app whose imported copy differs from `apps/<app>` (the bench imports a different copy, so the site runs code that changes to `apps/<app>` never reach).
These are live-observed and not persisted to the cache.

**Security Note:** The cache never stores Frappe secrets. Site and common configurations are whitelist-filtered before they are written, so database passwords, per-site encryption keys, admin/root passwords, and Redis URLs are stripped and never persisted (nothing reads them back from the cache - every credential consumer reads live from the container or from CLI flags/prompts). Any cache written before this behavior shipped is cleaned in place on the next run. As defense-in-depth, the cache is also stored with restricted filesystem permissions (directory: `0700`, database: `0600`) so only the current user can read it; still, do not share the cache directory (`~/.cwcli/cache/`) with untrusted users.

```bash
cwcli inspect [OPTIONS] PROJECT_NAME
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | The Docker Compose project to inspect (required) |

**Options:**

| Option | Description |
|--------|-------------|
| `--bench <index\|label>` | Narrow the report to one bench on a multi-bench project (its numeric index or label). Every bench is still discovered/refreshed/cached regardless; only the output is narrowed |
| `-v`, `--verbose` | Enable verbose diagnostic output |
| `-j`, `--json` | Output the result as a JSON object |
| `-u`, `--update` | Update the cache by re-inspecting the project |
| `--no-refresh` | Return cached data as-is, skipping the lightweight freshness pass (fastest; may be stale) |
| `-i`, `--interactive` | Prompt for a durable [label](#working-with-multiple-benches) for each bench (persisted to the cache and a marker file inside the bench). Requires a running container to write the marker |
| `-y`, `--yes` | Auto-start stopped containers without prompting (non-interactive) |

**What It Caches:**
- Bench instances and their paths
- Sites and installed apps for each bench
- Site configurations (non-secret keys only, e.g. database name, developer mode settings; passwords and encryption keys are stripped)
- Common site configuration (non-secret keys only, e.g. ports, default site, developer mode; Redis URLs and other secrets are stripped)
- Default-site pointer from `sites/currentsite.txt` (written by `bench use`), so the `(default)` marker and default-site resolution work even when `common_site_config.json` has no `default_site` key
- Default site is labeled with `(default)` in output (resolved from either source)

**Example Output:**

Each bench is shown with its numeric index (its default `--bench` selector) and any user label:

```
Project frappe-one
├── Bench [0] 'primary' at /workspace/frappe-bench
│   ├── Site: frappe-one.localhost (default)
│   │   ├── App: frappe (v15.0.0, develop)
│   │   └── App: erpnext (v15.0.0, version-15)
│   └── Site: site2.localhost
│       ├── App: frappe (v15.0.0, develop)
│       └── App: erpnext (v15.0.0, version-15)
└── Bench [1] at /workspace/frappe-bench-2
    └── Site: site3.localhost
        └── App: frappe (v15.0.0, develop)
```

**Benefits:**
- Enables default site feature: `unlock` command can omit `--site` flag
- Faster subsequent operations (uses cached data)
- Stores configurations for programmatic access
- Assigns numeric indices to benches so `--bench` can target one in a multi-bench project (see [Working with Multiple Benches](#working-with-multiple-benches))

**Examples:**

```bash
# Inspect and cache project structure
cwcli inspect frappe-one

# Force refresh the cache
cwcli inspect frappe-one --update

# Return cached data as-is, skipping the freshness pass (fastest)
cwcli inspect frappe-one --no-refresh

# Get JSON output
cwcli inspect frappe-one --json

# Narrow the report to one bench in a multi-bench project
cwcli inspect frappe-one --bench 1

# Interactively label each bench (durable handle for --bench)
cwcli inspect frappe-one --interactive
```

---

### `label` - Manage Bench Labels

Assigns, clears, or lists per-bench user labels for a project.
A user label is a durable, human-friendly alternative to the bench's stable numeric identity, and is the value you pass to `--bench` on the bench-operating commands.

```bash
cwcli label [OPTIONS] PROJECT_NAME [SELECTOR] [NEW_LABEL]
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | The Docker Compose project name (required) |
| `SELECTOR` | Which bench to label: its numeric index or an existing label. Omit to list the benches |
| `NEW_LABEL` | The new label to assign. Omit and pass `--clear` to remove the label |

**Options:**

| Option | Description |
|--------|-------------|
| `--clear` | Remove the selected bench's user label (revert to its numeric index) |
| `-v`, `--verbose` | Print the resolved marker file path and any operation notes on completion |

**Label rules:**

- May contain only letters, digits, dot (`.`), dash (`-`), and underscore (`_`), up to 64 characters
- May **not** be purely numeric (numeric selectors are reserved for bench indices)
- Must be unique within the project

**Behavior:**

- Run `cwcli inspect <project>` first so the benches are cached; without a cache the command errors.
- Listing benches (bare `cwcli label <project>`) is read-only and never starts anything, but it does check each cached bench against the running container so a bench whose directory has been removed is marked **GONE** instead of listed as if it were there.
  With no container to ask (a stopped project, an unreachable daemon) the rows are marked *cached, not verified* rather than presented as confirmed; the listing still works, so it can still tell you which `--bench` to pass to `cwcli start`.
  A stale row is reported, never deleted - refreshing the cache is `cwcli inspect --update`'s job.
- Setting or clearing a label writes it to **both** the SQLite cache and a marker file (`<bench-root>/.cwcli/.bench-label`) inside the bench, so labels survive a cache wipe and can be rebuilt by a full `cwcli inspect --update`. Because the marker lives inside the bench, setting or clearing a label requires a running frappe container; it will **not** auto-start a stopped project.

**Examples:**

```bash
# List benches with their indices and labels
cwcli label my-project

# Label bench index 1 as 'staging'
cwcli label my-project 1 staging

# Rename label 'staging' to 'prod'
cwcli label my-project staging prod

# Remove bench 1's label
cwcli label my-project 1 --clear
```

---

### `open` - Open in VS Code, Cursor, or Docker Exec

Opens a project's frappe container in VS Code/Cursor (with Dev Containers) or executes into it.

```bash
cwcli open [OPTIONS] PROJECT_NAME
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | The Docker Compose project name to open (required) |

**Options:**

| Option | Description |
|--------|-------------|
| `--bench TEXT` | Which bench to open: its numeric index or label (see [Working with Multiple Benches](#working-with-multiple-benches)) |
| `-p`, `--path TEXT` | Explicit bench directory inside the container (lower-level alternative to `--bench`; cannot be combined with it) |
| `-a`, `--app TEXT` | App name to open (opens the app's directory within the bench) |
| `--code` | Open with VS Code directly (skips interactive prompt) |
| `--code-insiders` | Open with VS Code Insiders directly (skips interactive prompt) |
| `--cursor` | Open with Cursor directly (skips interactive prompt) |
| `--docker` | Open with Docker exec directly (skips interactive prompt) |
| `-y`, `--yes` | Auto-start stopped containers without prompting |
| `-v`, `--verbose` | Enable verbose diagnostic output |

**Features:**

- Auto-detects VS Code, VS Code Insiders, and Cursor installations
- Interactive editor selection menu (when no editor flag specified)
- Direct editor selection via `--code`, `--code-insiders`, `--cursor`, or `--docker` flags
- Automatically installs required VS Code extensions (Docker and Dev Containers)
- Uses cached bench paths from `inspect` command
- Docker exec opens in bench directory (respects working directory)
- Falls back to Docker exec if VS Code is unavailable

**Examples:**

```bash
# Open project with interactive prompt (uses cached bench path)
cwcli open frappe-one

# Open directly with VS Code (skip prompt)
cwcli open frappe-one --code

# Open directly with Docker exec (skip prompt)
cwcli open frappe-one --docker

# Open specific app directory with VS Code Insiders
cwcli open frappe-one --app erpnext --code-insiders

# Open custom path
cwcli open frappe-one --path /workspace/custom-bench
```

**Interactive Prompt:**

When no editor flag is specified, you'll see:

```
How would you like to open this instance?
❯ VS Code - Open in development container
  Docker - Execute interactive shell in container
```

---

### `apps` - Manage Frappe Apps

First-class app management: list, install, uninstall, and update Frappe apps per bench and per site.
This replaces dropping to the raw `cwcli run <project> bench get-app ...` escape hatch: every subcommand resolves the target bench (`--bench <index|label>`/`--path`), returns honest non-zero exit codes, and honors the non-interactive contract (a non-TTY without the required flag refuses non-zero; auto-start gated by `-y`/`--yes`). Every subcommand, `update` included, supports `--json` machine-readable output.
There is deliberately **no `cwcli axi run`/`axi exec` verb**: an arbitrary-command passthrough would re-open the exact raw-`cwcli run` escape hatch this `apps` group was built to close, so agents drive `apps` (and the other structured axi verbs) instead.

**Multi-site by default:** `install`, `uninstall`, and `apps update` apply to **all** sites on the resolved bench when no `--site` is given; `--site` is repeatable and narrows to the named site(s).
The fan-out runs per site, aggregates the results, and exits non-zero if any site fails (printing a per-site report) - a partial failure is never hidden behind a success banner.
After a successful mutation the bench's cached app lists are refreshed so `where`/`open`/`inspect` reflect the new state.

```bash
cwcli apps list [OPTIONS] PROJECT_NAME
cwcli apps install [OPTIONS] PROJECT_NAME [APP ...]   # or -a/--app APP
cwcli apps uninstall [OPTIONS] PROJECT_NAME --app APP [--app APP ...]
cwcli apps update [OPTIONS] PROJECT_NAME APPS...
cwcli apps checkout [OPTIONS] PROJECT_NAME APP REF
```

**`apps list`** - lists apps available in the bench (live `ls apps/`); with `--installed`/`--site` it also lists the apps installed per site (all sites by default, grouped by site).
It flags an app whose `apps/<app>` is a symlink (naming the real directory it points at) and, more seriously, an app whose imported copy differs from `apps/<app>` (the bench's Python actually imports a different copy, so operating on `apps/<app>` leaves the site running stale code); in `--json` an `app_copies` array carries the same detail for any flagged app.

**`apps install`** - ensures each app is present on the bench, then installs it on the target site(s).
An app absent from `apps/` is fetched with `bench get-app` (honoring `--branch`); an app already present **and registered** (in `sites/apps.txt`) is not fetched again, so pre-warmed benches install it without failing on the existing directory. A present directory that is *not* a valid install - the leftover of an earlier fetch that cloned but failed at its `pip install` step - is refused with an actionable message telling you to remove it and re-run, rather than reporting a false "get-app ok" and then dumping a raw install traceback on every retry. When a fresh fetch itself fails, the partial `apps/<app>` directory it created is removed so a retry genuinely re-fetches (a pre-existing app directory is never touched).
Each app is a known app name **or** a git URL (passed straight to `bench get-app` when a fetch is needed, so custom apps not in bench's registry work). Apps may be given positionally (matching `apps update`/`apps checkout` and `axi apps install`) or with `-a`/`--app`; both forms combine, and repeat either to install more than one.
`--fetch-only` ensures the app is present without installing it on any site.
`--if-not-present` makes install idempotent: an app already installed on a target site is skipped (reported, not reinstalled, with its install hooks never rerun) and the command still exits 0, so "ensure this app is installed" is a single call whose exit code a script can trust; an app the site does not have is still installed normally, and a genuine install failure still exits non-zero.
A bench that is already running may still serve code it loaded before the install and fail to see the new app: install therefore **resynchronises** that bench - it restarts every cwcli-supervised program that runs the bench's Python (`web`, `schedule`, and every worker) and requires every changed site to answer Frappe before it reports success.
Each restart is announced as it happens and the step is reported as a `restart-processes` row, and the command exits non-zero rather than claiming success for a site it could not confirm.
A running manager that cwcli does not own is verified without being restarted; an unhealthy site fails with a manual-restart remedy.
`socketio`, `watch`, and the redis programs are deliberately left alone: they import no Frappe app, and cycling redis would drop the cache and the job queue for nothing.
Restarting a worker does interrupt a job in flight, which is the honest cost of not leaving background jobs running code that no longer exists.
A bench that was not running is left alone.

**`apps uninstall`** - removes each app from the target site(s) (`bench --site <site> uninstall-app`). This destroys site data, so it is gated by `-y`/`--yes` or an interactive confirmation (a non-TTY without `--yes` refuses). It uses the same resynchronise-and-verify step as install, so no supervised process - web, scheduler, or worker - can keep running against the code and tables of an app that is gone.

**`apps update`** - the canonical app-update path (what the deprecated `cwcli update` now delegates to). Updating the `frappe` framework app (or `erpnext`) is a **core update**: only the core app you name is reset to its remote branch (`apps update <p> frappe` never touches erpnext) (`git fetch <remote> <branch>` then `git reset --hard <remote>/<branch>`, for the app's own remote and checked-out branch, never bench's bench-wide `bench update --reset`, which hard-reset every app on the bench), every site's stale `locks` folder is cleared first and listed (a site whose migrate lock is held by a running migrate refuses the update before anything runs, with or without the preflight), the bench's requirements are reinstalled, then every affected site migrates under the same per-site maintenance lifecycle as any other app; other apps use the normal git-pull + migrate flow. After the migrations finish and maintenance mode is lifted, it runs the same **resynchronise** step as `apps install`, so a `git pull` cannot leave the bench's web, scheduler, and workers on the code that was there before it; every migrated site must answer Frappe before the run reports success. `--site` narrows which affected sites are migrated; if none of the named site(s) actually have the app installed, the command refuses and exits non-zero rather than silently migrating nothing (a genuine typo/mismatch guard - a bench with no affected sites at all still exits zero). It accepts the same migration flags as the [deprecated `update` command](#update---update-apps-and-migrate) (`--clear-cache`, `--clear-website-cache`, `--build`, `--skip-maintenance`, `--no-recache`). A core update honours `--site` and those flags like any other app, and always rebuilds assets.
Because the core update never runs bench's own bench-wide update, nothing is set in `sites/common_site_config.json`: a site that fails to migrate is named as such (`failed_migrations`), its maintenance mode is cleared by cwcli's `finally`, and the summary states the bench state - which sites migrated, which did not and why, and whether any site is still in maintenance.
`--force` is an opt-in, **destructive** recovery for a conflicting update (updating `frappe` gives it a different meaning, described under [the data-loss guard](#apps---manage-frappe-apps) below): if an app's `git pull` hits a **conflict** (local changes that would be overwritten, or a conflicted merge), it hard-resets that app to its remote tracking branch (`git fetch` then `git reset --hard @{u}`), discarding the local changes, so the update completes instead of failing. It fires **only on a conflict** - a clean update is untouched, and a pull that failed for another reason (network, auth) is left as the failure it is rather than having work discarded. Without `--force`, a conflict fails safely and discards nothing. Every force-reset app is reported (`force_reset_apps`) and a warning is printed at the reset. `--force` does not delete untracked files (it never runs `git clean`), matching `apps checkout --reset`. `--force` also clears a git **"dubious ownership"** refusal before pulling: when an app's repository is owned by a different user than the container runs as - common on a shared-mode or migrated instance whose app sources are owned by a pre-migration uid - it re-owns the repository to the container user so the pull proceeds, rather than surfacing a raw git ownership error. A `cwcli start`/`cwcli restart` self-heals the same ownership so a plain `apps update` works too; `--force` fixes it inline without a restart.
**The `frappe` preflight.** A core update resets the core app's git checkout and reinstalls the bench's requirements before any site migrates, so a git problem (a branch the remote lacks, an unreachable remote, a detached HEAD) or a code requirement the bench cannot meet would otherwise surface mid-run.
Updating `frappe` therefore runs a preflight first that predicts those failures in seconds, by evaluating bench's own rules against the same state bench reads.
It reports everything it finds at once, grouped as blockers, data loss, could-not-verify and notices, each with the cwcli command that fixes it.
Any blocker refuses the update with nothing changed and exits 1.
Blockers are: an app on a branch its remote does not have, or one its remote's fetch refspec does not cover (a single-branch clone after `cwcli apps checkout` of another branch), an unreachable remote (bench contacts every remote unless `shallow_clone` is set), a detached HEAD, `frappe`/`erpnext` on `master` or not a git checkout, `frappe` without an `upstream` remote, a remote bench would pick by mistake, a repository git refuses for dubious ownership, a path the container user cannot write, a missing `mariadb-dump`/`gzip`/`yarn`/`node`, an unreachable database or `redis_cache`, a site that cannot be opened, a held migrate lock, a nearly full disk, an unreadable `common_site_config.json`, and `release_bench`.
The preflight also reads the code the reset would install, from the same `git fetch` of each app's branch that bench runs, which moves only the remote-tracking branch and never the checked-out code.
It blocks when that code needs a newer Python than the bench's environment (`requires-python`) or a newer Node than the container's (`engines.node`), since bench would fail reinstalling it after every backup and every reset, and when it is a frappe major-version upgrade, which bench stops to confirm and cwcli's run cannot answer.
A `[tool.bench.frappe-dependencies]` the bench cannot satisfy - an app on `develop` declaring `frappe >=17` on a frappe 16 bench, or one app requiring another app's older line - is a blocker, because bench only warns and the site migrate then fails: the finding names the switch (`cwcli apps checkout <project> <app> version-N`), and `--fix-branches` has cwcli perform it before the update (a dirty tree still refuses).
A checked-out branch the remote's fetch refspec does not cover (a narrow clone switched by hand) is widened in place - `git remote set-branches --add <remote> <branch>` plus a fetch - and logged, since the reset needs `<remote>/<branch>` to exist; `cwcli apps checkout` tracks a branch the same way.
Uncommitted changes and unpushed commits that the reset would discard are reported as data loss, and refuse the update through the data-loss guard below.
Any other check that cannot run or times out is reported as unverified and never blocks.
No preflight can predict a failing patch, schema sync or asset build in the new code; those still surface only in the real run.
`--no-preflight` skips the checks, for a false positive such as a remote that only a proxy-configured fetch can reach; bench then fails wherever it fails.
It does not skip the data-loss guard below.
The findings ride the report as `preflight` (each with a stable `code`, `severity`, `app`/`site`, `message` and `remedy`) plus `preflight_blocked`, on `--json` and `cwcli axi apps update` alike.

**The data-loss guard.** The core reset hard-resets the named core apps (`frappe`, `erpnext`) to their remote branch, so it silently discards their uncommitted changes to tracked files and commits that exist on no remote, branch or tag; other apps are not touched, so their local work is not at risk.
Untracked files survive, because `git reset --hard` leaves them in place.
Updating `frappe` therefore refuses, with nothing run, while a core app being reset holds such work, and exits 1 naming each app.
An app whose local work cannot be checked refuses the same way, rather than being assumed clean.
`--force` accepts the loss: the reset runs and discards that work.
On this path `--force` also re-owns an app repository git refuses for dubious ownership before the checks run, and it never gets past a blocker.
The guard is a safety check, not a prediction, so `--no-preflight` does not skip it; under `--no-preflight` it runs alone, with no remote or service probe.
A terminal gets the same refusal as a script: there is no prompt, and `--force` is the only way through.
With `shallow_clone` set in the bench config, bench also expires the reflog and prunes after each reset, so the discarded work cannot be recovered afterwards.
The report carries `data_loss_refused` (the guard refused) and `data_loss_apps`: the apps holding the work, or after `--force`, the apps whose work bench was allowed to discard.

When `apps/<app>` is a symlink, the update writes through it and the run names the real directory it operated on.
After the pull, update verifies that the copy the bench actually imports is the copy it just updated; when they differ it does **not** report plain success - it warns, names both paths, and exits non-zero so a script notices the site is still running the other copy.

**`apps checkout`** - fetches and checks out an arbitrary branch, tag, or commit (`REF`) into an app that is **already present** in the bench (`apps/<app>`), so a specific feature branch can be put under test in the instance the app lives in.
Unlike `apps install` (bench acquisition when needed, then site installation) and `apps update` (the tracked upstream on every app), this targets one existing checkout: it runs `git fetch <remote> <ref>` then `git checkout -B <ref> FETCH_HEAD` in the app directory (the remote is auto-detected - `upstream` for a bench-installed app, `origin` for a hand-cloned one).
A **dirty working tree is refused** before anything is fetched, so uncommitted work in the in-instance checkout is never carried across a branch switch.
Once a step moves the tree (the `--reset` hard reset or the checkout), the command runs the same **resynchronise** step `apps install` uses, even if the checkout then fails.
Swapping the code under a running bench and leaving it serving the branch you just moved off is the quietest form of that defect, because nothing errors at all.
The sites proved are the ones with that app installed, so the command exits non-zero rather than reporting a checkout that left the app unable to serve.
If one site's installed-app list cannot be read, the command warns that the site was not checked and excludes it from the proof set.
If the bench's site list itself cannot be read, the resynchronisation fails.
Dirty means precisely **anything `git status --porcelain` reports: staged changes, unstaged modifications to tracked files, and untracked files**.
Files matched by `.gitignore` are not reported by git and so never count, which is why ordinary build residue (`__pycache__`, `node_modules`, `*.egg-info`) does not block a checkout.
`--reset` is the explicit opt-in through that refusal: it hard-resets the working tree before switching, discarding tracked local edits, so the checkout lands the clean tree a subsequent `build`/`migrate` needs on the fetched ref.
Note one honest limit: `--reset` does **not** delete untracked files, because cwcli never runs `git clean` - it lets the checkout proceed and leaves them where they are, and an untracked file the target ref would overwrite still makes the checkout fail.
Like `apps update`, checkout names the real directory when `apps/<app>` is a symlink, and after moving the tree it verifies the bench imports the copy it just changed; if the bench imports a different copy it warns, names both paths, and exits non-zero rather than reporting a checkout the site will not actually run.

**Private repos:** `apps install`/`apps update`/`apps checkout` (and the deprecated `update`), plus any bench subcommand run through [`cwcli run`](#run---execute-bench-commands) (`cwcli run <project> get-app <private-url>`, `cwcli run <project> update --pull`), transparently authenticate git fetches against private GitHub/GitLab app repos through your host's already-signed-in `gh`/`glab` - nothing to configure, no token ever stored in the container, and public repos are unaffected. Sign in on the host first (`gh auth login` / `glab auth login`). This credential bridge only serves operations run **through cwcli**; a raw `git` you run yourself inside the container (in a `docker exec` shell, or `cwcli open`) is not bridged and will prompt or fail auth. Run the fetch through `cwcli run <project> <bench-args>` instead, or - to bridge **interactive** git inside `cwcli open` or a `docker exec` shell (not just cwcli's own operations) - opt into the persistent [`config cred-bridge`](#config-cred-bridge---persistent-credential-bridge).

**Common Options:**

| Option | Description |
|--------|-------------|
| `--bench TEXT` | Which bench to target: its numeric index or label (see [Working with Multiple Benches](#working-with-multiple-benches)) |
| `-p`, `--path TEXT` | Explicit bench directory inside the container (lower-level alternative to `--bench`) |
| `-a`, `--app TEXT` | App name (`install` also accepts a git URL); repeatable. Required for `uninstall`; for `install` the app may instead be given positionally |
| `--site TEXT` | Target site(s); repeatable. Omit for all sites (list/install/uninstall) or all affected sites (update) |
| `--json` | Machine-readable JSON output (for `install`/`uninstall`, includes the per-`(app, site)` results; for `update`, the full per-phase report). Stdout carries only the document - bench output never corrupts it |
| `-y`, `--yes` | Auto-start stopped containers without prompting; for `uninstall`, also skip the destructive confirmation |
| `-v`, `--verbose` | Enable verbose output |

**Examples:**

```bash
# List apps available in the (only/selected) bench
cwcli apps list frappe-one

# List apps installed across every site, as JSON
cwcli apps list frappe-one --installed --json

# Install ERPNext on every site of the bench (positional app, or -a/--app)
cwcli apps install frappe-one erpnext

# Install more than one app in the same call
cwcli apps install frappe-one --app erpnext --app hrms

# Install a custom app from a git URL on one site
cwcli apps install frappe-one --app https://github.com/example/custom_app --site dev.localhost

# Fetch an app into the bench without installing it anywhere
cwcli apps install frappe-one --app payments --fetch-only

# Uninstall an app from all sites, non-interactively
cwcli apps uninstall frappe-one --app payments --yes

# Update an app (or the framework) - multi-bench aware
cwcli apps update frappe-one erpnext
cwcli apps update frappe-one frappe        # core update: frappe only, locks cleared, requirements, migrate

# Check a feature branch out into an already-installed app (private repo authed via the bridge)
cwcli apps checkout frappe-one erpnext feature/new-report
cwcli apps checkout frappe-one erpnext feature/new-report --reset   # force a clean tree
```

---

### `update` - Update Apps and Migrate

> **Deprecated:** use [`cwcli apps update`](#apps---manage-frappe-apps) instead. `cwcli update` keeps working as an alias (and now prints a deprecation notice), so existing scripts are unaffected.

Updates specified Frappe apps and migrates all sites where they are installed.
Updating the `frappe` framework app is the core update described under [`apps update`](#apps---manage-frappe-apps).

```bash
cwcli update [OPTIONS] PROJECT_NAME
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | The name of the project to update (required) |

**Options:**

| Option | Description |
|--------|-------------|
| `-a`, `--app TEXT` | App name(s) to update (specify multiple apps after `--app` or use `--app` multiple times) |
| `--bench TEXT` | Which bench to target: its numeric index or label (see [Working with Multiple Benches](#working-with-multiple-benches)) |
| `-p`, `--path TEXT` | Explicit bench directory inside the container (lower-level alternative to `--bench`; cannot be combined with it) |
| `--site TEXT` | Narrow migration to the named site(s); repeatable. Omit to migrate all affected sites. Refuses non-zero if none of the named sites are actually affected |
| `-v`, `--verbose` | Enable verbose output with streaming command execution |
| `-c`, `--clear-cache` | Clear cache for all affected sites after migration |
| `-w`, `--clear-website-cache` | Clear website cache for all affected sites after migration |
| `-b`, `--build` | Build assets after updating apps |
| `--skip-maintenance` | Skip enabling maintenance mode for affected sites during update |
| `--force` | Destructive. Updating `frappe`: run the core reset even though it discards uncommitted or unpushed work in that app (see [the data-loss guard](#apps---manage-frappe-apps)). Other apps: hard-reset an app whose `git pull` hits a conflict instead of failing. Either way, re-own app repositories git refuses for dubious ownership |
| `--fix-branches` | Updating `frappe`: switch an app whose declared frappe-dependencies do not fit this bench to the matching `version-N` branch first, instead of refusing |
| `--no-preflight` | Updating `frappe` only: skip the checks that run before the core reset (see [the `frappe` preflight](#apps---manage-frappe-apps)); the data-loss guard still runs |
| `-y`, `--yes` | Auto-start stopped containers without prompting |

**What It Does:**

1. Runs `git pull` in each specified app directory
2. Identifies all sites where the updated apps are installed
3. Enables maintenance mode for affected sites (to prevent user access during updates)
4. Runs `bench --site <site> migrate` for each site that entered maintenance mode (a site that could not be put into maintenance is skipped, reported as an error, and the command exits non-zero - it is never migrated unmaintained)
5. (Optional) Runs `bench build --app <app>` for successfully updated apps
6. (Optional) Runs `bench --site <site> clear-cache` for each migrated site
7. (Optional) Runs `bench --site <site> clear-website-cache` for each migrated site
8. Automatically clears locks folder for each migrated site to prevent stale locks
9. Disables maintenance mode for affected sites after completion (a site that cannot be taken back out of maintenance is reported as an error and the command exits non-zero)

**Examples:**

```bash
# Update a single app (with maintenance mode enabled by default)
cwcli update frappe-one --app erpnext

# Update multiple apps
cwcli update frappe-one --app frappe --app erpnext

# Update with build and cache clearing
cwcli update frappe-one --app erpnext --build --clear-cache --clear-website-cache

# Update without maintenance mode
cwcli update frappe-one --app erpnext --skip-maintenance

# Update with maintenance mode and all options
cwcli update frappe-one --app erpnext --build --clear-cache --clear-website-cache

# Update with verbose output
cwcli update frappe-one --app custom_app -v
```

**Note:** Maintenance mode is **enabled by default** for all affected sites during updates to prevent user access and potential data corruption. Use `--skip-maintenance` only if you need to update during operational hours when user access is critical.

**Example Output:**

```
Updating project: frappe-one

Updating 1 app(s) for project 'frappe-one'

→ Updating app: erpnext
✓ Successfully updated 'erpnext'
  Found 2 site(s) with 'erpnext' installed

Migrating 2 affected site(s)

✓ Migration complete for all affected sites

✓ Successfully updated 1 app(s)
```

---

### `unlock` - Unlock Site

Removes the locks folder for a specified site to unlock it.

```bash
cwcli unlock [OPTIONS] PROJECT_NAME
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | The Docker Compose project name (required) |

**Options:**

| Option | Description |
|--------|-------------|
| `-s`, `--site TEXT` | Site name to unlock. If not provided, uses the default site (from `common_site_config.json`'s `default_site` or `sites/currentsite.txt`), falling back to the sole site of a single-site bench |
| `--bench TEXT` | Which bench to target: its numeric index or label (see [Working with Multiple Benches](#working-with-multiple-benches)) |
| `-p`, `--path TEXT` | Explicit bench directory inside the container (lower-level alternative to `--bench`; cannot be combined with it) |
| `-y`, `--yes` | Auto-start stopped containers without prompting |
| `-v`, `--verbose` | Enable verbose output and print the removed paths on completion |

**What It Does:**

Removes the `{bench_path}/sites/{site_name}/locks` directory, which can help resolve issues when a site is stuck in a locked state due to incomplete migrations or background jobs.
A site with no locks folder is reported as an honest "already unlocked" success rather than a generic "unlocked" message.

**Smart Defaults:**
- If `--site` is not specified, uses your bench's configured default site (from `common_site_config.json`'s `default_site`, or `sites/currentsite.txt` when that key is absent); when no default is configured it falls back to the sole site of a single-site bench (the shape a fresh `cwcli init` leaves)
- A multi-site bench with no configured default still requires `--site`, and the error lists the available sites
- Shows "Using site '{site}'" when the site was resolved for you

**Examples:**

```bash
# Unlock a specific site
cwcli unlock my-project --site development.localhost

# Use the resolved site (no --site flag needed)
cwcli unlock my-project
# Output: Using site 'development.localhost'

# Verbose mode with default site
cwcli unlock my-project -v
```

**When to Use:**

- After a migration fails or is interrupted
- When you see "This document is currently locked and queued for execution" errors
- When background jobs don't complete properly

**Examples:**

```bash
# Unlock a site
cwcli unlock my-project --site development.localhost

# Unlock with verbose output to see files being removed
cwcli unlock my-project --site development.localhost -v
```

**Example Output:**

```
✓ Successfully unlocked site 'development.localhost'
Removed locks folder: /workspace/frappe-bench/sites/development.localhost/locks
```

**Note:** The `update` command automatically clears locks after completion, so manual unlocking is typically only needed for interrupted operations.

---

### `dev-mode` - Developer Mode for a Site

Shows, turns on, or turns off Frappe's developer mode for one site.
Frappe reads `developer_mode` from `common_site_config.json` (bench-wide), and a site's `site_config.json` overrides it, so the read names where the value you see comes from.

```bash
cwcli dev-mode [OPTIONS] PROJECT_NAME SITE [on|off]
```

| Option | Description |
|--------|-------------|
| `--bench TEXT` | Which bench: its numeric index or label |
| `--common` | Set it bench-wide in `common_site_config.json` instead of the site's `site_config.json` |
| `-y`, `--yes` | Skip the confirmation (and auto-start a stopped instance) |

Without `on` or `off` the command only reads the state.
A change asks for confirmation, runs `bench set-config developer_mode 1|0` for the site (or with `-g` for `--common`), and then clears the site's cache.
The command exits 1 if the cache could not be cleared.

```bash
cwcli dev-mode my-project example.localhost
cwcli dev-mode my-project example.localhost on
cwcli dev-mode my-project example.localhost off --common
```

---

### `db-access` - Check and Repair Site Database Users

Frappe grants each site's database user on the address the frappe container had when the site was created (`'<user>'@'172.21.0.5'`).
When a restart gives the container a new address, every site answers HTTP 500 with `OperationalError 1045 (Access denied)` while nothing in the site itself changed.
cwcli closes this three ways: every site it creates or restores gets its user widened to `'<user>'@'%'` right away, `start`/`restart` and the core update check the sites afterwards and report "database login refused for site X" instead of a bare 500, and `db-access` is the read and the one repair.

```bash
cwcli db-access check PROJECT_NAME [--bench SELECTOR] [--json]
cwcli db-access repair PROJECT_NAME [--bench SELECTOR] [--site NAME]... --yes [--json]
```

**Options:**

| Option | Description |
|--------|-------------|
| `--bench`, `-b` | Bench selector (index or label) on a multi-bench instance |
| `--site`, `-s` | `repair` only: limit the repair to these sites (default: every refused site) |
| `--mariadb-root-password` | MariaDB root password (default: the one stored for the instance; env `CWCLI_DB_ROOT_PASSWORD`) |
| `--yes`, `-y` | `repair` only: rename the users without asking |
| `--json` | Machine-readable output |

**Examples:**

```bash
# Which sites would be refused from the container's current address? (exit 1 if any)
cwcli db-access check staging16

# Rename each refused site's user to '%' so any container address may connect
cwcli db-access repair staging16 --yes
```

**Output:**

```
tmpcore.localhost  _a72993d7fe8f325a@{172.99.0.9}  container 172.21.0.5  REFUSED
```

The repair is a `RENAME USER` of the host part only: the user name, password and grants are untouched, and a user already on `'%'` is left alone.
The web UI shows the same finding on the restart or update job with a "Repair database access" button, and `cwcli axi db-access check` is the agent read.

---

### `backup` - Back Up a Site

Runs `bench backup` for a site inside the project's frappe container, optionally including public and private files.

```bash
cwcli backup [OPTIONS] PROJECT_NAME
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | The Docker Compose project name (required) |

**Options:**

| Option | Description |
|--------|-------------|
| `-s`, `--site TEXT` | Site name to back up. If not provided, uses the default site (from `common_site_config.json`'s `default_site` or `sites/currentsite.txt`), falling back to the sole site of a single-site bench; a multi-site bench with no default requires `--site` and the error lists the sites |
| `--bench TEXT` | Which bench to target: its numeric index or label (see [Working with Multiple Benches](#working-with-multiple-benches)) |
| `-p`, `--path TEXT` | Explicit bench directory inside the container (lower-level alternative to `--bench`; cannot be combined with it) |
| `--with-files` | Include public and private files in the backup |
| `-y`, `--yes` | Auto-start stopped containers without prompting |
| `-v`, `--verbose` | Enable verbose output |

**Examples:**

```bash
# Back up the default site (database only)
cwcli backup my-project

# Back up a specific site
cwcli backup my-project --site example.com

# Include public and private files
cwcli backup my-project --with-files

# Back up a specific bench in a multi-bench project
cwcli backup my-project --bench staging --with-files
```

---

### `backups` - The Backup Store

`cwcli backup` leaves its files inside the bench.
`cwcli backups` keeps backup sets outside every instance, under `~/.cwcli/backups/<project>/<site>/<id>/`, so they survive `cwcli rm`.
A set is identified by its key, `project/site/id`.
Taking a set copies the bench backup out to the host and checks every part: the database dump must be complete, the file archives must list, and the site config must parse.
Only a fully verified set has its copies inside the bench removed, and a set that fails a check is kept and marked unverified.

```bash
cwcli backups list [PROJECT] [--site NAME] [--json]
cwcli backups capture PROJECT [--site NAME] [--bench SEL] [--no-files] [--reason REASON] [--note TEXT] [--json]
cwcli backups verify KEY [--json]
cwcli backups delete KEY [--yes]
cwcli backups restore KEY PROJECT --site NAME [--bench SEL] [--admin-password PW] [--keep-config] [--no-migrate] [--yes] [--json]
cwcli backups transfer PROJECT --site SOURCE[:TARGET]... [--mode clone|move] [--to-project NAME] [--to-bench SEL] [--no-files] [--keep-config] [--yes] [--json]
cwcli backups send KEY...
cwcli backups receive TICKET [--project NAME --site NAME] [--json]
cwcli backups download KEY [--to DIR]
cwcli backups upload TAR [--project NAME --site NAME] [--json]
cwcli backups drill PROJECT --site NAME [--bench SEL] [--set KEY] [--json]
cwcli backups prune PROJECT [--dry-run] [--yes] [--json]
cwcli backups settings PROJECT [--daily N] [--weekly N] [--monthly N] [--safety-days N] [--safety-max N] [--min-free-gb N] [--stale-after-days N] [--schedule on|off] [--daily-at HH:MM] [--copy-on-backup/--no-copy-on-backup] [--json]
cwcli backups s3 configure [--endpoint URL] [--bucket NAME] [--access-key KEY] [--prefix P] [--region R] [--enable/--disable]
cwcli backups s3 list|copy|fetch|prune ...
cwcli backups key export --to FILE | import --from FILE [--replace]
```

| Subcommand | What it does |
|------------|--------------|
| `list` | Lists the sets, newest first, with each set's verdict and the store's disk use |
| `capture` | Takes a verified set of one site into the store (the site defaults like `cwcli backup`); exits 1 if the set did not verify |
| `verify` | Runs every check on a stored set again |
| `delete` | Deletes a set; a site's last verified set is never deleted |
| `restore` | Restores a set into a site: the same site, a new one, another bench, or another instance. It replaces all of the target site's data, so it asks first; a non-interactive session needs `--yes`. By default the set's app settings are carried over and the target's `db_*` keys are kept |
| `transfer` | Clones (default) or moves one or more sites to another name, bench, or instance, one site at a time |
| `send` / `receive` | Sends sets to another machine as a sendme ticket, and files a received ticket into this machine's store |
| `download` / `upload` | Writes one set as a plain (unencrypted) tar, and files such a tar into a store |
| `drill` | Restores the site's latest verified set into a throwaway site, checks that it answers, drops it, and records the result |
| `prune` | Applies the instance's retention policy; `--dry-run` lists what would be removed, and removing needs `--yes` |
| `settings` | Shows or changes the instance's retention, safety, disk floor, daily schedule, and whether new backups are copied off-site |
| `s3` | Encrypted off-site copies in an S3 bucket (AWS, MinIO, ...): `configure`, `list`, `copy`, `fetch`, `prune`. `list`, `copy`, `fetch` and `prune` work with whichever backup target integration is on, the S3 bucket or a community one. `configure` sets up the machine's S3 backup target integration, one bucket for every instance (the web UI's Settings > Integrations); it reads the secret key from `CWCLI_S3_SECRET_KEY`, tests the bucket, and turns the target on only when the bucket answers, exiting 1 otherwise. When another integration is the backup target it refuses unless `--switch`, which turns that one off once the bucket answers. `--disable` turns it off and keeps its settings |
| `key` | Exports or imports the key that encrypts off-site copies; restoring an off-site copy on another machine needs it |

**Safety rules:**

- A clone or move onto a target site that already exists asks for confirmation (the web UI asks you to type the site name), and a non-interactive session needs `--yes`. Before the target is replaced, cwcli takes a verified `before-overwrite` set of it into the store; if that set does not verify, the target is left alone.
- A move needs `--yes`, and drops each source only after its copy answered its checks, through the same backup gate `cwcli rm-site` uses.
- Retention keeps a site's newest verified set always, keeps safety sets (`before-*` reasons) for 14 days with at most 5 per site, and keeps the newest set of each of the last 7 days and 4 weeks (S3: 7 days, 4 weeks, 12 months). Change these with `cwcli backups settings`.
- `prune`, the scheduled prune, and the S3 prune never delete an unverified set. An unverified set takes no retention slot and stays until you delete it with `cwcli backups delete`.
- Apart from the daily backup you can switch on with `cwcli backups settings --schedule on`, cwcli takes an automatic backup in one case only: a core (frappe or erpnext) update started from the web UI or a schedule first takes a verified `before-core-update` set of every site on the bench, and stops before changing anything if one does not verify. `cwcli rm`, `rm-site`, and `rm-bench` also move their removal backups into the store as `before-removal` sets.

```bash
# Take a verified set of the default site, then list the store
cwcli backups capture my-project
cwcli backups list my-project

# Restore a set into a new site on bench 1
cwcli backups restore my-project/example.localhost/20261027_020000-b0 my-project --site copy.localhost --bench 1

# Clone a site to another instance under a new name
cwcli backups transfer my-project --site example.localhost:staging.localhost --to-project other-project

# See what the retention policy would remove, then remove it
cwcli backups prune my-project --dry-run
cwcli backups prune my-project --yes
```

**Agent surface:** `cwcli axi backups list|capture|verify|prune|settings` is the TOON spelling.

---

### `restore` - Restore Site from Backup

Interactively restore a site from a backup with automatic detection of file archives and encryption keys. Supports both local restoration and peer-to-peer backup transfers via sendme.

```bash
cwcli restore [OPTIONS] PROJECT_NAME
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | The Docker Compose project name (required) |

**Options:**

| Option | Description |
|--------|-------------|
| `-s`, `--site TEXT` | Site name to restore. If not provided, uses the default site (from `common_site_config.json`'s `default_site` or `sites/currentsite.txt`), falling back to the sole site of a single-site bench; a multi-site bench with no default requires `--site` and the error lists the sites |
| `--latest` | Non-interactive: select the most recent backup set for the target site (bypasses the backup menu). A non-TTY without any selector exits non-zero instead of hanging |
| `--backup-file TEXT` | Non-interactive: select the backup set whose database file matches this filename (or full container path). Bypasses the backup menu |
| `--bench TEXT` | Which bench to target: its numeric index or label (see [Working with Multiple Benches](#working-with-multiple-benches)) |
| `-p`, `--path TEXT` | Explicit bench directory inside the container (lower-level alternative to `--bench`; cannot be combined with it) |
| `--mariadb-root-username TEXT` | MariaDB root username (defaults to `root`; prompted interactively when omitted) |
| `--mariadb-root-password TEXT` | MariaDB root password. When omitted, the password remembered for the instance at `init` is used for the `root` user; with none remembered it is prompted interactively, and a non-TTY without this flag refuses rather than proceeding with an empty password |
| `--admin-password TEXT` | Set administrator password after restore |
| `--send` | **P2P Mode:** Share backup with another machine via peer-to-peer transfer |
| `--receive` | **P2P Mode:** Receive backup from another machine via peer-to-peer transfer |
| `--ticket TEXT` | The sendme ticket for `--receive`. When supplied, the ticket prompt is skipped; a non-TTY without `--ticket` refuses with a non-zero exit. Obviously malformed or truncated tickets are rejected before the transfer starts |
| `--no-recache` | **Deprecated no-op:** the missing-apps check now reads app availability live from the bench, so it never re-caches. Kept for backward compatibility |
| `--no-migrate` | Skip the post-restore `bench migrate` and instance restart. By default a successful restore runs `bench migrate` (bringing the restored DB to the code's schema) then restarts the instance |
| `-y`, `--yes` | Skip the interactive confirmation prompts on both the normal and `--receive` restore paths (the destructive-restore confirmation and the missing-apps prompt). A non-TTY without `--yes` refuses these and exits non-zero. Does not remove the sendme-ticket or MariaDB-credential prompts |
| `-v`, `--verbose` | Enable verbose output and show restore command details |

**What It Does:**

1. Scans all backup files across all sites in the bench
2. Presents an interactive menu with backups grouped by target site
3. Shows badges indicating backup contents: `[FILES]`, `[PRIVATE]`, `[DATABASE ONLY]`
4. Warns if the selected backup needs apps this bench does not have (read from the backup's own database dump, so a missing app is caught before the restore)
5. Automatically detects and restores public/private file archives
6. Restores encryption key from backup's site_config if available
7. Runs `bench migrate` and restarts the instance after a successful restore, so the restored database is brought to the code's schema and the app comes back up cleanly (skip with `--no-migrate`; a failed migrate is surfaced and exits non-zero but does not undo the restore)
8. Displays helpful error messages on failure with common causes

**Interactive Features:**

- **Smart Grouping**: Backups from the target site shown first, followed by backups from other sites
- **Visual Badges**: Clear indicators of what each backup contains
- **Secure Password Prompt**: Uses questionary for consistent, secure password input
- **Confirmation Dialog**: Warns about data replacement before restore
- **TipSpinner**: Shows helpful tips during restore operation

**Examples:**

```bash
# Restore with interactive backup selection (uses default site)
cwcli restore my-project

# Restore specific site
cwcli restore my-project --site production.localhost

# Restore with verbose output for debugging
cwcli restore my-project -v

# Provide password via command line (not recommended for production)
cwcli restore my-project --mariadb-root-password "secret123"
```

**Non-interactive / scripted restores:**

The backup-selection menu and both confirmation prompts (the destructive-restore and missing-apps warnings) can be skipped so a `cwcli restore` runs to completion with no prompts.
Pass `--latest` or `--backup-file <name>` to pick a backup without the menu, `--yes` to skip the confirmations, and `--mariadb-root-password` (and `--mariadb-root-username` if not `root`) for credentials.
A non-TTY missing any required selector or confirmation exits non-zero with a message naming the flag to pass, so a missing piece never hangs or silently succeeds.

```bash
# Restore the newest backup for the default site, fully non-interactive
cwcli restore my-project --latest --yes --mariadb-root-password "secret123"

# Restore a specific backup file by name or full container path
cwcli restore my-project --backup-file 20251112_105638-development_localhost-database.sql.gz \
  --yes --mariadb-root-password "secret123"

# Restore into a specific site non-interactively
cwcli restore my-project --site production.localhost --latest --yes \
  --mariadb-root-password "secret123"
```

> A declined confirmation (interactive `n`, or Ctrl-C), or a non-TTY run without `--yes` at a confirmation, exits non-zero rather than `0`.
> `--latest` and `--backup-file` are mutually exclusive, and neither applies to `--send`/`--receive` (those modes never reach the backup menu).


**Example Session:**

```
Using site 'development.localhost'

? Select a backup to restore: (Use arrow keys)

=== Backups from site: development.localhost ===
 > 2025-11-12 10:56:38  [FILES] [PRIVATE]
   2025-11-12 09:30:01  [DATABASE ONLY]
   2025-11-11 23:15:42  [FILES] [PRIVATE]

⚠ Warning: This will replace all data in site 'development.localhost'
Backup: 20251112_105638-development_localhost-database.sql.gz
From: 2025-11-12 10:56:38
Will restore: Database, Public files, Private files

? Are you sure you want to restore? (y/N) y
? MariaDB root username: root
MariaDB root password: ********

✓ Successfully restored site 'development.localhost'
From backup: 20251112_105638-development_localhost-database.sql.gz
Including file archives
Updated encryption_key from backup site_config

✓ Migrated site 'development.localhost'

Restarting instance...
✓ Instance restarted (logs: /workspace/frappe-bench/logs/web.dev.log)
```

**P2P Backup Transfer:**

Share backups between machines using peer-to-peer connections (powered by sendme/Iroh):

**Sending a Backup:**
```bash
# On the source machine
cwcli restore my-project --send

# Select backup from interactive menu
# Ticket automatically copied to clipboard
✓ Transfer ticket copied to clipboard!

Instructions for the receiver:
  1. Run: cwcli restore <project_name> --receive
  2. Paste the ticket when prompted
```

**Receiving a Backup:**
```bash
# On the destination machine
cwcli restore my-project --receive

# Paste the ticket from sender
? Enter the sendme ticket: blob...

# Or pass the ticket non-interactively (required on a non-TTY):
cwcli restore my-project --receive --ticket blob... \
  --mariadb-root-password secret --yes

# Files download with hash verification
✓ Files downloaded successfully

# Before overwriting the site, cwcli confirms the destructive restore
⚠ Warning: This will replace all data in site 'development.localhost'
Backup: 20251112_105638-development_localhost-database.sql.gz
? Are you sure you want to restore? (y/N) y
# Restore process begins
```

For scripted (non-interactive) receives, pass `-y`/`--yes` to skip the confirmation and the missing-apps prompt. Without a TTY and without `--yes`, cwcli refuses and exits non-zero rather than silently overwriting the site's data.
Before transferring, cwcli requires at least 2 GiB free in its temporary [transfer directory](#architecture).
It checks before the sender stages a backup and before the receiver downloads one.
The receiver also validates the target site before downloading.
The backup may require more space.
Set `TMPDIR`, `TEMP`, or `TMP` to place transfers on a larger filesystem, in that priority order, or relocate all cwcli data with `CWCLI_HOME`.
Copying the downloaded backup into the container streams through a bounded pipe instead of creating another full host-side copy.

**P2P Transfer Features:**
- **Hash-verified transfers** - BLAKE3 cryptographic verification ensures data integrity
- **NAT traversal** - Works behind firewalls and corporate networks
- **No cloud intermediary** - Direct peer-to-peer connections
- **Cross-platform** - Works on macOS, Linux, and Windows
- **Automatic setup** - sendme binary auto-installed on first use
- **Multiple receivers** - Same ticket can be used by multiple machines

**Use Cases:**
- Share production backups with development team
- Transfer large backups without cloud storage limits
- Migrate data between data centers
- Distribute backups to multiple environments simultaneously

**Security:**

- All command inputs (site name, paths, MariaDB username) are shell-quoted to prevent command injection
- MariaDB and admin passwords are passed to the container via the environment and referenced as `$VAR`s, so they never appear in the command string itself or in verbose output; the leaf `bench`/`frappe` process still briefly shows the plaintext value on its own argv (visible via `docker top`) while the operation runs, since `bench` accepts passwords only as a flag - an inherent bench limitation, not a cwcli gap
- Backup file existence verified before restore
- P2P transfers are hash-verified (BLAKE3) to prevent tampering
- Treat transfer tickets like passwords (they grant download access)

**When to Use:**

- Restore from scheduled backups after issues
- Migrate data between environments
- Recover from data corruption or accidental deletion
- Test backup integrity
- **Share backups between machines without cloud storage**

---

### `run` - Execute Bench Commands

Executes bench commands inside a project's frappe container.

```bash
cwcli run [OPTIONS] PROJECT_NAME BENCH_ARGS...
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | The Docker Compose project name (required) |
| `BENCH_ARGS` | Bench command and arguments to run (required) |

**Options:**

| Option | Description |
|--------|-------------|
| `--bench TEXT` | Which bench to target: its numeric index or label (see [Working with Multiple Benches](#working-with-multiple-benches)) |
| `-p`, `--path TEXT` | Explicit bench directory inside the container (lower-level alternative to `--bench`; cannot be combined with it). Falls back to the single cached bench, or `/workspace/frappe-bench` when nothing is cached |
| `-y`, `--yes` | Auto-start stopped containers without prompting |
| `-i`, `--interactive` | Forward stdin to the bench command, for bench commands that prompt |
| `-v`, `--verbose` | Enable verbose output |

**Passing bench's own flags:** a flag `cwcli run` does not define itself, such as
`--site`, `--branch`, or `--force`, is passed straight through to bench.
The options in the table above are the exception: they stay `cwcli`'s wherever
they appear, so `cwcli run frappe-one migrate --bench staging` targets the
`staging` bench rather than passing `--bench staging` to bench.
To send a flag that collides with one of those names, put it after a `--`
separator, which hands everything following it to bench verbatim:

```bash
# `--verbose` reaches bench instead of turning on cwcli's verbose output
cwcli run frappe-one -- build --verbose
```

For app management, prefer the [`apps`](#apps---manage-frappe-apps) group, which
takes these flags directly.

**Private repos:** a git fetch the bench subcommand makes (`get-app
<private-url>`, `update --pull`) authenticates through the same host `gh`/`glab`
credential bridge as `apps install` - no token in the container, public repos
unaffected.
When the persistent [`config cred-bridge`](#config-cred-bridge---persistent-credential-bridge)
is enabled and serving the instance, `run` uses it instead of standing up a
second, per-invocation bridge.

**Bench commands that prompt:** pass `-i`.
By default nothing is attached to the command's stdin, so a bench command that asks a question - `new-app`, `console`, `mariadb` - reads end-of-file and fails.
`-i` forwards stdin, which covers both ways the question gets answered: a human types the answers at a terminal, and automation pipes in the same lines.

```bash
# Interactive: answer frappe's new-app questions at the terminal
cwcli run frappe-one -i new-app my_app

# Non-interactive: pipe the same answers in
printf 'My App\nMy description\nme\nme@example.com\nmit\nn\n' \
  | cwcli run frappe-one -i new-app my_app
```

**Examples:**

```bash
# Run bench migrate
cwcli run frappe-one migrate

# Run bench with specific site (bench's own flags pass straight through)
cwcli run frappe-one --site development.localhost migrate

# Install an app from a branch
cwcli run frappe-one get-app --branch develop https://github.com/user/app.git

# Open bench's interactive console (`-i` attaches stdin)
cwcli run frappe-one -i console

# Target a specific bench in a multi-bench project
cwcli run frappe-one migrate --bench staging

# Use custom bench path
cwcli run frappe-one migrate --path /workspace/custom-bench
```

`cwcli run` exits with the bench command's own exit code. If the connection to
the command's output stream is lost, it reports that and exits non-zero rather
than claiming success.

Interrupting an `-i` command with Ctrl-C exits **130** once the command has been stopped inside the container.
If that cannot be confirmed - the bench command may still be running and still changing the bench - it says so and exits **1** instead, rather than reporting a clean stop it cannot vouch for.

---

### `status` - Check Health Status

Reports the health of a Frappe project instance: a single aggregate token on
stdout, with the per-bench, per-process breakdown on stderr.

An instance can hold several benches, and `status` reports **every** one of them
unless you narrow it with `--bench`. Each bench is probed on **its own** web port -
read from that bench's `sites/common_site_config.json`, which is what bench's own
`make_ports` writes - so a bench's HTTP code always describes that bench.

```bash
cwcli status [OPTIONS] [PROJECT_NAME]
```

**Arguments:**

| Argument | Description |
|----------|-------------|
| `PROJECT_NAME` | The Docker Compose project name to check. Omit it for the all-instances summary (the same table `cwcli ls` gives) - a bare `cwcli status` reads like "how is everything", not a missing-argument error |

**Options:**

| Option | Description |
|--------|-------------|
| `--bench` | Report ONE bench: its numeric index or label. Omit it to report every bench |
| `-v`, `--verbose` | Show the per-process detail and the web HTTP probe on stderr |
| `-w`, `--watch` | Live, continuously-refreshing per-process view (Ctrl-C to exit) |
| `--interval` | Seconds between `--watch` refreshes (default 2, floored at 1s) |

**Aggregate values** (printed on stdout, one token, exit 0). Each bench carries its
own; the stdout token is the **instance** fold over them (see below):

- **`offline`** - a real-but-stopped instance: the containers exist but the frappe container is not running
- **`online`** - the container is up, but the bench was never started (no supervisor)
- **`running`** - supervisord is up, every expected program is healthy, and the bench's own web port answers
- **`degraded`** - the bench was started but the supervisor is down (e.g. after a
  container restart), OR it is up but a program is not healthy (a `FATAL`/`BACKOFF`
  worker while the rest keeps serving - a stable partial stack), OR the web server
  is not answering

**The instance token folds the per-bench ones.** `degraded` dominates, so a real
fault is never masked by a healthy sibling. Otherwise a `running` bench wins over a
never-started `online` one: after `cwcli start <project> --bench 1`, bench 0 was
never started and bench 1 genuinely serves, and reporting `online` there would read
as "nothing is started" while a bench serves real traffic. With `--bench` the
instance token is that one bench's token by construction.

**The web port is never guessed.** If a bench's `sites/common_site_config.json`
cannot be read, or names no `webserver_port`, `status` reports `web_port: null` /
`web_port_verified: false`, makes **no** HTTP request for that bench, and warns
(`status.web_port_unknown`) - it does not fall back to `:8000`, because on a
multi-bench instance that measures a *different* bench's web server. A bench whose
port is unknown does not `degraded` on that account: cwcli not knowing where to look
is a gap in cwcli's knowledge, not a fault in the bench.

**The probe names the bench's site.** Frappe is multi-tenant and routes by the
`Host` header, so a request naming no site is correctly answered `404` by a
perfectly healthy bench - which is what `status` used to report on every read. The
probe now sends the bench's site as its `Host`, so a healthy bench reads `200`, and
the report says which site it asked for (`web_site`). That keeps the code
attributable the same way the port does: it is that site's code, not a claim about
the bench as a whole. A bench with no site recorded probes without a `Host` header,
as before, and reports `web_site: null`. The human `cwcli status` web line shows the
**host-reachable** address (`web <site>:<host-port> -> <code>`) - the same one the
init banner and `cwcli axi url` print, so it is copy-pasteable - resolved by mapping
the bench's container web port through the container's live host bindings; when that
host binding cannot be resolved it falls back to the container port labelled
`(in-container)` so nobody tries to open an unreachable address.

A truly-nonexistent project name (a typo, or one never created - no containers
with the label at all) is NOT `offline`: it exits non-zero with a "Project
'<name>' not found." error on stderr, so a script can tell a stopped instance apart
from a name that does not exist.

**Not under cwcli supervision:** if a bench is running but was NOT started by
cwcli's supervisord - a plain `bench start`, or an instance already running before
cwcli's supervisor existed - `status` does not report every process as down. It
falls back to detecting the honcho / `bench start` process tree and reports each
process's true `up`/PID/uptime, aggregating to the honest `running`/`degraded`. The
report is flagged `not_cwcli_supervised` (shown as `(not under cwcli supervision)`
in the human heading, `not_cwcli_supervised: true` in `cwcli axi status`) with a
hint to run `cwcli start` to bring it under cwcli's supervisor. In this mode the
per-process supervisord `state` is unavailable (there is no supervisord to ask);
`up` comes straight from `ps`. `status` only reads - it never launches supervisord.

**Maintenance mode is called out plainly.**
A site stuck in maintenance mode answers HTTP 503 to every user with no other signal - the state a `migrate` / `apps update` killed mid-run (a closed terminal, a `kill`, a service stop) leaves behind.
When a bench's site is in maintenance mode, `status` prints `MAINTENANCE MODE ON` for that site (even without `-v`, since it is a fault, not detail) and names the way out (`cwcli run <project> --site <site> set-maintenance-mode off`); `cwcli axi status` carries it as `maintenance_mode` plus a `status.maintenance_mode` warning.
It is read live the way Frappe reads it, not inferred from the 503: the bench's `sites/common_site_config.json` first, with the site's own `site_config.json` overriding it.
A failed `bench update` (the `cwcli apps update <project> frappe` path) leaves `maintenance_mode` and `pause_scheduler` set in that bench-wide file, which holds down every site on the bench that does not override them.
`status` reports each one it finds there (`status.maintenance_mode` and `status.scheduler_paused`) with the bench-wide way out, `cwcli run <project> set-config --global --parse maintenance_mode 0` (and the same for `pause_scheduler`); `set-maintenance-mode off` would bring back only the one site.
These reads are skipped in the `--watch` loop (which makes no web probe).

**Per-bench, per-process detail (stderr):** one sub-block per bench, headed by its
index, path, label, and own aggregate, with a web line naming the host-reachable
address that was probed (`web dev.localhost:17001 -> 200`, or the container port
labelled `(in-container)` when the host binding cannot be resolved). Beneath each, every Procfile process (`web`, `socketio`,
`worker`, `schedule`, `watch`, `redis_cache`, `redis_queue`) with up/down plus
its PID, uptime, CPU%, RSS, and supervisord's authoritative `state`
(`RUNNING`/`STARTING`/`BACKOFF`/`EXITED`/`FATAL`/`STOPPED`) - so a crash-looping or
give-up program is visible, not just "down". The stdout token stays a single word
so it is safe to script against; the detail is on stderr for humans (and in
structured form via `cwcli axi status`).

**Live view (`--watch`):** `cwcli status --watch frappe-one` shows a continuously
refreshing per-process table (rendered on stderr; Ctrl-C exits cleanly). Unlike
the one-shot command, the watch loop **never probes the web server** - it reads
process health from `ps` + supervisord's control socket each tick (neither touches
the web port), so watching leaves ZERO HTTP requests in the benches'
access logs. A `bench` column appears only when more than one bench is reported, so
the single-bench live view is unchanged. When stdout or stderr
is not a TTY (piped or redirected - the live view renders to stderr, so both
streams must be interactive), `--watch` degrades to a single quiet snapshot
instead of starting the live loop. (`cwcli axi status` stays one-shot - agents
re-invoke it for fresh reads rather than consuming a live stream.)

**Example:**

```bash
cwcli status frappe-one              # -> running (every bench, folded)
cwcli status frappe-one --bench 1    # -> running (just that bench)
cwcli status frappe-one -v           # token on stdout, per-bench detail on stderr
cwcli status --watch frappe-one      # live table, no web-log spam; Ctrl-C to exit
cwcli status -w --interval 5 frappe-one   # refresh every 5s
```

---

### `ui` - Open the Web UI

Opens a local browser dashboard over your instances: every instance, bench, site and app in one place, live process health, a resumable log pane, Settings (with the `doctor` health check inside it), a command palette (Ctrl+K) that searches the cache, and the everyday lifecycle actions.
Long work runs as detached jobs with a durable event log, so it continues when the tab closes and can be opened again from Jobs.
A failed job has a Retry button on its page that starts a new job with the same settings, so a creation that failed at the image pull continues from the project directory it left behind.
The passwords entered for a job are kept in the daemon's memory only (never in the job record or on disk) so Retry, and answering a decision the job stopped on, can reuse them; if the daemon has restarted, a creation reopens the form on its password step instead.
A retry never carries a destructive confirmation over: a removal asks for its typed name again, and a job that discards local work (`apps checkout --reset`, `apps update --force`) has no Retry and must be started again to confirm it.
A job that was interrupted, whose process vanished, that lost the output stream of a command inside the instance, or an `apps update` with a step whose outcome is unknown, has no Retry either, because its work may still be running inside the instance; its page says to review the last event before running it again.
The daemon binds `127.0.0.1` only and is served by cwcli itself; nothing is installed or fetched, and no Node is needed because the UI ships prebuilt inside the package.

```bash
cwcli ui [OPTIONS]
```

**Options:**

| Option | Description |
|--------|-------------|
| `-p`, `--port INTEGER` | Port to listen on (default: 8765) |
| `--foreground` | Run the daemon in this terminal (Ctrl-C to stop) instead of detached |
| `--stop` | Stop the detached daemon |
| `--no-browser` | Print the address instead of opening a browser |

**How it runs:** `cwcli ui` starts the daemon detached if none is running (otherwise it reuses the live one, unless cwcli was reinstalled since that daemon started, in which case it restarts it onto the new code), then opens your browser on `http://127.0.0.1:8765/`.
It also starts auto-inspect or the credential bridge when either is enabled with its boot unit but not running, and says so, or says why it could not.
The page asks for the **administrator account**: the username is `admin` and the first password is `admin`, and that first sign-in (and every sign-in after a reset) has to choose a new password - at least 8 characters, not the default - before anything else in the UI answers.
Sessions last 8 hours idle and 7 days at most; "Keep me signed in" makes both 30 days.
Five failed sign-ins within ten minutes lock the address for a minute and refuse further wrong passwords for the username, while the username's correct password still signs in, so nobody can lock the owner out.
Your account (in Settings) changes the password, lists the other signed-in browsers, signs them out, and signs out.
Appearance (in Settings, or the sidebar's Appearance row) picks the gradient theme and light, dark or the system setting (the default), and turns the moving visual effects off; the choice is kept in this browser.
If the password is lost, `cwcli admin reset-password` on the host puts the account back to `admin` / `admin` with the change forced again (see below).
With LDAP / Active Directory sign-in turned on (Settings > Integrations, below), directory users sign in on the same page with their own directory password, and the local `admin` account keeps working beside them as the way in when the directory is unreachable or misconfigured.

**Roles:** every signed-in person is an Admin, an Operator or a Viewer, and the daemon checks each request against the role it needs.
A Viewer reads everything the UI shows except the contents of backup sets (no download).
An Operator also runs the fleet: lifecycle actions, jobs, backups, transfers and schedules.
An Admin also changes Settings, Integrations and each instance's backup settings (retention, the daily schedule and off-site copies), updates cwcli itself, and runs everything that destroys data or code: removing or moving instances, benches, sites and backup sets, restoring a set over a site, pruning local or off-site backups, checking out an app discarding local changes, and forcing an app update past its data-loss guard.
A schedule that runs one of those (a prune) is created, changed, enabled or run by hand only by an Admin, and each scheduled run checks that the Admin who last saved it is still an Admin (a directory user is looked up in the directory again); otherwise the run is skipped and the schedule shows why.
The local `admin` account and a programmatic client holding the bearer token are Admins; a directory user's role comes from their directory groups.
The page hides what a role cannot do, and a request beyond the role is refused with `auth.role_forbidden`.
Programmatic clients (scripts, the self-update restart) keep using the bearer token in `CWCLI_SERVE_TOKEN`; nothing about a sign-in is ever in a URL.
`cwcli ui --stop` stops it.
While the daemon runs it polls process health for every running instance; the web check only runs for an instance a page has open.
That check is a bare TCP connect to the bench's web port, not an HTTP request, so polling writes nothing to the bench's web access log; the Bench page shows its verdict as `Listening` or `Not listening` next to the web port, or `unknown` while the latest health probe has failed and the page is showing the last successful probe's readings.
It refuses any request whose `Host` is not this machine, so another site in your browser cannot read or drive it.

**What it can do:** everything the CLI reads (`ls`, `status`, `inspect`, `where`, `doctor`, `logs`, `axi url`, `config show`) and the safe lifecycle set: start, stop and restart an instance or a bench, restart one process, set a bench label, unlock a site, take a site out of maintenance mode, widen the port range (the same core confirmation `cwcli scale` asks for, typed back as the instance name), and check out an app ref without `--reset`.
These lifecycle changes run as jobs too, so their steps and outcome remain visible after navigation.
When an action needs a stopped instance running, the page offers to start it first: an action on a bench, site or app starts that bench (`cwcli start <project> --bench <n>`), so a multi-bench instance never asks which bench, and an instance-level action starts only the containers, never a bench.
Remove bench on a stopped instance starts only its containers, and stops them again if the removal does not complete.
Restarting an instance from the page brings back exactly the benches that were serving (under cwcli's supervisor or `bench start`), unlike `cwcli restart <project>`, which brings back the first bench.
The long-job forms cover `init`, `apps install`, `apps update`, `apps checkout`, `backup`, `migrate`, `run-tests`, `build`, `setup-wizard`, and `self-update`.
`apps update` runs the same preflight as the CLI and shows every finding in the job view before the reset begins.
A failed job leads with its plain cause (for example MariaDB rejecting the database root password, or git failing to authenticate) when cwcli recognizes one in the failing step's output, and otherwise says it did not and points at that output.
Interrupt requests use the command's cleanup path; Detach only leaves the page, and an active record whose process disappears becomes `unknown` instead of being reported as failed or successful.
On Windows a job that has not begun its cleanup five minutes after Interrupt, or has not finished it two minutes after beginning, is terminated; one that never began is marked `unknown` because its cleanup may not have run.
Jobs keeps every active job and the 100 most recent finished ones; older finished records are pruned when a new job starts.
The `self-update` job restarts the local daemon and the open page reloads after its protocol check sees the replacement.
The guarded destructive verbs run as jobs too, each behind a typed confirmation of the exact name of what it destroys: remove an instance (`rm`: every site backed up first and the named volumes deletable only while the instance runs, mirroring `cwcli axi rm`, unless Force is ticked, which skips the verified backup like `cwcli rm --no-backup` after a red warning naming what is lost for good), drop a site (`rm-site`), remove a stopped bench (`rm-bench`), check out an app ref discarding local changes (`apps checkout --reset`, typed back as the app's name), and force a `frappe` update past the data-loss guard (`apps update` with Force ticked, typed back as the bench's name).
A failed backup drops nothing, exactly as on the command line, and the job page shows bench's own reason.
Settings is writable for an Admin: cache on/off, forget one remembered project or clear the whole cache (confirmed first), add, remove and prune bench search paths (the prune check names any stopped instance that keeps it from pruning), auto-inspect and its interval and boot hook, the persistent credential bridge and its boot unit, a Start (or Restart) button for either daemon while its feature is enabled, with the daemon log's last line shown while it is down, tips, and the agent SessionStart hooks `cwcli axi setup` installs; every control sits beside the terminal command that does the same.
Schedules puts any of those jobs on a timetable (templates, a friendly recurrence picker with the cron underneath, one-off runs, recipes of chained steps, "after a successful job" and "when the instance starts" triggers, a week's timeline, and "Schedule this…" on every job's page); see `schedule` below.
`cwcli restore` (the UI restores sets from the backup store instead), `apps uninstall`, `run`, `cwcli open`'s Docker shell (the UI opens VS Code, VS Code Insiders or Cursor only), an in-browser terminal, access from another machine and the shared multi-user mode are not in the UI; each needs its own decision first.

**Integrations (Settings > Integrations):** connections to systems outside cwcli, each off until an Admin turns it on.
Turning one on opens its setup, tests the connection, and turns it on only when the test passes; a change to an integration that is already on is applied only when the changed settings pass the test, so a typo never takes a working one down.
Each integration shows one of four states: Off, Needs setup (the last attempt to turn it on failed its test), On, or Error (it is on, and its last use failed).
Secrets are stored encrypted (AES-256-GCM) in `~/.cwcli/integrations.json` under a key in `~/.cwcli/keys/integrations.key`, both 0600; after a save the page shows only that a secret is set, and a new value replaces it.
A change to where a secret is sent (the LDAP server URL, StartTLS, CA certificate or service account, or the S3 endpoint) is refused with `integration.secret_retype` unless the secret is typed again, so a stored secret never goes to a server it was not saved for.
Two official integrations ship with cwcli, and an Admin can add community integrations from a repository (below).
Each integration provides hook points that cwcli calls: **sign-in** (who may sign in to the web UI, and with which role) and **the backup target** (where encrypted off-site copies go).
Only one integration serves a hook point at a time.
Turning on a second one that provides it asks the Admin to choose: **Test and switch** runs the new one's test and, only if it passes, turns it on and turns the other off, keeping its settings; nothing switches silently.

- **LDAP / Active Directory sign-in:** the server URL (`ldaps://` preferred, or `ldap://` with StartTLS; cwcli refuses to send passwords in clear text), the CA certificate that signed the directory's certificate (empty uses this machine's trusted CAs), Active Directory or OpenLDAP, a read-only service account and its password, the base DN, the user search filter (`{username}` is the typed name, escaped), the username, email and display-name attributes, and the allowed groups, each mapped to a role.
  At every sign-in cwcli finds the user with the service account, reads their groups (Active Directory: nested groups too; OpenLDAP: `groupOfNames`, `groupOfUniqueNames` and `posixGroup`), checks the password by binding as the user, and refuses an account the directory reports as disabled and anyone outside the allowed groups.
  Someone in several allowed groups gets the highest role, and a directory user can never take the local account's name.
  Directory users have no password change in cwcli and are never asked to pick one; the usual rate limiting and lockout apply, and their jobs record their directory username.
  The setup has a **Test connection** button and a **Look up a user** check that shows what sign-in would decide for a name (found, groups, disabled, role) without asking for a password.
  Saving the settings or turning sign-in off signs every directory user out.
- **S3 backup target:** one bucket (AWS, MinIO, Garage, Ceph) for every instance's encrypted off-site backup copies; the test lists the bucket and creates it when it does not exist.
  Each instance still chooses whether its new backups are copied there (Backups > Retention and schedule, or `cwcli backups settings --copy-on-backup/--no-copy-on-backup`).
  Upgrading from a version that set an S3 bucket per instance: cwcli turns the first such bucket (by instance name) into this integration once, and only the instances that were copying to that same bucket keep copying.
  Copies of an instance that used a different bucket are paused, never silently: the S3 card here and the Backups page name the instance and its old endpoint and bucket until its copies are turned back on.
  Every per-instance plaintext secret file (`~/.cwcli/backups/keys/s3-<instance>.secret`) is removed; type the secret key again in this integration if you set up that bucket here.
  `cwcli backups s3 configure` sets up the same integration from a terminal.
- **Community integrations:** **Add from a repository…** takes a GitHub, GitLab.com or self-hosted GitLab repository URL and an optional branch or tag.
  cwcli fetches the repository and reads its declaration without building or running anything, then shows the trust screen: the integration's name, publisher, description, the hook points it declares, the repository, and the exact commit the branch or tag points at.
  Nothing is installed until the Admin clicks **Install**, and then only that commit; if the branch moved in the meantime, cwcli refuses and asks for a new review.
  A public repository needs no sign-in.
  A private one is read with the sign-in this machine already has, the same way the credential bridge works: `gh` for GitHub, `glab` for GitLab.com and self-hosted GitLab.
  When that sign-in is missing, the error names the command to run, for example `glab auth login --hostname gitlab.example.org`.
  The card is marked **Community, not reviewed** and shows the repository and commit; it starts off and turns on through its own connection test like any other.
  **Check for update** shows the installed and the latest commit with the trust screen, and **Update** installs only that reviewed commit and turns the integration off, keeping its settings and secrets, so the new code's test runs before it is on again; nothing updates by itself.
  **Remove…** deletes the package, its settings and its secrets after the id is typed back.
  See `integrations` below for the terminal commands, the declaration a repository needs, and where the code is installed.

**Honest states:** the UI distinguishes `unknown` (not probed) from `offline`,
labels cache-backed data as remembered versus verified, and never guesses a
port - a site whose host port cannot be resolved reads "not serving".

**Under WSL:** a Windows browser reaches `http://localhost:8765` through WSL's
localhost forwarding; the address `cwcli ui` prints already works from there.

**Example:**

```bash
cwcli ui                       # start (or reuse) and open the browser
cwcli ui --no-browser          # print the address instead
cwcli ui --stop                # stop the detached daemon
cwcli ui --foreground -p 9000  # run in this terminal on port 9000
```

There is deliberately no `cwcli axi ui`: an agent does not stand up a
browser-facing daemon.

---

### `admin` - The Web UI Administrator Account

```bash
cwcli admin reset-password [--username admin]
```

Recovery from the host when the web UI password is lost: the account goes back to `admin` / `admin`, the next sign-in has to choose a new password, and every signed-in browser is signed out.
This local account always stays an Admin and always signs in with its own password, including while LDAP / Active Directory sign-in is on.
The account store is `~/.cwcli/accounts.json` (0600; passwords are scrypt hashes, sessions are stored as hashes of their ids), or the shared state tree on a shared install, where the reset needs root like the rest of `cwcli setup`.
There is deliberately no `cwcli axi` spelling: an agent must not reset a person's web UI credential.

---

### `integrations` - Community Integrations

```bash
cwcli integrations list [--json]
cwcli integrations add URL [--ref BRANCH_OR_TAG] [--yes]
cwcli integrations update ID [--check] [--yes]
cwcli integrations remove ID [--yes]
```

`list` shows every integration, official and community, with its state; a community one also shows its repository, commit, publisher and who installed it.
`add` fetches the repository (an `https://` GitHub, GitLab.com or self-hosted GitLab URL), prints what it declares and the exact commit, and installs that commit after you confirm; `--yes` confirms in a script, and without it a non-interactive run refuses.
A public repository is read with no sign-in: cwcli switches off every git credential helper and prompt for that first attempt.
Only when the host answers that a sign-in is required does git ask this machine's `gh` (GitHub) or `glab` (GitLab) sign-in, and a missing one fails with the command that fixes it.
A URL that carries a user name or token is refused; cwcli stores no token.
`update` shows the installed and the latest commit on the integration's branch or tag; `--check` stops there, otherwise it installs the latest commit after you confirm and turns the integration off until it is turned on again.
`remove` deletes the package, its settings and its secrets.
Every `add`, `update` and `remove` appends a line to `~/.cwcli/integrations-audit.log` with who did it, the URL and the commit.
On a shared install only an Admin changes community integrations: root here, or an Admin in the web UI.
There is deliberately no `cwcli axi` spelling: installing third-party code is a person's decision.

A community integration is a Python package whose repository declares it in `pyproject.toml`, which cwcli reads before running anything; a repository without a valid declaration is refused before anything is installed:

```toml
[project]
name = "cwcli-integration-localdir"
version = "0.1.0"
dependencies = []          # only your own; cwcli provides itself

[project.entry-points."cwcli.integrations"]
localdir = "cwcli_localdir:INTEGRATION"   # one per repository; the name is the integration's id

[tool.cwcli.integration]
name = "Local directory backup target"
publisher = "Example Org"
description = "Keeps off-site copies in a directory on this machine."
provides = ["backup_target"]       # sign_in and/or backup_target
```

The entry point names a `caffeinated_whale_cli.core.integrations.Integration` (its settings fields, connection test, optional actions) whose `hooks` maps each hook point it provides to the function cwcli calls; cwcli refuses one whose id or hook points differ from the declaration or that lacks a hook's function, and shows the declared name and description.
Once an Admin turns it on (its test runs first), cwcli calls it exactly where it calls the official integrations:

- `sign_in(settings, secrets, username, password)` returns the user (a dict with `username`, `role` - `admin`, `operator` or `viewer` - and optionally `name` and `email`) or `None` for a wrong name or password.
  It is asked for every name except the local `admin` account's, which always signs in locally; turning it off or changing it signs its users out.
  An optional `current_role(settings, secrets, username)` lets a schedule confirm its owner is still an Admin; without it, an Admin-only schedule saved by one of its users is skipped with that reason.
- `backup_target(settings, secrets)` returns the store off-site copies go to: an object with `location` (shown to people), `key(*parts)`, `ensure()`, `put_file(key, path)`, `put_bytes(key, data)`, `get_to_file(key, path)`, `get_bytes(key)`, `delete(key)` and `list(prefix)` (a list of `{"key": ...}`).
  cwcli encrypts every part before it reaches the target, exactly as for the S3 bucket, and `cwcli backups s3 list/copy/fetch/prune` work with whichever target is on.

A failure inside a hook reads as that integration's Error state, never as a crash: a sign-in that fails is "the directory could not check your sign-in", and a failing target is an off-site copy that failed.
cwcli installs each package with pip into its own directory under `~/.cwcli/integrations/community/`, never into cwcli's environment or the system, using a private venv there for pip; cwcli's own packages take precedence over any copy a package brings along.
The standalone Windows build cannot install packages.

---

### `schedule` - Scheduled Jobs

```bash
cwcli schedule add NAME --verb VERB --project NAME [--bench SEL] [--site NAME] [--process LABEL] (--daily HH:MM | --weekly "DAY HH:MM" | --cron "m h dom mon dow" | --at ISO) [--arg k=v]... [--allow-unattended] [--missed skip] [--start-if-stopped] [--in-window]
cwcli schedule list [--json]
cwcli schedule run ID
cwcli schedule enable ID | disable ID | rm ID
cwcli schedule daemon [--install-service]
```

A schedule starts an ordinary job on a timetable (every run is a job with `origin.schedule = <id>`, so Jobs, logs and Retry work unchanged).
Schedulable: backup (every site or one), prune, off-site copy and prune, restore drill, app or core update, migrate, restart, one process, start, stop, stop a bench, developer mode, clear maintenance, build.
Removals can never be scheduled.
Backups, restarts, starts and stops run unattended freely; an update or migrate needs `--allow-unattended` (the "Allowed unattended" tick in the UI), and a scheduled core update still takes its automatic backup first.
A missed run (the machine was off) runs once on wake unless the schedule says `--missed skip`.
Guards at fire time: the instance's maintenance window (`--in-window`), the quiet hours, a stopped instance (started first with `--start-if-stopped`, else skipped), free disk and memory thresholds, and one job per instance at a time (retried the next minute).
Windows, quiet hours and thresholds are set in the web UI (Schedules › Windows and guards); the store is `~/.cwcli/schedules.json`.
The scheduler ticks inside `cwcli ui`; on a shared install `sudo cwcli schedule daemon --install-service` installs the machine-wide `cwcli-scheduler.service` so schedules fire with no UI open (both lock the store, so a slot never fires twice).
`cwcli axi schedule list|add|run-now|remove` is the agent spelling.

---

### `doctor` - Preflight Your Environment

Checks whether cwcli can operate on this machine at all - Docker, cwcli's own
on-disk footprint, disk space, sendme, the `gh`/`glab` credential-bridge
dependencies, and (when enabled) the persistent credential-bridge daemon's
health - as a fast, read-only report. `status`/`inspect` answer "is this
instance healthy"; `doctor` answers "will the next command even work here".

```bash
cwcli doctor [OPTIONS]
```

**Options:**

| Option | Description |
|--------|-------------|
| `-v`, `--verbose` | Show each check's stable id (e.g. `[c2]`) alongside its title |

**Read-only checks:** doctor never starts a container, installs anything, or
writes a config file.
When a direct remedy is available, the finding includes the command or action
to apply yourself.

**Checks always run in full** (no tiers, no selection flags), grouped into
Docker, cwcli, Storage, Transfer, and Git hosting, plus a cross-instance
port-range collision check under Instances. Each check is `pass`, `warn`, or
`fail`; an absent *optional* tool (sendme, `gh`, `glab`) is `warn`, never
`fail`.
`gh`/`glab` sign-in is checked per host the credential bridge sends to that
tool (the bridge's allowed hosts, plus `github.com` for `gh`), and warns only
when the tool is signed in to none of them; a signed-out host beside a
signed-in one is listed with its `auth login --hostname` command.
An enabled but stopped auto-inspect or credential-bridge daemon is a `warn`
naming its start command; the web UI's Doctor view offers a Start button for it.

**Exit codes:** `0` when every check is pass/warn (a warning never blocks -
doctor is a chainable preflight gate), `1` when any check fails.

**Example:**

```bash
cwcli doctor          # grouped glyph checklist
cwcli doctor -v       # same, with each check's stable id shown
```

For agents, `cwcli axi doctor` emits the same checks as one TOON document with
a machine-readable `status` token per row.
The version row also carries `version_verified`, so `status: pass` with
`version_verified: false` is explicitly unverified rather than a claim that the
installed version is current.
See [the `axi` surface](#for-agents-the-cwcli-axi-surface).

---

### `config` - Manage Configuration

Manages the CLI configuration and cache.

```bash
cwcli config [SUBCOMMAND]
```

#### Subcommands

##### `config show` - Show the Effective Configuration

The one read for everything: the cache on/off state (and whether `CWCLI_NO_CACHE` is overriding it), the custom search paths, the auto-inspect settings together with live daemon state and boot-hook state, the credential-bridge state (enabled, daemon, transport, wired instances), the tips setting, and the config-file and cache-DB locations.

```bash
cwcli config show
cwcli config show --json    # stable machine-readable object on stdout
```

##### `config path` - Show Config Path

Prints the configuration file's path - bare, unwrapped at any terminal width, safe for command substitution.

```bash
cwcli config path
$EDITOR "$(cwcli config path)"
```

##### `config edit` - Edit the Config File

Opens the configuration file in `$EDITOR`.

```bash
cwcli config edit
```

##### `config paths` - Manage Bench Search Paths

Lists and edits the custom directories the `inspect` command searches for benches.

```bash
cwcli config paths                          # list, one path per line
cwcli config paths --json                   # the same list as JSON
cwcli config paths add /home/user/benches   # absolute paths only
cwcli config paths remove /home/user/benches
cwcli config paths prune                    # dry-run: list dead paths
cwcli config paths prune --yes              # actually drop them
```

`add` refuses a non-absolute path with a usage error (exit 2) after `~` expansion, and normalizes before duplicate detection, so `/a/b` and `/a/b/` are one entry, not two.
Adding an already-present path and removing an absent one are both no-op successes (exit 0, saying which no-op occurred).

`prune` removes custom search paths that no live instance references (they accumulate a graveyard: a path added for an instance later removed lingers forever and is re-scanned on every `inspect`).
It checks each path live against every instance's frappe container and, with `--yes`, drops the ones absent from all of them; without `--yes` it is a dry-run that only lists what it would drop (`--json` for machine output).
It is fail-closed: a path present in any instance is never dropped, and if any instance cannot be checked (stopped or unreachable) nothing is pruned, so a path a stopped instance still needs is never lost.

##### `config cache` - Manage Cache

Manages the cache for project inspection data.

```bash
cwcli config cache [SUBCOMMAND]
```

**Cache Subcommands:**

- **`clear [PROJECT_NAME]`** - Clear cache for a specific project or the entire cache
  - Options:
    - `-a`, `--all` - Clear the entire cache
    - `-y`, `--yes` - Skip the confirmation prompt (required to clear `--all` non-interactively; a non-TTY without `--yes` refuses rather than wiping the cache silently)
  - A project name combined with `--all` is a usage error (exit 2, nothing cleared) - contradictory targets are never resolved toward the more destructive reading. No target at all is likewise exit 2.
  - Example: `cwcli config cache clear frappe-one`
  - Example: `cwcli config cache clear --all --yes`

- **`path`** - Print the cache file's path (bare, substitution-safe)
  - Example: `cwcli config cache path`

- **`list [--json]`** - List all projects currently in the cache
  - Example: `cwcli config cache list --json`

- **`disable`** / **`enable`** - Turn the on-disk cache off entirely, or back on (the default)
  - Example: `cwcli config cache disable`
  - Example: `cwcli config cache enable`
  - See ["Disabling caching entirely"](#disabling-caching-entirely) below.

###### Disabling caching entirely

cwcli keeps an on-disk SQLite cache (under `~/.cwcli/cache`, or `$CWCLI_HOME/cache`) that read commands - `inspect`, `status`, `where`, `open`, the `apps` group, tab completion, and every `axi` read verb - work from, so they do not re-inspect a running instance every time.

You can turn it off entirely, in two equivalent ways:

- **Config key:** `cwcli config cache disable` sets `[cache] enabled = false` in the config file. `cwcli config cache enable` reverts it.
- **Environment variable:** `CWCLI_NO_CACHE=1` disables caching for a single shell or CI job; `CWCLI_NO_CACHE=0` re-enables it. The environment variable **overrides** the config key, so you can force caching on or off for one invocation regardless of the persisted setting.

With caching disabled:

- Every read resolves **live**, as if a fresh `inspect` had just run - a stale cached row can never be served.
- **Nothing** is written to or read from the cache DB, and a pre-existing cache file is left in place untouched, so re-enabling restores the previous behaviour.
- Tab completion degrades gracefully to a live/empty lookup rather than serving stale names.
- `auto-inspect` refuses to start (it exists only to keep the cache warm, which does nothing when caching is off).
- Bench labels still work, because they are also stored in each bench's marker file.
- Safety-critical commands (`rm`/`rm-site`'s backup step, `apps checkout`'s wrong-copy detection) get correct live data.

Because every read resolves live, commands are slower than with the cache warm - this is a correctness/isolation mode, not the everyday default.

##### `config auto-inspect` - Automatic Project Inspection

Manages automatic background inspection of running Frappe projects to keep cached data fresh (tab completion, project status queries, and other commands that rely on cached data).

```bash
cwcli config auto-inspect [SUBCOMMAND]
```

Auto-inspect has three state stores - the config flag, the live daemon process, and the OS boot hook - and the verbs are desired-state: one `enable` reaches "enabled and running", one `disable` tears all three down.

**Auto-Inspect Subcommands:**

- **`enable`** - Enable auto-inspect AND start the background process, in one verb
  - Options:
    - `-i`, `--interval INTEGER` - Inspection interval in seconds (minimum 60, default 3600)
    - `--startup` / `--no-startup` - Also install (or remove) the automatic start on system boot/login; omit both to leave the boot hook untouched
  - Validates every input BEFORE persisting anything: a failing `enable` leaves the config file untouched.
  - Idempotent: re-running applies changed settings (restarting the running daemon when the interval changed) and reports already-satisfied state as a no-op.
  - Example: `cwcli config auto-inspect enable --interval 1800 --startup`

- **`disable`** - Stop the background process, set enabled = false, AND remove the boot hook, reporting each action taken
  - Example: `cwcli config auto-inspect disable`

- **`stop`** - Stop the background process only (stays enabled; a boot-hooked daemon returns at the next boot)
  - Example: `cwcli config auto-inspect stop`

- **`status [--json]`** - Show all three stores separately: enabled + interval (config), process state + PID (daemon), start-on-boot (OS hook)
  - Example: `cwcli config auto-inspect status --json`

- **`logs`** - View recent background process logs (a failed read exits 1)
  - Options: `-n`, `--lines INTEGER` - Number of log lines to show (default 20)
  - Example: `cwcli config auto-inspect logs --lines 50`

**Notes:**
- Background process survives terminal closure
- Process stops on system restart unless the boot hook is installed (`enable --startup`; LaunchAgent on macOS, systemd user service on Linux, Task Scheduler on Windows)
- Logs stored in `~/.cwcli/run/auto-inspect.log`
- PID file stored in `~/.cwcli/run/auto-inspect.pid`

##### `config cred-bridge` - Persistent Credential Bridge

Opt-in.
When enabled, a small detached host daemon lets in-container git reach your host's already-signed-in `gh`/`glab` for private-repo fetches during **interactive** work - `cwcli open`, a plain `docker exec` shell, a VS Code attached-container terminal - not just cwcli's own `apps`/`init`/`update` operations.
The raw token never enters the container (cwcli forwards credential bytes and never parses or stores a token), and `store`/`erase` are no-ops so nothing is ever written inside the container.

```bash
cwcli config cred-bridge [SUBCOMMAND]
```

**How it degrades:** a stopped, crashed, or never-started daemon behaves byte-identically to having no helper configured at all - git simply prompts (at a TTY) or fails auth cleanly (non-TTY), with no added noise.
The bridge only ever adds an authenticated answer; it can never make git worse than it is without it.

**Security model, in three lines:** the token never enters the container; while the daemon runs, any process in the container can *use* (never read or extract) the credential for a request; disable it at any time with `cwcli config cred-bridge disable`.
This is the same class of exposure as SSH agent forwarding or VS Code's own credential forwarding, but the window is longer (the daemon's lifetime), which is exactly why it is opt-in - turn it on knowingly.
Every request is written to an audit log (`~/.cwcli/run/credbridge-audit.log`, one line per request: timestamp, instance, host, answered - never a credential byte).
For a tighter blast radius, authenticate `gh`/`glab` with a fine-grained, expiring token scoped to only the repos you need; the bridge faithfully forwards whatever the host tools hold, so the token's reach is your auth choice.
On a shared multi-user host, note the per-instance socket lives in the instance's workspace directory under `~/.cwcli/projects/<name>/data/`; `enable` tightens `~/.cwcli/projects` to owner-only (0700) for exactly that reason.
This per-user daemon is distinct from the **machine-wide** bridge of an opt-in [shared install](#shared-multi-user-install): there the daemon runs once as the `cwcli` service account (started by a system unit at boot), serves a single machine-owned read-only service-account token, and its socket is `0660 root:cwcli` so a uid outside the `cwcli` group cannot reach it. See that section for the security consequences.

**Host allowlist:** the daemon only answers credential requests for `github.com` and `gitlab.com` by default.
Using a self-hosted GitLab (or GitHub Enterprise)?
Add its host to `allowed_hosts` under `[cred_bridge]` in `cwcli config edit`, exactly as git reports it (include the port if non-default, e.g. `"git.corp.example:8443"`):

```toml
[cred_bridge]
allowed_hosts = ["github.com", "gitlab.com", "git.corp.example:8443"]
```

Edits apply immediately - no daemon restart needed.
The list scopes only the persistent daemon; cwcli's own `apps install`/`apps update`/`init` operations authenticate as before regardless.
Honestly stated: the allowlist keeps the long-lived channel from silently widening to *other* credential-bearing hosts your `gh`/`glab` know; it cannot stop a process in the container requesting an allowlisted host's credentials.

**Cred-Bridge Subcommands:**

- **`enable [--project <name>] [--startup/--no-startup]`** - Enable the bridge AND start its background daemon, in one verb
  - Idempotent; missing `gh`/`glab` is a warning, not a refusal (the bridge simply answers nothing until a tool is installed and authenticated).
  - Takes effect immediately on every currently-running instance cwcli manages (a Frappe container cwcli did not create is never touched), and self-heals onto each instance on the next `cwcli open`/`cwcli start`/`cwcli run`. When the default sweep wires more than one instance it names them, so an operator on a multi-instance machine is never surprised.
  - `--project <name>` scopes the sweep to the named instance(s) (repeatable; an unknown name is the usual `not found` error, and a running container cwcli does not manage is refused rather than wired). Omit it to wire every running managed instance.
  - `--startup` also installs an automatic start at system boot/login (LaunchAgent on macOS, systemd user service on Linux, Task Scheduler on Windows) - this covers reopening VS Code straight after a reboot, before any cwcli verb has run. `--no-startup` removes the boot unit; omit both to leave it untouched. If the boot unit cannot be installed (for example a host with no systemd user session), the bridge still enables and runs - the message says so and names `cwcli config cred-bridge start` for a manual start.
  - Example: `cwcli config cred-bridge enable --project my-instance --startup`

- **`disable`** - Stop the daemon, set enabled = false, make every wired shim inert, and remove the boot unit (non-destructive; an `enable` recreates everything)
  - Example: `cwcli config cred-bridge disable`

- **`start`** / **`stop`** - Start or stop the daemon only, leaving the enabled flag as-is (`start` refuses when disabled, which also keeps a stale boot unit inert; a stopped-while-enabled daemon returns on the next `open`/`start`/`run`)

- **`status [--json]`** - Show enabled, daemon state + PID, transport (`unix` on native Linux, `tcp` under Docker Desktop), start-on-boot state, allowed hosts, wired instances, and the most recent audit lines

**Notes:**
- Without `--startup`, the daemon is not boot-persistent; any `cwcli open`/`cwcli start`/`cwcli run` re-starts it while enabled.
- Logs stored in `~/.cwcli/run/credbridge.log`; audit log in `~/.cwcli/run/credbridge-audit.log`.
- There is deliberately no `cwcli axi cred-bridge` verb: standing up a persistent host credential channel is a decision a person makes, not an agent (the read-only state is on `cwcli axi config` / `cwcli axi doctor`).

##### `config tips` - Manage Contextual Tips

Control the display of helpful tips during long-running operations.

```bash
cwcli config tips [enable|disable]
```

When enabled (default), cwcli displays rotating helpful tips alongside spinners during long-running operations like `inspect`, `update`, and `open`. The current setting shows in `cwcli config show`.

#### Deprecated `config` spellings

These verbs remain as hidden aliases with byte-identical behavior plus a one-line stderr deprecation warning. They will not be removed before 1.0, and no earlier than two minor releases after the rework shipped - whichever is later.

| Deprecated | Use instead |
|------------|-------------|
| `config add-path P` | `config paths add P` (both now refuse non-absolute paths, exit 2) |
| `config remove-path P` | `config paths remove P` |
| `config auto-inspect start` | `config auto-inspect enable` |
| `config auto-inspect restart` | `config auto-inspect enable` (idempotent; restarts on interval change) |
| `config auto-inspect set-interval N` | `config auto-inspect enable --interval N` |
| `config auto-inspect install-startup` | `config auto-inspect enable --startup` |
| `config auto-inspect uninstall-startup` | `config auto-inspect enable --no-startup` (keep running, drop the hook) or `disable` |
| `config tips status` | `config show` |

**Boot-unit note:** startup units installed before this change (systemd/LaunchAgent/schtasks) exec `cwcli config auto-inspect start` verbatim.
The `start` alias keeps BOTH that argv and its refuse-when-disabled guard, so an existing unit keeps working and a stale hook left behind after `disable` stays inert.
Re-running `cwcli config auto-inspect enable --startup` rewrites the unit against the current executable path.

### `self-update` - Upgrade cwcli Itself

Upgrades cwcli to the latest version published on PyPI. It detects how cwcli was installed and runs the matching upgrade for you, so you don't have to remember whether you used `uv` or `pip`.

```bash
cwcli self-update [OPTIONS]
```

**Options:**

| Option | Description |
|--------|-------------|
| `--check` | Dry run: report current vs latest and print the upgrade command without executing. Exits non-zero when an update is available (so scripts/CI can gate on it) |
| `--no-cache` | Force a fresh PyPI check, ignoring the shared ≤1-day version cache. Use right after publishing a release |

**How it behaves:**

- **uv tool install** → runs `uv tool upgrade caffeinated-whale-cli`.
- **pip install** → runs `python -m pip install --upgrade caffeinated-whale-cli` (using the current interpreter).
- **dev/editable checkout** → a no-op; it tells you to `git pull` instead of self-modifying your source tree.
- **`uvx --from ... cwcli`** (ephemeral) → a no-op; there is nothing persistent to upgrade.

**Exit codes:**

| Code | When |
|------|------|
| `0` | Already up to date (including a dev checkout that is ahead of PyPI), upgraded successfully, or a dev/uvx no-op. Also a `--check` that found no update, or a `--check` whose network lookup failed open |
| `1` | The upgrade subprocess failed, a network failure blocked an actual upgrade, or `--check` found that an update is available |

**Examples:**

```bash
# Upgrade cwcli if a newer version exists
cwcli self-update

# Report only; exit 1 if an update is available (handy in CI)
cwcli self-update --check

# Force a fresh PyPI check, bypassing the version cache
cwcli self-update --no-cache
```

**Passive update notices:**

Every `cwcli` and `cwcli axi` run except the strictly read-only doctor commands also does a passive, cache-only check: if a newer release is already known (from the same ≤1-day cache `self-update`/`--check` share) and you're at an interactive terminal, cwcli prints a one-line "a newer cwcli is available" hint with the right upgrade command for how you installed it to stderr.
`cwcli doctor` and `cwcli axi doctor` skip this check because a stale or missing version cache would otherwise launch a background refresh that writes it.
The notice never makes a blocking network call itself: PyPI is actually re-checked at most once/day, via a detached background refresh kicked off whenever the cache is missing or stale, so this never delays a command; until that refresh lands, the hint keeps showing on each eligible run.
It never touches stdout, so it is invisible to pipes, scripts, CI, and `cwcli axi`'s TOON output.
Set `CWCLI_NO_UPDATE_CHECK=1` to suppress it entirely.

---

## Working with Multiple Benches

A single cwcli project (one Docker Compose instance) can hold more than one bench directory inside its frappe container. This is a **multi-bench** instance, and cwcli lets you address an individual bench without spelling out its full container path.

**How benches are addressed:**

- **Numeric index** - Every bench discovered by `cwcli inspect` gets a durable per-project number. `cwcli inspect` shows it next to each bench (for example `Bench [1] at /workspace/frappe-bench-2`). The number stays attached to that bench path when another bench is added or removed, so a stored or scripted `--bench 1` never quietly starts addressing a different bench.
  Removed numbers are not reused, so the available indices may be sparse, such as `0`, `2`, and `3`.
  Benches are listed in that numeric order, so a newly added bench appears at the end of the list even when its path sorts earlier.
- **User label** - A durable, human-friendly handle you assign with the [`label`](#label---manage-bench-labels) command or `cwcli inspect -i`. Labels may not be purely numeric (that would collide with an index) and must be unique within a project.

**The `--bench` selector:**

Pass `--bench <index|label>` to target one bench. The selector resolves a matching user label first, then a numeric index:

```bash
cwcli run my-project migrate --bench 1          # by index
cwcli update my-project --app erpnext --bench staging   # by label
```

`--bench` is available on `run`, `backup`, `update`, `open`, `unlock`, `restore`, `rm-site`, `rm-bench`, `start`, `stop`, `restart`, `status`, and `logs`. Setting labels lives in the [`label`](#label---manage-bench-labels) command.

**No silent guessing on data commands:**

If a project has more than one bench and you do not pass `--bench` (or `--path`), the data commands (`run`, `backup`, `update`, `open`, `unlock`, `restore`, `rm-site`, `rm-bench`) stop and list the benches instead of guessing:

```
Error: project 'my-project' has multiple benches; specify one with --bench <index|label>:
  [0] 'primary' /workspace/frappe-bench
  [1] 'staging' /workspace/frappe-bench-2
```

Single-bench projects are unaffected: with only one bench, that bench is used automatically and `--bench` is optional.

`start` and `cwcli restart --process` mutate one bench, so on a multi-bench project with no `--bench` they prompt which bench interactively and refuse (non-zero) on a non-TTY.
`status` is a read and reports every bench when `--bench` is omitted; it never prompts.
A whole-stack `cwcli restart` (no `--process`) takes every container down and brings **one** bench back up: `--bench` names which, and omitted it relaunches the first bench and says so.
`cwcli stop --bench` stops one bench's dev processes and leaves the containers and sibling benches running - the inverse of `cwcli start --bench`. Without `--bench`, `stop` stops the whole instance's containers, which takes every bench in it down.

**`--path` escape hatch:** `-p`/`--path` still accepts an explicit bench directory for cases outside the cached set. It takes precedence over `--bench`, but the two cannot be combined (that is an error).

**Label persistence and recovery:** A user label is stored in both the SQLite cache and a marker file (`<bench-root>/.cwcli/.bench-label`) inside the bench. Because the marker lives in the bench itself, a full `cwcli inspect --update` rebuilds labels from the markers even after the cache is cleared.

---

## Tips and Tricks

### Piping Commands

Many commands support piping project names from stdin:

```bash
# Start all projects
cwcli ls --quiet | cwcli start

# Stop specific projects using grep
cwcli ls --quiet | grep "frappe-" | cwcli stop
```

### Scripting with JSON

Use JSON output for programmatic access:

```bash
# Get project data as JSON
cwcli ls --json | jq '.[] | select(.status=="running")'

# Parse inspect output (each bench carries its numeric "index" for --bench)
cwcli inspect frappe-one --json | jq '.bench_instances[0].sites'
```

### For agents: the `cwcli axi` surface

`cwcli axi` is an agent-facing surface built for autonomous tools that drive cwcli through shell execution.
It sits on the same logic core as the human commands but renders differently:

- **Structured output on stdout** in [TOON](https://toonformat.dev) (a token-efficient, agent-readable format); progress and diagnostics go to stderr, so stdout is always clean, parseable data.
- **TOON help across the complete command tree.** `cwcli axi --help` and every nested `cwcli axi ... --help` path derive concise usage, commands, arguments, flags, defaults, descriptions, notes, and examples from the live command metadata.
  Group listings preserve the complete current command summaries, argument names match the usage line, and notes preserve bullet boundaries without leaking source markup.
  The human `cwcli ... --help` surface remains Rich.
- **No prompts, ever.** Every operation completes from flags alone; a decision it cannot make (an ambiguous multi-bench project, a stopped instance) is reported as a structured usage error naming the exact flag to pass, not an interactive question. An unrecognized flag or a missing required argument is likewise a structured `error:`+`help:` usage error on stdout - never a leaked stack trace or empty output - with the `help:` line naming the command's valid flags.
- **Conventional exit codes:** `0` success (including no-ops), `1` error, `2` usage error.

```bash
# Content-first home: the binary path, a description, live instances, and next steps
cwcli axi

# List all Frappe/ERPNext instances as a TOON table
cwcli axi ls

# Search cached instances for an app or site by name.
# Every row carries project_state (present | absent | unverified) and the document
# carries verified, so a cached hit is never presented as a live-confirmed one.
cwcli axi where erpnext

# Back up a site's database; the outcome prints as TOON on stdout
cwcli axi backup frappe-one --site development.localhost

# Include files too
cwcli axi backup frappe-one --with-files

# Remove a site's locks folder; the removed paths print as a structured TOON list.
# A site that was not locked is a clean success (already_unlocked: true).
cwcli axi unlock frappe-one --site development.localhost

# Stop a project's containers; the outcome prints as TOON. Idempotent: an
# already-stopped project is a definitive success (already_stopped: true).
cwcli axi stop frappe-one

# Stop ONE bench's dev processes, leaving sibling benches and every container
# running (the inverse of `cwcli axi start --bench`). Also idempotent.
cwcli axi stop frappe-one --bench 1

# Start a project's containers + bench; the outcome (including already_running)
# prints as TOON. --bench selects a bench on a multi-bench project; --yes
# auto-resolves a port conflict by stopping the conflicting Frappe project.
cwcli axi start frappe-one --yes

# Report per-BENCH, per-process health as TOON, with the instance "overall"
# aggregate up front. Every bench is reported unless --bench narrows it to one;
# each bench names itself and the web port its HTTP code was measured on.
cwcli axi status frappe-one
cwcli axi status frappe-one --bench 1

# Read a bounded tail of a bench's per-process logs; prints as ONE TOON document -
# a metadata head then one raw-line block per process. A bounded `tail -n N`, NOT a
# follow (an agent needs a terminating document, not a stream): use `cwcli logs` for
# an interactive tail. --lines/-n sets how many lines (default 100); --process/-p
# reads one process; --bench selects a bench. A running-but-quiet bench is a
# successful empty read (exit 0); a stopped project is a usage error naming
# `cwcli start` (exit 2).
cwcli axi logs frappe-one
cwcli axi logs frappe-one --process web --lines 50

# Resolve a bench's host URL and probe it fresh, right now; prints as TOON. This
# is the agent verb that reports `--bench 1`'s real address (bench 1 of a
# --port 21000 instance is :21001, not :8000) plus whether it answers HTTP now.
# `cwcli axi status` also probes fresh once; only human `cwcli status --watch`
# suppresses HTTP probing. An unreadable port config is `url: null` /
# `reachable: false`, never a guessed :8000; --site sends that Host header
# (Frappe routes by Host) instead of the bench's representative site.
cwcli axi url frappe-one
cwcli axi url frappe-one --bench 1 --site erp.localhost

# Restart ONE supervised process (siblings keep running); the outcome prints as
# TOON. --process is required; --bench selects a bench on a multi-bench project.
cwcli axi restart frappe-one --process web

# Widen the published port range so more than six benches are reachable from the
# host; the new host port map prints as TOON. Expanding restarts every serving
# bench in the instance, so it needs --yes. An instance whose range already covers
# every bench is a clean no-op (expanded: false), no --yes needed.
cwcli axi scale frappe-one --to 8 --yes

# Inspect a project's benches, sites, and apps; the report prints as ONE TOON
# document. ONE tiered read (cache/partial/full, whichever answers the request),
# not a read/refresh pair. --update forces a full re-inspect; --no-refresh
# serves the cache verbatim with zero container calls.
# Every site carries installed_apps_verified: true only on a full read - the
# cache/partial tiers carry a site's apps (and their git refs) forward without
# re-observing them, so a non-full read with a remembered app list also warns.
# --bench <index|label> narrows the report to one bench (every tier still
# refreshes/caches every bench regardless; only the emitted report is narrowed).
cwcli axi inspect frappe-one
cwcli axi inspect frappe-one --update
cwcli axi inspect frappe-one --bench 1

# List a project's benches with their indices and labels. This is what answers
# "pass --bench <index|label>" from any other verb - no other verb can.
# Each row carries `state` (present/absent/unverified) - read it before acting.
cwcli axi benches frappe-one
cwcli axi benches frappe-one --no-verify   # bare cache read; reports `unverified`

# Set or clear a bench's durable user label. Exactly one of --set/--clear is
# required; listing is `cwcli axi benches`, not a mode of this verb.
cwcli axi label frappe-one --bench 0 --set staging
cwcli axi label frappe-one --bench staging --clear

# List a bench's available apps, and (with --installed/--site) which apps are
# installed on which site. This answers what is ON the bench that
# `cwcli axi benches` names.
cwcli axi apps list frappe-one
cwcli axi apps list frappe-one --installed
cwcli axi apps list frappe-one --site erp.localhost

# Update app(s) and migrate every affected site; the full per-phase report
# prints as ONE TOON document. Blocks until it finishes (like axi backup) -
# there is no progress stream, because an agent needs a verdict to branch on.
cwcli axi apps update frappe-one erpnext
cwcli axi apps update frappe-one frappe          # the core update: resets frappe alone

# Put ONE branch, tag, or commit under test in an app already in the bench -
# the gap 'apps install' (bench acquisition and site installation) and 'apps update' (the tracked
# upstream) leave. Add --reset to force a clean tree at the fetched ref.
cwcli axi apps checkout frappe-one myapp feature/new-thing
cwcli axi apps checkout frappe-one myapp feature/new-thing --reset

# Ensure and install ONE app on ONE named site. --site is required (there is no
# fan-out here), and an app already installed on that site is refused rather
# than reinstalled over its existing data unless --if-not-present requests a skip.
cwcli axi apps install frappe-one hrms --site erp.localhost
cwcli axi apps install frappe-one https://github.com/me/myapp --site erp.localhost --branch develop
# Idempotent: skip (do not reinstall) an app already on the site and exit 0, so a
# CI step can trust the exit code. A real install failure still exits non-zero.
cwcli axi apps install frappe-one hrms --site erp.localhost --if-not-present

# Provision a new instance, bench, and site; the report prints as ONE TOON
# document. BLOCKS for the full 10-20 minute run (like axi apps update) and
# narrates coarse phases to stderr - run `cwcli logs`/`cwcli status` from a
# second shell for live progress. The admin password comes from the
# CWCLI_ADMIN_PASSWORD env var (recommended - off the argv) or --admin-password;
# omitted is a usage error (never generated, never prompted).
CWCLI_ADMIN_PASSWORD=changeme cwcli axi init frappe-two --version 16
cwcli axi init frappe-two --admin-password changeme --install-erpnext

# Report whether a newer cwcli is published. READ-ONLY: --check is required and
# this verb never upgrades. Exits 0 on any successful read - the answer is the
# is_outdated field, not the exit code.
cwcli axi self-update --check

# The effective cwcli configuration in one call. READ-ONLY: search paths,
# auto-inspect state (config, live daemon, boot hook - reported separately),
# tips, and the config-file/cache-DB locations.
cwcli axi config

# System-wide environment preflight as one TOON document: Docker, cwcli's own
# footprint, disk space, sendme, gh/glab. Machine-readable status per check;
# exits 0 unless a check FAILS (a warning never blocks a chained preflight).
cwcli axi doctor
```

`cwcli axi ls`, `cwcli axi where`, `cwcli axi backup`, `cwcli axi unlock`, `cwcli axi stop`, `cwcli axi start`, `cwcli axi status`, `cwcli axi restart`, `cwcli axi inspect`, `cwcli axi benches`, and `cwcli axi label` run on the same logic core as their human counterparts; only the output (always TOON, never JSON) and choice-handling differ. `cwcli axi start` never prompts: an ambiguous multi-bench project is a `--bench` usage error (exit 2), and an unresolved port conflict is a `CONFLICT` error naming `--yes` (exit 1). `cwcli axi unlock` follows `cwcli axi backup`'s conventions exactly: an ambiguous multi-bench project is a `--bench` usage error (exit 2) and a stopped instance is a usage error pointing at `cwcli start` (exit 2). `cwcli axi stop` is idempotent for an agent - stopping an already-stopped project is a success, not an error. `cwcli axi where` answers from the cache, which outlives the instances it describes, so every match carries `project_state` (`present`, `absent`, or `unverified`) and the document carries `verified`: an agent must read those before acting on a hit, because an `absent` row describes an instance that no longer exists and an `unverified` row means the liveness check could not run. An unreachable Docker daemon degrades the whole answer to `unverified` rather than vouching for it, and `--no-verify` skips the check (reporting `unverified`) when a caller has already established liveness. `cwcli axi status` always exits 0, leading with the instance `overall` aggregate (`offline`/`online`/`running`/`degraded`). It reports **every** bench, in one document, under `benches[N]` - with no `--bench` it used to refuse a multi-bench project with exit 2 and send you off to `cwcli axi benches` to poll once per bench, reassembling the instance view from documents that never said which bench they described. The shape is uniform, so `supervisor_up`, `web_http_code`, `processes` and `not_cwcli_supervised` live inside `benches[i]` even for a single-bench project (`benches[0].processes`), and there is one parse path rather than a branch on bench count. Each bench also carries `bench_present` (`present`/`absent`/`unverified`) - the bench LIST comes from the cache, and a bench whose directory was deleted has no marker and no supervisord, which is indistinguishable from one that was simply never started, so it used to read as `online`; the token answers that, and `overall` keeps its four tokens. Each bench also carries `index`, `bench_path`, `label`, its own `overall`, `web_port`/`web_port_verified` (the port that bench's `web_http_code` was actually measured on), and `web_site` (the site it was measured FOR - Frappe routes by `Host`, so the code belongs to that site, and a probe naming no site is answered `404` by a healthy bench). When that port cannot be read it is `null`/`false` and **no probe was made** - cwcli never falls back to `:8000`, because on a multi-bench instance that reports a different bench's web server as this one's. A stopped instance carries `benches: []`. `cwcli axi stop` takes `--bench <index|label>` to stop ONE bench's dev processes, leaving sibling benches and every container running - the inverse of `cwcli axi start --bench`; without it, it stops the whole instance's containers, and either form is idempotent. `cwcli axi restart` requires `--process`; an unknown/ambiguous process is a usage error listing the valid labels (exit 2). `cwcli axi inspect` serves whichever freshness tier answers the request (`served_from` names it) and, unlike every other bench-scoped verb, has deliberately NO `--yes` - a stopped project on the refresh path is a usage error (exit 2) naming `cwcli start`, and a drift escalation that can no longer discover the bench serves the cached data with `degraded: true` and a warning (exit 0). It takes `--bench <index|label>` like every other bench-scoped verb, narrowing the emitted report to one bench; every tier still discovers/refreshes/caches every bench regardless of the selector, and an unknown selector is a `bench.not_found` error (exit 1). Every site also carries `installed_apps_verified`: only a full read actually re-observes an app's installed version and git ref, so a cache or partial read carries the remembered list forward unverified and, when that list is non-empty, adds a warning naming `--update` - an agent must not treat an unverified `installed_apps` entry as the live git state. `cwcli axi benches` is the discovery verb behind every other verb's `--bench`: when a bench-scoped verb reports "multiple benches; pass `--bench <index|label>`", this is what tells you the valid values, and a project that has never been inspected is a structured error naming `cwcli axi inspect` rather than an empty list. Its rows come from the cache, which outlives the benches it describes, so every row carries `state` (`present`, `absent`, or `unverified`) and the document carries `verified` - the same contract `cwcli axi where` uses, cross-checked here against the running container in one exec. An agent must read `state` before acting on a row: `absent` means that bench directory no longer exists, and `unverified` means the check could not run (a stopped project, an unreachable daemon, or `--no-verify`) - never a confirmation. A stale row is reported, never pruned; `cwcli axi inspect <project> --update` is what refreshes it. `cwcli axi label` never starts a stopped project (the label marker lives inside the bench), and listing is deliberately `cwcli axi benches` rather than a mode of the mutation verb. `cwcli axi self-update --check` is read-only and **exits 0 whenever the check succeeds, including when an update is available** - the answer is the `is_outdated` field, not the exit code, because on the agent surface a non-zero exit means an error. This deliberately differs from the human `cwcli self-update --check`, which exits 1 when an update is available so shell scripts can gate on it; the mutating `cwcli axi self-update` is deliberately not offered. `cwcli axi config` is the read-only counterpart of `cwcli config show`: one TOON document carrying the search paths, all three auto-inspect state stores, the credential-bridge state, the tips setting, and the file locations. **No config-mutating axi verbs exist** (no `paths add`/`remove`, no `cache clear`, no `auto-inspect enable`/`disable`): an agent rewriting the user's search paths or wiping the cache is a product decision that deserves its own evidence, so mutations stay on the human `cwcli config` surface (whose reads all have `--json`). `cwcli axi doctor` is the read-only, system-wide counterpart of `cwcli doctor`: one TOON document listing every check with a machine-readable `status` (`pass`/`warn`/`fail`) an agent gates on directly, never by parsing the `detail` text. It exits 0 whenever every check is pass/warn and non-zero only when a check fails, exactly matching the human command, so a fleet can run it before driving any instance and `&&`-chain on the result.

`cwcli axi url <project>` answers two questions no other `axi` verb does: what HOST URL a bench actually serves on, and whether it is answering HTTP right now.
`cwcli axi status`'s `web_http_code` is measured against the bench's CONTAINER-internal port and never states the host address; the host URL is computed only by `cwcli open`'s success banner and the human `cwcli init` banner, neither reachable from `cwcli axi`, so getting either answer meant dropping to a raw `docker inspect`/`curl` against the container.
This verb reuses those existing primitives rather than adding a second URL resolver or probe mechanism: the URL comes from the same two-hop resolution `cwcli open` already uses (the bench's own assigned container port, mapped through the container's live published port bindings), and the probe is a fresh `curl` run **every invocation**.
`cwcli axi status` also performs a fresh one-shot probe; only the human `cwcli status --watch` path suppresses HTTP probing.
Like other single-bench verbs such as `cwcli axi logs`, a stopped project is a usage error naming `cwcli start` (exit 2, no `--yes`) and an ambiguous multi-bench project with no `--bench` is a usage error naming it.
`--site` sends that site as the `Host` header (Frappe routes by `Host`); omitted, it falls back to the bench's own representative site (its default, else its only/first cached site), reported so the observation stays attributable, and a bench with no site at all is probed host-less rather than guessing one.
An unreadable port config is `url: null` / `reachable: false` with a warning that names the live config file to repair; a missing live host binding names `cwcli axi scale` when the bench lies beyond the published range.
Neither case guesses `:8000`.
`http_code` is whatever code curl saw, verbatim: a 404 or 500 still counts as `reachable: true`, exactly as `cwcli axi status` already treats "any code is serving".
There is no human `cwcli url` yet.
Use `cwcli open`'s banner for the host address and `cwcli status` for the HTTP observation.

`cwcli axi apps update` blocks until the update finishes and emits ONE terminal document, exactly as `cwcli axi backup` does for a minutes-long `bench backup`: progress is deliberately not streamed, because N documents on stdout would break the one-TOON-document contract and an agent needs a verdict it can branch on rather than a progress bar (use `cwcli logs`/`cwcli status --watch` if you want live progress). Its exit code is `0` only when the report's `ok` is true; any failed phase, any stuck site, or any unknown outcome exits `1`, and an ambiguous multi-bench project is a `--bench` usage error (exit 2). A stopped project is likewise a usage error pointing at `cwcli start` (exit 2) - there is deliberately no `--yes` on this verb, because starting a container is UI-coupled and the core stays UI-pure about it, so an agent composes `cwcli axi start` then this verb, exactly as `cwcli axi unlock`/`cwcli axi backup` already document. **`failed_*` and `unknown_*` are not the same thing and must not be collapsed:** a `failed_*` item ran and failed, so retrying it is safe, while an `unknown_*` item's output stream was lost - its exit code is unknowable and **it may still be running**, so retrying it (a migration above all) can do real harm. Check before retrying. There is deliberately no `cwcli axi update`: the deprecated `cwcli update` spelling does not get an agent-facing verb. JSON output stays on the human commands (`cwcli ls --json`, `cwcli where --json`, `cwcli apps update --json`). `--force` is available here too and is **destructive**: an app whose git update hits a conflict is hard-reset to its remote tracking branch (discarding local changes) so the update completes, reported in the `force_reset_apps` field; it fires only on a conflict and never on a non-conflict failure.
Updating `frappe` runs the same preflight as the human command before the core reset.
Its findings are the `preflight` table (`severity,code,app,site,message,remedy`); branch on `code`, never on the message.
A blocker sets `preflight_blocked: true` and exits 1 with nothing run, and `--no-preflight` is available here too, because it skips predictions rather than a safety gate.
The data-loss guard runs here too, under `--no-preflight` as well: uncommitted or unpushed work in any app (or work that could not be checked) sets `data_loss_refused: true`, names the apps in `data_loss_apps`, and exits 1 with nothing run.
`--force` accepts the loss; the reset then runs and `data_loss_apps` names the apps it was allowed to discard work in.

`cwcli axi apps list` is the read that answers what is *on* the bench `cwcli axi benches` names: the bench's available apps, and with `--installed`/`--site` which apps are installed on which site (only the app name, never the version column `bench list-apps` prints). A site whose read FAILED is reported as `null` and exits `1`, never as an empty list - "has no apps" and "could not tell" are different facts, and only one of them is safe to act on. Like every other bench-scoped verb it takes `--bench`, has no `--yes`, and reports a stopped project as a usage error naming `cwcli start` (exit 2). **`cwcli axi apps uninstall` deliberately does not exist:** letting an agent drop the tables of a real site is a product decision that deserves its own evidence, not something settled as a side effect of moving code onto the logic core. Use the human `cwcli apps uninstall` (it has `--json` and honest exit codes) until that decision is taken. `cwcli axi apps install` was held under that same shared rationale and now exists, scoped to the half of it that rationale never covered - see below.

`cwcli axi apps checkout <project> <app> <ref>` puts ONE named branch, tag, or commit under test in an app that is already in the bench - the gap `apps install` (bench acquisition when needed, then site installation) and `apps update` (the tracked upstream on every app) leave. It emits the same per-step TOON report as the other `apps` verbs, one row per git step, and its exit code reads that report's `ok`, so a refused checkout exits `1` rather than looking like a success. **It existed before `apps install` did and while `apps uninstall` still does not, and that was deliberate rather than inconsistent:** their shared deferral names one threat, an agent destroying site data (uninstalling an app drops its tables), while a checkout runs `git fetch` then `git checkout -B` inside the app's source directory - no bench command, no site, no SQL. Its guards each protect against something named: there is no `--yes` (an agent must not start containers you deliberately stopped, so a stopped project is a usage error pointing at `cwcli start`); the app must already be a git checkout, so a typo'd name errors instead of silently doing nothing or implying an install; and a **dirty working tree is refused outright**, before anything is fetched, so your uncommitted edits are never carried across a branch switch. That refusal is deliberately **stronger than git's own**: git blocks only a checkout that would overwrite a modified file, which used to let a non-conflicting edit ride silently onto another ref. It matters here because these checkouts live in a shared dev instance, where the work carried across may not even be yours. Be precise about what counts as dirty: **anything `git status --porcelain` reports - staged changes, unstaged modifications to tracked files, and untracked files** - refuses. Untracked files count because a new module written but not yet added is uncommitted work, and it is exactly the case where the tool must not decide for you that the file is worthless; `.gitignore`d build residue is not reported by git at all, so it never blocks a checkout. `--reset` is the explicit opt-in through the refusal: it is the one destructive flag, is never implied, and is reported as its own row in the output; it discards tracked local edits in the container's copy of the app, which is what the post-merge step of an app delivery workflow wants. It does **not** delete untracked files - cwcli never runs `git clean` - so it lets the checkout proceed and leaves them in place. Private-repo fetches use the same credential bridge as `apps install`/`apps update`, so no token is stored in the container. One current limit: the report does not tell you which commit you landed on - that is deliberately left to a future read verb rather than bolted onto the shared mutation report.

`cwcli axi apps install <project> <app-or-git-url> --site <site>` ensures the app is present on the bench, fetching it only when absent, and installs it on one named site.
Installing an app is the first step of essentially any Frappe app work, and until this verb existed it was the one routine operation with no agent-surface form, so an agent had to drop to the raw human command for it.
It is deliberately narrower than the human `cwcli apps install`, in exactly two ways, because the original deferral of `apps install`/`apps uninstall` named a real threat - an agent destroying site data - that covers `uninstall` unconditionally but covers `install` only in one case.
Installing an app a site does **not** have creates that app's own tables and touches no other app's data; installing over an app the site **already** has re-runs that app's install hooks against rows that already exist.
So the verb ships scoped to the first case and refuses the second, rather than being withheld whole or opened wide.

**`--site` is required.**
The human verb installs on every site on the bench when you omit it; an unqualified fan-out is how an agent reaches a site nobody named, so on this surface the target is always explicit.
That follows `cwcli axi run-tests`, which requires its site for the same reason: `install-app` runs the app's `after_install`, which is arbitrary Python from the repository being installed, against a live database, and when the effect is unbounded, defaulting the target is the wrong default.

**By default, an app already installed on that site is refused**, before anything is fetched, as `app.already_installed` with exit `1`.
The refusal names what to do instead: `cwcli axi apps checkout` to move the app to another ref, `cwcli axi apps update` to pull and migrate it, or the human `cwcli apps install` for a genuine reinstall.
A site whose installed-app list cannot be *read* is refused too (`app.install_state_unknown`), because an unreadable state must never be treated as "nothing is installed there".
There is deliberately **no flag to bypass this by reinstalling**: a `--force` that re-ran the install hooks over existing data has no beneficiary in the workflow the verb serves (install, check out a ref, migrate, test), and its mere existence invites its use.
The escape hatch for a genuine reinstall is the human verb, which is where a human confirms one.

By default the already-installed case is **not** reported as an idempotent exit-`0` no-op, even though the agent surface generally treats an already-satisfied desired state as a success: the desired state here is "installed from this branch", and cwcli cannot confirm the copy already on the site matches the `--branch` you asked for, so a silent exit `0` would be asserting something it has not verified.
`--if-not-present` opts into the idempotent reading for a caller that just wants the app present: an app already installed on the site is then **skipped** (reported as a `skip-install` row, with its install hooks never rerun) and the verb exits `0`, so a CI step can trust the exit code instead of swallowing every failure with `|| true`; an app the site lacks is still installed, and a genuine install failure still exits non-zero.
It is not a `--force`: there is still no way to reinstall over an app the site already has.
Like every other bench-scoped verb it takes `--bench`, has no `--yes`, and reports a stopped project as a usage error naming `cwcli start`.
Private-repo fetches use the same credential bridge as `apps update`/`apps checkout`, so no token is stored in the container.
On an already-serving bench, the operation resynchronises that bench - it restarts every supervised program running the bench's Python (`web`, `schedule`, workers) and reports a `restart-processes` row.
It exits non-zero if a restart fails or the named site does not answer Frappe's public ping with HTTP `200`, so `ok: true` means the installed app is visible to the running site.

`cwcli axi migrate <project>` runs `bench migrate` against **exactly one site**, under maintenance mode. It is the migrate on its own: `cwcli axi apps update` migrates too, but only as the tail of a `git pull` across every named app, so an agent that has just pinned a feature branch with `axi apps checkout` cannot use it without a pull that moves the ref it pinned. Understand what it does before pointing an agent at it: it applies every pending schema patch from every installed app to that site's **live database**, altering tables and running patch code the apps ship. It is not transactional across patches, so one that fails partway leaves the database partially migrated, and there is no rollback - recovery is from a backup. It deliberately does not take one for you (`apps update` does not either, and a backup you did not ask for is not a guard); run `cwcli axi backup <project> --site <site>` first when the data matters. Its guards each protect against something named: it resolves **one** site (explicit `--site`, else the bench default) and never fans out, so it cannot migrate a site you did not name, and the resolved site is in the output so you can always read back what it touched; a genuinely held migrate lock refuses the operation before maintenance mode is touched and names the matching `cwcli unlock` command; maintenance mode is then enabled, and a **failed enable refuses the migrate** rather than running it against a live-serving site; the disable runs even if the migrate blows up, and a site left in maintenance is reported as `maintenance_left_on` and fails the command, because that site is down. There is no `--skip-maintenance` and no `--yes` - a stopped project is a usage error pointing at `cwcli start`. bench's own output goes to stderr in full: the patch that failed names itself there and nowhere else.

`cwcli axi run-tests <project> --site <site> --app <app>` runs that app's test suite against that site. **Both flags are required and neither has a default**, which is a deliberate break from `axi backup`, `axi unlock`, and `axi migrate` - they all fall back to the bench's default site. The reason is what this command actually is: it imports and executes the app's own test modules inside the container, against the named site's live database, and cwcli cannot bound what that code does because it *is* your repository's code. A Frappe test suite creates, modifies, and deletes records. Honestly stated, it is arbitrary Python from the repo under test, run against a live site - so point it at a dedicated test site. cwcli cannot tell a test site from one holding real data, so that is a practice it can state and not enforce. Making you name the site is the cheapest way to keep "I am willing for this site to be written to" an explicit act; requiring `--app` stops a bare run from executing every installed app's suite, Frappe's own included. This is **not** the general command passthrough cwcli deliberately does not offer: the difference is who writes the command. There is no `cwcli axi run`, and there will not be one - here you select an app whose tests already exist in the bench, and no flag on this command can express a second command. The runner's output goes to stderr in full and unparsed; cwcli reports only the honest pass/fail, because it does not own your test runner's output format. That pass/fail does not trust the exit code alone: Frappe v14/v15 exit 0 on a failed suite unless `CI` is set, so a run whose output ends in unittest's `FAILED (...)` summary, or that Frappe refused because `allow_tests` is off on the site, reports `ok: false`, exits non-zero, and names the reason.

`--module <dotted.path>` narrows a run to one test module. It only ever reduces what runs, so unlike `--site` and `--app` it needs no guard of its own; it exists because iterating on a single failing module is the common case, and without it that dropped back to a raw `cwcli run`.

`cwcli axi build <project> [--app <app>]` runs `bench build` to compile the bench's assets. It is the quiet third member of the loop: an app whose JS or CSS changed is not visibly changed until its assets are rebuilt, so a `apps checkout` of a front-end-bearing branch had no way to finish. It carries no guards because there is nothing to guard - a build compiles asset sources and writes under `sites/assets`, touching no database, running no patch, and needing no site. That last point is why the report's `site` is `null` here rather than naming the bench default: no site was acted on. `--app` narrows the work to one app.

These are agent-only for now: there is no human `cwcli migrate`, `cwcli run-tests`, or `cwcli build` yet. Use `cwcli run <project> --site <site> migrate` for the human equivalent.

`cwcli axi setup-wizard <project> <site> [--country <country>] [--currency <currency>] [--timezone <timezone>]` headlessly completes a site's Frappe setup wizard. A fresh site - whether created by `cwcli init`/`cwcli axi init` without `--complete-setup`, or by a bare `bench new-site` outside cwcli entirely - is left in the setup-wizard state: `frappe.is_setup_complete()` is `false` and the `desktop:home_page` default stays `setup-wizard`, so a non-System-Manager desk renders with no navbar. This runs the same whitelisted RPC a person finishing the wizard in a browser triggers - `frappe.desk.page.setup_wizard.setup_wizard.setup_complete`, called via `bench execute ... --kwargs` with the args dict the wizard UI would have submitted (System Settings' country/currency/timezone/language; omitted flags fall back to `United States`/`USD`/`UTC`/`English`). It is **idempotent by Frappe's own design**: `setup_complete` checks `frappe.is_setup_complete()` first and no-ops under an advisory lock when the site is already set up, so re-running it is always safe - the report's `already_complete` field says whether it found the site already done. No user is created: `email`/`full_name`/`password` are deliberately left out of the args dict, so the `Administrator` account `cwcli init`'s `--admin-password` already provisioned is left untouched. Like the other bench-scoped verbs it takes `--bench`, has no `--yes`, and reports a stopped project as a usage error naming `cwcli start`.

`cwcli axi init` provisions a new instance, bench, and site the way `cwcli init` does, but non-interactively and as ONE terminal TOON document. It **blocks** for the full 10-20 minute run - the same block-and-emit-one-document shape as `cwcli axi apps update`, because a progress stream would break the one-TOON-document contract - narrating coarse phase labels to stderr; run `cwcli logs <project>` / `cwcli status <project>` from a second shell for live progress. Running several `axi init` calls in parallel can OOM-kill the host on a memory-constrained machine - serialize them instead. Adding a bench to an already-running instance never disturbs it or its other benches: the frappe container is never pulled, recreated, or restarted, so every already-serving bench keeps running throughout. Re-running init against a stopped instance starts its existing containers as they are, without pulling images or recreating them, so its benches keep their Python environments. If the re-run would change that instance's compose settings, such as a different `--port` on an instance still on the default port range (8000-8005), init refuses with exit 1 (`instance.settings_changed`), leaves the compose file unchanged, and names each change; remove the instance with `cwcli rm` (which backs it up) and run init again, or rerun init without the changed options. For an instance created on another `--port` or widened by `cwcli scale`, init does not compare `--port`. An instance whose frappe container has never started, such as one left by a first init that failed at `docker compose up`, has no bench to protect, so init neither refuses nor uses `--no-recreate` there: it pulls images and recreates the containers with the new options. After the bench+site is created, it starts the bench's dev services by default (the same `core.start` behind `cwcli start`/`cwcli axi start`), leaving a running bench rather than a created-but-idle one; `--no-start` creates without starting, for automation/CI, distinct from Docker container startup (stage 1 always brings the containers up regardless of this flag). A dev-services start failure degrades to a stderr warning rather than a non-zero exit, since the bench was already created. The site administrator password comes from the **`CWCLI_ADMIN_PASSWORD` environment variable (recommended)** or `--admin-password`; the flag wins if both are set, and with neither set the verb refuses with a usage error (exit 2) naming both - it never generates a password and never prompts. The env var keeps the secret off the process argv (visible in `ps`/shell history) that the flag exposes; the container-side `bench new-site` still receives it, so treat the value as one-time. The MariaDB root password takes the same shape via `CWCLI_DB_ROOT_PASSWORD` / `--db-root-password` (default: the instance's remembered password, else `123`); on a new instance it becomes MariaDB's own root password, as on `cwcli init`. The interactive decisions the human `cwcli init` prompts for become non-prompting errors: an existing bench with neither `--reuse-bench` nor `--no-reuse-bench` is a usage error (exit 2) naming both; a port conflict names `--port` (exit 1); containers that do not come up point at `cwcli status`/`cwcli logs` (exit 1); an invalid `--uid` (0, negative, or non-numeric) is rejected before any container work. `--uid` behaves as it does on `cwcli init` (including the root-host fallback, whose one-time warning is folded into the report's `warnings`). There is no `--auto-start` (compose `cwcli axi start` then re-run) and no `--verbose` (stdout is always TOON). `--bench` here is the NAME of the bench to create, distinct from the `--bench <index|label>` selector the other verbs use.

`--complete-setup` runs the new site's Frappe setup wizard headlessly right after creation - the same operation `cwcli axi setup-wizard` performs standalone, sharing its exact implementation - so a fresh site does not land in the unfinished setup-wizard state a non-System-Manager desk renders with no navbar in. `--country`/`--currency`/`--timezone` tune it (require `--complete-setup`; each has a sensible default when omitted) and are usage errors on their own. Its outcome rides as a `setup_wizard` block inside the SAME `InitReport` document, not a second one; a failure there is never exit `0` even though the bench and site above it were already created successfully - the `setup_wizard` block still carries what happened.

`cwcli axi rm <project> --yes` permanently removes an instance: its containers, named volumes (databases, sites, and files), own Docker Compose network, and local project directory.
It is the agent form of `cwcli rm`, and it runs the same code.
The verified per-bench backup is taken and checked before anything is destroyed, and a backup that cannot be created and verified **aborts the removal with everything still intact**, so data is never destroyed without a confirmed copy on the host.
The project's own network is removed whether `--volumes` or `--no-volumes` is selected because it holds no user data.

Two things differ from the human command, both deliberate.

`--yes` is required and means **consent only**.
On `cwcli rm` the same flag also starts a stopped project so a live backup can be taken; here it does not, because asking to delete an instance is not asking to start one.
The consequence is that a **stopped** project cannot be removed by this verb while its volumes are in scope: a live backup needs a running bench, and deleting without one is what the gate exists to prevent.
It refuses before touching anything and tells you the three ways forward: start it and re-run, pass `--no-volumes` (which keeps the named-volume data but still removes the network), or use the human `cwcli rm <project> --no-backup`.

An **orphan** reaches the core for live resource discovery.
If named volumes exist, or Docker cannot verify their absence, the backup gate refuses cleanup.
If Docker confirms that no named volumes remain, `cwcli axi rm` removes the orphan's network and local directory under the defaults.

There is deliberately **no `--no-backup`** here. That flag turns off the one guard between this command and unrecoverable loss, and on the agent surface it has no beneficiary - the human command is where a person confirms that trade, the same way `cwcli axi apps install` has no `--force`. Removal takes **one project per invocation**; the human command's list and stdin pipe are not offered, because a fan-out is how an agent reaches an instance nobody named.

`cwcli axi rm-site <project> <site> --yes` permanently drops ONE site - its database and its files - leaving the instance and every other site on it running.
It is the per-site inverse of `cwcli axi init`: a workflow that creates a new site per task on a shared, already-running bench needs a way to drop it again at teardown, and unlike `cwcli axi rm` it carries none of the whole-instance blast radius, so it needs none of that verb's deferral history.
`--yes` is required and means consent only, same as `cwcli axi rm`; there is no auto-start, so a stopped project is a usage error naming `cwcli start` (exit 2), exactly like every other bench-scoped verb.
The site is backed up before it is dropped, with the same verified gate `cwcli axi rm` uses: `bench backup --with-files`, this run's database dump and file archives copied out to the managed host archive, and the dump checked non-empty there.
If that fails, nothing is dropped, and the error (exit 1) carries bench's own output.
`bench drop-site --no-backup` then archives the dropped site's full directory - including `site_config.json` and its database credentials - inside the container's own `archived/sites/` folder; left there it would grow, unpruned, forever.
So this copies that one archive out beside the verified backup (`archived_host_path` in the TOON report holds both) and, only once that copy is verified on disk, deletes the in-container copy (`archive_pruned_in_container: true`).
If anything could not be verified, the in-container archive is left in place rather than deleted unbacked, and `ok: false` plus a warning names exactly what remains - a caller that only checks the exit code still learns it.

#### Making agents aware of the surface

An agent cannot use a surface it does not know exists.
There are two ways to tell it, and **you only need one**:

**1. The SessionStart hook** (recommended - ambient, plus live state):

```bash
cwcli axi setup
```

This installs a session-start hook into every agent harness it detects - Claude Code (`~/.claude/settings.json`), Codex (`~/.codex/hooks.json`, plus `[features] hooks = true` in `config.toml`), and OpenCode (a managed plugin in `~/.config/opencode/plugins/`).
The hook runs `cwcli axi` once per session and feeds the home view - the verb list *and* the live instance list - into the agent's opening context, so it can act without a discovery call.
It is idempotent (re-running it reports `unchanged`), it repairs the recorded path in place after a reinstall or a move, and it skips any harness that is not installed rather than creating config directories for it.
Restart the agent session to pick it up.

**2. The installable skill** (no per-session token cost, works in any agent):

```bash
npx skills add karotkriss/caffeinated-whale-cli --skill cwcli
```

The skill ([`skills/cwcli/SKILL.md`](./skills/cwcli/SKILL.md)) loads on demand when the agent recognises a Frappe/ERPNext task, and costs nothing on sessions that never touch one.
It is static, so it teaches the surface but cannot show live state - that is the hook's advantage.
It is generated from the CLI's own verb registry (`uv run python scripts/build_skill.py`) and a test fails if the committed copy goes stale, so it cannot drift from the real surface.

### Verbose Mode for Debugging

Use `-v` flag on any command to see detailed diagnostic output:

```bash
cwcli start frappe-one -v
cwcli update frappe-one --app erpnext -v
```

### Shell Completion

cwcli supports intelligent tab completion for project names, apps, and sites across all shells (Bash, Zsh, Fish, PowerShell).

**One-time setup:**

```bash
# Install completion for your current shell
cwcli --install-completion

# Restart your shell or source your shell config
source ~/.bashrc  # For Bash
source ~/.zshrc   # For Zsh
```

**What gets completed:**

- **Project names** - All commands that accept project names (start, stop, restart, inspect, label, logs, open, status, run, update, unlock, apps)
- **App names** - Commands with `--app` option or an `APP` argument (open, update, `apps install`, `apps uninstall`, `apps update`)
- **Site names** - Commands with `--site` option (unlock)

**Examples:**

```bash
# Press TAB after typing partial project name
cwcli start frap<TAB>
# Completes to: cwcli start frappe-one

# Press TAB to see available apps for a project
cwcli update frappe-one --app <TAB>
# Shows: erpnext  frappe  hrms  custom_app

# Press TAB to see available sites
cwcli unlock frappe-one --site <TAB>
# Shows: site1.localhost  site2.localhost
```

**How it works:**

- **Projects**: Queried from Docker containers in real-time
- **Apps/Sites**: Loaded from cached project data (run `cwcli inspect` first)
- **Fast & Context-aware**: Completions adapt based on the project specified

**Troubleshooting:**

If completion doesn't work:
1. Ensure you've run `cwcli --install-completion`
2. Restart your shell
3. For apps/sites, run `cwcli inspect <project>` to populate the cache

## Architecture

The CLI uses:

- **Docker SDK for Python** - Container management
- **Typer** - CLI framework with type hints
- **Rich** - Terminal formatting and spinners
- **Questionary** - Interactive prompts
- **Peewee ORM** - SQLite-based caching

**Logic core:** business logic and I/O live in a UI-pure `core/` package that carries no `rich`/`questionary`/`typer`; it returns a serializable typed envelope (or raises a typed error) so the human CLI, the `cwcli axi` agent surface, and the Console GUI are all thin frontends over one implementation.
`backup`, `unlock`, `stop`, `label`, `run`, `ls`/`list`, `where`, `start`/`status`/`restart`, `logs`, `inspect`, `apps`, `update`, `open`, `init`, `config`, `restore`, `rm`, `rm-site`, and `rm-bench` are migrated onto it so far.

**Data Directories:**
- **Projects**: `~/.cwcli/projects/` - Project directories created by `cwcli init`
- **Config**: `~/.cwcli/config/` - Configuration files
- **Cache**: `~/.cwcli/cache/cwc-cache.db` - Project inspection cache
- **Runtime**: `~/.cwcli/run/` - PID and log files for background services
- **Archive**: `~/.cwcli/archive/` - Pre-deletion backups and config snapshots from `cwcli rm`, plus dropped-site archives from `cwcli rm-site` and `cwcli rm-bench`
- **Backups**: `~/.cwcli/backups/<project>/<site>/<id>/` - The backup store managed by [`cwcli backups`](#backups---the-backup-store), outside every instance
- **Transfers**: `~/.cwcli/tmp/` by default, or `$TMPDIR/cwcli/`, `$TEMP/cwcli/`, or `$TMP/cwcli/` when the first non-empty variable in that order is set. Temporary files used by `restore --send` and `restore --receive`; each attempt is removed when it finishes or fails

**Relocating cwcli's data (`CWCLI_HOME`):**

Set the `CWCLI_HOME` environment variable to move cwcli's on-disk footprint - projects, config, cache, runtime, archive, and transfers without an explicit temp-directory override - out of `~/.cwcli` and into a directory of your choice.
When it is set, cwcli uses the corresponding directories under `$CWCLI_HOME` in place of the `~/.cwcli/*` locations above, except when an explicit temp-directory variable controls transfers.
When it is unset (or empty), cwcli uses the default `~/.cwcli` locations, so existing installs are unaffected.

Unlike repointing `HOME`, `CWCLI_HOME` redirects only cwcli's own state - it does not change your process `HOME`, so `git`, `ssh`, and other tools that read `HOME` are untouched.
The relocated cache keeps the same restrictive permissions as the default (`0700` directory, `0600` database file).

```bash
# Keep a separate, sandboxed cwcli state for one shell session
export CWCLI_HOME=/tmp/cwcli-sandbox
cwcli ls        # reads/writes /tmp/cwcli-sandbox instead of ~/.cwcli

# Or per-invocation, without exporting
CWCLI_HOME=/tmp/cwcli-sandbox cwcli ls
```

## License

This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.

## Contributing

Contributions are welcome! Please see our [Contributing Guide](./CONTRIBUTING.md) for detailed information.

**Quick Links:**
- [Git Workflow](./docs/contributing/git-workflow.md) - Complete contribution workflow
- [Commit Messages](./docs/contributing/commit-messages.md) - Conventional commit standards
- [Code Quality](./docs/contributing/code-quality.md) - Formatting with Black, linting with Ruff
- [Testing Guide](./docs/testing/guide.md) - How to write and run tests
- [CI/CD](./docs/contributing/ci-cd.md) - GitHub Actions workflows

**Getting Started:**

```bash
# Clone the repository
git clone https://github.com/karotkriss/caffeinated-whale-cli.git
cd caffeinated-whale-cli

# Install dependencies
uv sync --all-extras

# Run tests
uv run pytest --cov

# Format and lint
uv run black src/ tests/
uv run ruff check src/ --fix
```

For questions or issues, please open an issue on [GitHub](https://github.com/karotkriss/caffeinated-whale-cli).
