# cellos-ctl

`cellctl` is the kubectl-style CLI for CellOS. It is a thin client over
`cellos-server`: every subcommand corresponds to exactly one HTTP call,
there is no client-side cache, and exit codes are a stable contract routed
through the pattern-11 `axiom_exit::Exit` FSM
(0 success / 2 usage / 3 preflight·validation / 64 API·transport failure).

Configuration lives in `~/.cellos/config` (TOML); the typical fields are
`server_url` and `api_token`. CLI flags and the `CELLCTL_SERVER` /
`CELLCTL_TOKEN` environment variables override the on-disk values.

## Commands

| Command | What it does |
|---|---|
| `apply -f <file>` | Submit a formation YAML spec (POST `/v1/formations`). |
| `get formations` / `get cells` | List resources. |
| `describe formation <name>` / `describe cell <name>` | Show state + recent events. |
| `delete formation <name>` | Tear down a formation (and its cells). |
| `logs <cell> [-f]` | Stream CloudEvents for one cell. |
| `events [--formation N] [-f]` | Stream global / formation-scoped events. |
| `rollout status <name>` | Poll a formation to a terminal state. |
| `diff -f <file>` | Compare local YAML against the server-side formation. |
| `config show` / `config set-server <url>` / `config set-token <token>` | Read/write `~/.cellos/config`. |
| `version` | Print client + server versions. |
| `webui` | Spin up a localhost browser proxy for the web view. |

## Formation manifest shapes

`cellctl apply -f <file>` forwards the file's contents verbatim to
`POST /v1/formations`. The server accepts **either** of two shapes; pick
one per file (mixing them in a single document is rejected).

**Kubectl-style** — matches `contracts/schemas/formation-v1.schema.json`
and is the preferred form for new manifests. New operators who already
think in kubectl terms will recognise it immediately:

```yaml
apiVersion: cellos.dev/v1
kind: Formation
metadata:
  name: demo
spec:
  coordinator: coord
  members:
    - name: coord
    - name: worker-a
      authorizedBy: coord
```

**Flat** — the server's canonical internal form. Shorter; used by
older manifests and by the server's test fixtures:

```yaml
name: demo
coordinator: coord
members:
  - id: coord
  - id: worker-a
    authorizedBy: coord
```

The mapping (kubectl → flat) is:

| kubectl path | flat path |
|---|---|
| `metadata.name` | `name` |
| `spec.coordinator` | `coordinator` |
| `spec.members[].name` | `members[].id` |
| `spec.members[].authorizedBy` | `members[].authorizedBy` |

`apiVersion` MUST be `cellos.dev/v1` and `kind` MUST be `Formation`;
anything else is rejected with `400 /problems/bad-request`. After the
adapter normalizes the document, the usual ADR-0010 admission checks
(`no-coordinator`, `multiple-coordinators`, `authority-not-narrowing`,
`cycle`, `duplicate-member-id`) run on the flat form and surface their
stable `/problems/formation/*` discriminants. The
`duplicate-member-id` type was split out from `multiple-coordinators`
so operators can tell a structural admission rejection ("two members
both named `coord` — pick one to be the coordinator") apart from a
typo ("two members share the same id — rename one").

## webui

`cellctl webui` is the only supported way to reach the CellOS browser
view (ADR-0017). It is a foreground process that runs a localhost
reverse proxy in front of `cellos-server`:

```
cellctl webui                       # default: bind BOTH loopback TCP and Unix socket
cellctl webui --open                # also launch the system browser
cellctl webui --bind loopback       # TCP loopback only (no Unix socket)
cellctl webui --bind unix           # Unix socket only — no browser URL (Linux/macOS)
```

On startup the command:

1. Reads `~/.cellos/config` (or `$CELLCTL_SERVER` / `$CELLCTL_TOKEN`)
   for the upstream URL and bearer token.
2. Binds one or both listeners according to `--bind`. The default
   (`auto`) binds **both** a loopback TCP port (so the browser can
   reach the proxy) and a Unix domain socket at
   `${XDG_RUNTIME_DIR:-/tmp}/cellctl-webui-<pid>.sock` with mode
   `0600` (so inter-process tooling can bypass loopback without
   exposing a TCP port to anything else on the machine). Both URLs
   are printed on stdout. The Unix socket is removed on graceful
   shutdown. On Windows, `auto` degrades to loopback-only and
   `--bind unix` errors.
3. Mints a 32-byte random session token and prints the launch URL with
   the token in the URL **fragment**:
   `http://127.0.0.1:<port>/#sess=<base64>`. Fragments are never
   transmitted in HTTP requests, so the token does not appear in proxy
   logs, server logs, or `Referer` headers.
4. Serves the Vite-built bundle from `crates/cellos-ctl/static/`. The
   bundle reads `location.hash`, posts to `/auth/exchange`, receives an
   `HttpOnly; SameSite=Strict` cookie, and clears the fragment.
5. Reverse-proxies `GET /v1/*` and `GET /ws/events` upstream, injecting
   `Authorization: Bearer <token>` on every outbound request. The
   bundle never sees the bearer token.
6. **Refuses any non-`GET` method with HTTP 405** (`Allow: GET`). This
   is the structural enforcement of ADR-0016's read-only browser
   boundary — even a compromised bundle cannot write through the
   proxy.
7. Exits on `Ctrl-C`, printing `shutting down` on stderr.

### Security properties

- The bearer token in `~/.cellos/config` never leaves the machine; it
  lives on the proxy's outbound socket and nowhere else.
- The browser sees a session cookie that is meaningless outside this
  `cellctl webui` process. When the process exits, the cookie is dead.
- Browser writes are impossible: the proxy returns 405 on any non-GET
  to anything other than `/auth/exchange` (the cookie-mint endpoint).
- The launch URL's token lives only in the fragment, so it cannot
  appear in HTTP access logs, the `Referer` header, or anything else
  that sees the path/query.

### Flags

| Flag | Default | Meaning |
|---|---|---|
| `--open` | off | After binding, launch the system browser at the URL. Ignored if there is no loopback URL (i.e. `--bind unix`). |
| `--bind <auto\|loopback\|unix>` | `auto` | `auto`: bind both loopback TCP and a Unix socket (Linux/macOS) or loopback-only (Windows). `loopback`: TCP only. `unix`: Unix socket only — no browser-reachable URL. |

### Bind modes in detail

| Mode | TCP loopback | Unix socket | Browser-reachable? | Use case |
|---|---|---|---|---|
| `auto` (default) | yes (random high port) | yes (`/tmp/cellctl-webui-<pid>.sock`, mode 0600) | yes | Normal desktop use. The browser uses the TCP URL; other local tools (forwarders, `curl --unix-socket`, `socat`) can use the UDS without exposing the proxy on a TCP port. |
| `loopback` | yes | no | yes | Same as today's behavior. Useful if you don't want a socket file in `$XDG_RUNTIME_DIR` / `/tmp`. |
| `unix` | no | yes | **no** | Inter-process forwarding only (e.g. `ssh -L 8080:/tmp/cellctl-webui-<pid>.sock` to expose the proxy to a remote browser through an SSH tunnel). `--open` is a no-op. |

Trade-offs:

- **`auto`** is the operator-friendly default. The cost is one extra
  file in `$XDG_RUNTIME_DIR` (or `/tmp` if the env var is unset) for
  the lifetime of the `cellctl webui` process. The file is mode `0600`
  (owner read+write only) and is unlinked on `Ctrl-C`.
- **`loopback`** is for environments where you specifically don't want
  a UDS — e.g. running inside a sandbox that disallows AF_UNIX, or
  paranoia about leftover socket files surviving a crash. If you crash
  with `auto`, the stale `.sock` file is harmless (a fresh run unlinks
  and re-binds) but it's still clutter.
- **`unix`** is the highest-isolation mode: nothing on the loopback
  interface, only filesystem permissions guard the proxy. The cost is
  that the browser can't reach it directly — you need a forwarder.
  Best used over an SSH tunnel into a remote operator host.

### Building the bundle

`cellctl webui` looks up `crates/cellos-ctl/static/` (set via
`CARGO_MANIFEST_DIR` at build time; override with
`$CELLCTL_WEBUI_BUNDLE_DIR` for development). If the directory is
missing, run the Vite build first:

```
npm --prefix web run build
```

See ADR-0017 for the full design rationale and ADR-0016 for the
read-only-browser invariant the 405-on-non-GET rule enforces.

## Doctrine

`cellctl` follows the Algol/AXIOM cohort doctrine (the same primitives as
tdep / tboundary / tflip):

- **Exit-code FSM (pattern 11).** Every process exit is routed through
  `axiom_exit::Exit`, the cohort-wide single source of truth for the exit
  byte: `0` ok, `1` assertion-failed (verify mismatch), `2` usage, `3`
  preflight (local parse/validation), `4` degraded, `>=64` tool-specific.
  `cellctl`'s "API/transport failure" is `Exit::ToolSpecific(64)` —
  the server said no, or was never reached — and is distinct from a usage
  (`2`) or preflight (`3`) failure. Errors go to stderr; machine-readable
  output goes to stdout. See `src/exit.rs`.
- **BLAKE3 audit trail (`axiom.audit.v1`).** CellOS lifecycle events are
  emitted to a BLAKE3-chained `axiom.audit.v1` sink that `aegress
  corridor-verify` ingests natively (ADR-0003). See `crates/cellos-core`.
- **cex correlation spine.** The supervisor mints a `cexsessionid` ULID per
  run to correlate the started/destroyed event pair, byte-compatible with
  the aegress signer's `cexsessionid` value space. cex events are separate
  artifacts and do not alter audit-row bytes.

cex events are emitted server- and supervisor-side; `cellctl` is the thin
read/apply client and inherits the exit-code half of the contract.
