aboutsummaryrefslogtreecommitdiff
path: root/src/cli/spec.rs
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/spec.rs
parentf76f895efe5b9f19aa9db909354c6c0f2195630b (diff)
feat: make agent automation a first-class cli primitive
Diffstat (limited to 'src/cli/spec.rs')
-rw-r--r--src/cli/spec.rs458
1 files changed, 379 insertions, 79 deletions
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");
+ }
}
}