Files
clide/docs/ADRs/0006-cli-and-event-surface.md
T
jpmschweitzerandClaude 9522625304 retire Go sidecar; adopt Dart core (ADR 0005)
ADR 0005 supersedes ADR 0002. The "sidecar vs app" split was
load-bearing on Go-vs-Dart being a meaningful boundary. Going
all-Dart collapses that — the daemon becomes clide --daemon mode
of the same AOT binary the CLI lives in, both sharing lib/ with
the Flutter app. The one native gap (Dart's multi-threaded VM
can't safely fork+exec) is filled by a small C supporter tool —
ptyc, Project Terminal Controller, peer of pql — rather than
introducing a second core language.

ADR 0006 defines the CLI/event surface on top of that Dart core:
subsystem list (pane/tab/editor/panel/tree/git/pql/canvas/graph/
theme/settings/project), command shape, versioned JSON event
schema, pql-parity exit codes, and command-event duality as the
operational form of user/Claude parity.

Deleted: sidecar/cmd, sidecar/go.mod, every sidecar/internal
package. Rewritten: Makefile (dart compile exe, flutter
analyze/format/test, build-linux/build-macos, ptyc-build),
ci/*.sh, .githooks/pre-push (no more GOBIN PATH dance),
.gitignore (Flutter/Dart at repo root, ptyc section),
project.yaml (drop module: and go_version:), CLAUDE.md
(guardrails, dependencies, commands refreshed).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 00:28:27 +02:00

191 lines
8.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR 0006 — CLI and event surface contract
**Status:** accepted
**Date:** 2026-04-20
## Context
[ADR 0001](0001-cli-first-not-mcp.md) established that Claude drives
Clide via a Bash CLI, not MCP. That decided the *channel*. It did
not define the *surface* — which subsystems exist, how commands
relate to events, what the JSON looks like on the wire, how
subscribers discover state changes.
CLAUDE.md states the rule colloquially: "every CLI subcommand has a
UI affordance in the app, and every UI action has a CLI. If you add
one side without the other, the feature is incomplete." This ADR
restates that as an implementable contract.
The shape needs to satisfy three things at once:
1. **User/Claude parity.** Anything the user can do with a mouse,
Claude can do with `clide <...>`. Anything the user can observe
in the UI, Claude can observe via events.
2. **Daemon as authoritative state.** The app and the CLI are both
clients. State lives in the `clide --daemon` process; commands
mutate it; events broadcast changes to all subscribers.
3. **pql-style ergonomics.** One tool-use pattern for Claude across
pql and clide — same exit codes, same JSON-on-stdout habit, same
stderr-for-diagnostics rule.
## Decision
The CLI is organised into **subsystems**. Each subsystem owns a
noun, a set of verbs, and a set of events. The set is closed at any
point in time (documented); growth is additive (new verbs, new
events — never renaming existing ones without a version bump).
### Subsystem list (initial, by tier)
| Subsystem | Tier | Nouns | Representative verbs | Representative events |
|---|---|---|---|---|
| `pane` | 1 | terminal pane | spawn, list, focus, close, write, resize, tail | `pane.spawned`, `pane.output`, `pane.exit`, `pane.resized` |
| `tab` | 2 | workspace tab | new, switch, close, list | `tab.opened`, `tab.switched`, `tab.closed` |
| `open` | 2 | editor shortcut | *(verb-only: `clide open <path>`)* | — (emits `editor.opened`) |
| `editor` | 2 | active editor buffer | goto, highlight, insert, replace-selection, save | `editor.opened`, `editor.selection_changed`, `editor.saved` |
| `panel` | 2 | sidebar/context panels | show, hide, toggle, list | `panel.visibility_changed` |
| `tree` | 2 | file tree | reveal, refresh | `tree.node_expanded`, `file.changed` |
| `git` | 3 | working tree | status, stage, unstage, stage-hunk, commit, stash, pull, push | `git.status_changed`, `git.branch_changed` |
| `pql` | 4 | queries | run, tags, backlinks | `pql.result` |
| `canvas` | 5 | canvas surface | open, node add/move/connect, save | `canvas.node_added`, `canvas.node_moved`, `canvas.connection_added` |
| `graph` | 5 | graph view | open, focus, filter | `graph.focused`, `graph.filter_changed` |
| `theme` | 6 | theme/palette | set, list, get | `theme.changed` |
| `settings` | 6 | settings store | get, set, list | `settings.changed` |
| `project` | — | whole-workspace | status, reload, events | `project.ready`, `project.reloaded` |
Two umbrella entry points sit outside any subsystem:
- `clide tail --events [--filter <subsystem>[:<id>]]` — subscribe to
the event stream. Bare `tail --events` gets everything; filtered
forms narrow by subsystem or by subsystem+id (e.g.
`--filter pane:p_7`, `--filter git`).
- `clide status` — one-shot daemon snapshot: connected clients,
live panes, open tabs, workspace root, daemon version, uptime.
### Command shape
```
clide <subsystem> <verb> [<positional>...] [--flag ...] [-- argv...]
```
- Positionals are nouns/ids; flags are modifiers.
- `--` separates Clide's args from an inner argv passed through
(e.g. `clide pane spawn --cwd X -- tmux new-session -A -s foo`).
- Verbs are imperative (`spawn`, not `create-pane`).
- Where it reads naturally, single-word shortcuts exist for the
hottest paths (`clide open <path>``clide editor open <path>`).
Shortcuts alias; they do not fork.
### Exit-code contract (parity with pql)
| Code | Meaning |
|---|---|
| `0` | Success |
| `1` | User error (bad args, unknown id, precondition failed) |
| `2` | Tool error (daemon unreachable, IPC failure, internal panic) |
| `3` | Not-found (id or path doesn't resolve) |
| `4` | Conflict (state busy, already-running, concurrent-modify) |
| `64``78` | Reserved, per `sysexits.h`, for future specific cases |
Diagnostic JSON on **stderr** (not stdout) on any non-zero exit:
```json
{"code":1,"kind":"user_error","subsystem":"pane","message":"pane id p_99 not found","hint":"clide pane list"}
```
Stdout stays machine-parseable on success. This matches pql.
### Event schema
Events are JSON objects, one per line, on the `--events` stream.
Every event:
```json
{
"v": 1,
"ts": "2026-04-20T21:00:00.123Z",
"type": "pane.output",
"subsystem": "pane",
"id": "p_7",
"payload": { "bytes_b64": "…", "seq": 412 }
}
```
- `v`: schema version. Bumped only for breaking changes. Old
subscribers pin `v`.
- `ts`: ISO-8601 UTC with millisecond precision.
- `type`: `<subsystem>.<verb_past|noun_changed>`. Past-tense for
things that happened; `_changed` suffix for state transitions.
- `subsystem` + `id`: redundant with `type`, but makes
filtering cheap and future-flexible.
- `payload`: subsystem-defined; documented per subsystem.
Binary payloads (PTY output, file contents) are base64. The ergonomic
cost is worth the "entire stream is line-delimited JSON" invariant.
### Command ↔ event duality
Every state-changing command emits at least one event. Subscribers
see the same mutation whether they triggered it or not, and the
issuing client gets the event back (so `clide pane spawn` followed
by a `tail --events` subscription sees `pane.spawned` regardless of
subscribe order, via a short replay buffer per subsystem).
Read-only commands (`list`, `get`, `status`) emit nothing.
### User/Claude parity as a check
Every merge to `main` that adds a UI affordance must either:
- add the matching CLI verb, or
- include a linked follow-up task naming the verb to add next.
Every merge that adds a CLI verb must either:
- surface it in the UI, or
- document why the verb is Claude-only (rare; mostly diagnostics
like `clide status`).
Events have the symmetric rule: any UI surface that reacts to state
must react to the corresponding event; any new event must be
consumable both by the UI and by `clide tail --events`.
## Consequences
- **Surface is enumerable.** Adding a subsystem means adding a row
to the table above and specifying its verbs + events in a short
doc under `docs/cli/`. The daemon registers it; the CLI dispatcher
picks it up; `clide --help` and `clide <subsystem> --help` stay
accurate by construction.
- **Wire schema is versioned.** `v: 1` is the starting point.
Compatibility breaks bump the major and land alongside a
`project.yaml` `schema_version:` bump.
- **Replay buffer per subsystem.** Cheap (most subsystems emit
seldom); needed so a subscriber that connects after a command
still sees that command's effect. Buffer depth per subsystem is a
tuning parameter; defaults to 16 events.
- **Events are the only UI→app state channel.** The Flutter app
does not poll; it subscribes. Panels render from the last event
for their subsystem + current snapshot from `project status`.
- **pql events fit naturally.** Long-running `pql` queries stream
rows as `pql.result` events keyed by a query id, letting the
Query panel render incrementally.
- **Testability.** Every subsystem can be integration-tested by:
start a daemon → open a `tail --events` subscriber → issue
commands over the CLI → assert events. No UI needed for
protocol-level coverage.
- **Extension API (Tier 6) inherits this.** A Dart extension
publishes a subsystem; the same registration pipeline exposes
it to Claude via the CLI. Extensions don't get a second-class
channel.
## Open questions
- **Authorisation granularity.** The daemon's token auth is coarse
(allow all / deny all). Later, per-subsystem grants may matter
(e.g. restrict `git push`). Out of scope here.
- **Back-pressure on event streams.** A subscriber that falls behind
on `pane.output` (a firehose) needs a policy: drop oldest, block
producer, coalesce, or kill subscriber. Defer until Tier 1 is in
real use.
- **Event persistence.** Events are in-memory only in v1. If a
future need (audit log, undo) wants persistence, it becomes a
subsystem that subscribes and writes — not a property of the bus.