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