diff options
Diffstat (limited to 'src/cli')
| -rw-r--r-- | src/cli/agent.rs | 384 | ||||
| -rw-r--r-- | src/cli/pane.rs | 112 | ||||
| -rw-r--r-- | src/cli/protocol_guard.rs | 4 | ||||
| -rw-r--r-- | src/cli/spec.rs | 458 |
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"); + } } } |
