feat(cli): spacesh-cli scaffold — clap tree, one-shot client, command mapping

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 22:17:15 +07:00
parent 635f9f4356
commit a9fa1bf77b
8 changed files with 301 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
[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
+81
View File
@@ -0,0 +1,81 @@
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 },
}
+68
View File
@@ -0,0 +1,68 @@
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")),
}
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod cli;
pub mod client;
pub mod mapping;
pub mod output;
+9
View File
@@ -0,0 +1,9 @@
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);
}
+115
View File
@@ -0,0 +1,115 @@
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!() }
}
}
+1
View File
@@ -0,0 +1 @@
pub async fn run(_c: crate::cli::Cli) -> i32 { 0 }