mirror of
https://github.com/herdrdev/herdr.git
synced 2026-11-06 00:06:18 +00:00
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:
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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::*;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(), || {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user