diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-30 17:47:12 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-30 17:49:37 +0300 |
| commit | 13cb5d9ae6aa314744ce5362b5d156182f06b503 (patch) | |
| tree | 8153bf4ffd1035bdc8b3dff7753fe87f86ba44dd | |
| parent | b7015f17a49501e99642009ebadabcbefc0fbad0 (diff) | |
feat: add shell completions
refs #435
| -rw-r--r-- | Cargo.lock | 42 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/next/CHANGELOG.md | 1 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/cli-reference.mdx | 35 | ||||
| -rw-r--r-- | src/cli.rs | 3 | ||||
| -rw-r--r-- | src/cli/completion.rs | 113 | ||||
| -rw-r--r-- | src/cli/spec.rs | 964 | ||||
| -rw-r--r-- | src/main.rs | 3 | ||||
| -rw-r--r-- | tests/cli_wrapper.rs | 41 |
9 files changed, 1204 insertions, 0 deletions
@@ -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", @@ -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 @@ -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") |
