diff --git a/Cargo.toml b/Cargo.toml index 0a5cfcf..849d892 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/spacesh-cli/Cargo.toml b/crates/spacesh-cli/Cargo.toml new file mode 100644 index 0000000..74cf22e --- /dev/null +++ b/crates/spacesh-cli/Cargo.toml @@ -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 diff --git a/crates/spacesh-cli/src/cli.rs b/crates/spacesh-cli/src/cli.rs new file mode 100644 index 0000000..132c582 --- /dev/null +++ b/crates/spacesh-cli/src/cli.rs @@ -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, + #[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 }, +} diff --git a/crates/spacesh-cli/src/client.rs b/crates/spacesh-cli/src/client.rs new file mode 100644 index 0000000..f01aefe --- /dev/null +++ b/crates/spacesh-cli/src/client.rs @@ -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 { + 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")), + } + } +} diff --git a/crates/spacesh-cli/src/lib.rs b/crates/spacesh-cli/src/lib.rs new file mode 100644 index 0000000..e81b7fe --- /dev/null +++ b/crates/spacesh-cli/src/lib.rs @@ -0,0 +1,4 @@ +pub mod cli; +pub mod client; +pub mod mapping; +pub mod output; diff --git a/crates/spacesh-cli/src/main.rs b/crates/spacesh-cli/src/main.rs new file mode 100644 index 0000000..5036b31 --- /dev/null +++ b/crates/spacesh-cli/src/main.rs @@ -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); +} diff --git a/crates/spacesh-cli/src/mapping.rs b/crates/spacesh-cli/src/mapping.rs new file mode 100644 index 0000000..9c964d7 --- /dev/null +++ b/crates/spacesh-cli/src/mapping.rs @@ -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!() } + } +} diff --git a/crates/spacesh-cli/src/output.rs b/crates/spacesh-cli/src/output.rs new file mode 100644 index 0000000..10954b2 --- /dev/null +++ b/crates/spacesh-cli/src/output.rs @@ -0,0 +1 @@ +pub async fn run(_c: crate::cli::Cli) -> i32 { 0 }