aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-29 19:29:23 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-29 19:29:23 +0300
commita5847a8039d2754dd2908a13e4625b31f7404d9a (patch)
tree83c8b4c811435a8108e382db59a1cc5c726daf9b
parent8b7848aac33ec67dee09e8d0b35fd70384aed1bb (diff)
feat(cli): add workspace and pane management commands
-rw-r--r--README.md42
-rw-r--r--SKILL.md232
-rw-r--r--SOCKET_API.md22
-rw-r--r--src/cli.rs762
-rw-r--r--src/main.rs14
-rw-r--r--tests/api_ping.rs1
-rw-r--r--tests/cli_wrapper.rs327
7 files changed, 1382 insertions, 18 deletions
diff --git a/README.md b/README.md
index c0ac109f..e474179c 100644
--- a/README.md
+++ b/README.md
@@ -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);
+}