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 | |
| parent | 8b7848aac33ec67dee09e8d0b35fd70384aed1bb (diff) | |
feat(cli): add workspace and pane management commands
| -rw-r--r-- | README.md | 42 | ||||
| -rw-r--r-- | SKILL.md | 232 | ||||
| -rw-r--r-- | SOCKET_API.md | 22 | ||||
| -rw-r--r-- | src/cli.rs | 762 | ||||
| -rw-r--r-- | src/main.rs | 14 | ||||
| -rw-r--r-- | tests/api_ping.rs | 1 | ||||
| -rw-r--r-- | tests/cli_wrapper.rs | 327 |
7 files changed, 1382 insertions, 18 deletions
@@ -192,7 +192,7 @@ see [`SOCKET_API.md`](./SOCKET_API.md) for request shapes, examples, and subscri - **notification hooks**: richer agent/script-side state reporting on top of the socket foundation, so unsupported tools can report status directly to herdr. - **in-app preferences**: rerun onboarding and adjust things like sound and toast notifications without editing config by hand. - **native notifications**: OS-level notifications when an agent needs attention and herdr isn't in focus. -- **agent cli wrapper**: `herdr pane ...`, `herdr wait ...`, and similar shell-friendly commands layered on top of the socket API. +- **expanded cli wrapper**: more shell-friendly commands on top of the socket API, especially broader workspace/pane management and streaming event wrappers. ## built with agents @@ -204,21 +204,37 @@ there will be rough edges. if you hit one, [open an issue](https://github.com/og ## cli -current built-in commands: - -``` -herdr launch herdr -herdr update download and install the latest version -herdr --version print version -herdr --default-config print default configuration -herdr --no-session start without restoring or saving sessions -herdr --help show help +built-in commands: + +```text +herdr launch herdr +herdr update download and install the latest version +herdr workspace list list workspaces +herdr workspace create ... create a workspace +herdr workspace get <workspace> inspect one workspace +herdr workspace focus <workspace> focus a workspace +herdr workspace rename <workspace> ... rename a workspace +herdr workspace close <workspace> close a workspace +herdr pane list ... list panes +herdr pane get <pane> inspect one pane +herdr pane read <pane> ... read pane output +herdr pane split <pane> ... split a pane +herdr pane close <pane> close a pane +herdr pane send-text <pane> <text> send text without submitting +herdr pane send-keys <pane> <keys...> send keypresses like Enter +herdr pane run <pane> <command> send text and press Enter +herdr wait output <pane> ... block until output matches +herdr wait agent-state <pane> ... block until pane reaches a state +herdr --version print version +herdr --default-config print default configuration +herdr --no-session start without restoring or saving sessions +herdr --help show help ``` -programmatic control today lives in the socket API: -- [`SOCKET_API.md`](./SOCKET_API.md) +pane ids can be passed either as socket ids like `p_1_1` or in a shorter human form like `1-1`. -shell-friendly wrapper commands like `herdr pane ...` and `herdr wait ...` are planned on top of that API. +these commands are thin wrappers over the socket API: +- [`SOCKET_API.md`](./SOCKET_API.md) ## building from source diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 00000000..428efa8c --- /dev/null +++ b/SKILL.md @@ -0,0 +1,232 @@ +# herdr — agent skill + +you are running inside herdr, a terminal workspace manager. herdr gives you workspaces (tabs) and panes (splits) — each pane is a full terminal with its own shell or agent. you can control all of it from the CLI. + +this means you can: +- see what other agents/panes are doing +- split panes and run commands in them +- start servers, watch logs, run tests — in separate panes +- wait for specific output before continuing +- wait for another agent to finish +- spawn new agent instances + +the `herdr` binary is available in your PATH. all commands talk to the running herdr instance over a local unix socket. + +## concepts + +**workspaces** are like tabs. each workspace has one or more panes. you switch between workspaces in the sidebar. + +**panes** are terminal splits inside a workspace. each pane runs its own process — a shell, an agent, a server, anything. + +**agent state** is detected automatically by herdr. each pane can be: +- `idle` — agent finished, user has seen it +- `busy` — agent is working +- `waiting` — agent needs user input +- `unknown` — no recognized agent, or just a shell + +**ids** — workspace ids look like `w_1`, `w_2`. pane ids look like `p_1_1`, `p_1_5` (workspace number + pane number). you can also use short human form: `1` for workspace, `1-1` for pane. + +## discover yourself + +find out which pane you're in and what else is running: + +```bash +herdr pane list +``` + +```json +{"id":"cli:pane:list","result":{"panes":[ + {"agent":"claude","agent_state":"waiting","cwd":"/home/user/project","focused":true,"pane_id":"p_1_5","workspace_id":"w_1"}, + {"agent":"pi","agent_state":"idle","cwd":"/home/user/project","focused":false,"pane_id":"p_1_1","workspace_id":"w_1"} +],"type":"pane_list"}} +``` + +the focused pane is yours. other panes are your neighbors. + +```bash +herdr workspace list +``` + +```json +{"id":"cli:workspace:list","result":{"type":"workspace_list","workspaces":[ + {"workspace_id":"w_1","number":1,"label":"project","focused":true,"pane_count":2,"agent_state":"waiting"} +]}} +``` + +## read another pane + +see what's on another pane's screen: + +```bash +herdr pane read p_1_1 --source recent --lines 50 +``` + +`--source visible` shows the current viewport. `--source recent` shows recent scrollback (default). + +## split a pane and run a command + +split your pane to the right and run a dev server: + +```bash +herdr pane split p_1_5 --direction right --no-focus +``` + +this returns the new pane's id. then run a command in it: + +```bash +herdr pane run p_1_7 "npm run dev" +``` + +`pane run` sends the text and presses Enter. use `--no-focus` on split to keep focus on your pane. + +split downward instead: + +```bash +herdr pane split p_1_5 --direction down --no-focus +``` + +## wait for output + +block until specific text appears in a pane. useful for waiting on servers, builds, tests: + +```bash +herdr wait output p_1_7 --match "ready on port 3000" --timeout 30000 +``` + +with regex: + +```bash +herdr wait output p_1_7 --match "server.*ready" --regex --timeout 30000 +``` + +timeout is in milliseconds. if it times out, exit code is 1. + +## wait for an agent to finish + +block until another agent reaches a specific state: + +```bash +herdr wait agent-state p_1_1 --state idle --timeout 60000 +``` + +useful when you need another agent to finish before you proceed. + +## send text or keys to a pane + +send text without pressing Enter: + +```bash +herdr pane send-text p_1_1 "hello from claude" +``` + +press Enter (or other keys): + +```bash +herdr pane send-keys p_1_1 Enter +``` + +`pane run` combines both — sends text then Enter: + +```bash +herdr pane run p_1_1 "echo hello" +``` + +## workspace management + +create a new workspace: + +```bash +herdr workspace create --cwd /path/to/project +``` + +```bash +herdr workspace create --no-focus +``` + +focus a workspace: + +```bash +herdr workspace focus w_2 +``` + +rename: + +```bash +herdr workspace rename w_1 "api server" +``` + +close: + +```bash +herdr workspace close w_2 +``` + +## close a pane + +```bash +herdr pane close p_1_7 +``` + +## recipes + +### run a server and watch for ready + +```bash +# split a pane for the server +herdr pane split p_1_5 --direction right --no-focus +# assume new pane is p_1_7 from the response + +# start the server +herdr pane run p_1_7 "npm run dev" + +# wait until it's ready +herdr wait output p_1_7 --match "ready" --timeout 30000 + +# now read the output to confirm +herdr pane read p_1_7 --source recent --lines 20 +``` + +### run tests in a separate pane and check results + +```bash +herdr pane split p_1_5 --direction down --no-focus +herdr pane run p_1_7 "cargo test" +herdr wait output p_1_7 --match "test result" --timeout 60000 +herdr pane read p_1_7 --source recent --lines 30 +``` + +### check what another agent is working on + +```bash +herdr pane list +herdr pane read p_1_1 --source recent --lines 80 +``` + +### spawn a new agent and give it a task + +```bash +herdr pane split p_1_5 --direction right --no-focus +herdr pane run p_1_7 "claude" +# wait for it to start +herdr wait output p_1_7 --match ">" --timeout 15000 +# send it a task +herdr pane run p_1_7 "review the test coverage in src/api/" +``` + +### coordinate with another agent + +```bash +# wait for the other agent to finish its current work +herdr wait agent-state p_1_1 --state idle --timeout 120000 + +# read what it produced +herdr pane read p_1_1 --source recent --lines 100 +``` + +## notes + +- all commands return JSON. parse the response to get pane ids from splits, workspace ids from creates, etc. +- `pane run` = `pane send-text` + `pane send-keys Enter`. use the separate commands when you need finer control. +- pane ids from split responses are the source of truth — don't guess pane ids, read them from the response. +- `--no-focus` on split keeps your terminal focused. without it, herdr focuses the new pane. +- if you're running inside herdr, the `HERDR_ENV` environment variable is set to `1`. diff --git a/SOCKET_API.md b/SOCKET_API.md index 48c74741..13ddb6d3 100644 --- a/SOCKET_API.md +++ b/SOCKET_API.md @@ -349,12 +349,26 @@ example pushed `pane.agent_state_changed` event: recommended architecture: - socket api = foundational integration protocol -- future `herdr ...` commands = ergonomic wrapper for humans and coding agents - -for agent workflows, the future CLI should likely expose blocking commands like: +- `herdr ...` commands = ergonomic wrapper for humans and coding agents + +current wrapper commands include: +- `herdr workspace list` +- `herdr workspace create ...` +- `herdr workspace get ...` +- `herdr workspace focus ...` +- `herdr workspace rename ...` +- `herdr workspace close ...` +- `herdr pane list ...` +- `herdr pane get ...` - `herdr pane read ...` +- `herdr pane split ...` +- `herdr pane close ...` +- `herdr pane send-text ...` +- `herdr pane send-keys ...` - `herdr pane run ...` - `herdr wait output ...` - `herdr wait agent-state ...` -but those should sit on top of this socket surface rather than replacing it. +those commands sit on top of this socket surface rather than replacing it. + +for convenience, the CLI accepts pane ids in either raw socket form (`p_1_1`) or short human form (`1-1`). 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); diff --git a/tests/api_ping.rs b/tests/api_ping.rs index a102eb27..17bc6b1c 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -63,6 +63,7 @@ fn spawn_herdr_with_path( cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); cmd.env("HERDR_SOCKET_PATH", socket_path); + cmd.env_remove("HERDR_ENV"); if let Some(path) = path_override { cmd.env("PATH", path); } diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs new file mode 100644 index 00000000..e758e5f8 --- /dev/null +++ b/tests/cli_wrapper.rs @@ -0,0 +1,327 @@ +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; + +fn unique_test_dir() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!("herdr-cli-test-{}-{nanos}", std::process::id())) +} + +struct SpawnedHerdr { + _master: Box<dyn MasterPty + Send>, + child: Box<dyn Child + Send + Sync>, +} + +fn wait_for_socket(path: &Path, timeout: Duration) { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if path.exists() && std::os::unix::net::UnixStream::connect(path).is_ok() { + return; + } + thread::sleep(Duration::from_millis(25)); + } + panic!("socket did not appear at {}", path.display()); +} + +fn spawn_herdr(config_home: &Path, runtime_dir: &Path, socket_path: &Path) -> SpawnedHerdr { + fs::create_dir_all(config_home.join("herdr")).unwrap(); + fs::create_dir_all(runtime_dir).unwrap(); + fs::write( + config_home.join("herdr/config.toml"), + "onboarding = false\n", + ) + .unwrap(); + + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + + let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + cmd.arg("--no-session"); + cmd.env("XDG_CONFIG_HOME", config_home); + cmd.env("XDG_RUNTIME_DIR", runtime_dir); + cmd.env("HERDR_SOCKET_PATH", socket_path); + cmd.env_remove("HERDR_ENV"); + + let child = pair.slave.spawn_command(cmd).unwrap(); + SpawnedHerdr { + _master: pair.master, + child, + } +} + +fn run_cli(socket_path: &Path, args: &[&str]) -> std::process::Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_herdr")); + command.args(args); + command.env("HERDR_SOCKET_PATH", socket_path); + command.output().unwrap() +} + +fn send_request(socket_path: &Path, json: &str) -> serde_json::Value { + let mut stream = UnixStream::connect(socket_path).unwrap(); + stream.write_all(json.as_bytes()).unwrap(); + stream.write_all(b"\n").unwrap(); + stream.flush().unwrap(); + + let mut line = String::new(); + let mut reader = BufReader::new(stream); + reader.read_line(&mut line).unwrap(); + serde_json::from_str(&line).unwrap() +} + +#[test] +fn workspace_and_pane_management_commands_work() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let listed = run_cli(&socket_path, &["workspace", "list"]); + assert!(listed.status.success()); + let listed_json: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap(); + assert_eq!(listed_json["result"]["type"], "workspace_list"); + assert_eq!( + listed_json["result"]["workspaces"] + .as_array() + .unwrap() + .len(), + 0 + ); + + let created = run_cli( + &socket_path, + &["workspace", "create", "--cwd", base.to_str().unwrap()], + ); + assert!(created.status.success()); + let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); + assert_eq!(created_json["result"]["workspace"]["workspace_id"], "w_1"); + + let panes = run_cli(&socket_path, &["pane", "list", "--workspace", "1"]); + assert!(panes.status.success()); + let panes_json: serde_json::Value = serde_json::from_slice(&panes.stdout).unwrap(); + assert_eq!(panes_json["result"]["panes"].as_array().unwrap().len(), 1); + + let split = run_cli( + &socket_path, + &["pane", "split", "1-1", "--direction", "right"], + ); + assert!( + split.status.success(), + "stderr: {}", + String::from_utf8_lossy(&split.stderr) + ); + let split_json: serde_json::Value = serde_json::from_slice(&split.stdout).unwrap(); + let split_pane_id = split_json["result"]["pane"]["pane_id"].as_str().unwrap(); + + let fetched = run_cli(&socket_path, &["pane", "get", split_pane_id]); + assert!(fetched.status.success()); + let fetched_json: serde_json::Value = serde_json::from_slice(&fetched.stdout).unwrap(); + assert_eq!(fetched_json["result"]["pane"]["pane_id"], split_pane_id); + + let closed = run_cli(&socket_path, &["pane", "close", split_pane_id]); + assert!(closed.status.success()); + let closed_json: serde_json::Value = serde_json::from_slice(&closed.stdout).unwrap(); + assert_eq!(closed_json["result"]["type"], "ok"); + + let renamed = run_cli(&socket_path, &["workspace", "rename", "1", "demo"]); + assert!(renamed.status.success()); + let renamed_json: serde_json::Value = serde_json::from_slice(&renamed.stdout).unwrap(); + assert_eq!(renamed_json["result"]["workspace"]["label"], "demo"); + + let focused = run_cli(&socket_path, &["workspace", "focus", "1"]); + assert!(focused.status.success()); + + let closed_workspace = run_cli(&socket_path, &["workspace", "close", "1"]); + assert!(closed_workspace.status.success()); + let closed_workspace_json: serde_json::Value = + serde_json::from_slice(&closed_workspace.stdout).unwrap(); + assert_eq!(closed_workspace_json["result"]["type"], "ok"); + + let _ = herdr.child.kill(); + let _ = herdr.child.wait(); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn pane_run_read_and_wait_commands_work() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = send_request( + &socket_path, + &format!( + r#"{{"id":"req_cli_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, + base.display() + ), + ); + assert_eq!(created["result"]["workspace"]["workspace_id"], "w_1"); + + let create = run_cli( + &socket_path, + &[ + "pane", + "run", + "p_1_1", + "echo alpha && echo beta && printf 'ready\\n'", + ], + ); + assert!(create.status.success()); + + let waited = run_cli( + &socket_path, + &[ + "wait", + "output", + "1-1", + "--match", + "ready", + "--source", + "recent", + "--lines", + "40", + "--timeout", + "5000", + ], + ); + assert!( + waited.status.success(), + "stderr: {}", + String::from_utf8_lossy(&waited.stderr) + ); + let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap(); + assert_eq!(waited_json["result"]["type"], "output_matched"); + + let read = run_cli( + &socket_path, + &["pane", "read", "1-1", "--source", "recent", "--lines", "40"], + ); + assert!(read.status.success()); + let text = String::from_utf8(read.stdout).unwrap(); + assert!(text.contains("alpha")); + assert!(text.contains("ready")); + + let _ = herdr.child.kill(); + let _ = herdr.child.wait(); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn wait_agent_state_exits_when_state_matches() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let bin_dir = base.join("bin"); + + fs::create_dir_all(&bin_dir).unwrap(); + let fake_pi = bin_dir.join("pi"); + fs::write( + &fake_pi, + "#!/bin/sh\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n", + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&fake_pi).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&fake_pi, perms).unwrap(); + } + + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + fs::create_dir_all(config_home.join("herdr")).unwrap(); + fs::create_dir_all(&runtime_dir).unwrap(); + fs::write( + config_home.join("herdr/config.toml"), + "onboarding = false\n", + ) + .unwrap(); + let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + cmd.arg("--no-session"); + cmd.env("XDG_CONFIG_HOME", &config_home); + cmd.env("XDG_RUNTIME_DIR", &runtime_dir); + cmd.env("HERDR_SOCKET_PATH", &socket_path); + cmd.env_remove("HERDR_ENV"); + cmd.env( + "PATH", + format!( + "{}:{}", + bin_dir.display(), + std::env::var("PATH").unwrap_or_default() + ), + ); + let child = pair.slave.spawn_command(cmd).unwrap(); + let mut herdr = SpawnedHerdr { + _master: pair.master, + child, + }; + + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = send_request( + &socket_path, + &format!( + r#"{{"id":"req_cli_2","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, + base.display() + ), + ); + assert_eq!(created["result"]["workspace"]["workspace_id"], "w_1"); + + let start_pi = run_cli(&socket_path, &["pane", "run", "1-1", "pi"]); + assert!(start_pi.status.success()); + + let waited = run_cli( + &socket_path, + &[ + "wait", + "agent-state", + "1-1", + "--state", + "idle", + "--timeout", + "5000", + ], + ); + assert!( + waited.status.success(), + "stderr: {}", + String::from_utf8_lossy(&waited.stderr) + ); + let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap(); + assert_eq!(waited_json["event"], "pane.agent_state_changed"); + assert_eq!(waited_json["data"]["state"], "idle"); + assert_eq!(waited_json["data"]["agent"], "pi"); + + let _ = herdr.child.kill(); + let _ = herdr.child.wait(); + let _ = fs::remove_dir_all(base); +} |
