Files
spaceshell/DOCS/superpowers/plans/2026-06-09-spacesh-m4.md
T
2026-06-09 22:06:34 +07:00

997 lines
37 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.
# 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 M0M2 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) <noreply@anthropic.com>`. 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<SurfaceId, SurfaceState>,
```
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<PathBuf> {
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<dyn crate::state_store::StateStore> =
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<String>,
#[arg(long = "arg")] args: Vec<String>,
#[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<String>,
#[arg(long = "arg")] args: Vec<String>,
},
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<String>,
},
SetRatios {
workspace_id: String,
#[arg(long, value_delimiter = ',')] path: Vec<u32>,
#[arg(long, value_delimiter = ',')] ratios: Vec<f32>,
},
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<String>,
#[arg(long)] group: Option<String>,
#[arg(long)] unread: Option<bool>,
#[arg(long)] order: Option<u32>,
},
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<String>, #[arg(long)] color: Option<String>, #[arg(long)] order: Option<u32> },
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<UnixStream> {
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<Value> {
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<Value> {
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 <ws>
# SPACESH_SOCK=$SOCK ./target/debug/spacesh notify --surface <sid> --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 12 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`.
```