# spacesh M4 Implementation Plan — CLI + status primitive > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship the `spacesh` CLI (a one-shot client at full bus parity minus interactive panels) and the `set_state`/`state` status primitive (5 ephemeral states stored in the daemon, emitted as a `state` event, surfaced in `status`). **Architecture:** New `spacesh-cli` crate produces the `spacesh` binary with a small blocking-over-async one-shot UDS client built on `spacesh-proto`'s codec. The daemon gains an in-memory per-surface `SurfaceState` map (not persisted), a `SetState` command, and a `State` event. Both daemon and CLI honor `SPACESH_SOCK` to locate the socket (defaults to `~/.spacesh/sock`), which also isolates tests. **Tech Stack:** Rust — clap + clap_complete (CLI), tokio (net/rt), serde_json, anyhow; builds on the shipped M0–M2 crates. **Spec:** `DOCS/superpowers/specs/2026-06-09-spacesh-m4-design.md`. Base: `DOCS/MAIN.md` §5/§7.4/§10.1/§12. **Conventions:** English code/comments. `cargo test --workspace` is the DoD and must stay green & non-flaky — new socket/PTY integration tests use `#[tokio::test(flavor = "multi_thread", worker_threads = 2)]` + a `serial()` guard (the daemon crate already has `crate::test_support::serial()`; the CLI crate introduces its own). Commit after each task; append: `Co-Authored-By: Claude Opus 4.8 (1M context) `. Do not `git push`. --- ## File Structure ``` Cargo.toml # + crates/spacesh-cli workspace member crates/spacesh-proto/src/ status.rs (new) # SurfaceState enum message.rs # + Cmd::SetState, Evt::State workspace.rs # + SurfaceView.state lib.rs # + re-exports crates/spaceshd/src/ registry.rs # + states map (idle on spawn / drop on exit) + state in to_view server.rs # + Cmd::SetState dispatch + Evt::State; init idle on every spawn; drop on exit lifecycle.rs # + SPACESH_SOCK env override in socket_path() crates/spacesh-cli/ (new) Cargo.toml src/ main.rs # clap parse -> dispatch -> exit code cli.rs # clap derive types (Cli, Sub, arg enums) mapping.rs # Sub -> proto Cmd (pure, unit-tested) client.rs # one-shot UDS: ensure_daemon, request, notify output.rs # render res (human / --json), status table ``` --- ## Phase 1 — proto: status primitive ### Task 1: SurfaceState + SetState/State + SurfaceView.state **Files:** - Create: `crates/spacesh-proto/src/status.rs` - Modify: `crates/spacesh-proto/src/message.rs`, `crates/spacesh-proto/src/workspace.rs`, `crates/spacesh-proto/src/lib.rs` - [ ] **Step 1: SurfaceState enum + test** Create `crates/spacesh-proto/src/status.rs`: ```rust use serde::{Deserialize, Serialize}; /// Ephemeral agent-activity status of a running surface (orthogonal to the /// running/stopped process lifecycle). Defaults to `Idle`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SurfaceState { Work, Wait, Done, Error, #[default] Idle, } #[cfg(test)] mod tests { use super::*; #[test] fn serializes_snake_case() { assert_eq!(serde_json::to_string(&SurfaceState::Work).unwrap(), r#""work""#); assert_eq!(serde_json::to_string(&SurfaceState::Idle).unwrap(), r#""idle""#); } #[test] fn default_is_idle() { assert_eq!(SurfaceState::default(), SurfaceState::Idle); } #[test] fn round_trips() { for s in [SurfaceState::Work, SurfaceState::Wait, SurfaceState::Done, SurfaceState::Error, SurfaceState::Idle] { let j = serde_json::to_string(&s).unwrap(); let back: SurfaceState = serde_json::from_str(&j).unwrap(); assert_eq!(back, s); } } } ``` - [ ] **Step 2: Add `state` to SurfaceView** In `crates/spacesh-proto/src/workspace.rs`, add the import and the field. Change the top import: ```rust use crate::status::SurfaceState; ``` And add the field to `SurfaceView`: ```rust pub struct SurfaceView { pub spec: SurfaceSpec, /// true = has a live actor/PTY; false = stopped (in tree, no process). pub running: bool, /// Ephemeral agent-activity status (meaningful while running). #[serde(default)] pub state: SurfaceState, } ``` - [ ] **Step 3: Add SetState command and State event** In `crates/spacesh-proto/src/message.rs`, add the import: ```rust use crate::status::SurfaceState; ``` Add to `enum Cmd` (before `Status`): ```rust SetState { surface_id: SurfaceId, state: SurfaceState }, ``` Add to `enum Evt`: ```rust State { surface_id: SurfaceId, state: SurfaceState }, ``` - [ ] **Step 4: Wire lib.rs** `crates/spacesh-proto/src/lib.rs`: ```rust pub mod codec; pub mod ids; pub mod layout; pub mod message; pub mod status; pub mod workspace; pub use ids::{GroupId, SurfaceId, WorkspaceId}; pub use layout::{LayoutNode, Orient}; pub use message::{Cmd, Envelope, ErrorBody, Evt}; pub use status::SurfaceState; pub use workspace::{Group, SurfaceSpec, SurfaceView, Workspace, WorkspaceView}; ``` - [ ] **Step 5: Add proto round-trip tests for the new variants** Append to the `tests` module in `crates/spacesh-proto/src/message.rs`: ```rust #[test] fn set_state_round_trips() { let env = Envelope::Req { id: 1, cmd: Cmd::SetState { surface_id: SurfaceId("s_1".into()), state: crate::status::SurfaceState::Done }, }; let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); assert_eq!(back, env); } #[test] fn state_event_round_trips() { let evt = Envelope::Evt(Evt::State { surface_id: SurfaceId("s_1".into()), state: crate::status::SurfaceState::Wait }); let j = serde_json::to_string(&evt).unwrap(); assert!(j.contains(r#""evt":"state""#)); let back: Envelope = serde_json::from_str(&j).unwrap(); assert_eq!(back, evt); } ``` - [ ] **Step 6: Run tests** Run: `cargo test -p spacesh-proto` Expected: PASS (all proto tests incl. status + 2 new message tests). Note: the daemon will NOT compile after this task (its `to_view` is missing the new `state` field and its `match cmd` lacks `SetState`) — that is expected and fixed in Task 2. Verify proto in isolation only. - [ ] **Step 7: Commit** ```bash git add crates/spacesh-proto/src/status.rs crates/spacesh-proto/src/message.rs crates/spacesh-proto/src/workspace.rs crates/spacesh-proto/src/lib.rs git commit -m "feat(proto): SurfaceState + SetState command + State event + SurfaceView.state" ``` --- ## Phase 2 — daemon: status storage & dispatch ### Task 2: states map, SetState dispatch, idle-on-spawn, drop-on-exit, SPACESH_SOCK **Files:** - Modify: `crates/spaceshd/src/registry.rs`, `crates/spaceshd/src/server.rs`, `crates/spaceshd/src/lifecycle.rs` - [ ] **Step 1: Add the states map to the registry** In `crates/spaceshd/src/registry.rs`: Add the import: ```rust use spacesh_proto::status::SurfaceState; ``` Add the field to `struct Registry`: ```rust /// Ephemeral per-surface status. In-memory only (never persisted). states: HashMap, ``` Add these methods inside `impl Registry` (near the live-actor section): ```rust pub fn set_state(&mut self, sid: &SurfaceId, state: SurfaceState) { self.states.insert(sid.clone(), state); } pub fn state(&self, sid: &SurfaceId) -> SurfaceState { self.states.get(sid).copied().unwrap_or_default() } pub fn drop_state(&mut self, sid: &SurfaceId) { self.states.remove(sid); } ``` Update `to_view` to include the state: ```rust fn to_view(&self, w: &Workspace) -> WorkspaceView { let surfaces = w.surfaces.iter().map(|(sid, spec)| { (sid.clone(), SurfaceView { spec: spec.clone(), running: self.live.contains_key(sid), state: self.state(sid), }) }).collect(); WorkspaceView { id: w.id.clone(), path: w.path.clone(), name: w.name.clone(), group_id: w.group_id.clone(), order: w.order, unread: w.unread, layout: w.layout.clone(), surfaces, } } ``` Also clear states in `restore` (cold start): add `self.states.clear();` next to `self.live.clear();`. - [ ] **Step 2: Add registry unit tests** Append to the `tests` module in `registry.rs`: ```rust #[test] fn state_defaults_idle_and_can_be_set() { let mut r = Registry::new(); let (ws, _) = r.open_workspace(std::env::temp_dir()); let sid = r.new_surface_id(); r.add_surface_spec(&ws, sid.clone(), spec()); assert_eq!(r.state(&sid), spacesh_proto::status::SurfaceState::Idle); r.set_state(&sid, spacesh_proto::status::SurfaceState::Work); assert_eq!(r.state(&sid), spacesh_proto::status::SurfaceState::Work); let v = r.workspace_view(&ws).unwrap(); assert_eq!(v.surfaces.get(&sid).unwrap().state, spacesh_proto::status::SurfaceState::Work); } #[test] fn drop_state_resets_to_idle() { let mut r = Registry::new(); let (ws, _) = r.open_workspace(std::env::temp_dir()); let sid = r.new_surface_id(); r.add_surface_spec(&ws, sid.clone(), spec()); r.set_state(&sid, spacesh_proto::status::SurfaceState::Error); r.drop_state(&sid); assert_eq!(r.state(&sid), spacesh_proto::status::SurfaceState::Idle); } ``` - [ ] **Step 3: SPACESH_SOCK override in lifecycle** In `crates/spaceshd/src/lifecycle.rs`, replace `socket_path`: ```rust pub fn socket_path() -> Result { if let Ok(p) = std::env::var("SPACESH_SOCK") { if !p.is_empty() { return Ok(PathBuf::from(p)); } } Ok(spacesh_dir()?.join("sock")) } ``` Add a test in `lifecycle.rs` tests module: ```rust #[test] fn socket_path_honors_env_override() { // Note: set/remove around the assertion; tests in this module run serially enough, // but guard by restoring afterwards. std::env::set_var("SPACESH_SOCK", "/tmp/spacesh-test-override.sock"); let p = socket_path().unwrap(); std::env::remove_var("SPACESH_SOCK"); assert_eq!(p, std::path::PathBuf::from("/tmp/spacesh-test-override.sock")); } ``` - [ ] **Step 4: Initialize idle on every spawn; drop on exit; dispatch SetState** In `crates/spaceshd/src/server.rs`: (a) After EACH place that calls `reg.set_live(handle)` for a newly spawned surface — there are four: `NewSurface`, `SplitSurface`, `ApplyPreset` (inside the loop), `RestartSurface` — add an idle init right after `set_live`. For the single-surface handlers: ```rust reg.set_live(handle); reg.set_state(&sid, spacesh_proto::status::SurfaceState::Idle); ``` (use `new_sid` in `SplitSurface`, `surface_id` in `RestartSurface`, the loop's `new_sid` in `ApplyPreset`). (b) In the `router`'s `ServerMsg::Exit` arm, drop the state alongside `mark_stopped`: ```rust ServerMsg::Exit { surface_id, code } => { reg.mark_stopped(&surface_id); reg.drop_state(&surface_id); let evt = Envelope::Evt(Evt::Exit { surface_id: surface_id.clone(), code }); broadcast_evt(&clients, &evt); } ``` (c) Add the `Cmd::SetState` arm to `handle_request` (place it near `Cmd::RestartSurface`): ```rust Cmd::SetState { surface_id, state } => { if reg.is_running(&surface_id) { reg.set_state(&surface_id, state); broadcast_evt(clients, &Envelope::Evt(Evt::State { surface_id: surface_id.clone(), state })); let _ = out.send(ok(id, serde_json::Value::Null)).await; } else { // unknown or stopped surface — status is only meaningful while running. let _ = out.send(err(id, "NOT_FOUND", "surface not running")).await; } } ``` - [ ] **Step 5: Add a daemon integration test for SetState** Append to the `tests` module in `server.rs` (mirror the existing integration-test scaffolding — `tempdir_path`, `wait_for_socket`, `req`, `res_data`, the store construction): ```rust #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn set_state_updates_status_and_emits_event() { let _serial = crate::test_support::serial(); let dir = tempdir_path(); let sock = dir.join("sock"); let store: std::sync::Arc = std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); let sock2 = sock.clone(); tokio::spawn(async move { let _ = serve(&sock2, store).await; }); wait_for_socket(&sock).await; let mut s = UnixStream::connect(&sock).await.unwrap(); let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await; let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string(); let r = req(&mut s, 2, Cmd::NewSurface { workspace_id: spacesh_proto::WorkspaceId(ws.clone()), command: Some("/bin/sh".into()), args: vec!["-c".into(), "sleep 1".into()], cols: 80, rows: 24, }).await; let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string(); let surface_id = spacesh_proto::SurfaceId(sid.clone()); // set_state on the running surface let r = req(&mut s, 3, Cmd::SetState { surface_id: surface_id.clone(), state: spacesh_proto::status::SurfaceState::Work }).await; assert!(matches!(r, Envelope::Res { ok: true, .. })); // status reflects it let r = req(&mut s, 4, Cmd::Status).await; let wss = res_data(&r)["workspaces"].as_array().unwrap(); let w0 = wss.iter().find(|w| w["id"] == ws).unwrap(); assert_eq!(w0["surfaces"][&sid]["state"], "work"); // unknown surface -> NOT_FOUND let r = req(&mut s, 5, Cmd::SetState { surface_id: spacesh_proto::SurfaceId("s_nope".into()), state: spacesh_proto::status::SurfaceState::Done }).await; match r { Envelope::Res { ok, error, .. } => { assert!(!ok); assert_eq!(error.unwrap().code, "NOT_FOUND"); }, _ => panic!() } } ``` - [ ] **Step 6: Run the full suite (3×)** Run: `cargo test --workspace > /tmp/m4.log 2>&1; echo EXIT=$?` — three times, all 0. Expected: proto + daemon (incl. new tests) green. - [ ] **Step 7: Commit** ```bash git add crates/spaceshd/src/registry.rs crates/spaceshd/src/server.rs crates/spaceshd/src/lifecycle.rs git commit -m "feat(daemon): per-surface status (set_state/state), idle-on-spawn, SPACESH_SOCK override" ``` --- ## Phase 3 — spacesh-cli ### Task 3: CLI scaffold — crate, clap tree, client, mapping (with unit tests) **Files:** - Modify: `Cargo.toml` (workspace members) - Create: `crates/spacesh-cli/Cargo.toml`, `crates/spacesh-cli/src/main.rs`, `cli.rs`, `mapping.rs`, `client.rs` - [ ] **Step 1: Add to workspace + crate manifest** In the root `Cargo.toml` `[workspace] members`, add `"crates/spacesh-cli"`. Add to `[workspace.dependencies]`: ```toml clap = { version = "4", features = ["derive"] } clap_complete = "4" ``` Create `crates/spacesh-cli/Cargo.toml` (lib + bin so integration tests can call the real client/mapping): ```toml [package] name = "spacesh-cli" edition.workspace = true version.workspace = true [lib] name = "spacesh_cli" path = "src/lib.rs" [[bin]] name = "spacesh" path = "src/main.rs" [dependencies] spacesh-proto = { path = "../spacesh-proto" } clap.workspace = true clap_complete.workspace = true tokio = { workspace = true } serde_json.workspace = true anyhow.workspace = true ``` - [ ] **Step 2: clap types** Create `crates/spacesh-cli/src/cli.rs`: ```rust use clap::{Parser, Subcommand, ValueEnum}; #[derive(Parser, Debug)] #[command(name = "spacesh", about = "spacesh CLI — thin client to the spacesh daemon")] pub struct Cli { /// Print raw JSON instead of human output. #[arg(long, global = true)] pub json: bool, #[command(subcommand)] pub cmd: Sub, } #[derive(ValueEnum, Clone, Copy, Debug, PartialEq)] pub enum StateArg { Work, Wait, Done, Error, Idle } #[derive(ValueEnum, Clone, Copy, Debug, PartialEq)] pub enum DirArg { Right, Down } #[derive(ValueEnum, Clone, Copy, Debug, PartialEq)] pub enum EdgeArg { Left, Right, Top, Bottom } #[derive(Subcommand, Debug)] pub enum Sub { Open { path: String }, Status, NewSurface { workspace_id: String, #[arg(long)] cmd: Option, #[arg(long = "arg")] args: Vec, #[arg(long, default_value_t = 80)] cols: u16, #[arg(long, default_value_t = 24)] rows: u16, }, Split { surface_id: String, #[arg(long, value_enum, default_value_t = DirArg::Right)] dir: DirArg, #[arg(long)] cmd: Option, #[arg(long = "arg")] args: Vec, }, Close { surface_id: String }, Focus { surface_id: String }, Restart { surface_id: String }, Notify { #[arg(long)] surface: String, #[arg(long, value_enum)] state: StateArg, }, ApplyPreset { workspace_id: String, #[arg(long)] preset: String, #[arg(long = "agent")] agents: Vec, }, SetRatios { workspace_id: String, #[arg(long, value_delimiter = ',')] path: Vec, #[arg(long, value_delimiter = ',')] ratios: Vec, }, Move { surface_id: String, #[arg(long)] target: String, #[arg(long, value_enum)] edge: EdgeArg, }, CloseWorkspace { workspace_id: String }, Group { #[command(subcommand)] action: GroupAction, }, SetMeta { workspace_id: String, #[arg(long)] name: Option, #[arg(long)] group: Option, #[arg(long)] unread: Option, #[arg(long)] order: Option, }, Shutdown, Completions { shell: clap_complete::Shell }, } #[derive(Subcommand, Debug)] pub enum GroupAction { Create { #[arg(long)] name: String, #[arg(long)] color: String }, Set { group_id: String, #[arg(long)] name: Option, #[arg(long)] color: Option, #[arg(long)] order: Option }, Delete { group_id: String }, } ``` - [ ] **Step 3: Write the mapping + its failing tests** Create `crates/spacesh-cli/src/mapping.rs`: ```rust use spacesh_proto::ids::{GroupId, SurfaceId, WorkspaceId}; use spacesh_proto::message::{Cmd, Edge, PresetSlot, SplitDir}; use spacesh_proto::status::SurfaceState; use crate::cli::{DirArg, EdgeArg, GroupAction, StateArg, Sub}; pub fn state_of(a: StateArg) -> SurfaceState { match a { StateArg::Work => SurfaceState::Work, StateArg::Wait => SurfaceState::Wait, StateArg::Done => SurfaceState::Done, StateArg::Error => SurfaceState::Error, StateArg::Idle => SurfaceState::Idle, } } /// Map a parsed subcommand to a bus command. `Completions` has no Cmd (handled /// before dispatch), so callers must not pass it here. pub fn to_cmd(sub: Sub) -> Cmd { match sub { Sub::Open { path } => Cmd::Open { path }, Sub::Status => Cmd::Status, Sub::NewSurface { workspace_id, cmd, args, cols, rows } => Cmd::NewSurface { workspace_id: WorkspaceId(workspace_id), command: cmd, args, cols, rows, }, Sub::Split { surface_id, dir, cmd, args } => Cmd::SplitSurface { surface_id: SurfaceId(surface_id), dir: match dir { DirArg::Right => SplitDir::Right, DirArg::Down => SplitDir::Down }, command: cmd, args, }, Sub::Close { surface_id } => Cmd::Close { surface_id: SurfaceId(surface_id) }, Sub::Focus { surface_id } => Cmd::Focus { surface_id: SurfaceId(surface_id) }, Sub::Restart { surface_id } => Cmd::RestartSurface { surface_id: SurfaceId(surface_id) }, Sub::Notify { surface, state } => Cmd::SetState { surface_id: SurfaceId(surface), state: state_of(state) }, Sub::ApplyPreset { workspace_id, preset, agents } => Cmd::ApplyPreset { workspace_id: WorkspaceId(workspace_id), preset_id: preset, slots: agents.into_iter().map(|a| if a == "shell" { PresetSlot { command: None, args: vec![] } } else { PresetSlot { command: Some(a), args: vec![] } }).collect(), }, Sub::SetRatios { workspace_id, path, ratios } => Cmd::SetRatios { workspace_id: WorkspaceId(workspace_id), node_path: path, ratios, }, Sub::Move { surface_id, target, edge } => Cmd::MoveSurface { surface_id: SurfaceId(surface_id), target_surface_id: SurfaceId(target), edge: match edge { EdgeArg::Left => Edge::Left, EdgeArg::Right => Edge::Right, EdgeArg::Top => Edge::Top, EdgeArg::Bottom => Edge::Bottom }, }, Sub::CloseWorkspace { workspace_id } => Cmd::CloseWorkspace { workspace_id: WorkspaceId(workspace_id) }, Sub::Group { action } => match action { GroupAction::Create { name, color } => Cmd::CreateGroup { name, color }, GroupAction::Set { group_id, name, color, order } => Cmd::SetGroup { group_id: GroupId(group_id), name, color, order }, GroupAction::Delete { group_id } => Cmd::DeleteGroup { group_id: GroupId(group_id) }, }, Sub::SetMeta { workspace_id, name, group, unread, order } => Cmd::SetWorkspaceMeta { workspace_id: WorkspaceId(workspace_id), name, // None = no change; Some("") = ungroup; Some(g) = set group. group_id: group.map(|g| if g.is_empty() { None } else { Some(GroupId(g)) }), unread, order, }, Sub::Shutdown => Cmd::Shutdown, Sub::Completions { .. } => unreachable!("completions handled before dispatch"), } } #[cfg(test)] mod tests { use super::*; use crate::cli::Cli; use clap::Parser; fn parse(argv: &[&str]) -> Sub { Cli::try_parse_from(argv).unwrap().cmd } #[test] fn notify_maps_to_set_state() { let cmd = to_cmd(parse(&["spacesh", "notify", "--surface", "s_1", "--state", "done"])); assert!(matches!(cmd, Cmd::SetState { state: SurfaceState::Done, .. })); } #[test] fn split_default_dir_is_right() { let cmd = to_cmd(parse(&["spacesh", "split", "s_1"])); match cmd { Cmd::SplitSurface { dir, .. } => assert_eq!(dir, SplitDir::Right), _ => panic!() } } #[test] fn set_ratios_parses_csv() { let cmd = to_cmd(parse(&["spacesh", "set-ratios", "w_1", "--path", "0,1", "--ratios", "0.3,0.7"])); match cmd { Cmd::SetRatios { node_path, ratios, .. } => { assert_eq!(node_path, vec![0,1]); assert_eq!(ratios, vec![0.3,0.7]); }, _ => panic!() } } #[test] fn apply_preset_shell_agent_is_empty_slot() { let cmd = to_cmd(parse(&["spacesh", "apply-preset", "w_1", "--preset", "2lr", "--agent", "shell", "--agent", "claude"])); match cmd { Cmd::ApplyPreset { slots, .. } => { assert!(slots[0].command.is_none()); assert_eq!(slots[1].command.as_deref(), Some("claude")); } _ => panic!(), } } #[test] fn set_meta_group_empty_means_ungroup() { let cmd = to_cmd(parse(&["spacesh", "set-meta", "w_1", "--group", ""])); match cmd { Cmd::SetWorkspaceMeta { group_id, .. } => assert_eq!(group_id, Some(None)), _ => panic!() } } } ``` - [ ] **Step 4: One-shot client** Create `crates/spacesh-cli/src/client.rs`: ```rust use std::path::PathBuf; use anyhow::{anyhow, Context, Result}; use serde_json::Value; use spacesh_proto::codec::{read_frame, write_frame}; use spacesh_proto::{Cmd, Envelope}; use tokio::net::UnixStream; pub fn socket_path() -> PathBuf { if let Ok(p) = std::env::var("SPACESH_SOCK") { if !p.is_empty() { return PathBuf::from(p); } } let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); PathBuf::from(home).join(".spacesh").join("sock") } /// Connect, lazy-starting the daemon if the socket is absent. async fn connect_or_start() -> Result { let sock = socket_path(); if let Ok(s) = UnixStream::connect(&sock).await { return Ok(s); } // Locate the daemon next to this binary and spawn it. let exe = std::env::current_exe().context("current_exe")?; let daemon = exe.with_file_name("spaceshd"); let _ = std::process::Command::new(daemon).spawn(); for _ in 0..100 { if let Ok(s) = UnixStream::connect(&sock).await { return Ok(s); } tokio::time::sleep(std::time::Duration::from_millis(30)).await; } Err(anyhow!("daemon unavailable")) } /// One-shot request/response. Skips any interleaved events; returns `data` on ok. pub async fn request(cmd: Cmd) -> Result { let mut stream = connect_or_start().await?; send_and_read(&mut stream, cmd).await } /// Best-effort status notify: connect only (no spawn); silently succeed if absent. pub async fn notify(cmd: Cmd) -> Result<()> { let sock = socket_path(); let Ok(mut stream) = UnixStream::connect(&sock).await else { return Ok(()); // no daemon — best-effort no-op }; let _ = send_and_read(&mut stream, cmd).await; Ok(()) } async fn send_and_read(stream: &mut UnixStream, cmd: Cmd) -> Result { write_frame(stream, &Envelope::Req { id: 1, cmd }).await.map_err(|e| anyhow!(e.to_string()))?; loop { match read_frame(stream).await.map_err(|e| anyhow!(e.to_string()))? { Some(Envelope::Res { id: 1, ok, data, error }) => { if ok { return Ok(data); } let (code, msg) = error.map(|e| (e.code, e.msg)).unwrap_or_else(|| ("ERROR".into(), "error".into())); return Err(anyhow!("{code}: {msg}")); } Some(_) => continue, // events / non-matching res None => return Err(anyhow!("connection closed")), } } } ``` - [ ] **Step 5: lib.rs (module roots) + thin main.rs** Create `crates/spacesh-cli/src/lib.rs`: ```rust pub mod cli; pub mod client; pub mod mapping; pub mod output; ``` Create `crates/spacesh-cli/src/main.rs`: ```rust use clap::Parser; use spacesh_cli::cli::Cli; use spacesh_cli::output; #[tokio::main(flavor = "multi_thread", worker_threads = 2)] async fn main() { let parsed = Cli::parse(); std::process::exit(output::run(parsed).await); } ``` (`output::run` is created in Task 4. To make Task 3 compile on its own, create a temporary `output.rs` with a stub `pub async fn run(_c: crate::cli::Cli) -> i32 { 0 }`, replaced fully in Task 4. The modules in `mapping.rs`/`client.rs`/`cli.rs`/`output.rs` reference each other via `crate::...` — correct under the lib root.) Temporary `crates/spacesh-cli/src/output.rs`: ```rust pub async fn run(_c: crate::cli::Cli) -> i32 { 0 } ``` - [ ] **Step 6: Run mapping tests** Run: `cargo test -p spacesh-cli mapping` Expected: PASS (5 tests). Also `cargo build -p spacesh-cli` succeeds. - [ ] **Step 7: Commit** ```bash git add Cargo.toml crates/spacesh-cli/ git commit -m "feat(cli): spacesh-cli scaffold — clap tree, one-shot client, command mapping" ``` --- ### Task 4: dispatch, output rendering, completions **Files:** - Modify: `crates/spacesh-cli/src/output.rs` (replace the stub) - [ ] **Step 1: Implement run + rendering** Replace `crates/spacesh-cli/src/output.rs`: ```rust use clap::CommandFactory; use serde_json::Value; use crate::cli::{Cli, Sub}; use crate::{client, mapping}; /// Entry point: returns the process exit code. pub async fn run(cli: Cli) -> i32 { // Completions are local — no daemon. if let Sub::Completions { shell } = cli.cmd { let mut cmd = Cli::command(); clap_complete::generate(shell, &mut cmd, "spacesh", &mut std::io::stdout()); return 0; } // notify is best-effort: never fails the caller. if let Sub::Notify { .. } = &cli.cmd { let _ = client::notify(mapping::to_cmd(cli.cmd)).await; return 0; } let is_status = matches!(cli.cmd, Sub::Status); let cmd = mapping::to_cmd(cli.cmd); match client::request(cmd).await { Ok(data) => { if cli.json { println!("{}", serde_json::to_string_pretty(&data).unwrap_or_else(|_| "null".into())); } else if is_status { print_status(&data); } else { print_human(&data); } 0 } Err(e) => { if cli.json { println!("{}", serde_json::json!({ "ok": false, "error": e.to_string() })); } else { eprintln!("{e}"); } 1 } } } /// Human render for non-status commands: surface the salient id, else "ok". fn print_human(data: &Value) { if let Some(id) = data.get("workspace_id").and_then(|v| v.as_str()) { println!("{id}"); } else if let Some(id) = data.get("surface_id").and_then(|v| v.as_str()) { println!("{id}"); } else if let Some(id) = data.get("group_id").and_then(|v| v.as_str()) { println!("{id}"); } else if let Some(ids) = data.get("surface_ids").and_then(|v| v.as_array()) { for id in ids { if let Some(s) = id.as_str() { println!("{s}"); } } } else { println!("ok"); } } /// Compact table for `status`. fn print_status(data: &Value) { let empty = vec![]; let workspaces = data.get("workspaces").and_then(|v| v.as_array()).unwrap_or(&empty); if workspaces.is_empty() { println!("(no workspaces)"); return; } for w in workspaces { let name = w.get("name").and_then(|v| v.as_str()).unwrap_or("?"); let id = w.get("id").and_then(|v| v.as_str()).unwrap_or("?"); let unread = w.get("unread").and_then(|v| v.as_bool()).unwrap_or(false); println!("{} ({}){}", name, id, if unread { " *" } else { "" }); if let Some(surfaces) = w.get("surfaces").and_then(|v| v.as_object()) { for (sid, sv) in surfaces { let running = sv.get("running").and_then(|v| v.as_bool()).unwrap_or(false); let state = sv.get("state").and_then(|v| v.as_str()).unwrap_or("idle"); let agent = sv.get("spec").and_then(|s| s.get("agent_label")).and_then(|v| v.as_str()).unwrap_or("shell"); let life = if running { "running" } else { "stopped" }; println!(" {sid} {agent:<8} {life:<8} {state}"); } } } } ``` - [ ] **Step 2: Build + manual smoke (optional)** Run: `cargo build -p spacesh-cli` Expected: PASS. Manual: `cargo run -p spacesh-cli -- completions zsh | head` prints a completion script (no daemon needed). - [ ] **Step 3: Commit** ```bash git add crates/spacesh-cli/src/output.rs git commit -m "feat(cli): command dispatch, human/--json rendering, status table, completions" ``` --- ### Task 5: CLI integration tests against a mock daemon **Files:** - Create: `crates/spacesh-cli/tests/integration.rs` Because `spacesh-cli` exposes a `spacesh_cli` lib (Task 3), these tests call the REAL `client::request` / `client::notify` against a tiny in-test UDS server that speaks the framing protocol, isolated via a unique `SPACESH_SOCK` per test (so they run safely in parallel — no serial guard needed). Cross-crate spawning of the real `spaceshd` binary is intentionally avoided; the daemon's own integration tests + the manual check cover end-to-end. - [ ] **Step 1: Write the integration tests** Create `crates/spacesh-cli/tests/integration.rs`: ```rust use std::path::PathBuf; use spacesh_proto::codec::{read_frame, write_frame}; use spacesh_proto::{Cmd, Envelope, SurfaceId}; use spacesh_proto::status::SurfaceState; use spacesh_cli::client; use tokio::net::UnixListener; // These tests mutate the process-global SPACESH_SOCK env var, so they must not // run concurrently. Serialize them on a process-wide lock (poison-tolerant). static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); fn serial() -> std::sync::MutexGuard<'static, ()> { SERIAL.lock().unwrap_or_else(|e| e.into_inner()) } fn tmp_sock(name: &str) -> PathBuf { let mut p = std::env::temp_dir(); let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); p.push(format!("spacesh-cli-{name}-{n}.sock")); p } /// One-shot mock daemon: accept one connection, read one request, send `reply`. fn mock_daemon(sock: PathBuf, reply: Envelope) { let listener = UnixListener::bind(&sock).unwrap(); tokio::spawn(async move { if let Ok((mut stream, _)) = listener.accept().await { if let Ok(Some(_req)) = read_frame(&mut stream).await { let _ = write_frame(&mut stream, &reply).await; } } }); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn request_returns_data_from_daemon() { let _g = serial(); let sock = tmp_sock("req"); std::env::set_var("SPACESH_SOCK", &sock); mock_daemon(sock.clone(), Envelope::Res { id: 1, ok: true, data: serde_json::json!({ "workspace_id": "w_1" }), error: None, }); tokio::time::sleep(std::time::Duration::from_millis(50)).await; // let the listener bind let data = client::request(Cmd::Open { path: "/tmp".into() }).await.unwrap(); std::env::remove_var("SPACESH_SOCK"); let _ = std::fs::remove_file(&sock); assert_eq!(data["workspace_id"], "w_1"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn request_surfaces_daemon_error() { let _g = serial(); let sock = tmp_sock("err"); std::env::set_var("SPACESH_SOCK", &sock); mock_daemon(sock.clone(), Envelope::Res { id: 1, ok: false, data: serde_json::Value::Null, error: Some(spacesh_proto::ErrorBody { code: "NOT_FOUND".into(), msg: "surface".into() }), }); tokio::time::sleep(std::time::Duration::from_millis(50)).await; let res = client::request(Cmd::Close { surface_id: SurfaceId("s_x".into()) }).await; std::env::remove_var("SPACESH_SOCK"); let _ = std::fs::remove_file(&sock); assert!(res.is_err()); assert!(res.unwrap_err().to_string().contains("NOT_FOUND")); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn notify_with_no_daemon_is_silent_success() { let _g = serial(); let sock = tmp_sock("nodaemon"); // never bound std::env::set_var("SPACESH_SOCK", &sock); let r = client::notify(Cmd::SetState { surface_id: SurfaceId("s_1".into()), state: SurfaceState::Done }).await; std::env::remove_var("SPACESH_SOCK"); assert!(r.is_ok(), "notify must be a silent success when no daemon is listening"); } ``` Note: `mapping`/`output` are unit-tested in Task 3 (mapping) and Task 4; `client` is now exercised for real here. - [ ] **Step 2: Run tests (3×) for non-flakiness** Run: `cargo test --workspace > /tmp/m4b.log 2>&1; echo EXIT=$?` — three times, all 0. Expected: all crates green including the CLI tests. - [ ] **Step 3: Manual end-to-end (real daemon + CLI)** Run: ```bash cargo build SOCK=$(mktemp -u /tmp/spacesh-e2e-XXXX.sock) SPACESH_SOCK=$SOCK ./target/debug/spaceshd & sleep 1 SPACESH_SOCK=$SOCK ./target/debug/spacesh open /tmp SPACESH_SOCK=$SOCK ./target/debug/spacesh status # grab a workspace id from status, create a surface, set state, observe in status: # SPACESH_SOCK=$SOCK ./target/debug/spacesh new-surface # SPACESH_SOCK=$SOCK ./target/debug/spacesh notify --surface --state work # SPACESH_SOCK=$SOCK ./target/debug/spacesh status # shows state=work SPACESH_SOCK=$SOCK ./target/debug/spacesh shutdown rm -f $SOCK ``` Expected: open prints a workspace id, status lists it, notify flips the surface state shown by a subsequent status. - [ ] **Step 4: Commit** ```bash git add crates/spacesh-cli/tests/integration.rs git commit -m "test(cli): wire-level integration tests via SPACESH_SOCK mock daemon" ``` --- ## Definition of Done - [ ] `cargo test --workspace` — green & non-flaky across 3 consecutive runs. - [ ] `cargo build` clean (CLI binary `spacesh` builds). - [ ] **Manual** (Task 5 Step 3): real `spaceshd` + `spacesh` over `SPACESH_SOCK` — `open`/`status`/`new-surface`/`notify` work; `notify` with no daemon exits 0; `completions zsh` prints a script. ## Notes for the implementer - **Tasks 1–2 compile together.** Adding `SurfaceView.state` and `Cmd::SetState` breaks the daemon until Task 2 updates `to_view` and the `match`. Verify proto alone after Task 1 (`cargo test -p spacesh-proto`); run the workspace suite after Task 2. - **`Edge`/`SplitDir`/`PresetSlot`/`GroupId` live in `spacesh_proto::message` / `::ids`** (added in M2) — import paths in `mapping.rs` are exact; if a re-export is missing, use the full module path. - **Status is ephemeral:** never add `state` to `PersistState`/`Workspace`. It lives only in the registry's in-memory `states` map and is dropped on `exit`. - **notify is best-effort:** no lazy-start, swallow connect errors, always exit 0. Do not "improve" it to surface errors — a failing hook must not break the agent. - **Out of slice:** status detection sources (Claude Code hook adapter, OSC 133, fallback patterns) and the status UI (rings/badges/Event Center, native notifications) are **M3**, built on this primitive and `spacesh notify`. ```