From a5847a8039d2754dd2908a13e4625b31f7404d9a Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 29 Mar 2026 19:29:23 +0300 Subject: feat(cli): add workspace and pane management commands --- README.md | 42 ++- SKILL.md | 232 ++++++++++++++++ SOCKET_API.md | 22 +- src/cli.rs | 762 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 14 +- tests/api_ping.rs | 1 + tests/cli_wrapper.rs | 327 ++++++++++++++++++++++ 7 files changed, 1382 insertions(+), 18 deletions(-) create mode 100644 SKILL.md create mode 100644 src/cli.rs create mode 100644 tests/cli_wrapper.rs 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 inspect one workspace +herdr workspace focus focus a workspace +herdr workspace rename ... rename a workspace +herdr workspace close close a workspace +herdr pane list ... list panes +herdr pane get inspect one pane +herdr pane read ... read pane output +herdr pane split ... split a pane +herdr pane close close a pane +herdr pane send-text send text without submitting +herdr pane send-keys send keypresses like Enter +herdr pane run send text and press Enter +herdr wait output ... block until output matches +herdr wait agent-state ... 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let Some(raw_workspace_id) = args.first() else { + eprintln!("usage: herdr workspace get "); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr workspace get "); + 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 { + let Some(raw_workspace_id) = args.first() else { + eprintln!("usage: herdr workspace focus "); + return Ok(2); + }; + if args.len() != 1 { + eprintln!("usage: herdr workspace focus "); + 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 { + if args.len() < 2 { + eprintln!("usage: herdr workspace rename