aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-30 17:47:12 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-30 17:49:37 +0300
commit13cb5d9ae6aa314744ce5362b5d156182f06b503 (patch)
tree8153bf4ffd1035bdc8b3dff7753fe87f86ba44dd
parentb7015f17a49501e99642009ebadabcbefc0fbad0 (diff)
feat: add shell completions
refs #435
-rw-r--r--Cargo.lock42
-rw-r--r--Cargo.toml2
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/website/src/content/docs/cli-reference.mdx35
-rw-r--r--src/cli.rs3
-rw-r--r--src/cli/completion.rs113
-rw-r--r--src/cli/spec.rs964
-rw-r--r--src/main.rs3
-rw-r--r--tests/cli_wrapper.rs41
9 files changed, 1204 insertions, 0 deletions
diff --git a/Cargo.lock b/Cargo.lock
index ee370b2e..f2ecadf7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -24,6 +24,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -161,6 +167,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
+name = "clap"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
+dependencies = [
+ "clap_builder",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
+dependencies = [
+ "anstyle",
+ "clap_lex",
+]
+
+[[package]]
+name = "clap_complete"
+version = "4.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772"
+dependencies = [
+ "clap",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
name = "compact_str"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -548,6 +588,8 @@ dependencies = [
"base64",
"bincode",
"bytes",
+ "clap",
+ "clap_complete",
"crossterm",
"ctrlc",
"interprocess",
diff --git a/Cargo.toml b/Cargo.toml
index d30b1162..06f08d1e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,6 +22,8 @@ include = [
base64 = "0.22.1"
bincode = { version = "2", features = ["serde"] }
bytes = "1"
+clap = { version = "4.5", default-features = false, features = ["std"] }
+clap_complete = "4.5"
crossterm = "0.29"
ctrlc = "3"
interprocess = "2.4.2"
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index a6ada92c..1f61ded0 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -4,6 +4,7 @@
### Added
- Added `ui.sidebar_collapsed_mode = "hidden"` to make a collapsed sidebar use zero width while keeping the existing compact rail as the default. (#842)
+- Added `herdr completion <shell>` / `herdr completions <shell>` to generate shell completion scripts for bash, elvish, fish, PowerShell, and zsh. (#435)
- Added `session.snapshot` to bootstrap client runtime state in one socket API response before subscribing to events.
- Added `herdr api schema` to inspect the bundled socket API schema, with `--json` for the full JSON Schema document and `--output PATH` for file output.
- Added `herdr terminal session observe` for read-only live ANSI terminal streams that bridge processes can consume as newline-delimited JSON.
diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx
index d4708df6..4d2980fb 100644
--- a/docs/next/website/src/content/docs/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/cli-reference.mdx
@@ -19,6 +19,7 @@ herdr --no-session # single-process escape hatch
herdr --default-config # print default config
herdr update # download and install from the configured channel
herdr update --handoff # opt into live handoff for supported running servers
+herdr completion zsh # generate a zsh completion script
herdr channel show # print stable or preview
herdr channel set preview # opt into preview builds
herdr channel set stable # return Linux/macOS direct installs to stable
@@ -45,6 +46,40 @@ herdr api schema --output herdr-api.schema.json
with the installed binary. Use `--json` for the full JSON Schema document, or
`--output PATH` to write that document to a file.
+## Shell completions
+
+```bash
+herdr completion zsh
+herdr completions zsh
+herdr completion bash
+herdr completion fish
+herdr completion powershell
+herdr completion elvish
+```
+
+`completion` prints the script to stdout. `completions` is an alias. For a
+temporary zsh session, load the script directly:
+
+```bash
+source <(herdr completion zsh)
+```
+
+For a persistent zsh setup, write the generated `_herdr` function somewhere on
+your `fpath` before `compinit` runs:
+
+```bash
+mkdir -p ~/.zfunc
+herdr completion zsh > ~/.zfunc/_herdr
+```
+
+Then make sure your `.zshrc` contains:
+
+```zsh
+fpath=(~/.zfunc $fpath)
+autoload -Uz compinit
+compinit
+```
+
## Server
```bash
diff --git a/src/cli.rs b/src/cli.rs
index 64c25596..70420b09 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -10,11 +10,13 @@ use crate::api::schema::{
mod agent;
mod api;
+mod completion;
mod integration;
mod notification;
mod pane;
mod plugin;
mod server;
+mod spec;
mod status;
mod tab;
mod workspace;
@@ -57,6 +59,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
}
"api" => api::run_api_command(&args[2..])?,
"status" => status::run_status_command(&args[2..])?,
+ "completion" | "completions" => completion::run_completion_command(&args[2..])?,
"config" => run_config_command(&args[2..])?,
"channel" => run_channel_command(&args[2..])?,
"workspace" => workspace::run_workspace_command(&args[2..])?,
diff --git a/src/cli/completion.rs b/src/cli/completion.rs
new file mode 100644
index 00000000..f049175d
--- /dev/null
+++ b/src/cli/completion.rs
@@ -0,0 +1,113 @@
+use std::io::Write;
+
+use clap_complete::{generate, Shell};
+
+pub(super) const SUPPORTED_SHELLS: [&str; 5] = ["bash", "elvish", "fish", "powershell", "zsh"];
+
+pub(super) fn supported_shells_usage() -> String {
+ SUPPORTED_SHELLS.join("|")
+}
+
+pub(super) fn run_completion_command(args: &[String]) -> std::io::Result<i32> {
+ let Some(shell) = args.first().map(String::as_str) else {
+ print_completion_help();
+ return Ok(2);
+ };
+ if matches!(shell, "help" | "--help" | "-h") {
+ print_completion_help();
+ return Ok(0);
+ }
+ if args.len() != 1 {
+ print_completion_help();
+ return Ok(2);
+ }
+
+ let Some(shell) = parse_shell(shell) else {
+ eprintln!("unknown shell: {shell}");
+ print_completion_help();
+ return Ok(2);
+ };
+
+ let mut command = super::spec::command();
+ if matches!(shell, Shell::Zsh) {
+ let mut output = Vec::new();
+ generate(shell, &mut command, "herdr", &mut output);
+ let script = String::from_utf8(output).map_err(|err| {
+ std::io::Error::new(std::io::ErrorKind::InvalidData, err.utf8_error())
+ })?;
+ std::io::stdout().write_all(space_separated_zsh_long_options(&script).as_bytes())?;
+ } else {
+ generate(shell, &mut command, "herdr", &mut std::io::stdout());
+ }
+ Ok(0)
+}
+
+fn space_separated_zsh_long_options(script: &str) -> String {
+ let mut output = String::with_capacity(script.len());
+ let mut rest = script;
+
+ while let Some(start) = rest.find("--") {
+ output.push_str(&rest[..start]);
+ let candidate = &rest[start + 2..];
+ let name_len = candidate
+ .chars()
+ .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '-')
+ .map(char::len_utf8)
+ .sum::<usize>();
+ if name_len > 0 && candidate[name_len..].starts_with("=[") {
+ output.push_str("--");
+ output.push_str(&candidate[..name_len]);
+ output.push('[');
+ rest = &candidate[name_len + 2..];
+ } else {
+ output.push_str("--");
+ rest = candidate;
+ }
+ }
+
+ output.push_str(rest);
+ output
+}
+
+fn parse_shell(shell: &str) -> Option<Shell> {
+ match shell {
+ value if value == SUPPORTED_SHELLS[0] => Some(Shell::Bash),
+ value if value == SUPPORTED_SHELLS[1] => Some(Shell::Elvish),
+ value if value == SUPPORTED_SHELLS[2] => Some(Shell::Fish),
+ value if value == SUPPORTED_SHELLS[3] => Some(Shell::PowerShell),
+ value if value == SUPPORTED_SHELLS[4] => Some(Shell::Zsh),
+ _ => None,
+ }
+}
+
+fn print_completion_help() {
+ eprintln!("usage: herdr completion <{}>", supported_shells_usage());
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn parses_supported_shells() {
+ assert!(matches!(
+ super::parse_shell("zsh"),
+ Some(clap_complete::Shell::Zsh)
+ ));
+ assert!(matches!(
+ super::parse_shell("powershell"),
+ Some(clap_complete::Shell::PowerShell)
+ ));
+ assert!(super::parse_shell("tcsh").is_none());
+ }
+
+ #[test]
+ fn zsh_long_options_use_space_separated_arguments() {
+ let script =
+ "'--cwd=[]:PATH:_files' \\\n'--json[]' \\\n'--split=[]:DIRECTION:(right down)' \\";
+ let normalized = super::space_separated_zsh_long_options(script);
+ assert!(normalized.contains("'--cwd[]:PATH:_files'"));
+ assert!(normalized.contains("'--json[]'"));
+ assert!(normalized.contains("'--split[]:DIRECTION:(right down)'"));
+ assert!(!normalized.contains("--cwd=[]"));
+ assert!(!normalized.contains("--split=[]"));
+ }
+}
diff --git a/src/cli/spec.rs b/src/cli/spec.rs
new file mode 100644
index 00000000..71ecd9cc
--- /dev/null
+++ b/src/cli/spec.rs
@@ -0,0 +1,964 @@
+use clap::{Arg, ArgAction, 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(flag("no-session").help("Run monolithically without server/client session mode"))
+ .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(
+ 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(wait_command())
+ .subcommand(terminal_command())
+ .subcommand(session_command())
+ .subcommand(integration_command())
+ .subcommand(plugin_command());
+ disable_auto_help(command)
+}
+
+fn disable_auto_help(command: Command) -> Command {
+ command
+ .disable_help_flag(true)
+ .mut_subcommands(disable_auto_help)
+}
+
+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("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")
+ .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(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(json_flag()),
+ )
+ .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(json_flag()),
+ )
+ .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(json_flag()),
+ )
+ .subcommand(
+ Command::new("remove")
+ .about("Remove a worktree checkout")
+ .arg(option("workspace", "ID"))
+ .arg(flag("force"))
+ .arg(json_flag()),
+ )
+}
+
+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")
+ .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")
+ .about("Send text to an agent")
+ .arg(required("target", "TARGET"))
+ .arg(required("text", "TEXT")),
+ )
+ .subcommand(
+ Command::new("rename")
+ .about("Rename an agent")
+ .arg(required("target", "TARGET"))
+ .arg(Arg::new("name").value_name("NAME"))
+ .arg(flag("clear")),
+ )
+ .subcommand(id_command("focus", "target", "Focus an agent"))
+ .subcommand(
+ Command::new("wait")
+ .about("Wait for an agent status")
+ .arg(required("target", "TARGET"))
+ .arg(agent_wait_status_option())
+ .arg(option("timeout", "MS")),
+ )
+ .subcommand(
+ Command::new("attach")
+ .about("Attach directly to an agent terminal")
+ .arg(required("target", "TARGET"))
+ .arg(flag("takeover")),
+ )
+ .subcommand(
+ Command::new("start")
+ .about("Start an agent command")
+ .arg(required("name", "NAME"))
+ .arg(path_option("cwd", "PATH"))
+ .arg(option("workspace", "ID"))
+ .arg(option("tab", "ID"))
+ .arg(split_option())
+ .arg(env_option())
+ .arg(flag("focus"))
+ .arg(flag("no-focus")),
+ )
+ .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),
+ ),
+ )
+}
+
+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(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(direction_option())
+ .args(current_pane_args()),
+ )
+ .subcommand(
+ Command::new("resize")
+ .about("Resize a pane split")
+ .arg(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("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(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")),
+ )
+ .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..)),
+ )
+ .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"))
+ .arg(option("agent", "LABEL"))
+ .arg(pane_agent_state_option("state"))
+ .arg(option("message", "TEXT"))
+ .arg(option("custom-status", "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"))
+ .arg(option("agent", "LABEL"))
+ .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"))
+ .arg(option("agent", "LABEL"))
+ .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"))
+ .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("custom-status", "TEXT"))
+ .arg(flag("clear-custom-status"))
+ .arg(option("state-label", "STATUS=TEXT"))
+ .arg(flag("clear-state-labels"))
+ .arg(option("seq", "N"))
+ .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")
+ .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("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([
+ "pi", "omp", "claude", "codex", "copilot", "devin", "droid", "kimi", "opencode",
+ "kilo", "hermes", "qodercli", "cursor",
+ ])
+}
+
+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 split_option() -> Arg {
+ option("split", "DIRECTION").value_parser(["right", "down"])
+}
+
+fn split_direction_option() -> Arg {
+ option("direction", "DIRECTION").value_parser(["right", "down"])
+}
+
+fn status_option(name: &'static str, required: bool) -> Arg {
+ option(name, "STATUS")
+ .required(required)
+ .value_parser(["idle", "working", "blocked", "done", "unknown"])
+}
+
+fn agent_wait_status_option() -> Arg {
+ option("status", "STATUS")
+ .required(true)
+ .value_parser(["idle", "working", "blocked", "unknown"])
+}
+
+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)
+}
+
+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 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::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 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_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_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_includes_agent_status_values() {
+ let cmd = super::command();
+ let wait = command_path(&cmd, &["agent", "wait"]);
+ let values = option_values(wait, "status");
+ assert!(values.contains(&"idle".to_string()));
+ assert!(values.contains(&"working".to_string()));
+ assert!(values.contains(&"blocked".to_string()));
+ assert!(!values.contains(&"done".to_string()));
+ }
+
+ #[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_does_not_complete_agent_start_argv_without_separator() {
+ let cmd = super::command();
+ let agent_start = command_path(&cmd, &["agent", "start"]);
+ assert!(!agent_start
+ .get_arguments()
+ .any(|arg| arg.get_id() == "argv"));
+ }
+
+ #[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"));
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 8fd60ef6..97546e13 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -490,11 +490,13 @@ fn main() -> io::Result<()> {
println!(" herdr --session <name> [options]");
println!(" herdr --remote <ssh-target> [--session <name>]");
println!(" herdr session attach <name>");
+ println!(" herdr completion zsh");
println!(" herdr update [--handoff]");
println!(" herdr channel set <stable|preview>");
println!(" herdr server stop");
println!(" herdr server reload-config");
println!(" herdr api <subcommand> ...");
+ println!(" herdr completion <shell>");
println!(" herdr config <subcommand> ...");
println!(" herdr channel <subcommand> ...");
println!(" herdr workspace <subcommand> ...");
@@ -515,6 +517,7 @@ fn main() -> io::Result<()> {
"Show local client and running server status",
),
("herdr update", "Download and install the latest version"),
+ ("herdr completion zsh", "Generate shell completions for zsh"),
(
"herdr server stop",
"Stop the running server via the API socket",
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index ea402515..99d4f107 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -1031,6 +1031,7 @@ fn help_commands_exit_successfully() {
&["--help"],
&["api", "-h"],
&["api", "schema", "-h"],
+ &["completion", "-h"],
&["status", "-h"],
&["server", "-h"],
&["workspace", "-h"],
@@ -1060,6 +1061,46 @@ fn help_commands_exit_successfully() {
}
#[test]
+fn completion_command_prints_zsh_script_without_session_startup() {
+ let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
+ .args(["completion", "zsh"])
+ .env_remove("HERDR_SOCKET_PATH")
+ .env_remove("HERDR_CLIENT_SOCKET_PATH")
+ .env_remove("HERDR_ENV")
+ .output()
+ .unwrap();
+
+ assert!(
+ output.status.success(),
+ "status={:?} stderr={}",
+ output.status.code(),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ assert!(stdout.contains("#compdef herdr"), "stdout: {stdout}");
+ assert!(
+ stdout.contains("bash elvish fish powershell zsh"),
+ "stdout: {stdout}"
+ );
+ assert!(
+ stdout.contains("'--direction[]:DIRECTION:(right down)'"),
+ "pane split/plugin pane open should complete --direction right|down without requiring equals: {stdout}"
+ );
+ assert!(
+ !stdout.contains("--cwd=[]"),
+ "zsh completions should not suggest equals-style values unsupported by most manual parsers: {stdout}"
+ );
+ assert!(
+ !stdout.contains("--direction=[]"),
+ "zsh completions should not suggest equals-style direction values: {stdout}"
+ );
+ assert!(
+ !stdout.contains("live-handoff"),
+ "internal server handoff command should not be completed: {stdout}"
+ );
+}
+
+#[test]
fn root_help_hides_explicit_client_command() {
let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
.arg("--help")