refactor: share cli and api command contracts (#5212)

* refactor: share cli and api command contracts

* fix: address cli contract review findings
This commit is contained in:
Can Celik
2026-10-13 04:25:38 +03:00
committed by GitHub
parent 212b710b92
commit 241b99cd39
45 changed files with 6868 additions and 5611 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ ghostty-vt = { path = "crates/ghostty-vt" }
base64 = "0.22.1"
bincode = { version = "2", features = ["serde"] }
bytes = "1"
clap = { version = "4.5", default-features = false, features = ["std", "help", "usage"] }
clap = { version = "4.5", default-features = false, features = ["std", "help", "usage", "error-context", "string"] }
clap_complete = "4.5"
crossterm = "0.29"
ctrlc = { version = "3", features = ["termination"] }
@@ -7,6 +7,8 @@ Herdr’s CLI talks to the running server over the same local socket API used by
Most commands print JSON responses for deterministic automation in scripts.
API-backed commands use the same argument definitions for parsing, `--help`, and shell completion. Value options accept both `--label text` and `--label=text`. Invalid arguments are rejected before connecting to the server.
## Launch and status
```bash
@@ -7,6 +7,8 @@ Herdr の CLI は、インテグレーションやエージェントが使うの
ほとんどのコマンドは JSON レスポンスを出力します。決定的な自動化が欲しいときはスクリプトから使ってください。
API を呼び出すコマンドでは、引数の解析、`--help`、シェル補完が同じ引数定義を使います。値を取るオプションは `--label text` と `--label=text` の両方を受け付けます。不正な引数はサーバーに接続する前に拒否されます。
## 起動とステータス
```bash
@@ -7,6 +7,8 @@ Herdr 的 CLI 通过与集成和智能体相同的本地 socket API 与运行中
大多数命令输出 JSON 响应。需要确定性自动化时,从脚本中使用它们。
调用 API 的命令使用同一组参数定义进行解析、显示 `--help` 和生成 shell 补全。带值的选项同时接受 `--label text` 和 `--label=text`。无效参数会在连接服务器之前被拒绝。
## 启动与状态
```bash
+2 -59
View File
@@ -15,69 +15,12 @@ use std::path::PathBuf;
use tokio::sync::mpsc;
use crate::api::schema::{Method, Request};
use crate::api::schema::Request;
pub const SOCKET_PATH_ENV_VAR: &str = "HERDR_SOCKET_PATH";
pub(crate) fn request_changes_ui(request: &Request) -> bool {
matches!(
&request.method,
Method::ServerReloadConfig(_)
| Method::ServerReloadAgentManifests(_)
| Method::NotificationShow(_)
| Method::ProductAnnouncementDismiss(_)
| Method::ReleaseNotesDismiss(_)
| Method::CommandInvoke(_)
| Method::WorkspaceCreate(_)
| Method::WorkspaceFocus(_)
| Method::WorkspaceRename(_)
| Method::WorkspaceMove(_)
| Method::WorkspaceMoveBlock(_)
| Method::WorkspaceReportMetadata(_)
| Method::WorkspaceClose(_)
| Method::WorktreeCreate(_)
| Method::WorktreeOpen(_)
| Method::WorktreeRemove(_)
| Method::TabCreate(_)
| Method::TabFocus(_)
| Method::TabRename(_)
| Method::TabMove(_)
| Method::TabClose(_)
| Method::LayoutApply(_)
| Method::LayoutSetSplitRatio(_)
| Method::AgentRename(_)
| Method::AgentViewSet(_)
| Method::AgentViewClear(_)
| Method::AgentFocus(_)
| Method::AgentStart(_)
| Method::AgentPrompt(_)
| Method::AgentSendKeys(_)
| Method::PaneSplit(_)
| Method::PaneSwap(_)
| Method::PaneMove(_)
| Method::PaneZoom(_)
| Method::PaneFocusDirection(_)
| Method::PaneResize(_)
| Method::PaneScroll(_)
| Method::PaneClear(_)
| Method::PaneEditScrollback(_)
| Method::PaneFocus(_)
| Method::PaneInputSet(_)
| Method::PaneRename(_)
| Method::PaneReportAgent(_)
| Method::PaneReportAgentSession(_)
| Method::PaneReportMetadata(_)
| Method::PaneClearAgentAuthority(_)
| Method::PaneReleaseAgent(_)
| Method::PaneClose(_)
| Method::PopupClose(_)
| Method::PluginUnlink(_)
| Method::PluginDisable(_)
| Method::PluginActionInvoke(_)
| Method::PluginPaneOpen(_)
| Method::PluginPaneFocus(_)
| Method::PluginPaneClose(_)
)
request.method.operation().changes_ui
}
pub struct ApiRequestMessage {
+106 -210
View File
@@ -5,6 +5,7 @@ pub mod commands;
pub mod common;
pub mod events;
pub mod integrations;
pub(crate) mod operations;
pub mod panes;
pub mod plugins;
pub mod response;
@@ -39,216 +40,111 @@ pub struct Request {
pub method: Method,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(tag = "method", content = "params")]
// Request enums are short-lived wire values; keeping variants direct preserves
// the simple serde shape and avoids boxing churn across every caller.
#[allow(clippy::large_enum_variant)]
pub enum Method {
#[serde(rename = "ping")]
Ping(PingParams),
#[serde(rename = "server.stop")]
ServerStop(EmptyParams),
#[serde(rename = "server.live_handoff")]
ServerLiveHandoff(ServerLiveHandoffParams),
#[serde(rename = "server.reload_config")]
ServerReloadConfig(EmptyParams),
#[serde(rename = "server.ssh_agent.register")]
ServerSshAgentRegister(ServerSshAgentRegisterParams),
#[serde(rename = "server.agent_manifests")]
ServerAgentManifests(EmptyParams),
#[serde(rename = "server.reload_agent_manifests")]
ServerReloadAgentManifests(EmptyParams),
#[serde(rename = "notification.show")]
NotificationShow(NotificationShowParams),
#[serde(rename = "product_announcement.dismiss")]
ProductAnnouncementDismiss(ProductAnnouncementDismissParams),
#[serde(rename = "release_notes.dismiss")]
ReleaseNotesDismiss(ReleaseNotesDismissParams),
#[serde(rename = "command.invoke")]
CommandInvoke(CommandInvokeParams),
#[serde(rename = "client.window_title.set")]
ClientWindowTitleSet(ClientWindowTitleSetParams),
#[serde(rename = "client.window_title.clear")]
ClientWindowTitleClear(EmptyParams),
#[serde(rename = "client_shell.surface.set")]
ClientShellSurfaceSet(ClientShellSurfaceSetParams),
#[serde(rename = "session.snapshot")]
SessionSnapshot(EmptyParams),
#[serde(rename = "workspace.create")]
WorkspaceCreate(WorkspaceCreateParams),
#[serde(rename = "workspace.list")]
WorkspaceList(EmptyParams),
#[serde(rename = "workspace.get")]
WorkspaceGet(WorkspaceTarget),
#[serde(rename = "workspace.focus")]
WorkspaceFocus(WorkspaceTarget),
#[serde(rename = "workspace.rename")]
WorkspaceRename(WorkspaceRenameParams),
#[serde(rename = "workspace.move")]
WorkspaceMove(WorkspaceMoveParams),
#[serde(rename = "workspace.move_block")]
WorkspaceMoveBlock(WorkspaceMoveBlockParams),
#[serde(rename = "workspace.report_metadata")]
WorkspaceReportMetadata(WorkspaceReportMetadataParams),
#[serde(rename = "workspace.close")]
WorkspaceClose(WorkspaceCloseParams),
#[serde(rename = "worktree.list")]
WorktreeList(WorktreeListParams),
#[serde(rename = "worktree.create")]
WorktreeCreate(WorktreeCreateParams),
#[serde(rename = "worktree.open")]
WorktreeOpen(WorktreeOpenParams),
#[serde(rename = "worktree.remove")]
WorktreeRemove(WorktreeRemoveParams),
#[serde(rename = "tab.create")]
TabCreate(TabCreateParams),
#[serde(rename = "tab.list")]
TabList(TabListParams),
#[serde(rename = "tab.get")]
TabGet(TabTarget),
#[serde(rename = "tab.focus")]
TabFocus(TabTarget),
#[serde(rename = "tab.rename")]
TabRename(TabRenameParams),
#[serde(rename = "tab.move")]
TabMove(TabMoveParams),
#[serde(rename = "tab.close")]
TabClose(TabTarget),
#[serde(rename = "agent.list")]
AgentList(EmptyParams),
#[serde(rename = "agent.get")]
AgentGet(AgentTarget),
#[serde(rename = "agent.read")]
AgentRead(AgentReadParams),
#[serde(rename = "agent.explain")]
AgentExplain(AgentTarget),
#[serde(rename = "agent.send_keys")]
AgentSendKeys(AgentSendKeysParams),
#[serde(rename = "agent.rename")]
AgentRename(AgentRenameParams),
#[serde(rename = "agent.view.set")]
AgentViewSet(AgentViewSetParams),
#[serde(rename = "agent.view.clear")]
AgentViewClear(AgentViewClearParams),
#[serde(rename = "agent.focus")]
AgentFocus(AgentTarget),
#[serde(rename = "agent.start")]
AgentStart(AgentStartParams),
#[serde(rename = "agent.prompt")]
AgentPrompt(AgentPromptParams),
#[serde(rename = "agent.wait")]
AgentWait(AgentWaitParams),
#[serde(rename = "pane.split")]
PaneSplit(PaneSplitParams),
#[serde(rename = "pane.swap")]
PaneSwap(PaneSwapParams),
#[serde(rename = "pane.move")]
PaneMove(PaneMoveParams),
#[serde(rename = "pane.zoom")]
PaneZoom(PaneZoomParams),
#[serde(rename = "pane.layout")]
PaneLayout(PaneLayoutParams),
#[serde(rename = "pane.process_info")]
PaneProcessInfo(PaneProcessInfoParams),
#[serde(rename = "layout.export")]
LayoutExport(LayoutExportParams),
#[serde(rename = "layout.apply")]
LayoutApply(LayoutApplyParams),
#[serde(rename = "layout.set_split_ratio")]
LayoutSetSplitRatio(LayoutSetSplitRatioParams),
#[serde(rename = "pane.neighbor")]
PaneNeighbor(PaneNeighborParams),
#[serde(rename = "pane.edges")]
PaneEdges(PaneEdgesParams),
#[serde(rename = "pane.focus_direction")]
PaneFocusDirection(PaneFocusDirectionParams),
#[serde(rename = "pane.resize")]
PaneResize(PaneResizeParams),
#[serde(rename = "pane.scroll")]
PaneScroll(PaneScrollParams),
#[serde(rename = "pane.clear")]
PaneClear(PaneTarget),
#[serde(rename = "pane.edit_scrollback")]
PaneEditScrollback(PaneTarget),
#[serde(rename = "pane.selection.read")]
PaneSelectionRead(PaneSelectionReadParams),
#[serde(rename = "pane.copy_motion")]
PaneCopyMotion(PaneCopyMotionParams),
#[serde(rename = "pane.copy_search")]
PaneCopySearch(PaneCopySearchParams),
#[serde(rename = "pane.list")]
PaneList(PaneListParams),
#[serde(rename = "pane.current")]
PaneCurrent(PaneCurrentParams),
#[serde(rename = "pane.get")]
PaneGet(PaneTarget),
#[serde(rename = "pane.focus")]
PaneFocus(PaneTarget),
#[serde(rename = "pane.input.set")]
PaneInputSet(PaneInputSetParams),
#[serde(rename = "pane.link.activate")]
PaneLinkActivate(PaneLinkActivateParams),
#[serde(rename = "pane.link.resolve")]
PaneLinkResolve(PaneLinkActivateParams),
#[serde(rename = "pane.rename")]
PaneRename(PaneRenameParams),
#[serde(rename = "pane.send_text")]
PaneSendText(PaneSendTextParams),
#[serde(rename = "pane.send_keys")]
PaneSendKeys(PaneSendKeysParams),
#[serde(rename = "pane.send_input")]
PaneSendInput(PaneSendInputParams),
#[serde(rename = "pane.read")]
PaneRead(PaneReadParams),
#[serde(rename = "pane.report_agent")]
PaneReportAgent(PaneReportAgentParams),
#[serde(rename = "pane.report_agent_session")]
PaneReportAgentSession(PaneReportAgentSessionParams),
#[serde(rename = "pane.report_metadata")]
PaneReportMetadata(PaneReportMetadataParams),
#[serde(rename = "pane.clear_agent_authority")]
PaneClearAgentAuthority(PaneClearAgentAuthorityParams),
#[serde(rename = "pane.release_agent")]
PaneReleaseAgent(PaneReleaseAgentParams),
#[serde(rename = "pane.close")]
PaneClose(PaneTarget),
#[serde(rename = "popup.close")]
PopupClose(EmptyParams),
#[serde(rename = "events.subscribe")]
EventsSubscribe(EventsSubscribeParams),
#[serde(rename = "events.wait")]
EventsWait(EventsWaitParams),
#[serde(rename = "pane.wait_for_output")]
PaneWaitForOutput(PaneWaitForOutputParams),
#[serde(rename = "integration.list")]
IntegrationList(EmptyParams),
#[serde(rename = "integration.install")]
IntegrationInstall(IntegrationInstallParams),
#[serde(rename = "integration.uninstall")]
IntegrationUninstall(IntegrationUninstallParams),
#[serde(rename = "plugin.link")]
PluginLink(PluginLinkParams),
#[serde(rename = "plugin.list")]
PluginList(PluginListParams),
#[serde(rename = "plugin.unlink")]
PluginUnlink(PluginUnlinkParams),
#[serde(rename = "plugin.enable")]
PluginEnable(PluginSetEnabledParams),
#[serde(rename = "plugin.disable")]
PluginDisable(PluginSetEnabledParams),
#[serde(rename = "plugin.action.list")]
PluginActionList(PluginActionListParams),
#[serde(rename = "plugin.action.invoke")]
PluginActionInvoke(PluginActionInvokeParams),
#[serde(rename = "plugin.log.list")]
PluginLogList(PluginLogListParams),
#[serde(rename = "plugin.pane.open")]
PluginPaneOpen(PluginPaneOpenParams),
#[serde(rename = "plugin.pane.focus")]
PluginPaneFocus(PluginPaneFocusParams),
#[serde(rename = "plugin.pane.close")]
PluginPaneClose(PluginPaneCloseParams),
// Variant order and parameter types preserve the existing wire contract.
// The two flags are independent: presentation impact, then client-shell admission.
operations::define_operations! {
Ping(PingParams) => "ping", false, false;
ServerStop(EmptyParams) => "server.stop", false, false;
ServerLiveHandoff(ServerLiveHandoffParams) => "server.live_handoff", false, false;
ServerReloadConfig(EmptyParams) => "server.reload_config", true, true;
ServerSshAgentRegister(ServerSshAgentRegisterParams) => "server.ssh_agent.register", false, false;
ServerAgentManifests(EmptyParams) => "server.agent_manifests", false, false;
ServerReloadAgentManifests(EmptyParams) => "server.reload_agent_manifests", true, false;
NotificationShow(NotificationShowParams) => "notification.show", true, false;
ProductAnnouncementDismiss(ProductAnnouncementDismissParams) => "product_announcement.dismiss", true, true;
ReleaseNotesDismiss(ReleaseNotesDismissParams) => "release_notes.dismiss", true, true;
CommandInvoke(CommandInvokeParams) => "command.invoke", true, true;
ClientWindowTitleSet(ClientWindowTitleSetParams) => "client.window_title.set", false, false;
ClientWindowTitleClear(EmptyParams) => "client.window_title.clear", false, false;
ClientShellSurfaceSet(ClientShellSurfaceSetParams) => "client_shell.surface.set", false, true;
SessionSnapshot(EmptyParams) => "session.snapshot", false, false;
WorkspaceCreate(WorkspaceCreateParams) => "workspace.create", true, true;
WorkspaceList(EmptyParams) => "workspace.list", false, false;
WorkspaceGet(WorkspaceTarget) => "workspace.get", false, false;
WorkspaceFocus(WorkspaceTarget) => "workspace.focus", true, true;
WorkspaceRename(WorkspaceRenameParams) => "workspace.rename", true, true;
WorkspaceMove(WorkspaceMoveParams) => "workspace.move", true, true;
WorkspaceMoveBlock(WorkspaceMoveBlockParams) => "workspace.move_block", true, true;
WorkspaceReportMetadata(WorkspaceReportMetadataParams) => "workspace.report_metadata", true, false;
WorkspaceClose(WorkspaceCloseParams) => "workspace.close", true, true;
WorktreeList(WorktreeListParams) => "worktree.list", false, true;
WorktreeCreate(WorktreeCreateParams) => "worktree.create", true, true;
WorktreeOpen(WorktreeOpenParams) => "worktree.open", true, true;
WorktreeRemove(WorktreeRemoveParams) => "worktree.remove", true, true;
TabCreate(TabCreateParams) => "tab.create", true, true;
TabList(TabListParams) => "tab.list", false, false;
TabGet(TabTarget) => "tab.get", false, false;
TabFocus(TabTarget) => "tab.focus", true, true;
TabRename(TabRenameParams) => "tab.rename", true, true;
TabMove(TabMoveParams) => "tab.move", true, true;
TabClose(TabTarget) => "tab.close", true, true;
AgentList(EmptyParams) => "agent.list", false, false;
AgentGet(AgentTarget) => "agent.get", false, false;
AgentRead(AgentReadParams) => "agent.read", false, false;
AgentExplain(AgentTarget) => "agent.explain", false, false;
AgentSendKeys(AgentSendKeysParams) => "agent.send_keys", true, false;
AgentRename(AgentRenameParams) => "agent.rename", true, false;
AgentViewSet(AgentViewSetParams) => "agent.view.set", true, false;
AgentViewClear(AgentViewClearParams) => "agent.view.clear", true, false;
AgentFocus(AgentTarget) => "agent.focus", true, false;
AgentStart(AgentStartParams) => "agent.start", true, false;
AgentPrompt(AgentPromptParams) => "agent.prompt", true, false;
AgentWait(AgentWaitParams) => "agent.wait", false, false;
PaneSplit(PaneSplitParams) => "pane.split", true, true;
PaneSwap(PaneSwapParams) => "pane.swap", true, true;
PaneMove(PaneMoveParams) => "pane.move", true, false;
PaneZoom(PaneZoomParams) => "pane.zoom", true, true;
PaneLayout(PaneLayoutParams) => "pane.layout", false, false;
PaneProcessInfo(PaneProcessInfoParams) => "pane.process_info", false, false;
LayoutExport(LayoutExportParams) => "layout.export", false, false;
LayoutApply(LayoutApplyParams) => "layout.apply", true, false;
LayoutSetSplitRatio(LayoutSetSplitRatioParams) => "layout.set_split_ratio", true, true;
PaneNeighbor(PaneNeighborParams) => "pane.neighbor", false, false;
PaneEdges(PaneEdgesParams) => "pane.edges", false, false;
PaneFocusDirection(PaneFocusDirectionParams) => "pane.focus_direction", true, true;
PaneResize(PaneResizeParams) => "pane.resize", true, true;
PaneScroll(PaneScrollParams) => "pane.scroll", true, true;
PaneClear(PaneTarget) => "pane.clear", true, true;
PaneEditScrollback(PaneTarget) => "pane.edit_scrollback", true, true;
PaneSelectionRead(PaneSelectionReadParams) => "pane.selection.read", false, true;
PaneCopyMotion(PaneCopyMotionParams) => "pane.copy_motion", false, true;
PaneCopySearch(PaneCopySearchParams) => "pane.copy_search", false, true;
PaneList(PaneListParams) => "pane.list", false, false;
PaneCurrent(PaneCurrentParams) => "pane.current", false, false;
PaneGet(PaneTarget) => "pane.get", false, false;
PaneFocus(PaneTarget) => "pane.focus", true, true;
PaneInputSet(PaneInputSetParams) => "pane.input.set", true, true;
PaneLinkActivate(PaneLinkActivateParams) => "pane.link.activate", false, true;
PaneLinkResolve(PaneLinkActivateParams) => "pane.link.resolve", false, true;
PaneRename(PaneRenameParams) => "pane.rename", true, true;
PaneSendText(PaneSendTextParams) => "pane.send_text", false, false;
PaneSendKeys(PaneSendKeysParams) => "pane.send_keys", false, false;
PaneSendInput(PaneSendInputParams) => "pane.send_input", false, false;
PaneRead(PaneReadParams) => "pane.read", false, false;
PaneReportAgent(PaneReportAgentParams) => "pane.report_agent", true, false;
PaneReportAgentSession(PaneReportAgentSessionParams) => "pane.report_agent_session", true, false;
PaneReportMetadata(PaneReportMetadataParams) => "pane.report_metadata", true, false;
PaneClearAgentAuthority(PaneClearAgentAuthorityParams) => "pane.clear_agent_authority", true, false;
PaneReleaseAgent(PaneReleaseAgentParams) => "pane.release_agent", true, false;
PaneClose(PaneTarget) => "pane.close", true, true;
PopupClose(EmptyParams) => "popup.close", true, false;
EventsSubscribe(EventsSubscribeParams) => "events.subscribe", false, false;
EventsWait(EventsWaitParams) => "events.wait", false, false;
PaneWaitForOutput(PaneWaitForOutputParams) => "pane.wait_for_output", false, false;
IntegrationList(EmptyParams) => "integration.list", false, true;
IntegrationInstall(IntegrationInstallParams) => "integration.install", false, true;
IntegrationUninstall(IntegrationUninstallParams) => "integration.uninstall", false, false;
PluginLink(PluginLinkParams) => "plugin.link", false, false;
PluginList(PluginListParams) => "plugin.list", false, false;
PluginUnlink(PluginUnlinkParams) => "plugin.unlink", true, false;
PluginEnable(PluginSetEnabledParams) => "plugin.enable", false, false;
PluginDisable(PluginSetEnabledParams) => "plugin.disable", true, false;
PluginActionList(PluginActionListParams) => "plugin.action.list", false, false;
PluginActionInvoke(PluginActionInvokeParams) => "plugin.action.invoke", true, false;
PluginLogList(PluginLogListParams) => "plugin.log.list", false, false;
PluginPaneOpen(PluginPaneOpenParams) => "plugin.pane.open", true, false;
PluginPaneFocus(PluginPaneFocusParams) => "plugin.pane.focus", true, false;
PluginPaneClose(PluginPaneCloseParams) => "plugin.pane.close", true, false;
}
#[cfg(test)]
+74 -51
View File
@@ -3,50 +3,68 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::common::{AgentStatus, ReadFormat, ReadSource};
use crate::cli::agent::bindings;
use crate::cli::projection::{cli_params, Field};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AgentReadParams {
pub target: String,
pub source: ReadSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lines: Option<u32>,
#[serde(default)]
pub format: ReadFormat,
#[serde(default = "super::common::default_true")]
pub strip_ansi: bool,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AgentReadParams => AgentReadProjection {
pub target: String => Field::new(String::new()).input(bindings::target()),
pub source: ReadSource => Field::new(ReadSource::Recent).input(bindings::source()),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lines: Option<u32> => Field::new(None).input(bindings::lines()),
#[serde(default)]
pub format: ReadFormat => Field::new(ReadFormat::Text)
.input(bindings::format()).input(bindings::ansi()),
#[serde(default = "super::common::default_true")]
pub strip_ansi: bool => Field::new(true),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AgentSendKeysParams {
pub target: String,
pub keys: Vec<String>,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AgentSendKeysParams => AgentSendKeysProjection {
pub target: String => Field::new(String::new()).input(bindings::target()),
pub keys: Vec<String> => Field::new(Vec::new()).input(bindings::keys()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AgentWaitParams {
pub target: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub until: Vec<AgentStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AgentWaitParams => AgentWaitProjection {
pub target: String => Field::new(String::new()).input(bindings::target()),
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub until: Vec<AgentStatus> => Field::new(Vec::new()).input(bindings::until()
.help("State to match; repeat for more than one state")),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64> => Field::new(None).input(bindings::timeout()
.help("Fail after this many milliseconds")),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AgentPromptWaitOptions {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub until: Vec<AgentStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
#[serde(skip)]
#[schemars(skip)]
pub(crate) submission_deadline: Option<std::time::Instant>,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AgentPromptWaitOptions => AgentPromptWaitProjection {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub until: Vec<AgentStatus> => Field::new(Vec::new()).input(bindings::until()
.help("State to match after --wait; repeat for more than one state")),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64> => Field::new(None).input(bindings::timeout()
.help("Fail after this many milliseconds")),
#[serde(skip)]
#[schemars(skip)]
pub(crate) submission_deadline: Option<std::time::Instant> => Field::new(None::<std::time::Instant>),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AgentRenameParams {
pub target: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AgentRenameParams => AgentRenameProjection {
pub target: String => Field::new(String::new()).input(bindings::target()),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String> => Field::new(None)
.input(bindings::rename_name()).input(bindings::clear_name()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
@@ -163,24 +181,29 @@ pub enum AgentViewSortOrder {
Desc,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AgentStartParams {
pub name: String,
pub kind: String,
pub pane_id: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
/// Startup timeout in milliseconds. Values must be greater than 3000 and at most 300000.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AgentStartParams => AgentStartProjection {
pub name: String => Field::new(String::new()).input(bindings::text_position("name", "NAME", 0)),
pub kind: String => Field::new(String::new()).input(bindings::kind()),
pub pane_id: String => Field::new(String::new()).input(bindings::pane()),
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String> => Field::new(Vec::new()).input(bindings::agent_args()),
/// Startup timeout in milliseconds. Values must be greater than 3000 and at most 300000.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64> => Field::new(None).input(bindings::timeout()
.help("Wait for interactive readiness (default: 30000; max: 300000)")),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AgentPromptParams {
pub target: String,
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait: Option<AgentPromptWaitOptions>,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AgentPromptParams => AgentPromptProjection {
pub target: String => Field::new(String::new()).input(bindings::target()),
pub text: String => Field::new(String::new()).input(bindings::text_position("text", "TEXT", 1).verbatim()),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait: Option<AgentPromptWaitOptions> => Field::new(None).input(bindings::wait_presence()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+99 -12
View File
@@ -56,9 +56,16 @@ pub struct AgentTarget {
pub target: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ClientWindowTitleSetParams {
pub title: String,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ClientWindowTitleSetParams => ClientWindowTitleSetProjection {
pub title: String => crate::cli::projection::Field::new(String::new()).input(
crate::cli::projection::Input::positional("title", 0, |title, value| {
*title = value.to_owned();
Ok(())
}).value_name("TITLE").verbatim()
),
}
}
/// Updates whether the requesting client shell receives and controls pane presentation.
@@ -83,6 +90,14 @@ pub enum ReadSource {
Detection,
}
pub(crate) const READ_SOURCES: &[crate::cli::projection::Choice<ReadSource>] = &[
crate::cli::projection::Choice::new("visible", ReadSource::Visible),
crate::cli::projection::Choice::new("recent", ReadSource::Recent),
crate::cli::projection::Choice::new("recent-unwrapped", ReadSource::RecentUnwrapped),
crate::cli::projection::Choice::hidden("recent_unwrapped", ReadSource::RecentUnwrapped),
crate::cli::projection::Choice::new("detection", ReadSource::Detection),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum ReadIntent {
#[default]
@@ -100,15 +115,87 @@ pub enum ReadFormat {
Ansi,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct NotificationShowParams {
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position: Option<crate::config::ToastHerdrPosition>,
#[serde(default, skip_serializing_if = "NotificationShowSound::is_none")]
pub sound: NotificationShowSound,
pub(crate) const READ_FORMATS: &[crate::cli::projection::Choice<ReadFormat>] = &[
crate::cli::projection::Choice::new("text", ReadFormat::Text),
crate::cli::projection::Choice::new("ansi", ReadFormat::Ansi),
];
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct NotificationShowParams => NotificationShowProjection {
pub title: String => notification_cli::title(),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String> => notification_cli::body(),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position: Option<crate::config::ToastHerdrPosition> => notification_cli::position(),
#[serde(default, skip_serializing_if = "NotificationShowSound::is_none")]
pub sound: NotificationShowSound => notification_cli::sound(),
}
}
mod notification_cli {
use super::NotificationShowSound;
use crate::cli::projection::{Field, Input, ParseError};
use crate::config::ToastHerdrPosition;
const POSITIONS: [(&str, ToastHerdrPosition); 4] = [
("top-left", ToastHerdrPosition::TopLeft),
("top-right", ToastHerdrPosition::TopRight),
("bottom-left", ToastHerdrPosition::BottomLeft),
("bottom-right", ToastHerdrPosition::BottomRight),
];
const SOUNDS: [(&str, NotificationShowSound); 3] = [
("none", NotificationShowSound::None),
("done", NotificationShowSound::Done),
("request", NotificationShowSound::Request),
];
pub(super) fn title() -> Field<String> {
Field::new(String::new()).input(
Input::positional("title", 0, |slot, value| {
*slot = value.to_owned();
Ok(())
})
.value_name("TITLE")
.verbatim(),
)
}
pub(super) fn body() -> Field<Option<String>> {
Field::new(None)
.input(crate::cli::workspace_bindings::optional_text("body", "TEXT").order(0))
}
pub(super) fn position() -> Field<Option<ToastHerdrPosition>> {
Field::new(None).input(Input::value("position", "POSITION", |slot, value| {
*slot = Some(POSITIONS.iter().find(|(name, _)| *name == value).map(|(_, value)| *value)
.ok_or_else(|| ParseError::Usage(format!("invalid position: {value} (expected top-left, top-right, bottom-left, or bottom-right)")))?);
Ok(())
}).configure(|arg| arg.value_parser(clap::builder::PossibleValuesParser::new(POSITIONS.iter().map(|(name, _)| *name)))).order(1))
}
pub(super) fn sound() -> Field<NotificationShowSound> {
Field::new(NotificationShowSound::None).input(
Input::value("sound", "SOUND", |slot, value| {
*slot = SOUNDS
.iter()
.find(|(name, _)| *name == value)
.map(|(_, value)| *value)
.ok_or_else(|| {
ParseError::Usage(format!(
"invalid sound: {value} (expected none, done, or request)"
))
})?;
Ok(())
})
.configure(|arg| {
arg.value_parser(clap::builder::PossibleValuesParser::new(
SOUNDS.iter().map(|(name, _)| *name),
))
})
.order(2),
)
}
}
#[derive(
+218
View File
@@ -0,0 +1,218 @@
use std::sync::LazyLock;
#[derive(Debug, Clone, Copy)]
pub(crate) struct Operation {
pub name: &'static str,
pub changes_ui: bool,
pub client_shell: bool,
}
macro_rules! define_operations {
($( $variant:ident($params:ty) => $name:literal, $changes_ui:literal, $client_shell:literal; )*) => {
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
#[serde(tag = "method", content = "params")]
// Request enums are short-lived wire values; keeping variants direct preserves
// the simple serde shape and avoids boxing churn across every caller.
#[allow(clippy::large_enum_variant)]
pub enum Method {
$(#[serde(rename = $name)] $variant($params),)*
}
impl Method {
pub(crate) const OPERATIONS: &'static [operations::Operation] = &[
$(operations::Operation {
name: $name,
changes_ui: $changes_ui,
client_shell: $client_shell,
},)*
];
pub(crate) fn operation(&self) -> operations::Operation {
match self {
$(Self::$variant(_) => operations::Operation {
name: $name,
changes_ui: $changes_ui,
client_shell: $client_shell,
},)*
}
}
}
};
}
pub(super) use define_operations;
pub(crate) fn client_shell_method_names() -> &'static [&'static str] {
static NAMES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
let mut names: Vec<_> = super::Method::OPERATIONS
.iter()
.filter(|operation| operation.client_shell)
.map(|operation| operation.name)
.collect();
names.sort_unstable();
names
});
&NAMES
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::super::{Method, Request};
fn sample_value(
schema: &serde_json::Value,
definitions: &serde_json::Value,
) -> serde_json::Value {
if let Some(reference) = schema.get("$ref").and_then(serde_json::Value::as_str) {
return sample_value(
&definitions[reference.rsplit('/').next().unwrap()],
definitions,
);
}
if let Some(value) = schema.get("const") {
return value.clone();
}
if let Some(values) = schema.get("enum").and_then(serde_json::Value::as_array) {
return values[0].clone();
}
for key in ["oneOf", "anyOf"] {
if let Some(branches) = schema.get(key).and_then(serde_json::Value::as_array) {
return sample_value(&branches[0], definitions);
}
}
match schema.get("type").and_then(serde_json::Value::as_str) {
Some("object") => {
let mut object = serde_json::Map::new();
if let Some(required) = schema.get("required").and_then(serde_json::Value::as_array)
{
for name in required {
let name = name.as_str().unwrap();
object.insert(
name.into(),
sample_value(&schema["properties"][name], definitions),
);
}
}
serde_json::Value::Object(object)
}
Some("array") => serde_json::json!([]),
Some("string") => serde_json::json!("sample"),
Some("integer" | "number") => serde_json::json!(1),
Some("boolean") => serde_json::json!(false),
_ => serde_json::Value::Null,
}
}
#[test]
fn operation_catalog_matches_every_wire_method() {
let schema = serde_json::to_value(schemars::schema_for!(Request)).unwrap();
let branches = schema["oneOf"].as_array().unwrap();
let names: BTreeSet<_> = Method::OPERATIONS
.iter()
.map(|operation| operation.name)
.collect();
assert_eq!(
names.len(),
Method::OPERATIONS.len(),
"duplicate operation name"
);
assert_eq!(branches.len(), names.len());
for branch in branches {
let name = branch["properties"]["method"]["const"].as_str().unwrap();
assert!(names.contains(name), "missing operation {name}");
let request: Request = serde_json::from_value(serde_json::json!({
"id": "catalog",
"method": name,
"params": sample_value(&branch["properties"]["params"], &schema["$defs"]),
}))
.unwrap_or_else(|error| panic!("sample for {name}: {error}"));
let operation = request.method.operation();
assert_eq!(operation.name, name);
assert_eq!(crate::api::api_method_name(&request.method), name);
assert_eq!(serde_json::to_value(&request).unwrap()["method"], name);
assert_eq!(
crate::api::request_changes_ui(&request),
operation.changes_ui
);
assert_eq!(
crate::server::client_commands::supports_client_shell_method(&request.method),
operation.client_shell
);
assert_eq!(
crate::server::client_commands::supports_client_shell_method_name(name),
operation.client_shell
);
}
}
#[test]
fn operation_ui_impact_preserves_existing_classification() {
let expected = [
"server.reload_config",
"server.reload_agent_manifests",
"notification.show",
"product_announcement.dismiss",
"release_notes.dismiss",
"command.invoke",
"workspace.create",
"workspace.focus",
"workspace.rename",
"workspace.move",
"workspace.move_block",
"workspace.report_metadata",
"workspace.close",
"worktree.create",
"worktree.open",
"worktree.remove",
"tab.create",
"tab.focus",
"tab.rename",
"tab.move",
"tab.close",
"layout.apply",
"layout.set_split_ratio",
"agent.rename",
"agent.view.set",
"agent.view.clear",
"agent.focus",
"agent.start",
"agent.prompt",
"agent.send_keys",
"pane.split",
"pane.swap",
"pane.move",
"pane.zoom",
"pane.focus_direction",
"pane.resize",
"pane.scroll",
"pane.clear",
"pane.edit_scrollback",
"pane.focus",
"pane.input.set",
"pane.rename",
"pane.report_agent",
"pane.report_agent_session",
"pane.report_metadata",
"pane.clear_agent_authority",
"pane.release_agent",
"pane.close",
"popup.close",
"plugin.unlink",
"plugin.disable",
"plugin.action.invoke",
"plugin.pane.open",
"plugin.pane.focus",
"plugin.pane.close",
]
.into_iter()
.collect::<BTreeSet<_>>();
let actual = Method::OPERATIONS
.iter()
.filter(|operation| operation.changes_ui)
.map(|operation| operation.name)
.collect::<BTreeSet<_>>();
assert_eq!(actual, expected);
}
}
+233 -183
View File
@@ -2,6 +2,9 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::cli::pane::bindings as binding;
use crate::cli::projection::{Field, Input};
use super::agents::AgentSessionInfo;
use super::common::{AgentStatus, PaneAgentState, ReadFormat, ReadSource, SplitDirection};
@@ -15,29 +18,33 @@ pub enum PaneRightClickTarget {
Pane,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneSplitParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_pane_id: Option<String>,
pub direction: SplitDirection,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ratio: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default)]
pub focus: bool,
#[serde(default)]
pub right_click: PaneRightClickTarget,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String>,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneSplitParams => PaneSplitProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String> => Field::new(None::<String>),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_pane_id: Option<String> => binding::target(true),
pub direction: SplitDirection => Field::new(SplitDirection::Right).input(binding::split_direction()),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ratio: Option<f32> => Field::new(None).input(binding::finite("ratio")),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String> => Field::new(None).input(binding::optional_text("cwd", "PATH").path()),
#[serde(default)]
pub focus: bool => Field::new(false).input(binding::boolean("focus", true)).input(binding::boolean("no-focus", false)),
#[serde(default)]
pub right_click: PaneRightClickTarget => Field::new(PaneRightClickTarget::Herdr).input(binding::right_click()),
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String> => Field::new(HashMap::new()).input(binding::environment()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneInputSetParams {
pub pane_id: String,
pub right_click: PaneRightClickTarget,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneInputSetParams => PaneInputProjection {
pub pane_id: String => binding::input_target(),
pub right_click: PaneRightClickTarget => Field::new(PaneRightClickTarget::Herdr).input(binding::right_click().required()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
@@ -60,24 +67,28 @@ pub enum PaneDirection {
Down,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PaneSwapParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub direction: Option<PaneDirection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_pane_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_pane_id: Option<String>,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PaneSwapParams => PaneSwapProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String> => binding::target(false),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub direction: Option<PaneDirection> => Field::new(None).input(binding::optional_direction().configure(|arg| arg.conflicts_with_all(["source-pane", "target-pane"]))),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_pane_id: Option<String> => Field::new(None).input(binding::optional_text("source-pane", "ID").configure(|arg| arg.requires("target-pane"))),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_pane_id: Option<String> => Field::new(None).input(binding::optional_text("target-pane", "ID").configure(|arg| arg.requires("source-pane"))),
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneMoveParams {
pub pane_id: String,
pub destination: PaneMoveDestination,
#[serde(default)]
pub focus: bool,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneMoveParams => PaneMoveProjection {
pub pane_id: String => Field::new(String::new()).input(binding::pane_id()),
pub destination: PaneMoveDestination => binding::destination(),
#[serde(default)]
pub focus: bool => Field::new(true).input(binding::boolean("focus", true)).input(binding::boolean("no-focus", false)),
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
@@ -105,12 +116,17 @@ pub enum PaneMoveDestination {
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PaneZoomParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String>,
#[serde(default)]
pub mode: PaneZoomMode,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PaneZoomParams => PaneZoomProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String> => binding::target(true),
#[serde(default)]
pub mode: PaneZoomMode => Field::new(PaneZoomMode::Toggle)
.input(Input::flag("toggle", |slot, _| { *slot = PaneZoomMode::Toggle; Ok(()) }).configure(|arg| arg.conflicts_with_all(["on", "off"])))
.input(Input::flag("on", |slot, _| { *slot = PaneZoomMode::On; Ok(()) }).configure(|arg| arg.conflicts_with("off")))
.input(Input::flag("off", |slot, _| { *slot = PaneZoomMode::Off; Ok(()) })),
}
}
#[derive(
@@ -124,16 +140,20 @@ pub enum PaneZoomMode {
Off,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PaneLayoutParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String>,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PaneLayoutParams => PaneLayoutProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String> => binding::target(false),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PaneProcessInfoParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String>,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PaneProcessInfoParams => PaneProcessInfoProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String> => binding::target(false),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
@@ -205,33 +225,41 @@ pub struct LayoutPane {
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneNeighborParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String>,
pub direction: PaneDirection,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneNeighborParams => PaneNeighborProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String> => binding::target(false),
pub direction: PaneDirection => Field::new(PaneDirection::Left).input(binding::direction()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PaneEdgesParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String>,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PaneEdgesParams => PaneEdgesProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String> => binding::target(false),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneFocusDirectionParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String>,
pub direction: PaneDirection,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneFocusDirectionParams => PaneFocusProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String> => binding::target(false),
pub direction: PaneDirection => Field::new(PaneDirection::Left).input(binding::direction()),
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneResizeParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String>,
pub direction: PaneDirection,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub amount: Option<f32>,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneResizeParams => PaneResizeProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String> => binding::target(false),
pub direction: PaneDirection => Field::new(PaneDirection::Left).input(binding::direction()),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub amount: Option<f32> => Field::new(None).input(binding::finite("amount")),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
@@ -303,128 +331,148 @@ pub struct PaneCopySearchParams {
pub previous: Option<PaneTextRange>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PaneListParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PaneListParams => PaneListProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String> => Field::new(None).input(Input::value("workspace", "WORKSPACE_ID", |slot, value| { *slot = Some(crate::cli::normalize_workspace_id(value)); Ok(()) })),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PaneCurrentParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caller_pane_id: Option<String>,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PaneCurrentParams => PaneCurrentProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caller_pane_id: Option<String> => binding::target(false),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneRenameParams {
pub pane_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneRenameParams => PaneRenameProjection {
pub pane_id: String => Field::new(String::new()).input(binding::pane_id()),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String> => binding::label(),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneSendTextParams {
pub pane_id: String,
pub text: String,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneSendTextParams => PaneSendTextProjection {
pub pane_id: String => Field::new(String::new()).input(binding::pane_id()),
pub text: String => Field::new(String::new()).input(binding::joined("text", "TEXT")),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneSendKeysParams {
pub pane_id: String,
pub keys: Vec<String>,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneSendKeysParams => PaneSendKeysProjection {
pub pane_id: String => Field::new(String::new()).input(binding::pane_id()),
pub keys: Vec<String> => Field::new(Vec::new()).input(binding::keys()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneSendInputParams {
pub pane_id: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub text: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keys: Vec<String>,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneSendInputParams => PaneRunProjection {
pub pane_id: String => Field::new(String::new()).input(binding::pane_id()),
#[serde(default, skip_serializing_if = "String::is_empty")]
pub text: String => Field::new(String::new()).input(binding::joined("command", "COMMAND")),
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keys: Vec<String> => Field::new(vec![String::from("Enter")]),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneReadParams {
pub pane_id: String,
pub source: ReadSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lines: Option<u32>,
#[serde(default)]
pub format: ReadFormat,
#[serde(default = "super::default_true")]
pub strip_ansi: bool,
#[serde(skip)]
#[schemars(skip)]
pub(crate) intent: super::common::ReadIntent,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneReadParams => PaneReadProjection {
pub pane_id: String => Field::new(String::new()).input(binding::pane_id()),
pub source: ReadSource => Field::new(ReadSource::Recent).input(binding::read_source(true)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lines: Option<u32> => Field::new(None).input(binding::lines()),
#[serde(default)]
pub format: ReadFormat => binding::read_format(),
#[serde(default = "super::default_true")]
pub strip_ansi: bool => Field::new(true).input(binding::boolean("raw", false)),
#[serde(skip)]
#[schemars(skip)]
pub(crate) intent: super::common::ReadIntent => Field::new(super::common::ReadIntent::Interactive),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneReportAgentParams {
pub pane_id: String,
pub source: String,
pub agent: String,
pub state: PaneAgentState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session_path: Option<String>,
/// Command that resumes this agent's session after a Herdr restart. The
/// first element must be a plain command name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume_argv: Option<Vec<String>>,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneReportAgentParams => PaneReportAgentProjection {
pub pane_id: String => Field::new(String::new()).input(binding::pane_id()),
pub source: String => Field::new(String::new()).input(binding::source()),
pub agent: String => Field::new(String::new()).input(binding::text("agent", "LABEL").required()),
pub state: PaneAgentState => Field::new(PaneAgentState::Unknown).input(binding::state()),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String> => Field::new(None).input(binding::optional_text("message", "TEXT")),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64> => Field::new(None).input(binding::number("seq", "N")),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session_id: Option<String> => Field::new(None).input(binding::optional_text("agent-session-id", "ID")),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session_path: Option<String> => Field::new(None).input(binding::optional_text("agent-session-path", "PATH").path()),
/// Command that resumes this agent's session after a Herdr restart. The
/// first element must be a plain command name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume_argv: Option<Vec<String>> => Field::new(None).input(binding::resume()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneReportAgentSessionParams {
pub pane_id: String,
pub source: String,
pub agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_start_source: Option<String>,
/// Command that resumes this agent's session after a Herdr restart. The
/// first element must be a plain command name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume_argv: Option<Vec<String>>,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneReportAgentSessionParams => PaneReportSessionProjection {
pub pane_id: String => Field::new(String::new()).input(binding::pane_id()),
pub source: String => Field::new(String::new()).input(binding::source()),
pub agent: String => Field::new(String::new()).input(binding::text("agent", "LABEL").required()),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64> => Field::new(None).input(binding::number("seq", "N")),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session_id: Option<String> => Field::new(None).input(binding::optional_text("agent-session-id", "ID")),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session_path: Option<String> => Field::new(None).input(binding::optional_text("agent-session-path", "PATH").path()),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_start_source: Option<String> => Field::new(None).input(binding::optional_text("session-start-source", "SOURCE")),
/// Command that resumes this agent's session after a Herdr restart. The
/// first element must be a plain command name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume_argv: Option<Vec<String>> => Field::new(None).input(binding::resume()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneReportMetadataParams {
pub pane_id: String,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub applies_to_source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_agent: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub state_labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
#[schemars(schema_with = "super::common::metadata_token_patch_schema")]
pub tokens: HashMap<String, Option<String>>,
#[serde(default)]
pub clear_title: bool,
#[serde(default)]
pub clear_display_agent: bool,
#[serde(default)]
pub clear_state_labels: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1, max = 86_400_000))]
pub ttl_ms: Option<u64>,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneReportMetadataParams => PaneMetadataProjection {
pub pane_id: String => Field::new(String::new()).input(binding::pane_id()),
pub source: String => Field::new(String::new()).input(binding::source()),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String> => Field::new(None).input(binding::optional_text("agent", "LABEL")),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub applies_to_source: Option<String> => Field::new(None).input(binding::optional_text("applies-to-source", "ID")),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String> => Field::new(None).input(binding::optional_text("title", "TEXT").configure(|arg| arg.conflicts_with("clear-title"))),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_agent: Option<String> => Field::new(None).input(binding::optional_text("display-agent", "TEXT").configure(|arg| arg.conflicts_with("clear-display-agent"))),
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub state_labels: HashMap<String, String> => Field::new(HashMap::new()).input(binding::state_label().configure(|arg| arg.conflicts_with("clear-state-labels"))),
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
#[schemars(schema_with = "super::common::metadata_token_patch_schema")]
pub tokens: HashMap<String, Option<String>> => Field::new(HashMap::new()).input(binding::token()).input(binding::clear_token()),
#[serde(default)]
pub clear_title: bool => Field::new(false).input(binding::boolean("clear-title", true)),
#[serde(default)]
pub clear_display_agent: bool => Field::new(false).input(binding::boolean("clear-display-agent", true)),
#[serde(default)]
pub clear_state_labels: bool => Field::new(false).input(binding::boolean("clear-state-labels", true)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64> => Field::new(None).input(binding::number("seq", "N")),
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1, max = 86_400_000))]
pub ttl_ms: Option<u64> => Field::new(None).input(binding::number("ttl-ms", "N")),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
@@ -436,13 +484,15 @@ pub struct PaneClearAgentAuthorityParams {
pub seq: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneReleaseAgentParams {
pub pane_id: String,
pub source: String,
pub agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64>,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneReleaseAgentParams => PaneReleaseProjection {
pub pane_id: String => Field::new(String::new()).input(binding::pane_id()),
pub source: String => Field::new(String::new()).input(binding::source()),
pub agent: String => Field::new(String::new()).input(binding::text("agent", "LABEL").required()),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64> => Field::new(None).input(binding::number("seq", "N")),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+88 -62
View File
@@ -6,31 +6,43 @@ use super::common::AgentStatus;
use super::common::SplitDirection;
use super::panes::PaneInfo;
use super::workspaces::WorkspaceWorktreeInfo;
use crate::cli::plugin::bindings;
use crate::cli::projection::{cli_params, Field};
use crate::popup_size::PopupSize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PluginLinkParams {
pub path: String,
#[serde(default = "super::common::default_true")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<PluginSourceInfo>,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PluginLinkParams => PluginLinkProjection {
pub path: String => Field::new(String::new()).input(bindings::path()),
#[serde(default = "super::common::default_true")]
pub enabled: bool => Field::new(true)
.input(bindings::boolean("disabled", false))
.input(bindings::boolean("enabled", true)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<PluginSourceInfo> => Field::new(None::<PluginSourceInfo>),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PluginListParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_id: Option<String>,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PluginListParams => PluginListProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_id: Option<String> => Field::new(None).input(bindings::optional_text("plugin", "ID")),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PluginUnlinkParams {
pub plugin_id: String,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PluginUnlinkParams => PluginUnlinkProjection {
pub plugin_id: String => Field::new(String::new()).input(bindings::id("plugin_id", "PLUGIN_ID")),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PluginSetEnabledParams {
pub plugin_id: String,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PluginSetEnabledParams => PluginSetEnabledProjection {
pub plugin_id: String => Field::new(String::new()).input(bindings::id("plugin_id", "PLUGIN_ID")),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
@@ -288,27 +300,33 @@ pub struct PluginManifestLinkHandler {
pub platforms: Option<Vec<PluginPlatform>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PluginActionListParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_id: Option<String>,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PluginActionListParams => PluginActionListProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_id: Option<String> => Field::new(None).input(bindings::optional_text("plugin", "ID")),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PluginLogListParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PluginLogListParams => PluginLogListProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_id: Option<String> => Field::new(None).input(bindings::optional_text("plugin", "ID")),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize> => Field::new(None).input(bindings::limit()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PluginActionInvokeParams {
pub action_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<PluginInvocationContext>,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PluginActionInvokeParams => PluginActionInvokeProjection {
pub action_id: String => Field::new(String::new()).input(bindings::id("action_id", "ACTION_ID")),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_id: Option<String> => Field::new(None).input(bindings::optional_text("plugin", "ID")),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<PluginInvocationContext> => Field::new(Some(bindings::invocation_context())),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
@@ -414,28 +432,32 @@ impl PluginActionInfo {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PluginPaneOpenParams {
pub plugin_id: String,
pub entrypoint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placement: Option<PluginPanePlacement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<PopupSize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<PopupSize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_pane_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub direction: Option<SplitDirection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default)]
pub focus: bool,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String>,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PluginPaneOpenParams => PluginPaneOpenProjection {
pub plugin_id: String => Field::new(String::new()).input(bindings::required_text("plugin", "ID").order(0)),
pub entrypoint: String => Field::new(String::new()).input(bindings::required_text("entrypoint", "ID").order(1)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placement: Option<PluginPanePlacement> => Field::new(None).input(bindings::placement().order(2)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<PopupSize> => Field::new(None).input(bindings::width().hidden()),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<PopupSize> => Field::new(None).input(bindings::height().hidden()),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String> => Field::new(None).input(bindings::optional_text("workspace", "ID").order(3)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_pane_id: Option<String> => Field::new(None).input(bindings::optional_text("target-pane", "PANE").order(4)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub direction: Option<SplitDirection> => Field::new(None).input(bindings::direction().order(5)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String> => Field::new(None).input(bindings::optional_text("cwd", "PATH").path().order(6)),
#[serde(default)]
pub focus: bool => Field::new(true)
.input(bindings::boolean("focus", true).order(8))
.input(bindings::boolean("no-focus", false).order(9)),
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String> => Field::new(HashMap::new()).input(bindings::environment().order(7)),
}
}
#[derive(
@@ -451,14 +473,18 @@ pub enum PluginPanePlacement {
Zoomed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PluginPaneFocusParams {
pub pane_id: String,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PluginPaneFocusParams => PluginPaneFocusProjection {
pub pane_id: String => Field::new(String::new()).input(bindings::pane_id()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PluginPaneCloseParams {
pub pane_id: String,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PluginPaneCloseParams => PluginPaneCloseProjection {
pub pane_id: String => Field::new(String::new()).input(bindings::pane_id()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+27 -8
View File
@@ -3,14 +3,33 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct PingParams {}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ServerLiveHandoffParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub import_exe: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_protocol: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_version: Option<String>,
crate::cli::projection::cli_params! {
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ServerLiveHandoffParams => ServerLiveHandoffProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub import_exe: Option<String> => crate::cli::projection::Field::new(None).input(
crate::cli::projection::Input::value("import-exe", "path", |slot, value| {
*slot = Some(value.to_owned());
Ok(())
}).path()
),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_protocol: Option<u32> => crate::cli::projection::Field::new(None).input(
crate::cli::projection::Input::value("expected-protocol", "n", |slot, value| {
*slot = Some(value.parse::<u32>().map_err(|_| {
crate::cli::projection::ParseError::Usage(format!("invalid expected protocol: {value}"))
})?);
Ok(())
})
),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_version: Option<String> => crate::cli::projection::Field::new(None).input(
crate::cli::projection::Input::value("expected-version", "version", |slot, value| {
*slot = Some(value.to_owned());
Ok(())
})
),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+45 -20
View File
@@ -3,31 +3,56 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::common::AgentStatus;
use crate::cli::projection::{cli_params, Field, Input};
use crate::cli::workspace_bindings as bindings;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TabCreateParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default)]
pub focus: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String>,
pub(crate) fn workspace_option(value_name: &'static str) -> Input<Option<String>> {
Input::value("workspace", value_name, |slot, value| {
*slot = Some(crate::cli::normalize_workspace_id(value));
Ok(())
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct TabListParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
pub(crate) fn tab_id() -> Input<String> {
Input::positional("tab_id", 0, |slot, value| {
*slot = crate::cli::normalize_tab_id(value);
Ok(())
})
.value_name("TAB_ID")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TabRenameParams {
pub tab_id: String,
pub label: String,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TabCreateParams => TabCreateProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String> => Field::new(None).input(workspace_option("WORKSPACE_ID").order(0)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String> => Field::new(None).input(bindings::optional_text("cwd", "PATH").path().order(1)),
#[serde(default)]
pub focus: bool => Field::new(false)
.input(bindings::boolean("focus", true).order(4))
.input(bindings::boolean("no-focus", false).order(5)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String> => Field::new(None).input(bindings::optional_text("label", "TEXT").order(2)),
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String> => Field::new(HashMap::new()).input(bindings::environment().order(3)),
}
}
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct TabListParams => TabListProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String> => Field::new(None).input(workspace_option("WORKSPACE_ID")),
}
}
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TabRenameParams => TabRenameProjection {
pub tab_id: String => Field::new(String::new()).input(tab_id()),
pub label: String => Field::new(String::new()).input(bindings::label()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+6
View File
@@ -1459,3 +1459,9 @@ fn pane_link_resolve_round_trips() {
result
);
}
#[test]
fn read_choices_cover_every_api_value() {
crate::cli::projection::assert_choices_cover_api_enum(READ_SOURCES);
crate::cli::projection::assert_choices_cover_api_enum(READ_FORMATS);
}
+54 -33
View File
@@ -3,33 +3,48 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::common::AgentStatus;
use crate::cli::projection::{cli_params, Field};
use crate::cli::workspace_bindings as bindings;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WorkspaceCreateParams {
/// Workspace whose focused pane supplies the `follow` cwd policy.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default)]
pub focus: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String>,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WorkspaceCreateParams => WorkspaceCreateProjection {
/// Workspace whose focused pane supplies the `follow` cwd policy.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_workspace_id: Option<String> => Field::new(None::<String>),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String> => Field::new(None)
.input(bindings::optional_text("cwd", "PATH").path().order(0)),
#[serde(default)]
pub focus: bool => Field::new(false)
.input(bindings::boolean("focus", true).order(3))
.input(bindings::boolean("no-focus", false).order(4)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String> => Field::new(None)
.input(bindings::optional_text("label", "TEXT").order(1)),
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String> => Field::new(HashMap::new())
.input(bindings::environment().order(2)),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WorkspaceCloseParams {
pub workspace_id: String,
#[serde(default, skip_serializing_if = "super::is_false")]
pub close_group: bool,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WorkspaceCloseParams => WorkspaceCloseProjection {
pub workspace_id: String => Field::new(String::new()).input(bindings::workspace_id()),
#[serde(default, skip_serializing_if = "super::is_false")]
pub close_group: bool => Field::new(false)
.input(bindings::boolean("group", true).hidden()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WorkspaceRenameParams {
pub workspace_id: String,
pub label: String,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WorkspaceRenameParams => WorkspaceRenameProjection {
pub workspace_id: String => Field::new(String::new())
.input(bindings::workspace_id().value_name("WORKSPACE_ID")),
pub label: String => Field::new(String::new()).input(bindings::label()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
@@ -45,17 +60,23 @@ pub struct WorkspaceMoveBlockParams {
pub before_workspace_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WorkspaceReportMetadataParams {
pub workspace_id: String,
pub source: String,
#[schemars(schema_with = "super::common::metadata_token_patch_schema")]
pub tokens: HashMap<String, Option<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1, max = 86_400_000))]
pub ttl_ms: Option<u64>,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WorkspaceReportMetadataParams => WorkspaceMetadataProjection {
pub workspace_id: String => Field::new(String::new())
.input(bindings::workspace_id().value_name("WORKSPACE_ID")),
pub source: String => Field::new(String::new()).input(bindings::source())
.validate(|source| bindings::validate_source(source)),
#[schemars(schema_with = "super::common::metadata_token_patch_schema")]
pub tokens: HashMap<String, Option<String>> => Field::new(HashMap::new())
.input(bindings::token()).input(bindings::clear_token())
.validate(bindings::validate_tokens),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64> => Field::new(None).input(bindings::seq()),
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1, max = 86_400_000))]
pub ttl_ms: Option<u64> => Field::new(None).input(bindings::ttl_ms()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+83 -49
View File
@@ -1,60 +1,94 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct WorktreeListParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "super::is_false")]
pub trust_repository: bool,
use crate::cli::projection::{cli_params, Field, Input};
use crate::cli::workspace_bindings as bindings;
pub(crate) fn workspace() -> Input<Option<String>> {
super::tabs::workspace_option("ID").configure(|arg| arg.conflicts_with("cwd"))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct WorktreeCreateParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default)]
pub focus: bool,
#[serde(default, skip_serializing_if = "super::is_false")]
pub trust_repository: bool,
fn json_compatibility() -> Input<bool> {
Input::flag("json", |_, _| Ok(())).hidden()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct WorktreeOpenParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default)]
pub focus: bool,
#[serde(default, skip_serializing_if = "super::is_false")]
pub trust_repository: bool,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct WorktreeListParams => WorktreeListProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String> => Field::new(None).input(workspace().order(0)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String> => Field::new(None).input(crate::cli::worktree::bindings::path("cwd").order(1)),
#[serde(default, skip_serializing_if = "super::is_false")]
pub trust_repository: bool => Field::new(false)
.input(bindings::boolean("trust-repository", true).order(2))
.input(json_compatibility()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WorktreeRemoveParams {
pub workspace_id: String,
#[serde(default)]
pub force: bool,
#[serde(default, skip_serializing_if = "super::is_false")]
pub trust_repository: bool,
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct WorktreeCreateParams => WorktreeCreateProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String> => Field::new(None).input(workspace().order(0)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String> => Field::new(None).input(crate::cli::worktree::bindings::path("cwd").order(1)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String> => Field::new(None).input(bindings::optional_text("branch", "NAME").order(2)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base: Option<String> => Field::new(None).input(bindings::optional_text("base", "REF").order(3)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String> => Field::new(None).input(crate::cli::worktree::bindings::path("path").order(4)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String> => Field::new(None).input(bindings::optional_text("label", "TEXT").order(5)),
#[serde(default)]
pub focus: bool => Field::new(false)
.input(bindings::boolean("focus", true).order(6))
.input(bindings::boolean("no-focus", false).order(7)),
#[serde(default, skip_serializing_if = "super::is_false")]
pub trust_repository: bool => Field::new(false)
.input(bindings::boolean("trust-repository", true).order(8))
.input(json_compatibility()),
}
}
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)]
pub struct WorktreeOpenParams => WorktreeOpenProjection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String> => Field::new(None).input(workspace().order(0)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String> => Field::new(None).input(crate::cli::worktree::bindings::path("cwd").order(1)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String> => Field::new(None).input(crate::cli::worktree::bindings::path("path").order(2)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String> => Field::new(None).input(bindings::optional_text("branch", "NAME").order(3)),
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String> => Field::new(None).input(bindings::optional_text("label", "TEXT").order(4)),
#[serde(default)]
pub focus: bool => Field::new(false)
.input(bindings::boolean("focus", true).order(5))
.input(bindings::boolean("no-focus", false).order(6)),
#[serde(default, skip_serializing_if = "super::is_false")]
pub trust_repository: bool => Field::new(false)
.input(bindings::boolean("trust-repository", true).order(7))
.input(json_compatibility()),
}
}
cli_params! {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WorktreeRemoveParams => WorktreeRemoveProjection {
pub workspace_id: String => Field::new(String::new()).input(Input::value("workspace", "ID", |slot, value| {
*slot = crate::cli::normalize_workspace_id(value);
Ok(())
}).required().order(0)),
#[serde(default)]
pub force: bool => Field::new(false).input(bindings::boolean("force", true).order(1)),
#[serde(default, skip_serializing_if = "super::is_false")]
pub trust_repository: bool => Field::new(false)
.input(bindings::boolean("trust-repository", true).order(2))
.input(json_compatibility()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+1 -104
View File
@@ -602,110 +602,7 @@ fn handle_request(
}
pub(crate) fn api_method_name(method: &Method) -> &'static str {
match method {
Method::Ping(_) => "ping",
Method::ServerStop(_) => "server.stop",
Method::ServerLiveHandoff(_) => "server.live_handoff",
Method::ServerReloadConfig(_) => "server.reload_config",
Method::ServerSshAgentRegister(_) => "server.ssh_agent.register",
Method::ServerAgentManifests(_) => "server.agent_manifests",
Method::ServerReloadAgentManifests(_) => "server.reload_agent_manifests",
Method::NotificationShow(_) => "notification.show",
Method::ProductAnnouncementDismiss(_) => "product_announcement.dismiss",
Method::ReleaseNotesDismiss(_) => "release_notes.dismiss",
Method::CommandInvoke(_) => "command.invoke",
Method::ClientWindowTitleSet(_) => "client.window_title.set",
Method::ClientWindowTitleClear(_) => "client.window_title.clear",
Method::ClientShellSurfaceSet(_) => "client_shell.surface.set",
Method::SessionSnapshot(_) => "session.snapshot",
Method::WorkspaceCreate(_) => "workspace.create",
Method::WorkspaceList(_) => "workspace.list",
Method::WorkspaceGet(_) => "workspace.get",
Method::WorkspaceFocus(_) => "workspace.focus",
Method::WorkspaceRename(_) => "workspace.rename",
Method::WorkspaceMove(_) => "workspace.move",
Method::WorkspaceMoveBlock(_) => "workspace.move_block",
Method::WorkspaceReportMetadata(_) => "workspace.report_metadata",
Method::WorkspaceClose(_) => "workspace.close",
Method::WorktreeList(_) => "worktree.list",
Method::WorktreeCreate(_) => "worktree.create",
Method::WorktreeOpen(_) => "worktree.open",
Method::WorktreeRemove(_) => "worktree.remove",
Method::TabCreate(_) => "tab.create",
Method::TabList(_) => "tab.list",
Method::TabGet(_) => "tab.get",
Method::TabFocus(_) => "tab.focus",
Method::TabRename(_) => "tab.rename",
Method::TabMove(_) => "tab.move",
Method::TabClose(_) => "tab.close",
Method::AgentList(_) => "agent.list",
Method::AgentGet(_) => "agent.get",
Method::AgentRead(_) => "agent.read",
Method::AgentExplain(_) => "agent.explain",
Method::AgentSendKeys(_) => "agent.send_keys",
Method::AgentRename(_) => "agent.rename",
Method::AgentViewSet(_) => "agent.view.set",
Method::AgentViewClear(_) => "agent.view.clear",
Method::AgentFocus(_) => "agent.focus",
Method::AgentStart(_) => "agent.start",
Method::AgentPrompt(_) => "agent.prompt",
Method::AgentWait(_) => "agent.wait",
Method::PaneSplit(_) => "pane.split",
Method::PaneSwap(_) => "pane.swap",
Method::PaneMove(_) => "pane.move",
Method::PaneZoom(_) => "pane.zoom",
Method::PaneLayout(_) => "pane.layout",
Method::PaneProcessInfo(_) => "pane.process_info",
Method::LayoutExport(_) => "layout.export",
Method::LayoutApply(_) => "layout.apply",
Method::LayoutSetSplitRatio(_) => "layout.set_split_ratio",
Method::PaneNeighbor(_) => "pane.neighbor",
Method::PaneEdges(_) => "pane.edges",
Method::PaneFocusDirection(_) => "pane.focus_direction",
Method::PaneResize(_) => "pane.resize",
Method::PaneScroll(_) => "pane.scroll",
Method::PaneClear(_) => "pane.clear",
Method::PaneEditScrollback(_) => "pane.edit_scrollback",
Method::PaneSelectionRead(_) => "pane.selection.read",
Method::PaneCopyMotion(_) => "pane.copy_motion",
Method::PaneCopySearch(_) => "pane.copy_search",
Method::PaneList(_) => "pane.list",
Method::PaneCurrent(_) => "pane.current",
Method::PaneGet(_) => "pane.get",
Method::PaneFocus(_) => "pane.focus",
Method::PaneInputSet(_) => "pane.input.set",
Method::PaneLinkActivate(_) => "pane.link.activate",
Method::PaneLinkResolve(_) => "pane.link.resolve",
Method::PaneRename(_) => "pane.rename",
Method::PaneSendText(_) => "pane.send_text",
Method::PaneSendKeys(_) => "pane.send_keys",
Method::PaneSendInput(_) => "pane.send_input",
Method::PaneRead(_) => "pane.read",
Method::PaneReportAgent(_) => "pane.report_agent",
Method::PaneReportAgentSession(_) => "pane.report_agent_session",
Method::PaneReportMetadata(_) => "pane.report_metadata",
Method::PaneClearAgentAuthority(_) => "pane.clear_agent_authority",
Method::PaneReleaseAgent(_) => "pane.release_agent",
Method::PaneClose(_) => "pane.close",
Method::PopupClose(_) => "popup.close",
Method::EventsSubscribe(_) => "events.subscribe",
Method::EventsWait(_) => "events.wait",
Method::PaneWaitForOutput(_) => "pane.wait_for_output",
Method::IntegrationList(_) => "integration.list",
Method::IntegrationInstall(_) => "integration.install",
Method::IntegrationUninstall(_) => "integration.uninstall",
Method::PluginLink(_) => "plugin.link",
Method::PluginList(_) => "plugin.list",
Method::PluginUnlink(_) => "plugin.unlink",
Method::PluginEnable(_) => "plugin.enable",
Method::PluginDisable(_) => "plugin.disable",
Method::PluginActionList(_) => "plugin.action.list",
Method::PluginActionInvoke(_) => "plugin.action.invoke",
Method::PluginLogList(_) => "plugin.log.list",
Method::PluginPaneOpen(_) => "plugin.pane.open",
Method::PluginPaneFocus(_) => "plugin.pane.focus",
Method::PluginPaneClose(_) => "plugin.pane.close",
}
method.operation().name
}
fn api_response_outcome(response: &str) -> &'static str {
+53 -102
View File
@@ -3,10 +3,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use serde::Serialize;
use crate::api::client::{ApiClient, ApiClientError};
use crate::api::schema::{
AgentStatus, ClientWindowTitleSetParams, EmptyParams, Method, PaneAgentState, ReadFormat,
ReadSource, Request, SplitDirection,
};
use crate::api::schema::{Method, Request};
macro_rules! print {
($($arg:tt)*) => {{
@@ -22,24 +19,26 @@ macro_rules! println {
}};
}
mod agent;
pub(crate) mod agent;
mod api;
mod completion;
mod integration;
mod machine;
mod notification;
mod pane;
mod plugin;
pub(crate) mod notification;
pub(crate) mod pane;
pub(crate) mod plugin;
pub(crate) mod projection;
mod protocol_guard;
mod runtime;
mod server;
mod server_not_running;
mod spec;
mod status;
mod tab;
pub(crate) mod tab;
mod target;
mod title;
mod workspace;
mod worktree;
pub(crate) mod workspace_bindings;
pub(crate) mod worktree;
const TERMINAL_SESSION_OBSERVE_USAGE: &str =
"usage: herdr terminal session observe <target> [--cols N] [--rows N]";
@@ -107,6 +106,13 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
return Ok(CommandOutcome::Handled(0));
}
if args.len() == 2 {
if let Some(group) = api_group(command) {
eprintln!("{}", projection::group_commands(&group));
return Ok(CommandOutcome::Handled(2));
}
}
let exit_code = match command {
"server" => {
let Some(exit_code) = server::run_server_command(&args[2..])? else {
@@ -136,6 +142,20 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
Ok(CommandOutcome::Handled(exit_code))
}
/// API-backed command groups, which list their commands when run without one.
fn api_group(name: &str) -> Option<clap::Command> {
Some(match name {
"workspace" => workspace_bindings::command(),
"worktree" => worktree::command(),
"tab" => tab::command(),
"notification" => notification::command(),
"agent" => agent::command(),
"pane" => pane::command(),
"plugin" => plugin::command(),
_ => return None,
})
}
fn run_channel_command(args: &[String]) -> std::io::Result<i32> {
match args.first().map(|arg| arg.as_str()) {
Some("set") => channel_set(&args[1..]),
@@ -684,40 +704,7 @@ fn parse_terminal_dimension(raw: &str, flag: &str) -> std::io::Result<u16> {
}
fn terminal_title(args: &[String]) -> std::io::Result<i32> {
match args.first().map(|arg| arg.as_str()) {
Some("set") => {
if args.len() != 2 {
eprintln!("usage: herdr terminal title set <title>");
return Ok(2);
}
print_response(&send_request(&Request {
id: "cli:terminal:title:set".into(),
method: Method::ClientWindowTitleSet(ClientWindowTitleSetParams {
title: args[1].clone(),
}),
})?)
}
Some("clear") => {
if args.len() != 1 {
eprintln!("usage: herdr terminal title clear");
return Ok(2);
}
print_response(&send_request(&Request {
id: "cli:terminal:title:clear".into(),
method: Method::ClientWindowTitleClear(EmptyParams::default()),
})?)
}
Some("help" | "--help" | "-h") => {
eprintln!("usage: herdr terminal title set <title>");
eprintln!(" herdr terminal title clear");
Ok(0)
}
_ => {
eprintln!("usage: herdr terminal title set <title>");
eprintln!(" herdr terminal title clear");
Ok(2)
}
}
title::run(args)
}
pub(super) fn parse_attach_target(args: &[String], usage: &str) -> Result<(String, bool), i32> {
@@ -752,6 +739,27 @@ pub(super) fn print_response(response: &serde_json::Value) -> std::io::Result<i3
Ok(0)
}
/// Sends a parsed request and prints its JSON response; parse failures never connect.
fn run_json_request(request: Result<Request, projection::ParseError>) -> std::io::Result<i32> {
let request = match request {
Ok(request) => request,
Err(projection::ParseError::Usage(message)) => {
eprintln!("{message}");
return Ok(2);
}
Err(projection::ParseError::Io(error)) => return Err(error),
Err(projection::ParseError::Clap(error)) => return print_clap_error(&error),
};
print_response(&send_request(&request)?)
}
/// Prints a Clap diagnostic or help page and returns its exit code.
pub(super) fn print_clap_error(error: &clap::Error) -> std::io::Result<i32> {
crate::platform::begin_cli_output();
error.print()?;
Ok(error.exit_code())
}
pub(super) fn send_ok_request(method: Method) -> std::io::Result<i32> {
let response = send_request(&Request {
id: "cli:request".into(),
@@ -869,63 +877,6 @@ pub(super) fn normalize_pane_id(value: &str) -> String {
value.to_string()
}
pub(super) fn parse_split_direction(value: &str) -> std::io::Result<SplitDirection> {
match value {
"right" => Ok(SplitDirection::Right),
"down" => Ok(SplitDirection::Down),
_ => Err(std::io::Error::other(format!(
"invalid split direction: {value}"
))),
}
}
pub(super) fn parse_read_source(value: &str) -> std::io::Result<ReadSource> {
match value {
"visible" => Ok(ReadSource::Visible),
"recent" => Ok(ReadSource::Recent),
"recent-unwrapped" | "recent_unwrapped" => Ok(ReadSource::RecentUnwrapped),
"detection" => Ok(ReadSource::Detection),
_ => Err(std::io::Error::other(format!(
"invalid read source: {value}"
))),
}
}
pub(super) fn parse_read_format(value: &str) -> std::io::Result<ReadFormat> {
match value {
"text" => Ok(ReadFormat::Text),
"ansi" => Ok(ReadFormat::Ansi),
_ => Err(std::io::Error::other(format!(
"invalid read format: {value}"
))),
}
}
fn parse_agent_status(value: &str) -> std::io::Result<AgentStatus> {
match value {
"idle" => Ok(AgentStatus::Idle),
"working" => Ok(AgentStatus::Working),
"blocked" => Ok(AgentStatus::Blocked),
"done" => Ok(AgentStatus::Done),
"unknown" => Ok(AgentStatus::Unknown),
_ => Err(std::io::Error::other(format!(
"invalid agent status: {value} (expected idle, working, blocked, done, or unknown)"
))),
}
}
pub(super) fn parse_pane_agent_state(value: &str) -> std::io::Result<PaneAgentState> {
match value {
"idle" => Ok(PaneAgentState::Idle),
"working" => Ok(PaneAgentState::Working),
"blocked" => Ok(PaneAgentState::Blocked),
"unknown" => Ok(PaneAgentState::Unknown),
_ => Err(std::io::Error::other(format!(
"invalid pane agent state: {value} (expected idle, working, blocked, or unknown)"
))),
}
}
pub(super) fn parse_u32_flag(flag: &str, value: &str) -> std::io::Result<u32> {
value
.parse::<u32>()
+87 -430
View File
@@ -1,113 +1,77 @@
use std::time::{Duration, Instant};
use crate::api::schema::{
AgentPromptParams, AgentPromptWaitOptions, AgentReadParams, AgentRenameParams,
AgentSendKeysParams, AgentStartParams, AgentTarget, AgentWaitParams, EmptyParams, ErrorBody,
ErrorResponse, Method, PaneProcessInfoParams, PaneTarget, ReadFormat, ReadSource, Request,
AgentPromptParams, AgentReadParams, AgentRenameParams, AgentSendKeysParams, AgentStartParams,
AgentTarget, AgentWaitParams, EmptyParams, ErrorBody, ErrorResponse, Method,
PaneProcessInfoParams, PaneTarget, Request,
};
const AGENT_START_POLL_INTERVAL: Duration = Duration::from_millis(100);
const PANE_SHELL_READINESS_RETRY_TIMEOUT: Duration = Duration::from_secs(2);
pub(super) fn run_agent_command(args: &[String]) -> std::io::Result<i32> {
let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
print_agent_help();
return Ok(2);
};
pub(crate) mod bindings;
#[cfg(test)]
mod bindings_tests;
match subcommand {
"list" => agent_list(&args[1..]),
"get" => agent_get(&args[1..]),
"read" => agent_read(&args[1..]),
"send-keys" => agent_send_keys(&args[1..]),
"prompt" => agent_prompt(&args[1..]),
"rename" => agent_rename(&args[1..]),
"focus" => agent_focus(&args[1..]),
"wait" => agent_wait(&args[1..]),
"attach" => agent_attach(&args[1..]),
"start" => agent_start(&args[1..]),
"explain" => agent_explain(&args[1..]),
"help" | "--help" | "-h" => {
print_agent_help();
Ok(0)
pub(crate) fn command() -> clap::Command {
bindings::command()
}
pub(super) fn run_agent_command(args: &[String]) -> std::io::Result<i32> {
use super::projection::ParseError;
use bindings::Operation;
// `explain` and `wait` historically treat a bare `help` operand after the target as help.
let help_args;
let args = match args.first().map(String::as_str) {
Some(leaf @ ("explain" | "wait")) => {
let operands = super::projection::operands(&command(), args);
let skip = usize::from(leaf == "wait");
if operands.iter().skip(skip).any(|operand| *operand == "help") {
help_args = [leaf.to_owned(), "--help".to_owned()];
&help_args[..]
} else {
args
}
}
_ => {
print_agent_help();
Ok(2)
_ => args,
};
let operation = match super::projection::parse(
command().bin_name("herdr agent"),
args,
bindings::from_matches,
) {
Ok(operation) => operation,
Err(ParseError::Clap(error)) => return super::print_clap_error(&error),
Err(ParseError::Usage(message)) => {
eprintln!("{message}");
return Ok(2);
}
Err(ParseError::Io(error)) => return Err(error),
};
match operation {
Operation::List => agent_list(),
Operation::Get(params) => agent_get(params),
Operation::Focus(params) => agent_focus(params),
Operation::Read(params) => agent_read(params),
Operation::SendKeys(params) => agent_send_keys(params),
Operation::Rename(params) => agent_rename(params),
Operation::Wait(params) => agent_wait(params),
Operation::Prompt(params) => agent_prompt(params),
Operation::Start(params) => agent_start(params),
Operation::Attach(params) => agent_attach(params),
Operation::Explain(params) => agent_explain(params),
}
}
fn agent_explain(args: &[String]) -> std::io::Result<i32> {
let mut file = None;
let mut agent = None;
let mut json = false;
let mut verbose = false;
let mut target = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--file" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --file");
return Ok(2);
};
file = Some(value.clone());
index += 2;
}
"--agent" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --agent");
return Ok(2);
};
agent = Some(value.clone());
index += 2;
}
"--json" => {
json = true;
index += 1;
}
"--format" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --format");
return Ok(2);
};
match value.as_str() {
"json" => json = true,
"text" => json = false,
other => {
eprintln!("invalid --format: {other} (expected text or json)");
return Ok(2);
}
}
index += 2;
}
"--verbose" | "-v" => {
verbose = true;
index += 1;
}
"help" | "--help" | "-h" => {
eprintln!("usage: herdr agent explain <target> [--json|--verbose]");
eprintln!(
"usage: herdr agent explain --file PATH --agent LABEL [--json|--verbose]"
);
return Ok(0);
}
value if value.starts_with('-') => {
eprintln!("unknown option: {value}");
return Ok(2);
}
value => {
if target.is_some() {
eprintln!("usage: herdr agent explain <target> [--json]");
return Ok(2);
}
target = Some(value.to_string());
index += 1;
}
}
}
fn agent_explain(params: bindings::Explain) -> std::io::Result<i32> {
let bindings::Explain {
file,
agent,
json,
verbose,
target,
} = params;
let explain = if let Some(path) = file {
if target.is_some() {
@@ -286,72 +250,19 @@ fn matched_rule_region_preview<'a>(
.filter(|preview| !preview.is_empty())
}
fn agent_start(args: &[String]) -> std::io::Result<i32> {
let Some(name) = args.first() else {
eprintln!("usage: herdr agent start <name> --kind KIND --pane ID [--timeout MS] [-- <agent-args...>]");
return Ok(2);
};
let separator = args
.iter()
.position(|arg| arg == "--")
.unwrap_or(args.len());
let mut kind = None;
let mut pane_id = None;
let mut timeout_ms = None;
let mut index = 1;
while index < separator {
match args[index].as_str() {
"--kind" => {
let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else {
eprintln!("missing value for --kind");
return Ok(2);
};
kind = Some(value.clone());
index += 2;
}
"--pane" => {
let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else {
eprintln!("missing value for --pane");
return Ok(2);
};
pane_id = Some(super::normalize_pane_id(value));
index += 2;
}
"--timeout" => {
let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else {
eprintln!("missing value for --timeout");
return Ok(2);
};
timeout_ms = match parse_timeout(value) {
Ok(timeout_ms) => Some(timeout_ms),
Err(exit_code) => return Ok(exit_code),
};
index += 2;
}
other => {
eprintln!("unknown option: {other}");
return Ok(2);
}
}
}
let Some(kind) = kind else {
eprintln!("missing required --kind");
return Ok(2);
};
let Some(pane_id) = pane_id else {
eprintln!("missing required --pane");
return Ok(2);
};
fn agent_start(params: AgentStartParams) -> std::io::Result<i32> {
let AgentStartParams {
name,
kind,
pane_id,
args: agent_args,
timeout_ms,
} = params;
let Some(expected_kind) = crate::detect::parse_agent_label(&kind) else {
eprintln!("unsupported interactive agent kind: {kind}");
return Ok(2);
};
let expected_kind = crate::detect::agent_label(expected_kind).to_string();
let agent_args = if separator < args.len() {
args[separator + 1..].to_vec()
} else {
Vec::new()
};
let timeout = Duration::from_millis(timeout_ms.unwrap_or(30_000));
let retryable_timeout = timeout > crate::app::AGENT_START_SETTLE_DELAY
&& timeout <= crate::app::MAX_AGENT_START_TIMEOUT;
@@ -414,10 +325,10 @@ fn agent_start(args: &[String]) -> std::io::Result<i32> {
.as_deref()
.is_some_and(|pinned| pinned != expected_terminal_id)
{
return super::print_response(&agent_name_lost_error("cli:agent:start", name));
return super::print_response(&agent_name_lost_error("cli:agent:start", &name));
}
let waited = wait_for_named_agent(
name,
&name,
&pane_id,
timeout,
&expected_kind,
@@ -435,60 +346,29 @@ fn agent_start(args: &[String]) -> std::io::Result<i32> {
}
}
fn agent_list(args: &[String]) -> std::io::Result<i32> {
if !args.is_empty() {
eprintln!("usage: herdr agent list");
return Ok(2);
}
fn agent_list() -> std::io::Result<i32> {
super::print_response(&super::send_request(&Request {
id: "cli:agent:list".into(),
method: Method::AgentList(EmptyParams::default()),
})?)
}
fn agent_get(args: &[String]) -> std::io::Result<i32> {
let Some(target) = args.first() else {
eprintln!("usage: herdr agent get <target>");
return Ok(2);
};
if args.len() != 1 {
eprintln!("usage: herdr agent get <target>");
return Ok(2);
}
fn agent_get(params: AgentTarget) -> std::io::Result<i32> {
super::print_response(&super::send_request(&Request {
id: "cli:agent:get".into(),
method: Method::AgentGet(AgentTarget {
target: target.clone(),
}),
method: Method::AgentGet(params),
})?)
}
fn agent_focus(args: &[String]) -> std::io::Result<i32> {
let Some(target) = args.first() else {
eprintln!("usage: herdr agent focus <target>");
return Ok(2);
};
if args.len() != 1 {
eprintln!("usage: herdr agent focus <target>");
return Ok(2);
}
fn agent_focus(params: AgentTarget) -> std::io::Result<i32> {
super::print_response(&super::send_request(&Request {
id: "cli:agent:focus".into(),
method: Method::AgentFocus(AgentTarget {
target: target.clone(),
}),
method: Method::AgentFocus(params),
})?)
}
fn agent_attach(args: &[String]) -> std::io::Result<i32> {
let (target, takeover) =
match super::parse_attach_target(args, "usage: herdr agent attach <target> [--takeover]") {
Ok(parsed) => parsed,
Err(code) => return Ok(code),
};
fn agent_attach(params: bindings::Attach) -> std::io::Result<i32> {
let bindings::Attach { target, takeover } = params;
let response = resolve_agent_target(&target, "cli:agent:attach:resolve")?;
if response.get("error").is_some() {
@@ -503,59 +383,10 @@ fn agent_attach(args: &[String]) -> std::io::Result<i32> {
Ok(0)
}
fn agent_wait(args: &[String]) -> std::io::Result<i32> {
let Some(target) = args.first() else {
eprintln!("usage: herdr agent wait <target> [--until STATUS]... [--timeout MS]");
return Ok(2);
};
let mut until = Vec::new();
let mut timeout_ms = None;
let mut index = 1;
while index < args.len() {
match args[index].as_str() {
"--until" => {
let Some(value) = args.get(index + 1) else {
eprintln!("--until requires at least one status");
return Ok(2);
};
let status = match super::parse_agent_status(value) {
Ok(status) => status,
Err(err) => {
eprintln!("{err}");
return Ok(2);
}
};
until.push(status);
index += 2;
}
"--timeout" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --timeout");
return Ok(2);
};
timeout_ms = match parse_timeout(value) {
Ok(timeout_ms) => Some(timeout_ms),
Err(exit_code) => return Ok(exit_code),
};
index += 2;
}
"help" | "--help" | "-h" => {
eprintln!("usage: herdr agent wait <target> [--until STATUS]... [--timeout MS]");
return Ok(0);
}
other => {
eprintln!("unknown option: {other}");
return Ok(2);
}
}
}
fn agent_wait(params: AgentWaitParams) -> std::io::Result<i32> {
super::print_response(&super::send_request(&Request {
id: "cli:agent:wait".into(),
method: Method::AgentWait(AgentWaitParams {
target: target.clone(),
until,
timeout_ms,
}),
method: Method::AgentWait(params),
})?)
}
@@ -754,206 +585,32 @@ fn agent_get_request(target: &str, request_id: &str) -> Request {
}
}
fn agent_rename(args: &[String]) -> std::io::Result<i32> {
let [target, value] = args else {
eprintln!("usage: herdr agent rename <target> <name>|--clear");
return Ok(2);
};
let name = if value == "--clear" {
None
} else {
Some(value.clone())
};
fn agent_rename(params: AgentRenameParams) -> std::io::Result<i32> {
super::print_response(&super::send_request(&Request {
id: "cli:agent:rename".into(),
method: Method::AgentRename(AgentRenameParams {
target: target.clone(),
name,
}),
method: Method::AgentRename(params),
})?)
}
fn agent_prompt(args: &[String]) -> std::io::Result<i32> {
let Some(target) = args.first() else {
eprintln!(
"usage: herdr agent prompt <target> <text> [--wait] [--until STATUS]... [--timeout MS]"
);
return Ok(2);
};
let Some(text) = args.get(1) else {
eprintln!("agent prompt requires text");
return Ok(2);
};
let mut wait = false;
let mut until = Vec::new();
let mut timeout_ms = None;
let mut index = 2;
while index < args.len() {
match args[index].as_str() {
"--wait" => {
wait = true;
index += 1;
}
"--until" => {
let Some(value) = args.get(index + 1) else {
eprintln!("--until requires at least one status");
return Ok(2);
};
let status = match super::parse_agent_status(value) {
Ok(status) => status,
Err(err) => {
eprintln!("{err}");
return Ok(2);
}
};
until.push(status);
index += 2;
}
"--timeout" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --timeout");
return Ok(2);
};
timeout_ms = match parse_timeout(value) {
Ok(timeout_ms) => Some(timeout_ms),
Err(exit_code) => return Ok(exit_code),
};
index += 2;
}
option => {
eprintln!("unknown option: {option}");
return Ok(2);
}
}
}
if !until.is_empty() && !wait {
eprintln!("--until requires --wait");
return Ok(2);
}
if timeout_ms.is_some() && !wait {
eprintln!("--timeout requires --wait");
return Ok(2);
}
fn agent_prompt(params: AgentPromptParams) -> std::io::Result<i32> {
let response = super::send_request(&Request {
id: "cli:agent:prompt".into(),
method: Method::AgentPrompt(AgentPromptParams {
target: target.clone(),
text: text.clone(),
wait: wait.then_some(AgentPromptWaitOptions {
until,
timeout_ms,
submission_deadline: None,
}),
}),
method: Method::AgentPrompt(params),
})?;
super::print_response(&response)
}
fn agent_send_keys(args: &[String]) -> std::io::Result<i32> {
if args.len() < 2 {
eprintln!("usage: herdr agent send-keys <target> <key> [key ...]");
return Ok(2);
}
fn agent_send_keys(params: AgentSendKeysParams) -> std::io::Result<i32> {
super::print_response(&super::send_request(&Request {
id: "cli:agent:send-keys".into(),
method: Method::AgentSendKeys(AgentSendKeysParams {
target: args[0].clone(),
keys: args[1..].to_vec(),
}),
method: Method::AgentSendKeys(params),
})?)
}
fn agent_read(args: &[String]) -> std::io::Result<i32> {
let Some(target) = args.first() else {
eprintln!("usage: herdr agent read <target> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]");
return Ok(2);
};
let mut source = ReadSource::Recent;
let mut lines = None;
let mut format = ReadFormat::Text;
let mut strip_ansi = true;
let mut index = 1;
while index < args.len() {
match args[index].as_str() {
"--source" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --source");
return Ok(2);
};
source = super::parse_read_source(value)?;
index += 2;
}
"--lines" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --lines");
return Ok(2);
};
lines = Some(super::parse_u32_flag("--lines", value)?);
index += 2;
}
"--format" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --format");
return Ok(2);
};
format = super::parse_read_format(value)?;
strip_ansi = !matches!(format, ReadFormat::Ansi);
index += 2;
}
"--ansi" => {
format = ReadFormat::Ansi;
strip_ansi = false;
index += 1;
}
other => {
eprintln!("unknown option: {other}");
return Ok(2);
}
}
}
fn agent_read(params: AgentReadParams) -> std::io::Result<i32> {
let response = super::send_request(&Request {
id: "cli:agent:read".into(),
method: Method::AgentRead(AgentReadParams {
target: target.clone(),
source,
lines,
format,
strip_ansi,
}),
method: Method::AgentRead(params),
})?;
super::print_read_response(&response)
}
fn print_agent_help() {
eprintln!("herdr agent commands:");
eprintln!(" herdr agent list");
eprintln!(" herdr agent get <target>");
eprintln!(" herdr agent read <target> [--source visible|recent|recent-unwrapped|detection] [--lines N] [--format text|ansi] [--ansi]");
eprintln!(" herdr agent send-keys <target> <key> [key ...]");
eprintln!(" herdr agent prompt <target> <text> [--wait] [--until STATUS]... [--timeout MS]");
eprintln!(" herdr agent rename <target> <name>|--clear");
eprintln!(" herdr agent focus <target>");
eprintln!(" herdr agent wait <target> [--until STATUS]... [--timeout MS]");
eprintln!(" herdr agent attach <target> [--takeover]");
eprintln!(
" herdr agent start <name> --kind KIND --pane ID [--timeout MS] [-- <agent-args...>]"
);
eprintln!(" herdr agent explain <target> [--json|--format text|json] [--verbose]");
eprintln!(
" herdr agent explain --file PATH --agent LABEL [--json|--format text|json] [--verbose]"
);
eprintln!(" targets accept unique agent names and pane ids that currently host agents");
eprintln!(" kinds: {}", super::spec::agent_kind_values().join("|"));
}
fn parse_timeout(value: &str) -> Result<u64, i32> {
super::parse_u64_flag("--timeout", value).map_err(|err| {
eprintln!("{err}");
2
})
}
+311
View File
@@ -0,0 +1,311 @@
use clap::{ArgGroup, ArgMatches, Command};
use crate::api::schema::agents::{
AgentPromptProjection, AgentPromptWaitProjection, AgentReadProjection, AgentRenameProjection,
AgentSendKeysProjection, AgentStartProjection, AgentWaitProjection,
};
use crate::api::schema::{
AgentPromptParams, AgentReadParams, AgentRenameParams, AgentSendKeysParams, AgentStartParams,
AgentStatus, AgentTarget, AgentWaitParams, ReadFormat, ReadSource, READ_FORMATS, READ_SOURCES,
};
pub(crate) use crate::cli::projection::optional_text;
use crate::cli::projection::{
choices, cli_projection, parse_choice, Choice, Field, Input, ParseError,
};
const STATUSES: &[Choice<AgentStatus>] = &[
Choice::new("idle", AgentStatus::Idle),
Choice::new("working", AgentStatus::Working),
Choice::new("blocked", AgentStatus::Blocked),
Choice::new("done", AgentStatus::Done),
Choice::new("unknown", AgentStatus::Unknown),
];
const EXPLAIN_FORMATS: &[Choice<bool>] = &[Choice::new("text", false), Choice::new("json", true)];
pub(crate) fn target() -> Input<String> {
text_position("target", "TARGET", 0)
}
pub(crate) fn text_position(
name: &'static str,
value_name: &'static str,
index: usize,
) -> Input<String> {
Input::positional(name, index, |slot, value| {
*slot = value.to_owned();
Ok(())
})
.value_name(value_name)
}
pub(crate) fn until() -> Input<Vec<AgentStatus>> {
Input::<Vec<AgentStatus>>::value("until", "STATUS", |slot, value| {
slot.push(parse_choice(STATUSES, "agent status", value)?);
Ok(())
})
.configure(|arg| choices(arg, STATUSES))
}
pub(crate) fn timeout() -> Input<Option<u64>> {
Input::value("timeout", "MS", |slot, value| {
*slot = Some(super::super::parse_u64_flag("--timeout", value)?);
Ok(())
})
.configure(|arg| arg.value_parser(clap::builder::ValueParser::new(validate_timeout)))
}
fn validate_timeout(value: &str) -> Result<String, String> {
super::super::parse_u64_flag("--timeout", value)
.map(|_| value.to_owned())
.map_err(|error| error.to_string())
}
pub(crate) fn source() -> Input<ReadSource> {
Input::value("source", "SOURCE", |slot, value| {
*slot = parse_choice(READ_SOURCES, "read source", value)?;
Ok(())
})
.configure(|arg| choices(arg, READ_SOURCES))
}
pub(crate) fn format() -> Input<ReadFormat> {
Input::value("format", "FORMAT", |slot, value| {
*slot = parse_choice(READ_FORMATS, "read format", value)?;
Ok(())
})
.configure(|arg| choices(arg, READ_FORMATS))
}
pub(crate) fn ansi() -> Input<ReadFormat> {
Input::flag("ansi", |slot, _| {
*slot = ReadFormat::Ansi;
Ok(())
})
}
pub(crate) fn lines() -> Input<Option<u32>> {
Input::value("lines", "N", |slot, value| {
*slot = Some(super::super::parse_u32_flag("--lines", value)?);
Ok(())
})
}
pub(crate) fn keys() -> Input<Vec<String>> {
Input::<Vec<String>>::positional("key", 1, |slot, value| {
slot.push(value.to_owned());
Ok(())
})
.value_name("KEY")
.configure(|arg| arg.num_args(1..))
.verbatim()
}
pub(crate) fn agent_args() -> Input<Vec<String>> {
Input::<Vec<String>>::positional("agent_args", 1, |slot, value| {
slot.push(value.to_owned());
Ok(())
})
.value_name("AGENT_ARG")
.configure(|arg| arg.required(false).num_args(0..).last(true))
}
pub(crate) fn kind() -> Input<String> {
Input::value("kind", "KIND", |slot, value| {
*slot = value.to_owned();
Ok(())
})
.required()
.configure(|arg| arg.value_parser(AgentKindCodec))
.help("Supported agent kind and canonical executable")
}
/// Lists kinds for help and completion. Only the final `--kind` is validated, by
/// `agent_start`, so an overwritten earlier value is not rejected.
#[derive(Clone)]
struct AgentKindCodec;
impl clap::builder::TypedValueParser for AgentKindCodec {
type Value = String;
fn parse_ref(
&self,
command: &Command,
argument: Option<&clap::Arg>,
value: &std::ffi::OsStr,
) -> Result<String, clap::Error> {
clap::builder::StringValueParser::new().parse_ref(command, argument, value)
}
fn possible_values(
&self,
) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
Some(Box::new(crate::detect::Agent::ALL.into_iter().map(
|agent| clap::builder::PossibleValue::new(crate::detect::agent_label(agent)),
)))
}
}
pub(crate) fn pane() -> Input<String> {
Input::value("pane", "ID", |slot, value| {
*slot = super::super::normalize_pane_id(value);
Ok(())
})
.required()
.help("Existing pane at an interactive shell prompt")
}
pub(crate) fn rename_name() -> Input<Option<String>> {
Input::positional("name", 1, |slot, value| {
*slot = Some(value.to_owned());
Ok(())
})
.value_name("NAME")
.verbatim()
.configure(|arg| arg.required(false).conflicts_with("clear"))
}
pub(crate) fn clear_name() -> Input<Option<String>> {
Input::flag("clear", |slot, _| {
*slot = None;
Ok(())
})
}
pub(crate) fn wait_presence() -> Input<Option<crate::api::schema::AgentPromptWaitOptions>> {
Input::flag("wait", |slot, _| {
*slot = Some(crate::api::schema::AgentPromptWaitOptions {
until: Vec::new(),
timeout_ms: None,
submission_deadline: None,
});
Ok(())
})
.help("Wait for the first matching state observed after submission")
}
cli_projection! {
pub(crate) TargetProjection for AgentTarget {
target => Field::new(String::new()).input(target()),
}
}
pub(crate) struct Attach {
pub(crate) target: String,
pub(crate) takeover: bool,
}
cli_projection! {
AttachProjection for Attach {
target => Field::new(String::new()).input(target()),
takeover => Field::new(false).input(Input::flag("takeover", |slot, _| { *slot = true; Ok(()) })),
}
}
pub(crate) struct Explain {
pub(crate) target: Option<String>,
pub(crate) file: Option<String>,
pub(crate) agent: Option<String>,
pub(crate) json: bool,
pub(crate) verbose: bool,
}
cli_projection! {
ExplainProjection for Explain {
target => Field::new(None).input(Input::positional("target", 0, |slot, value| {
*slot = Some(value.to_owned()); Ok(())
}).value_name("TARGET").configure(|arg| arg.required(false).allow_hyphen_values(false).conflicts_with("file"))),
file => Field::new(None).input(optional_text("file", "PATH").path().configure(|arg| arg.requires("agent"))),
agent => Field::new(None).input(optional_text("agent", "LABEL").configure(|arg| arg.requires("file").conflicts_with("target"))),
json => Field::new(false)
.input(Input::flag("json", |slot, _| { *slot = true; Ok(()) }))
.input(Input::value("format", "FORMAT", |slot, value| { *slot = parse_choice(EXPLAIN_FORMATS, "explain format", value)?; Ok(()) })
.configure(|arg| choices(arg, EXPLAIN_FORMATS))),
verbose => Field::new(false).input(Input::flag("verbose", |slot, _| { *slot = true; Ok(()) }).configure(|arg| arg.short('v'))),
}
}
pub(crate) enum Operation {
List,
Get(AgentTarget),
Focus(AgentTarget),
Read(AgentReadParams),
SendKeys(AgentSendKeysParams),
Rename(AgentRenameParams),
Wait(AgentWaitParams),
Prompt(AgentPromptParams),
Start(AgentStartParams),
Attach(Attach),
Explain(Explain),
}
fn leaf(name: &'static str, arguments: Vec<clap::Arg>, about: &'static str) -> Command {
crate::cli::projection::command(name, arguments).about(about)
}
pub(crate) fn command() -> Command {
Command::new("agent")
.about("Control and inspect agent panes")
.subcommand_required(true)
.subcommand(leaf("list", Vec::new(), "List agents"))
.subcommand(leaf("get", TargetProjection::arguments(), "Show an agent"))
.subcommand(leaf("focus", TargetProjection::arguments(), "Focus an agent"))
.subcommand(leaf("read", AgentReadProjection::arguments(), "Read agent terminal output")
.override_usage("herdr agent read <TARGET> [OPTIONS]"))
.subcommand(leaf("send-keys", AgentSendKeysProjection::arguments(), "Send key presses to an agent")
.after_help("Use esc as the canonical Escape key name; escape is also accepted."))
.subcommand(leaf("rename", AgentRenameProjection::arguments(), "Rename an agent")
.override_usage("herdr agent rename <TARGET> <NAME>|--clear")
.group(ArgGroup::new("rename").args(["name", "clear"]).required(true)))
.subcommand(leaf("wait", AgentWaitProjection::arguments(), "Wait until an agent reaches one of the requested states")
.override_usage("herdr agent wait <TARGET> [OPTIONS]")
.after_help("Without --until, matches idle, done, or blocked. Use --until unknown explicitly when needed. Without --timeout, waits indefinitely."))
.subcommand(leaf("prompt", AgentPromptProjection::arguments(), "Submit a prompt to an agent")
.args(AgentPromptWaitProjection::arguments().into_iter().map(|arg| arg.requires("wait")))
.override_usage("herdr agent prompt <TARGET> <TEXT> [OPTIONS]")
.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 requires an observed working or blocked state within 5000ms; otherwise it returns agent_prompt_stalled. A caller timeout that expires first returns timeout. 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."))
.subcommand(leaf("start", AgentStartProjection::arguments(), "Start a supported interactive agent in an existing pane")
.override_usage("herdr agent start <NAME> --kind <KIND> --pane <ID> [OPTIONS] [-- [AGENT_ARG]...]")
.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(leaf("attach", AttachProjection::arguments(), "Attach directly to an agent terminal")
.override_usage("herdr agent attach <TARGET> [OPTIONS]"))
.subcommand(leaf("explain", ExplainProjection::arguments(), "Explain agent detection state")
.group(ArgGroup::new("explain_source").args(["target", "file"]).required(true)))
}
pub(crate) fn from_matches(matches: &ArgMatches) -> Result<Operation, ParseError> {
let Some((name, matches)) = matches.subcommand() else {
return Err(ParseError::Usage("missing agent command".into()));
};
Ok(match name {
"list" => Operation::List,
"get" => Operation::Get(TargetProjection::from_matches(matches)?),
"focus" => Operation::Focus(TargetProjection::from_matches(matches)?),
"read" => {
let mut params = AgentReadProjection::from_matches(matches)?;
params.strip_ansi = !matches!(params.format, ReadFormat::Ansi);
Operation::Read(params)
}
"send-keys" => Operation::SendKeys(AgentSendKeysProjection::from_matches(matches)?),
"rename" => Operation::Rename(AgentRenameProjection::from_matches(matches)?),
"wait" => Operation::Wait(AgentWaitProjection::from_matches(matches)?),
"prompt" => {
let mut params = AgentPromptProjection::from_matches(matches)?;
if params.wait.is_some() {
params.wait = Some(AgentPromptWaitProjection::from_matches(matches)?);
}
Operation::Prompt(params)
}
"start" => Operation::Start(AgentStartProjection::from_matches(matches)?),
"attach" => Operation::Attach(AttachProjection::from_matches(matches)?),
"explain" => Operation::Explain(ExplainProjection::from_matches(matches)?),
_ => return Err(ParseError::Usage(format!("unknown agent command: {name}"))),
})
}
#[cfg(test)]
mod choice_tests {
#[test]
fn agent_statuses_cover_every_api_value() {
crate::cli::projection::assert_choices_cover_api_enum(super::STATUSES);
}
}
+214
View File
@@ -0,0 +1,214 @@
use super::bindings::{self, Operation};
use crate::api::schema::{Method, Request};
use crate::cli::projection::{self, ParseError};
fn parse(args: &[&str]) -> Result<Operation, ParseError> {
projection::parse(
super::command(),
&args.iter().map(|arg| (*arg).to_owned()).collect::<Vec<_>>(),
bindings::from_matches,
)
}
fn params(args: &[&str]) -> serde_json::Value {
let method = match parse(args).unwrap() {
Operation::Get(params) => Method::AgentGet(params),
Operation::Focus(params) => Method::AgentFocus(params),
Operation::Read(params) => Method::AgentRead(params),
Operation::SendKeys(params) => Method::AgentSendKeys(params),
Operation::Rename(params) => Method::AgentRename(params),
Operation::Wait(params) => Method::AgentWait(params),
Operation::Prompt(params) => Method::AgentPrompt(params),
Operation::Start(params) => Method::AgentStart(params),
_ => panic!("expected API params"),
};
serde_json::to_value(Request {
id: "test".into(),
method,
})
.unwrap()["params"]
.clone()
}
#[test]
fn agent_projection_defaults_and_ordered_read_policy() {
for (args, expected) in [
(
vec!["read", "opaque"],
serde_json::json!({"target":"opaque", "source":"recent", "format":"text", "strip_ansi":true}),
),
(
vec!["read", "opaque", "--format", "ansi"],
serde_json::json!({"target":"opaque", "source":"recent", "format":"ansi", "strip_ansi":false}),
),
(
vec!["read", "opaque", "--ansi", "--format", "text"],
serde_json::json!({"target":"opaque", "source":"recent", "format":"text", "strip_ansi":true}),
),
(
vec!["read", "opaque", "--format", "text", "--ansi"],
serde_json::json!({"target":"opaque", "source":"recent", "format":"ansi", "strip_ansi":false}),
),
(
vec!["read", "opaque", "--source=recent_unwrapped", "--lines=0"],
serde_json::json!({"target":"opaque", "source":"recent_unwrapped", "lines":0, "format":"text", "strip_ansi":true}),
),
] {
assert_eq!(params(&args), expected, "{args:?}");
}
assert_eq!(
params(&["read", "--format=ansi", "opaque"]),
serde_json::json!({"target":"opaque", "source":"recent", "format":"ansi", "strip_ansi":false})
);
assert_eq!(
params(&["wait", "opaque"]),
serde_json::json!({"target":"opaque"})
);
}
#[test]
fn agent_projection_nested_wait_omission_and_repeated_statuses() {
for text in ["--wait", "--until", "--timeout", "--help", ""] {
assert_eq!(
params(&["prompt", "opaque", text]),
serde_json::json!({"target":"opaque", "text":text})
);
assert_eq!(
params(&["prompt", "opaque", text, "--wait"]),
serde_json::json!({"target":"opaque", "text":text, "wait":{}})
);
}
assert_eq!(
params(&["prompt", "opaque", "exact text"]),
serde_json::json!({"target":"opaque", "text":"exact text"})
);
assert_eq!(
params(&["prompt", "opaque", "exact text", "--wait"]),
serde_json::json!({"target":"opaque", "text":"exact text", "wait":{}})
);
assert_eq!(
params(&[
"prompt",
"opaque",
"exact text",
"--until",
"idle",
"--until",
"idle",
"--until",
"blocked",
"--timeout=0",
"--wait"
]),
serde_json::json!({"target":"opaque", "text":"exact text", "wait":{"until":["idle","idle","blocked"], "timeout_ms":0}})
);
assert_eq!(
params(&[
"wait",
"opaque",
"--until",
"done",
"--until",
"done",
"--timeout",
"10",
"--timeout",
"0"
]),
serde_json::json!({"target":"opaque", "until":["done","done"], "timeout_ms":0})
);
}
#[test]
fn agent_projection_raw_vectors_and_start_defaults() {
assert_eq!(
params(&["send-keys", "opaque", "esc", "--literal", "two words", ""]),
serde_json::json!({"target":"opaque", "keys":["esc","--literal","two words",""]})
);
assert_eq!(
params(&["start", "name", "--kind", "codex", "--pane", "1"]),
serde_json::json!({"name":"name", "kind":"codex", "pane_id":"1"})
);
assert_eq!(
params(&["start", "name", "--kind=claude-code", "--pane=1"]),
serde_json::json!({"name":"name", "kind":"claude-code", "pane_id":"1"})
);
assert_eq!(
params(&[
"start",
"name",
"--kind=codex",
"--pane=1",
"--timeout=0",
"--",
"--help",
"two words",
"",
"--"
]),
serde_json::json!({"name":"name", "kind":"codex", "pane_id":"1", "timeout_ms":0, "args":["--help","two words","","--"]})
);
}
#[test]
fn agent_projection_rename_and_local_adapters() {
assert_eq!(
params(&["rename", "opaque", "name"]),
serde_json::json!({"target":"opaque", "name":"name"})
);
assert_eq!(
params(&["rename", "opaque", "--clear"]),
serde_json::json!({"target":"opaque"})
);
let Operation::Attach(params) = parse(&["attach", "opaque", "--takeover"]).unwrap() else {
panic!("attach")
};
assert_eq!(params.target, "opaque");
assert!(params.takeover);
let Operation::Explain(params) = parse(&[
"explain",
"--file",
"file",
"--agent",
"codex",
"--json",
"--format=text",
"-v",
])
.unwrap() else {
panic!("explain")
};
assert_eq!(params.file.as_deref(), Some("file"));
assert_eq!(params.agent.as_deref(), Some("codex"));
assert!(!params.json);
assert!(params.verbose);
}
#[test]
fn agent_projection_clap_relations_and_help_before_transport() {
super::command().debug_assert();
for args in [
vec!["get"],
vec!["rename", "opaque"],
vec!["rename", "opaque", "name", "--clear"],
vec!["prompt", "opaque", "text", "--until=idle"],
vec!["prompt", "opaque", "text", "--timeout=0"],
vec!["wait", "opaque", "--timeout=bad"],
vec!["wait", "opaque", "--until=invalid"],
vec!["start", "name", "--kind=codex"],
vec!["explain", "--file=file"],
vec!["explain", "opaque", "--agent=codex"],
] {
assert!(
matches!(parse(&args), Err(ParseError::Clap(error)) if error.exit_code() == 2),
"{args:?}"
);
}
for leaf in super::command().get_subcommands() {
assert!(
matches!(parse(&[leaf.get_name(), "--help"]), Err(ParseError::Clap(error)) if error.exit_code() == 0),
"{}",
leaf.get_name()
);
}
}
+139 -1
View File
@@ -28,7 +28,9 @@ pub(super) fn run_completion_command(args: &[String]) -> std::io::Result<i32> {
return Ok(2);
};
let mut command = super::spec::command();
let mut source = super::spec::command();
source.build();
let mut command = completion_command(&source);
if matches!(shell, Shell::Zsh) {
let mut output = Vec::new();
generate(shell, &mut command, "herdr", &mut output);
@@ -44,6 +46,61 @@ pub(super) fn run_completion_command(args: &[String]) -> std::io::Result<i32> {
Ok(0)
}
// Completion generators do not consistently filter hidden metadata. This tree is
// presentation metadata only: parsing and its relations remain on the source.
fn completion_command(source: &clap::Command) -> clap::Command {
let mut command = clap::Command::new(source.get_name().to_owned())
.disable_help_flag(true)
.disable_help_subcommand(true)
.disable_version_flag(true)
.visible_aliases(source.get_visible_aliases().map(str::to_owned));
if let Some(about) = source.get_about() {
command = command.about(about.clone());
}
for source_arg in source.get_arguments().filter(|arg| !arg.is_hide_set()) {
let mut arg = clap::Arg::new(source_arg.get_id().clone())
.action(source_arg.get_action().clone())
.required(source_arg.is_required_set())
.value_hint(source_arg.get_value_hint());
if let Some(long) = source_arg.get_long() {
arg = arg.long(long.to_owned());
}
if let Some(short) = source_arg.get_short() {
arg = arg.short(short);
}
if let Some(aliases) = source_arg.get_visible_aliases() {
arg = arg.visible_aliases(aliases.into_iter().map(str::to_owned));
}
if let Some(aliases) = source_arg.get_visible_short_aliases() {
arg = arg.visible_short_aliases(aliases);
}
if let Some(help) = source_arg.get_help() {
arg = arg.help(help.clone());
}
if let Some(names) = source_arg.get_value_names() {
arg = arg.value_names(names.iter().cloned());
}
if let Some(arity) = source_arg.get_num_args() {
arg = arg.num_args(arity);
}
if let Some(index) = source_arg.get_index() {
arg = arg.index(index);
}
if let Some(values) = source_arg.get_value_parser().possible_values() {
arg = arg.value_parser(clap::builder::PossibleValuesParser::new(
values.filter(|value| !value.is_hide_set()),
));
}
command = command.arg(arg);
}
command.subcommands(
source
.get_subcommands()
.filter(|command| !command.is_hide_set())
.map(completion_command),
)
}
fn space_separated_zsh_long_options(script: &str) -> String {
let mut output = String::with_capacity(script.len());
let mut rest = script;
@@ -88,6 +145,87 @@ fn print_completion_help() {
#[cfg(test)]
mod tests {
fn script(shell: clap_complete::Shell, source: &clap::Command) -> String {
let mut command = super::completion_command(source);
let mut output = Vec::new();
clap_complete::generate(shell, &mut command, "herdr", &mut output);
String::from_utf8(output).expect("completion is utf8")
}
fn advertises(script: &str, flag: &str) -> bool {
script.contains(&format!("--{flag}")) || script.contains(&format!("-l {flag}"))
}
#[test]
fn completions_hide_private_commands_arguments_and_values() {
let mut root = crate::cli::spec::command();
root.build();
let family = |name: &str| root.find_subcommand(name).expect("family");
let plugin = family("plugin");
let open = plugin
.find_subcommand("pane")
.and_then(|pane| pane.find_subcommand("open"))
.expect("plugin pane open");
let worktree = family("worktree");
for shell in super::SUPPORTED_SHELLS {
let shell = super::parse_shell(shell).expect("supported shell");
let full = script(shell, &root);
assert!(!full.contains("live-handoff"), "{shell}: hidden command");
assert!(!advertises(&full, "group"), "{shell}: hidden group");
assert!(advertises(&full, "version"), "{shell}: public root flag");
let worktree = script(shell, worktree);
assert!(
!advertises(&worktree, "json"),
"{shell}: hidden worktree json"
);
assert!(
advertises(&worktree, "base"),
"{shell}: public worktree flag"
);
let plugin = script(shell, open);
for hidden in ["width", "height"] {
assert!(!advertises(&plugin, hidden), "{shell}: hidden {hidden}");
}
for hidden in ["popup", "fullscreen"] {
assert!(!plugin.contains(hidden), "{shell}: hidden {hidden}");
}
assert!(
advertises(&plugin, "placement"),
"{shell}: public placement"
);
}
}
#[test]
fn projection_preserves_completion_metadata_without_execution_relations() {
let mut source = clap::Command::new("demo")
.visible_alias("visible")
.arg(
clap::Arg::new("path")
.long("path")
.visible_alias("file")
.short('p')
.visible_short_alias('f')
.value_hint(clap::ValueHint::FilePath)
.requires("private"),
)
.arg(clap::Arg::new("private").long("private").hide(true));
source.build();
let mut projected = super::completion_command(&source);
projected.build();
assert_eq!(
projected.get_visible_aliases().collect::<Vec<_>>(),
["visible"]
);
let arg = projected
.get_arguments()
.find(|arg| arg.get_id() == "path")
.expect("path");
assert_eq!(arg.get_visible_aliases(), Some(vec!["file"]));
assert_eq!(arg.get_visible_short_aliases(), Some(vec!['f']));
assert_eq!(arg.get_value_hint(), clap::ValueHint::FilePath);
}
#[test]
fn parses_supported_shells() {
assert!(matches!(
+5 -204
View File
@@ -1,207 +1,8 @@
use crate::api::schema::{Method, NotificationShowParams, NotificationShowSound, Request};
use crate::config::ToastHerdrPosition;
#[path = "notification_bindings.rs"]
mod bindings;
pub(crate) use bindings::command;
pub(super) fn run_notification_command(args: &[String]) -> std::io::Result<i32> {
let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
print_notification_help();
return Ok(2);
};
match subcommand {
"show" => notification_show(&args[1..]),
"help" | "--help" | "-h" => {
print_notification_help();
Ok(0)
}
_ => {
print_notification_help();
Ok(2)
}
}
}
fn notification_show(args: &[String]) -> std::io::Result<i32> {
let params = match parse_notification_show_args(args) {
Ok(params) => params,
Err(NotificationShowArgError::Usage) => {
eprintln!(
"usage: herdr notification show <title> [--body TEXT] [--position top-left|top-right|bottom-left|bottom-right] [--sound none|done|request]"
);
return Ok(2);
}
Err(NotificationShowArgError::Message(message)) => {
eprintln!("{message}");
return Ok(2);
}
};
super::print_response(&super::send_request(&Request {
id: "cli:notification:show".into(),
method: Method::NotificationShow(params),
})?)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum NotificationShowArgError {
Usage,
Message(String),
}
fn parse_notification_show_args(
args: &[String],
) -> Result<NotificationShowParams, NotificationShowArgError> {
let Some(title) = args.first().cloned() else {
return Err(NotificationShowArgError::Usage);
};
if matches!(title.as_str(), "help" | "--help" | "-h") {
return Err(NotificationShowArgError::Usage);
}
let mut body = None;
let mut position = None;
let mut sound = NotificationShowSound::None;
let mut index = 1;
while index < args.len() {
match args[index].as_str() {
"--body" => {
let Some(value) = args.get(index + 1) else {
return Err(NotificationShowArgError::Message(
"missing value for --body".into(),
));
};
body = Some(value.clone());
index += 2;
}
"--position" => {
let Some(value) = args.get(index + 1) else {
return Err(NotificationShowArgError::Message(
"missing value for --position".into(),
));
};
position = Some(parse_toast_position(value)?);
index += 2;
}
"--sound" => {
let Some(value) = args.get(index + 1) else {
return Err(NotificationShowArgError::Message(
"missing value for --sound".into(),
));
};
sound = parse_notification_sound(value)?;
index += 2;
}
other => {
return Err(NotificationShowArgError::Message(format!(
"unknown option: {other}"
)));
}
}
}
Ok(NotificationShowParams {
title,
body,
position,
sound,
})
}
fn parse_toast_position(value: &str) -> Result<ToastHerdrPosition, NotificationShowArgError> {
match value {
"top-left" => Ok(ToastHerdrPosition::TopLeft),
"top-right" => Ok(ToastHerdrPosition::TopRight),
"bottom-left" => Ok(ToastHerdrPosition::BottomLeft),
"bottom-right" => Ok(ToastHerdrPosition::BottomRight),
_ => Err(NotificationShowArgError::Message(format!(
"invalid position: {value} (expected top-left, top-right, bottom-left, or bottom-right)"
))),
}
}
fn parse_notification_sound(
value: &str,
) -> Result<NotificationShowSound, NotificationShowArgError> {
match value {
"none" => Ok(NotificationShowSound::None),
"done" => Ok(NotificationShowSound::Done),
"request" => Ok(NotificationShowSound::Request),
_ => Err(NotificationShowArgError::Message(format!(
"invalid sound: {value} (expected none, done, or request)"
))),
}
}
fn print_notification_help() {
eprintln!("herdr notification commands:");
eprintln!(
" herdr notification show <title> [--body TEXT] [--position top-left|top-right|bottom-left|bottom-right] [--sound none|done|request]"
);
}
#[cfg(test)]
mod tests {
use super::*;
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn notification_show_args_parse_title_body_and_position() {
let params = parse_notification_show_args(&args(&[
"build failed",
"--body",
"api workspace",
"--position",
"top-right",
"--sound",
"request",
]))
.unwrap();
assert_eq!(
params,
NotificationShowParams {
title: "build failed".into(),
body: Some("api workspace".into()),
position: Some(ToastHerdrPosition::TopRight),
sound: NotificationShowSound::Request,
}
);
}
#[test]
fn notification_show_args_reject_invalid_position() {
let error =
parse_notification_show_args(&args(&["build failed", "--position", "top-center"]))
.unwrap_err();
assert_eq!(
error,
NotificationShowArgError::Message(
"invalid position: top-center (expected top-left, top-right, bottom-left, or bottom-right)"
.into()
)
);
}
#[test]
fn notification_show_args_default_sound_is_none() {
let params = parse_notification_show_args(&args(&["build failed"])).unwrap();
assert_eq!(params.sound, NotificationShowSound::None);
}
#[test]
fn notification_show_args_reject_invalid_sound() {
let error =
parse_notification_show_args(&args(&["build failed", "--sound", "loud"])).unwrap_err();
assert_eq!(
error,
NotificationShowArgError::Message(
"invalid sound: loud (expected none, done, or request)".into()
)
);
}
super::run_json_request(bindings::request(args))
}
+124
View File
@@ -0,0 +1,124 @@
use clap::Command;
use crate::api::schema::common::NotificationShowProjection;
use crate::api::schema::{Method, Request};
use super::super::projection::{self, ParseError};
pub(crate) fn command() -> Command {
Command::new("notification")
.about("Show Herdr notifications")
.subcommand_required(true)
.subcommand(
projection::command("show", NotificationShowProjection::arguments())
.about("Show a notification"),
)
}
pub(crate) fn request(args: &[String]) -> Result<Request, ParseError> {
// A bare `help` title historically asks for usage instead of showing a notification.
// The title is a verbatim slot, so route through the help subcommand.
let help_args;
let args = match args {
[show, title, ..] if show == "show" && title == "help" => {
help_args = ["help".to_owned(), show.clone()];
&help_args[..]
}
_ => args,
};
projection::parse(command().bin_name("herdr notification"), args, |matches| {
let matches = matches
.subcommand_matches("show")
.ok_or_else(|| ParseError::Usage("missing notification show command".into()))?;
Ok(Request {
id: "cli:notification:show".into(),
method: Method::NotificationShow(NotificationShowProjection::from_matches(matches)?),
})
})
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Request, ParseError> {
request(&args.iter().map(|arg| (*arg).to_owned()).collect::<Vec<_>>())
}
#[test]
fn notification_projection_preserves_request_literals_and_last_wins() {
for (args, expected) in [
(
vec!["show", "build failed"],
serde_json::json!({"id":"cli:notification:show", "method":"notification.show", "params":{"title":"build failed"}}),
),
(
vec![
"show",
"--literal-title",
"--body",
"first",
"--body",
"--literal-body",
"--position",
"bottom-left",
"--position",
"top-right",
"--sound",
"done",
"--sound",
"request",
],
serde_json::json!({"id":"cli:notification:show", "method":"notification.show", "params":{"title":"--literal-title", "body":"--literal-body", "position":"top-right", "sound":"request"}}),
),
(
vec!["show", "title", "--sound=none"],
serde_json::json!({"id":"cli:notification:show", "method":"notification.show", "params":{"title":"title"}}),
),
] {
assert_eq!(
serde_json::to_value(parse(&args).unwrap()).unwrap(),
expected
);
}
}
#[test]
fn notification_projection_preserves_all_cli_enum_values() {
for position in ["top-left", "top-right", "bottom-left", "bottom-right"] {
assert_eq!(
serde_json::to_value(parse(&["show", "title", "--position", position]).unwrap())
.unwrap()["params"]["position"],
position
);
}
for sound in ["done", "request"] {
assert_eq!(
serde_json::to_value(parse(&["show", "title", "--sound", sound]).unwrap()).unwrap()
["params"]["sound"],
sound
);
}
command().debug_assert();
}
#[test]
fn notification_projection_clap_validation_and_help_before_transport() {
for args in [
vec!["show"],
vec!["show", "title", "extra"],
vec!["show", "title", "--body"],
vec!["show", "title", "--position", "top_right"],
vec!["show", "title", "--sound", "DONE"],
] {
assert!(matches!(parse(&args), Err(ParseError::Clap(error)) if error.exit_code() == 2));
}
assert!(
matches!(parse(&["show", "title", "--help"]), Err(ParseError::Clap(error)) if error.exit_code() == 0)
);
assert_eq!(
serde_json::to_value(parse(&["show", "title", "--sound=request"]).unwrap()).unwrap(),
serde_json::json!({"id":"cli:notification:show", "method":"notification.show", "params":{"title":"title", "sound":"request"}})
);
}
}
+863 -1956
View File
File diff suppressed because it is too large Load Diff
+751
View File
@@ -0,0 +1,751 @@
use std::collections::HashMap;
use clap::{ArgMatches, Command};
use crate::api::schema::panes::*;
use crate::api::schema::{
Method, OutputMatch, PaneAgentState, PaneDirection, PaneTarget, PaneWaitForOutputParams,
ReadFormat, ReadSource, Request, SplitDirection, READ_FORMATS, READ_SOURCES,
};
pub(crate) use crate::cli::projection::parse_choice;
use crate::cli::projection::{choices, choices_where, Choice};
pub(crate) const DIRECTIONS: &[Choice<PaneDirection>] = &[
Choice::new("left", PaneDirection::Left),
Choice::new("right", PaneDirection::Right),
Choice::new("up", PaneDirection::Up),
Choice::new("down", PaneDirection::Down),
];
pub(crate) const SPLITS: &[Choice<SplitDirection>] = &[
Choice::new("right", SplitDirection::Right),
Choice::new("down", SplitDirection::Down),
];
const RIGHT_CLICK: &[Choice<PaneRightClickTarget>] = &[
Choice::new("herdr", PaneRightClickTarget::Herdr),
Choice::new("pane", PaneRightClickTarget::Pane),
];
const STATES: &[Choice<PaneAgentState>] = &[
Choice::new("idle", PaneAgentState::Idle),
Choice::new("working", PaneAgentState::Working),
Choice::new("blocked", PaneAgentState::Blocked),
Choice::new("unknown", PaneAgentState::Unknown),
];
pub(crate) fn state() -> Input<PaneAgentState> {
Input::value("state", "STATE", |slot, value| {
*slot = parse_choice(STATES, "agent state", value)?;
Ok(())
})
.required()
.configure(|arg| choices(arg, STATES))
}
use crate::cli::projection::{self, cli_projection, Field, Input, ParseError};
pub(crate) use crate::cli::projection::{boolean, optional_text};
pub(crate) fn pane_id() -> Input<String> {
Input::positional("pane_id", 0, |slot, value| {
*slot = crate::cli::normalize_pane_id(value);
Ok(())
})
.value_name("PANE_ID")
}
pub(crate) fn text(name: &'static str, value: &'static str) -> Input<String> {
Input::value(name, value, |slot, value| {
*slot = value.to_owned();
Ok(())
})
}
pub(crate) fn target(positional: bool) -> Field<Option<String>> {
let mut field = Field::new(None)
.input(Input::value("pane", "ID", |slot, value| {
*slot = Some(crate::cli::normalize_pane_id(value));
Ok(())
}))
.input(Input::flag("current", |slot, _| {
*slot = None;
Ok(())
}));
if positional {
field = field.input(
Input::positional("pane_id", 0, |slot, value| {
// Single-dash ids are accepted; `--` words are mistyped options.
if value.starts_with("--") {
return Err(ParseError::Usage(format!("unknown option: {value}")));
}
*slot = Some(crate::cli::normalize_pane_id(value));
Ok(())
})
.value_name("PANE_ID")
.configure(|arg| arg.required(false).allow_hyphen_values(true)),
);
}
field
}
pub(crate) fn input_target() -> Field<String> {
Field::new(String::new())
.input(pane_id().configure(|arg| {
arg.required(false)
.allow_hyphen_values(false)
.conflicts_with_all(["pane", "current"])
}))
.input(
Input::value("pane", "ID", |slot, value| {
*slot = crate::cli::normalize_pane_id(value);
Ok(())
})
.configure(|arg| arg.conflicts_with("current")),
)
.input(Input::flag("current", |_, _| Ok(())))
}
pub(crate) fn direction() -> Input<crate::api::schema::PaneDirection> {
Input::value("direction", "DIRECTION", |slot, value| {
*slot = super::direction(value)?;
Ok(())
})
.required()
.configure(|arg| choices(arg, DIRECTIONS))
}
pub(crate) fn optional_direction() -> Input<Option<crate::api::schema::PaneDirection>> {
Input::value("direction", "DIRECTION", |slot, value| {
*slot = Some(super::direction(value)?);
Ok(())
})
.configure(|arg| choices(arg, DIRECTIONS))
}
pub(crate) fn split_direction() -> Input<SplitDirection> {
Input::value("direction", "DIRECTION", |slot, value| {
*slot = parse_choice(SPLITS, "split direction", value)?;
Ok(())
})
.required()
.configure(|arg| choices(arg, SPLITS))
}
pub(crate) fn finite(name: &'static str) -> Input<Option<f32>> {
let update: fn(&mut Option<f32>, &str) -> Result<(), ParseError> = if name == "ratio" {
|slot, value| {
*slot = Some(super::finite("ratio", value)?);
Ok(())
}
} else {
|slot, value| {
*slot = Some(super::finite("amount", value)?);
Ok(())
}
};
Input::value(name, "FLOAT", update)
}
pub(crate) fn right_click() -> Input<PaneRightClickTarget> {
Input::value("right-click", "TARGET", |slot, value| {
*slot = parse_choice(RIGHT_CLICK, "right-click target", value)?;
Ok(())
})
.configure(|arg| choices(arg, RIGHT_CLICK))
}
pub(crate) fn environment() -> Input<HashMap<String, String>> {
Input::<HashMap<String, String>>::value("env", "KEY=VALUE", |slot, value| {
let (key, value) = crate::cli::parse_env_assignment(value)?;
slot.insert(key, value);
Ok(())
})
.help("Set an environment variable for the launched process")
}
pub(crate) fn joined(name: &'static str, value: &'static str) -> Input<String> {
Input::rest(name, value, 1, |slot, value| {
*slot = value.to_owned();
Ok(())
})
.verbatim()
}
pub(crate) fn keys() -> Input<Vec<String>> {
Input::positional("key", 1, |slot: &mut Vec<String>, value| {
slot.push(value.to_owned());
Ok(())
})
.value_name("KEY")
.configure(|arg| arg.num_args(1..))
.verbatim()
}
pub(crate) fn label() -> Field<Option<String>> {
Field::new(None)
.input(
Input::rest("label", "LABEL", 1, |slot, value| {
*slot = Some(value.to_owned());
Ok(())
})
.verbatim()
.configure(|arg| {
arg.required(false)
.required_unless_present("clear")
.conflicts_with("clear")
}),
)
.input(Input::flag("clear", |slot, _| {
*slot = None;
Ok(())
}))
}
pub(crate) fn read_source(include_detection: bool) -> Input<ReadSource> {
Input::value("source", "SOURCE", |slot, value| {
*slot = parse_choice(READ_SOURCES, "read source", value)?;
Ok(())
})
.configure(|arg| {
choices_where(arg, READ_SOURCES, |source| {
include_detection || *source != ReadSource::Detection
})
})
}
pub(crate) fn lines() -> Input<Option<u32>> {
Input::value("lines", "N", |slot, value| {
*slot = Some(crate::cli::parse_u32_flag("--lines", value)?);
Ok(())
})
}
pub(crate) fn number(name: &'static str, value: &'static str) -> Input<Option<u64>> {
let update: fn(&mut Option<u64>, &str) -> Result<(), ParseError> = match name {
"seq" => |slot, value| {
*slot = Some(crate::cli::parse_u64_flag("--seq", value)?);
Ok(())
},
"ttl-ms" => |slot, value| {
*slot = Some(crate::cli::parse_u64_flag("--ttl-ms", value)?);
Ok(())
},
_ => |slot, value| {
*slot = Some(crate::cli::parse_u64_flag("--timeout", value)?);
Ok(())
},
};
Input::value(name, value, update)
}
pub(crate) fn read_format() -> Field<ReadFormat> {
Field::new(ReadFormat::Text)
.input(
Input::value("format", "FORMAT", |slot, value| {
*slot = parse_choice(READ_FORMATS, "read format", value)?;
Ok(())
})
.configure(|arg| choices(arg, READ_FORMATS)),
)
.input(Input::flag("ansi", |slot, _| {
*slot = ReadFormat::Ansi;
Ok(())
}))
}
pub(crate) fn matcher() -> Field<OutputMatch> {
Field::new(OutputMatch::Substring {
value: String::new(),
})
.input(
Input::value("match", "TEXT", |slot, value| {
*slot = OutputMatch::Substring {
value: value.to_owned(),
};
Ok(())
})
.once()
.configure(|arg| arg.conflicts_with("regex").required_unless_present("regex"))
.help("Match a literal substring"),
)
.input(
Input::value("regex", "PATTERN", |slot, value| {
*slot = OutputMatch::Regex {
value: value.to_owned(),
};
Ok(())
})
.once()
.configure(|arg| arg.conflicts_with("match").required_unless_present("match"))
.help("Match a Rust regular expression"),
)
}
pub(crate) fn source() -> Input<String> {
text("source", "ID").required()
}
pub(crate) fn resume() -> Input<Option<Vec<String>>> {
Input::positional("resume_argv", 1, |slot: &mut Option<Vec<String>>, value| {
slot.get_or_insert_with(Vec::new).push(value.to_owned());
Ok(())
})
.value_name("RESUME_ARG")
.configure(|arg| arg.required(false).num_args(0..).last(true))
.help(
"Command that resumes this session after a Herdr restart; starts with a plain command name",
)
}
pub(crate) fn token() -> Input<HashMap<String, Option<String>>> {
Input::<HashMap<String, Option<String>>>::value("token", "NAME=VALUE", |slot, value| {
let (key, value) = crate::cli::parse_token_assignment(value)?;
slot.insert(key, value);
Ok(())
})
}
pub(crate) fn clear_token() -> Input<HashMap<String, Option<String>>> {
Input::<HashMap<String, Option<String>>>::value("clear-token", "NAME", |slot, value| {
slot.insert(value.to_owned(), None);
Ok(())
})
}
pub(crate) fn state_label() -> Input<HashMap<String, String>> {
Input::<HashMap<String, String>>::value("state-label", "STATUS=TEXT", |slot, value| {
let Some((status, label)) = value.split_once('=') else {
return Err(ParseError::Usage(
"expected --state-label STATUS=TEXT".into(),
));
};
let status = status.trim().to_ascii_lowercase();
if !matches!(
status.as_str(),
"idle" | "working" | "blocked" | "done" | "unknown"
) {
return Err(ParseError::Usage(format!("unknown state label: {status}")));
}
slot.insert(status, label.to_owned());
Ok(())
})
}
// The destination is validated and constructed as a tagged value after Clap parses
// these inputs. Its grammar still belongs to this shared field declaration.
pub(crate) fn destination() -> Field<PaneMoveDestination> {
let value = |name, label| Input::value(name, label, |_: &mut PaneMoveDestination, _| Ok(()));
let flag = |name| Input::flag(name, |_: &mut PaneMoveDestination, _| Ok(()));
Field::new(PaneMoveDestination::NewTab {
workspace_id: None,
label: None,
})
.input(value("tab", "TAB_ID").configure(|arg| {
arg.conflicts_with_all(["new-tab", "new-workspace"])
.requires("split")
}))
// Clap skips `requires` when the required arg conflicts with a present one,
// so each destination's options also conflict with the other destinations.
.input(value("split", "DIRECTION").configure(|arg| {
choices(arg, SPLITS)
.requires("tab")
.conflicts_with_all(["new-tab", "new-workspace"])
}))
.input(value("target-pane", "ID").configure(|arg| {
arg.requires("tab")
.conflicts_with_all(["new-tab", "new-workspace"])
}))
.input(Input::value(
"ratio",
"FLOAT",
|_: &mut PaneMoveDestination, value| super::finite("ratio", value).map(drop),
))
.input(flag("new-tab").configure(|arg| arg.conflicts_with_all(["tab", "new-workspace"])))
.input(value("workspace", "ID").configure(|arg| {
arg.requires("new-tab")
.conflicts_with_all(["tab", "new-workspace"])
}))
.input(flag("new-workspace").configure(|arg| arg.conflicts_with_all(["tab", "new-tab"])))
.input(value("label", "TEXT").configure(|arg| arg.conflicts_with("tab")))
.input(value("tab-label", "TEXT").configure(|arg| {
arg.requires("new-workspace")
.conflicts_with_all(["tab", "new-tab"])
}))
}
cli_projection! { pub(crate) PaneWaitProjection for PaneWaitForOutputParams {
pane_id => Field::new(String::new()).input(pane_id()),
source => Field::new(ReadSource::Recent).input(read_source(false)),
lines => Field::new(None).input(lines().help("Restrict the searched snapshot to N lines")),
r#match => matcher(),
timeout_ms => Field::new(None).input(number("timeout", "MS").help("Fail after this many milliseconds")),
strip_ansi => Field::new(true).input(boolean("raw", false).help("Keep ANSI escape sequences while matching")),
} }
cli_projection! { pub(crate) PaneTargetProjection for PaneTarget {
pane_id => Field::new(String::new()).input(pane_id()),
} }
#[derive(Clone, Copy)]
pub(super) enum Output {
Json,
Quiet,
Read,
}
pub(super) struct Leaf {
pub(super) name: &'static str,
about: &'static str,
id: &'static str,
pub(super) output: Output,
arguments: fn() -> Vec<clap::Arg>,
method: fn(&ArgMatches) -> Result<Method, ParseError>,
}
impl Leaf {
pub(super) fn command(&self) -> Command {
let command = projection::command(self.name, (self.arguments)()).about(self.about);
match self.name {
"input" => command.group(clap::ArgGroup::new("selector").args(["pane_id", "pane", "current"]).required(true)),
"move" => command.group(clap::ArgGroup::new("destination").args(["tab", "new-tab", "new-workspace"]).required(true)),
"send-text" => command.after_help("next: herdr pane run <PANE_ID> <COMMAND> sends text and Enter in one call"),
"send-keys" => command.after_help("Use esc as the canonical Escape key name; escape is also accepted."),
"wait-output" => command.after_help("The selected snapshot is searched immediately, including existing output, then polled. Without --timeout, this waits indefinitely."),
_ => command,
}
}
pub(super) fn request(&self, args: &[String]) -> Result<Request, ParseError> {
let mut method = projection::parse(
self.command().bin_name(format!("herdr pane {}", self.name)),
args,
self.method,
)?;
// Clap cannot distinguish an absent zero-arity last positional from an
// explicit empty separator. This is presence metadata, not another parser.
if args.iter().any(|arg| arg == "--") {
match &mut method {
Method::PaneReportAgent(params) => {
params.resume_argv.get_or_insert_with(Vec::new);
}
Method::PaneReportAgentSession(params) => {
params.resume_argv.get_or_insert_with(Vec::new);
}
_ => {}
}
}
Ok(Request {
id: self.id.into(),
method,
})
}
}
macro_rules! leaf {
($name:literal, $about:literal, $id:literal, $output:ident, $projection:ident, $method:ident) => {
Leaf {
name: $name,
about: $about,
id: $id,
output: Output::$output,
arguments: $projection::arguments,
method: |matches| $projection::from_matches(matches).map(Method::$method),
}
};
}
pub(super) const LEAVES: &[Leaf] = &[
leaf!(
"list",
"List panes",
"cli:pane:list",
Json,
PaneListProjection,
PaneList
),
Leaf {
name: "current",
about: "Show the current pane",
id: "cli:pane:current",
output: Output::Json,
arguments: PaneCurrentProjection::arguments,
method: |matches| {
let mut params = PaneCurrentProjection::from_matches(matches)?;
params.caller_pane_id = super::caller_target(
matches,
params.caller_pane_id,
crate::cli::target::caller_pane_id().as_deref(),
true,
false,
)?;
Ok(Method::PaneCurrent(params))
},
},
leaf!(
"get",
"Show a pane",
"cli:pane:get",
Json,
PaneTargetProjection,
PaneGet
),
Leaf {
name: "layout",
about: "Show pane layout information",
id: "cli:pane:layout",
output: Output::Json,
arguments: PaneLayoutProjection::arguments,
method: |matches| {
let mut params = PaneLayoutProjection::from_matches(matches)?;
params.pane_id = super::caller_target(
matches,
params.pane_id,
crate::cli::target::caller_pane_id().as_deref(),
false,
false,
)?;
Ok(Method::PaneLayout(params))
},
},
Leaf {
name: "process-info",
about: "Show pane process information",
id: "cli:pane:process_info",
output: Output::Json,
arguments: PaneProcessInfoProjection::arguments,
method: |matches| {
let mut params = PaneProcessInfoProjection::from_matches(matches)?;
params.pane_id = super::caller_target(
matches,
params.pane_id,
crate::cli::target::caller_pane_id().as_deref(),
false,
false,
)?;
Ok(Method::PaneProcessInfo(params))
},
},
leaf!(
"neighbor",
"Find a pane neighbor",
"cli:pane:neighbor",
Json,
PaneNeighborProjection,
PaneNeighbor
),
Leaf {
name: "edges",
about: "Show pane edge information",
id: "cli:pane:edges",
output: Output::Json,
arguments: PaneEdgesProjection::arguments,
method: |matches| {
let mut params = PaneEdgesProjection::from_matches(matches)?;
params.pane_id = super::caller_target(
matches,
params.pane_id,
crate::cli::target::caller_pane_id().as_deref(),
false,
false,
)?;
Ok(Method::PaneEdges(params))
},
},
leaf!(
"focus",
"Focus a neighboring pane",
"cli:pane:focus",
Json,
PaneFocusProjection,
PaneFocusDirection
),
leaf!(
"resize",
"Resize a pane split",
"cli:pane:resize",
Json,
PaneResizeProjection,
PaneResize
),
leaf!(
"zoom",
"Toggle or set pane zoom",
"cli:pane:zoom",
Json,
PaneZoomProjection,
PaneZoom
),
Leaf {
name: "read",
about: "Read pane terminal output",
id: "cli:pane:read",
output: Output::Read,
arguments: PaneReadProjection::arguments,
method: |matches| {
let mut params = PaneReadProjection::from_matches(matches)?;
if matches.get_flag("raw")
&& matches.index_of("raw") > super::last_index(matches, &["format", "ansi"])
{
params.format = ReadFormat::Ansi;
}
Ok(Method::PaneRead(params))
},
},
leaf!(
"rename",
"Rename a pane",
"cli:pane:rename",
Json,
PaneRenameProjection,
PaneRename
),
Leaf {
name: "input",
about: "Set pane input routing",
id: "cli:pane:input:set",
output: Output::Json,
arguments: PaneInputProjection::arguments,
method: |matches| {
let mut params = PaneInputProjection::from_matches(matches)?;
if matches.get_flag("current") {
params.pane_id = crate::cli::target::caller_pane_id()
.map(|value| crate::cli::normalize_pane_id(&value))
.ok_or_else(|| ParseError::Usage("--current requires HERDR_PANE_ID".into()))?;
}
Ok(Method::PaneInputSet(params))
},
},
Leaf {
name: "split",
about: "Split a pane",
id: "cli:pane:split",
output: Output::Json,
arguments: PaneSplitProjection::arguments,
method: |matches| {
let mut params = PaneSplitProjection::from_matches(matches)?;
params.target_pane_id = super::caller_target(
matches,
params.target_pane_id,
crate::cli::target::caller_pane_id().as_deref(),
true,
true,
)?;
Ok(Method::PaneSplit(params))
},
},
Leaf {
name: "swap",
about: "Swap panes",
id: "cli:pane:swap",
output: Output::Json,
arguments: PaneSwapProjection::arguments,
method: super::swap,
},
Leaf {
name: "move",
about: "Move a pane",
id: "cli:pane:move",
output: Output::Json,
arguments: PaneMoveProjection::arguments,
method: super::move_pane,
},
leaf!(
"close",
"Close a pane",
"cli:pane:close",
Json,
PaneTargetProjection,
PaneClose
),
leaf!(
"send-text",
"Send literal text to a pane",
"cli:request",
Quiet,
PaneSendTextProjection,
PaneSendText
),
leaf!(
"send-keys",
"Send key presses to a pane",
"cli:request",
Quiet,
PaneSendKeysProjection,
PaneSendKeys
),
leaf!(
"wait-output",
"Wait for matching pane output",
"cli:pane:wait-output",
Json,
PaneWaitProjection,
PaneWaitForOutput
),
leaf!(
"run",
"Run a command in a pane",
"cli:request",
Quiet,
PaneRunProjection,
PaneSendInput
),
Leaf {
name: "report-agent",
about: "Report pane agent lifecycle state",
id: "cli:request",
output: Output::Quiet,
arguments: PaneReportAgentProjection::arguments,
method: |matches| {
let mut params = PaneReportAgentProjection::from_matches(matches)?;
params.source = super::source(params.source)?;
Ok(Method::PaneReportAgent(params))
},
},
Leaf {
name: "report-agent-session",
about: "Report pane agent session identity",
id: "cli:request",
output: Output::Quiet,
arguments: PaneReportSessionProjection::arguments,
method: |matches| {
let mut params = PaneReportSessionProjection::from_matches(matches)?;
params.source = super::source(params.source)?;
Ok(Method::PaneReportAgentSession(params))
},
},
Leaf {
name: "release-agent",
about: "Release pane agent lifecycle authority",
id: "cli:request",
output: Output::Quiet,
arguments: PaneReleaseProjection::arguments,
method: |matches| {
let mut params = PaneReleaseProjection::from_matches(matches)?;
params.source = super::source(params.source)?;
Ok(Method::PaneReleaseAgent(params))
},
},
Leaf {
name: "report-metadata",
about: "Report display-only pane metadata",
id: "cli:request",
output: Output::Quiet,
arguments: PaneMetadataProjection::arguments,
method: super::metadata,
},
];
pub(super) fn command() -> Command {
Command::new("pane")
.about("Control terminal panes")
.subcommands(LEAVES.iter().map(Leaf::command))
}
#[cfg(test)]
mod choice_tests {
use crate::cli::projection::assert_choices_cover_api_enum;
#[test]
fn pane_choices_cover_every_api_value() {
assert_choices_cover_api_enum(super::DIRECTIONS);
assert_choices_cover_api_enum(super::SPLITS);
assert_choices_cover_api_enum(super::RIGHT_CLICK);
assert_choices_cover_api_enum(super::STATES);
}
}
+35 -502
View File
@@ -1,82 +1,55 @@
use std::collections::HashMap;
use std::fmt;
use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
use super::projection::{self, ParseError};
use crate::api::schema::{
InstalledPluginInfo, Method, PluginActionInvokeParams, PluginActionListParams,
PluginInvocationContext, PluginLinkParams, PluginListParams, PluginLogListParams,
PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneOpenParams, PluginPanePlacement,
PluginPlatform, PluginSetEnabledParams, PluginSourceInfo, PluginSourceKind, PluginUnlinkParams,
Request, ResponseResult, SplitDirection, SuccessResponse,
InstalledPluginInfo, Method, PluginLinkParams, PluginListParams, PluginPlatform,
PluginSourceInfo, PluginSourceKind, PluginUnlinkParams, Request, ResponseResult,
SuccessResponse,
};
use crate::popup_size::PopupSize;
pub(crate) mod bindings;
pub(crate) fn command() -> clap::Command {
bindings::command()
}
const PLUGIN_BUILD_OUTPUT_MAX_BYTES: usize = 64 * 1024;
const PLUGIN_INSTALL_USAGE: &str =
"usage: herdr plugin install [--ref REF] [--yes|-y] <owner>/<repo>[/subdir...]";
pub(super) fn run_plugin_command(args: &[String]) -> std::io::Result<i32> {
let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
print_plugin_help();
return Ok(2);
match args.first().map(String::as_str) {
Some("install") => return plugin_install(&args[1..]),
Some("update") => return plugin_update(&args[1..]),
Some("uninstall") => return plugin_uninstall(&args[1..]),
Some("config-dir") => return plugin_config_dir_command(&args[1..]),
_ => {}
}
let (method, json) = match projection::parse(
command().bin_name("herdr plugin"),
args,
bindings::from_matches,
) {
Ok(parsed) => parsed,
Err(ParseError::Io(error)) => return Err(error),
Err(ParseError::Usage(message)) => {
eprintln!("{message}");
return Ok(2);
}
Err(ParseError::Clap(error)) => return super::print_clap_error(&error),
};
match subcommand {
"install" => plugin_install(&args[1..]),
"update" => plugin_update(&args[1..]),
"uninstall" => plugin_uninstall(&args[1..]),
"link" => plugin_link(&args[1..]),
"list" => plugin_list(&args[1..]),
"config-dir" => plugin_config_dir_command(&args[1..]),
"unlink" => plugin_unlink(&args[1..]),
"enable" => plugin_set_enabled(&args[1..], true),
"disable" => plugin_set_enabled(&args[1..], false),
"action" => run_plugin_action_command(&args[1..]),
"log" | "logs" => plugin_log_list(&args[1..]),
"pane" => run_plugin_pane_command(&args[1..]),
"help" | "--help" | "-h" => {
print_plugin_help();
Ok(0)
}
_ => {
print_plugin_help();
Ok(2)
}
match method {
Method::PluginLink(params) => plugin_link(params),
Method::PluginList(params) => plugin_list(params, json),
method => print_plugin_response(method),
}
}
fn plugin_link(args: &[String]) -> std::io::Result<i32> {
let Some(path) = args.first() else {
eprintln!("usage: herdr plugin link <path> [--disabled]");
return Ok(2);
};
let path = normalize_plugin_path_arg(path)?;
let mut enabled = true;
let mut index = 1;
while index < args.len() {
match args[index].as_str() {
"--disabled" => {
enabled = false;
index += 1;
}
"--enabled" => {
enabled = true;
index += 1;
}
other => {
eprintln!("unknown option: {other}");
return Ok(2);
}
}
}
let params = PluginLinkParams {
path,
enabled,
source: None,
};
fn plugin_link(params: PluginLinkParams) -> std::io::Result<i32> {
let response = match super::send_request(&Request {
id: "cli:plugin".into(),
method: Method::PluginLink(params.clone()),
@@ -103,29 +76,7 @@ fn plugin_config_dir_command(args: &[String]) -> std::io::Result<i32> {
Ok(0)
}
fn plugin_list(args: &[String]) -> std::io::Result<i32> {
let mut plugin_id = None;
let mut json = false;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--json" => {
json = true;
index += 1;
}
"--plugin" => {
let Some(value) = required_value(args, &mut index, "--plugin") else {
return Ok(2);
};
plugin_id = Some(value);
}
other => {
eprintln!("unknown option: {other}");
return Ok(2);
}
}
}
let params = PluginListParams { plugin_id };
fn plugin_list(params: PluginListParams, json: bool) -> std::io::Result<i32> {
let response = match super::send_request(&Request {
id: "cli:plugin".into(),
method: Method::PluginList(params.clone()),
@@ -140,20 +91,6 @@ fn plugin_list(args: &[String]) -> std::io::Result<i32> {
print_plugin_list_human(&response)
}
fn plugin_unlink(args: &[String]) -> std::io::Result<i32> {
let Some(plugin_id) = args.first() else {
eprintln!("usage: herdr plugin unlink <plugin_id>");
return Ok(2);
};
if args.len() != 1 {
eprintln!("usage: herdr plugin unlink <plugin_id>");
return Ok(2);
}
print_plugin_response(Method::PluginUnlink(PluginUnlinkParams {
plugin_id: plugin_id.clone(),
}))
}
#[derive(Debug)]
struct PluginInstallArgs {
source: GithubPluginSource,
@@ -516,381 +453,6 @@ fn plugin_uninstall(args: &[String]) -> std::io::Result<i32> {
Ok(0)
}
fn plugin_set_enabled(args: &[String], enabled: bool) -> std::io::Result<i32> {
let Some(plugin_id) = args.first() else {
eprintln!(
"usage: herdr plugin {} <plugin_id>",
if enabled { "enable" } else { "disable" }
);
return Ok(2);
};
if args.len() != 1 {
eprintln!(
"usage: herdr plugin {} <plugin_id>",
if enabled { "enable" } else { "disable" }
);
return Ok(2);
}
let params = PluginSetEnabledParams {
plugin_id: plugin_id.clone(),
};
if enabled {
print_plugin_response(Method::PluginEnable(params))
} else {
print_plugin_response(Method::PluginDisable(params))
}
}
fn plugin_log_list(args: &[String]) -> std::io::Result<i32> {
let mut plugin_id = None;
let mut limit = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"list" if index == 0 => index += 1,
"--plugin" => {
let Some(value) = required_value(args, &mut index, "--plugin") else {
return Ok(2);
};
plugin_id = Some(value);
}
"--limit" => {
let Some(raw) = required_value(args, &mut index, "--limit") else {
return Ok(2);
};
let Ok(parsed) = raw.parse::<usize>() else {
eprintln!("invalid --limit value: {raw}");
return Ok(2);
};
limit = Some(parsed);
}
other => {
eprintln!("unknown option: {other}");
return Ok(2);
}
}
}
print_plugin_response(Method::PluginLogList(PluginLogListParams {
plugin_id,
limit,
}))
}
fn run_plugin_action_command(args: &[String]) -> std::io::Result<i32> {
let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
print_plugin_action_help();
return Ok(2);
};
match subcommand {
"list" => plugin_action_list(&args[1..]),
"invoke" => plugin_action_invoke(&args[1..]),
"help" | "--help" | "-h" => {
print_plugin_action_help();
Ok(0)
}
_ => {
print_plugin_action_help();
Ok(2)
}
}
}
fn plugin_action_list(args: &[String]) -> std::io::Result<i32> {
let mut plugin_id = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--plugin" => {
let Some(value) = required_value(args, &mut index, "--plugin") else {
return Ok(2);
};
plugin_id = Some(value);
}
other => {
eprintln!("unknown option: {other}");
return Ok(2);
}
}
}
print_plugin_response(Method::PluginActionList(PluginActionListParams {
plugin_id,
}))
}
fn plugin_action_invoke(args: &[String]) -> std::io::Result<i32> {
let Some(action_id) = args.first() else {
eprintln!("usage: herdr plugin action invoke <action_id> [--plugin ID]");
return Ok(2);
};
let mut plugin_id = None;
let mut index = 1;
while index < args.len() {
match args[index].as_str() {
"--plugin" => {
let Some(value) = required_value(args, &mut index, "--plugin") else {
return Ok(2);
};
plugin_id = Some(value);
}
other => {
eprintln!("unknown option: {other}");
return Ok(2);
}
}
}
print_plugin_response(Method::PluginActionInvoke(PluginActionInvokeParams {
action_id: action_id.clone(),
plugin_id,
context: Some(PluginInvocationContext {
workspace_id: None,
workspace_label: None,
workspace_cwd: None,
worktree: None,
tab_id: None,
tab_label: None,
focused_pane_id: None,
focused_pane_cwd: None,
focused_pane_agent: None,
focused_pane_status: None,
selected_text: None,
invocation_source: Some("cli".into()),
correlation_id: None,
clicked_url: None,
link_handler_id: None,
}),
}))
}
fn run_plugin_pane_command(args: &[String]) -> std::io::Result<i32> {
let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
print_plugin_pane_help();
return Ok(2);
};
match subcommand {
"open" => plugin_pane_open(&args[1..]),
"focus" => plugin_pane_focus(&args[1..]),
"close" => plugin_pane_close(&args[1..]),
"help" | "--help" | "-h" => {
print_plugin_pane_help();
Ok(0)
}
_ => {
print_plugin_pane_help();
Ok(2)
}
}
}
fn plugin_pane_open(args: &[String]) -> std::io::Result<i32> {
let mut plugin_id = None;
let mut entrypoint = None;
let mut placement = None;
let mut width = None;
let mut height = None;
let mut workspace_id = None;
let mut target_pane_id = None;
let mut direction = None;
let mut cwd = None;
let mut focus = true;
let mut env = HashMap::new();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--plugin" => {
let Some(value) = required_value(args, &mut index, "--plugin") else {
return Ok(2);
};
plugin_id = Some(value);
}
"--entrypoint" => {
let Some(value) = required_value(args, &mut index, "--entrypoint") else {
return Ok(2);
};
entrypoint = Some(value);
}
"--placement" => {
let Some(value) = required_value(args, &mut index, "--placement") else {
return Ok(2);
};
let Some(parsed) = parse_pane_placement(&value) else {
return Ok(2);
};
placement = Some(parsed);
}
"--width" => {
let Some(value) = required_value(args, &mut index, "--width") else {
return Ok(2);
};
let Some(parsed) = parse_popup_dimension(&value, "--width") else {
return Ok(2);
};
width = Some(parsed);
}
"--height" => {
let Some(value) = required_value(args, &mut index, "--height") else {
return Ok(2);
};
let Some(parsed) = parse_popup_dimension(&value, "--height") else {
return Ok(2);
};
height = Some(parsed);
}
"--workspace" => {
let Some(value) = required_value(args, &mut index, "--workspace") else {
return Ok(2);
};
workspace_id = Some(value);
}
"--target-pane" => {
let Some(value) = required_value(args, &mut index, "--target-pane") else {
return Ok(2);
};
target_pane_id = Some(value);
}
"--direction" => {
let Some(value) = required_value(args, &mut index, "--direction") else {
return Ok(2);
};
let Some(parsed) = parse_split_direction(&value) else {
return Ok(2);
};
direction = Some(parsed);
}
"--cwd" => {
let Some(value) = required_value(args, &mut index, "--cwd") else {
return Ok(2);
};
cwd = Some(value);
}
"--env" => {
let Some(value) = required_value(args, &mut index, "--env") else {
return Ok(2);
};
let (key, value) = match super::parse_env_assignment(&value) {
Ok(pair) => pair,
Err(err) => {
eprintln!("{err}");
return Ok(2);
}
};
env.insert(key, value);
}
"--focus" => {
focus = true;
index += 1;
}
"--no-focus" => {
focus = false;
index += 1;
}
other => {
eprintln!("unknown option: {other}");
return Ok(2);
}
}
}
let Some(plugin_id) = plugin_id else {
eprintln!("missing required --plugin");
return Ok(2);
};
let Some(entrypoint) = entrypoint else {
eprintln!("missing required --entrypoint");
return Ok(2);
};
print_plugin_response(Method::PluginPaneOpen(PluginPaneOpenParams {
plugin_id,
entrypoint,
placement,
width,
height,
workspace_id,
target_pane_id,
direction,
cwd,
focus,
env,
}))
}
fn parse_popup_dimension(value: &str, flag: &str) -> Option<PopupSize> {
match PopupSize::parse_cli(value) {
Ok(value) => Some(value),
Err(message) => {
eprintln!("{flag} {message}");
None
}
}
}
fn plugin_pane_focus(args: &[String]) -> std::io::Result<i32> {
let Some(pane_id) = args.first() else {
eprintln!("usage: herdr plugin pane focus <pane_id>");
return Ok(2);
};
if args.len() != 1 {
eprintln!("usage: herdr plugin pane focus <pane_id>");
return Ok(2);
}
print_plugin_response(Method::PluginPaneFocus(PluginPaneFocusParams {
pane_id: super::normalize_pane_id(pane_id),
}))
}
fn plugin_pane_close(args: &[String]) -> std::io::Result<i32> {
let Some(pane_id) = args.first() else {
eprintln!("usage: herdr plugin pane close <pane_id>");
return Ok(2);
};
if args.len() != 1 {
eprintln!("usage: herdr plugin pane close <pane_id>");
return Ok(2);
}
print_plugin_response(Method::PluginPaneClose(PluginPaneCloseParams {
pane_id: super::normalize_pane_id(pane_id),
}))
}
fn required_value(args: &[String], index: &mut usize, flag: &str) -> Option<String> {
let Some(value) = args.get(*index + 1) else {
eprintln!("missing value for {flag}");
return None;
};
*index += 2;
Some(value.clone())
}
fn parse_pane_placement(value: &str) -> Option<PluginPanePlacement> {
match value {
"overlay" => Some(PluginPanePlacement::Overlay),
"popup" => Some(PluginPanePlacement::Popup),
"split" => Some(PluginPanePlacement::Split),
"tab" => Some(PluginPanePlacement::Tab),
"zoomed" | "fullscreen" => Some(PluginPanePlacement::Zoomed),
_ => {
eprintln!("invalid pane placement: {value}");
None
}
}
}
fn parse_split_direction(value: &str) -> Option<SplitDirection> {
match value {
"right" => Some(SplitDirection::Right),
"down" => Some(SplitDirection::Down),
_ => {
eprintln!("invalid split direction: {value}");
None
}
}
}
fn normalize_plugin_path_arg(value: &str) -> std::io::Result<String> {
if super::target::is_remote() {
if super::target::remote_path_is_absolute(value) {
@@ -1806,35 +1368,6 @@ fn print_plugin_response(method: Method) -> std::io::Result<i32> {
})?)
}
fn print_plugin_help() {
eprintln!("herdr plugin commands:");
eprintln!(" herdr plugin install <owner>/<repo>[/subdir...] [--ref REF] [--yes]");
eprintln!(" herdr plugin update [<plugin_id|owner/repo[/subdir...]>...] [--yes]");
eprintln!(" herdr plugin uninstall <plugin_id|owner/repo[/subdir...]>");
eprintln!(" herdr plugin link <path> [--disabled]");
eprintln!(" herdr plugin list [--plugin ID] [--json]");
eprintln!(" herdr plugin config-dir <plugin_id>");
eprintln!(" herdr plugin unlink <plugin_id>");
eprintln!(" herdr plugin enable <plugin_id>");
eprintln!(" herdr plugin disable <plugin_id>");
eprintln!(" herdr plugin action <list|invoke>");
eprintln!(" herdr plugin log list [--plugin ID] [--limit N]");
eprintln!(" herdr plugin pane <open|focus|close>");
}
fn print_plugin_action_help() {
eprintln!("herdr plugin action commands:");
eprintln!(" herdr plugin action list [--plugin ID]");
eprintln!(" herdr plugin action invoke <action_id> [--plugin ID]");
}
fn print_plugin_pane_help() {
eprintln!("herdr plugin pane commands:");
eprintln!(" herdr plugin pane open --plugin ID --entrypoint ID [--placement overlay|popup|split|tab|zoomed] [--width SIZE] [--height SIZE] [--workspace ID] [--target-pane PANE] [--direction right|down] [--cwd PATH] [--env KEY=VALUE] [--focus|--no-focus]");
eprintln!(" herdr plugin pane focus <pane_id>");
eprintln!(" herdr plugin pane close <pane_id>");
}
#[cfg(test)]
mod tests {
use super::*;
+484
View File
@@ -0,0 +1,484 @@
use std::collections::HashMap;
use clap::{builder::PossibleValue, Arg, ArgAction, ArgMatches, Command};
use crate::api::schema::plugins::{
PluginActionInvokeProjection, PluginActionListProjection, PluginLinkProjection,
PluginListProjection, PluginLogListProjection, PluginPaneCloseProjection,
PluginPaneFocusProjection, PluginPaneOpenProjection, PluginSetEnabledProjection,
PluginUnlinkProjection,
};
use crate::api::schema::{Method, PluginInvocationContext, PluginPanePlacement, SplitDirection};
use crate::cli::projection::{self, Input, ParseError};
use crate::popup_size::PopupSize;
pub(crate) use crate::cli::projection::{boolean, optional_text};
pub(crate) fn required_text(name: &'static str, value: &'static str) -> Input<String> {
Input::value(name, value, |slot, value| {
*slot = value.to_owned();
Ok(())
})
.required()
}
pub(crate) fn id(name: &'static str, value: &'static str) -> Input<String> {
Input::positional(name, 0, |slot, value| {
*slot = value.to_owned();
Ok(())
})
.value_name(value)
}
pub(crate) fn path() -> Input<String> {
Input::positional("path", 0, |slot, value| {
*slot = super::normalize_plugin_path_arg(value)?;
Ok(())
})
.value_name("PATH")
.path()
.verbatim()
}
pub(crate) fn pane_id() -> Input<String> {
Input::positional("pane_id", 0, |slot, value| {
*slot = crate::cli::normalize_pane_id(value);
Ok(())
})
.value_name("PANE_ID")
}
pub(crate) fn environment() -> Input<HashMap<String, String>> {
Input::<HashMap<String, String>>::value("env", "KEY=VALUE", |slot, value| {
let (key, value) = crate::cli::parse_env_assignment(value)?;
slot.insert(key, value);
Ok(())
})
}
pub(crate) fn limit() -> Input<Option<usize>> {
Input::value("limit", "N", |slot, value| {
*slot = Some(
value
.parse()
.map_err(|_| ParseError::Usage(format!("invalid --limit value: {value}")))?,
);
Ok(())
})
}
const PLACEMENTS: [(&str, PluginPanePlacement, bool); 6] = [
("overlay", PluginPanePlacement::Overlay, false),
("popup", PluginPanePlacement::Popup, true),
("split", PluginPanePlacement::Split, false),
("tab", PluginPanePlacement::Tab, false),
("zoomed", PluginPanePlacement::Zoomed, false),
("fullscreen", PluginPanePlacement::Zoomed, true),
];
const DIRECTIONS: [(&str, SplitDirection); 2] = [
("right", SplitDirection::Right),
("down", SplitDirection::Down),
];
pub(crate) fn placement() -> Input<Option<PluginPanePlacement>> {
Input::value("placement", "PLACEMENT", |slot, value| {
*slot = Some(
PLACEMENTS
.iter()
.find(|(name, _, _)| *name == value)
.map(|(_, placement, _)| *placement)
.ok_or_else(|| ParseError::Usage(format!("invalid pane placement: {value}")))?,
);
Ok(())
})
.configure(|arg| {
arg.value_parser(clap::builder::PossibleValuesParser::new(
PLACEMENTS
.iter()
.map(|(name, _, hidden)| PossibleValue::new(*name).hide(*hidden)),
))
})
}
pub(crate) fn direction() -> Input<Option<SplitDirection>> {
Input::value("direction", "DIRECTION", |slot, value| {
*slot = Some(
DIRECTIONS
.iter()
.find(|(name, _)| *name == value)
.map(|(_, direction)| direction.clone())
.ok_or_else(|| ParseError::Usage(format!("invalid split direction: {value}")))?,
);
Ok(())
})
.configure(|arg| {
arg.value_parser(clap::builder::PossibleValuesParser::new(
DIRECTIONS.iter().map(|(name, _)| *name),
))
})
}
pub(crate) fn width() -> Input<Option<PopupSize>> {
Input::value("width", "SIZE", |slot, value| {
*slot = Some(PopupSize::parse_cli(value).map_err(|error| format!("--width {error}"))?);
Ok(())
})
}
pub(crate) fn height() -> Input<Option<PopupSize>> {
Input::value("height", "SIZE", |slot, value| {
*slot = Some(PopupSize::parse_cli(value).map_err(|error| format!("--height {error}"))?);
Ok(())
})
}
pub(crate) fn invocation_context() -> PluginInvocationContext {
PluginInvocationContext {
workspace_id: None,
workspace_label: None,
workspace_cwd: None,
worktree: None,
tab_id: None,
tab_label: None,
focused_pane_id: None,
focused_pane_cwd: None,
focused_pane_agent: None,
focused_pane_status: None,
selected_text: None,
invocation_source: Some("cli".into()),
correlation_id: None,
clicked_url: None,
link_handler_id: None,
}
}
struct Leaf {
name: &'static str,
about: &'static str,
arguments: fn() -> Vec<Arg>,
method: fn(&ArgMatches) -> Result<Method, ParseError>,
json: bool,
}
impl Leaf {
fn command(&self) -> Command {
let command = projection::command(self.name, (self.arguments)()).about(self.about);
if self.json {
command.arg(Arg::new("json").long("json").action(ArgAction::SetTrue))
} else {
command
}
}
fn method(&self, matches: &ArgMatches) -> Result<(Method, bool), ParseError> {
Ok((
(self.method)(matches)?,
self.json && matches.get_flag("json"),
))
}
}
const DIRECT: &[Leaf] = &[
Leaf {
name: "link",
about: "Link a local plugin",
arguments: PluginLinkProjection::arguments,
method: |m| PluginLinkProjection::from_matches(m).map(Method::PluginLink),
json: false,
},
Leaf {
name: "unlink",
about: "Unlink a local plugin",
arguments: PluginUnlinkProjection::arguments,
method: |m| PluginUnlinkProjection::from_matches(m).map(Method::PluginUnlink),
json: false,
},
Leaf {
name: "enable",
about: "Enable a plugin",
arguments: PluginSetEnabledProjection::arguments,
method: |m| PluginSetEnabledProjection::from_matches(m).map(Method::PluginEnable),
json: false,
},
Leaf {
name: "disable",
about: "Disable a plugin",
arguments: PluginSetEnabledProjection::arguments,
method: |m| PluginSetEnabledProjection::from_matches(m).map(Method::PluginDisable),
json: false,
},
Leaf {
name: "list",
about: "List installed plugins",
arguments: PluginListProjection::arguments,
method: |m| PluginListProjection::from_matches(m).map(Method::PluginList),
json: true,
},
];
const ACTION: &[Leaf] = &[
Leaf {
name: "list",
about: "List plugin actions",
arguments: PluginActionListProjection::arguments,
method: |m| PluginActionListProjection::from_matches(m).map(Method::PluginActionList),
json: false,
},
Leaf {
name: "invoke",
about: "Invoke a plugin action",
arguments: PluginActionInvokeProjection::arguments,
method: |m| PluginActionInvokeProjection::from_matches(m).map(Method::PluginActionInvoke),
json: false,
},
];
const PANE: &[Leaf] = &[
Leaf {
name: "open",
about: "Open a plugin pane",
arguments: PluginPaneOpenProjection::arguments,
method: |m| PluginPaneOpenProjection::from_matches(m).map(Method::PluginPaneOpen),
json: false,
},
Leaf {
name: "focus",
about: "Focus a plugin pane",
arguments: PluginPaneFocusProjection::arguments,
method: |m| PluginPaneFocusProjection::from_matches(m).map(Method::PluginPaneFocus),
json: false,
},
Leaf {
name: "close",
about: "Close a plugin pane",
arguments: PluginPaneCloseProjection::arguments,
method: |m| PluginPaneCloseProjection::from_matches(m).map(Method::PluginPaneClose),
json: false,
},
];
const LOG: Leaf = Leaf {
name: "list",
about: "List plugin command logs",
arguments: PluginLogListProjection::arguments,
method: |m| PluginLogListProjection::from_matches(m).map(Method::PluginLogList),
json: false,
};
fn group(name: &'static str, about: &'static str, leaves: &[Leaf]) -> Command {
Command::new(name)
.about(about)
.subcommand_required(true)
.subcommands(leaves.iter().map(Leaf::command))
}
pub(crate) fn command() -> Command {
Command::new("plugin")
.about("Install and run workflow plugins")
.subcommand_required(true)
.subcommand(
Command::new("install")
.about("Install a plugin from GitHub")
.arg(
Arg::new("source")
.value_name("OWNER/REPO[/SUBDIR]")
.required(true),
)
.arg(Arg::new("ref").long("ref").value_name("REF"))
.arg(
Arg::new("yes")
.short('y')
.long("yes")
.action(ArgAction::SetTrue),
),
)
.subcommand(
Command::new("uninstall")
.about("Uninstall a plugin")
.arg(Arg::new("plugin").value_name("PLUGIN").required(true)),
)
.subcommand(
Command::new("update")
.about("Update GitHub-installed plugins")
.arg(Arg::new("plugins").value_name("PLUGIN").num_args(0..))
.arg(
Arg::new("yes")
.short('y')
.long("yes")
.action(ArgAction::SetTrue),
),
)
.subcommands(DIRECT.iter().map(Leaf::command))
.subcommand(
Command::new("config-dir")
.about("Print a plugin config directory")
.arg(Arg::new("plugin_id").value_name("PLUGIN_ID").required(true)),
)
.subcommand(group("action", "List or invoke plugin actions", ACTION))
.subcommand(
projection::command("log", (LOG.arguments)())
.about("Inspect plugin command logs")
.visible_alias("logs")
.args_conflicts_with_subcommands(true)
.subcommand(LOG.command()),
)
.subcommand(group("pane", "Manage plugin-owned panes", PANE))
}
fn leaf_matches(leaves: &[Leaf], matches: &ArgMatches) -> Result<(Method, bool), ParseError> {
let (name, matches) = matches
.subcommand()
.ok_or_else(|| ParseError::Usage("missing plugin command".into()))?;
leaves
.iter()
.find(|leaf| leaf.name == name)
.ok_or_else(|| ParseError::Usage(format!("unknown plugin command: {name}")))?
.method(matches)
}
pub(crate) fn from_matches(matches: &ArgMatches) -> Result<(Method, bool), ParseError> {
match matches.subcommand() {
Some(("action", matches)) => leaf_matches(ACTION, matches),
Some(("pane", matches)) => leaf_matches(PANE, matches),
Some(("log", matches)) => LOG.method(matches.subcommand_matches("list").unwrap_or(matches)),
_ => leaf_matches(DIRECT, matches),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<(Method, bool), ParseError> {
projection::parse(
command(),
&args.iter().map(|arg| (*arg).to_owned()).collect::<Vec<_>>(),
from_matches,
)
}
#[test]
fn plugin_clap_preserves_context_filters_and_ordered_updates() {
let (Method::PluginLink(params), _) =
parse(&["link", "./plugin", "--disabled", "--enabled", "--disabled"]).unwrap()
else {
panic!("link request");
};
assert!(!params.enabled);
assert!(params.source.is_none());
assert_eq!(
params.path,
std::env::current_dir()
.unwrap()
.join("./plugin")
.display()
.to_string()
);
// The first operand is always the path, even when it looks like a flag.
let (Method::PluginLink(params), _) = parse(&["link", "--disabled"]).unwrap() else {
panic!("flag-shaped link path");
};
assert!(params.enabled);
assert!(params.path.ends_with("--disabled"), "{}", params.path);
let (Method::PluginActionInvoke(params), _) =
parse(&["action", "invoke", "apply", "--plugin=plugin:7"]).unwrap()
else {
panic!("action request");
};
assert_eq!(params.action_id, "apply");
assert_eq!(params.plugin_id.as_deref(), Some("plugin:7"));
assert_eq!(
serde_json::to_value(params.context).unwrap(),
serde_json::json!({"invocation_source":"cli"})
);
let (Method::PluginPaneOpen(params), _) = parse(&[
"pane",
"open",
"--plugin=x",
"--entrypoint=main",
"--no-focus",
"--focus",
"--no-focus",
"--env=KEY=first",
"--env=KEY=last=value",
"--env=EMPTY=",
])
.unwrap() else {
panic!("pane request");
};
assert!(!params.focus);
assert_eq!(
params.env.get("KEY").map(String::as_str),
Some("last=value")
);
assert_eq!(params.env.get("EMPTY").map(String::as_str), Some(""));
let (Method::PluginList(params), json) = parse(&["list", "--json", "--plugin="]).unwrap()
else {
panic!("list request");
};
assert!(json);
assert_eq!(params.plugin_id.as_deref(), Some(""));
}
#[test]
fn plugin_clap_preserves_remote_path_policy() {
crate::cli::target::with_test_client(crate::api::client::ApiClient::local(), || {
assert!(matches!(
parse(&["link", "relative/path"]),
Err(ParseError::Io(_))
));
for path in ["/remote/plugin", "C:\\remote\\plugin"] {
let (Method::PluginLink(params), _) = parse(&["link", path]).unwrap() else {
panic!("link request");
};
assert_eq!(params.path, path);
}
});
}
#[test]
fn plugin_clap_keeps_hidden_popup_controls_and_help() {
let mut family = command();
family.clone().debug_assert();
let help = family
.find_subcommand_mut("pane")
.unwrap()
.find_subcommand_mut("open")
.unwrap()
.render_long_help()
.to_string();
for hidden in ["--width", "--height", "popup", "fullscreen"] {
assert!(!help.contains(hidden), "{help}");
}
for placement in ["popup", "fullscreen"] {
assert!(matches!(
parse(&[
"pane",
"open",
"--plugin=x",
"--entrypoint=main",
"--placement",
placement,
"--width=0",
"--height=100%"
]),
Ok((Method::PluginPaneOpen(_), _))
));
}
for dimension in ["0%", "101%", "65536", "bad"] {
assert!(parse(&[
"pane",
"open",
"--plugin=x",
"--entrypoint=main",
"--width",
dimension
])
.is_err());
}
assert!(
matches!(parse(&["pane", "open", "--help"]), Err(ParseError::Clap(error)) if error.kind() == clap::error::ErrorKind::DisplayHelp)
);
}
}
+946
View File
@@ -0,0 +1,946 @@
use std::any::TypeId;
use clap::builder::{StringValueParser, TypedValueParser};
use clap::{parser::ValueSource, Arg, ArgAction, ArgMatches, Command, ValueHint};
use crate::api::schema::{Method, Request};
#[derive(Debug)]
pub(crate) enum ParseError {
Usage(String),
Io(std::io::Error),
Clap(clap::Error),
}
impl From<String> for ParseError {
fn from(message: String) -> Self {
Self::Usage(message)
}
}
impl From<std::io::Error> for ParseError {
fn from(error: std::io::Error) -> Self {
Self::Io(error)
}
}
impl From<clap::Error> for ParseError {
fn from(error: clap::Error) -> Self {
Self::Clap(error)
}
}
pub(crate) fn command(name: &'static str, arguments: Vec<Arg>) -> Command {
Command::new(name).args_override_self(true).args(arguments)
}
pub(crate) fn parse<T>(
command: Command,
args: &[String],
from_matches: impl FnOnce(&ArgMatches) -> Result<T, ParseError>,
) -> Result<T, ParseError> {
let args = protect_verbatim_slots(&command, args);
let matches = command.no_binary_name(true).try_get_matches_from(args)?;
from_matches(&matches)
}
/// An API-backed subcommand: the request id it sends and how its matches become the method.
pub(crate) struct Leaf {
pub(crate) name: &'static str,
pub(crate) about: &'static str,
pub(crate) id: &'static str,
pub(crate) arguments: fn() -> Vec<Arg>,
pub(crate) method: fn(&ArgMatches) -> Result<Method, ParseError>,
}
impl Leaf {
pub(crate) fn command(&self) -> Command {
command(self.name, (self.arguments)()).about(self.about)
}
}
fn render_argument(argument: &Arg) -> String {
let value = argument
.get_value_names()
.and_then(|names| names.first())
.map_or_else(|| argument.get_id().as_str(), |name| name.as_str());
let repeated = argument
.get_num_args()
.is_some_and(|range| range.max_values() > 1);
match argument.get_long() {
Some(long) if argument.get_action().takes_values() => format!("--{long} {value}"),
Some(long) => format!("--{long}"),
None if argument.is_last_set() => format!("-- <{value}>..."),
None if repeated => format!("<{value}>..."),
None => format!("<{value}>"),
}
}
/// One-line usage for `command`, derived from its declared arguments and groups.
fn synopsis(path: &str, command: &Command) -> String {
let required_groups: Vec<_> = command
.get_groups()
.filter(|group| group.is_required_set())
.collect();
let grouped = |argument: &Arg| {
required_groups
.iter()
.any(|group| group.get_args().any(|id| id == argument.get_id()))
};
let visible = || {
command
.get_arguments()
.filter(|argument| !argument.is_hide_set() && !grouped(argument))
};
let render = |argument: &Arg| {
let text = render_argument(argument);
if argument.is_required_set() {
text
} else {
format!("[{text}]")
}
};
let mut parts = vec![path.to_owned()];
parts.extend(
visible()
.filter(|argument| argument.is_positional() && !argument.is_last_set())
.map(render),
);
for group in &required_groups {
let members: Vec<_> = group
.get_args()
.filter_map(|id| {
command
.get_arguments()
.find(|argument| argument.get_id() == id)
})
.map(render_argument)
.collect();
parts.push(format!("({})", members.join(" | ")));
}
parts.extend(
visible()
.filter(|argument| !argument.is_positional())
.map(render),
);
parts.extend(
visible()
.filter(|argument| argument.is_last_set())
.map(render),
);
parts.join(" ")
}
fn push_synopses(path: &str, command: &Command, lines: &mut Vec<String>) {
if command.has_subcommands() {
for subcommand in command.get_subcommands().filter(|sub| !sub.is_hide_set()) {
push_synopses(
&format!("{path} {}", subcommand.get_name()),
subcommand,
lines,
);
}
} else {
lines.push(synopsis(path, command));
}
}
/// The command list a bare group prints, derived from the group's declarations.
pub(crate) fn group_commands(group: &Command) -> String {
let path = format!("herdr {}", group.get_name());
let mut lines = vec![format!("{path} commands:")];
let mut synopses = Vec::new();
push_synopses(&path, group, &mut synopses);
lines.extend(synopses.into_iter().map(|line| format!(" {line}")));
lines.join("\n")
}
/// Parses `args` against a command group and builds the selected leaf's request.
pub(crate) fn group_request(
group: Command,
leaves: &[Leaf],
args: &[String],
) -> Result<Request, ParseError> {
let group_name = group.get_name().to_owned();
parse(
group.bin_name(format!("herdr {group_name}")),
args,
|matches| {
let (name, matches) = matches
.subcommand()
.ok_or_else(|| ParseError::Usage(format!("missing {group_name} command")))?;
let leaf = leaves
.iter()
.find(|leaf| leaf.name == name)
.ok_or_else(|| {
ParseError::Usage(format!("unknown {group_name} command: {name}"))
})?;
Ok(Request {
id: leaf.id.into(),
method: (leaf.method)(matches)?,
})
},
)
}
const VERBATIM_ESCAPE: char = '\u{1}';
/// Marks verbatim operands by value type so `parse` can find them on any `Arg`.
#[derive(Clone)]
struct VerbatimText(String);
fn is_verbatim(argument: &Arg) -> bool {
argument.get_value_parser().type_id() == TypeId::of::<VerbatimText>()
}
/// The innermost subcommand named by `args` and the index of its first argument.
fn select_leaf<'a>(command: &'a Command, args: &[String]) -> (&'a Command, usize) {
let mut leaf = command;
let mut start = 0;
while let Some(subcommand) = args.get(start).and_then(|name| leaf.find_subcommand(name)) {
leaf = subcommand;
start += 1;
}
(leaf, start)
}
/// The declared option that `token` names, if any. Undeclared dash words are operands.
fn declared_option<'a>(leaf: &'a Command, token: &str) -> Option<&'a Arg> {
if let Some(long) = token.strip_prefix("--") {
let name = long.split('=').next().unwrap_or(long);
leaf.get_arguments().find(|argument| {
argument.get_long() == Some(name)
|| argument
.get_all_aliases()
.is_some_and(|aliases| aliases.contains(&name))
})
} else {
let short = token.strip_prefix('-')?.chars().next()?;
leaf.get_arguments()
.find(|argument| argument.get_short() == Some(short))
}
}
/// Operands of the selected leaf, skipping declared options and their values.
pub(crate) fn operands<'a>(command: &Command, args: &'a [String]) -> Vec<&'a str> {
let (leaf, start) = select_leaf(command, args);
let mut operands = Vec::new();
let mut tokens = args[start..].iter();
while let Some(token) = tokens.next() {
match declared_option(leaf, token) {
Some(option) if option.get_action().takes_values() && !token.contains('=') => {
tokens.next();
}
Some(_) => {}
None => operands.push(token.as_str()),
}
}
operands
}
/// Text operands are read by argv position, so words such as `--wait` or `-h`
/// in those positions stay text instead of becoming flags. The escape keeps
/// Clap from treating them as options and is removed by `Input::verbatim`.
fn protect_verbatim_slots(command: &Command, args: &[String]) -> Vec<String> {
let (leaf, start) = select_leaf(command, args);
let operands = &args[start..];
let mut protected = args.to_vec();
for argument in leaf
.get_positionals()
.filter(|argument| is_verbatim(argument))
{
let Some(slot) = argument.get_index().and_then(|index| index.checked_sub(1)) else {
continue;
};
// Positions are only fixed while no declared option precedes the slot.
if slot >= operands.len()
|| operands[..slot]
.iter()
.any(|arg| declared_option(leaf, arg).is_some())
{
continue;
}
let end = if argument
.get_num_args()
.is_some_and(|range| range.max_values() > 1)
{
operands.len()
} else {
slot + 1
};
// A lone flag declared as this operand's alternative (`rename ID --clear`) stays a flag.
if end == operands.len() && end == slot + 1 {
let token = operands[slot].as_str();
if leaf.get_arg_conflicts_with(argument).iter().any(|flag| {
flag.get_long()
.is_some_and(|long| token.strip_prefix("--") == Some(long))
}) {
continue;
}
}
for token in &mut protected[start + slot..start + end] {
if token.starts_with('-') || token.starts_with(VERBATIM_ESCAPE) {
token.insert(0, VERBATIM_ESCAPE);
}
}
}
protected
}
fn string_values(matches: &ArgMatches, argument: &Arg) -> Vec<String> {
let id = argument.get_id().as_str();
if is_verbatim(argument) {
matches
.get_many::<VerbatimText>(id)
.map(|values| values.map(|value| value.0.clone()).collect())
.unwrap_or_default()
} else {
matches
.get_many::<String>(id)
.map(|values| values.cloned().collect())
.unwrap_or_default()
}
}
enum InputKind {
Value,
Joined,
Flag,
}
type Update<T> = fn(&mut T, &str) -> Result<(), ParseError>;
pub(crate) struct Input<T> {
argument: Arg,
kind: InputKind,
update: Update<T>,
once: bool,
}
impl<T> Input<T> {
pub(crate) fn positional(name: &'static str, index: usize, update: Update<T>) -> Self {
Self {
argument: Arg::new(name)
.value_name(name)
.index(index + 1)
.allow_hyphen_values(true)
.required(true),
kind: InputKind::Value,
update,
once: false,
}
}
pub(crate) fn rest(
name: &'static str,
value_name: &'static str,
index: usize,
update: Update<T>,
) -> Self {
Self {
argument: Arg::new(name)
.value_name(value_name)
.index(index + 1)
.allow_hyphen_values(true)
.required(true)
.num_args(1..),
kind: InputKind::Joined,
update,
once: false,
}
}
/// Every occurrence is validated in argv order; the field reducer decides
/// whether the last one wins or occurrences accumulate.
pub(crate) fn value(name: &'static str, value_name: &'static str, update: Update<T>) -> Self {
Self {
argument: Arg::new(name)
.long(name)
.value_name(value_name)
.allow_hyphen_values(true)
.action(ArgAction::Append),
kind: InputKind::Value,
update,
once: false,
}
}
pub(crate) fn flag(name: &'static str, update: Update<T>) -> Self {
Self {
argument: Arg::new(name).long(name).action(ArgAction::SetTrue),
kind: InputKind::Flag,
update,
once: false,
}
}
pub(crate) fn configure(mut self, configure: impl FnOnce(Arg) -> Arg) -> Self {
self.argument = configure(self.argument);
self
}
/// Reject a repeated option instead of letting the last occurrence win.
pub(crate) fn once(mut self) -> Self {
self.once = true;
self
}
/// Read this operand by position, keeping flag-shaped words as text.
pub(crate) fn verbatim(mut self) -> Self {
self.argument = self
.argument
.value_parser(StringValueParser::new().map(|value| {
VerbatimText(
value
.strip_prefix(VERBATIM_ESCAPE)
.map(str::to_owned)
.unwrap_or(value),
)
}));
self
}
pub(crate) fn value_name(self, name: &'static str) -> Self {
self.configure(|argument| argument.value_name(name))
}
pub(crate) fn path(mut self) -> Self {
self.argument = self.argument.value_hint(ValueHint::AnyPath);
self
}
pub(crate) fn order(mut self, order: usize) -> Self {
self.argument = self.argument.display_order(order);
self
}
pub(crate) fn required(mut self) -> Self {
self.argument = self.argument.required(true);
self
}
pub(crate) fn help(mut self, help: &'static str) -> Self {
self.argument = self.argument.help(help);
self
}
pub(crate) fn hidden(mut self) -> Self {
self.argument = self.argument.hide(true);
self
}
}
pub(crate) fn optional_text(name: &'static str, value_name: &'static str) -> Input<Option<String>> {
Input::value(name, value_name, |slot, value| {
*slot = Some(value.to_owned());
Ok(())
})
}
/// CLI spelling of an API value. Hidden spellings parse but stay out of help and completion.
pub(crate) struct Choice<T> {
pub(crate) name: &'static str,
pub(crate) value: T,
pub(crate) hidden: bool,
}
impl<T> Choice<T> {
pub(crate) const fn new(name: &'static str, value: T) -> Self {
Self {
name,
value,
hidden: false,
}
}
pub(crate) const fn hidden(name: &'static str, value: T) -> Self {
Self {
name,
value,
hidden: true,
}
}
}
pub(crate) fn parse_choice<T: Clone>(
choices: &[Choice<T>],
label: &str,
value: &str,
) -> Result<T, ParseError> {
choices
.iter()
.find(|choice| choice.name == value)
.map(|choice| choice.value.clone())
.ok_or_else(|| ParseError::Usage(format!("invalid {label}: {value}")))
}
/// Fails when an API enum gains a value the CLI table cannot spell.
#[cfg(test)]
pub(crate) fn assert_choices_cover_api_enum<T: serde::Serialize + schemars::JsonSchema>(
table: &[Choice<T>],
) {
use std::collections::BTreeSet;
let schema = serde_json::to_value(schemars::schema_for!(T)).expect("enum schema");
let api: BTreeSet<String> = schema["enum"]
.as_array()
.expect("unit enum schema")
.iter()
.map(|value| value.as_str().expect("string enum value").to_owned())
.collect();
let cli: BTreeSet<String> = table
.iter()
.map(|choice| {
serde_json::to_value(&choice.value)
.expect("serializable choice")
.as_str()
.expect("string enum value")
.to_owned()
})
.collect();
assert_eq!(cli, api, "{}", std::any::type_name::<T>());
}
pub(crate) fn choices<T>(argument: Arg, table: &[Choice<T>]) -> Arg {
choices_where(argument, table, |_| true)
}
/// Like `choices`, but hides values the command does not advertise.
pub(crate) fn choices_where<T>(
argument: Arg,
table: &[Choice<T>],
advertised: impl Fn(&T) -> bool,
) -> Arg {
argument.value_parser(clap::builder::PossibleValuesParser::new(table.iter().map(
|choice| {
clap::builder::PossibleValue::new(choice.name)
.hide(choice.hidden || !advertised(&choice.value))
},
)))
}
pub(crate) fn boolean(name: &'static str, value: bool) -> Input<bool> {
// Updates are function pointers, so each choice uses a non-capturing reducer.
if value {
Input::flag(name, |slot, _| {
*slot = true;
Ok(())
})
} else {
Input::flag(name, |slot, _| {
*slot = false;
Ok(())
})
}
}
type Validate<T> = fn(&T) -> Result<(), ParseError>;
pub(crate) struct Field<T> {
value: T,
inputs: Vec<Input<T>>,
validate: Option<Validate<T>>,
}
impl<T> Field<T> {
pub(crate) fn new(value: T) -> Self {
Self {
value,
inputs: Vec::new(),
validate: None,
}
}
pub(crate) fn input(mut self, input: Input<T>) -> Self {
self.inputs.push(input);
self
}
pub(crate) fn validate(mut self, validate: Validate<T>) -> Self {
self.validate = Some(validate);
self
}
pub(crate) fn arguments(self) -> Vec<Arg> {
self.inputs
.into_iter()
.map(|input| input.argument)
.collect()
}
pub(crate) fn read_matches(&mut self, matches: &ArgMatches) -> Result<(), ParseError> {
// A field can have several inputs (set/clear, focus/no-focus). Reduce them
// in argv order rather than field-declaration order.
let mut updates = Vec::new();
for input in &self.inputs {
let id = input.argument.get_id().as_str();
if matches.value_source(id) != Some(ValueSource::CommandLine) {
continue;
}
let Some(indices) = matches.indices_of(id) else {
continue;
};
match input.kind {
InputKind::Flag => {
for index in indices {
updates.push((index, input, String::new()));
}
}
InputKind::Value => {
if input.once && indices.len() > 1 {
return Err(ParseError::Clap(clap::Error::raw(
clap::error::ErrorKind::ArgumentConflict,
format!("the argument '--{id}' cannot be used multiple times\n"),
)));
}
for (index, value) in indices.zip(string_values(matches, &input.argument)) {
updates.push((index, input, value));
}
}
InputKind::Joined => {
if let Some(index) = indices.into_iter().next() {
let values = string_values(matches, &input.argument);
updates.push((index, input, values.join(" ")));
}
}
}
}
updates.sort_by_key(|(index, _, _)| *index);
for (_, input, value) in updates {
(input.update)(&mut self.value, &value)?;
}
Ok(())
}
pub(crate) fn finish(self) -> Result<T, ParseError> {
if let Some(validate) = self.validate {
validate(&self.value)?;
}
Ok(self.value)
}
}
macro_rules! cli_projection {
($visibility:vis $projection:ident for $params:ident {
$($field:ident => $binding:expr),* $(,)?
}) => {
$visibility struct $projection;
impl $projection {
pub(crate) fn arguments() -> Vec<clap::Arg> {
let arguments: Vec<Vec<clap::Arg>> = vec![$(($binding).arguments()),*];
let mut arguments: Vec<_> = arguments.into_iter().flatten().collect();
arguments.sort_by_key(clap::Arg::get_display_order);
arguments
}
pub(crate) fn from_matches(
matches: &clap::ArgMatches,
) -> Result<$params, $crate::cli::projection::ParseError> {
$(let mut $field = $binding;
$field.read_matches(matches)?;)*
let _ = matches;
Ok($params { $($field: $field.finish()?),* })
}
}
};
}
macro_rules! cli_params {
($(#[$attribute:meta])* $visibility:vis struct $params:ident => $projection:ident {
$($(#[$field_attribute:meta])* $field_visibility:vis $field:ident: $type:ty => $binding:expr),* $(,)?
}) => {
$(#[$attribute])*
$visibility struct $params {
$($(#[$field_attribute])* $field_visibility $field: $type),*
}
$crate::cli::projection::cli_projection! {
pub(crate) $projection for $params {
$($field => $binding),*
}
}
};
}
pub(crate) use cli_params;
pub(crate) use cli_projection;
#[cfg(test)]
mod tests {
use super::*;
struct Sample {
id: String,
value: Option<String>,
enabled: bool,
}
cli_projection! {
SampleProjection for Sample {
id => Field::new(String::new()).input(Input::positional("id", 0, |id, value| {
*id = value.to_owned();
Ok(())
})),
value => Field::new(None).input(Input::value("value", "TEXT", |slot, value| {
*slot = Some(value.to_owned());
Ok(())
}).configure(|argument| argument.short('v').alias("text"))),
enabled => Field::new(false)
.input(Input::flag("on", |slot, _| { *slot = true; Ok(()) }))
.input(Input::flag("off", |slot, _| { *slot = false; Ok(()) }).hidden()),
}
}
fn args(args: &[&str]) -> Vec<String> {
args.iter().map(|arg| (*arg).to_owned()).collect()
}
fn parse_sample(args: &[String]) -> Result<Sample, ParseError> {
parse(
command("sample", SampleProjection::arguments()),
args,
SampleProjection::from_matches,
)
}
#[test]
fn projection_uses_clap_for_ordered_values_and_hidden_flags() {
let parsed = parse_sample(&args(&[
"--literal-id",
"--value",
"first",
"--on",
"--value",
"--literal-value",
"--off",
]))
.unwrap();
assert_eq!(parsed.id, "--literal-id");
assert_eq!(parsed.value.as_deref(), Some("--literal-value"));
assert!(!parsed.enabled);
let arguments = SampleProjection::arguments();
assert!(arguments
.iter()
.any(|arg| arg.get_long() == Some("off") && arg.is_hide_set()));
}
#[test]
fn projection_parses_the_composed_command_relations() {
let composed = || {
command("sample", SampleProjection::arguments()).group(
clap::ArgGroup::new("switch")
.args(["on", "off"])
.required(true),
)
};
assert!(matches!(
parse(composed(), &args(&["id"]), SampleProjection::from_matches),
Err(ParseError::Clap(error)) if error.kind() == clap::error::ErrorKind::MissingRequiredArgument
));
let parsed = parse(
composed(),
&args(&["id", "--text=x", "--off"]),
SampleProjection::from_matches,
)
.unwrap();
assert_eq!(parsed.value.as_deref(), Some("x"));
assert!(!parsed.enabled);
assert!(!command("sample", SampleProjection::arguments())
.render_long_help()
.to_string()
.contains("--off"));
}
#[test]
fn projection_accepts_clap_normalization_and_diagnostics() {
let parsed = parse_sample(&args(&["--value=x", "id"])).unwrap();
assert_eq!(parsed.value.as_deref(), Some("x"));
for (args, kind) in [
(args(&[]), clap::error::ErrorKind::MissingRequiredArgument),
(
args(&["id", "--value"]),
clap::error::ErrorKind::InvalidValue,
),
(
args(&["id", "extra"]),
clap::error::ErrorKind::UnknownArgument,
),
] {
assert!(
matches!(parse_sample(&args), Err(ParseError::Clap(error)) if error.kind() == kind)
);
}
}
struct Text {
target: String,
text: String,
wait: bool,
}
cli_projection! {
TextProjection for Text {
target => Field::new(String::new()).input(Input::positional("target", 0, |slot, value| {
*slot = value.to_owned();
Ok(())
})),
text => Field::new(String::new()).input(Input::positional("text", 1, |slot, value| {
*slot = value.to_owned();
Ok(())
}).verbatim()),
wait => Field::new(false).input(boolean("wait", true)),
}
}
fn parse_text(values: &[&str]) -> Result<Text, ParseError> {
let group = Command::new("group").subcommand(command("leaf", TextProjection::arguments()));
parse(group, &args(values), |matches| {
TextProjection::from_matches(matches.subcommand_matches("leaf").expect("leaf"))
})
}
#[test]
fn verbatim_slot_keeps_flag_shaped_words_as_text() {
for (values, text, wait) in [
(&["leaf", "t", "--wait"][..], "--wait", false),
(&["leaf", "t", "-h", "--wait"][..], "-h", true),
(&["leaf", "t", "--", "--wait"][..], "--", true),
(&["leaf", "t", "\u{1}x"][..], "\u{1}x", false),
(&["leaf", "--wait", "t", "plain"][..], "plain", true),
] {
let parsed = parse_text(values).unwrap();
assert_eq!(parsed.target, "t");
assert_eq!(
(parsed.text.as_str(), parsed.wait),
(text, wait),
"{values:?}"
);
}
// An undeclared dash word is an operand, so it does not shift the slot.
let parsed = parse_text(&["leaf", "-t", "--wait"]).unwrap();
assert_eq!(
(parsed.target.as_str(), parsed.text.as_str(), parsed.wait),
("-t", "--wait", false)
);
}
#[test]
fn group_commands_are_derived_from_declarations() {
let group = Command::new("group")
.subcommand(command("label", LabelProjection::arguments()))
.subcommand(
Command::new("nested")
.subcommand(command("numbers", NumbersProjection::arguments())),
)
.subcommand(
command("pick", NumbersProjection::arguments()).group(
clap::ArgGroup::new("pick")
.args(["seq", "pattern"])
.required(true),
),
);
assert_eq!(
group_commands(&group),
"herdr group commands:\n herdr group label <id> [<LABEL>...] [--clear]\n herdr group nested numbers [--seq N] [--pattern TEXT]\n herdr group pick (--seq N | --pattern TEXT)"
);
}
#[test]
fn operands_skip_declared_options_and_their_values() {
let group =
Command::new("group").subcommand(command("leaf", NumbersProjection::arguments()));
let values = args(&["leaf", "--seq", "help", "--pattern=x", "-u", "help"]);
assert_eq!(operands(&group, &values), ["-u", "help"]);
}
struct Label {
id: String,
label: Option<String>,
}
cli_projection! {
LabelProjection for Label {
id => Field::new(String::new()).input(Input::positional("id", 0, |slot, value| {
*slot = value.to_owned();
Ok(())
})),
label => Field::new(None)
.input(Input::rest("label", "LABEL", 1, |slot, value| {
*slot = Some(value.to_owned());
Ok(())
}).verbatim().configure(|arg| arg.required(false).conflicts_with("clear")))
.input(Input::flag("clear", |slot, _| {
*slot = None;
Ok(())
})),
}
}
#[test]
fn verbatim_rest_joins_flags_but_a_lone_alternative_stays_a_flag() {
let parse_label = |values: &[&str]| {
let parsed = parse(
command("label", LabelProjection::arguments()),
&args(values),
LabelProjection::from_matches,
)
.unwrap();
assert_eq!(parsed.id, "id");
parsed.label
};
assert_eq!(parse_label(&["id", "--clear"]), None);
assert_eq!(
parse_label(&["id", "--clear", "x"]).as_deref(),
Some("--clear x")
);
assert_eq!(
parse_label(&["id", "-h", "--help"]).as_deref(),
Some("-h --help")
);
}
struct Numbers {
seq: Option<u64>,
pattern: Option<String>,
}
cli_projection! {
NumbersProjection for Numbers {
seq => Field::new(None).input(Input::value("seq", "N", |slot, value| {
*slot = Some(value.parse::<u64>().map_err(|_| ParseError::Usage(format!("invalid seq: {value}")))?);
Ok(())
})),
pattern => Field::new(None).input(optional_text("pattern", "TEXT").once()),
}
}
#[test]
fn repeated_options_validate_every_occurrence_unless_declared_once() {
let parse_numbers = |values: &[&str]| {
parse(
command("numbers", NumbersProjection::arguments()),
&args(values),
NumbersProjection::from_matches,
)
};
assert_eq!(
parse_numbers(&["--seq", "1", "--seq", "2"]).unwrap().seq,
Some(2)
);
assert!(matches!(
parse_numbers(&["--seq", "bogus", "--seq", "1"]),
Err(ParseError::Usage(message)) if message == "invalid seq: bogus"
));
assert_eq!(
parse_numbers(&["--pattern", "a"])
.unwrap()
.pattern
.as_deref(),
Some("a")
);
assert!(matches!(
parse_numbers(&["--pattern", "a", "--pattern", "b"]),
Err(ParseError::Clap(error)) if error.kind() == clap::error::ErrorKind::ArgumentConflict
));
}
}
-123
View File
@@ -1,123 +0,0 @@
use crate::api::schema::{
EmptyParams, Method, PaneFocusDirectionParams, PaneInputSetParams, PaneMoveParams,
PaneRenameParams, PaneResizeParams, PaneSplitParams, PaneSwapParams, PaneTarget,
PaneZoomParams, Request, TabCreateParams, TabListParams, TabRenameParams, TabTarget,
WorkspaceCloseParams, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget,
WorktreeCreateParams, WorktreeListParams, WorktreeOpenParams, WorktreeRemoveParams,
};
fn print_method_response(id: &'static str, method: Method) -> std::io::Result<i32> {
super::print_response(&super::send_request(&Request {
id: id.into(),
method,
})?)
}
pub(super) fn workspace_list() -> std::io::Result<i32> {
print_method_response(
"cli:workspace:list",
Method::WorkspaceList(EmptyParams::default()),
)
}
pub(super) fn workspace_create(params: WorkspaceCreateParams) -> std::io::Result<i32> {
print_method_response("cli:workspace:create", Method::WorkspaceCreate(params))
}
pub(super) fn workspace_get(workspace_id: String) -> std::io::Result<i32> {
print_method_response(
"cli:workspace:get",
Method::WorkspaceGet(WorkspaceTarget { workspace_id }),
)
}
pub(super) fn workspace_focus(workspace_id: String) -> std::io::Result<i32> {
print_method_response(
"cli:workspace:focus",
Method::WorkspaceFocus(WorkspaceTarget { workspace_id }),
)
}
pub(super) fn workspace_rename(params: WorkspaceRenameParams) -> std::io::Result<i32> {
print_method_response("cli:workspace:rename", Method::WorkspaceRename(params))
}
pub(super) fn workspace_close(params: WorkspaceCloseParams) -> std::io::Result<i32> {
print_method_response("cli:workspace:close", Method::WorkspaceClose(params))
}
pub(super) fn tab_list(params: TabListParams) -> std::io::Result<i32> {
print_method_response("cli:tab:list", Method::TabList(params))
}
pub(super) fn tab_create(params: TabCreateParams) -> std::io::Result<i32> {
print_method_response("cli:tab:create", Method::TabCreate(params))
}
pub(super) fn tab_get(tab_id: String) -> std::io::Result<i32> {
print_method_response("cli:tab:get", Method::TabGet(TabTarget { tab_id }))
}
pub(super) fn tab_focus(tab_id: String) -> std::io::Result<i32> {
print_method_response("cli:tab:focus", Method::TabFocus(TabTarget { tab_id }))
}
pub(super) fn tab_rename(params: TabRenameParams) -> std::io::Result<i32> {
print_method_response("cli:tab:rename", Method::TabRename(params))
}
pub(super) fn tab_close(tab_id: String) -> std::io::Result<i32> {
print_method_response("cli:tab:close", Method::TabClose(TabTarget { tab_id }))
}
pub(super) fn worktree_list(params: WorktreeListParams) -> std::io::Result<i32> {
print_method_response("cli:worktree:list", Method::WorktreeList(params))
}
pub(super) fn worktree_create(params: WorktreeCreateParams) -> std::io::Result<i32> {
print_method_response("cli:worktree:create", Method::WorktreeCreate(params))
}
pub(super) fn worktree_open(params: WorktreeOpenParams) -> std::io::Result<i32> {
print_method_response("cli:worktree:open", Method::WorktreeOpen(params))
}
pub(super) fn worktree_remove(params: WorktreeRemoveParams) -> std::io::Result<i32> {
print_method_response("cli:worktree:remove", Method::WorktreeRemove(params))
}
pub(super) fn pane_focus(params: PaneFocusDirectionParams) -> std::io::Result<i32> {
print_method_response("cli:pane:focus", Method::PaneFocusDirection(params))
}
pub(super) fn pane_resize(params: PaneResizeParams) -> std::io::Result<i32> {
print_method_response("cli:pane:resize", Method::PaneResize(params))
}
pub(super) fn pane_zoom(params: PaneZoomParams) -> std::io::Result<i32> {
print_method_response("cli:pane:zoom", Method::PaneZoom(params))
}
pub(super) fn pane_rename(params: PaneRenameParams) -> std::io::Result<i32> {
print_method_response("cli:pane:rename", Method::PaneRename(params))
}
pub(super) fn pane_input_set(params: PaneInputSetParams) -> std::io::Result<i32> {
print_method_response("cli:pane:input:set", Method::PaneInputSet(params))
}
pub(super) fn pane_split(params: PaneSplitParams) -> std::io::Result<i32> {
print_method_response("cli:pane:split", Method::PaneSplit(params))
}
pub(super) fn pane_swap(params: PaneSwapParams) -> std::io::Result<i32> {
print_method_response("cli:pane:swap", Method::PaneSwap(params))
}
pub(super) fn pane_move(params: PaneMoveParams) -> std::io::Result<i32> {
print_method_response("cli:pane:move", Method::PaneMove(params))
}
pub(super) fn pane_close(pane_id: String) -> std::io::Result<i32> {
print_method_response("cli:pane:close", Method::PaneClose(PaneTarget { pane_id }))
}
+44 -29
View File
@@ -1,3 +1,5 @@
use super::projection::{self, ParseError};
use crate::api::schema::server::ServerLiveHandoffProjection;
use crate::api::schema::{EmptyParams, Method, Request, ServerLiveHandoffParams};
pub(super) fn run_server_command(args: &[String]) -> std::io::Result<Option<i32>> {
@@ -202,12 +204,21 @@ fn print_agent_manifest_status(response: &serde_json::Value) {
}
}
pub(super) fn live_handoff_command() -> clap::Command {
projection::command("live-handoff", ServerLiveHandoffProjection::arguments())
.about("Hand off live panes to a new local server")
.hide(true)
}
fn server_live_handoff(args: &[String]) -> std::io::Result<i32> {
let Some(params) = parse_live_handoff_params(args) else {
eprintln!(
"usage: herdr server live-handoff [--import-exe <path>] [--expected-protocol <n>] [--expected-version <version>]"
);
return Ok(2);
let params = match parse_live_handoff_params(args) {
Ok(params) => params,
Err(ParseError::Clap(error)) => return super::print_clap_error(&error),
Err(ParseError::Usage(message)) => {
eprintln!("{message}");
return Ok(2);
}
Err(ParseError::Io(error)) => return Err(error),
};
// Live handoff is itself a protocol-mismatch recovery path, so it must
@@ -235,30 +246,12 @@ fn server_live_handoff(args: &[String]) -> std::io::Result<i32> {
Ok(0)
}
fn parse_live_handoff_params(args: &[String]) -> Option<ServerLiveHandoffParams> {
let mut params = ServerLiveHandoffParams::default();
let mut idx = 0;
while idx < args.len() {
let arg = &args[idx];
let (flag, value) = if let Some((flag, value)) = arg.split_once('=') {
(flag, Some(value.to_string()))
} else {
let value = args.get(idx + 1).cloned();
idx += 1;
(arg.as_str(), value)
};
let value = value?;
match flag {
"--import-exe" => params.import_exe = Some(value),
"--expected-protocol" => {
params.expected_protocol = Some(value.parse().ok()?);
}
"--expected-version" => params.expected_version = Some(value),
_ => return None,
}
idx += 1;
}
Some(params)
fn parse_live_handoff_params(args: &[String]) -> Result<ServerLiveHandoffParams, ParseError> {
projection::parse(
live_handoff_command().bin_name("herdr server live-handoff"),
args,
ServerLiveHandoffProjection::from_matches,
)
}
fn print_server_help() {
@@ -356,6 +349,28 @@ mod tests {
);
}
#[test]
fn live_handoff_projection_preserves_defaults_and_stays_hidden() {
assert_eq!(
serde_json::to_value(parse_live_handoff_params(&[]).unwrap()).unwrap(),
serde_json::json!({})
);
assert!(live_handoff_command().is_hide_set());
let args = vec!["--help".into()];
assert!(
matches!(parse_live_handoff_params(&args), Err(ParseError::Clap(error)) if error.exit_code() == 0)
);
for values in [
vec!["--expected-protocol", "bad"],
vec!["--expected-protocol", "4294967296"],
vec!["--import-exe"],
vec!["--other", "value"],
] {
let args = values.into_iter().map(String::from).collect::<Vec<_>>();
assert!(parse_live_handoff_params(&args).is_err());
}
}
#[test]
fn live_handoff_params_parse_remote_update_fields() {
let args = vec![
+17 -683
View File
@@ -1,6 +1,6 @@
use std::io::Write;
use clap::{Arg, ArgAction, ArgGroup, Command, ValueHint};
use clap::{Arg, ArgAction, Command, ValueHint};
mod completion;
mod machine;
@@ -159,6 +159,7 @@ fn channel_command() -> Command {
fn server_command() -> Command {
let command = Command::new("server")
.about("Run or control the headless server")
.subcommand(super::server::live_handoff_command())
.subcommand(Command::new("stop").about("Stop the running server"))
.subcommand(Command::new("reload-config").about("Reload config in the running server"))
.subcommand(
@@ -196,512 +197,27 @@ fn api_command() -> Command {
}
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"))
super::workspace_bindings::command()
}
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")),
)
super::worktree::command()
}
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"))
super::tab::command()
}
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"])),
)
super::notification::command()
}
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 requires an observed working or blocked state within 5000ms; otherwise it returns agent_prompt_stalled. A caller timeout that expires first returns timeout. 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.",
),
)
.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()
super::agent::command()
}
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"))
.arg(resume_argv_arg())
}
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"))
.arg(resume_argv_arg())
}
fn resume_argv_arg() -> Arg {
Arg::new("resume_argv")
.value_name("RESUME_ARG")
.num_args(0..)
.last(true)
.help("Command that resumes this session after a Herdr restart; starts with a plain command name")
}
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"))
super::pane::command()
}
fn terminal_command() -> Command {
@@ -732,16 +248,7 @@ fn terminal_command() -> Command {
.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")),
)
.subcommand(super::title::command())
}
fn session_command() -> Command {
@@ -788,130 +295,7 @@ fn integration_command() -> Command {
}
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("update")
.about("Update GitHub-installed plugins")
.arg(Arg::new("plugins").value_name("PLUGIN").num_args(0..))
.arg(
Arg::new("yes")
.short('y')
.long("yes")
.action(ArgAction::SetTrue),
),
)
.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")]
super::plugin::command()
}
fn integration_target_arg() -> Arg {
@@ -930,47 +314,6 @@ fn integration_target_values() -> Vec<&'static str> {
values
}
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")
}
@@ -983,12 +326,6 @@ fn help_flag() -> Arg {
.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)
}
@@ -1000,10 +337,6 @@ fn option(name: &'static str, value_name: &'static str) -> Arg {
.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)
}
@@ -1012,10 +345,6 @@ 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};
@@ -1242,12 +571,17 @@ mod tests {
}
#[test]
fn worktree_json_compatibility_flag_stays_out_of_public_spec() {
fn worktree_json_compatibility_flag_stays_out_of_public_help() {
let cmd = super::command();
for subcommand in ["list", "create", "open", "remove"] {
let worktree_command = command_path(&cmd, &["worktree", subcommand]);
assert!(option_arg(worktree_command, "json").is_hide_set());
assert!(
!has_option(worktree_command, "json"),
!worktree_command
.clone()
.render_long_help()
.to_string()
.contains("--json"),
"herdr worktree {subcommand} should not advertise --json"
);
}
+58 -175
View File
@@ -1,187 +1,70 @@
use std::collections::HashMap;
#[path = "tab_bindings.rs"]
mod bindings;
use crate::api::schema::{TabCreateParams, TabListParams, TabRenameParams};
pub(crate) use bindings::command;
pub(super) fn run_tab_command(args: &[String]) -> std::io::Result<i32> {
let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
print_tab_help();
return Ok(2);
};
match subcommand {
"list" => tab_list(&args[1..]),
"create" => tab_create(&args[1..]),
"get" => tab_get(&args[1..]),
"focus" => tab_focus(&args[1..]),
"rename" => tab_rename(&args[1..]),
"close" => tab_close(&args[1..]),
"help" | "--help" | "-h" => {
print_tab_help();
Ok(0)
}
_ => {
print_tab_help();
Ok(2)
}
}
super::run_json_request(bindings::request(args))
}
fn tab_list(args: &[String]) -> std::io::Result<i32> {
let mut workspace_id = None;
#[cfg(test)]
mod tests {
use std::collections::HashMap;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--workspace" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --workspace");
return Ok(2);
};
workspace_id = Some(super::normalize_workspace_id(value));
index += 2;
}
other => {
eprintln!("unknown option: {other}");
return Ok(2);
}
use crate::api::schema::{TabCreateParams, TabListParams};
use super::*;
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_owned()).collect()
}
#[test]
fn simple_families_tab_rejects_invalid_grammar_before_transport() {
for values in [
vec![],
vec!["unknown"],
vec!["list", "--workspace"],
vec!["create", "--cwd"],
vec!["create", "--label"],
vec!["create", "--env"],
vec!["create", "--env", "NO_EQUALS"],
vec!["create", "--focus=true"],
vec!["get"],
vec!["get", "2", "extra"],
vec!["focus"],
vec!["focus", "2", "extra"],
vec!["rename", "2"],
vec!["close"],
vec!["close", "2", "extra"],
] {
assert!(
bindings::request(&args(&values)).is_err(),
"argv: {values:?}"
);
}
}
super::runtime::tab_list(TabListParams { workspace_id })
}
fn tab_create(args: &[String]) -> std::io::Result<i32> {
let mut workspace_id = None;
let mut cwd = None;
let mut focus = false;
let mut label = None;
let mut env = HashMap::new();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--workspace" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --workspace");
return Ok(2);
};
workspace_id = Some(super::normalize_workspace_id(value));
index += 2;
#[test]
fn simple_families_tab_dto_literal_defaults() {
let create: TabCreateParams = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(
create,
TabCreateParams {
workspace_id: None,
cwd: None,
focus: false,
label: None,
env: HashMap::new(),
}
"--cwd" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --cwd");
return Ok(2);
};
cwd = Some(value.clone());
index += 2;
}
"--label" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --label");
return Ok(2);
};
label = Some(value.clone());
index += 2;
}
"--focus" => {
focus = true;
index += 1;
}
"--no-focus" => {
focus = false;
index += 1;
}
"--env" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --env");
return Ok(2);
};
let (key, value) = match super::parse_env_assignment(value) {
Ok(pair) => pair,
Err(err) => {
eprintln!("{err}");
return Ok(2);
}
};
env.insert(key, value);
index += 2;
}
other => {
eprintln!("unknown option: {other}");
return Ok(2);
}
}
);
assert_eq!(
serde_json::to_value(create).unwrap(),
serde_json::json!({"focus": false})
);
assert_eq!(
serde_json::to_value(TabListParams::default()).unwrap(),
serde_json::json!({})
);
}
super::runtime::tab_create(TabCreateParams {
workspace_id,
cwd,
focus,
label,
env,
})
}
fn tab_get(args: &[String]) -> std::io::Result<i32> {
let Some(raw_tab_id) = args.first() else {
eprintln!("usage: herdr tab get <tab_id>");
return Ok(2);
};
if args.len() != 1 {
eprintln!("usage: herdr tab get <tab_id>");
return Ok(2);
}
super::runtime::tab_get(super::normalize_tab_id(raw_tab_id))
}
fn tab_focus(args: &[String]) -> std::io::Result<i32> {
let Some(raw_tab_id) = args.first() else {
eprintln!("usage: herdr tab focus <tab_id>");
return Ok(2);
};
if args.len() != 1 {
eprintln!("usage: herdr tab focus <tab_id>");
return Ok(2);
}
super::runtime::tab_focus(super::normalize_tab_id(raw_tab_id))
}
fn tab_rename(args: &[String]) -> std::io::Result<i32> {
if args.len() < 2 {
eprintln!("usage: herdr tab rename <tab_id> <label>");
return Ok(2);
}
super::runtime::tab_rename(TabRenameParams {
tab_id: super::normalize_tab_id(&args[0]),
label: args[1..].join(" "),
})
}
fn tab_close(args: &[String]) -> std::io::Result<i32> {
let Some(raw_tab_id) = args.first() else {
eprintln!("usage: herdr tab close <tab_id>");
return Ok(2);
};
if args.len() != 1 {
eprintln!("usage: herdr tab close <tab_id>");
return Ok(2);
}
super::runtime::tab_close(super::normalize_tab_id(raw_tab_id))
}
fn print_tab_help() {
eprintln!("herdr tab commands:");
eprintln!(" herdr tab list [--workspace <workspace_id>]");
eprintln!(
" herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--env KEY=VALUE] [--focus] [--no-focus]"
);
eprintln!(" herdr tab get <tab_id>");
eprintln!(" herdr tab focus <tab_id>");
eprintln!(" herdr tab rename <tab_id> <label>");
eprintln!(" herdr tab close <tab_id>");
}
+170
View File
@@ -0,0 +1,170 @@
use clap::Command;
use crate::api::schema::tabs::{
tab_id, TabCreateProjection, TabListProjection, TabRenameProjection,
};
use crate::api::schema::{Method, Request, TabTarget};
use super::super::projection::{self, cli_projection, Field, Leaf, ParseError};
cli_projection! {
pub(crate) TabTargetProjection for TabTarget {
tab_id => Field::new(String::new()).input(tab_id()),
}
}
const LEAVES: [Leaf; 6] = [
Leaf {
name: "list",
about: "List tabs",
id: "cli:tab:list",
arguments: TabListProjection::arguments,
method: |matches| TabListProjection::from_matches(matches).map(Method::TabList),
},
Leaf {
name: "create",
about: "Create a tab",
id: "cli:tab:create",
arguments: TabCreateProjection::arguments,
method: |matches| TabCreateProjection::from_matches(matches).map(Method::TabCreate),
},
Leaf {
name: "get",
about: "Show a tab",
id: "cli:tab:get",
arguments: TabTargetProjection::arguments,
method: |matches| TabTargetProjection::from_matches(matches).map(Method::TabGet),
},
Leaf {
name: "focus",
about: "Focus a tab",
id: "cli:tab:focus",
arguments: TabTargetProjection::arguments,
method: |matches| TabTargetProjection::from_matches(matches).map(Method::TabFocus),
},
Leaf {
name: "rename",
about: "Rename a tab",
id: "cli:tab:rename",
arguments: TabRenameProjection::arguments,
method: |matches| TabRenameProjection::from_matches(matches).map(Method::TabRename),
},
Leaf {
name: "close",
about: "Close a tab",
id: "cli:tab:close",
arguments: TabTargetProjection::arguments,
method: |matches| TabTargetProjection::from_matches(matches).map(Method::TabClose),
},
];
pub(crate) fn command() -> Command {
Command::new("tab")
.about("Manage tabs over the socket API")
.subcommand_required(true)
.subcommands(LEAVES.iter().map(Leaf::command))
}
pub(crate) fn request(args: &[String]) -> Result<Request, ParseError> {
projection::group_request(command(), &LEAVES, args)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Request, ParseError> {
request(&args.iter().map(|arg| (*arg).to_owned()).collect::<Vec<_>>())
}
#[test]
fn tab_projection_preserves_requests_defaults_and_maps() {
for (args, expected) in [
(
vec!["list"],
serde_json::json!({"id":"cli:tab:list","method":"tab.list","params":{}}),
),
(
vec!["create"],
serde_json::json!({"id":"cli:tab:create","method":"tab.create","params":{"focus":false}}),
),
(
vec![
"create",
"--workspace",
"2",
"--cwd",
"relative",
"--label",
"first",
"--label",
"last",
"--focus",
"--no-focus",
"--env",
"K=first",
"--env",
"K=",
"--env",
"V=a=b",
],
serde_json::json!({"id":"cli:tab:create","method":"tab.create","params":{"workspace_id":"2","cwd":"relative","label":"last","focus":false,"env":{"K":"","V":"a=b"}}}),
),
(
vec!["rename", "2", "two", "--words", "here"],
serde_json::json!({"id":"cli:tab:rename","method":"tab.rename","params":{"tab_id":"2","label":"two --words here"}}),
),
(
vec!["get", "2"],
serde_json::json!({"id":"cli:tab:get","method":"tab.get","params":{"tab_id":"2"}}),
),
(
vec!["focus", "2"],
serde_json::json!({"id":"cli:tab:focus","method":"tab.focus","params":{"tab_id":"2"}}),
),
(
vec!["close", "tab-2"],
serde_json::json!({"id":"cli:tab:close","method":"tab.close","params":{"tab_id":"tab-2"}}),
),
] {
assert_eq!(
serde_json::to_value(parse(&args).unwrap()).unwrap(),
expected,
"{args:?}"
);
}
assert!(
matches!(parse(&["create", "--env", "=value"]), Err(ParseError::Usage(message)) if message == "env key must not be empty")
);
}
#[test]
fn tab_projection_clap_grammar_and_ordered_focus() {
let value = serde_json::to_value(
parse(&[
"create",
"--focus",
"--no-focus",
"--focus",
"--cwd=relative",
])
.unwrap(),
)
.unwrap();
assert_eq!(
value["params"],
serde_json::json!({"cwd":"relative", "focus":true})
);
assert!(
matches!(parse(&["get", "2", "--help"]), Err(ParseError::Clap(error)) if error.exit_code() == 0)
);
for args in [
vec!["get"],
vec!["get", "2", "extra"],
vec!["create", "--cwd"],
] {
assert!(matches!(parse(&args), Err(ParseError::Clap(error)) if error.exit_code() == 2));
}
command().debug_assert();
}
}
+95
View File
@@ -0,0 +1,95 @@
use clap::{ArgMatches, Command};
use crate::api::schema::common::ClientWindowTitleSetProjection;
use crate::api::schema::{EmptyParams, Method, Request};
use super::projection::{self, ParseError};
pub(crate) fn command() -> Command {
Command::new("title")
.about("Manage the outer terminal title")
.subcommand_required(true)
.arg_required_else_help(true)
.subcommand(
projection::command("set", ClientWindowTitleSetProjection::arguments())
.about("Set the outer terminal title"),
)
.subcommand(Command::new("clear").about("Clear the outer terminal title"))
}
fn request(matches: &ArgMatches) -> Result<Request, ParseError> {
match matches.subcommand() {
Some(("set", matches)) => Ok(Request {
id: "cli:terminal:title:set".into(),
method: Method::ClientWindowTitleSet(ClientWindowTitleSetProjection::from_matches(
matches,
)?),
}),
Some(("clear", _)) => Ok(Request {
id: "cli:terminal:title:clear".into(),
method: Method::ClientWindowTitleClear(EmptyParams::default()),
}),
_ => Err(ParseError::Usage("expected title set or clear".into())),
}
}
pub(super) fn run(args: &[String]) -> std::io::Result<i32> {
match projection::parse(command(), args, request) {
Ok(request) => super::print_response(&super::send_request(&request)?),
Err(ParseError::Clap(error)) => super::print_clap_error(&error),
Err(ParseError::Usage(message)) => {
eprintln!("{message}");
Ok(2)
}
Err(ParseError::Io(error)) => Err(error),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn title_binding_preserves_literal_requests() {
for (args, expected) in [
(
vec!["set", "a literal title"],
serde_json::json!({
"id": "cli:terminal:title:set",
"method": "client.window_title.set",
"params": {"title": "a literal title"},
}),
),
(
vec!["clear"],
serde_json::json!({
"id": "cli:terminal:title:clear",
"method": "client.window_title.clear",
"params": {},
}),
),
] {
let args: Vec<_> = args.into_iter().map(String::from).collect();
let actual = projection::parse(command(), &args, request).expect("valid title command");
assert_eq!(
serde_json::to_value(actual).expect("request JSON"),
expected
);
}
}
#[test]
fn title_binding_help_and_invalid_arity_stop_before_transport() {
command().debug_assert();
for args in [vec!["set"], vec!["clear", "extra"], vec!["set", "a", "b"]] {
let args: Vec<_> = args.into_iter().map(String::from).collect();
assert!(
matches!(projection::parse(command(), &args, request), Err(ParseError::Clap(error)) if error.exit_code() == 2)
);
}
let args = vec!["clear".into(), "--help".into()];
assert!(
matches!(projection::parse(command(), &args, request), Err(ParseError::Clap(error)) if error.exit_code() == 0)
);
}
}
+21 -242
View File
@@ -1,253 +1,32 @@
use std::collections::HashMap;
use crate::api::schema::{
Method, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceReportMetadataParams,
};
use super::projection::ParseError;
use super::workspace_bindings::{command, LEAVES};
pub(super) fn run_workspace_command(args: &[String]) -> std::io::Result<i32> {
let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
print_workspace_help();
let print_commands = || eprintln!("{}", super::projection::group_commands(&command()));
let Some(subcommand) = args.first().map(String::as_str) else {
print_commands();
return Ok(2);
};
match subcommand {
"list" => workspace_list(&args[1..]),
"create" => workspace_create(&args[1..]),
"get" => workspace_get(&args[1..]),
"focus" => workspace_focus(&args[1..]),
"rename" => workspace_rename(&args[1..]),
"report-metadata" => workspace_report_metadata(&args[1..]),
"close" => workspace_close(&args[1..]),
"help" | "--help" | "-h" => {
print_workspace_help();
Ok(0)
}
_ => {
print_workspace_help();
Ok(2)
}
if matches!(subcommand, "help" | "--help" | "-h") {
print_commands();
return Ok(0);
}
}
fn workspace_list(args: &[String]) -> std::io::Result<i32> {
if !args.is_empty() {
eprintln!("usage: herdr workspace list");
return Ok(2);
}
super::runtime::workspace_list()
}
fn workspace_create(args: &[String]) -> std::io::Result<i32> {
let mut cwd = None;
let mut focus = false;
let mut label = None;
let mut env = HashMap::new();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--cwd" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --cwd");
return Ok(2);
};
cwd = Some(value.clone());
index += 2;
}
"--label" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --label");
return Ok(2);
};
label = Some(value.clone());
index += 2;
}
"--focus" => {
focus = true;
index += 1;
}
"--no-focus" => {
focus = false;
index += 1;
}
"--env" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --env");
return Ok(2);
};
let (key, value) = match super::parse_env_assignment(value) {
Ok(pair) => pair,
Err(err) => {
eprintln!("{err}");
return Ok(2);
}
};
env.insert(key, value);
index += 2;
}
other => {
eprintln!("unknown option: {other}");
return Ok(2);
}
}
}
super::runtime::workspace_create(WorkspaceCreateParams {
source_workspace_id: None,
cwd,
focus,
label,
env,
})
}
fn workspace_get(args: &[String]) -> std::io::Result<i32> {
let Some(raw_workspace_id) = args.first() else {
eprintln!("usage: herdr workspace get <workspace_id>");
let Some(leaf) = LEAVES.iter().find(|leaf| leaf.name == subcommand) else {
print_commands();
return Ok(2);
};
if args.len() != 1 {
eprintln!("usage: herdr workspace get <workspace_id>");
return Ok(2);
}
super::runtime::workspace_get(super::normalize_workspace_id(raw_workspace_id))
}
fn workspace_focus(args: &[String]) -> std::io::Result<i32> {
let Some(raw_workspace_id) = args.first() else {
eprintln!("usage: herdr workspace focus <workspace_id>");
return Ok(2);
};
if args.len() != 1 {
eprintln!("usage: herdr workspace focus <workspace_id>");
return Ok(2);
}
super::runtime::workspace_focus(super::normalize_workspace_id(raw_workspace_id))
}
fn workspace_rename(args: &[String]) -> std::io::Result<i32> {
if args.len() < 2 {
eprintln!("usage: herdr workspace rename <workspace_id> <label>");
return Ok(2);
}
super::runtime::workspace_rename(WorkspaceRenameParams {
workspace_id: super::normalize_workspace_id(&args[0]),
label: args[1..].join(" "),
})
}
fn workspace_report_metadata(args: &[String]) -> std::io::Result<i32> {
let Some(raw_workspace_id) = args.first() else {
eprintln!("usage: herdr workspace report-metadata <workspace_id> --source ID [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]");
return Ok(2);
};
let workspace_id = super::normalize_workspace_id(raw_workspace_id);
let mut source = None;
let mut tokens = HashMap::new();
let mut seq = None;
let mut ttl_ms = None;
let mut index = 1;
while index < args.len() {
match args[index].as_str() {
"--source" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --source");
return Ok(2);
};
source = Some(value.clone());
index += 2;
}
"--token" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --token");
return Ok(2);
};
let (key, value) = match super::parse_token_assignment(value) {
Ok(token) => token,
Err(message) => {
eprintln!("{message}");
return Ok(2);
}
};
tokens.insert(key, value);
index += 2;
}
"--clear-token" => {
let Some(key) = args.get(index + 1) else {
eprintln!("missing value for --clear-token");
return Ok(2);
};
tokens.insert(key.clone(), None);
index += 2;
}
"--seq" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --seq");
return Ok(2);
};
seq = Some(super::parse_u64_flag("--seq", value)?);
index += 2;
}
"--ttl-ms" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --ttl-ms");
return Ok(2);
};
ttl_ms = Some(super::parse_u64_flag("--ttl-ms", value)?);
index += 2;
}
other => {
eprintln!("unknown option: {other}");
return Ok(2);
}
}
}
let Some(source) = source.filter(|source| !source.trim().is_empty()) else {
eprintln!("missing required --source");
return Ok(2);
};
if tokens.is_empty() {
eprintln!("missing token to set or clear");
return Ok(2);
}
super::send_ok_request(Method::WorkspaceReportMetadata(
WorkspaceReportMetadataParams {
workspace_id,
source,
tokens,
seq,
ttl_ms,
},
))
}
fn workspace_close(args: &[String]) -> std::io::Result<i32> {
let (raw_workspace_id, close_group) = match args {
[workspace_id] => (workspace_id, false),
[workspace_id, flag] if flag == "--group" => (workspace_id, true),
_ => {
eprintln!("usage: herdr workspace close <workspace_id> [--group]");
let request = match leaf.request(&args[1..]) {
Ok(request) => request,
Err(ParseError::Usage(message)) => {
eprintln!("{message}");
return Ok(2);
}
Err(ParseError::Io(error)) => return Err(error),
Err(ParseError::Clap(error)) => return super::print_clap_error(&error),
};
super::runtime::workspace_close(crate::api::schema::WorkspaceCloseParams {
workspace_id: super::normalize_workspace_id(raw_workspace_id),
close_group,
})
}
fn print_workspace_help() {
eprintln!("herdr workspace commands:");
eprintln!(" herdr workspace list");
eprintln!(" herdr workspace create [--cwd PATH] [--label TEXT] [--env KEY=VALUE] [--focus] [--no-focus]");
eprintln!(" herdr workspace get <workspace_id>");
eprintln!(" herdr workspace focus <workspace_id>");
eprintln!(" herdr workspace rename <workspace_id> <label>");
eprintln!(" herdr workspace report-metadata <workspace_id> --source ID [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]");
eprintln!(" herdr workspace close <workspace_id> [--group]");
if leaf.quiet {
super::send_ok_request(request.method)
} else {
super::print_response(&super::send_request(&request)?)
}
}
+347
View File
@@ -0,0 +1,347 @@
use std::collections::HashMap;
use clap::{Arg, ArgMatches, Command};
use crate::api::schema::workspaces::{
WorkspaceCloseProjection, WorkspaceCreateProjection, WorkspaceMetadataProjection,
WorkspaceRenameProjection,
};
use crate::api::schema::{EmptyParams, Method, Request, WorkspaceTarget};
use super::projection::{cli_projection, Field, Input, ParseError};
pub(crate) use super::projection::{boolean, optional_text};
pub(crate) fn workspace_id() -> Input<String> {
Input::positional("workspace_id", 0, |slot, value| {
*slot = super::normalize_workspace_id(value);
Ok(())
})
}
pub(crate) fn label() -> Input<String> {
Input::rest("label", "LABEL", 1, |slot, value| {
*slot = value.to_owned();
Ok(())
})
.verbatim()
}
pub(crate) fn environment() -> Input<HashMap<String, String>> {
Input::<HashMap<String, String>>::value("env", "KEY=VALUE", |slot, value| {
let (key, value) = super::parse_env_assignment(value)?;
slot.insert(key, value);
Ok(())
})
.help("Set an environment variable for the launched process")
}
pub(crate) fn source() -> Input<String> {
Input::value("source", "ID", |slot, value| {
*slot = value.to_owned();
Ok(())
})
.required()
}
pub(crate) fn validate_source(source: &str) -> Result<(), ParseError> {
if source.trim().is_empty() {
return Err(ParseError::Usage("missing required --source".into()));
}
Ok(())
}
pub(crate) fn token() -> Input<HashMap<String, Option<String>>> {
Input::<HashMap<String, Option<String>>>::value("token", "NAME=VALUE", |slot, value| {
let (key, value) = super::parse_token_assignment(value)?;
slot.insert(key, value);
Ok(())
})
}
pub(crate) fn clear_token() -> Input<HashMap<String, Option<String>>> {
Input::<HashMap<String, Option<String>>>::value("clear-token", "NAME", |slot, value| {
slot.insert(value.to_owned(), None);
Ok(())
})
}
pub(crate) fn validate_tokens(tokens: &HashMap<String, Option<String>>) -> Result<(), ParseError> {
if tokens.is_empty() {
return Err(ParseError::Usage("missing token to set or clear".into()));
}
Ok(())
}
pub(crate) fn seq() -> Input<Option<u64>> {
Input::value("seq", "N", |slot, value| {
*slot = Some(super::parse_u64_flag("--seq", value)?);
Ok(())
})
}
pub(crate) fn ttl_ms() -> Input<Option<u64>> {
Input::value("ttl-ms", "N", |slot, value| {
*slot = Some(super::parse_u64_flag("--ttl-ms", value)?);
Ok(())
})
}
cli_projection! {
pub(crate) WorkspaceTargetProjection for WorkspaceTarget {
workspace_id => Field::new(String::new()).input(workspace_id()),
}
}
cli_projection! {
pub(crate) WorkspaceListProjection for EmptyParams {}
}
pub(crate) struct Leaf {
pub(crate) name: &'static str,
about: &'static str,
id: &'static str,
pub(crate) quiet: bool,
arguments: fn() -> Vec<Arg>,
method: fn(&ArgMatches) -> Result<Method, ParseError>,
}
impl Leaf {
pub(crate) fn request(&self, args: &[String]) -> Result<Request, ParseError> {
let method = super::projection::parse(
self.command()
.bin_name(format!("herdr workspace {}", self.name)),
args,
self.method,
)?;
Ok(Request {
id: self.id.into(),
method,
})
}
fn command(&self) -> Command {
super::projection::command(self.name, (self.arguments)()).about(self.about)
}
}
pub(crate) const LEAVES: [Leaf; 7] = [
Leaf {
name: "list",
about: "List workspaces",
id: "cli:workspace:list",
quiet: false,
arguments: WorkspaceListProjection::arguments,
method: |matches| WorkspaceListProjection::from_matches(matches).map(Method::WorkspaceList),
},
Leaf {
name: "create",
about: "Create a workspace",
id: "cli:workspace:create",
quiet: false,
arguments: WorkspaceCreateProjection::arguments,
method: |matches| {
WorkspaceCreateProjection::from_matches(matches).map(Method::WorkspaceCreate)
},
},
Leaf {
name: "get",
about: "Show a workspace",
id: "cli:workspace:get",
quiet: false,
arguments: WorkspaceTargetProjection::arguments,
method: |matches| {
WorkspaceTargetProjection::from_matches(matches).map(Method::WorkspaceGet)
},
},
Leaf {
name: "focus",
about: "Focus a workspace",
id: "cli:workspace:focus",
quiet: false,
arguments: WorkspaceTargetProjection::arguments,
method: |matches| {
WorkspaceTargetProjection::from_matches(matches).map(Method::WorkspaceFocus)
},
},
Leaf {
name: "rename",
about: "Rename a workspace",
id: "cli:workspace:rename",
quiet: false,
arguments: WorkspaceRenameProjection::arguments,
method: |matches| {
WorkspaceRenameProjection::from_matches(matches).map(Method::WorkspaceRename)
},
},
Leaf {
name: "report-metadata",
about: "Report display-only workspace metadata",
id: "cli:request",
quiet: true,
arguments: WorkspaceMetadataProjection::arguments,
method: |matches| {
WorkspaceMetadataProjection::from_matches(matches).map(Method::WorkspaceReportMetadata)
},
},
Leaf {
name: "close",
about: "Close a workspace",
id: "cli:workspace:close",
quiet: false,
arguments: WorkspaceCloseProjection::arguments,
method: |matches| {
WorkspaceCloseProjection::from_matches(matches).map(Method::WorkspaceClose)
},
},
];
pub(crate) fn command() -> Command {
Command::new("workspace")
.about("Manage workspaces over the socket API")
.subcommands(LEAVES.iter().map(Leaf::command))
}
#[cfg(test)]
mod tests {
use super::*;
fn request(command: &str, args: &[&str]) -> Result<Request, ParseError> {
LEAVES
.iter()
.find(|leaf| leaf.name == command)
.unwrap()
.request(&args.iter().map(|arg| (*arg).to_owned()).collect::<Vec<_>>())
}
#[test]
fn workspace_projection_preserves_defaults_and_ordered_maps() {
let request = request(
"create",
&[
"--cwd",
"first",
"--cwd",
"--literal",
"--focus",
"--no-focus",
"--env",
"K=first",
"--env",
"K=",
"--env",
"V=a=b",
],
)
.unwrap();
assert_eq!(
serde_json::to_value(request).unwrap(),
serde_json::json!({
"id": "cli:workspace:create", "method": "workspace.create",
"params": { "cwd": "--literal", "focus": false, "env": {"K": "", "V": "a=b"} }
})
);
assert_eq!(
serde_json::to_value(super::tests::request("create", &[]).unwrap()).unwrap(),
serde_json::json!({
"id": "cli:workspace:create", "method": "workspace.create", "params": {"focus": false}
})
);
}
#[test]
fn workspace_projection_preserves_joined_labels_and_hidden_group() {
assert_eq!(
serde_json::to_value(request("rename", &["--id", "a", "--flag", "b"]).unwrap())
.unwrap(),
serde_json::json!({
"id": "cli:workspace:rename", "method": "workspace.rename",
"params": {"workspace_id": "--id", "label": "a --flag b"}
})
);
assert_eq!(
serde_json::to_value(request("close", &["id", "--group"]).unwrap()).unwrap(),
serde_json::json!({
"id": "cli:workspace:close", "method": "workspace.close",
"params": {"workspace_id": "id", "close_group": true}
})
);
let close = command().find_subcommand("close").unwrap().clone();
assert!(close
.get_arguments()
.any(|arg| arg.get_long() == Some("group") && arg.is_hide_set()));
command().debug_assert();
}
#[test]
fn workspace_projection_preserves_metadata_patches_and_error_channels() {
assert_eq!(
serde_json::to_value(
request(
"report-metadata",
&[
"id",
"--source",
" user:jj ",
"--token",
"K=a=b",
"--clear-token",
"K",
"--token",
"E=",
"--seq",
"0",
"--ttl-ms",
"0",
]
)
.unwrap()
)
.unwrap(),
serde_json::json!({
"id": "cli:request", "method": "workspace.report_metadata",
"params": {"workspace_id": "id", "source": " user:jj ", "tokens": {"K": null, "E": ""}, "seq": 0, "ttl_ms": 0}
})
);
assert!(
matches!(request("report-metadata", &["id", "--source", "valid", "--seq", "bad"]), Err(ParseError::Io(error)) if error.to_string() == "invalid value for --seq: bad")
);
assert!(
matches!(request("report-metadata", &["id", "--source", " "]), Err(ParseError::Usage(message)) if message == "missing required --source")
);
assert!(
matches!(request("report-metadata", &["id", "--source", "valid"]), Err(ParseError::Usage(message)) if message == "missing token to set or clear")
);
}
#[test]
fn workspace_projection_uses_authoritative_clap_grammar() {
assert_eq!(
serde_json::to_value(request("create", &["--cwd=x"]).unwrap()).unwrap(),
serde_json::json!({
"id": "cli:workspace:create", "method": "workspace.create", "params": {"cwd": "x", "focus": false}
})
);
assert_eq!(
serde_json::to_value(request("close", &["--group", "id"]).unwrap()).unwrap(),
serde_json::json!({
"id": "cli:workspace:close", "method": "workspace.close", "params": {"workspace_id": "id", "close_group": true}
})
);
assert!(
matches!(request("get", &[]), Err(ParseError::Clap(error)) if error.kind() == clap::error::ErrorKind::MissingRequiredArgument)
);
assert!(
matches!(request("get", &["id", "extra"]), Err(ParseError::Clap(error)) if error.kind() == clap::error::ErrorKind::UnknownArgument)
);
assert!(
matches!(request("create", &["--cwd"]), Err(ParseError::Clap(error)) if error.exit_code() == 2)
);
assert!(
matches!(request("get", &["id", "--help"]), Err(ParseError::Clap(error)) if error.kind() == clap::error::ErrorKind::DisplayHelp && error.exit_code() == 0)
);
assert!(
matches!(request("create", &["--env", "=value"]), Err(ParseError::Usage(message)) if message == "env key must not be empty")
);
}
}
+84 -320
View File
@@ -1,325 +1,10 @@
use crate::api::schema::{
WorktreeCreateParams, WorktreeListParams, WorktreeOpenParams, WorktreeRemoveParams,
};
#[path = "worktree_bindings.rs"]
pub(crate) mod bindings;
pub(crate) use bindings::command;
// Worktree output is always JSON. The parsers retain `--json` as a hidden compatibility no-op.
pub(super) fn run_worktree_command(args: &[String]) -> std::io::Result<i32> {
let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
print_worktree_help();
return Ok(2);
};
match subcommand {
"list" => worktree_list(&args[1..]),
"create" => worktree_create(&args[1..]),
"open" => worktree_open(&args[1..]),
"remove" => worktree_remove(&args[1..]),
"help" | "--help" | "-h" => {
print_worktree_help();
Ok(0)
}
_ => {
print_worktree_help();
Ok(2)
}
}
}
fn worktree_list(args: &[String]) -> std::io::Result<i32> {
let mut workspace_id = None;
let mut cwd = None;
let mut trust_repository = false;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--workspace" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --workspace");
return Ok(2);
};
workspace_id = Some(super::normalize_workspace_id(value));
index += 2;
}
"--cwd" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --cwd");
return Ok(2);
};
cwd = Some(normalize_path_arg(value)?);
index += 2;
}
"--trust-repository" => {
trust_repository = true;
index += 1;
}
"--json" => index += 1,
other => {
eprintln!("unknown option: {other}");
return Ok(2);
}
}
}
if workspace_id.is_some() && cwd.is_some() {
eprintln!("usage: herdr worktree list [--workspace ID | --cwd PATH] [--trust-repository]");
return Ok(2);
}
super::runtime::worktree_list(WorktreeListParams {
workspace_id,
cwd,
trust_repository,
})
}
fn worktree_create(args: &[String]) -> std::io::Result<i32> {
let mut workspace_id = None;
let mut cwd = None;
let mut branch = None;
let mut base = None;
let mut path = None;
let mut label = None;
let mut focus = false;
let mut trust_repository = false;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--workspace" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --workspace");
return Ok(2);
};
workspace_id = Some(super::normalize_workspace_id(value));
index += 2;
}
"--cwd" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --cwd");
return Ok(2);
};
cwd = Some(normalize_path_arg(value)?);
index += 2;
}
"--branch" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --branch");
return Ok(2);
};
branch = Some(value.clone());
index += 2;
}
"--base" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --base");
return Ok(2);
};
base = Some(value.clone());
index += 2;
}
"--path" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --path");
return Ok(2);
};
path = Some(normalize_path_arg(value)?);
index += 2;
}
"--label" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --label");
return Ok(2);
};
label = Some(value.clone());
index += 2;
}
"--focus" => {
focus = true;
index += 1;
}
"--no-focus" => {
focus = false;
index += 1;
}
"--trust-repository" => {
trust_repository = true;
index += 1;
}
"--json" => index += 1,
other => {
eprintln!("unknown option: {other}");
return Ok(2);
}
}
}
if workspace_id.is_some() && cwd.is_some() {
eprintln!(
"usage: herdr worktree create [--workspace ID | --cwd PATH] [--branch NAME] [--base REF] [--path PATH] [--label TEXT] [--focus] [--no-focus] [--trust-repository]"
);
return Ok(2);
}
super::runtime::worktree_create(WorktreeCreateParams {
workspace_id,
cwd,
branch,
base,
path,
label,
focus,
trust_repository,
})
}
fn worktree_open(args: &[String]) -> std::io::Result<i32> {
let mut workspace_id = None;
let mut cwd = None;
let mut path = None;
let mut branch = None;
let mut label = None;
let mut focus = false;
let mut trust_repository = false;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--workspace" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --workspace");
return Ok(2);
};
workspace_id = Some(super::normalize_workspace_id(value));
index += 2;
}
"--cwd" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --cwd");
return Ok(2);
};
cwd = Some(normalize_path_arg(value)?);
index += 2;
}
"--path" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --path");
return Ok(2);
};
path = Some(normalize_path_arg(value)?);
index += 2;
}
"--branch" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --branch");
return Ok(2);
};
branch = Some(value.clone());
index += 2;
}
"--label" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --label");
return Ok(2);
};
label = Some(value.clone());
index += 2;
}
"--focus" => {
focus = true;
index += 1;
}
"--no-focus" => {
focus = false;
index += 1;
}
"--trust-repository" => {
trust_repository = true;
index += 1;
}
"--json" => index += 1,
other => {
eprintln!("unknown option: {other}");
return Ok(2);
}
}
}
if workspace_id.is_some() && cwd.is_some() {
eprintln!(
"usage: herdr worktree open [--workspace ID | --cwd PATH] (--path PATH | --branch NAME) [--label TEXT] [--focus] [--no-focus] [--trust-repository]"
);
return Ok(2);
}
if path.is_some() == branch.is_some() {
eprintln!(
"usage: herdr worktree open [--workspace ID | --cwd PATH] (--path PATH | --branch NAME) [--label TEXT] [--focus] [--no-focus] [--trust-repository]"
);
return Ok(2);
}
super::runtime::worktree_open(WorktreeOpenParams {
workspace_id,
cwd,
path,
branch,
label,
focus,
trust_repository,
})
}
fn worktree_remove(args: &[String]) -> std::io::Result<i32> {
let mut workspace_id = None;
let mut force = false;
let mut trust_repository = false;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--workspace" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --workspace");
return Ok(2);
};
workspace_id = Some(super::normalize_workspace_id(value));
index += 2;
}
"--force" => {
force = true;
index += 1;
}
"--trust-repository" => {
trust_repository = true;
index += 1;
}
"--json" => index += 1,
other => {
eprintln!("unknown option: {other}");
return Ok(2);
}
}
}
let Some(workspace_id) = workspace_id else {
eprintln!("usage: herdr worktree remove --workspace ID [--force] [--trust-repository]");
return Ok(2);
};
super::runtime::worktree_remove(WorktreeRemoveParams {
workspace_id,
force,
trust_repository,
})
}
fn print_worktree_help() {
eprintln!("herdr worktree commands:");
eprintln!(" herdr worktree list [--workspace ID | --cwd PATH] [--trust-repository]");
eprintln!(
" herdr worktree create [--workspace ID | --cwd PATH] [--branch NAME] [--base REF] [--path PATH] [--label TEXT] [--focus] [--no-focus] [--trust-repository]"
);
eprintln!(
" herdr worktree open [--workspace ID | --cwd PATH] (--path PATH | --branch NAME) [--label TEXT] [--focus] [--no-focus] [--trust-repository]"
);
eprintln!(" herdr worktree remove --workspace ID [--force] [--trust-repository]");
super::run_json_request(bindings::request(args))
}
fn normalize_path_arg(value: &str) -> std::io::Result<String> {
@@ -344,6 +29,85 @@ fn normalize_path_arg(value: &str) -> std::io::Result<String> {
#[cfg(test)]
mod machine_tests {
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_owned()).collect()
}
#[test]
fn simple_families_worktree_rejects_invalid_grammar_before_transport() {
for values in [
vec!["list", "--workspace"],
vec!["list", "--workspace", "2", "--cwd", "."],
vec!["create", "--branch"],
vec!["create", "--base"],
vec!["create", "--path"],
vec!["create", "--label"],
vec!["create", "--focus=true"],
vec!["create", "--workspace", "2", "--cwd", "."],
vec!["open"],
vec!["open", "--json"],
vec!["open", "--path", ".", "--branch", "topic"],
vec![
"open",
"--workspace",
"2",
"--cwd",
".",
"--branch",
"topic",
],
vec!["remove"],
vec!["remove", "--json", "--force", "--trust-repository"],
vec!["remove", "--workspace"],
] {
assert!(
super::bindings::request(&args(&values)).is_err(),
"argv: {values:?}"
);
}
}
#[test]
fn simple_families_worktree_validates_every_path_occurrence() {
crate::cli::target::with_test_client(crate::api::client::ApiClient::local(), || {
for values in [
vec!["list", "--cwd", "relative", "--cwd", "/valid"],
vec!["create", "--path", "relative", "--path", "/valid"],
vec!["open", "--path", "relative", "--path", "/valid"],
] {
assert!(super::bindings::request(&args(&values)).is_err());
}
assert_eq!(super::normalize_path_arg("~").unwrap(), "~");
});
}
#[test]
fn simple_families_worktree_dto_literal_defaults() {
use crate::api::schema::{
WorktreeCreateParams, WorktreeListParams, WorktreeOpenParams, WorktreeRemoveParams,
};
assert_eq!(
serde_json::to_value(WorktreeListParams::default()).unwrap(),
serde_json::json!({})
);
assert_eq!(
serde_json::to_value(WorktreeCreateParams::default()).unwrap(),
serde_json::json!({"focus": false})
);
assert_eq!(
serde_json::to_value(WorktreeOpenParams::default()).unwrap(),
serde_json::json!({"focus": false})
);
let remove: WorktreeRemoveParams =
serde_json::from_value(serde_json::json!({"workspace_id": "ws-2"})).unwrap();
assert!(!remove.force);
assert!(!remove.trust_repository);
assert_eq!(
serde_json::to_value(remove).unwrap(),
serde_json::json!({"workspace_id": "ws-2", "force": false})
);
}
#[test]
fn remote_worktree_paths_are_not_expanded_on_the_caller_machine() {
crate::cli::target::with_test_client(crate::api::client::ApiClient::local(), || {
+194
View File
@@ -0,0 +1,194 @@
use clap::Command;
use crate::api::schema::worktrees::{
WorktreeCreateProjection, WorktreeListProjection, WorktreeOpenProjection,
WorktreeRemoveProjection,
};
use crate::api::schema::{Method, Request};
use super::super::projection::{self, Input, Leaf, ParseError};
pub(crate) fn path(name: &'static str) -> Input<Option<String>> {
Input::value(name, "PATH", |slot, value| {
*slot = Some(super::normalize_path_arg(value)?);
Ok(())
})
.path()
}
const LEAVES: [Leaf; 4] = [
Leaf {
name: "list",
about: "List worktree workspaces",
id: "cli:worktree:list",
arguments: WorktreeListProjection::arguments,
method: |matches| WorktreeListProjection::from_matches(matches).map(Method::WorktreeList),
},
Leaf {
name: "create",
about: "Create and open a Git worktree",
id: "cli:worktree:create",
arguments: WorktreeCreateProjection::arguments,
method: |matches| {
WorktreeCreateProjection::from_matches(matches).map(Method::WorktreeCreate)
},
},
Leaf {
name: "open",
about: "Open an existing Git worktree",
id: "cli:worktree:open",
arguments: WorktreeOpenProjection::arguments,
method: |matches| WorktreeOpenProjection::from_matches(matches).map(Method::WorktreeOpen),
},
Leaf {
name: "remove",
about: "Remove a worktree checkout",
id: "cli:worktree:remove",
arguments: WorktreeRemoveProjection::arguments,
method: |matches| {
WorktreeRemoveProjection::from_matches(matches).map(Method::WorktreeRemove)
},
},
];
pub(crate) fn command() -> Command {
Command::new("worktree")
.about("Manage Git worktree-backed workspaces")
.subcommand_required(true)
.subcommands(LEAVES.iter().map(Leaf::command))
.mut_subcommand("open", |open| {
open.group(
clap::ArgGroup::new("open_target")
.args(["path", "branch"])
.required(true),
)
})
}
pub(crate) fn request(args: &[String]) -> Result<Request, ParseError> {
projection::group_request(command(), &LEAVES, args)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Request, ParseError> {
request(&args.iter().map(|arg| (*arg).to_owned()).collect::<Vec<_>>())
}
#[test]
fn worktree_projection_preserves_literal_requests_and_hidden_json() {
for (args, expected) in [
(
vec!["list", "--json"],
serde_json::json!({"id":"cli:worktree:list","method":"worktree.list","params":{}}),
),
(
vec!["create", "--json"],
serde_json::json!({"id":"cli:worktree:create","method":"worktree.create","params":{"focus":false}}),
),
(
vec![
"create",
"--workspace",
"2",
"--branch",
"topic",
"--base",
"HEAD",
"--label",
"name",
"--focus",
"--no-focus",
"--focus",
"--trust-repository",
"--json",
],
serde_json::json!({"id":"cli:worktree:create","method":"worktree.create","params":{"workspace_id":"2","branch":"topic","base":"HEAD","label":"name","focus":true,"trust_repository":true}}),
),
(
vec!["open", "--branch", "topic", "--json"],
serde_json::json!({"id":"cli:worktree:open","method":"worktree.open","params":{"branch":"topic","focus":false}}),
),
(
vec!["remove", "--workspace=2", "--json"],
serde_json::json!({"id":"cli:worktree:remove","method":"worktree.remove","params":{"workspace_id":"2","force":false}}),
),
(
vec![
"remove",
"--workspace",
"2",
"--force",
"--trust-repository",
"--json",
],
serde_json::json!({"id":"cli:worktree:remove","method":"worktree.remove","params":{"workspace_id":"2","force":true,"trust_repository":true}}),
),
] {
assert_eq!(
serde_json::to_value(parse(&args).unwrap()).unwrap(),
expected
);
}
for leaf in command().get_subcommands() {
assert!(leaf
.get_arguments()
.any(|arg| arg.get_long() == Some("json") && arg.is_hide_set()));
let mut leaf = leaf.clone();
assert!(!leaf.render_long_help().to_string().contains("--json"));
}
command().debug_assert();
}
#[test]
fn worktree_projection_normalizes_only_local_paths() {
let expected = std::env::current_dir()
.unwrap()
.join("relative")
.display()
.to_string();
assert_eq!(
serde_json::to_value(parse(&["open", "--path", "relative"]).unwrap()).unwrap(),
serde_json::json!({
"id":"cli:worktree:open", "method":"worktree.open", "params":{"path":expected, "focus":false}
})
);
crate::cli::target::with_test_client(crate::api::client::ApiClient::local(), || {
assert_eq!(
serde_json::to_value(
parse(&["create", "--cwd", "~/repo", "--path", "/remote/new"]).unwrap()
)
.unwrap(),
serde_json::json!({
"id":"cli:worktree:create", "method":"worktree.create", "params":{"cwd":"~/repo", "path":"/remote/new", "focus":false}
})
);
assert!(
matches!(parse(&["open", "--path", "relative"]), Err(ParseError::Io(error)) if error.kind() == std::io::ErrorKind::InvalidInput && error.to_string() == "remote worktree paths must be absolute or start with ~/")
);
});
}
#[test]
fn worktree_projection_keeps_context_and_xor_validations() {
for args in [
vec!["list", "--workspace", "2", "--cwd", "."],
vec!["create", "--workspace", "2", "--cwd", "."],
vec!["open"],
vec!["open", "--path", ".", "--branch", "topic"],
] {
assert!(
matches!(parse(&args), Err(ParseError::Clap(error)) if error.exit_code() == 2),
"{args:?}"
);
}
assert!(
matches!(parse(&["remove"]), Err(ParseError::Clap(error)) if error.exit_code() == 2)
);
assert!(
matches!(parse(&["open", "--help"]), Err(ParseError::Clap(error)) if error.exit_code() == 0)
);
}
}
+6 -49
View File
@@ -12,59 +12,16 @@ pub(crate) const MAX_ENDPOINT_BOOT_ID_BYTES: usize = 128;
pub(crate) const MAX_ENDPOINT_REQUEST_ID_BYTES: usize = 128;
const ENDPOINT_RESPONSE_CHUNK_BYTES: usize = 512 * 1024;
const CLIENT_SHELL_METHODS: &[&str] = &[
"client_shell.surface.set",
"command.invoke",
"integration.install",
"integration.list",
"layout.set_split_ratio",
"pane.clear",
"pane.close",
"pane.copy_motion",
"pane.copy_search",
"pane.edit_scrollback",
"pane.focus",
"pane.focus_direction",
"pane.input.set",
"pane.link.activate",
"pane.link.resolve",
"pane.rename",
"pane.resize",
"pane.scroll",
"pane.selection.read",
"pane.split",
"pane.swap",
"pane.zoom",
"product_announcement.dismiss",
"release_notes.dismiss",
"server.reload_config",
"tab.close",
"tab.create",
"tab.focus",
"tab.move",
"tab.rename",
"workspace.close",
"workspace.create",
"workspace.focus",
"workspace.move",
"workspace.move_block",
"workspace.rename",
"worktree.create",
"worktree.list",
"worktree.open",
"worktree.remove",
];
pub(crate) fn supported_client_shell_method_names() -> &'static [&'static str] {
CLIENT_SHELL_METHODS
crate::api::schema::operations::client_shell_method_names()
}
pub(crate) fn supports_client_shell_method_name(method: &str) -> bool {
CLIENT_SHELL_METHODS.contains(&method)
supported_client_shell_method_names().contains(&method)
}
pub(crate) fn supports_client_shell_method(method: &Method) -> bool {
supports_client_shell_method_name(crate::api::api_method_name(method))
method.operation().client_shell
}
pub(crate) fn error_response(id: String, code: &str, message: impl Into<String>) -> String {
@@ -245,7 +202,7 @@ mod tests {
.expect("request method branches");
let mut digests = BTreeMap::new();
for method in CLIENT_SHELL_METHODS {
for method in supported_client_shell_method_names() {
let branch = branches
.iter()
.find(|branch| {
@@ -306,7 +263,7 @@ mod tests {
#[test]
fn advertised_client_shell_methods_are_sorted_unique_and_in_schema() {
assert!(CLIENT_SHELL_METHODS
assert!(supported_client_shell_method_names()
.windows(2)
.all(|pair| pair[0] < pair[1]));
@@ -337,7 +294,7 @@ mod tests {
.expect("request schema");
let mut schema_methods = Vec::new();
collect_method_constants(&schema, &mut schema_methods);
for method in CLIENT_SHELL_METHODS {
for method in supported_client_shell_method_names() {
assert!(
schema_methods.iter().any(|candidate| candidate == method),
"advertised endpoint method {method:?} is absent from the request schema"
+773
View File
@@ -0,0 +1,773 @@
use super::harness::*;
use serde_json::{json, Value};
struct Capture {
output: std::process::Output,
requests: Vec<Value>,
}
fn capture(args: &[&str], response: Value) -> Capture {
let base = unique_test_dir();
fs::create_dir_all(&base).unwrap();
let socket = base.join("contract.sock");
let listener = UnixListener::bind(&socket).unwrap();
listener.set_nonblocking(true).unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_herdr"))
.args(args)
.env("HERDR_SOCKET_PATH", &socket)
.env("XDG_CONFIG_HOME", &base)
.env("XDG_RUNTIME_DIR", &base)
.env_remove("HERDR_CLIENT_SOCKET_PATH")
.env_remove("HERDR_ENV")
.env_remove("HERDR_PANE_ID")
.env_remove("HERDR_SESSION")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
let mut requests = Vec::new();
loop {
if Instant::now() >= deadline {
child.kill().unwrap();
let output = child.wait_with_output().unwrap();
drop(listener);
cleanup_test_base(&base);
panic!("CLI timed out: {args:?}; output: {output:?}; requests: {requests:?}");
}
match listener.accept() {
Ok((mut stream, _)) => {
// BSD-derived platforms inherit the listener's non-blocking flag.
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(1)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(1)))
.unwrap();
let mut line = String::new();
BufReader::new(stream.try_clone().unwrap())
.read_line(&mut line)
.unwrap();
let request: Value = serde_json::from_str(&line).unwrap();
if request["method"] == "ping" {
write_fake_pong(&mut stream, &request, "contract-baseline", CURRENT_PROTOCOL);
} else {
writeln!(stream, "{response}").unwrap();
stream.flush().unwrap();
}
requests.push(request);
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
if child.try_wait().unwrap().is_some() {
break;
}
thread::sleep(Duration::from_millis(5));
}
Err(error) => panic!("contract listener failed: {error}"),
}
}
let output = child.wait_with_output().unwrap();
drop(listener);
cleanup_test_base(&base);
Capture { output, requests }
}
fn assert_request(args: &[&str], expected: Value) {
let response = json!({"id": expected["id"], "result": {"type": "ok"}});
let captured = capture(args, response.clone());
assert_eq!(
captured.output.status.code(),
Some(0),
"{args:?}: {:?}",
captured.output
);
assert!(
captured.output.stderr.is_empty(),
"{args:?}: {:?}",
captured.output
);
assert_eq!(
captured.requests.len(),
2,
"{args:?}: {:?}",
captured.requests
);
assert_eq!(captured.requests[0]["method"], "ping", "{args:?}");
assert_eq!(captured.requests[1], expected, "{args:?}");
assert_eq!(
captured.output.stdout,
format!("{response}\n").as_bytes(),
"{args:?}"
);
}
#[test]
fn workspace_cli_request_contract_matrix() {
let cases: Vec<(Vec<&str>, Value)> = vec![
(
vec!["workspace", "list"],
json!({"id":"cli:workspace:list","method":"workspace.list","params":{}}),
),
(
vec!["workspace", "get", "opaque:w-7:p.2"],
json!({"id":"cli:workspace:get","method":"workspace.get","params":{"workspace_id":"opaque:w-7:p.2"}}),
),
(
vec!["workspace", "focus", "007"],
json!({"id":"cli:workspace:focus","method":"workspace.focus","params":{"workspace_id":"007"}}),
),
(
vec!["workspace", "rename", "w:7", "two", "words", "--literal"],
json!({"id":"cli:workspace:rename","method":"workspace.rename","params":{"workspace_id":"w:7","label":"two words --literal"}}),
),
(
vec!["workspace", "rename", "w:7", ""],
json!({"id":"cli:workspace:rename","method":"workspace.rename","params":{"workspace_id":"w:7","label":""}}),
),
(
vec!["workspace", "create"],
json!({"id":"cli:workspace:create","method":"workspace.create","params":{"focus":false}}),
),
(
vec!["workspace", "create", "--focus", "--no-focus", "--focus"],
json!({"id":"cli:workspace:create","method":"workspace.create","params":{"focus":true}}),
),
(
vec!["workspace", "create", "--no-focus", "--focus", "--no-focus"],
json!({"id":"cli:workspace:create","method":"workspace.create","params":{"focus":false}}),
),
(
vec![
"workspace",
"create",
"--cwd",
"relative/first",
"--cwd",
"../last",
"--label",
"first",
"--label",
"--dash-value",
"--env",
"KEY=first",
"--env",
"KEY=last=value",
"--env",
"EMPTY=",
],
json!({"id":"cli:workspace:create","method":"workspace.create","params":{"cwd":"../last","label":"--dash-value","focus":false,"env":{"KEY":"last=value","EMPTY":""}}}),
),
(
vec!["workspace", "close", "w:7"],
json!({"id":"cli:workspace:close","method":"workspace.close","params":{"workspace_id":"w:7"}}),
),
(
vec!["workspace", "close", "w:7", "--group"],
json!({"id":"cli:workspace:close","method":"workspace.close","params":{"workspace_id":"w:7","close_group":true}}),
),
(
vec!["workspace", "get", "--literal"],
json!({"id":"cli:workspace:get","method":"workspace.get","params":{"workspace_id":"--literal"}}),
),
];
for (args, request) in cases {
assert_request(&args, request);
}
}
#[test]
fn workspace_metadata_presence_and_order_contract() {
let cases: Vec<(Vec<&str>, Value)> = vec![
(
vec![
"workspace",
"report-metadata",
"w:7",
"--source",
"first",
"--source",
"user:final",
"--token",
"set=a=b",
"--token",
"empty=",
"--clear-token",
"cleared",
"--seq",
"1",
"--seq",
"0",
"--ttl-ms",
"5",
"--ttl-ms",
"0",
],
json!({"id":"cli:request","method":"workspace.report_metadata","params":{"workspace_id":"w:7","source":"user:final","tokens":{"set":"a=b","empty":"","cleared":null},"seq":0,"ttl_ms":0}}),
),
(
vec![
"workspace",
"report-metadata",
"w:7",
"--source",
"user",
"--token",
"key=first",
"--clear-token",
"key",
],
json!({"id":"cli:request","method":"workspace.report_metadata","params":{"workspace_id":"w:7","source":"user","tokens":{"key":null}}}),
),
(
vec![
"workspace",
"report-metadata",
"w:7",
"--source",
"user",
"--clear-token",
"key",
"--token",
"key=",
],
json!({"id":"cli:request","method":"workspace.report_metadata","params":{"workspace_id":"w:7","source":"user","tokens":{"key":""}}}),
),
];
for (args, expected) in cases {
let captured = capture(&args, json!({"id":"cli:request","result":{"type":"ok"}}));
assert_eq!(
captured.output.status.code(),
Some(0),
"{args:?}: {:?}",
captured.output
);
assert!(captured.output.stdout.is_empty(), "{args:?}");
assert!(captured.output.stderr.is_empty(), "{args:?}");
assert_eq!(captured.requests.len(), 2, "{args:?}");
assert_eq!(captured.requests[0]["method"], "ping");
assert_eq!(captured.requests[1], expected, "{args:?}");
}
}
#[test]
fn workspace_legacy_rejections_do_not_connect() {
let cases: Vec<(Vec<&str>, &str)> = vec![
(
vec!["workspace", "list", "extra"],
"error: unexpected argument 'extra' found",
),
(vec!["workspace", "get"], "<workspace_id>"),
(
vec!["workspace", "get", "w1", "extra"],
"error: unexpected argument 'extra' found",
),
(vec!["workspace", "focus"], "<workspace_id>"),
(vec!["workspace", "rename", "w1"], "<LABEL>"),
(
vec!["workspace", "create", "--label"],
"error: a value is required for '--label <TEXT>' but none was supplied",
),
(
vec!["workspace", "create", "--cwd"],
"error: a value is required for '--cwd <PATH>' but none was supplied",
),
(
vec!["workspace", "create", "--env"],
"error: a value is required for '--env <KEY=VALUE>' but none was supplied",
),
(
vec!["workspace", "create", "--env", "=value"],
"env key must not be empty\n",
),
(
vec!["workspace", "create", "--env", "KEY"],
"env must use KEY=VALUE\n",
),
(
vec!["workspace", "create", "--focus=true"],
"error: unexpected value 'true' for '--focus'",
),
(
vec!["workspace", "create", "--unknown"],
"error: unexpected argument '--unknown' found",
),
(
vec!["workspace", "close", "w1", "extra"],
"error: unexpected argument 'extra' found",
),
(
vec!["workspace", "report-metadata", "w1", "--source", "user"],
"missing token to set or clear\n",
),
(
vec!["workspace", "report-metadata", "w1", "--token", "key=value"],
"--source <ID>",
),
(
vec![
"workspace",
"report-metadata",
"w1",
"--source",
"user",
"--token",
"=value",
],
"token name must not be empty\n",
),
(
vec![
"workspace",
"report-metadata",
"w1",
"--source",
"user",
"--token",
"key",
],
"token must use NAME=VALUE\n",
),
(
vec![
"workspace",
"report-metadata",
"w1",
"--source",
"user",
"--token",
],
"error: a value is required for '--token <NAME=VALUE>' but none was supplied",
),
];
for (args, stderr) in cases {
let captured = capture(&args, json!({"id":"unexpected","result":{"type":"ok"}}));
assert_eq!(
captured.output.status.code(),
Some(2),
"{args:?}: {:?}",
captured.output
);
assert!(captured.output.stdout.is_empty(), "{args:?}");
if stderr.ends_with('\n') {
assert_eq!(captured.output.stderr, stderr.as_bytes(), "{args:?}");
} else {
let diagnostic = String::from_utf8_lossy(&captured.output.stderr);
assert!(diagnostic.contains(stderr), "{args:?}: {diagnostic}");
if !stderr.starts_with("error:") {
assert!(
diagnostic.contains("required arguments were not provided"),
"{args:?}: {diagnostic}"
);
}
}
assert!(
captured.requests.is_empty(),
"{args:?}: {:?}",
captured.requests
);
}
}
#[test]
fn workspace_normalized_syntax_request_contract() {
let cases: Vec<(Vec<&str>, Value)> = vec![
(
vec!["workspace", "create", "--label=value"],
json!({"id":"cli:workspace:create","method":"workspace.create","params":{"label":"value","focus":false}}),
),
(
vec!["workspace", "create", "--cwd=path"],
json!({"id":"cli:workspace:create","method":"workspace.create","params":{"cwd":"path","focus":false}}),
),
(
vec!["workspace", "create", "--env=KEY=value"],
json!({"id":"cli:workspace:create","method":"workspace.create","params":{"env":{"KEY":"value"},"focus":false}}),
),
(
vec!["workspace", "close", "--group", "w1"],
json!({"id":"cli:workspace:close","method":"workspace.close","params":{"workspace_id":"w1","close_group":true}}),
),
(
vec!["workspace", "close", "w1", "--group", "--group"],
json!({"id":"cli:workspace:close","method":"workspace.close","params":{"workspace_id":"w1","close_group":true}}),
),
];
for (args, expected) in cases {
assert_request(&args, expected);
}
for args in [
vec![
"workspace",
"report-metadata",
"--source",
"user",
"w1",
"--token",
"key=value",
],
vec![
"workspace",
"report-metadata",
"w1",
"--source=user",
"--token",
"key=value",
],
] {
let captured = capture(&args, json!({"id":"cli:request","result":{"type":"ok"}}));
assert_eq!(
captured.output.status.code(),
Some(0),
"{args:?}: {:?}",
captured.output
);
assert!(captured.output.stdout.is_empty(), "{args:?}");
assert!(captured.output.stderr.is_empty(), "{args:?}");
assert_eq!(captured.requests.len(), 2, "{args:?}");
assert_eq!(captured.requests[0]["method"], "ping", "{args:?}");
assert_eq!(
captured.requests[1],
json!({"id":"cli:request","method":"workspace.report_metadata","params":{"workspace_id":"w1","source":"user","tokens":{"key":"value"}}}),
"{args:?}"
);
}
}
#[test]
fn workspace_help_preserves_public_and_runtime_grammar_split() {
let cases = [
("list", vec![]),
("get", vec!["<workspace_id>"]),
("focus", vec!["<workspace_id>"]),
("rename", vec!["<WORKSPACE_ID>", "<LABEL>..."]),
(
"create",
vec![
"--cwd <PATH>",
"--label <TEXT>",
"--env <KEY=VALUE>",
"--focus",
"--no-focus",
],
),
("close", vec!["<workspace_id>"]),
(
"report-metadata",
vec![
"<WORKSPACE_ID>",
"--source <ID>",
"--token <NAME=VALUE>",
"--clear-token <NAME>",
"--seq <N>",
"--ttl-ms <N>",
],
),
];
for (leaf, fragments) in cases {
let captured = capture(&["workspace", leaf, "--help"], json!({}));
assert_eq!(
captured.output.status.code(),
Some(0),
"{leaf}: {:?}",
captured.output
);
assert!(captured.requests.is_empty(), "{leaf}");
assert!(captured.output.stderr.is_empty(), "{leaf}");
let help = String::from_utf8(captured.output.stdout).unwrap();
assert!(help.contains(&format!("herdr workspace {leaf}")), "{help}");
for fragment in fragments {
assert!(help.contains(fragment), "missing {fragment:?}: {help}");
}
if leaf == "close" {
assert!(!help.contains("--group"), "{help}");
}
}
}
#[test]
fn workspace_json_output_and_error_envelope_contract() {
for response in [
json!({"id":"cli:workspace:get","result":{"type":"workspace","custom":"preserve","workspace":{"workspace_id":"w:7"}}}),
json!({"id":"server:literal-id","error":{"code":"literal_error","message":"exact message"}}),
] {
let captured = capture(&["workspace", "get", "w:7"], response.clone());
let is_error = response.get("error").is_some();
assert_eq!(
captured.output.status.code(),
Some(if is_error { 1 } else { 0 })
);
let expected = format!("{response}\n");
if is_error {
assert!(captured.output.stdout.is_empty());
assert_eq!(captured.output.stderr, expected.as_bytes());
} else {
assert_eq!(captured.output.stdout, expected.as_bytes());
assert!(captured.output.stderr.is_empty());
}
assert_eq!(captured.requests.len(), 2);
assert_eq!(
captured.requests[1],
json!({"id":"cli:workspace:get","method":"workspace.get","params":{"workspace_id":"w:7"}})
);
}
}
#[test]
fn pane_and_agent_read_distinct_ansi_contract() {
let cases: Vec<(Vec<&str>, Value)> = vec![
(
vec!["pane", "read", "p:7"],
json!({"id":"cli:pane:read","method":"pane.read","params":{"pane_id":"p:7","source":"recent","format":"text","strip_ansi":true}}),
),
(
vec!["pane", "read", "p:7", "--ansi"],
json!({"id":"cli:pane:read","method":"pane.read","params":{"pane_id":"p:7","source":"recent","format":"ansi","strip_ansi":true}}),
),
(
vec!["pane", "read", "p:7", "--format", "ansi"],
json!({"id":"cli:pane:read","method":"pane.read","params":{"pane_id":"p:7","source":"recent","format":"ansi","strip_ansi":true}}),
),
(
vec!["pane", "read", "p:7", "--raw", "--format", "text"],
json!({"id":"cli:pane:read","method":"pane.read","params":{"pane_id":"p:7","source":"recent","format":"text","strip_ansi":false}}),
),
(
vec![
"pane",
"read",
"--source=recent_unwrapped",
"--lines=0",
"p:7",
"--raw",
],
json!({"id":"cli:pane:read","method":"pane.read","params":{"pane_id":"p:7","source":"recent_unwrapped","lines":0,"format":"ansi","strip_ansi":false}}),
),
(
vec!["agent", "read", "named-agent"],
json!({"id":"cli:agent:read","method":"agent.read","params":{"target":"named-agent","source":"recent","format":"text","strip_ansi":true}}),
),
(
vec!["agent", "read", "named-agent", "--ansi"],
json!({"id":"cli:agent:read","method":"agent.read","params":{"target":"named-agent","source":"recent","format":"ansi","strip_ansi":false}}),
),
(
vec![
"agent",
"read",
"named-agent",
"--format",
"ansi",
"--format",
"text",
],
json!({"id":"cli:agent:read","method":"agent.read","params":{"target":"named-agent","source":"recent","format":"text","strip_ansi":true}}),
),
];
for (args, expected) in cases {
let text = "\u{1b}[31mexact text\u{1b}[0m";
let captured = capture(
&args,
json!({"id":expected["id"],"result":{"type":"read","read":{"text":text}}}),
);
assert_eq!(
captured.output.status.code(),
Some(0),
"{args:?}: {:?}",
captured.output
);
assert_eq!(captured.output.stdout, text.as_bytes(), "{args:?}");
assert!(captured.output.stderr.is_empty(), "{args:?}");
assert_eq!(captured.requests.len(), 2, "{args:?}");
assert_eq!(captured.requests[1], expected, "{args:?}");
}
}
#[test]
fn plugin_popup_dimensions_and_log_alias_contract() {
let cases: Vec<(Vec<&str>, Value)> = vec![
(
vec![
"plugin",
"pane",
"open",
"--plugin",
"plugin:7",
"--entrypoint",
"main",
],
json!({"id":"cli:plugin","method":"plugin.pane.open","params":{"plugin_id":"plugin:7","entrypoint":"main","focus":true}}),
),
(
vec![
"plugin",
"pane",
"open",
"--plugin",
"plugin:7",
"--entrypoint",
"main",
"--placement",
"popup",
"--width",
"80%",
"--height",
"24",
"--no-focus",
],
json!({"id":"cli:plugin","method":"plugin.pane.open","params":{"plugin_id":"plugin:7","entrypoint":"main","placement":"popup","width":"80%","height":24,"focus":false}}),
),
(
vec![
"plugin",
"pane",
"open",
"--plugin",
"plugin:7",
"--entrypoint",
"main",
"--placement",
"fullscreen",
"--width",
"0",
"--height",
"100%",
"--no-focus",
"--focus",
],
json!({"id":"cli:plugin","method":"plugin.pane.open","params":{"plugin_id":"plugin:7","entrypoint":"main","placement":"zoomed","width":0,"height":"100%","focus":true}}),
),
(
vec!["plugin", "log"],
json!({"id":"cli:plugin","method":"plugin.log.list","params":{}}),
),
(
vec![
"plugin", "logs", "list", "--plugin", "plugin:7", "--limit", "0",
],
json!({"id":"cli:plugin","method":"plugin.log.list","params":{"plugin_id":"plugin:7","limit":0}}),
),
(
vec!["plugin", "log", "list", "--limit", "2", "--limit", "7"],
json!({"id":"cli:plugin","method":"plugin.log.list","params":{"limit":7}}),
),
];
for (args, expected) in cases {
assert_request(&args, expected);
}
}
#[test]
fn agent_prompt_missing_text_rejects_before_connecting() {
let captured = capture(
&["agent", "prompt", "main"],
json!({"id":"unexpected","result":{"type":"ok"}}),
);
assert_eq!(captured.output.status.code(), Some(2));
assert!(captured.output.stdout.is_empty());
let diagnostic = String::from_utf8_lossy(&captured.output.stderr);
assert!(
diagnostic.contains("required arguments were not provided"),
"{diagnostic}"
);
assert!(diagnostic.contains("<TEXT>"), "{diagnostic}");
assert!(captured.requests.is_empty(), "{:?}", captured.requests);
}
#[test]
fn agent_prompt_nested_wait_presence_contract() {
let cases: Vec<(Vec<&str>, Value)> = vec![
(
vec!["agent", "prompt", "named-agent", "literal - text"],
json!({"id":"cli:agent:prompt","method":"agent.prompt","params":{"target":"named-agent","text":"literal - text"}}),
),
(
vec!["agent", "prompt", "named-agent", "literal - text", "--wait"],
json!({"id":"cli:agent:prompt","method":"agent.prompt","params":{"target":"named-agent","text":"literal - text","wait":{}}}),
),
(
vec![
"agent",
"prompt",
"named-agent",
"literal - text",
"--wait",
"--until",
"idle",
"--until",
"done",
"--until",
"idle",
"--timeout",
"0",
],
json!({"id":"cli:agent:prompt","method":"agent.prompt","params":{"target":"named-agent","text":"literal - text","wait":{"until":["idle","done","idle"],"timeout_ms":0}}}),
),
];
for (args, expected) in cases {
assert_request(&args, expected);
}
}
#[test]
fn text_operands_keep_flag_shaped_words_like_the_legacy_cli() {
for (args, expected) in [
(
vec!["agent", "prompt", "main", "--wait"],
json!({"id":"cli:agent:prompt","method":"agent.prompt","params":{"target":"main","text":"--wait"}}),
),
(
vec!["agent", "prompt", "-agent", "--wait"],
json!({"id":"cli:agent:prompt","method":"agent.prompt","params":{"target":"-agent","text":"--wait"}}),
),
(
vec!["pane", "send-text", "p1", "--help", "more"],
json!({"id":"cli:request","method":"pane.send_text","params":{"pane_id":"p1","text":"--help more"}}),
),
(
vec!["pane", "run", "p1", "ls", "-h"],
json!({"id":"cli:request","method":"pane.send_input","params":{"pane_id":"p1","text":"ls -h","keys":["Enter"]}}),
),
(
vec!["pane", "send-keys", "p1", "-h"],
json!({"id":"cli:request","method":"pane.send_keys","params":{"pane_id":"p1","keys":["-h"]}}),
),
(
vec!["pane", "rename", "p1", "--clear", "x"],
json!({"id":"cli:pane:rename","method":"pane.rename","params":{"pane_id":"p1","label":"--clear x"}}),
),
(
vec!["pane", "rename", "p1", "--clear"],
json!({"id":"cli:pane:rename","method":"pane.rename","params":{"pane_id":"p1"}}),
),
(
vec!["tab", "rename", "t1", "--help", "x"],
json!({"id":"cli:tab:rename","method":"tab.rename","params":{"tab_id":"t1","label":"--help x"}}),
),
(
vec!["agent", "rename", "main", "--help"],
json!({"id":"cli:agent:rename","method":"agent.rename","params":{"target":"main","name":"--help"}}),
),
(
vec!["agent", "rename", "main", "--clear"],
json!({"id":"cli:agent:rename","method":"agent.rename","params":{"target":"main"}}),
),
(
vec!["notification", "show", "--body"],
json!({"id":"cli:notification:show","method":"notification.show","params":{"title":"--body"}}),
),
] {
let captured = capture(
&args,
json!({"id": expected["id"], "result": {"type": "ok"}}),
);
assert_eq!(
captured.output.status.code(),
Some(0),
"{args:?}: {:?}",
captured.output
);
assert_eq!(captured.requests.get(1), Some(&expected), "{args:?}");
}
}
+1
View File
@@ -2,6 +2,7 @@ mod agent_options;
mod agent_transport;
mod agent_wait;
mod agents;
mod contracts;
mod harness;
mod hooks;
mod panes;
+1 -1
View File
@@ -647,6 +647,6 @@ fn pane_read_rejects_invalid_value_with_usage_error() {
let read = run_cli(socket_path, &["pane", "read", "w1:p1", "--source", "bogus"]);
assert_eq!(read.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&read.stderr);
assert!(stderr.contains("invalid read source: bogus"));
assert!(stderr.contains("invalid value 'bogus' for '--source <SOURCE>'"));
assert!(!stderr.contains("Error: Custom"));
}
+1 -1
View File
@@ -954,7 +954,7 @@ command = ["sh", "-c", "sleep 5"]
let missing_plugin_value = run_cli(&socket_path, &["plugin", "list", "--plugin"]);
assert_eq!(missing_plugin_value.status.code(), Some(2));
assert!(String::from_utf8_lossy(&missing_plugin_value.stderr)
.contains("missing value for --plugin"));
.contains("a value is required for '--plugin <ID>' but none was supplied"));
let invalid_limit = run_cli(
&socket_path,
+1 -1
View File
@@ -368,7 +368,7 @@ fn removed_wait_and_agent_send_commands_are_rejected() {
.unwrap();
assert_eq!(send.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&send.stderr);
assert!(stderr.contains("herdr agent send-keys"));
assert!(stderr.contains("unrecognized subcommand 'send'"));
assert!(!stderr.contains("herdr agent send <"));
}