diff --git a/Cargo.toml b/Cargo.toml index 7baeb89d8..be9864d34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index d5ee1423c..82d3a2d79 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -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 diff --git a/docs/next/website/src/content/docs/ja/cli-reference.mdx b/docs/next/website/src/content/docs/ja/cli-reference.mdx index a2ea967c1..26ccb09a7 100644 --- a/docs/next/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx @@ -7,6 +7,8 @@ Herdr の CLI は、インテグレーションやエージェントが使うの ほとんどのコマンドは JSON レスポンスを出力します。決定的な自動化が欲しいときはスクリプトから使ってください。 +API を呼び出すコマンドでは、引数の解析、`--help`、シェル補完が同じ引数定義を使います。値を取るオプションは `--label text` と `--label=text` の両方を受け付けます。不正な引数はサーバーに接続する前に拒否されます。 + ## 起動とステータス ```bash diff --git a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx index 07cf89252..6f55954b9 100644 --- a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx +++ b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx @@ -7,6 +7,8 @@ Herdr 的 CLI 通过与集成和智能体相同的本地 socket API 与运行中 大多数命令输出 JSON 响应。需要确定性自动化时,从脚本中使用它们。 +调用 API 的命令使用同一组参数定义进行解析、显示 `--help` 和生成 shell 补全。带值的选项同时接受 `--label text` 和 `--label=text`。无效参数会在连接服务器之前被拒绝。 + ## 启动与状态 ```bash diff --git a/src/api/mod.rs b/src/api/mod.rs index 86f90da86..5ec57b298 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -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 { diff --git a/src/api/schema.rs b/src/api/schema.rs index 439ed3315..8077377e2 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -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)] diff --git a/src/api/schema/agents.rs b/src/api/schema/agents.rs index 48450c30c..8f0726f68 100644 --- a/src/api/schema/agents.rs +++ b/src/api/schema/agents.rs @@ -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, - #[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 => 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, +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 => 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timeout_ms: Option, +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 => 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 => 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timeout_ms: Option, - #[serde(skip)] - #[schemars(skip)] - pub(crate) submission_deadline: Option, +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 => 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 => Field::new(None).input(bindings::timeout() + .help("Fail after this many milliseconds")), + #[serde(skip)] + #[schemars(skip)] + pub(crate) submission_deadline: Option => Field::new(None::), + } } -#[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, +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 => 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, - /// 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, +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 => 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 => 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, +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 => Field::new(None).input(bindings::wait_presence()), + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] diff --git a/src/api/schema/common.rs b/src/api/schema/common.rs index bf0d7cafb..2f0f7ee63 100644 --- a/src/api/schema/common.rs +++ b/src/api/schema/common.rs @@ -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] = &[ + 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub position: Option, - #[serde(default, skip_serializing_if = "NotificationShowSound::is_none")] - pub sound: NotificationShowSound, +pub(crate) const READ_FORMATS: &[crate::cli::projection::Choice] = &[ + 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 => notification_cli::body(), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub position: Option => 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 { + 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> { + Field::new(None) + .input(crate::cli::workspace_bindings::optional_text("body", "TEXT").order(0)) + } + + pub(super) fn position() -> Field> { + 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 { + 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( diff --git a/src/api/schema/operations.rs b/src/api/schema/operations.rs new file mode 100644 index 000000000..a25de9ab2 --- /dev/null +++ b/src/api/schema/operations.rs @@ -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> = 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::>(); + let actual = Method::OPERATIONS + .iter() + .filter(|operation| operation.changes_ui) + .map(|operation| operation.name) + .collect::>(); + assert_eq!(actual, expected); + } +} diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index 9dbe0221d..ab25c83d2 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_pane_id: Option, - pub direction: SplitDirection, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ratio: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[serde(default)] - pub focus: bool, - #[serde(default)] - pub right_click: PaneRightClickTarget, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub env: HashMap, +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 => Field::new(None::), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_pane_id: Option => 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 => Field::new(None).input(binding::finite("ratio")), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option => 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 => 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub direction: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_pane_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_pane_id: Option, +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 => binding::target(false), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub direction: Option => 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 => 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 => 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, - #[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 => 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, +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 => 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, +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 => binding::target(false), + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default)] @@ -205,33 +225,41 @@ pub struct LayoutPane { pub env: HashMap, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct PaneNeighborParams { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pane_id: Option, - 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 => 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, +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 => 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, - 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 => 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, - pub direction: PaneDirection, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub amount: Option, +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 => binding::target(false), + pub direction: PaneDirection => Field::new(PaneDirection::Left).input(binding::direction()), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub amount: Option => 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, } -#[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, +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 => 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, +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 => 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, +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 => 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, +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 => 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, +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 => 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, - #[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 => 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub seq: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_session_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_session_path: Option, - /// 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>, +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 => Field::new(None).input(binding::optional_text("message", "TEXT")), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option => Field::new(None).input(binding::number("seq", "N")), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session_id: Option => Field::new(None).input(binding::optional_text("agent-session-id", "ID")), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session_path: Option => 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> => 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_session_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_session_path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_start_source: Option, - /// 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>, +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 => Field::new(None).input(binding::number("seq", "N")), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session_id: Option => Field::new(None).input(binding::optional_text("agent-session-id", "ID")), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session_path: Option => 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 => 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> => 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub applies_to_source: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub display_agent: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub state_labels: HashMap, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - #[schemars(schema_with = "super::common::metadata_token_patch_schema")] - pub tokens: HashMap>, - #[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(range(min = 1, max = 86_400_000))] - pub ttl_ms: Option, +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 => Field::new(None).input(binding::optional_text("agent", "LABEL")), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub applies_to_source: Option => Field::new(None).input(binding::optional_text("applies-to-source", "ID")), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option => 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 => 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 => 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> => 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 => 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 => 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, } -#[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, +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 => Field::new(None).input(binding::number("seq", "N")), + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] diff --git a/src/api/schema/plugins.rs b/src/api/schema/plugins.rs index 52df3ca6f..7b4d242f9 100644 --- a/src/api/schema/plugins.rs +++ b/src/api/schema/plugins.rs @@ -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, +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 => Field::new(None::), + } } -#[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, +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 => 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>, } -#[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, +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 => 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub limit: Option, +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 => Field::new(None).input(bindings::optional_text("plugin", "ID")), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option => 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub context: Option, +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 => Field::new(None).input(bindings::optional_text("plugin", "ID")), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option => 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub width: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub height: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_pane_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub direction: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[serde(default)] - pub focus: bool, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub env: HashMap, +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 => Field::new(None).input(bindings::placement().order(2)), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option => Field::new(None).input(bindings::width().hidden()), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option => Field::new(None).input(bindings::height().hidden()), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option => Field::new(None).input(bindings::optional_text("workspace", "ID").order(3)), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_pane_id: Option => Field::new(None).input(bindings::optional_text("target-pane", "PANE").order(4)), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub direction: Option => Field::new(None).input(bindings::direction().order(5)), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option => 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 => 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)] diff --git a/src/api/schema/server.rs b/src/api/schema/server.rs index 1adb62f5f..b65e28805 100644 --- a/src/api/schema/server.rs +++ b/src/api/schema/server.rs @@ -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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub expected_protocol: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub expected_version: Option, +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 => 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 => crate::cli::projection::Field::new(None).input( + crate::cli::projection::Input::value("expected-protocol", "n", |slot, value| { + *slot = Some(value.parse::().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 => 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)] diff --git a/src/api/schema/tabs.rs b/src/api/schema/tabs.rs index 236cebeee..6114f5573 100644 --- a/src/api/schema/tabs.rs +++ b/src/api/schema/tabs.rs @@ -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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[serde(default)] - pub focus: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub env: HashMap, +pub(crate) fn workspace_option(value_name: &'static str) -> Input> { + 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, +pub(crate) fn tab_id() -> Input { + 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 => Field::new(None).input(workspace_option("WORKSPACE_ID").order(0)), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option => 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 => Field::new(None).input(bindings::optional_text("label", "TEXT").order(2)), + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap => 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 => 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)] diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 3b43133b1..7ad3cbd4c 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -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); +} diff --git a/src/api/schema/workspaces.rs b/src/api/schema/workspaces.rs index 1b7132c43..f4a6c1581 100644 --- a/src/api/schema/workspaces.rs +++ b/src/api/schema/workspaces.rs @@ -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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[serde(default)] - pub focus: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub env: HashMap, +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 => Field::new(None::), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option => 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 => Field::new(None) + .input(bindings::optional_text("label", "TEXT").order(1)), + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap => 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, } -#[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>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub seq: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(range(min = 1, max = 86_400_000))] - pub ttl_ms: Option, +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> => 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 => 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 => Field::new(None).input(bindings::ttl_ms()), + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] diff --git a/src/api/schema/worktrees.rs b/src/api/schema/worktrees.rs index 3b0d4b352..a3648b2cf 100644 --- a/src/api/schema/worktrees.rs +++ b/src/api/schema/worktrees.rs @@ -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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[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> { + 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub branch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub base: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option, - #[serde(default)] - pub focus: bool, - #[serde(default, skip_serializing_if = "super::is_false")] - pub trust_repository: bool, +fn json_compatibility() -> Input { + 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub branch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option, - #[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 => Field::new(None).input(workspace().order(0)), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option => 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 => Field::new(None).input(workspace().order(0)), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option => Field::new(None).input(crate::cli::worktree::bindings::path("cwd").order(1)), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option => Field::new(None).input(bindings::optional_text("branch", "NAME").order(2)), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base: Option => Field::new(None).input(bindings::optional_text("base", "REF").order(3)), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option => Field::new(None).input(crate::cli::worktree::bindings::path("path").order(4)), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option => 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 => Field::new(None).input(workspace().order(0)), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option => Field::new(None).input(crate::cli::worktree::bindings::path("cwd").order(1)), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option => Field::new(None).input(crate::cli::worktree::bindings::path("path").order(2)), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option => Field::new(None).input(bindings::optional_text("branch", "NAME").order(3)), + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option => 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)] diff --git a/src/api/server.rs b/src/api/server.rs index 0a4cfa662..f59ba0ebb 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -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 { diff --git a/src/cli.rs b/src/cli.rs index 3dbdceafc..0efac7e35 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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 [--cols N] [--rows N]"; @@ -107,6 +106,13 @@ pub fn maybe_run(args: &[String]) -> std::io::Result { 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 { Ok(CommandOutcome::Handled(exit_code)) } +/// API-backed command groups, which list their commands when run without one. +fn api_group(name: &str) -> Option { + 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 { 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 { } fn terminal_title(args: &[String]) -> std::io::Result { - match args.first().map(|arg| arg.as_str()) { - Some("set") => { - if args.len() != 2 { - eprintln!("usage: herdr terminal title set "); - 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>() diff --git a/src/cli/agent.rs b/src/cli/agent.rs index 09043460d..195233eb9 100644 --- a/src/cli/agent.rs +++ b/src/cli/agent.rs @@ -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 - }) -} diff --git a/src/cli/agent/bindings.rs b/src/cli/agent/bindings.rs new file mode 100644 index 000000000..04d85c893 --- /dev/null +++ b/src/cli/agent/bindings.rs @@ -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); + } +} diff --git a/src/cli/agent/bindings_tests.rs b/src/cli/agent/bindings_tests.rs new file mode 100644 index 000000000..f8c5c5201 --- /dev/null +++ b/src/cli/agent/bindings_tests.rs @@ -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() + ); + } +} diff --git a/src/cli/completion.rs b/src/cli/completion.rs index 02391ec8d..198974239 100644 --- a/src/cli/completion.rs +++ b/src/cli/completion.rs @@ -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!( diff --git a/src/cli/notification.rs b/src/cli/notification.rs index ff56d4f22..24cf9c934 100644 --- a/src/cli/notification.rs +++ b/src/cli/notification.rs @@ -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)) } diff --git a/src/cli/notification_bindings.rs b/src/cli/notification_bindings.rs new file mode 100644 index 000000000..5ccbabf07 --- /dev/null +++ b/src/cli/notification_bindings.rs @@ -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"}}) + ); + } +} diff --git a/src/cli/pane.rs b/src/cli/pane.rs index 33583ee48..92e0605d5 100644 --- a/src/cli/pane.rs +++ b/src/cli/pane.rs @@ -1,1913 +1,726 @@ -use crate::api::schema::{ - Method, OutputMatch, PaneCurrentParams, PaneDirection, PaneEdgesParams, - PaneFocusDirectionParams, PaneInputSetParams, PaneLayoutParams, PaneListParams, - PaneMoveDestination, PaneMoveParams, PaneNeighborParams, PaneProcessInfoParams, PaneReadParams, - PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams, - PaneReportMetadataParams, PaneResizeParams, PaneRightClickTarget, PaneSendInputParams, - PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, - PaneWaitForOutputParams, PaneZoomMode, PaneZoomParams, ReadFormat, ReadSource, Request, - SplitDirection, -}; +pub(crate) mod bindings; + +use clap::ArgMatches; + +use super::projection::ParseError; +use crate::api::schema::panes::{PaneMetadataProjection, PaneMoveProjection, PaneSwapProjection}; +use crate::api::schema::{Method, PaneDirection, PaneMoveDestination}; + +pub(crate) fn command() -> clap::Command { + bindings::command() +} pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> { - let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { - print_pane_help(); + let Some(name) = args.first() else { + command().print_help()?; + println!(); return Ok(2); }; - - match subcommand { - "list" => pane_list(&args[1..]), - "current" => pane_current(&args[1..]), - "get" => pane_get(&args[1..]), - "layout" => pane_layout(&args[1..]), - "process-info" => pane_process_info(&args[1..]), - "neighbor" => pane_neighbor(&args[1..]), - "edges" => pane_edges(&args[1..]), - "focus" => pane_focus(&args[1..]), - "resize" => pane_resize(&args[1..]), - "zoom" => pane_zoom(&args[1..]), - "read" => pane_read(&args[1..]), - "rename" => pane_rename(&args[1..]), - "input" => pane_input(&args[1..]), - "split" => pane_split(&args[1..]), - "swap" => pane_swap(&args[1..]), - "move" => pane_move(&args[1..]), - "close" => pane_close(&args[1..]), - "send-text" => pane_send_text(&args[1..]), - "send-keys" => pane_send_keys(&args[1..]), - "wait-output" => pane_wait_output(&args[1..]), - "report-agent" => pane_report_agent(&args[1..]), - "report-agent-session" => pane_report_agent_session(&args[1..]), - "release-agent" => pane_release_agent(&args[1..]), - "report-metadata" => pane_report_metadata(&args[1..]), - "run" => pane_run(&args[1..]), - "help" | "--help" | "-h" => { - print_pane_help(); - Ok(0) - } - _ => { - print_pane_help(); - Ok(2) - } + if matches!(name.as_str(), "help" | "--help" | "-h") { + command().print_help()?; + println!(); + return Ok(0); } -} - -fn pane_list(args: &[String]) -> std::io::Result<i32> { - let mut workspace_id = None; - - 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); - } + let Some(leaf) = bindings::LEAVES.iter().find(|leaf| leaf.name == name) else { + if let Err(error) = command().no_binary_name(true).try_get_matches_from(args) { + return super::print_clap_error(&error); } - } - - super::print_response(&super::send_request(&Request { - id: "cli:pane:list".into(), - method: Method::PaneList(PaneListParams { workspace_id }), - })?) -} - -fn pane_get(args: &[String]) -> std::io::Result<i32> { - let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane get <pane_id>"); return Ok(2); }; - if args.len() != 1 { - eprintln!("usage: herdr pane get <pane_id>"); - return Ok(2); - } - - super::print_response(&super::send_request(&Request { - id: "cli:pane:get".into(), - method: Method::PaneGet(PaneTarget { - pane_id: super::normalize_pane_id(raw_pane_id), - }), - })?) -} - -fn pane_current(args: &[String]) -> std::io::Result<i32> { - let env_pane_id = super::target::caller_pane_id(); - let caller_pane_id = match parse_pane_current_args(args, env_pane_id.as_deref()) { - Ok(caller_pane_id) => caller_pane_id, - Err(message) => { + let request = match leaf.request(&args[1..]) { + Ok(request) => request, + 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), }; - - super::print_response(&super::send_request(&Request { - id: "cli:pane:current".into(), - method: Method::PaneCurrent(PaneCurrentParams { caller_pane_id }), - })?) -} - -fn parse_pane_current_args( - args: &[String], - env_pane_id: Option<&str>, -) -> Result<Option<String>, String> { - let mut caller_pane_id = env_pane_id.map(super::normalize_pane_id); - let mut index = 0; - while index < args.len() { - match args[index].as_str() { - "--pane" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --pane".into()); - }; - caller_pane_id = Some(super::normalize_pane_id(value)); - index += 2; + if matches!(leaf.output, bindings::Output::Quiet) { + return super::send_ok_request(request.method); + } + let response = super::send_request(&request)?; + match leaf.output { + bindings::Output::Json => super::print_response(&response), + bindings::Output::Read => super::print_read_response(&response), + bindings::Output::Quiet => { + if response.get("error").is_some() { + super::print_response(&response) + } else { + Ok(0) } - "--current" => { - caller_pane_id = env_pane_id.map(super::normalize_pane_id); - index += 1; - } - other => return Err(format!("unknown option: {other}")), } } - Ok(caller_pane_id) } -fn pane_layout(args: &[String]) -> std::io::Result<i32> { - let pane_id = match parse_optional_current_pane_args_from_env(args) { - Ok(pane_id) => pane_id, - Err(message) => { - eprintln!("{message}"); - return Ok(2); - } - }; - - super::print_response(&super::send_request(&Request { - id: "cli:pane:layout".into(), - method: Method::PaneLayout(PaneLayoutParams { pane_id }), - })?) -} - -fn pane_process_info(args: &[String]) -> std::io::Result<i32> { - let pane_id = match parse_optional_current_pane_args_from_env(args) { - Ok(pane_id) => pane_id, - Err(message) => { - eprintln!("{message}"); - return Ok(2); - } - }; - - super::print_response(&super::send_request(&Request { - id: "cli:pane:process_info".into(), - method: Method::PaneProcessInfo(PaneProcessInfoParams { pane_id }), - })?) -} - -fn pane_edges(args: &[String]) -> std::io::Result<i32> { - let pane_id = match parse_optional_current_pane_args_from_env(args) { - Ok(pane_id) => pane_id, - Err(message) => { - eprintln!("{message}"); - return Ok(2); - } - }; - - super::print_response(&super::send_request(&Request { - id: "cli:pane:edges".into(), - method: Method::PaneEdges(PaneEdgesParams { pane_id }), - })?) -} - -fn pane_neighbor(args: &[String]) -> std::io::Result<i32> { - let params = match parse_pane_neighbor_args(args) { - Ok(params) => params, - Err(message) => { - eprintln!("{message}"); - return Ok(2); - } - }; - - super::print_response(&super::send_request(&Request { - id: "cli:pane:neighbor".into(), - method: Method::PaneNeighbor(params), - })?) -} - -fn pane_focus(args: &[String]) -> std::io::Result<i32> { - let params = match parse_pane_focus_args(args) { - Ok(params) => params, - Err(message) => { - eprintln!("{message}"); - return Ok(2); - } - }; - - super::runtime::pane_focus(params) -} - -fn pane_resize(args: &[String]) -> std::io::Result<i32> { - let params = match parse_pane_resize_args(args) { - Ok(params) => params, - Err(message) => { - eprintln!("{message}"); - return Ok(2); - } - }; - - super::runtime::pane_resize(params) -} - -fn parse_optional_current_pane_args_from_env(args: &[String]) -> Result<Option<String>, String> { - let env_pane_id = super::target::caller_pane_id(); - parse_optional_current_pane_args(args, env_pane_id.as_deref()) -} - -fn parse_optional_current_pane_args( - args: &[String], - env_pane_id: Option<&str>, -) -> Result<Option<String>, String> { - let mut pane_id = None; - let mut index = 0; - while index < args.len() { - match args[index].as_str() { - "--pane" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --pane".into()); - }; - pane_id = Some(super::normalize_pane_id(value)); - index += 2; +fn last_index(matches: &ArgMatches, names: &[&str]) -> Option<usize> { + names + .iter() + .filter_map(|name| { + if matches.value_source(name) == Some(clap::parser::ValueSource::CommandLine) { + matches + .indices_of(name) + .and_then(|mut indices| indices.next_back()) + } else { + None } - "--current" => { - pane_id = env_pane_id.map(super::normalize_pane_id); - index += 1; - } - other => return Err(format!("unknown option: {other}")), - } + }) + .max() +} + +fn caller_target( + matches: &ArgMatches, + pane: Option<String>, + caller: Option<&str>, + default_caller: bool, + require_current_caller: bool, +) -> Result<Option<String>, ParseError> { + let current = last_index(matches, &["current"]); + let mut explicit = last_index(matches, &["pane"]); + if matches.try_get_one::<String>("pane_id").is_ok() { + explicit = explicit.max(last_index(matches, &["pane_id"])); } - Ok(pane_id) -} - -fn parse_pane_neighbor_args(args: &[String]) -> Result<PaneNeighborParams, String> { - let mut pane_id = None; - let mut direction = None; - - let mut index = 0; - while index < args.len() { - match args[index].as_str() { - "--pane" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --pane".into()); - }; - pane_id = Some(super::normalize_pane_id(value)); - index += 2; - } - "--current" => { - pane_id = None; - index += 1; - } - "--direction" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --direction".into()); - }; - direction = Some(parse_pane_direction(value)?); - index += 2; - } - other => return Err(format!("unknown option: {other}")), + if current.is_some() && current > explicit { + let caller = caller.map(super::normalize_pane_id); + if require_current_caller && caller.is_none() { + return Err(ParseError::Usage("--current requires HERDR_PANE_ID".into())); } + return Ok(caller); } - - let Some(direction) = direction else { - return Err( - "usage: herdr pane neighbor --direction left|right|up|down [--pane ID|--current]" - .into(), - ); - }; - - Ok(PaneNeighborParams { pane_id, direction }) -} - -fn parse_pane_focus_args(args: &[String]) -> Result<PaneFocusDirectionParams, String> { - let params = parse_pane_neighbor_args(args).map_err(|_| { - "usage: herdr pane focus --direction left|right|up|down [--pane ID|--current]".to_string() - })?; - Ok(PaneFocusDirectionParams { - pane_id: params.pane_id, - direction: params.direction, - }) -} - -fn parse_pane_resize_args(args: &[String]) -> Result<PaneResizeParams, String> { - let mut pane_id = None; - let mut direction = None; - let mut amount = None; - - let mut index = 0; - while index < args.len() { - match args[index].as_str() { - "--pane" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --pane".into()); - }; - pane_id = Some(super::normalize_pane_id(value)); - index += 2; - } - "--current" => { - pane_id = None; - index += 1; - } - "--direction" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --direction".into()); - }; - direction = Some(parse_pane_direction(value)?); - index += 2; - } - "--amount" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --amount".into()); - }; - let parsed = value - .parse::<f32>() - .map_err(|_| format!("invalid amount: {value}"))?; - if !parsed.is_finite() { - return Err(format!("invalid amount: {value}")); - } - amount = Some(parsed); - index += 2; - } - other => return Err(format!("unknown option: {other}")), - } + if current.is_none() && explicit.is_none() && default_caller { + return Ok(caller.map(super::normalize_pane_id)); } - - let Some(direction) = direction else { - return Err( - "usage: herdr pane resize --direction left|right|up|down [--amount FLOAT] [--pane ID|--current]" - .into(), - ); - }; - - Ok(PaneResizeParams { - pane_id, - direction, - amount, - }) + Ok(pane) } -fn pane_zoom(args: &[String]) -> std::io::Result<i32> { - let params = match parse_pane_zoom_args(args) { - Ok(params) => params, - Err(message) => { - eprintln!("{message}"); - return Ok(2); - } - }; - - super::runtime::pane_zoom(params) +fn direction(value: &str) -> Result<PaneDirection, ParseError> { + bindings::parse_choice(bindings::DIRECTIONS, "pane direction", value) } -fn parse_pane_zoom_args(args: &[String]) -> Result<PaneZoomParams, String> { - let mut pane_id = None; - let mut mode = PaneZoomMode::Toggle; - let mut mode_seen = false; +fn finite(name: &str, value: &str) -> Result<f32, ParseError> { + value + .parse::<f32>() + .ok() + .filter(|value| value.is_finite()) + .ok_or_else(|| ParseError::Usage(format!("invalid {name}: {value}"))) +} - let mut index = 0; - if args - .first() - .is_some_and(|arg| !arg.as_str().starts_with("--")) +fn source(value: String) -> Result<String, ParseError> { + let value = value.trim().to_owned(); + if value.is_empty() { + return Err(ParseError::Usage("missing required --source".into())); + } + Ok(value) +} + +fn swap(matches: &ArgMatches) -> Result<Method, ParseError> { + let mut params = PaneSwapProjection::from_matches(matches)?; + let directional = params.direction.is_some(); + let explicit = params.source_pane_id.is_some() || params.target_pane_id.is_some(); + if !((directional && !explicit) + || (!directional + && explicit + && params.pane_id.is_none() + && params.source_pane_id.is_some() + && params.target_pane_id.is_some())) { - pane_id = args.first().map(|arg| super::normalize_pane_id(arg)); - index = 1; + return Err(ParseError::Usage( + "provide a direction or both source and target panes".into(), + )); } - while index < args.len() { - match args[index].as_str() { - "--pane" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --pane".into()); - }; - pane_id = Some(super::normalize_pane_id(value)); - index += 2; - } - "--current" => { - pane_id = None; - index += 1; - } - "--toggle" => { - if mode_seen { - return Err("provide only one of --toggle, --on, or --off".into()); - } - mode = PaneZoomMode::Toggle; - mode_seen = true; - index += 1; - } - "--on" => { - if mode_seen { - return Err("provide only one of --toggle, --on, or --off".into()); - } - mode = PaneZoomMode::On; - mode_seen = true; - index += 1; - } - "--off" => { - if mode_seen { - return Err("provide only one of --toggle, --on, or --off".into()); - } - mode = PaneZoomMode::Off; - mode_seen = true; - index += 1; - } - other => return Err(format!("unknown option: {other}")), - } - } - - Ok(PaneZoomParams { pane_id, mode }) + params.source_pane_id = params + .source_pane_id + .map(|value| super::normalize_pane_id(&value)); + params.target_pane_id = params + .target_pane_id + .map(|value| super::normalize_pane_id(&value)); + Ok(Method::PaneSwap(params)) } -fn pane_rename(args: &[String]) -> std::io::Result<i32> { - let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane rename <pane_id> <label>|--clear"); - return Ok(2); +fn move_pane(matches: &ArgMatches) -> Result<Method, ParseError> { + let mut params = PaneMoveProjection::from_matches(matches)?; + let text = |name| { + matches + .get_many::<String>(name) + .and_then(|mut values| values.next_back()) + .cloned() }; - if args.len() < 2 { - eprintln!("usage: herdr pane rename <pane_id> <label>|--clear"); - return Ok(2); - } - let label = if args.len() == 2 && args[1] == "--clear" { - None - } else { - Some(args[1..].join(" ")) - }; - - super::runtime::pane_rename(PaneRenameParams { - pane_id: super::normalize_pane_id(raw_pane_id), - label, - }) -} - -fn pane_read(args: &[String]) -> std::io::Result<i32> { - let params = match parse_pane_read_args(args) { - Ok(params) => params, - Err(message) => { - eprintln!("{message}"); - return Ok(2); - } - }; - - let response = super::send_request(&Request { - id: "cli:pane:read".into(), - method: Method::PaneRead(params), - })?; - - super::print_read_response(&response) -} - -fn parse_pane_read_args(args: &[String]) -> Result<PaneReadParams, String> { - const USAGE: &str = "usage: herdr pane read <pane_id> [--source visible|recent|recent-unwrapped|detection] [--lines N] [--format text|ansi] [--ansi] [--raw]"; - - let args = super::expand_equals_args(args, &["--source", "--lines", "--format"]); - let mut pane_id = None; - let mut source = ReadSource::Recent; - let mut lines = None; - let mut format = ReadFormat::Text; - let mut strip_ansi = true; - - let mut index = 0; - while index < args.len() { - match args[index].as_str() { - "--source" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --source".into()); - }; - source = super::parse_read_source(value).map_err(|err| err.to_string())?; - index += 2; - } - "--lines" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --lines".into()); - }; - lines = - Some(super::parse_u32_flag("--lines", value).map_err(|err| err.to_string())?); - index += 2; - } - "--format" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --format".into()); - }; - format = super::parse_read_format(value).map_err(|err| err.to_string())?; - index += 2; - } - "--ansi" => { - format = ReadFormat::Ansi; - index += 1; - } - "--raw" => { - format = ReadFormat::Ansi; - strip_ansi = false; - index += 1; - } - option if option.starts_with('-') => { - return Err(format!("unknown option: {option}")); - } - positional => { - if pane_id.is_some() { - return Err(format!("unexpected argument: {positional}")); - } - pane_id = Some(super::normalize_pane_id(positional)); - index += 1; - } - } - } - - let Some(pane_id) = pane_id else { - return Err(USAGE.into()); - }; - - Ok(PaneReadParams { - pane_id, - source, - lines, - format, - strip_ansi, - intent: crate::api::schema::ReadIntent::Interactive, - }) -} - -fn pane_input(args: &[String]) -> std::io::Result<i32> { - let env_pane_id = super::target::caller_pane_id(); - let params = match parse_pane_input_args(args, env_pane_id.as_deref()) { - Ok(params) => params, - Err(message) => { - eprintln!("{message}"); - return Ok(2); - } - }; - super::runtime::pane_input_set(params) -} - -fn parse_pane_input_args( - args: &[String], - env_pane_id: Option<&str>, -) -> Result<PaneInputSetParams, String> { - const USAGE: &str = - "usage: herdr pane input [<pane_id>|--pane ID|--current] --right-click herdr|pane"; - - let args = super::expand_equals_args(args, &["--pane", "--right-click"]); - let mut pane_id = None; - let mut right_click = None; - let mut index = 0; - while index < args.len() { - match args[index].as_str() { - "--pane" => { - if pane_id.is_some() { - return Err("provide only one pane selector".into()); - } - let Some(value) = args.get(index + 1) else { - return Err("missing value for --pane".into()); - }; - pane_id = Some(super::normalize_pane_id(value)); - index += 2; - } - "--current" => { - if pane_id.is_some() { - return Err("provide only one pane selector".into()); - } - pane_id = Some( - env_pane_id - .map(super::normalize_pane_id) - .ok_or("--current requires HERDR_PANE_ID")?, - ); - index += 1; - } - "--right-click" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --right-click".into()); - }; - right_click = Some(parse_right_click_target(value)?); - index += 2; - } - option if option.starts_with('-') => return Err(format!("unknown option: {option}")), - positional => { - if pane_id.is_some() { - return Err(format!("unexpected argument: {positional}")); - } - pane_id = Some(super::normalize_pane_id(positional)); - index += 1; - } - } - } - - Ok(PaneInputSetParams { - pane_id: pane_id.ok_or(USAGE)?, - right_click: right_click.ok_or(USAGE)?, - }) -} - -fn parse_right_click_target(value: &str) -> Result<PaneRightClickTarget, String> { - match value { - "herdr" => Ok(PaneRightClickTarget::Herdr), - "pane" => Ok(PaneRightClickTarget::Pane), - _ => Err(format!("invalid right-click target: {value}")), - } -} - -fn pane_split(args: &[String]) -> std::io::Result<i32> { - let env_pane_id = super::target::caller_pane_id(); - let params = match parse_pane_split_args(args, env_pane_id.as_deref()) { - Ok(params) => params, - Err(message) => { - eprintln!("{message}"); - return Ok(2); - } - }; - - super::runtime::pane_split(params) -} - -fn parse_pane_split_args( - args: &[String], - env_pane_id: Option<&str>, -) -> Result<PaneSplitParams, String> { - let args = super::expand_equals_args(args, &["--right-click"]); - let mut env = std::collections::HashMap::new(); - let mut pane_id = env_pane_id.map(super::normalize_pane_id); - let mut direction = None; - let mut ratio = None; - let mut cwd = None; - let mut focus = false; - let mut right_click = PaneRightClickTarget::Herdr; - - let mut index = 0; - if args - .first() - .is_some_and(|arg| !arg.as_str().starts_with("--")) - { - pane_id = args.first().map(|arg| super::normalize_pane_id(arg)); - index = 1; - } - while index < args.len() { - match args[index].as_str() { - "--pane" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --pane".into()); - }; - pane_id = Some(super::normalize_pane_id(value)); - index += 2; - } - "--current" => { - pane_id = Some( - env_pane_id - .map(super::normalize_pane_id) - .ok_or("--current requires HERDR_PANE_ID")?, - ); - index += 1; - } - "--direction" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --direction".into()); - }; - direction = - Some(super::parse_split_direction(value).map_err(|err| err.to_string())?); - index += 2; - } - "--ratio" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --ratio".into()); - }; - let parsed = value - .parse::<f32>() - .map_err(|_| format!("invalid ratio: {value}"))?; - if !parsed.is_finite() { - return Err(format!("invalid ratio: {value}")); - } - ratio = Some(parsed); - index += 2; - } - "--cwd" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --cwd".into()); - }; - cwd = Some(value.clone()); - index += 2; - } - "--right-click" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --right-click".into()); - }; - right_click = parse_right_click_target(value)?; - index += 2; - } - "--focus" => { - focus = true; - index += 1; - } - "--no-focus" => { - focus = false; - index += 1; - } - "--env" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --env".into()); - }; - let (key, value) = super::parse_env_assignment(value)?; - env.insert(key, value); - index += 2; - } - other => return Err(format!("unknown option: {other}")), - } - } - - let Some(direction) = direction else { - return Err( - "usage: herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--env KEY=VALUE] [--right-click herdr|pane] [--focus] [--no-focus]" - .into(), - ); - }; - - Ok(PaneSplitParams { - workspace_id: None, - target_pane_id: pane_id, - direction, - ratio, - cwd, - focus, - right_click, - env, - }) -} - -fn pane_swap(args: &[String]) -> std::io::Result<i32> { - let params = match parse_pane_swap_args(args) { - Ok(params) => params, - Err(message) => { - eprintln!("{message}"); - return Ok(2); - } - }; - - super::runtime::pane_swap(params) -} - -fn pane_move(args: &[String]) -> std::io::Result<i32> { - let params = match parse_pane_move_args(args) { - Ok(params) => params, - Err(message) => { - eprintln!("{message}"); - return Ok(2); - } - }; - - super::runtime::pane_move(params) -} - -fn parse_pane_move_args(args: &[String]) -> Result<PaneMoveParams, String> { - let Some(raw_pane_id) = args.first() else { - return Err(pane_move_usage()); - }; - if raw_pane_id.starts_with('-') { - return Err(pane_move_usage()); - } - - let pane_id = super::normalize_pane_id(raw_pane_id); - let mut tab_id = None; - let mut new_tab = false; - let mut new_workspace = false; - let mut workspace_id = None; - let mut target_pane_id = None; - let mut split = None; - let mut ratio = None; - let mut label = None; - let mut tab_label = None; - let mut focus = true; - - let mut index = 1; - while index < args.len() { - match args[index].as_str() { - "--tab" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --tab".into()); - }; - tab_id = Some(super::normalize_tab_id(value)); - index += 2; - } - "--new-tab" => { - new_tab = true; - index += 1; - } - "--new-workspace" => { - new_workspace = true; - index += 1; - } - "--workspace" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --workspace".into()); - }; - workspace_id = Some(super::normalize_workspace_id(value)); - index += 2; - } - "--target-pane" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --target-pane".into()); - }; - target_pane_id = Some(super::normalize_pane_id(value)); - index += 2; - } - "--split" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --split".into()); - }; - split = Some(parse_split_direction(value)?); - index += 2; - } - "--ratio" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --ratio".into()); - }; - let parsed = value - .parse::<f32>() - .map_err(|_| format!("invalid ratio: {value}"))?; - if !parsed.is_finite() { - return Err(format!("invalid ratio: {value}")); - } - ratio = Some(parsed); - index += 2; - } - "--label" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --label".into()); - }; - label = Some(value.clone()); - index += 2; - } - "--tab-label" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --tab-label".into()); - }; - tab_label = Some(value.clone()); - index += 2; - } - "--focus" => { - focus = true; - index += 1; - } - "--no-focus" => { - focus = false; - index += 1; - } - other => return Err(format!("unknown option: {other}")), - } - } - - let destination_count = - usize::from(tab_id.is_some()) + usize::from(new_tab) + usize::from(new_workspace); - if destination_count != 1 { - return Err(pane_move_usage()); - } - - let destination = if let Some(tab_id) = tab_id { - let Some(split) = split else { - return Err(pane_move_usage()); - }; - if workspace_id.is_some() - || new_tab - || new_workspace - || label.is_some() - || tab_label.is_some() - { - return Err(pane_move_usage()); - } + let ratio = text("ratio") + .map(|value| finite("ratio", &value)) + .transpose()?; + params.destination = if let Some(tab) = text("tab") { + let split = + text("split").ok_or_else(|| ParseError::Usage("missing required --split".into()))?; PaneMoveDestination::Tab { - tab_id, - target_pane_id, - split, + tab_id: super::normalize_tab_id(&tab), + target_pane_id: text("target-pane").map(|value| super::normalize_pane_id(&value)), + split: bindings::parse_choice(bindings::SPLITS, "split direction", &split)?, ratio, } - } else if new_tab { - if split.is_some() || target_pane_id.is_some() || new_workspace || tab_label.is_some() { - return Err(pane_move_usage()); - } + } else if matches.get_flag("new-tab") { PaneMoveDestination::NewTab { - workspace_id, - label, + workspace_id: text("workspace").map(|value| super::normalize_workspace_id(&value)), + label: text("label"), } } else { - if split.is_some() || target_pane_id.is_some() || workspace_id.is_some() || new_tab { - return Err(pane_move_usage()); - } - PaneMoveDestination::NewWorkspace { label, tab_label } - }; - - Ok(PaneMoveParams { - pane_id, - destination, - focus, - }) -} - -fn pane_move_usage() -> String { - "usage: herdr pane move <pane_id> --tab <tab_id> --split right|down [--target-pane ID] [--ratio FLOAT] [--focus|--no-focus]\n herdr pane move <pane_id> --new-tab [--workspace ID] [--label TEXT] [--focus|--no-focus]\n herdr pane move <pane_id> --new-workspace [--label TEXT] [--tab-label TEXT] [--focus|--no-focus]" - .into() -} - -fn parse_pane_swap_args(args: &[String]) -> Result<PaneSwapParams, String> { - let mut pane_id = None; - let mut direction = None; - let mut source_pane_id = None; - let mut target_pane_id = None; - - let mut index = 0; - while index < args.len() { - match args[index].as_str() { - "--pane" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --pane".into()); - }; - pane_id = Some(super::normalize_pane_id(value)); - index += 2; - } - "--current" => { - pane_id = None; - index += 1; - } - "--direction" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --direction".into()); - }; - direction = Some(parse_pane_direction(value)?); - index += 2; - } - "--source-pane" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --source-pane".into()); - }; - source_pane_id = Some(super::normalize_pane_id(value)); - index += 2; - } - "--target-pane" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --target-pane".into()); - }; - target_pane_id = Some(super::normalize_pane_id(value)); - index += 2; - } - other => return Err(format!("unknown option: {other}")), - } - } - - let directional = direction.is_some(); - let explicit = source_pane_id.is_some() || target_pane_id.is_some(); - match (directional, explicit) { - (true, false) => Ok(PaneSwapParams { - pane_id, - direction, - ..PaneSwapParams::default() - }), - (false, true) if pane_id.is_none() && source_pane_id.is_some() && target_pane_id.is_some() => { - Ok(PaneSwapParams { - source_pane_id, - target_pane_id, - ..PaneSwapParams::default() - }) - } - _ => Err( - "usage: herdr pane swap --direction left|right|up|down [--pane ID|--current]\n herdr pane swap --source-pane ID --target-pane ID" - .into(), - ), - } -} - -fn parse_split_direction(value: &str) -> Result<SplitDirection, String> { - match value { - "right" => Ok(SplitDirection::Right), - "down" => Ok(SplitDirection::Down), - _ => Err(format!( - "invalid split direction: {value} (expected right or down)" - )), - } -} - -fn parse_pane_direction(value: &str) -> Result<PaneDirection, String> { - match value { - "left" => Ok(PaneDirection::Left), - "right" => Ok(PaneDirection::Right), - "up" => Ok(PaneDirection::Up), - "down" => Ok(PaneDirection::Down), - _ => Err(format!( - "invalid pane direction: {value} (expected left, right, up, or down)" - )), - } -} - -fn pane_close(args: &[String]) -> std::io::Result<i32> { - let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane close <pane_id>"); - return Ok(2); - }; - if args.len() != 1 { - eprintln!("usage: herdr pane close <pane_id>"); - return Ok(2); - } - - super::runtime::pane_close(super::normalize_pane_id(raw_pane_id)) -} - -fn pane_send_text(args: &[String]) -> std::io::Result<i32> { - if args.len() < 2 { - eprintln!("usage: herdr pane send-text <pane_id> <text>"); - return Ok(2); - } - - let pane_id = super::normalize_pane_id(&args[0]); - let text = args[1..].join(" "); - super::send_ok_request(Method::PaneSendText(PaneSendTextParams { pane_id, text })) -} - -fn pane_send_keys(args: &[String]) -> std::io::Result<i32> { - if args.len() < 2 { - eprintln!("usage: herdr pane send-keys <pane_id> <key> [key ...]"); - return Ok(2); - } - - let pane_id = super::normalize_pane_id(&args[0]); - let keys = args[1..].to_vec(); - super::send_ok_request(Method::PaneSendKeys(PaneSendKeysParams { pane_id, keys })) -} - -fn pane_run(args: &[String]) -> std::io::Result<i32> { - if args.len() < 2 { - eprintln!("usage: herdr pane run <pane_id> <command>"); - return Ok(2); - } - - let pane_id = super::normalize_pane_id(&args[0]); - let text = args[1..].join(" "); - super::send_ok_request(Method::PaneSendInput(PaneSendInputParams { - pane_id, - text, - keys: vec!["Enter".into()], - })) -} - -fn pane_wait_output(args: &[String]) -> std::io::Result<i32> { - let params = match parse_pane_wait_output_args(args) { - Ok(params) => params, - Err(message) => { - eprintln!("{message}"); - return Ok(2); + PaneMoveDestination::NewWorkspace { + label: text("label"), + tab_label: text("tab-label"), } }; - - super::print_response(&super::send_request(&Request { - id: "cli:pane:wait-output".into(), - method: Method::PaneWaitForOutput(params), - })?) + Ok(Method::PaneMove(params)) } -fn parse_pane_wait_output_args(args: &[String]) -> Result<PaneWaitForOutputParams, String> { - const USAGE: &str = "usage: herdr pane wait-output <pane_id> (--match TEXT | --regex PATTERN) [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--raw]"; - - let args = super::expand_equals_args( - args, - &["--match", "--regex", "--source", "--lines", "--timeout"], - ); - let mut pane_id = None; - let mut source = ReadSource::Recent; - let mut lines = None; - let mut timeout_ms = None; - let mut strip_ansi = true; - let mut matcher = None; - let mut index = 0; - while index < args.len() { - match args[index].as_str() { - option @ ("--match" | "--regex") => { - let Some(value) = args.get(index + 1) else { - return Err(format!("missing value for {option}")); - }; - if matcher.is_some() { - return Err("--match and --regex are mutually exclusive".into()); - } - matcher = Some(if option == "--regex" { - OutputMatch::Regex { - value: value.clone(), - } - } else { - OutputMatch::Substring { - value: value.clone(), - } - }); - index += 2; - } - "--source" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --source".into()); - }; - source = super::parse_read_source(value).map_err(|err| err.to_string())?; - index += 2; - } - "--lines" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --lines".into()); - }; - lines = - Some(super::parse_u32_flag("--lines", value).map_err(|err| err.to_string())?); - index += 2; - } - "--timeout" => { - let Some(value) = args.get(index + 1) else { - return Err("missing value for --timeout".into()); - }; - timeout_ms = - Some(super::parse_u64_flag("--timeout", value).map_err(|err| err.to_string())?); - index += 2; - } - "--raw" => { - strip_ansi = false; - index += 1; - } - option if option.starts_with('-') => { - return Err(format!("unknown option: {option}")); - } - positional => { - if pane_id.is_some() { - return Err(format!("unexpected argument: {positional}")); - } - pane_id = Some(super::normalize_pane_id(positional)); - index += 1; - } - } - } - let Some(pane_id) = pane_id else { - return Err(USAGE.into()); - }; - let Some(matcher) = matcher else { - return Err("missing required --match or --regex".into()); - }; - Ok(PaneWaitForOutputParams { - pane_id, - source, - lines, - r#match: matcher, - timeout_ms, - strip_ansi, - }) -} - -fn pane_report_agent(args: &[String]) -> std::io::Result<i32> { - const USAGE: &str = "usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH] [-- <resume-command...>]"; - - let (args, resume_argv) = split_resume_argv(args); - let args = super::expand_equals_args( - args, - &[ - "--source", - "--agent", - "--state", - "--message", - "--seq", - "--agent-session-id", - "--agent-session-path", - ], - ); - let mut pane_id = None; - let mut source = None; - let mut agent = None; - let mut state = None; - let mut message = None; - let mut seq = None; - let mut agent_session_id = None; - let mut agent_session_path = None; - - let mut index = 0; - 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; - } - "--agent" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --agent"); - return Ok(2); - }; - agent = Some(value.clone()); - index += 2; - } - "--state" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --state"); - return Ok(2); - }; - state = Some(super::parse_pane_agent_state(value)?); - index += 2; - } - "--message" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --message"); - return Ok(2); - }; - message = Some(value.clone()); - 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; - } - "--agent-session-id" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --agent-session-id"); - return Ok(2); - }; - agent_session_id = Some(value.clone()); - index += 2; - } - "--agent-session-path" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --agent-session-path"); - return Ok(2); - }; - agent_session_path = Some(value.clone()); - index += 2; - } - option if option.starts_with('-') => { - eprintln!("unknown option: {option}"); - return Ok(2); - } - positional => { - if pane_id.is_some() { - eprintln!("unexpected argument: {positional}"); - return Ok(2); - } - pane_id = Some(super::normalize_pane_id(positional)); - index += 1; - } - } - } - - let Some(pane_id) = pane_id else { - eprintln!("{USAGE}"); - return Ok(2); - }; - let Some(source) = source.and_then(|source| { - let source = source.trim().to_string(); - (!source.is_empty()).then_some(source) - }) else { - eprintln!("missing required --source"); - return Ok(2); - }; - let Some(agent) = agent else { - eprintln!("missing required --agent"); - return Ok(2); - }; - let Some(state) = state else { - eprintln!("missing required --state"); - return Ok(2); - }; - - super::send_ok_request(Method::PaneReportAgent(PaneReportAgentParams { - pane_id, - source, - agent, - state, - message, - seq, - agent_session_id, - agent_session_path, - resume_argv, - })) -} - -fn split_resume_argv(args: &[String]) -> (&[String], Option<Vec<String>>) { - match args.iter().position(|arg| arg == "--") { - Some(separator) => (&args[..separator], Some(args[separator + 1..].to_vec())), - None => (args, None), - } -} - -fn pane_report_agent_session(args: &[String]) -> std::io::Result<i32> { - const USAGE: &str = "usage: herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH] [--session-start-source SOURCE] [-- <resume-command...>]"; - - let (args, resume_argv) = split_resume_argv(args); - let args = super::expand_equals_args( - args, - &[ - "--source", - "--agent", - "--seq", - "--agent-session-id", - "--agent-session-path", - "--session-start-source", - ], - ); - let mut pane_id = None; - let mut source = None; - let mut agent = None; - let mut seq = None; - let mut agent_session_id = None; - let mut agent_session_path = None; - let mut session_start_source = None; - - let mut index = 0; - 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; - } - "--agent" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --agent"); - return Ok(2); - }; - agent = Some(value.clone()); - 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; - } - "--agent-session-id" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --agent-session-id"); - return Ok(2); - }; - agent_session_id = Some(value.clone()); - index += 2; - } - "--agent-session-path" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --agent-session-path"); - return Ok(2); - }; - agent_session_path = Some(value.clone()); - index += 2; - } - "--session-start-source" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --session-start-source"); - return Ok(2); - }; - session_start_source = Some(value.clone()); - index += 2; - } - option if option.starts_with('-') => { - eprintln!("unknown option: {option}"); - return Ok(2); - } - positional => { - if pane_id.is_some() { - eprintln!("unexpected argument: {positional}"); - return Ok(2); - } - pane_id = Some(super::normalize_pane_id(positional)); - index += 1; - } - } - } - - let Some(pane_id) = pane_id else { - eprintln!("{USAGE}"); - return Ok(2); - }; - let Some(source) = source.and_then(|source| { - let source = source.trim().to_string(); - (!source.is_empty()).then_some(source) - }) else { - eprintln!("missing required --source"); - return Ok(2); - }; - let Some(agent) = agent else { - eprintln!("missing required --agent"); - return Ok(2); - }; - - super::send_ok_request(Method::PaneReportAgentSession( - PaneReportAgentSessionParams { - pane_id, - source, - agent, - seq, - agent_session_id, - agent_session_path, - session_start_source, - resume_argv, - }, - )) -} - -fn pane_release_agent(args: &[String]) -> std::io::Result<i32> { - let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane release-agent <pane_id> --source ID --agent LABEL [--seq N]"); - return Ok(2); - }; - - let pane_id = super::normalize_pane_id(raw_pane_id); - let mut source = None; - let mut agent = None; - let mut seq = 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; - } - "--agent" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --agent"); - return Ok(2); - }; - agent = Some(value.clone()); - 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; - } - other => { - eprintln!("unknown option: {other}"); - return Ok(2); - } - } - } - - let Some(source) = source.and_then(|source| { - let source = source.trim().to_string(); - (!source.is_empty()).then_some(source) - }) else { - eprintln!("missing required --source"); - return Ok(2); - }; - let Some(agent) = agent else { - eprintln!("missing required --agent"); - return Ok(2); - }; - - super::send_ok_request(Method::PaneReleaseAgent(PaneReleaseAgentParams { - pane_id, - source, - agent, - seq, - })) -} - -fn pane_report_metadata(args: &[String]) -> std::io::Result<i32> { - let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr pane report-metadata <pane_id> --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--state-label STATUS=TEXT] [--clear-state-labels] [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]"); - return Ok(2); - }; - - let pane_id = super::normalize_pane_id(raw_pane_id); - let mut source = None; - let mut agent = None; - let mut applies_to_source = None; - let mut title = None; - let mut display_agent = None; - let mut state_labels = std::collections::HashMap::new(); - let mut tokens = std::collections::HashMap::new(); - let mut clear_title = false; - let mut clear_display_agent = false; - let mut clear_state_labels = false; - 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; - } - "--agent" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --agent"); - return Ok(2); - }; - agent = Some(value.clone()); - index += 2; - } - "--applies-to-source" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --applies-to-source"); - return Ok(2); - }; - applies_to_source = Some(value.clone()); - index += 2; - } - "--title" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --title"); - return Ok(2); - }; - title = Some(value.clone()); - index += 2; - } - "--clear-title" => { - clear_title = true; - index += 1; - } - "--display-agent" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --display-agent"); - return Ok(2); - }; - display_agent = Some(value.clone()); - index += 2; - } - "--clear-display-agent" => { - clear_display_agent = true; - index += 1; - } - "--state-label" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --state-label"); - return Ok(2); - }; - let Some((status, label)) = value.split_once('=') else { - eprintln!("expected --state-label STATUS=TEXT"); - return Ok(2); - }; - let status = status.trim().to_ascii_lowercase(); - if !matches!( - status.as_str(), - "idle" | "working" | "blocked" | "done" | "unknown" - ) { - eprintln!("unknown state label: {status}"); - return Ok(2); - } - state_labels.insert(status, label.to_string()); - index += 2; - } - "--clear-state-labels" => { - clear_state_labels = true; - index += 1; - } - "--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.and_then(|source| { - let source = source.trim().to_string(); - (!source.is_empty()).then_some(source) - }) else { - eprintln!("missing required --source"); - return Ok(2); - }; - if applies_to_source +fn metadata(matches: &ArgMatches) -> Result<Method, ParseError> { + let mut params = PaneMetadataProjection::from_matches(matches)?; + params.source = source(params.source)?; + if params + .applies_to_source .as_deref() - .is_some_and(|source| source.trim().is_empty()) + .is_some_and(|value| value.trim().is_empty()) { - eprintln!("missing value for --applies-to-source"); - return Ok(2); + return Err(ParseError::Usage( + "missing value for --applies-to-source".into(), + )); } - if title.is_some() && clear_title - || display_agent.is_some() && clear_display_agent - || !state_labels.is_empty() && clear_state_labels + if params.title.is_none() + && params.display_agent.is_none() + && params.state_labels.is_empty() + && params.tokens.is_empty() + && !params.clear_title + && !params.clear_display_agent + && !params.clear_state_labels { - eprintln!("cannot set and clear the same metadata field"); - return Ok(2); + return Err(ParseError::Usage( + "missing metadata field to set or clear".into(), + )); } - if title.is_none() - && display_agent.is_none() - && state_labels.is_empty() - && tokens.is_empty() - && !clear_title - && !clear_display_agent - && !clear_state_labels - { - eprintln!("missing metadata field to set or clear"); - return Ok(2); - } - - super::send_ok_request(Method::PaneReportMetadata(PaneReportMetadataParams { - pane_id, - source, - agent, - applies_to_source, - title, - display_agent, - state_labels, - tokens, - clear_title, - clear_display_agent, - clear_state_labels, - seq, - ttl_ms, - })) -} - -fn print_pane_help() { - eprintln!("herdr pane commands:"); - eprintln!(" herdr pane list [--workspace <workspace_id>]"); - eprintln!(" herdr pane current [--pane ID|--current]"); - eprintln!(" herdr pane get <pane_id>"); - eprintln!(" herdr pane layout [--pane ID|--current]"); - eprintln!(" herdr pane process-info [--pane ID|--current]"); - eprintln!(" herdr pane neighbor --direction left|right|up|down [--pane ID|--current]"); - eprintln!(" herdr pane edges [--pane ID|--current]"); - eprintln!(" herdr pane focus --direction left|right|up|down [--pane ID|--current]"); - eprintln!( - " herdr pane resize --direction left|right|up|down [--amount FLOAT] [--pane ID|--current]" - ); - eprintln!(" herdr pane zoom [<pane_id>|--pane ID|--current] [--toggle|--on|--off]"); - eprintln!(" herdr pane rename <pane_id> <label>|--clear"); - eprintln!(" herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); - eprintln!(" herdr pane input [<pane_id>|--pane ID|--current] --right-click herdr|pane"); - eprintln!( - " herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--env KEY=VALUE] [--right-click herdr|pane] [--focus] [--no-focus]" - ); - eprintln!(" herdr pane swap --direction left|right|up|down [--pane ID|--current]"); - eprintln!(" herdr pane swap --source-pane ID --target-pane ID"); - eprintln!(" herdr pane move <pane_id> --tab <tab_id> --split right|down [--target-pane ID] [--ratio FLOAT] [--focus|--no-focus]"); - eprintln!(" herdr pane move <pane_id> --new-tab [--workspace ID] [--label TEXT] [--focus|--no-focus]"); - eprintln!(" herdr pane move <pane_id> --new-workspace [--label TEXT] [--tab-label TEXT] [--focus|--no-focus]"); - eprintln!(" herdr pane close <pane_id>"); - eprintln!(" herdr pane send-text <pane_id> <text>"); - eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]"); - eprintln!(" herdr pane wait-output <pane_id> (--match TEXT | --regex PATTERN) [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--raw]"); - eprintln!(" herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); - eprintln!(" herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); - eprintln!(" herdr pane release-agent <pane_id> --source ID --agent LABEL [--seq N]"); - eprintln!(" herdr pane report-metadata <pane_id> --source ID [--agent LABEL] [--applies-to-source ID] [--title TEXT|--clear-title] [--display-agent TEXT|--clear-display-agent] [--state-label STATUS=TEXT] [--clear-state-labels] [--token NAME=VALUE] [--clear-token NAME] [--seq N] [--ttl-ms N]"); - eprintln!(" herdr pane run <pane_id> <command>"); + Ok(Method::PaneReportMetadata(params)) } #[cfg(test)] mod tests { use super::*; + use crate::api::schema::{ + OutputMatch, PaneRightClickTarget, PaneZoomMode, ReadFormat, ReadSource, SplitDirection, + }; fn args(values: &[&str]) -> Vec<String> { - values.iter().map(|value| (*value).to_string()).collect() + values.iter().map(|value| (*value).to_owned()).collect() + } + + fn request(name: &str, values: &[&str]) -> Result<crate::api::schema::Request, ParseError> { + bindings::LEAVES + .iter() + .find(|leaf| leaf.name == name) + .unwrap() + .request(&args(values)) + } + + fn parse_pane_split_args( + values: &[&str], + caller: Option<&str>, + ) -> crate::api::schema::PaneSplitParams { + let leaf = bindings::LEAVES + .iter() + .find(|leaf| leaf.name == "split") + .unwrap(); + let matches = leaf + .command() + .no_binary_name(true) + .try_get_matches_from(values) + .unwrap(); + let mut params = + crate::api::schema::panes::PaneSplitProjection::from_matches(&matches).unwrap(); + params.target_pane_id = + caller_target(&matches, params.target_pane_id, caller, true, true).unwrap(); + params + } + + #[test] + fn pane_projection_all_leaf_request_values() { + use serde_json::json; + let cases = [ + ("list", vec![], "cli:pane:list", "pane.list", json!({})), + ( + "current", + vec!["--pane", "pane"], + "cli:pane:current", + "pane.current", + json!({"caller_pane_id": "pane"}), + ), + ( + "get", + vec!["pane"], + "cli:pane:get", + "pane.get", + json!({"pane_id": "pane"}), + ), + ( + "layout", + vec!["--pane", "pane"], + "cli:pane:layout", + "pane.layout", + json!({"pane_id": "pane"}), + ), + ( + "process-info", + vec!["--pane", "pane"], + "cli:pane:process_info", + "pane.process_info", + json!({"pane_id": "pane"}), + ), + ( + "neighbor", + vec!["--direction", "left"], + "cli:pane:neighbor", + "pane.neighbor", + json!({"direction": "left"}), + ), + ( + "edges", + vec!["--pane", "pane"], + "cli:pane:edges", + "pane.edges", + json!({"pane_id": "pane"}), + ), + ( + "focus", + vec!["--direction", "right"], + "cli:pane:focus", + "pane.focus_direction", + json!({"direction": "right"}), + ), + ( + "resize", + vec!["--direction", "up"], + "cli:pane:resize", + "pane.resize", + json!({"direction": "up"}), + ), + ( + "zoom", + vec![], + "cli:pane:zoom", + "pane.zoom", + json!({"mode": "toggle"}), + ), + ( + "read", + vec!["pane"], + "cli:pane:read", + "pane.read", + json!({"pane_id": "pane", "source": "recent", "format": "text", "strip_ansi": true}), + ), + ( + "rename", + vec!["pane", "--clear"], + "cli:pane:rename", + "pane.rename", + json!({"pane_id": "pane"}), + ), + ( + "input", + vec!["pane", "--right-click", "herdr"], + "cli:pane:input:set", + "pane.input.set", + json!({"pane_id": "pane", "right_click": "herdr"}), + ), + ( + "split", + vec!["pane", "--direction", "down"], + "cli:pane:split", + "pane.split", + json!({"target_pane_id": "pane", "direction": "down", "focus": false, "right_click": "herdr"}), + ), + ( + "swap", + vec!["--direction", "down"], + "cli:pane:swap", + "pane.swap", + json!({"direction": "down"}), + ), + ( + "move", + vec!["pane", "--new-tab"], + "cli:pane:move", + "pane.move", + json!({"pane_id": "pane", "destination": {"type": "new_tab"}, "focus": true}), + ), + ( + "close", + vec!["pane"], + "cli:pane:close", + "pane.close", + json!({"pane_id": "pane"}), + ), + ( + "send-text", + vec!["pane", ""], + "cli:request", + "pane.send_text", + json!({"pane_id": "pane", "text": ""}), + ), + ( + "send-keys", + vec!["pane", "Enter", "esc"], + "cli:request", + "pane.send_keys", + json!({"pane_id": "pane", "keys": ["Enter", "esc"]}), + ), + ( + "wait-output", + vec!["pane", "--match", ""], + "cli:pane:wait-output", + "pane.wait_for_output", + json!({"pane_id": "pane", "source": "recent", "match": {"type": "substring", "value": ""}, "strip_ansi": true}), + ), + ( + "run", + vec!["pane", ""], + "cli:request", + "pane.send_input", + json!({"pane_id": "pane", "keys": ["Enter"]}), + ), + ( + "report-agent", + vec![ + "pane", "--source", " hook ", "--agent", "", "--state", "unknown", + ], + "cli:request", + "pane.report_agent", + json!({"pane_id": "pane", "source": "hook", "agent": "", "state": "unknown"}), + ), + ( + "report-agent-session", + vec!["pane", "--source", " hook ", "--agent", "a"], + "cli:request", + "pane.report_agent_session", + json!({"pane_id": "pane", "source": "hook", "agent": "a"}), + ), + ( + "release-agent", + vec!["pane", "--source", " hook ", "--agent", "a"], + "cli:request", + "pane.release_agent", + json!({"pane_id": "pane", "source": "hook", "agent": "a"}), + ), + ( + "report-metadata", + vec!["pane", "--source", " hook ", "--clear-title"], + "cli:request", + "pane.report_metadata", + json!({"pane_id": "pane", "source": "hook", "clear_title": true, "clear_display_agent": false, "clear_state_labels": false}), + ), + ]; + assert_eq!(cases.len(), bindings::LEAVES.len()); + for (name, values, id, method, params) in cases { + assert_eq!( + serde_json::to_value(request(name, &values).unwrap()).unwrap(), + json!({"id": id, "method": method, "params": params}), + "{name}" + ); + } + } + + #[test] + fn pane_projection_dash_leading_targets_and_empty_values() { + let params = parse_pane_split_args(&["-literal", "--direction", "right"], None); + assert_eq!(params.target_pane_id.as_deref(), Some("-literal")); + for name in ["get", "close", "zoom"] { + let json = serde_json::to_value(request(name, &["-literal"]).unwrap()).unwrap(); + assert_eq!(json["params"]["pane_id"], "-literal"); + } + let json = serde_json::to_value( + request( + "move", + &[ + "pane", + "--new-workspace", + "--label", + "", + "--tab-label", + "-literal", + ], + ) + .unwrap(), + ) + .unwrap(); + assert_eq!( + json["params"]["destination"], + serde_json::json!({"type": "new_workspace", "label": "", "tab_label": "-literal"}) + ); + let json = serde_json::to_value( + request( + "move", + &[ + "pane", + "--new-tab", + "--workspace", + "workspace", + "--label", + "label", + ], + ) + .unwrap(), + ) + .unwrap(); + assert_eq!( + json["params"]["destination"], + serde_json::json!({"type": "new_tab", "workspace_id": "workspace", "label": "label"}) + ); + } + + #[test] + fn pane_projection_command_is_the_execution_grammar() { + command().debug_assert(); + for leaf in bindings::LEAVES { + let command = command().find_subcommand(leaf.name).unwrap().clone(); + assert_eq!( + command + .get_arguments() + .map(|arg| arg.get_id().to_string()) + .collect::<Vec<_>>(), + leaf.command() + .get_arguments() + .map(|arg| arg.get_id().to_string()) + .collect::<Vec<_>>() + ); + } } #[test] fn parse_pane_split_args_accepts_ratio() { let params = parse_pane_split_args( - &args(&["issue-1", "--direction", "right", "--ratio", "0.333"]), + &["issue-1", "--direction", "right", "--ratio", "0.333"], None, - ) - .unwrap(); - + ); assert_eq!(params.target_pane_id, Some("issue-1".into())); - assert_eq!(params.direction, crate::api::schema::SplitDirection::Right); + assert_eq!(params.direction, SplitDirection::Right); assert_eq!(params.ratio, Some(0.333)); assert_eq!(params.right_click, PaneRightClickTarget::Herdr); } #[test] fn parse_pane_split_args_accepts_pane_right_click_target() { - let params = parse_pane_split_args( - &args(&["--direction", "right", "--right-click", "pane"]), - None, - ) - .unwrap(); - + let params = + parse_pane_split_args(&["--direction", "right", "--right-click", "pane"], None); assert_eq!(params.right_click, PaneRightClickTarget::Pane); } - #[test] - fn parse_pane_input_args_requires_and_uses_calling_pane() { - let params = parse_pane_input_args( - &args(&["--current", "--right-click", "pane"]), - Some("issue-1:p1"), - ) - .unwrap(); - - assert_eq!(params.pane_id, "issue-1:p1"); - assert_eq!(params.right_click, PaneRightClickTarget::Pane); - assert!( - parse_pane_input_args(&args(&["--current", "--right-click", "pane"]), None).is_err() - ); - } - - #[test] - fn parse_pane_input_args_rejects_conflicting_selectors() { - assert!(parse_pane_input_args( - &args(&["pane-a", "--pane", "pane-b", "--right-click", "pane"]), - None, - ) - .is_err()); - assert!(parse_pane_input_args( - &args(&["--pane", "pane-a", "--current", "--right-click", "pane",]), - Some("pane-b"), - ) - .is_err()); - } - #[test] fn parse_pane_split_args_accepts_equals_right_click_target() { - let params = - parse_pane_split_args(&args(&["--direction", "right", "--right-click=pane"]), None) - .unwrap(); - + let params = parse_pane_split_args(&["--direction", "right", "--right-click=pane"], None); assert_eq!(params.right_click, PaneRightClickTarget::Pane); } #[test] fn parse_pane_split_args_accepts_current_target() { - let params = parse_pane_split_args( - &args(&["--direction", "down", "--current"]), - Some("issue-1"), - ) - .unwrap(); - + let params = parse_pane_split_args(&["--direction", "down", "--current"], Some("issue-1")); assert_eq!(params.target_pane_id, Some("issue-1".into())); - assert_eq!(params.direction, crate::api::schema::SplitDirection::Down); - } - - #[test] - fn parse_pane_split_args_current_without_env_is_rejected() { - assert!(parse_pane_split_args(&args(&["--direction", "down", "--current"]), None).is_err()); + assert_eq!(params.direction, SplitDirection::Down); } #[test] fn parse_pane_split_args_omitted_target_uses_caller() { let params = parse_pane_split_args( - &args(&["--no-focus", "--direction", "right", "--cwd", "/var/tmp"]), + &["--no-focus", "--direction", "right", "--cwd", "/var/tmp"], Some("w1:p2"), - ) - .unwrap(); - + ); assert_eq!(params.target_pane_id, Some("w1:p2".into())); assert!(!params.focus); } #[test] fn parse_pane_split_args_without_caller_keeps_focused_fallback() { - let params = parse_pane_split_args(&args(&["--direction", "down"]), None).unwrap(); - - assert_eq!(params.target_pane_id, None); + assert_eq!( + parse_pane_split_args(&["--direction", "down"], None).target_pane_id, + None + ); } #[test] fn parse_pane_split_args_explicit_target_overrides_caller() { - for target in [args(&["w2:p3"]), args(&["--pane", "w2:p3"])] { - let mut input = target; - input.extend(args(&["--direction", "right"])); - let params = parse_pane_split_args(&input, Some("w1:p2")).unwrap(); - - assert_eq!(params.target_pane_id, Some("w2:p3".into())); + for values in [ + vec!["w2:p3", "--direction", "right"], + vec!["--pane", "w2:p3", "--direction", "right"], + ] { + assert_eq!( + parse_pane_split_args(&values, Some("w1:p2")).target_pane_id, + Some("w2:p3".into()) + ); } } #[test] - fn parse_pane_current_args_uses_env_pane_by_default() { - let pane_id = parse_pane_current_args(&args(&[]), Some("issue-1")).unwrap(); - - assert_eq!(pane_id, Some("issue-1".into())); + fn pane_projection_characterization_caller_and_focused_policies() { + for (name, default_caller) in [ + ("current", true), + ("layout", false), + ("process-info", false), + ("edges", false), + ] { + let leaf = bindings::LEAVES + .iter() + .find(|leaf| leaf.name == name) + .unwrap(); + for (values, expected) in [ + (vec![], if default_caller { Some("caller") } else { None }), + (vec!["--pane", "explicit"], Some("explicit")), + (vec!["--pane", "explicit", "--current"], Some("caller")), + (vec!["--current", "--pane", "explicit"], Some("explicit")), + ] { + let matches = leaf + .command() + .no_binary_name(true) + .try_get_matches_from(&values) + .unwrap(); + let mut field = bindings::target(false); + field.read_matches(&matches).unwrap(); + assert_eq!( + caller_target( + &matches, + field.finish().unwrap(), + Some("caller"), + default_caller, + false + ) + .unwrap() + .as_deref(), + expected + ); + if values.is_empty() { + assert_eq!( + caller_target(&matches, None, None, default_caller, false).unwrap(), + None + ); + } + } + } + for name in ["neighbor", "focus", "resize", "swap"] { + let parsed = request( + name, + &["--pane", "explicit", "--current", "--direction", "left"], + ) + .unwrap(); + assert!(serde_json::to_value(parsed).unwrap()["params"] + .get("pane_id") + .is_none()); + } } #[test] - fn parse_pane_current_args_accepts_explicit_pane() { - let pane_id = - parse_pane_current_args(&args(&["--pane", "issue-2"]), Some("issue-1")).unwrap(); - - assert_eq!(pane_id, Some("issue-2".into())); + fn parse_pane_input_args_requires_and_uses_calling_pane() { + let leaf = bindings::LEAVES + .iter() + .find(|leaf| leaf.name == "input") + .unwrap(); + let matches = leaf + .command() + .no_binary_name(true) + .try_get_matches_from(["--current", "--right-click", "pane"]) + .unwrap(); + let mut params = + crate::api::schema::panes::PaneInputProjection::from_matches(&matches).unwrap(); + params.pane_id = caller_target(&matches, None, Some("issue-1:p1"), false, true) + .unwrap() + .unwrap(); + assert_eq!(params.pane_id, "issue-1:p1"); + assert_eq!(params.right_click, PaneRightClickTarget::Pane); + assert!(caller_target(&matches, None, None, false, true).is_err()); } #[test] - fn parse_pane_current_args_current_keeps_env_pane() { - let pane_id = parse_pane_current_args(&args(&["--current"]), Some("issue-1")).unwrap(); - - assert_eq!(pane_id, Some("issue-1".into())); + fn parse_pane_input_args_rejects_conflicting_selectors() { + assert!(request( + "input", + &["pane-a", "--pane", "pane-b", "--right-click", "pane"] + ) + .is_err()); + assert!(request( + "input", + &["--pane", "pane-a", "--current", "--right-click", "pane"] + ) + .is_err()); } #[test] - fn parse_pane_current_args_without_env_falls_back_to_focused_pane() { - let pane_id = parse_pane_current_args(&args(&[]), None).unwrap(); - - assert_eq!(pane_id, None); - } - - #[test] - fn parse_pane_swap_args_accepts_directional_current() { - let params = parse_pane_swap_args(&args(&["--direction", "right"])).unwrap(); - - assert_eq!(params.pane_id, None); - assert_eq!(params.direction, Some(PaneDirection::Right)); - assert_eq!(params.source_pane_id, None); - assert_eq!(params.target_pane_id, None); + fn pane_projection_split_ordered_values() { + let params = parse_pane_split_args( + &[ + "--pane", + "explicit", + "--current", + "--direction", + "right", + "--direction", + "down", + "--focus", + "--no-focus", + "--focus", + "--env", + "A=first", + "--env", + "A=last", + ], + Some("caller"), + ); + assert_eq!(params.target_pane_id.as_deref(), Some("caller")); + assert_eq!(params.direction, SplitDirection::Down); + assert!(params.focus); + assert_eq!(params.env.get("A").map(String::as_str), Some("last")); } #[test] fn parse_pane_swap_args_accepts_explicit_source_and_target() { - let params = parse_pane_swap_args(&args(&[ - "--source-pane", - "issue-1", - "--target-pane", - "issue-2", - ])) - .unwrap(); - + let Method::PaneSwap(params) = request( + "swap", + &["--source-pane", "issue-1", "--target-pane", "issue-2"], + ) + .unwrap() + .method + else { + panic!() + }; assert_eq!(params.direction, None); assert_eq!(params.source_pane_id, Some("issue-1".into())); assert_eq!(params.target_pane_id, Some("issue-2".into())); - } - - #[test] - fn parse_pane_swap_args_rejects_mixed_forms() { - let err = parse_pane_swap_args(&args(&[ - "--direction", - "left", - "--source-pane", - "issue-1", - "--target-pane", - "issue-2", - ])) - .unwrap_err(); - - assert!(err.contains("usage: herdr pane swap")); + assert!(request( + "swap", + &[ + "--direction", + "left", + "--source-pane", + "a", + "--target-pane", + "b" + ] + ) + .is_err()); } #[test] fn parse_pane_move_args_accepts_existing_tab_destination() { - let params = parse_pane_move_args(&args(&[ - "issue-1", - "--tab", - "issue:2", - "--split", - "right", - "--target-pane", - "issue-3", - "--ratio", - "0.25", - "--no-focus", - ])) - .unwrap(); - + let Method::PaneMove(params) = request( + "move", + &[ + "issue-1", + "--tab", + "issue:2", + "--split", + "right", + "--target-pane", + "issue-3", + "--ratio", + "0.25", + "--no-focus", + ], + ) + .unwrap() + .method + else { + panic!() + }; assert_eq!(params.pane_id, "issue-1"); assert!(!params.focus); assert_eq!( @@ -1916,169 +729,139 @@ mod tests { tab_id: "issue:2".into(), target_pane_id: Some("issue-3".into()), split: SplitDirection::Right, - ratio: Some(0.25), + ratio: Some(0.25) } ); } #[test] - fn parse_pane_move_args_rejects_target_pane_without_tab() { - let err = - parse_pane_move_args(&args(&["issue-1", "--target-pane", "issue-2"])).unwrap_err(); - - assert!(err.contains("usage: herdr pane move")); - } - - #[test] - fn parse_pane_move_args_rejects_non_finite_ratio() { - let err = parse_pane_move_args(&args(&[ - "issue-1", "--tab", "issue:2", "--split", "right", "--ratio", "NaN", - ])) - .unwrap_err(); - - assert!(err.contains("invalid ratio")); + fn pane_projection_move_and_finite_values() { + for destination in ["--new-tab", "--new-workspace"] { + let parsed = request( + "move", + &[ + "pane", + destination, + "--ratio", + "0.5", + "--no-focus", + "--focus", + ], + ) + .unwrap(); + let json = serde_json::to_value(parsed).unwrap(); + assert_eq!(json["params"]["focus"], true); + assert!(json["params"]["destination"].get("ratio").is_none()); + } + for value in ["NaN", "inf", "-inf"] { + for (name, values) in [ + ("resize", vec!["--direction", "right", "--amount", value]), + ("split", vec!["--direction", "right", "--ratio", value]), + ( + "move", + vec!["pane", "--tab", "tab", "--split", "right", "--ratio", value], + ), + ] { + assert!(request(name, &values).is_err()); + } + } } #[test] fn parse_pane_zoom_args_defaults_to_current_toggle() { - let params = parse_pane_zoom_args(&args(&[])).unwrap(); - + let Method::PaneZoom(params) = request("zoom", &[]).unwrap().method else { + panic!() + }; assert_eq!(params.pane_id, None); assert_eq!(params.mode, PaneZoomMode::Toggle); + for (values, pane, mode) in [ + (vec!["issue-1", "--on"], "issue-1", PaneZoomMode::On), + ( + vec!["--pane", "issue-2", "--off"], + "issue-2", + PaneZoomMode::Off, + ), + ] { + let Method::PaneZoom(params) = request("zoom", &values).unwrap().method else { + panic!() + }; + assert_eq!(params.pane_id.as_deref(), Some(pane)); + assert_eq!(params.mode, mode); + } + assert!(request("zoom", &["--on", "--off"]).is_err()); } #[test] - fn parse_pane_zoom_args_accepts_positional_pane_and_on() { - let params = parse_pane_zoom_args(&args(&["issue-1", "--on"])).unwrap(); - - assert_eq!(params.pane_id, Some("issue-1".into())); - assert_eq!(params.mode, PaneZoomMode::On); + fn pane_projection_read_preserves_defaults_and_ansi_order() { + for (values, source, lines, format, strip) in [ + ( + vec!["issue-1"], + ReadSource::Recent, + None, + ReadFormat::Text, + true, + ), + ( + vec!["issue-1", "--source", "visible", "--lines", "5", "--ansi"], + ReadSource::Visible, + Some(5), + ReadFormat::Ansi, + true, + ), + ( + vec!["--source=visible", "--lines=5", "issue-1"], + ReadSource::Visible, + Some(5), + ReadFormat::Text, + true, + ), + ( + vec![ + "--raw", + "issue-1", + "--format=text", + "--ansi", + "--format", + "text", + "--lines=0", + "--source=recent_unwrapped", + ], + ReadSource::RecentUnwrapped, + Some(0), + ReadFormat::Text, + false, + ), + ( + vec!["issue-1", "--format=text", "--raw"], + ReadSource::Recent, + None, + ReadFormat::Ansi, + false, + ), + ] { + let Method::PaneRead(params) = request("read", &values).unwrap().method else { + panic!() + }; + assert_eq!(params.pane_id, "issue-1"); + assert_eq!(params.source, source); + assert_eq!(params.lines, lines); + assert_eq!(params.format, format); + assert_eq!(params.strip_ansi, strip); + assert_eq!(params.intent, crate::api::schema::ReadIntent::Interactive); + } } #[test] - fn parse_pane_zoom_args_accepts_pane_option_and_off() { - let params = parse_pane_zoom_args(&args(&["--pane", "issue-2", "--off"])).unwrap(); - - assert_eq!(params.pane_id, Some("issue-2".into())); - assert_eq!(params.mode, PaneZoomMode::Off); - } - - #[test] - fn parse_pane_zoom_args_rejects_multiple_modes() { - let err = parse_pane_zoom_args(&args(&["--on", "--off"])).unwrap_err(); - - assert!(err.contains("provide only one")); - } - - #[test] - fn parse_pane_neighbor_args_accepts_directional_current() { - let params = parse_pane_neighbor_args(&args(&["--direction", "down"])).unwrap(); - - assert_eq!(params.pane_id, None); - assert_eq!(params.direction, PaneDirection::Down); - } - - #[test] - fn parse_optional_current_pane_args_accepts_current_target() { - let pane_id = - parse_optional_current_pane_args(&args(&["--current"]), Some("issue-1")).unwrap(); - - assert_eq!(pane_id, Some("issue-1".into())); - } - - #[test] - fn parse_optional_current_pane_args_current_without_env_keeps_focused_fallback() { - let pane_id = parse_optional_current_pane_args(&args(&["--current"]), None).unwrap(); - - assert_eq!(pane_id, None); - } - - #[test] - fn parse_optional_current_pane_args_omitted_target_keeps_focused_fallback() { - let pane_id = parse_optional_current_pane_args(&args(&[]), Some("issue-1")).unwrap(); - - assert_eq!(pane_id, None); - } - - #[test] - fn parse_optional_current_pane_args_accepts_explicit_pane() { - let pane_id = - parse_optional_current_pane_args(&args(&["--pane", "issue-2"]), Some("issue-1")) - .unwrap(); - - assert_eq!(pane_id, Some("issue-2".into())); - } - - #[test] - fn parse_pane_focus_args_accepts_directional_current() { - let params = parse_pane_focus_args(&args(&["--direction", "up"])).unwrap(); - - assert_eq!(params.pane_id, None); - assert_eq!(params.direction, PaneDirection::Up); - } - - #[test] - fn parse_pane_resize_args_accepts_amount_and_pane() { - let params = parse_pane_resize_args(&args(&[ - "--pane", - "issue-2", - "--direction", - "left", - "--amount", - "0.125", - ])) - .unwrap(); - - assert_eq!(params.pane_id, Some("issue-2".into())); - assert_eq!(params.direction, PaneDirection::Left); - assert_eq!(params.amount, Some(0.125)); - } - - #[test] - fn parse_pane_read_args_defaults_with_bare_pane_id() { - let params = parse_pane_read_args(&args(&["issue-1"])).unwrap(); - - assert_eq!(params.pane_id, "issue-1"); - assert_eq!(params.source, ReadSource::Recent); - assert_eq!(params.lines, None); - assert_eq!(params.format, ReadFormat::Text); - assert!(params.strip_ansi); - } - - #[test] - fn parse_pane_read_args_accepts_space_separated_options() { - let params = parse_pane_read_args(&args(&[ - "issue-1", "--source", "visible", "--lines", "5", "--ansi", - ])) - .unwrap(); - - assert_eq!(params.pane_id, "issue-1"); - assert_eq!(params.source, ReadSource::Visible); - assert_eq!(params.lines, Some(5)); - assert_eq!(params.format, ReadFormat::Ansi); - } - - #[test] - fn parse_pane_read_args_accepts_reordered_equals_options() { - let params = - parse_pane_read_args(&args(&["--source=visible", "--lines=5", "issue-1"])).unwrap(); - - assert_eq!(params.pane_id, "issue-1"); - assert_eq!(params.source, ReadSource::Visible); - assert_eq!(params.lines, Some(5)); - } - - #[test] - fn parse_pane_wait_output_args_accepts_space_separated_options() { - let params = parse_pane_wait_output_args(&args(&[ - "issue-1", - "--match", - "ready", - "--timeout", - "5000", - ])) - .unwrap(); - + fn pane_projection_wait_preserves_matcher_and_defaults() { + let Method::PaneWaitForOutput(params) = request( + "wait-output", + &["issue-1", "--match", "ready", "--timeout", "5000"], + ) + .unwrap() + .method + else { + panic!() + }; assert_eq!(params.pane_id, "issue-1"); assert_eq!( params.r#match, @@ -2089,34 +872,158 @@ mod tests { assert_eq!(params.timeout_ms, Some(5000)); assert_eq!(params.source, ReadSource::Recent); assert!(params.strip_ansi); - } - - #[test] - fn parse_pane_wait_output_args_accepts_reordered_equals_options() { - let params = - parse_pane_wait_output_args(&args(&["--match=a=b", "--timeout=100", "issue-1"])) - .unwrap(); - - assert_eq!(params.pane_id, "issue-1"); + let Method::PaneWaitForOutput(params) = request( + "wait-output", + &[ + "--regex=a=b", + "--source=detection", + "issue-1", + "--timeout=0", + "--raw", + ], + ) + .unwrap() + .method + else { + panic!() + }; assert_eq!( params.r#match, - OutputMatch::Substring { + OutputMatch::Regex { value: "a=b".into() } ); - assert_eq!(params.timeout_ms, Some(100)); + assert_eq!(params.source, ReadSource::Detection); + assert_eq!(params.timeout_ms, Some(0)); + assert!(!params.strip_ansi); + assert!(request("wait-output", &["issue-1"]).is_err()); + assert!(request("wait-output", &["issue-1", "--match", "a", "--regex", "b"]).is_err()); } #[test] - fn parse_pane_wait_output_args_requires_matcher() { - let err = parse_pane_wait_output_args(&args(&["issue-1"])).unwrap_err(); - assert!(err.contains("missing required --match or --regex")); + fn pane_projection_atomic_run_joined_text_and_raw_keys() { + for (name, field) in [("run", "text"), ("send-text", "text"), ("rename", "label")] { + let parsed = request(name, &["pane", "a", "--literal", "b"]).unwrap(); + let json = serde_json::to_value(parsed).unwrap(); + assert_eq!(json["params"][field], "a --literal b"); + if name == "run" { + assert_eq!(json["params"]["keys"], serde_json::json!(["Enter"])); + assert_eq!(json["method"], "pane.send_input"); + assert_eq!(json["id"], "cli:request"); + } + } + let Method::PaneSendKeys(params) = + request("send-keys", &["pane", "Enter", "--literal", "esc"]) + .unwrap() + .method + else { + panic!() + }; + assert_eq!(params.keys, args(&["Enter", "--literal", "esc"])); + let Method::PaneRename(params) = request("rename", &["pane", "--clear"]).unwrap().method + else { + panic!() + }; + assert_eq!(params.label, None); } #[test] - fn parse_pane_wait_output_args_rejects_conflicting_matchers() { - let err = parse_pane_wait_output_args(&args(&["issue-1", "--match", "a", "--regex", "b"])) - .unwrap_err(); - assert!(err.contains("mutually exclusive")); + fn pane_projection_resume_presence_and_metadata_patches() { + for name in ["report-agent", "report-agent-session"] { + for (suffix, expected) in [ + (vec![], None), + (vec!["--"], Some(vec![])), + ( + vec!["--", "agent", "--source=y", "--"], + Some(args(&["agent", "--source=y", "--"])), + ), + ] { + let mut values = vec![ + "--source= hook ", + "pane", + "--agent=a", + "--seq=0", + "--agent-session-id=session", + "--agent-session-path=/path", + ]; + if name == "report-agent" { + values.push("--state=idle"); + } + values.extend(suffix); + let json = serde_json::to_value(request(name, &values).unwrap()).unwrap(); + assert_eq!(json["params"]["source"], "hook"); + assert_eq!(json["params"]["seq"], 0); + match expected { + Some(argv) => assert_eq!( + json["params"]["resume_argv"], + serde_json::to_value(argv).unwrap() + ), + None => assert!(json["params"].get("resume_argv").is_none()), + } + } + } + let json = serde_json::to_value( + request( + "report-metadata", + &[ + "pane", + "--source", + " hook ", + "--title", + "", + "--token", + "K=a=b", + "--clear-token", + "K", + "--token", + "E=", + "--state-label", + " Idle =ready", + "--seq", + "0", + ], + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(json["params"]["source"], "hook"); + assert_eq!(json["params"]["title"], ""); + assert_eq!( + json["params"]["tokens"], + serde_json::json!({"K": null, "E": ""}) + ); + assert_eq!( + json["params"]["state_labels"], + serde_json::json!({"idle": "ready"}) + ); + assert!(request("report-metadata", &["pane", "--source", "hook"]).is_err()); + assert!(request( + "report-metadata", + &["pane", "--source", "hook", "--title", "x", "--clear-title"] + ) + .is_err()); + } + + #[test] + fn pane_projection_help_and_invalid_syntax_do_not_use_transport() { + for leaf in bindings::LEAVES { + assert_eq!(run_pane_command(&args(&[leaf.name, "--help"])).unwrap(), 0); + } + for name in [ + "get", + "close", + "send-text", + "send-keys", + "run", + "rename", + "input", + "move", + "report-agent", + "report-agent-session", + "release-agent", + "report-metadata", + ] { + assert_eq!(run_pane_command(&args(&[name])).unwrap(), 2); + } } } diff --git a/src/cli/pane/bindings.rs b/src/cli/pane/bindings.rs new file mode 100644 index 000000000..aaa14bf76 --- /dev/null +++ b/src/cli/pane/bindings.rs @@ -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); + } +} diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs index 3d9b4a541..9d31dd274 100644 --- a/src/cli/plugin.rs +++ b/src/cli/plugin.rs @@ -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::*; diff --git a/src/cli/plugin/bindings.rs b/src/cli/plugin/bindings.rs new file mode 100644 index 000000000..6fda87b6a --- /dev/null +++ b/src/cli/plugin/bindings.rs @@ -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) + ); + } +} diff --git a/src/cli/projection.rs b/src/cli/projection.rs new file mode 100644 index 000000000..28411e2e0 --- /dev/null +++ b/src/cli/projection.rs @@ -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 + )); + } +} diff --git a/src/cli/runtime.rs b/src/cli/runtime.rs deleted file mode 100644 index 793ca24ee..000000000 --- a/src/cli/runtime.rs +++ /dev/null @@ -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 })) -} diff --git a/src/cli/server.rs b/src/cli/server.rs index 657a5db8e..7c553fde3 100644 --- a/src/cli/server.rs +++ b/src/cli/server.rs @@ -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![ diff --git a/src/cli/spec.rs b/src/cli/spec.rs index 9a503a18a..272d580b3 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -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" ); } diff --git a/src/cli/tab.rs b/src/cli/tab.rs index 440b9797d..70dd51421 100644 --- a/src/cli/tab.rs +++ b/src/cli/tab.rs @@ -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>"); } diff --git a/src/cli/tab_bindings.rs b/src/cli/tab_bindings.rs new file mode 100644 index 000000000..a795ef2d9 --- /dev/null +++ b/src/cli/tab_bindings.rs @@ -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(); + } +} diff --git a/src/cli/title.rs b/src/cli/title.rs new file mode 100644 index 000000000..2c0feef5c --- /dev/null +++ b/src/cli/title.rs @@ -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) + ); + } +} diff --git a/src/cli/workspace.rs b/src/cli/workspace.rs index 525398b4e..074c45d83 100644 --- a/src/cli/workspace.rs +++ b/src/cli/workspace.rs @@ -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)?) + } } diff --git a/src/cli/workspace_bindings.rs b/src/cli/workspace_bindings.rs new file mode 100644 index 000000000..2462e1db4 --- /dev/null +++ b/src/cli/workspace_bindings.rs @@ -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") + ); + } +} diff --git a/src/cli/worktree.rs b/src/cli/worktree.rs index ba492e973..bd553c158 100644 --- a/src/cli/worktree.rs +++ b/src/cli/worktree.rs @@ -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(), || { diff --git a/src/cli/worktree_bindings.rs b/src/cli/worktree_bindings.rs new file mode 100644 index 000000000..4b4f2a8e5 --- /dev/null +++ b/src/cli/worktree_bindings.rs @@ -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) + ); + } +} diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs index 87824c85d..97092b45f 100644 --- a/src/server/client_commands.rs +++ b/src/server/client_commands.rs @@ -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" diff --git a/tests/cli/contracts.rs b/tests/cli/contracts.rs new file mode 100644 index 000000000..ee396cb03 --- /dev/null +++ b/tests/cli/contracts.rs @@ -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:?}"); + } +} diff --git a/tests/cli/mod.rs b/tests/cli/mod.rs index 0fdff6f1f..c5f98daa0 100644 --- a/tests/cli/mod.rs +++ b/tests/cli/mod.rs @@ -2,6 +2,7 @@ mod agent_options; mod agent_transport; mod agent_wait; mod agents; +mod contracts; mod harness; mod hooks; mod panes; diff --git a/tests/cli/panes.rs b/tests/cli/panes.rs index 90b2bf795..03e3ed738 100644 --- a/tests/cli/panes.rs +++ b/tests/cli/panes.rs @@ -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")); } diff --git a/tests/cli/plugins.rs b/tests/cli/plugins.rs index 6cbb3ccd9..5f52ca477 100644 --- a/tests/cli/plugins.rs +++ b/tests/cli/plugins.rs @@ -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, diff --git a/tests/cli/surface.rs b/tests/cli/surface.rs index b48baf575..1c7f617a3 100644 --- a/tests/cli/surface.rs +++ b/tests/cli/surface.rs @@ -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 <")); }