# spacesh M4 — design spec

> Slice: **M4 — CLI** (thin `spacesh` client over the command bus) **+ the `set_state`/`state` status primitive** (proto + daemon).
> Reordered ahead of M3 so the agent-hook status path in M3 targets a real `spacesh notify`, not a mock.
> Builds on M0+M1+M2 (shipped). Base spec: `DOCS/MAIN.md` §5, §7.4, §10.1, §12.
> Date: 2026-06-09.

---

## 1. Scope

**In (decided in brainstorm):**
- New `spacesh-cli` crate producing the `spacesh` binary — a **one-shot** client to the daemon UDS (connect → request → await matching `res` → exit). Own small blocking client over `spacesh-proto` codec; not the persistent streaming bridge.
- **Command set: core + M2 operations** (everything scriptable; interactive `attach`/`input`/`detach` are excluded).
- **`set_state`/`state` primitive** added to `spacesh-proto` and the daemon: 5 ephemeral states `work/wait/done/error/idle`, in-memory (NOT persisted), orthogonal to the M2 `running`/`stopped` lifecycle, new running panel starts `idle`. `SurfaceView` (status) gains a `state` field.
- **Lazy-start** the daemon from the CLI (like the GUI, `DOCS/MAIN.md` §4.1); **`notify` is best-effort** — if the socket is absent it exits 0 silently without spawning a daemon.
- `--json` global flag (raw `data` / error object); human output otherwise; `status` renders a compact table. Shell completions via `clap_complete`.

**Out (later slices):**
- The status **detection sources** that CALL `set_state` — Claude Code hook adapter, OSC 133 shell integration, output-pattern fallback — are **M3**.
- The status **UI** — panel rings, sidebar badges, Event Center wiring, native macOS notifications — is **M3**.
- External notifications (Telegram/MAX), zoom, scrollback search, diff view — **M5**. Remote — **M6**. Auth — separate.

M4 ships only the status *primitive* (store + emit + status field) and the CLI. `spacesh notify` is the public entry to the primitive that M3's hooks will use.

---

## 2. Crate & transport

```
crates/spacesh-cli/
├── Cargo.toml            # clap, clap_complete, tokio (rt-multi-thread), spacesh-proto, anyhow, serde_json
└── src/
    ├── main.rs           # clap parse -> dispatch -> process exit code
    ├── client.rs         # one-shot UDS: ensure_daemon (lazy-start), request(cmd)->Value, notify (best-effort)
    ├── commands.rs       # subcommand enum + mapping to Cmd + result rendering
    └── output.rs         # human renderers (status table) + --json passthrough
```

- Binary name: `spacesh` (per `DOCS/MAIN.md`). The daemon binary `spaceshd` is a sibling; the CLI spawns it during lazy-start.
- `client.rs`:
  - `request(cmd: Cmd) -> Result<serde_json::Value>` — connect (lazy-start if absent), `write_frame(Req{id,cmd})`, read frames skipping any `evt`/non-matching `res` until the matching `res`; on `ok:true` return `data`, on `ok:false` return an error carrying `code`/`msg`.
  - `notify(surface_id, state)` — best-effort: try-connect only (no spawn); on success send `SetState` and read the `res` (ignore result); on connect failure return `Ok(())`.
  - `ensure_daemon()` — connect; if it fails, locate `spaceshd` next to the current exe, spawn it, poll-connect with a timeout (same shape as `app/src-tauri`'s `ensure_daemon`).
- Depends only on `spacesh-proto` (types + codec). No `spacesh-core`/`spacesh-pty`.
- Add `crates/spacesh-cli` to the root workspace members.

---

## 3. Status primitive — `set_state` / `state`

### proto

New `SurfaceState` enum (e.g. `crates/spacesh-proto/src/status.rs`, re-exported from `lib.rs`):
```rust
#[serde(rename_all = "snake_case")]
pub enum SurfaceState { Work, Wait, Done, Error, Idle }
```
- `Cmd::SetState { surface_id: SurfaceId, state: SurfaceState }`
- `Evt::State { surface_id: SurfaceId, state: SurfaceState }`
- `SurfaceView` gains `state: SurfaceState` (defaults to `Idle`).

### daemon

- Registry holds `states: HashMap<SurfaceId, SurfaceState>` — **in-memory, not in `PersistState`**.
- A new running surface (`new_surface` / `split_surface` / `apply_preset` / `restart_surface`) initializes its state to `Idle`.
- `Cmd::SetState`: if the surface exists AND is `running` → store the state, emit `Evt::State`, reply `ok`. Unknown or non-running surface → `NOT_FOUND` (the CLI `notify` swallows this — best-effort). State is meaningful only while running.
- `Exit` (running→stopped): remove the surface's entry from `states` (status is moot for a stopped panel; the UI renders `stopped`). `restart_surface` re-initializes to `Idle`.
- `status`: `SurfaceView.state` comes from `states` (or `Idle` if absent).
- `Evt::State` fans out to all clients via the existing single broadcast path.

### boundary with M3

M4 provides only the primitive: storage + `state` event + the `state` field in `status`. The callers (`notify` from hooks, OSC 133, fallback patterns) and the UI (rings/badges/Event Center/native notifications) are M3, built on top.

---

## 4. CLI commands

`spacesh <subcommand>`. Each maps to one bus `Cmd`.

| Subcommand | Args / flags | Cmd | Human output |
|---|---|---|---|
| `open <path>` | | `Open` | `workspace_id` |
| `status` | `[--json]` | `Status` | table: workspace · group · panels (id, agent, running/stopped, **state**) |
| `new-surface <ws>` | `--cmd, --arg…, --cols=80, --rows=24` | `NewSurface` | `surface_id` |
| `split <surface>` | `--dir right\|down, --cmd, --arg…` | `SplitSurface` | `surface_id` |
| `close <surface>` | | `Close` | `ok` |
| `focus <surface>` | | `Focus` | `ok` |
| `restart <surface>` | | `RestartSurface` | `ok` |
| `notify` | `--surface <id> --state work\|wait\|done\|error\|idle` | `SetState` | silent (best-effort) |
| `apply-preset <ws>` | `--preset <id> --agent <a>…` (slots in order) | `ApplyPreset` | `surface_ids` |
| `set-ratios <ws>` | `--path 0,1 --ratios 0.3,0.7` | `SetRatios` | `ok` |
| `move <surface>` | `--target <id> --edge left\|right\|top\|bottom` | `MoveSurface` | `ok` |
| `close-workspace <ws>` | | `CloseWorkspace` | `ok` |
| `group create` | `--name --color` | `CreateGroup` | `group_id` |
| `group set <id>` | `--name --color --order` | `SetGroup` | `ok` |
| `group delete <id>` | | `DeleteGroup` | `ok` |
| `set-meta <ws>` | `--name --group <id\|""> --unread <bool> --order <n>` | `SetWorkspaceMeta` | `ok` |
| `shutdown` | | `Shutdown` | `ok` |
| `completions <shell>` | `bash\|zsh\|fish` | — | completion script (clap_complete) |

**Behavior:**
- **`--json`** (global): prints the raw response `data` (or `{ "ok": false, "error": {…} }` on failure). Human mode is default; `status` renders a compact table.
- **Lazy-start:** before a request, connect; if no socket, locate `spaceshd` next to the binary, spawn, poll for readiness. Exception: `notify` connects best-effort and exits 0 silently if absent (no spawn) — a hook must not orphan a daemon or break the agent.
- **Exit codes:** `0` ok; `1` error (`res ok:false` or transport); `notify` always `0`; clap arg errors use clap's default `2`. Errors print to stderr as `code: msg`.
- **`set-meta --group ""`** maps to "ungroup" (`Some(None)`); omitting `--group` means "no change" — mirrors the daemon's `Option<Option<GroupId>>`.
- `focus` is currently a daemon no-op (window raise is GUI-side); the CLI sends it anyway for parity.
- **Completions** via `clap_complete::generate` from the same clap command tree.

---

## 5. Errors & testing

**Errors / edge-cases:**
- Transport: connect failure after lazy-start timeout → stderr `daemon unavailable`, exit 1; for `notify` → exit 0 silently.
- Invalid args (bad `--state`/`--edge`/`--dir`, non-numeric `--ratios`/`--path`) → clap enum/value-parser validation, exit 2.
- Daemon `ok:false` → stderr `<code>: <msg>`, exit 1 (except `notify`). With `--json`, print `{ "ok": false, "error": {…} }` to stdout, exit 1.
- Lazy-start race: the daemon's M0 single-instance lock resolves concurrent spawns; the CLI just polls the ready socket.

**Tests:**

| Level | What |
|---|---|
| `spacesh-proto` (unit) | `SurfaceState` + `Cmd::SetState` / `Evt::State` serde round-trip; `SurfaceView.state` default |
| `spaceshd` (unit, registry) | new-surface → state `idle`; `set_state` on running stores; on stopped/unknown → not found; `exit` removes state; restart → idle; `status` view carries state |
| `spaceshd` (integration, serial-guard + multi_thread) | over socket: `set_state` on running → `status` shows it + a `state` evt is pushed; mismatched surface → `NOT_FOUND` |
| `spacesh-cli` (unit) | clap parsing of every subcommand → correct `Cmd`; human/`--json` rendering; `--ratios`/`--path`/enum parsers |
| `spacesh-cli` (integration, serial-guard + multi_thread) | spin a daemon on a temp socket → `spacesh open` / `new-surface` / `status --json` / `notify` work; `notify` with no daemon → exit 0; `status` with no daemon → lazy-start succeeds |
| manual | hook-style `spacesh notify --surface $ID --state done` flips `state` in `status`; `spacesh completions zsh` prints a script |

**Budgets:** one-shot CLI call < 50 ms against a live daemon (connect + req + res); lazy-start poll cadence as in M0.

**Test robustness:** any new socket/PTY integration test (daemon or CLI) uses `#[tokio::test(flavor = "multi_thread", worker_threads = 2)]` and the `crate::test_support::serial()` guard, matching M1/M2. The CLI integration tests bind their own daemon on a temp socket and must serialize likewise (introduce a `test_support::serial()` in the CLI crate, or run those tests single-threaded).

---

## 6. Implementation order (within the slice)

1. `spacesh-proto` — `SurfaceState`, `Cmd::SetState`, `Evt::State`, `SurfaceView.state` + serde tests.
2. `spaceshd` — registry `states` map (idle on spawn, set/get, drop on exit, restart→idle), `SetState` dispatch + `Evt::State`, `status` carries state + tests.
3. `spacesh-cli` scaffold — crate, clap command tree, `client.rs` (request + ensure_daemon + notify) + unit tests for parsing.
4. `spacesh-cli` — wire every subcommand to its `Cmd`, human + `--json` rendering, `status` table, completions.
5. `spacesh-cli` — integration tests (daemon on temp socket); `notify` best-effort + lazy-start paths.

End of slice: a working `spacesh` CLI at full bus parity (minus interactive panels), and a real `spacesh notify` driving the in-memory status primitive — the foundation M3's detection sources and status UI build on.
