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:
@@ -5,6 +5,7 @@ members = [
|
||||
"crates/spacesh-pty",
|
||||
"crates/spacesh-core",
|
||||
"crates/spaceshd",
|
||||
"crates/spacesh-cli",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -25,3 +26,5 @@ portable-pty = "0.8"
|
||||
alacritty_terminal = "0.25"
|
||||
fs2 = "0.4"
|
||||
dirs = "5"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
clap_complete = "4"
|
||||
|
||||
@@ -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
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod cli;
|
||||
pub mod client;
|
||||
pub mod mapping;
|
||||
pub mod output;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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!() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub async fn run(_c: crate::cli::Cli) -> i32 { 0 }
|
||||
Reference in New Issue
Block a user