aboutsummaryrefslogtreecommitdiff
path: root/src/cli
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-19 23:52:20 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-19 23:56:07 +0300
commit3f80947678a9723434aa7fddfe23750b5393e24c (patch)
tree6f463797132acfc51dfb6e135820a052954b4f13 /src/cli
parentf76f895efe5b9f19aa9db909354c6c0f2195630b (diff)
feat: make agent automation a first-class cli primitive
Diffstat (limited to 'src/cli')
-rw-r--r--src/cli/agent.rs384
-rw-r--r--src/cli/pane.rs112
-rw-r--r--src/cli/protocol_guard.rs4
-rw-r--r--src/cli/spec.rs458
4 files changed, 627 insertions, 331 deletions
diff --git a/src/cli/agent.rs b/src/cli/agent.rs
index 898de33c..e48d6b3b 100644
--- a/src/cli/agent.rs
+++ b/src/cli/agent.rs
@@ -1,8 +1,9 @@
use std::time::{Duration, Instant};
use crate::api::schema::{
- AgentPromptParams, AgentReadParams, AgentRenameParams, AgentSendParams, AgentStartParams,
- AgentTarget, EmptyParams, Method, ReadFormat, ReadSource, Request,
+ AgentPromptParams, AgentPromptWaitOptions, AgentReadParams, AgentRenameParams,
+ AgentSendKeysParams, AgentStartParams, AgentTarget, AgentWaitParams, EmptyParams, Method,
+ ReadFormat, ReadSource, Request,
};
pub(super) fn run_agent_command(args: &[String]) -> std::io::Result<i32> {
@@ -15,7 +16,7 @@ pub(super) fn run_agent_command(args: &[String]) -> std::io::Result<i32> {
"list" => agent_list(&args[1..]),
"get" => agent_get(&args[1..]),
"read" => agent_read(&args[1..]),
- "send" => agent_send(&args[1..]),
+ "send-keys" => agent_send_keys(&args[1..]),
"prompt" => agent_prompt(&args[1..]),
"rename" => agent_rename(&args[1..]),
"focus" => agent_focus(&args[1..]),
@@ -304,7 +305,10 @@ fn agent_start(args: &[String]) -> std::io::Result<i32> {
eprintln!("missing value for --timeout");
return Ok(2);
};
- timeout_ms = Some(super::parse_u64_flag("--timeout", value)?);
+ timeout_ms = match parse_timeout(value) {
+ Ok(timeout_ms) => Some(timeout_ms),
+ Err(exit_code) => return Ok(exit_code),
+ };
index += 2;
}
other => {
@@ -343,22 +347,20 @@ fn agent_start(args: &[String]) -> std::io::Result<i32> {
if response.get("error").is_some() {
return super::print_response(&response);
}
- let Some(terminal_id) = response["result"]["agent"]["terminal_id"].as_str() else {
+ let timeout = Duration::from_millis(timeout_ms.unwrap_or(30_000));
+ let Some(expected_terminal_id) = response["result"]["agent"]["terminal_id"].as_str() else {
return super::print_response(&cli_agent_error(
"cli:agent:start",
"agent_start_failed",
"agent start response did not include terminal_id",
));
};
- let terminal_id = terminal_id.to_string();
- let timeout = Duration::from_millis(timeout_ms.unwrap_or(30_000));
let waited = wait_for_named_agent(
- &terminal_id,
name,
- Some(timeout),
- AgentWaitMode::Start {
- expected_kind: &expected_kind,
- },
+ &pane_id,
+ timeout,
+ &expected_kind,
+ expected_terminal_id,
);
match waited {
Ok(Ok(agent)) => {
@@ -442,23 +444,42 @@ fn agent_attach(args: &[String]) -> std::io::Result<i32> {
fn agent_wait(args: &[String]) -> std::io::Result<i32> {
let Some(target) = args.first() else {
- eprintln!("usage: herdr agent wait <name> [--timeout MS]");
+ eprintln!("usage: herdr agent wait <target> [--until STATUS]... [--timeout MS]");
return Ok(2);
};
+ let mut until = Vec::new();
let mut timeout_ms = None;
let mut index = 1;
while index < args.len() {
match args[index].as_str() {
+ "--until" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("--until requires at least one status");
+ return Ok(2);
+ };
+ let status = match super::parse_agent_status(value) {
+ Ok(status) => status,
+ Err(err) => {
+ eprintln!("{err}");
+ return Ok(2);
+ }
+ };
+ until.push(status);
+ index += 2;
+ }
"--timeout" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --timeout");
return Ok(2);
};
- timeout_ms = Some(super::parse_u64_flag("--timeout", value)?);
+ timeout_ms = match parse_timeout(value) {
+ Ok(timeout_ms) => Some(timeout_ms),
+ Err(exit_code) => return Ok(exit_code),
+ };
index += 2;
}
"help" | "--help" | "-h" => {
- eprintln!("usage: herdr agent wait <name> [--timeout MS]");
+ eprintln!("usage: herdr agent wait <target> [--until STATUS]... [--timeout MS]");
return Ok(0);
}
other => {
@@ -467,171 +488,71 @@ fn agent_wait(args: &[String]) -> std::io::Result<i32> {
}
}
}
- let initial = resolve_agent_target(target, "cli:agent:wait:resolve")?;
- if initial.get("error").is_some() {
- return super::print_response(&initial);
- }
- let agent = &initial["result"]["agent"];
- if agent["name"].as_str() != Some(target) {
- return super::print_response(&cli_agent_error(
- "cli:agent:wait",
- "agent_name_not_found",
- format!("named agent {target} not found"),
- ));
- }
- match agent["agent_status"].as_str().unwrap_or("unknown") {
- "idle" | "done" | "blocked" => return super::print_response(&initial),
- "unknown" => {
- return super::print_response(&cli_agent_error(
- "cli:agent:wait",
- "agent_not_running",
- "agent is no longer running",
- ))
- }
- _ => {}
- }
- let Some(terminal_id) = agent["terminal_id"].as_str() else {
- return super::print_response(&cli_agent_error(
- "cli:agent:wait",
- "agent_wait_failed",
- "agent response did not include terminal_id",
- ));
- };
- let timeout = timeout_ms.map(Duration::from_millis);
- let waited = match wait_for_named_agent(terminal_id, target, timeout, AgentWaitMode::Current) {
- Ok(waited) => waited,
- Err(err) => {
- return print_agent_transport_error(
- err,
- "cli:agent:wait",
- "agent_wait_transport_failed",
- )
- }
- };
- match waited {
- Ok(agent) => super::print_response(&serde_json::json!({
- "id": "cli:agent:wait",
- "result": { "type": "agent_info", "agent": agent }
- })),
- Err(error) => super::print_response(&error),
- }
-}
-
-#[derive(Clone, Copy)]
-enum AgentWaitMode<'a> {
- Start { expected_kind: &'a str },
- Current,
- AfterPrompt { baseline_state_change_seq: u64 },
+ super::print_response(&super::send_request(&Request {
+ id: "cli:agent:wait".into(),
+ method: Method::AgentWait(AgentWaitParams {
+ target: target.clone(),
+ until,
+ timeout_ms,
+ }),
+ })?)
}
fn wait_for_named_agent(
- lookup_target: &str,
- expected_name: &str,
- timeout: Option<Duration>,
- mode: AgentWaitMode<'_>,
+ name: &str,
+ fallback_pane_id: &str,
+ timeout: Duration,
+ expected_kind: &str,
+ expected_terminal_id: &str,
) -> std::io::Result<Result<serde_json::Value, serde_json::Value>> {
let started_at = Instant::now();
- let deadline = timeout.and_then(|timeout| started_at.checked_add(timeout));
+ let deadline = started_at.checked_add(timeout);
let mut first_poll = true;
loop {
if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
- if matches!(mode, AgentWaitMode::Start { .. }) {
- // Let the server reconcile its matching startup deadline before
- // returning so the pending name is immediately reusable.
- let _ = resolve_agent_target_unchecked(lookup_target, "cli:agent:start:timeout");
- }
- return Ok(Err(agent_wait_timeout(mode)));
+ // Let the server reconcile its matching startup deadline before
+ // returning so the pending name is immediately reusable.
+ let _ = resolve_agent_target_unchecked(name, "cli:agent:start:timeout");
+ return Ok(Err(agent_wait_timeout()));
}
- let poll_id = match mode {
- AgentWaitMode::Start { .. } => "cli:agent:start",
- AgentWaitMode::Current => "cli:agent:wait",
- AgentWaitMode::AfterPrompt { .. } => "cli:agent:prompt",
- };
- let response = if first_poll {
+ let poll_id = "cli:agent:start";
+ let mut response = if first_poll {
first_poll = false;
- resolve_agent_target(lookup_target, poll_id)?
+ resolve_agent_target(name, poll_id)?
} else {
- resolve_agent_target_unchecked(lookup_target, poll_id)?
+ resolve_agent_target_unchecked(name, poll_id)?
};
if response.get("error").is_some() {
- if matches!(mode, AgentWaitMode::Start { .. }) {
- return Ok(Err(cli_agent_error(
- "cli:agent:start",
- "agent_start_failed",
- "agent target disappeared before becoming interactive",
- )));
+ response = resolve_agent_target_unchecked(fallback_pane_id, poll_id)?;
+ if response.get("error").is_some() {
+ std::thread::sleep(Duration::from_millis(100));
+ continue;
}
- return Ok(Err(response));
}
let agent = &response["result"]["agent"];
- let status = agent["agent_status"].as_str().unwrap_or("unknown");
- let completed = matches!(status, "idle" | "done" | "blocked");
- if !matches!(mode, AgentWaitMode::Start { .. })
- && agent["name"].as_str() != Some(expected_name)
+ let outcome = if agent["terminal_id"].as_str() != Some(expected_terminal_id) {
+ Some(Err(agent_name_lost_error("cli:agent:start", name)))
+ } else if let Some(actual) = agent["agent"]
+ .as_str()
+ .filter(|actual| *actual != expected_kind)
{
- return Ok(Err(agent_name_lost_error(poll_id, expected_name)));
- }
- let outcome = match mode {
- AgentWaitMode::Start { expected_kind } => {
- if let Some(actual) = agent["agent"].as_str() {
- if actual != expected_kind {
- Some(Err(cli_agent_error(
- "cli:agent:start",
- "agent_kind_mismatch",
- format!("expected {expected_kind}, detected {actual}"),
- )))
- } else if agent["name"].as_str() != Some(expected_name) {
- Some(Err(agent_name_lost_error("cli:agent:start", expected_name)))
- } else if completed && agent["interactive_ready"].as_bool().unwrap_or(false) {
- Some(Ok(agent.clone()))
- } else if !agent["launch_pending"].as_bool().unwrap_or(false) {
- Some(Err(cli_agent_error(
- "cli:agent:start",
- "agent_start_failed",
- "agent process exited before becoming interactive",
- )))
- } else {
- None
- }
- } else if !agent["launch_pending"].as_bool().unwrap_or(false) {
- Some(Err(cli_agent_error(
- "cli:agent:start",
- "agent_start_failed",
- "agent process exited before becoming interactive",
- )))
- } else {
- None
- }
- }
- AgentWaitMode::Current => {
- if completed {
- Some(Ok(agent.clone()))
- } else if status == "unknown" {
- Some(Err(cli_agent_error(
- "cli:agent:wait",
- "agent_not_running",
- "agent is no longer running",
- )))
- } else {
- None
- }
- }
- AgentWaitMode::AfterPrompt {
- baseline_state_change_seq,
- } => {
- let sequence = agent["state_change_seq"].as_u64().unwrap_or(0);
- if sequence > baseline_state_change_seq && completed {
- Some(Ok(agent.clone()))
- } else if status == "unknown" {
- Some(Err(cli_agent_error(
- "cli:agent:prompt",
- "agent_not_running",
- "agent exited while waiting for the prompt",
- )))
- } else {
- None
- }
- }
+ Some(Err(cli_agent_error(
+ "cli:agent:start",
+ "agent_kind_mismatch",
+ format!("expected {expected_kind}, detected {actual}"),
+ )))
+ } else if agent["name"].as_str() != Some(name) {
+ Some(Err(agent_name_lost_error("cli:agent:start", name)))
+ } else if agent["interactive_ready"].as_bool().unwrap_or(false) {
+ Some(Ok(agent.clone()))
+ } else if !agent["launch_pending"].as_bool().unwrap_or(false) {
+ Some(Err(cli_agent_error(
+ "cli:agent:start",
+ "agent_start_failed",
+ "agent process exited before becoming interactive",
+ )))
+ } else {
+ None
};
if let Some(outcome) = outcome {
return Ok(outcome);
@@ -659,15 +580,12 @@ fn print_agent_transport_error(
super::print_response(&cli_agent_error(request_id, code, err.to_string()))
}
-fn agent_wait_timeout(mode: AgentWaitMode<'_>) -> serde_json::Value {
- let (id, message) = match mode {
- AgentWaitMode::Start { .. } => ("cli:agent:start", "timed out waiting for agent startup"),
- AgentWaitMode::Current => ("cli:agent:wait", "timed out waiting for agent completion"),
- AgentWaitMode::AfterPrompt { .. } => {
- ("cli:agent:prompt", "timed out waiting for prompted work")
- }
- };
- cli_agent_error(id, "timeout", message)
+fn agent_wait_timeout() -> serde_json::Value {
+ cli_agent_error(
+ "cli:agent:start",
+ "timeout",
+ "timed out waiting for agent startup",
+ )
}
fn cli_agent_error(id: &str, code: &str, message: impl Into<String>) -> serde_json::Value {
@@ -698,18 +616,14 @@ fn agent_get_request(target: &str, request_id: &str) -> Request {
}
fn agent_rename(args: &[String]) -> std::io::Result<i32> {
- let Some(target) = args.first() else {
+ let [target, value] = args else {
eprintln!("usage: herdr agent rename <target> <name>|--clear");
return Ok(2);
};
- if args.len() < 2 {
- eprintln!("usage: herdr agent rename <target> <name>|--clear");
- return Ok(2);
- }
- let name = if args.len() == 2 && args[1] == "--clear" {
+ let name = if value == "--clear" {
None
} else {
- Some(args[1..].join(" "))
+ Some(value.clone())
};
super::print_response(&super::send_request(&Request {
@@ -723,7 +637,9 @@ fn agent_rename(args: &[String]) -> std::io::Result<i32> {
fn agent_prompt(args: &[String]) -> std::io::Result<i32> {
let Some(target) = args.first() else {
- eprintln!("usage: herdr agent prompt <name> <text> [--wait] [--timeout MS]");
+ eprintln!(
+ "usage: herdr agent prompt <target> <text> [--wait] [--until STATUS]... [--timeout MS]"
+ );
return Ok(2);
};
let Some(text) = args.get(1) else {
@@ -731,6 +647,7 @@ fn agent_prompt(args: &[String]) -> std::io::Result<i32> {
return Ok(2);
};
let mut wait = false;
+ let mut until = Vec::new();
let mut timeout_ms = None;
let mut index = 2;
while index < args.len() {
@@ -739,12 +656,30 @@ fn agent_prompt(args: &[String]) -> std::io::Result<i32> {
wait = true;
index += 1;
}
+ "--until" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("--until requires at least one status");
+ return Ok(2);
+ };
+ let status = match super::parse_agent_status(value) {
+ Ok(status) => status,
+ Err(err) => {
+ eprintln!("{err}");
+ return Ok(2);
+ }
+ };
+ until.push(status);
+ index += 2;
+ }
"--timeout" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --timeout");
return Ok(2);
};
- timeout_ms = Some(super::parse_u64_flag("--timeout", value)?);
+ timeout_ms = match parse_timeout(value) {
+ Ok(timeout_ms) => Some(timeout_ms),
+ Err(exit_code) => return Ok(exit_code),
+ };
index += 2;
}
option => {
@@ -753,63 +688,36 @@ fn agent_prompt(args: &[String]) -> std::io::Result<i32> {
}
}
}
- let mut response = super::send_request(&Request {
+ if !until.is_empty() && !wait {
+ eprintln!("--until requires --wait");
+ return Ok(2);
+ }
+ if timeout_ms.is_some() && !wait {
+ eprintln!("--timeout requires --wait");
+ return Ok(2);
+ }
+ let response = super::send_request(&Request {
id: "cli:agent:prompt".into(),
method: Method::AgentPrompt(AgentPromptParams {
target: target.clone(),
text: text.clone(),
+ wait: wait.then_some(AgentPromptWaitOptions { until, timeout_ms }),
}),
})?;
- if response.get("error").is_some() || !wait {
- return super::print_response(&response);
- }
- let baseline_state_change_seq = response["result"]["baseline_state_change_seq"]
- .as_u64()
- .unwrap_or(0);
- let Some(terminal_id) = response["result"]["agent"]["terminal_id"].as_str() else {
- return super::print_response(&cli_agent_error(
- "cli:agent:prompt",
- "agent_prompt_failed",
- "agent prompt response did not include terminal_id",
- ));
- };
- let waited = match wait_for_named_agent(
- terminal_id,
- target,
- timeout_ms.map(Duration::from_millis),
- AgentWaitMode::AfterPrompt {
- baseline_state_change_seq,
- },
- ) {
- Ok(waited) => waited,
- Err(err) => {
- return print_agent_transport_error(
- err,
- "cli:agent:prompt",
- "agent_prompt_transport_failed",
- )
- }
- };
- match waited {
- Ok(agent) => {
- response["result"]["agent"] = agent;
- super::print_response(&response)
- }
- Err(error) => super::print_response(&error),
- }
+ super::print_response(&response)
}
-fn agent_send(args: &[String]) -> std::io::Result<i32> {
+fn agent_send_keys(args: &[String]) -> std::io::Result<i32> {
if args.len() < 2 {
- eprintln!("usage: herdr agent send <target> <text>");
+ eprintln!("usage: herdr agent send-keys <target> <key> [key ...]");
return Ok(2);
}
super::print_response(&super::send_request(&Request {
- id: "cli:agent:send".into(),
- method: Method::AgentSend(AgentSendParams {
+ id: "cli:agent:send-keys".into(),
+ method: Method::AgentSendKeys(AgentSendKeysParams {
target: args[0].clone(),
- text: args[1..].join(" "),
+ keys: args[1..].to_vec(),
}),
})?)
}
@@ -865,7 +773,7 @@ fn agent_read(args: &[String]) -> std::io::Result<i32> {
}
}
- super::print_response(&super::send_request(&Request {
+ let response = super::send_request(&Request {
id: "cli:agent:read".into(),
method: Method::AgentRead(AgentReadParams {
target: target.clone(),
@@ -874,27 +782,35 @@ fn agent_read(args: &[String]) -> std::io::Result<i32> {
format,
strip_ansi,
}),
- })?)
+ })?;
+ super::print_read_response(&response)
}
fn print_agent_help() {
eprintln!("herdr agent commands:");
eprintln!(" herdr agent list");
eprintln!(" herdr agent get <target>");
- eprintln!(" herdr agent read <target> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]");
- eprintln!(" herdr agent send <target> <text>");
- eprintln!(" herdr agent prompt <name> <text> [--wait] [--timeout MS]");
+ eprintln!(" herdr agent read <target> [--source visible|recent|recent-unwrapped|detection] [--lines N] [--format text|ansi] [--ansi]");
+ eprintln!(" herdr agent send-keys <target> <key> [key ...]");
+ eprintln!(" herdr agent prompt <target> <text> [--wait] [--until STATUS]... [--timeout MS]");
eprintln!(" herdr agent rename <target> <name>|--clear");
eprintln!(" herdr agent focus <target>");
- eprintln!(" herdr agent wait <name> [--timeout MS]");
+ eprintln!(" herdr agent wait <target> [--until STATUS]... [--timeout MS]");
eprintln!(" herdr agent attach <target> [--takeover]");
eprintln!(
" herdr agent start <name> --kind KIND --pane ID [--timeout MS] [-- <agent-args...>]"
);
- eprintln!(" herdr agent explain <target> [--json]");
- eprintln!(" herdr agent explain --file PATH --agent LABEL [--json]");
- eprintln!(" targets accept terminal ids, unique agent names, detected/reported agent labels, and legacy pane ids");
+ eprintln!(" herdr agent explain <target> [--json|--format text|json] [--verbose]");
eprintln!(
- " agent send writes literal text; use pane run when you want command text plus Enter"
+ " herdr agent explain --file PATH --agent LABEL [--json|--format text|json] [--verbose]"
);
+ eprintln!(" targets accept unique agent names and pane ids that currently host agents");
+ eprintln!(" kinds: {}", super::spec::agent_kind_values().join("|"));
+}
+
+fn parse_timeout(value: &str) -> Result<u64, i32> {
+ super::parse_u64_flag("--timeout", value).map_err(|err| {
+ eprintln!("{err}");
+ 2
+ })
}
diff --git a/src/cli/pane.rs b/src/cli/pane.rs
index 80e10c69..4448840b 100644
--- a/src/cli/pane.rs
+++ b/src/cli/pane.rs
@@ -1,11 +1,11 @@
use crate::api::schema::{
- Method, PaneCurrentParams, PaneDirection, PaneEdgesParams, PaneFocusDirectionParams,
- PaneLayoutParams, PaneListParams, PaneMoveDestination, PaneMoveParams, PaneNeighborParams,
- PaneProcessInfoParams, PaneReadParams, PaneReleaseAgentParams, PaneRenameParams,
- PaneReportAgentParams, PaneReportAgentSessionParams, PaneReportMetadataParams,
- PaneResizeParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams,
- PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, ReadFormat, ReadSource, Request,
- SplitDirection,
+ Method, OutputMatch, PaneCurrentParams, PaneDirection, PaneEdgesParams,
+ PaneFocusDirectionParams, PaneLayoutParams, PaneListParams, PaneMoveDestination,
+ PaneMoveParams, PaneNeighborParams, PaneProcessInfoParams, PaneReadParams,
+ PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams,
+ PaneReportMetadataParams, PaneResizeParams, PaneSendInputParams, PaneSendKeysParams,
+ PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneWaitForOutputParams,
+ PaneZoomMode, PaneZoomParams, ReadFormat, ReadSource, Request, SplitDirection,
};
pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
@@ -33,6 +33,7 @@ pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
"close" => pane_close(&args[1..]),
"send-text" => pane_send_text(&args[1..]),
"send-keys" => pane_send_keys(&args[1..]),
+ "wait-output" => pane_wait_output(&args[1..]),
"report-agent" => pane_report_agent(&args[1..]),
"report-agent-session" => pane_report_agent_session(&args[1..]),
"release-agent" => pane_release_agent(&args[1..]),
@@ -505,15 +506,7 @@ fn pane_read(args: &[String]) -> std::io::Result<i32> {
}),
})?;
- 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)
+ super::print_read_response(&response)
}
fn pane_split(args: &[String]) -> std::io::Result<i32> {
@@ -941,6 +934,92 @@ fn pane_run(args: &[String]) -> std::io::Result<i32> {
}))
}
+fn pane_wait_output(args: &[String]) -> std::io::Result<i32> {
+ let Some(raw_pane_id) = args.first() else {
+ eprintln!("usage: herdr pane wait-output <pane_id> (--match TEXT | --regex PATTERN) [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--raw]");
+ return Ok(2);
+ };
+ let pane_id = super::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 matcher = None;
+ let mut index = 1;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--match" | "--regex" => {
+ let option = args[index].as_str();
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for {option}");
+ return Ok(2);
+ };
+ if matcher.is_some() {
+ eprintln!("--match and --regex are mutually exclusive");
+ return Ok(2);
+ }
+ matcher = Some(if option == "--regex" {
+ OutputMatch::Regex {
+ value: value.clone(),
+ }
+ } else {
+ OutputMatch::Substring {
+ value: value.clone(),
+ }
+ });
+ index += 2;
+ }
+ "--source" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --source");
+ return Ok(2);
+ };
+ source = super::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(super::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(super::parse_u64_flag("--timeout", value)?);
+ index += 2;
+ }
+ "--raw" => {
+ strip_ansi = false;
+ index += 1;
+ }
+ other => {
+ eprintln!("unknown option: {other}");
+ return Ok(2);
+ }
+ }
+ }
+ let Some(matcher) = matcher else {
+ eprintln!("missing required --match or --regex");
+ return Ok(2);
+ };
+ super::print_response(&super::send_request(&Request {
+ id: "cli:pane:wait-output".into(),
+ method: Method::PaneWaitForOutput(PaneWaitForOutputParams {
+ pane_id,
+ source,
+ lines,
+ r#match: matcher,
+ timeout_ms,
+ strip_ansi,
+ }),
+ })?)
+}
+
fn pane_report_agent(args: &[String]) -> std::io::Result<i32> {
let Some(raw_pane_id) = args.first() else {
eprintln!("usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]");
@@ -1430,6 +1509,7 @@ fn print_pane_help() {
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 wait-output <pane_id> (--match TEXT | --regex PATTERN) [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--raw]");
eprintln!(" herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]");
eprintln!(" herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH]");
eprintln!(" herdr pane release-agent <pane_id> --source ID --agent LABEL [--seq N]");
diff --git a/src/cli/protocol_guard.rs b/src/cli/protocol_guard.rs
index 21302bbf..db12b6f8 100644
--- a/src/cli/protocol_guard.rs
+++ b/src/cli/protocol_guard.rs
@@ -64,13 +64,13 @@ mod tests {
#[test]
fn older_server_error_preserves_request_id_and_guidance() {
let response = mismatch_response(
- "cli:wait:agent-status",
+ "cli:agent:wait",
crate::protocol::PROTOCOL_VERSION - 1,
"Run the session stop command, then restart.",
)
.unwrap();
- assert_eq!(response.id, "cli:wait:agent-status");
+ assert_eq!(response.id, "cli:agent:wait");
assert_eq!(response.error.code, "protocol_mismatch");
assert!(response.error.message.contains(&format!(
"client protocol {}",
diff --git a/src/cli/spec.rs b/src/cli/spec.rs
index 31cd834f..7b2977ac 100644
--- a/src/cli/spec.rs
+++ b/src/cli/spec.rs
@@ -1,4 +1,6 @@
-use clap::{Arg, ArgAction, Command, ValueHint};
+use std::io::Write;
+
+use clap::{Arg, ArgAction, ArgGroup, Command, ValueHint};
pub(super) fn command() -> Command {
let command = Command::new("herdr")
@@ -36,18 +38,64 @@ pub(super) fn command() -> Command {
.subcommand(notification_command())
.subcommand(agent_command())
.subcommand(pane_command())
- .subcommand(wait_command())
.subcommand(terminal_command())
.subcommand(session_command())
.subcommand(integration_command())
.subcommand(plugin_command());
- disable_auto_help(command)
+ configure_help(command, true)
}
-fn disable_auto_help(command: Command) -> Command {
+fn configure_help(command: Command, root: bool) -> Command {
+ let command = if root {
+ command
+ } else {
+ command.disable_help_flag(false)
+ };
command
- .disable_help_flag(true)
- .mut_subcommands(disable_auto_help)
+ .disable_help_subcommand(true)
+ .mut_subcommands(|subcommand| configure_help(subcommand, false))
+}
+
+pub(super) fn print_requested_help(args: &[String]) -> std::io::Result<bool> {
+ let mut stdout = std::io::stdout().lock();
+ write_requested_help(args, &mut stdout)
+}
+
+fn write_requested_help(args: &[String], output: &mut impl Write) -> std::io::Result<bool> {
+ let Some(help_index) = args
+ .iter()
+ .position(|arg| matches!(arg.as_str(), "--help" | "-h"))
+ else {
+ return Ok(false);
+ };
+ if help_index < 2 {
+ return Ok(false);
+ }
+ if args[1..help_index].iter().any(|arg| arg == "--") {
+ return Ok(false);
+ }
+
+ let mut root = command();
+ root.build();
+ let mut selected = &mut root;
+ let mut path = vec!["herdr".to_string()];
+ for segment in &args[1..help_index] {
+ if selected.find_subcommand(segment).is_none() {
+ break;
+ }
+ path.push(segment.clone());
+ selected = selected
+ .find_subcommand_mut(segment)
+ .expect("subcommand checked immediately before mutable lookup");
+ }
+ if path.len() == 1 || help_index != path.len() {
+ return Ok(false);
+ }
+
+ selected.set_bin_name(path.join(" "));
+ selected.write_long_help(&mut *output)?;
+ writeln!(output)?;
+ Ok(true)
}
fn completion_command() -> Command {
@@ -164,7 +212,7 @@ fn workspace_command() -> Command {
Command::new("report-metadata")
.about("Report display-only workspace metadata")
.arg(required("workspace_id", "WORKSPACE_ID"))
- .arg(option("source", "ID"))
+ .arg(option("source", "ID").required(true))
.arg(repeatable_option("token", "NAME=VALUE"))
.arg(repeatable_option("clear-token", "NAME"))
.arg(option("seq", "N"))
@@ -279,32 +327,65 @@ fn agent_command() -> Command {
.arg(flag("ansi")),
)
.subcommand(
- Command::new("send")
- .about("Send text to an agent")
+ Command::new("send-keys")
+ .about("Send key presses to an agent")
.arg(required("target", "TARGET"))
- .arg(required("text", "TEXT")),
+ .arg(required("key", "KEY").num_args(1..))
+ .after_help("Use esc as the canonical Escape key name; escape is also accepted."),
)
.subcommand(
Command::new("prompt")
- .about("Submit a prompt to a named agent")
- .arg(required("name", "NAME"))
+ .about("Submit a prompt to an agent")
+ .arg(required("target", "TARGET"))
.arg(required("text", "TEXT"))
- .arg(flag("wait"))
- .arg(option("timeout", "MS")),
+ .arg(
+ flag("wait")
+ .help("Wait for the first matching state observed after submission"),
+ )
+ .arg(
+ option("until", "STATUS")
+ .action(ArgAction::Append)
+ .requires("wait")
+ .value_parser(["idle", "working", "blocked", "done", "unknown"])
+ .help("State to match after --wait; repeat for more than one state"),
+ )
+ .arg(
+ option("timeout", "MS")
+ .requires("wait")
+ .help("Fail after this many milliseconds"),
+ )
+ .after_help(
+ "Without --until, --wait matches the first idle, done, or blocked state observed after submission. It does not track turns: if the agent is already working, that active turn's completion may match. Use --until unknown explicitly when needed. Without --timeout, it waits indefinitely.",
+ ),
)
.subcommand(
Command::new("rename")
.about("Rename an agent")
+ .override_usage("herdr agent rename <TARGET> <NAME>|--clear")
.arg(required("target", "TARGET"))
.arg(Arg::new("name").value_name("NAME"))
- .arg(flag("clear")),
+ .arg(flag("clear"))
+ .group(
+ ArgGroup::new("rename")
+ .args(["name", "clear"])
+ .required(true),
+ ),
)
.subcommand(id_command("focus", "target", "Focus an agent"))
.subcommand(
Command::new("wait")
- .about("Wait until a named agent is no longer working")
- .arg(required("name", "NAME"))
- .arg(option("timeout", "MS")),
+ .about("Wait until an agent reaches one of the requested states")
+ .arg(required("target", "TARGET"))
+ .arg(
+ option("until", "STATUS")
+ .action(ArgAction::Append)
+ .value_parser(["idle", "working", "blocked", "done", "unknown"])
+ .help("State to match; repeat for more than one state"),
+ )
+ .arg(option("timeout", "MS").help("Fail after this many milliseconds"))
+ .after_help(
+ "Without --until, matches idle, done, or blocked. Use --until unknown explicitly when needed. Without --timeout, waits indefinitely.",
+ ),
)
.subcommand(
Command::new("attach")
@@ -316,14 +397,29 @@ fn agent_command() -> Command {
Command::new("start")
.about("Start a supported interactive agent in an existing pane")
.arg(required("name", "NAME"))
- .arg(option("kind", "KIND").required(true))
- .arg(option("pane", "ID").required(true))
- .arg(option("timeout", "MS"))
+ .arg(
+ option("kind", "KIND")
+ .required(true)
+ .value_parser(agent_kind_values())
+ .help("Supported agent kind and canonical executable"),
+ )
+ .arg(
+ option("pane", "ID")
+ .required(true)
+ .help("Existing pane at an interactive shell prompt"),
+ )
+ .arg(
+ option("timeout", "MS")
+ .help("Wait for interactive readiness (default: 30000; max: 300000)"),
+ )
.arg(
Arg::new("agent_args")
.value_name("AGENT_ARG")
.num_args(0..)
.last(true),
+ )
+ .after_help(
+ "The pane must be at its interactive shell prompt. Success means the expected agent was detected in the same terminal and is ready for input.",
),
)
.subcommand(
@@ -343,6 +439,13 @@ fn agent_command() -> Command {
)
}
+pub(super) fn agent_kind_values() -> Vec<&'static str> {
+ crate::detect::Agent::ALL
+ .into_iter()
+ .map(crate::detect::agent_label)
+ .collect()
+}
+
fn pane_command() -> Command {
Command::new("pane")
.about("Control terminal panes")
@@ -370,7 +473,7 @@ fn pane_command() -> Command {
.subcommand(
Command::new("neighbor")
.about("Find a pane neighbor")
- .arg(direction_option())
+ .arg(required_direction_option())
.args(current_pane_args()),
)
.subcommand(
@@ -381,13 +484,13 @@ fn pane_command() -> Command {
.subcommand(
Command::new("focus")
.about("Focus a neighboring pane")
- .arg(direction_option())
+ .arg(required_direction_option())
.args(current_pane_args()),
)
.subcommand(
Command::new("resize")
.about("Resize a pane split")
- .arg(direction_option())
+ .arg(required_direction_option())
.arg(option("amount", "FLOAT"))
.args(current_pane_args()),
)
@@ -464,7 +567,37 @@ fn pane_command() -> Command {
Command::new("send-keys")
.about("Send key presses to a pane")
.arg(required("pane_id", "PANE_ID"))
- .arg(required("key", "KEY").num_args(1..)),
+ .arg(required("key", "KEY").num_args(1..))
+ .after_help("Use esc as the canonical Escape key name; escape is also accepted."),
+ )
+ .subcommand(
+ Command::new("wait-output")
+ .about("Wait for matching pane output")
+ .arg(required("pane_id", "PANE_ID"))
+ .arg(
+ option("match", "TEXT")
+ .conflicts_with("regex")
+ .required_unless_present("regex")
+ .help("Match a literal substring"),
+ )
+ .arg(
+ option("regex", "PATTERN")
+ .conflicts_with("match")
+ .required_unless_present("match")
+ .help("Match a Rust regular expression"),
+ )
+ .arg(read_source_option(false))
+ .arg(option("lines", "N").help("Restrict the searched snapshot to N lines"))
+ .arg(option("timeout", "MS").help("Fail after this many milliseconds"))
+ .arg(flag("raw").help("Keep ANSI escape sequences while matching"))
+ .group(
+ ArgGroup::new("matcher")
+ .args(["match", "regex"])
+ .required(true),
+ )
+ .after_help(
+ "The selected snapshot is searched immediately, including existing output, then polled. Without --timeout, this waits indefinitely.",
+ ),
)
.subcommand(
Command::new("run")
@@ -482,8 +615,8 @@ fn report_agent_command() -> Command {
Command::new("report-agent")
.about("Report pane agent lifecycle state")
.arg(required("pane_id", "PANE_ID"))
- .arg(option("source", "ID"))
- .arg(option("agent", "LABEL"))
+ .arg(option("source", "ID").required(true))
+ .arg(option("agent", "LABEL").required(true))
.arg(pane_agent_state_option("state"))
.arg(option("message", "TEXT"))
.arg(option("seq", "N"))
@@ -495,8 +628,8 @@ fn report_agent_session_command() -> Command {
Command::new("report-agent-session")
.about("Report pane agent session identity")
.arg(required("pane_id", "PANE_ID"))
- .arg(option("source", "ID"))
- .arg(option("agent", "LABEL"))
+ .arg(option("source", "ID").required(true))
+ .arg(option("agent", "LABEL").required(true))
.arg(option("seq", "N"))
.arg(option("agent-session-id", "ID"))
.arg(path_option("agent-session-path", "PATH"))
@@ -507,8 +640,8 @@ fn release_agent_command() -> Command {
Command::new("release-agent")
.about("Release pane agent lifecycle authority")
.arg(required("pane_id", "PANE_ID"))
- .arg(option("source", "ID"))
- .arg(option("agent", "LABEL"))
+ .arg(option("source", "ID").required(true))
+ .arg(option("agent", "LABEL").required(true))
.arg(option("seq", "N"))
}
@@ -516,7 +649,7 @@ fn report_metadata_command() -> Command {
Command::new("report-metadata")
.about("Report display-only pane metadata")
.arg(required("pane_id", "PANE_ID"))
- .arg(option("source", "ID"))
+ .arg(option("source", "ID").required(true))
.arg(option("agent", "LABEL"))
.arg(option("applies-to-source", "ID"))
.arg(option("title", "TEXT"))
@@ -531,29 +664,6 @@ fn report_metadata_command() -> Command {
.arg(option("ttl-ms", "N"))
}
-fn wait_command() -> Command {
- Command::new("wait")
- .about("Wait for pane output or agent state")
- .subcommand(
- Command::new("output")
- .about("Wait for matching pane output")
- .arg(required("pane_id", "PANE_ID"))
- .arg(option("match", "TEXT"))
- .arg(read_source_option(false))
- .arg(option("lines", "N"))
- .arg(option("timeout", "MS"))
- .arg(flag("regex"))
- .arg(flag("raw")),
- )
- .subcommand(
- Command::new("agent-status")
- .about("Wait for pane agent status")
- .arg(required("pane_id", "PANE_ID"))
- .arg(status_option("status", true))
- .arg(option("timeout", "MS")),
- )
-}
-
fn terminal_command() -> Command {
Command::new("terminal")
.about("Attach to or observe raw terminal streams")
@@ -567,6 +677,14 @@ fn terminal_command() -> Command {
Command::new("session")
.about("Work with terminal sessions")
.subcommand(
+ Command::new("control")
+ .about("Control a terminal stream")
+ .arg(required("target", "TARGET"))
+ .arg(flag("takeover"))
+ .arg(option("cols", "N"))
+ .arg(option("rows", "N")),
+ )
+ .subcommand(
Command::new("observe")
.about("Observe a terminal stream")
.arg(required("target", "TARGET"))
@@ -749,10 +867,14 @@ fn integration_target_arg() -> Arg {
Arg::new("target")
.value_name("TARGET")
.required(true)
- .value_parser([
- "pi", "omp", "claude", "codex", "copilot", "devin", "droid", "kimi", "opencode",
- "kilo", "hermes", "qodercli", "cursor",
- ])
+ .value_parser(integration_target_values())
+}
+
+fn integration_target_values() -> Vec<&'static str> {
+ crate::api::schema::IntegrationTarget::ALL
+ .into_iter()
+ .map(crate::integration::integration_target_label)
+ .collect()
}
fn id_command(name: &'static str, id: &'static str, about: &'static str) -> Command {
@@ -763,14 +885,12 @@ fn direction_option() -> Arg {
option("direction", "DIRECTION").value_parser(["left", "right", "up", "down"])
}
-fn split_direction_option() -> Arg {
- option("direction", "DIRECTION").value_parser(["right", "down"])
+fn required_direction_option() -> Arg {
+ direction_option().required(true)
}
-fn status_option(name: &'static str, required: bool) -> Arg {
- option(name, "STATUS")
- .required(required)
- .value_parser(["idle", "working", "blocked", "done", "unknown"])
+fn split_direction_option() -> Arg {
+ option("direction", "DIRECTION").value_parser(["right", "down"])
}
fn pane_agent_state_option(name: &'static str) -> Arg {
@@ -785,7 +905,9 @@ fn read_source_option(include_detection: bool) -> Arg {
} else {
vec!["visible", "recent", "recent-unwrapped"]
};
- option("source", "SOURCE").value_parser(values)
+ option("source", "SOURCE")
+ .value_parser(values)
+ .help("Terminal snapshot source (default: recent)")
}
fn text_ansi_format_option() -> Arg {
@@ -843,7 +965,7 @@ fn path_arg(name: &'static str, value_name: &'static str) -> Arg {
#[cfg(test)]
mod tests {
- use clap::Command;
+ use clap::{Arg, Command};
fn command_path<'a>(cmd: &'a Command, path: &[&str]) -> &'a Command {
let mut current = cmd;
@@ -874,6 +996,31 @@ mod tests {
.any(|arg| arg.get_long() == Some(option))
}
+ fn option_arg<'a>(cmd: &'a Command, option: &str) -> &'a Arg {
+ cmd.get_arguments()
+ .find(|arg| arg.get_long() == Some(option))
+ .unwrap_or_else(|| panic!("missing --{option}"))
+ }
+
+ fn argument<'a>(cmd: &'a Command, id: &str) -> &'a Arg {
+ cmd.get_arguments()
+ .find(|arg| arg.get_id() == id)
+ .unwrap_or_else(|| panic!("missing argument {id}"))
+ }
+
+ fn collect_subcommand_paths(
+ cmd: &Command,
+ path: &mut Vec<String>,
+ paths: &mut Vec<Vec<String>>,
+ ) {
+ for subcommand in cmd.get_subcommands() {
+ path.push(subcommand.get_name().to_string());
+ paths.push(path.clone());
+ collect_subcommand_paths(subcommand, path, paths);
+ path.pop();
+ }
+ }
+
fn assert_command_descriptions(cmd: &Command, path: &mut Vec<String>) {
if !path.is_empty() {
assert!(
@@ -896,6 +1043,37 @@ mod tests {
}
#[test]
+ fn spec_passes_clap_invariants() {
+ super::command().debug_assert();
+ }
+
+ #[test]
+ fn every_spec_subcommand_renders_short_and_long_help() {
+ let mut paths = Vec::new();
+ collect_subcommand_paths(&super::command(), &mut Vec::new(), &mut paths);
+
+ for path in paths {
+ for flag in ["-h", "--help"] {
+ let mut args = vec!["herdr".to_string()];
+ args.extend(path.iter().cloned());
+ args.push(flag.to_string());
+ let mut output = Vec::new();
+ assert!(
+ super::write_requested_help(&args, &mut output).unwrap(),
+ "help was not handled for herdr {} {flag}",
+ path.join(" ")
+ );
+ let output = String::from_utf8(output).unwrap();
+ assert!(
+ output.contains(&format!("Usage: herdr {}", path.join(" "))),
+ "unexpected help for herdr {}: {output}",
+ path.join(" ")
+ );
+ }
+ }
+ }
+
+ #[test]
fn spec_includes_completion_alias_and_shells() {
let cmd = super::command();
let completion = command_path(&cmd, &["completion"]);
@@ -916,6 +1094,93 @@ mod tests {
}
#[test]
+ fn spec_matches_all_integration_targets() {
+ let cmd = super::command();
+ let install = command_path(&cmd, &["integration", "install"]);
+ assert_eq!(
+ argument(install, "target")
+ .get_value_parser()
+ .possible_values()
+ .unwrap()
+ .map(|value| value.get_name().to_string())
+ .collect::<Vec<_>>(),
+ crate::api::schema::IntegrationTarget::ALL
+ .map(crate::integration::integration_target_label)
+ .map(str::to_string)
+ );
+ }
+
+ #[test]
+ fn spec_marks_runtime_required_options_as_required() {
+ for (path, options) in [
+ (&["workspace", "report-metadata"][..], &["source"][..]),
+ (&["pane", "neighbor"][..], &["direction"][..]),
+ (&["pane", "focus"][..], &["direction"][..]),
+ (&["pane", "resize"][..], &["direction"][..]),
+ (&["pane", "report-agent"][..], &["source", "agent"][..]),
+ (
+ &["pane", "report-agent-session"][..],
+ &["source", "agent"][..],
+ ),
+ (&["pane", "release-agent"][..], &["source", "agent"][..]),
+ (&["pane", "report-metadata"][..], &["source"][..]),
+ ] {
+ let cmd = command_path(&super::command(), path).clone();
+ for option in options {
+ assert!(
+ option_arg(&cmd, option).is_required_set(),
+ "herdr {} --{option} should be required",
+ path.join(" ")
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn agent_prompt_until_requires_wait() {
+ let error = super::command()
+ .try_get_matches_from([
+ "herdr", "agent", "prompt", "reviewer", "hello", "--until", "idle",
+ ])
+ .unwrap_err();
+ assert_eq!(
+ error.kind(),
+ clap::error::ErrorKind::MissingRequiredArgument
+ );
+ }
+
+ #[test]
+ fn agent_rename_requires_exactly_one_name_or_clear() {
+ for valid in [
+ &["herdr", "agent", "rename", "reviewer", "worker"][..],
+ &["herdr", "agent", "rename", "reviewer", "--clear"][..],
+ ] {
+ assert!(super::command().try_get_matches_from(valid).is_ok());
+ }
+ for invalid in [
+ &["herdr", "agent", "rename", "reviewer"][..],
+ &["herdr", "agent", "rename", "reviewer", "worker", "--clear"][..],
+ ] {
+ assert!(super::command().try_get_matches_from(invalid).is_err());
+ }
+
+ let mut help = Vec::new();
+ super::write_requested_help(
+ &[
+ "herdr".to_string(),
+ "agent".to_string(),
+ "rename".to_string(),
+ "--help".to_string(),
+ ],
+ &mut help,
+ )
+ .unwrap();
+ assert!(String::from_utf8(help)
+ .unwrap()
+ .contains("Usage: herdr agent rename <TARGET> <NAME>|--clear"));
+ }
+
+ #[test]
fn spec_includes_nested_plugin_pane_open_options() {
let cmd = super::command();
let open = command_path(&cmd, &["plugin", "pane", "open"]);
@@ -930,10 +1195,38 @@ mod tests {
let cmd = super::command();
let wait = command_path(&cmd, &["agent", "wait"]);
assert!(!has_option(wait, "status"));
+ assert_eq!(
+ option_values(wait, "until"),
+ ["idle", "working", "blocked", "done", "unknown"]
+ );
assert!(has_option(wait, "timeout"));
}
#[test]
+ fn spec_matches_refactored_agent_and_pane_commands() {
+ let cmd = super::command();
+ assert!(cmd
+ .get_subcommands()
+ .all(|subcommand| subcommand.get_name() != "wait"));
+
+ let agent = command_path(&cmd, &["agent"]);
+ assert!(agent
+ .get_subcommands()
+ .any(|subcommand| subcommand.get_name() == "send-keys"));
+ assert!(agent
+ .get_subcommands()
+ .any(|subcommand| subcommand.get_name() == "wait"));
+ assert!(agent
+ .get_subcommands()
+ .all(|subcommand| subcommand.get_name() != "send"));
+
+ let pane = command_path(&cmd, &["pane"]);
+ assert!(pane
+ .get_subcommands()
+ .any(|subcommand| subcommand.get_name() == "wait-output"));
+ }
+
+ #[test]
fn spec_includes_pane_read_raw_flag() {
let cmd = super::command();
let pane_read = command_path(&cmd, &["pane", "read"]);
@@ -954,6 +1247,12 @@ mod tests {
let cmd = super::command();
let agent_start = command_path(&cmd, &["agent", "start"]);
assert!(has_option(agent_start, "kind"));
+ assert_eq!(
+ option_values(agent_start, "kind"),
+ crate::detect::Agent::ALL
+ .map(crate::detect::agent_label)
+ .map(str::to_string)
+ );
assert!(has_option(agent_start, "pane"));
for legacy in ["cwd", "workspace", "tab", "split", "focus", "env", "argv"] {
assert!(!has_option(agent_start, legacy), "legacy option --{legacy}");
@@ -964,17 +1263,18 @@ mod tests {
}
#[test]
- fn zsh_completion_contains_public_commands_and_values() {
- let mut cmd = super::command();
- let mut output = Vec::new();
- clap_complete::generate(clap_complete::Shell::Zsh, &mut cmd, "herdr", &mut output);
- let script = String::from_utf8(output).unwrap();
- assert!(script.contains("#compdef herdr"));
- assert!(script.contains("--help"));
- assert!(script.contains("'completion:Generate shell completion scripts'"));
- assert!(script.contains("bash elvish fish powershell zsh"));
- assert!(script.contains("'pane:Control terminal panes'"));
- assert!(script.contains("idle working blocked done unknown"));
- assert!(!script.contains("live-handoff"));
+ fn completion_generation_succeeds_for_every_supported_shell() {
+ for shell in [
+ clap_complete::Shell::Bash,
+ clap_complete::Shell::Elvish,
+ clap_complete::Shell::Fish,
+ clap_complete::Shell::PowerShell,
+ clap_complete::Shell::Zsh,
+ ] {
+ let mut cmd = super::command();
+ let mut output = Vec::new();
+ clap_complete::generate(shell, &mut cmd, "herdr", &mut output);
+ assert!(!output.is_empty(), "empty {shell:?} completion output");
+ }
}
}