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-pty",
|
||||||
"crates/spacesh-core",
|
"crates/spacesh-core",
|
||||||
"crates/spaceshd",
|
"crates/spaceshd",
|
||||||
|
"crates/spacesh-cli",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -25,3 +26,5 @@ portable-pty = "0.8"
|
|||||||
alacritty_terminal = "0.25"
|
alacritty_terminal = "0.25"
|
||||||
fs2 = "0.4"
|
fs2 = "0.4"
|
||||||
dirs = "5"
|
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