diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-29 19:29:23 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-29 19:29:23 +0300 |
| commit | a5847a8039d2754dd2908a13e4625b31f7404d9a (patch) | |
| tree | 83c8b4c811435a8108e382db59a1cc5c726daf9b /src | |
| parent | 8b7848aac33ec67dee09e8d0b35fd70384aed1bb (diff) | |
feat(cli): add workspace and pane management commands
Diffstat (limited to 'src')
| -rw-r--r-- | src/cli.rs | 762 | ||||
| -rw-r--r-- | src/main.rs | 14 |
2 files changed, 775 insertions, 1 deletions
diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 00000000..724a8f57 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,762 @@ +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::time::Duration; + +use serde::Serialize; + +use crate::api; +use crate::api::schema::{ + EmptyParams, Method, OutputMatch, PaneAgentState, PaneListParams, PaneReadParams, + PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneTarget, PaneWaitForOutputParams, + ReadSource, Request, SplitDirection, Subscription, WorkspaceCreateParams, + WorkspaceRenameParams, WorkspaceTarget, +}; + +pub enum CommandOutcome { + Handled(i32), + NotCli, +} + +pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> { + let Some(command) = args.get(1).map(|arg| arg.as_str()) else { + return Ok(CommandOutcome::NotCli); + }; + + let exit_code = match command { + "workspace" => run_workspace_command(&args[2..])?, + "pane" => run_pane_command(&args[2..])?, + "wait" => run_wait_command(&args[2..])?, + _ => return Ok(CommandOutcome::NotCli), + }; + + Ok(CommandOutcome::Handled(exit_code)) +} + +fn run_workspace_command(args: &[String]) -> std::io::Result<i32> { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + print_workspace_help(); + return Ok(2); + }; + + match subcommand { + "list" => workspace_list(&args[1..]), + "create" => workspace_create(&args[1..]), + "get" => workspace_get(&args[1..]), + "focus" => workspace_focus(&args[1..]), + "rename" => workspace_rename(&args[1..]), + "close" => workspace_close(&args[1..]), + _ => { + print_workspace_help(); + Ok(2) + } + } +} + +fn run_pane_command(args: &[String]) -> std::io::Result<i32> { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + print_pane_help(); + return Ok(2); + }; + + match subcommand { + "list" => pane_list(&args[1..]), + "get" => pane_get(&args[1..]), + "read" => pane_read(&args[1..]), + "split" => pane_split(&args[1..]), + "close" => pane_close(&args[1..]), + "send-text" => pane_send_text(&args[1..]), + "send-keys" => pane_send_keys(&args[1..]), + "run" => pane_run(&args[1..]), + _ => { + print_pane_help(); + Ok(2) + } + } +} + +fn run_wait_command(args: &[String]) -> std::io::Result<i32> { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + print_wait_help(); + return Ok(2); + }; + + match subcommand { + "output" => wait_output(&args[1..]), + "agent-state" => wait_agent_state(&args[1..]), + _ => { + print_wait_help(); + Ok(2) + } + } +} + +fn workspace_list(args: &[String]) -> std::io::Result<i32> { + if !args.is_empty() { + eprintln!("usage: herdr workspace list"); + return Ok(2); + } + + print_response(&send_request(&Request { + id: "cli:workspace:list".into(), + method: Method::WorkspaceList(EmptyParams::default()), + })?) +} + +fn workspace_create(args: &[String]) -> std::io::Result<i32> { + let mut cwd = None; + let mut focus = true; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--cwd" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --cwd"); + return Ok(2); + }; + cwd = Some(value.clone()); + index += 2; + } + "--no-focus" => { + focus = false; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + print_response(&send_request(&Request { + id: "cli:workspace:create".into(), + method: Method::WorkspaceCreate(WorkspaceCreateParams { cwd, focus }), + })?) +} + +fn workspace_get(args: &[String]) -> std::io::Result<i32> { + let Some(raw_workspace_id) = args.first() else { + eprintln!("usage: herdr workspace get <workspace_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr workspace get <workspace_id>"); + return Ok(2); + } + + print_response(&send_request(&Request { + id: "cli:workspace:get".into(), + method: Method::WorkspaceGet(WorkspaceTarget { + workspace_id: normalize_workspace_id(raw_workspace_id), + }), + })?) +} + +fn workspace_focus(args: &[String]) -> std::io::Result<i32> { + let Some(raw_workspace_id) = args.first() else { + eprintln!("usage: herdr workspace focus <workspace_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr workspace focus <workspace_id>"); + return Ok(2); + } + + print_response(&send_request(&Request { + id: "cli:workspace:focus".into(), + method: Method::WorkspaceFocus(WorkspaceTarget { + workspace_id: normalize_workspace_id(raw_workspace_id), + }), + })?) +} + +fn workspace_rename(args: &[String]) -> std::io::Result<i32> { + if args.len() < 2 { + eprintln!("usage: herdr workspace rename <workspace_id> <label>"); + return Ok(2); + } + + print_response(&send_request(&Request { + id: "cli:workspace:rename".into(), + method: Method::WorkspaceRename(WorkspaceRenameParams { + workspace_id: normalize_workspace_id(&args[0]), + label: args[1..].join(" "), + }), + })?) +} + +fn workspace_close(args: &[String]) -> std::io::Result<i32> { + let Some(raw_workspace_id) = args.first() else { + eprintln!("usage: herdr workspace close <workspace_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr workspace close <workspace_id>"); + return Ok(2); + } + + print_response(&send_request(&Request { + id: "cli:workspace:close".into(), + method: Method::WorkspaceClose(WorkspaceTarget { + workspace_id: normalize_workspace_id(raw_workspace_id), + }), + })?) +} + +fn pane_list(args: &[String]) -> std::io::Result<i32> { + let mut workspace_id = None; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --workspace"); + return Ok(2); + }; + workspace_id = Some(normalize_workspace_id(value)); + index += 2; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + print_response(&send_request(&Request { + id: "cli:pane:list".into(), + method: Method::PaneList(PaneListParams { workspace_id }), + })?) +} + +fn pane_get(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr pane get <pane_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr pane get <pane_id>"); + return Ok(2); + } + + print_response(&send_request(&Request { + id: "cli:pane:get".into(), + method: Method::PaneGet(PaneTarget { + pane_id: normalize_pane_id(raw_pane_id), + }), + })?) +} + +fn pane_read(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr pane read <pane_id> [--source visible|recent] [--lines N]"); + return Ok(2); + }; + + let pane_id = normalize_pane_id(raw_pane_id); + let mut source = ReadSource::Recent; + let mut lines = None; + let mut strip_ansi = true; + + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--source" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --source"); + return Ok(2); + }; + source = parse_read_source(value)?; + index += 2; + } + "--lines" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --lines"); + return Ok(2); + }; + lines = Some(parse_u32_flag("--lines", value)?); + index += 2; + } + "--raw" => { + strip_ansi = false; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + let response = send_request(&Request { + id: "cli:pane:read".into(), + method: Method::PaneRead(PaneReadParams { + pane_id, + source, + lines, + strip_ansi, + }), + })?; + + if let Some(error) = response.get("error") { + eprintln!("{}", serde_json::to_string(error).unwrap()); + return Ok(1); + } + + if let Some(text) = response["result"]["read"]["text"].as_str() { + print!("{text}"); + } + Ok(0) +} + +fn pane_split(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!( + "usage: herdr pane split <pane_id> --direction right|down [--cwd PATH] [--no-focus]" + ); + return Ok(2); + }; + + let pane_id = normalize_pane_id(raw_pane_id); + let mut direction = None; + let mut cwd = None; + let mut focus = true; + + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--direction" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --direction"); + return Ok(2); + }; + direction = Some(parse_split_direction(value)?); + index += 2; + } + "--cwd" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --cwd"); + return Ok(2); + }; + cwd = Some(value.clone()); + index += 2; + } + "--no-focus" => { + focus = false; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + let Some(direction) = direction else { + eprintln!("missing required --direction"); + return Ok(2); + }; + + print_response(&send_request(&Request { + id: "cli:pane:split".into(), + method: Method::PaneSplit(PaneSplitParams { + workspace_id: None, + target_pane_id: pane_id, + direction, + cwd, + focus, + }), + })?) +} + +fn pane_close(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr pane close <pane_id>"); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr pane close <pane_id>"); + return Ok(2); + } + + print_response(&send_request(&Request { + id: "cli:pane:close".into(), + method: Method::PaneClose(PaneTarget { + pane_id: normalize_pane_id(raw_pane_id), + }), + })?) +} + +fn pane_send_text(args: &[String]) -> std::io::Result<i32> { + if args.len() < 2 { + eprintln!("usage: herdr pane send-text <pane_id> <text>"); + return Ok(2); + } + + let pane_id = normalize_pane_id(&args[0]); + let text = args[1..].join(" "); + send_ok_request(Method::PaneSendText(PaneSendTextParams { pane_id, text })) +} + +fn pane_send_keys(args: &[String]) -> std::io::Result<i32> { + if args.len() < 2 { + eprintln!("usage: herdr pane send-keys <pane_id> <key> [key ...]"); + return Ok(2); + } + + let pane_id = normalize_pane_id(&args[0]); + let keys = args[1..].to_vec(); + send_ok_request(Method::PaneSendKeys(PaneSendKeysParams { pane_id, keys })) +} + +fn pane_run(args: &[String]) -> std::io::Result<i32> { + if args.len() < 2 { + eprintln!("usage: herdr pane run <pane_id> <command>"); + return Ok(2); + } + + let pane_id = normalize_pane_id(&args[0]); + let text = args[1..].join(" "); + let send_text_status = send_ok_request(Method::PaneSendText(PaneSendTextParams { + pane_id: pane_id.clone(), + text, + }))?; + if send_text_status != 0 { + return Ok(send_text_status); + } + + send_ok_request(Method::PaneSendKeys(PaneSendKeysParams { + pane_id, + keys: vec!["Enter".into()], + })) +} + +fn wait_output(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr wait output <pane_id> --match <text> [--source visible|recent] [--lines N] [--timeout MS] [--regex]"); + return Ok(2); + }; + + let pane_id = normalize_pane_id(raw_pane_id); + let mut source = ReadSource::Recent; + let mut lines = None; + let mut timeout_ms = None; + let mut strip_ansi = true; + let mut regex = false; + let mut match_value = None; + + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--match" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --match"); + return Ok(2); + }; + match_value = Some(value.clone()); + index += 2; + } + "--source" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --source"); + return Ok(2); + }; + source = parse_read_source(value)?; + index += 2; + } + "--lines" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --lines"); + return Ok(2); + }; + lines = Some(parse_u32_flag("--lines", value)?); + index += 2; + } + "--timeout" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --timeout"); + return Ok(2); + }; + timeout_ms = Some(parse_u64_flag("--timeout", value)?); + index += 2; + } + "--regex" => { + regex = true; + index += 1; + } + "--raw" => { + strip_ansi = false; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + let Some(match_value) = match_value else { + eprintln!("missing required --match"); + return Ok(2); + }; + + let matcher = if regex { + OutputMatch::Regex { value: match_value } + } else { + OutputMatch::Substring { value: match_value } + }; + + let response = send_request(&Request { + id: "cli:wait:output".into(), + method: Method::PaneWaitForOutput(PaneWaitForOutputParams { + pane_id, + source, + lines, + r#match: matcher, + timeout_ms, + strip_ansi, + }), + })?; + + if response.get("error").is_some() { + eprintln!("{}", serde_json::to_string(&response).unwrap()); + return Ok(1); + } + + println!("{}", serde_json::to_string(&response).unwrap()); + Ok(0) +} + +fn wait_agent_state(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr wait agent-state <pane_id> --state <idle|busy|waiting|unknown> [--timeout MS]"); + return Ok(2); + }; + + let pane_id = normalize_pane_id(raw_pane_id); + let mut timeout_ms = None; + let mut desired_state = None; + + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--state" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --state"); + return Ok(2); + }; + desired_state = Some(parse_agent_state(value)?); + index += 2; + } + "--timeout" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --timeout"); + return Ok(2); + }; + timeout_ms = Some(parse_u64_flag("--timeout", value)?); + index += 2; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + let Some(state) = desired_state else { + eprintln!("missing required --state"); + return Ok(2); + }; + + let request = Request { + id: "cli:wait:agent-state".into(), + method: Method::EventsSubscribe(crate::api::schema::EventsSubscribeParams { + subscriptions: vec![Subscription::PaneAgentStateChanged { + pane_id, + state: Some(state), + }], + }), + }; + + let mut stream = UnixStream::connect(api::socket_path())?; + stream.write_all(serde_json::to_string(&request)?.as_bytes())?; + stream.write_all(b"\n")?; + stream.flush()?; + + if let Some(timeout_ms) = timeout_ms { + stream.set_read_timeout(Some(Duration::from_millis(timeout_ms)))?; + } + + let mut reader = BufReader::new(stream); + let mut ack = String::new(); + reader.read_line(&mut ack)?; + if ack.trim().is_empty() { + eprintln!("empty subscription ack"); + return Ok(1); + } + let ack_value: serde_json::Value = serde_json::from_str(&ack)?; + if ack_value.get("error").is_some() { + eprintln!("{}", serde_json::to_string(&ack_value).unwrap()); + return Ok(1); + } + + let mut event = String::new(); + match reader.read_line(&mut event) { + Ok(0) => { + eprintln!("subscription closed before event arrived"); + Ok(1) + } + Ok(_) => { + let event_value: serde_json::Value = serde_json::from_str(&event)?; + println!("{}", serde_json::to_string(&event_value).unwrap()); + Ok(0) + } + Err(err) + if matches!( + err.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ) => + { + eprintln!("timed out waiting for agent state change"); + Ok(1) + } + Err(err) => Err(err), + } +} + +fn print_response(response: &serde_json::Value) -> std::io::Result<i32> { + if response.get("error").is_some() { + eprintln!("{}", serde_json::to_string(response).unwrap()); + return Ok(1); + } + + println!("{}", serde_json::to_string(response).unwrap()); + Ok(0) +} + +fn send_ok_request(method: Method) -> std::io::Result<i32> { + let response = send_request(&Request { + id: "cli:request".into(), + method, + })?; + + if response.get("error").is_some() { + eprintln!("{}", serde_json::to_string(&response).unwrap()); + return Ok(1); + } + + Ok(0) +} + +fn send_request(request: &Request) -> std::io::Result<serde_json::Value> { + let mut stream = UnixStream::connect(api::socket_path())?; + stream.write_all(serde_json::to_string(request)?.as_bytes())?; + stream.write_all(b"\n")?; + stream.flush()?; + + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line)?; + serde_json::from_str(&line).map_err(std::io::Error::other) +} + +fn normalize_workspace_id(value: &str) -> String { + if value.starts_with("w_") { + return value.to_string(); + } + if let Ok(number) = value.parse::<usize>() { + return format!("w_{number}"); + } + value.to_string() +} + +fn normalize_pane_id(value: &str) -> String { + if value.starts_with("p_") { + return value.to_string(); + } + if let Some((workspace, pane)) = value.split_once('-') { + if workspace.parse::<usize>().is_ok() && pane.parse::<u32>().is_ok() { + return format!("p_{workspace}_{pane}"); + } + } + value.to_string() +} + +fn parse_split_direction(value: &str) -> std::io::Result<SplitDirection> { + match value { + "right" => Ok(SplitDirection::Right), + "down" => Ok(SplitDirection::Down), + _ => Err(std::io::Error::other(format!( + "invalid split direction: {value}" + ))), + } +} + +fn parse_read_source(value: &str) -> std::io::Result<ReadSource> { + match value { + "visible" => Ok(ReadSource::Visible), + "recent" => Ok(ReadSource::Recent), + _ => Err(std::io::Error::other(format!( + "invalid read source: {value}" + ))), + } +} + +fn parse_agent_state(value: &str) -> std::io::Result<PaneAgentState> { + match value { + "idle" => Ok(PaneAgentState::Idle), + "busy" => Ok(PaneAgentState::Busy), + "waiting" => Ok(PaneAgentState::Waiting), + "unknown" => Ok(PaneAgentState::Unknown), + _ => Err(std::io::Error::other(format!( + "invalid agent state: {value}" + ))), + } +} + +fn parse_u32_flag(flag: &str, value: &str) -> std::io::Result<u32> { + value + .parse::<u32>() + .map_err(|_| std::io::Error::other(format!("invalid value for {flag}: {value}"))) +} + +fn parse_u64_flag(flag: &str, value: &str) -> std::io::Result<u64> { + value + .parse::<u64>() + .map_err(|_| std::io::Error::other(format!("invalid value for {flag}: {value}"))) +} + +fn print_workspace_help() { + eprintln!("herdr workspace commands:"); + eprintln!(" herdr workspace list"); + eprintln!(" herdr workspace create [--cwd PATH] [--no-focus]"); + eprintln!(" herdr workspace get <workspace_id>"); + eprintln!(" herdr workspace focus <workspace_id>"); + eprintln!(" herdr workspace rename <workspace_id> <label>"); + eprintln!(" herdr workspace close <workspace_id>"); +} + +fn print_pane_help() { + eprintln!("herdr pane commands:"); + eprintln!(" herdr pane list [--workspace <workspace_id>]"); + eprintln!(" herdr pane get <pane_id>"); + eprintln!(" herdr pane read <pane_id> [--source visible|recent] [--lines N]"); + eprintln!(" herdr pane split <pane_id> --direction right|down [--cwd PATH] [--no-focus]"); + eprintln!(" herdr pane close <pane_id>"); + eprintln!(" herdr pane send-text <pane_id> <text>"); + eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]"); + eprintln!(" herdr pane run <pane_id> <command>"); +} + +fn print_wait_help() { + eprintln!("herdr wait commands:"); + eprintln!(" herdr wait output <pane_id> --match <text> [--source visible|recent] [--lines N] [--timeout MS] [--regex]"); + eprintln!( + " herdr wait agent-state <pane_id> --state <idle|busy|waiting|unknown> [--timeout MS]" + ); +} + +fn _print_json<T: Serialize>(value: &T) { + println!("{}", serde_json::to_string(value).unwrap()); +} diff --git a/src/main.rs b/src/main.rs index 8b31cdc7..2341e09b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ const NESTED_HERDR_MESSAGES: [&str; 6] = [ mod api; mod app; +mod cli; mod config; mod detect; mod events; @@ -149,6 +150,10 @@ fn random_nested_message() -> &'static str { fn main() -> io::Result<()> { let args: Vec<String> = std::env::args().collect(); + if let cli::CommandOutcome::Handled(code) = cli::maybe_run(&args)? { + std::process::exit(code); + } + // Subcommands and flags (no TUI, no logging needed) if args.get(1).map(|s| s.as_str()) == Some("update") { match update::self_update() { @@ -165,9 +170,15 @@ fn main() -> io::Result<()> { println!(); println!("Usage: herdr [options]"); println!(" herdr update"); + println!(" herdr workspace <subcommand> ..."); + println!(" herdr pane <subcommand> ..."); + println!(" herdr wait <subcommand> ..."); println!(); println!("Commands:"); println!(" update Download and install the latest version"); + println!(" workspace workspace helpers over the socket api"); + println!(" pane pane control helpers over the socket api"); + println!(" wait blocking wait helpers over the socket api"); println!(); println!("Options:"); println!(" --no-session Don't restore or save sessions"); @@ -206,7 +217,8 @@ fn main() -> io::Result<()> { eprintln!("run 'herdr --help' for usage"); std::process::exit(1); } - if !arg.starts_with('-') && arg != "update" { + if !arg.starts_with('-') && !["update", "workspace", "pane", "wait"].contains(&arg.as_str()) + { eprintln!("unknown command: {arg}"); eprintln!("run 'herdr --help' for usage"); std::process::exit(1); |
