Files
herdr/src/cli/spec.rs
T
Can Celik 207be3c771 refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell

* feat: add client-owned shell mouse controls

* feat: complete client-owned shell chrome

* feat: add client-owned shell settings and notifications

* feat: run client shell custom commands

* feat: render popup terminals in client shell

* feat: add client-owned scrollback and copy mode

* fix: preserve client worktree trust defaults

* feat: render startup diagnostics in client shell

* feat: render onboarding in client shell

* feat: render product announcements in client shell

* feat: render release notes in client shell

* feat: render mobile switcher in client shell

* feat: render kitty graphics in client shell

* fix: preserve client shell lifecycle coherence

* refactor: route client shell commands through endpoint connections

* refactor: make the client-rendered shell the default

* refactor: remove monolithic launch mode

* refactor: complete client-rendered shell cutover

* perf: retain client pane surface updates

* perf: scale retained pane surface updates

* perf: remove remote pane input latency

* test: make client shell checks platform-independent

* fix: prevent client timer starvation

* test: fix client shell platform gates

* test: accept retained surfaces in cross-area checks

* test: make client mode assertions platform-neutral

* fix: badge only outdated integrations
2026-09-01 15:48:22 +03:00

1377 lines
49 KiB
Rust

use std::io::Write;
use clap::{Arg, ArgAction, ArgGroup, Command, ValueHint};
pub(super) fn command() -> Command {
let command = Command::new("herdr")
.about("terminal workspace manager for AI coding agents")
.disable_help_flag(true)
.disable_version_flag(true)
.arg(help_flag())
.arg(option("session", "NAME").help("Use or create a named persistent session"))
.arg(option("remote", "TARGET").help("Attach through SSH to a remote Herdr server"))
.arg(
option("remote-keybindings", "MODE")
.value_parser(["local", "server"])
.help("Choose local or server keybindings for remote attach"),
)
.arg(flag("handoff").help("Opt into live handoff for update or remote attach"))
.arg(flag("default-config").help("Print default configuration and exit"))
.arg(flag("skill").help("Print the agent skill file and exit"))
.arg(
Arg::new("version")
.short('V')
.long("version")
.action(ArgAction::SetTrue)
.help("Print version and exit"),
)
.subcommand(completion_command())
.subcommand(update_command())
.subcommand(status_command())
.subcommand(config_command())
.subcommand(channel_command())
.subcommand(server_command())
.subcommand(api_command())
.subcommand(workspace_command())
.subcommand(worktree_command())
.subcommand(tab_command())
.subcommand(notification_command())
.subcommand(agent_command())
.subcommand(pane_command())
.subcommand(terminal_command())
.subcommand(session_command())
.subcommand(integration_command())
.subcommand(plugin_command());
configure_help(command, 0)
}
fn configure_help(command: Command, depth: usize) -> Command {
let command = if depth == 0 {
command
} else {
command.disable_help_flag(false)
};
let command = if depth == 1 && command.has_subcommands() {
command.after_help(super::AGENT_HELP_FOOTER)
} else {
command
};
command
.disable_help_subcommand(true)
.mut_subcommands(|subcommand| configure_help(subcommand, depth + 1))
}
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, crate::platform::begin_cli_output)
}
fn write_requested_help(
args: &[String],
output: &mut impl Write,
before_write: impl FnOnce(),
) -> 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(" "));
before_write();
selected.write_long_help(&mut *output)?;
writeln!(output)?;
Ok(true)
}
fn completion_command() -> Command {
Command::new("completion")
.visible_alias("completions")
.about("Generate shell completion scripts")
.arg(
Arg::new("shell")
.value_name("SHELL")
.required(true)
.value_parser(super::completion::SUPPORTED_SHELLS)
.help("Shell to generate completions for"),
)
}
fn update_command() -> Command {
Command::new("update")
.about("Download and install the latest version")
.arg(flag("handoff").help("Try live handoff after installing"))
}
fn status_command() -> Command {
Command::new("status")
.about("Show local client and running server status")
.arg(json_flag())
.subcommand(
Command::new("server")
.about("Show running server status")
.arg(json_flag()),
)
.subcommand(
Command::new("client")
.about("Show local client status")
.arg(json_flag()),
)
}
fn config_command() -> Command {
Command::new("config")
.about("Manage local configuration")
.subcommand(Command::new("check").about("Validate config.toml and print diagnostics"))
.subcommand(Command::new("reset-keys").about("Reset custom keybindings"))
}
fn channel_command() -> Command {
Command::new("channel")
.about("Manage stable and preview update channels")
.subcommand(Command::new("show").about("Print the configured update channel"))
.subcommand(
Command::new("set").about("Choose the update channel").arg(
Arg::new("channel")
.value_name("CHANNEL")
.required(true)
.value_parser(["stable", "preview"]),
),
)
}
fn server_command() -> Command {
Command::new("server")
.about("Run or control the headless server")
.subcommand(Command::new("stop").about("Stop the running server"))
.subcommand(Command::new("reload-config").about("Reload config in the running server"))
.subcommand(
Command::new("agent-manifests")
.about("Show active agent detection manifests")
.arg(json_flag()),
)
.subcommand(
Command::new("update-agent-manifests")
.about("Fetch and reload agent detection manifests")
.arg(json_flag()),
)
.subcommand(
Command::new("reload-agent-manifests")
.about("Reload local agent detection manifest overrides"),
)
}
fn api_command() -> Command {
Command::new("api")
.about("Inspect socket API metadata and live runtime state")
.subcommand(Command::new("snapshot").about("Print the live session snapshot"))
.subcommand(
Command::new("schema")
.about("Print or write the bundled API schema")
.arg(json_flag())
.arg(path_option("output", "PATH")),
)
}
fn workspace_command() -> Command {
Command::new("workspace")
.about("Manage workspaces over the socket API")
.subcommand(Command::new("list").about("List workspaces"))
.subcommand(
Command::new("create")
.about("Create a workspace")
.arg(path_option("cwd", "PATH"))
.arg(option("label", "TEXT"))
.arg(env_option())
.arg(flag("focus"))
.arg(flag("no-focus")),
)
.subcommand(id_command("get", "workspace_id", "Show a workspace"))
.subcommand(id_command("focus", "workspace_id", "Focus a workspace"))
.subcommand(
Command::new("rename")
.about("Rename a workspace")
.arg(required("workspace_id", "WORKSPACE_ID"))
.arg(required("label", "LABEL").num_args(1..)),
)
.subcommand(
Command::new("report-metadata")
.about("Report display-only workspace metadata")
.arg(required("workspace_id", "WORKSPACE_ID"))
.arg(option("source", "ID").required(true))
.arg(repeatable_option("token", "NAME=VALUE"))
.arg(repeatable_option("clear-token", "NAME"))
.arg(option("seq", "N"))
.arg(option("ttl-ms", "N")),
)
.subcommand(id_command("close", "workspace_id", "Close a workspace"))
}
fn worktree_command() -> Command {
Command::new("worktree")
.about("Manage Git worktree-backed workspaces")
.subcommand(
Command::new("list")
.about("List worktree workspaces")
.arg(option("workspace", "ID"))
.arg(path_option("cwd", "PATH"))
.arg(flag("trust-repository")),
)
.subcommand(
Command::new("create")
.about("Create and open a Git worktree")
.arg(option("workspace", "ID"))
.arg(path_option("cwd", "PATH"))
.arg(option("branch", "NAME"))
.arg(option("base", "REF"))
.arg(path_option("path", "PATH"))
.arg(option("label", "TEXT"))
.arg(flag("focus"))
.arg(flag("no-focus"))
.arg(flag("trust-repository")),
)
.subcommand(
Command::new("open")
.about("Open an existing Git worktree")
.arg(option("workspace", "ID"))
.arg(path_option("cwd", "PATH"))
.arg(path_option("path", "PATH"))
.arg(option("branch", "NAME"))
.arg(option("label", "TEXT"))
.arg(flag("focus"))
.arg(flag("no-focus"))
.arg(flag("trust-repository")),
)
.subcommand(
Command::new("remove")
.about("Remove a worktree checkout")
.arg(option("workspace", "ID"))
.arg(flag("force"))
.arg(flag("trust-repository")),
)
}
fn tab_command() -> Command {
Command::new("tab")
.about("Manage tabs over the socket API")
.subcommand(
Command::new("list")
.about("List tabs")
.arg(option("workspace", "WORKSPACE_ID")),
)
.subcommand(
Command::new("create")
.about("Create a tab")
.arg(option("workspace", "WORKSPACE_ID"))
.arg(path_option("cwd", "PATH"))
.arg(option("label", "TEXT"))
.arg(env_option())
.arg(flag("focus"))
.arg(flag("no-focus")),
)
.subcommand(id_command("get", "tab_id", "Show a tab"))
.subcommand(id_command("focus", "tab_id", "Focus a tab"))
.subcommand(
Command::new("rename")
.about("Rename a tab")
.arg(required("tab_id", "TAB_ID"))
.arg(required("label", "LABEL").num_args(1..)),
)
.subcommand(id_command("close", "tab_id", "Close a tab"))
}
fn notification_command() -> Command {
Command::new("notification")
.about("Show Herdr notifications")
.subcommand(
Command::new("show")
.about("Show a notification")
.arg(required("title", "TITLE"))
.arg(option("body", "TEXT"))
.arg(option("position", "POSITION").value_parser([
"top-left",
"top-right",
"bottom-left",
"bottom-right",
]))
.arg(option("sound", "SOUND").value_parser(["none", "done", "request"])),
)
}
fn agent_command() -> Command {
Command::new("agent")
.about("Control and inspect agent panes")
.subcommand(Command::new("list").about("List agents"))
.subcommand(id_command("get", "target", "Show an agent"))
.subcommand(
Command::new("read")
.about("Read agent terminal output")
.override_usage("herdr agent read <TARGET> [OPTIONS]")
.arg(required("target", "TARGET"))
.arg(read_source_option(true))
.arg(option("lines", "N"))
.arg(text_ansi_format_option())
.arg(flag("ansi")),
)
.subcommand(
Command::new("send-keys")
.about("Send key presses to an agent")
.arg(required("target", "TARGET"))
.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 an agent")
.override_usage("herdr agent prompt <TARGET> <TEXT> [OPTIONS]")
.arg(required("target", "TARGET"))
.arg(required("text", "TEXT"))
.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(
"If the agent is already blocked, submission is rejected with agent_blocked before any input is sent. When an accepted submission starts from another non-working state, --wait first requires an observed state change within 5000ms; otherwise it returns agent_prompt_stalled. A shorter --timeout returns timeout instead. It then matches idle, done, or blocked by default, or any exact --until state. It does not track turns: if the agent is already working, that active turn's completion may match. Without --timeout, the settled-state wait is indefinite.",
),
)
.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"))
.group(
ArgGroup::new("rename")
.args(["name", "clear"])
.required(true),
),
)
.subcommand(id_command("focus", "target", "Focus an agent"))
.subcommand(
Command::new("wait")
.about("Wait until an agent reaches one of the requested states")
.override_usage("herdr agent wait <TARGET> [OPTIONS]")
.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")
.about("Attach directly to an agent terminal")
.override_usage("herdr agent attach <TARGET> [OPTIONS]")
.arg(required("target", "TARGET"))
.arg(flag("takeover")),
)
.subcommand(
Command::new("start")
.about("Start a supported interactive agent in an existing pane")
.override_usage(
"herdr agent start <NAME> --kind <KIND> --pane <ID> [OPTIONS] [-- [AGENT_ARG]...]",
)
.arg(required("name", "NAME"))
.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.\n\nnext: herdr agent prompt <TARGET> <TEXT> --wait",
),
)
.subcommand(
Command::new("explain")
.about("Explain agent detection state")
.arg(Arg::new("target").value_name("TARGET"))
.arg(path_option("file", "PATH"))
.arg(option("agent", "LABEL"))
.arg(json_flag())
.arg(text_json_format_option())
.arg(
Arg::new("verbose")
.short('v')
.long("verbose")
.action(ArgAction::SetTrue),
),
)
}
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")
.subcommand(
Command::new("list")
.about("List panes")
.arg(option("workspace", "WORKSPACE_ID")),
)
.subcommand(
Command::new("current")
.about("Show the current pane")
.args(current_pane_args()),
)
.subcommand(id_command("get", "pane_id", "Show a pane"))
.subcommand(
Command::new("layout")
.about("Show pane layout information")
.args(current_pane_args()),
)
.subcommand(
Command::new("process-info")
.about("Show pane process information")
.args(current_pane_args()),
)
.subcommand(
Command::new("neighbor")
.about("Find a pane neighbor")
.arg(required_direction_option())
.args(current_pane_args()),
)
.subcommand(
Command::new("edges")
.about("Show pane edge information")
.args(current_pane_args()),
)
.subcommand(
Command::new("focus")
.about("Focus a neighboring pane")
.arg(required_direction_option())
.args(current_pane_args()),
)
.subcommand(
Command::new("resize")
.about("Resize a pane split")
.arg(required_direction_option())
.arg(option("amount", "FLOAT"))
.args(current_pane_args()),
)
.subcommand(
Command::new("zoom")
.about("Toggle or set pane zoom")
.arg(Arg::new("pane_id").value_name("PANE_ID"))
.args(current_pane_args())
.arg(flag("toggle"))
.arg(flag("on"))
.arg(flag("off")),
)
.subcommand(
Command::new("read")
.about("Read pane terminal output")
.arg(required("pane_id", "PANE_ID"))
.arg(read_source_option(true))
.arg(option("lines", "N"))
.arg(text_ansi_format_option())
.arg(flag("ansi"))
.arg(flag("raw")),
)
.subcommand(
Command::new("rename")
.about("Rename a pane")
.arg(required("pane_id", "PANE_ID"))
.arg(Arg::new("label").value_name("LABEL").num_args(1..))
.arg(flag("clear")),
)
.subcommand(
Command::new("input")
.about("Set pane input routing")
.arg(Arg::new("pane_id").value_name("PANE_ID"))
.args(current_pane_args())
.arg(
option("right-click", "TARGET")
.value_parser(["herdr", "pane"])
.required(true),
),
)
.subcommand(
Command::new("split")
.about("Split a pane")
.arg(Arg::new("pane_id").value_name("PANE_ID"))
.args(current_pane_args())
.arg(split_direction_option())
.arg(option("ratio", "FLOAT"))
.arg(path_option("cwd", "PATH"))
.arg(env_option())
.arg(option("right-click", "TARGET").value_parser(["herdr", "pane"]))
.arg(flag("focus"))
.arg(flag("no-focus")),
)
.subcommand(
Command::new("swap")
.about("Swap panes")
.arg(direction_option())
.args(current_pane_args())
.arg(option("source-pane", "ID"))
.arg(option("target-pane", "ID")),
)
.subcommand(
Command::new("move")
.about("Move a pane")
.arg(required("pane_id", "PANE_ID"))
.arg(option("tab", "TAB_ID"))
.arg(option("split", "DIRECTION").value_parser(["right", "down"]))
.arg(option("target-pane", "ID"))
.arg(option("ratio", "FLOAT"))
.arg(flag("new-tab"))
.arg(option("workspace", "ID"))
.arg(flag("new-workspace"))
.arg(option("label", "TEXT"))
.arg(option("tab-label", "TEXT"))
.arg(flag("focus"))
.arg(flag("no-focus")),
)
.subcommand(id_command("close", "pane_id", "Close a pane"))
.subcommand(
Command::new("send-text")
.about("Send literal text to a pane")
.arg(required("pane_id", "PANE_ID"))
.arg(required("text", "TEXT"))
.after_help(
"next: herdr pane run <PANE_ID> <COMMAND> sends text and Enter in one call",
),
)
.subcommand(
Command::new("send-keys")
.about("Send key presses to a pane")
.arg(required("pane_id", "PANE_ID"))
.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")
.about("Run a command in a pane")
.arg(required("pane_id", "PANE_ID"))
.arg(required("command", "COMMAND").num_args(1..)),
)
.subcommand(report_agent_command())
.subcommand(report_agent_session_command())
.subcommand(release_agent_command())
.subcommand(report_metadata_command())
}
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").required(true))
.arg(option("agent", "LABEL").required(true))
.arg(pane_agent_state_option("state"))
.arg(option("message", "TEXT"))
.arg(option("seq", "N"))
.arg(option("agent-session-id", "ID"))
.arg(path_option("agent-session-path", "PATH"))
}
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").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"))
.arg(option("session-start-source", "SOURCE"))
}
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").required(true))
.arg(option("agent", "LABEL").required(true))
.arg(option("seq", "N"))
}
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").required(true))
.arg(option("agent", "LABEL"))
.arg(option("applies-to-source", "ID"))
.arg(option("title", "TEXT"))
.arg(flag("clear-title"))
.arg(option("display-agent", "TEXT"))
.arg(flag("clear-display-agent"))
.arg(option("state-label", "STATUS=TEXT"))
.arg(flag("clear-state-labels"))
.arg(repeatable_option("token", "NAME=VALUE"))
.arg(repeatable_option("clear-token", "NAME"))
.arg(option("seq", "N"))
.arg(option("ttl-ms", "N"))
}
fn terminal_command() -> Command {
Command::new("terminal")
.about("Attach to or observe raw terminal streams")
.subcommand(
Command::new("attach")
.about("Attach directly to a terminal stream")
.arg(required("terminal_id", "TERMINAL_ID"))
.arg(flag("takeover")),
)
.subcommand(
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"))
.arg(option("cols", "N"))
.arg(option("rows", "N")),
),
)
.subcommand(
Command::new("title")
.about("Manage the outer terminal title")
.subcommand(
Command::new("set")
.about("Set the outer terminal title")
.arg(required("title", "TITLE")),
)
.subcommand(Command::new("clear").about("Clear the outer terminal title")),
)
}
fn session_command() -> Command {
Command::new("session")
.about("Manage named persistent sessions")
.subcommand(Command::new("list").about("List sessions").arg(json_flag()))
.subcommand(
Command::new("attach")
.about("Attach to a session")
.arg(required("name", "NAME")),
)
.subcommand(
Command::new("stop")
.about("Stop a session")
.arg(required("name", "NAME"))
.arg(json_flag()),
)
.subcommand(
Command::new("delete")
.about("Delete a stopped session")
.arg(required("name", "NAME"))
.arg(json_flag()),
)
}
fn integration_command() -> Command {
Command::new("integration")
.about("Manage built-in agent integrations")
.subcommand(
Command::new("install")
.about("Install an integration")
.arg(integration_target_arg()),
)
.subcommand(
Command::new("uninstall")
.about("Uninstall an integration")
.arg(integration_target_arg()),
)
.subcommand(
Command::new("status")
.about("Show integration status")
.arg(flag("outdated-only")),
)
}
fn plugin_command() -> Command {
Command::new("plugin")
.about("Install and run workflow plugins")
.subcommand(
Command::new("install")
.about("Install a plugin from GitHub")
.arg(required("source", "OWNER/REPO[/SUBDIR]"))
.arg(option("ref", "REF"))
.arg(
Arg::new("yes")
.short('y')
.long("yes")
.action(ArgAction::SetTrue),
),
)
.subcommand(
Command::new("uninstall")
.about("Uninstall a plugin")
.arg(required("plugin", "PLUGIN")),
)
.subcommand(
Command::new("link")
.about("Link a local plugin")
.arg(path_arg("path", "PATH"))
.arg(flag("disabled"))
.arg(flag("enabled")),
)
.subcommand(
Command::new("unlink")
.about("Unlink a local plugin")
.arg(required("plugin_id", "PLUGIN_ID")),
)
.subcommand(
Command::new("enable")
.about("Enable a plugin")
.arg(required("plugin_id", "PLUGIN_ID")),
)
.subcommand(
Command::new("disable")
.about("Disable a plugin")
.arg(required("plugin_id", "PLUGIN_ID")),
)
.subcommand(
Command::new("list")
.about("List installed plugins")
.arg(option("plugin", "ID"))
.arg(json_flag()),
)
.subcommand(
Command::new("config-dir")
.about("Print a plugin config directory")
.arg(required("plugin_id", "PLUGIN_ID")),
)
.subcommand(
Command::new("action")
.about("List or invoke plugin actions")
.subcommand(
Command::new("list")
.about("List plugin actions")
.arg(option("plugin", "ID")),
)
.subcommand(
Command::new("invoke")
.about("Invoke a plugin action")
.arg(required("action_id", "ACTION_ID"))
.arg(option("plugin", "ID")),
),
)
.subcommand(
Command::new("log")
.about("Inspect plugin command logs")
.visible_alias("logs")
.subcommand(
Command::new("list")
.about("List plugin command logs")
.arg(option("plugin", "ID"))
.arg(option("limit", "N")),
),
)
.subcommand(
Command::new("pane")
.about("Manage plugin-owned panes")
.subcommand(
Command::new("open")
.about("Open a plugin pane")
.arg(option("plugin", "ID"))
.arg(option("entrypoint", "ID"))
.arg(
option("placement", "PLACEMENT")
.value_parser(["overlay", "split", "tab", "zoomed"]),
)
.arg(option("workspace", "ID"))
.arg(option("target-pane", "PANE"))
.arg(split_direction_option())
.arg(path_option("cwd", "PATH"))
.arg(env_option())
.arg(flag("focus"))
.arg(flag("no-focus")),
)
.subcommand(
Command::new("focus")
.about("Focus a plugin pane")
.arg(required("pane_id", "PANE_ID")),
)
.subcommand(
Command::new("close")
.about("Close a plugin pane")
.arg(required("pane_id", "PANE_ID")),
),
)
}
fn current_pane_args() -> [Arg; 2] {
[option("pane", "ID"), flag("current")]
}
fn integration_target_arg() -> Arg {
Arg::new("target")
.value_name("TARGET")
.required(true)
.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 {
Command::new(name).about(about).arg(required(id, id))
}
fn direction_option() -> Arg {
option("direction", "DIRECTION").value_parser(["left", "right", "up", "down"])
}
fn required_direction_option() -> Arg {
direction_option().required(true)
}
fn split_direction_option() -> Arg {
option("direction", "DIRECTION").value_parser(["right", "down"])
}
fn pane_agent_state_option(name: &'static str) -> Arg {
option(name, "STATUS")
.required(true)
.value_parser(["idle", "working", "blocked", "unknown"])
}
fn read_source_option(include_detection: bool) -> Arg {
let values = if include_detection {
vec!["visible", "recent", "recent-unwrapped", "detection"]
} else {
vec!["visible", "recent", "recent-unwrapped"]
};
option("source", "SOURCE")
.value_parser(values)
.help("Terminal snapshot source (default: recent)")
}
fn text_ansi_format_option() -> Arg {
option("format", "FORMAT").value_parser(["text", "ansi"])
}
fn text_json_format_option() -> Arg {
option("format", "FORMAT").value_parser(["text", "json"])
}
fn json_flag() -> Arg {
flag("json")
}
fn help_flag() -> Arg {
Arg::new("help")
.short('h')
.long("help")
.action(ArgAction::SetTrue)
.help("Show help")
}
fn env_option() -> Arg {
option("env", "KEY=VALUE")
.action(ArgAction::Append)
.help("Set an environment variable for the launched process")
}
fn flag(name: &'static str) -> Arg {
Arg::new(name).long(name).action(ArgAction::SetTrue)
}
fn option(name: &'static str, value_name: &'static str) -> Arg {
Arg::new(name)
.long(name)
.value_name(value_name)
.action(ArgAction::Set)
}
fn repeatable_option(name: &'static str, value_name: &'static str) -> Arg {
option(name, value_name).action(ArgAction::Append)
}
fn path_option(name: &'static str, value_name: &'static str) -> Arg {
option(name, value_name).value_hint(ValueHint::AnyPath)
}
fn required(name: &'static str, value_name: &'static str) -> Arg {
Arg::new(name).value_name(value_name).required(true)
}
fn path_arg(name: &'static str, value_name: &'static str) -> Arg {
required(name, value_name).value_hint(ValueHint::AnyPath)
}
#[cfg(test)]
mod tests {
use clap::{Arg, Command};
fn command_path<'a>(cmd: &'a Command, path: &[&str]) -> &'a Command {
let mut current = cmd;
for name in path {
current = current
.get_subcommands()
.find(|subcommand| subcommand.get_name() == *name)
.unwrap_or_else(|| panic!("missing command path segment {name}"));
}
current
}
fn option_values(cmd: &Command, option: &str) -> Vec<String> {
let arg = cmd
.get_arguments()
.find(|arg| arg.get_long() == Some(option))
.unwrap_or_else(|| panic!("missing --{option}"));
arg.get_value_parser()
.possible_values()
.into_iter()
.flatten()
.map(|value| value.get_name().to_string())
.collect()
}
fn has_option(cmd: &Command, option: &str) -> bool {
cmd.get_arguments()
.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!(
cmd.get_about().is_some(),
"missing completion description for {}",
path.join(" ")
);
}
for subcommand in cmd.get_subcommands() {
path.push(subcommand.get_name().to_string());
assert_command_descriptions(subcommand, path);
path.pop();
}
}
#[test]
fn spec_describes_all_completion_commands() {
let cmd = super::command();
assert_command_descriptions(&cmd, &mut Vec::new());
}
#[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"]);
assert!(completion
.get_all_aliases()
.any(|alias| alias == "completions"));
let shells = completion
.get_arguments()
.find(|arg| arg.get_id() == "shell")
.unwrap()
.get_value_parser()
.possible_values()
.unwrap()
.map(|value| value.get_name().to_string())
.collect::<Vec<_>>();
assert!(shells.contains(&"zsh".to_string()));
assert!(shells.contains(&"fish".to_string()));
}
#[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 worktree_json_compatibility_flag_stays_out_of_public_spec() {
let cmd = super::command();
for subcommand in ["list", "create", "open", "remove"] {
let worktree_command = command_path(&cmd, &["worktree", subcommand]);
assert!(
!has_option(worktree_command, "json"),
"herdr worktree {subcommand} should not advertise --json"
);
}
}
#[test]
fn spec_includes_nested_plugin_pane_open_options() {
let cmd = super::command();
let open = command_path(&cmd, &["plugin", "pane", "open"]);
assert!(open
.get_arguments()
.any(|arg| arg.get_long() == Some("entrypoint")));
assert!(option_values(open, "placement").contains(&"zoomed".to_string()));
}
#[test]
fn spec_keeps_agent_wait_status_free() {
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"]);
assert!(has_option(pane_read, "raw"));
}
#[test]
fn spec_matches_pane_split_direction_flag() {
let cmd = super::command();
let pane_split = command_path(&cmd, &["pane", "split"]);
assert!(has_option(pane_split, "direction"));
assert!(!has_option(pane_split, "split"));
assert_eq!(option_values(pane_split, "direction"), ["right", "down"]);
}
#[test]
fn spec_models_agent_start_target_and_trailing_args() {
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}");
}
assert!(agent_start
.get_arguments()
.any(|arg| arg.get_id() == "agent_args"));
}
fn long_help(path: &[&str]) -> String {
let mut args = vec!["herdr".to_string()];
args.extend(path.iter().map(|segment| segment.to_string()));
args.push("--help".to_string());
let mut output = Vec::new();
assert!(
super::write_requested_help(&args, &mut output, || {}).unwrap(),
"help was not handled for herdr {}",
path.join(" ")
);
String::from_utf8(output).unwrap()
}
#[test]
fn agent_resources_appear_on_command_groups_but_not_leaf_commands() {
for group in ["agent", "pane", "workspace", "terminal"] {
let help = long_help(&[group]);
assert!(
help.contains(super::super::AGENT_HELP_FOOTER),
"herdr {group} is missing agent resources: {help}"
);
}
let leaf = long_help(&["agent", "wait"]);
assert!(
!leaf.contains(super::super::AGENT_HELP_FOOTER),
"leaf help should stay focused: {leaf}"
);
}
#[test]
fn next_step_hints_render_without_replacing_existing_after_help() {
let agent_start = long_help(&["agent", "start"]);
assert!(
agent_start.contains("The pane must be at its interactive shell prompt."),
"agent start dropped its existing after_help: {agent_start}"
);
assert!(
agent_start.contains("next: herdr agent prompt <TARGET> <TEXT> --wait"),
"agent start is missing its next-step hint: {agent_start}"
);
let pane_send_text = long_help(&["pane", "send-text"]);
assert!(
pane_send_text.contains(
"next: herdr pane run <PANE_ID> <COMMAND> sends text and Enter in one call"
),
"pane send-text is missing its next-step hint: {pane_send_text}"
);
}
#[test]
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");
}
}
}