From fabf65ec6a62bfa0bb77e24d9c234a5b9c71ea57 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 7 Oct 2026 09:24:41 -0700 Subject: [PATCH 1/4] fix: make ctrl-click link capture opt-in via a links add-on; plugin 0.9.0 The Full and Windows Full manifests no longer declare the markdown-file link handler or the open-link action, so Ctrl-click on file:// Markdown links keeps Herdr's default behavior. The new links/ add-on (annotate-links) declares the handler and runs the installed annotate plugin's plannotator-tui, without a binary of its own. refs #69 --- .github/workflows/windows-full-ci.yml | 1 + README.md | 26 +++++++- docs/windows-full-acceptance.md | 14 ++--- herdr-plugin.toml | 25 ++------ links/herdr-plugin.toml | 33 ++++++++++ links/open-link.sh | 56 +++++++++++++++++ lite/herdr-plugin.toml | 2 +- scripts/smoke.sh | 25 ++++++-- scripts/test-herdr-windows-full-plugin.ps1 | 15 +++-- scripts/test-herdr-windows-plugin.ps1 | 4 +- scripts/test-windows-full-manifest.py | 70 +++++++++++++++++----- skills/plannotator-tui/SKILL.md | 7 ++- windows-full/herdr-plugin.toml | 22 ++----- 13 files changed, 224 insertions(+), 76 deletions(-) create mode 100644 links/herdr-plugin.toml create mode 100644 links/open-link.sh diff --git a/.github/workflows/windows-full-ci.yml b/.github/workflows/windows-full-ci.yml index 4f51b8a..88d75e6 100644 --- a/.github/workflows/windows-full-ci.yml +++ b/.github/workflows/windows-full-ci.yml @@ -19,6 +19,7 @@ on: - "scripts/test-http-server.py" - "scripts/test-windows-full-manifest.py" - "windows-full/**" + - "links/**" - ".github/workflows/windows-full-ci.yml" merge_group: diff --git a/README.md b/README.md index 7cf46b0..4712586 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ Full install. Works with Claude Code, Codex, pi, Copilot CLI, Droid, Oh My Pi, H | `Ctrl+B O` | this folder, with a file tree | | `Ctrl+B Shift+O` | the agent's recent replies | | `Ctrl+B Ctrl+O` | the agent's newest reply, with no picker | -| Ctrl-click a `file://…md` link | that file | +| Ctrl-click a `file://…md` link | that file, only with the opt-in Links add-on below | `annotate.terminal` opens the focused pane's recent terminal output (the last 200 lines) for review in the same way. It has no default key; to use it, bind one yourself, for example: @@ -225,6 +225,29 @@ command = "annotate.terminal" description = "review this pane's recent output" ``` +#### Ctrl-click Markdown links (opt-in) + +Annotate does not capture Ctrl-click on links by default, so `file://…md` links open the way +Herdr normally opens them. To send Ctrl-clicked `file://` Markdown links (`.md`, `.markdown`, +`.mdx`) to Annotate instead, install the Links add-on next to the Full install (macOS and Linux): + +```sh +herdr plugin install plannotator/herdr-annotate/links +``` + +To turn it off again: + +```sh +herdr plugin uninstall annotate-links +``` + +The add-on has no binary of its own; it opens the file with the Full plugin's plannotator-tui. + +> **Upgrading from 0.8.0 or earlier:** up to plugin 0.8.0, the Full install captured these clicks +> itself. From 0.9.0 it does not +> ([#69](https://github.com/plannotator/herdr-annotate/issues/69)). Install the Links add-on to keep +> the old behavior. + **Send** (or `E`) makes the review the agent's next message. `q` closes. On an agent reply, `S` sends and closes in one key; `E` and `q` are unchanged. @@ -288,6 +311,7 @@ herdr plugin link "$PWD" # or "$PWD/lite" for the Lite variant `herdr plugin link` does not run manifest build hooks, so stage the binary first. It also replaces any existing `annotate` link; link the other directory to switch back. +The Links add-on has no build step: `herdr plugin link "$PWD/links"` links it as `annotate-links`. `bash scripts/lite-regression.sh` checks the runtime against goldens recorded from the retired Bun runtime. [docs/lite-testing.md](docs/lite-testing.md) covers what it compares and everything else diff --git a/docs/windows-full-acceptance.md b/docs/windows-full-acceptance.md index 5d0a1b7..957bb40 100644 --- a/docs/windows-full-acceptance.md +++ b/docs/windows-full-acceptance.md @@ -33,7 +33,6 @@ requirements, not completed verification. Every box starts unchecked. | `send-archive` | Send annotations to the agent and archive them | `pane` | | `manage` | Manage annotations | `global` | | `open` | Annotate: open here | `workspace`, `pane` | - | `open-link` | Annotate this file | `pane` | | `last` | Annotate: agent's last message | `pane` | | `last-newest` | Annotate: agent's newest message | `pane` | | `terminal` | Annotate: terminal output | `pane` | @@ -43,13 +42,15 @@ requirements, not completed verification. Every box starts unchecked. command must be exactly `["./bin/plannotator-tui.exe", "herdr", "pane"]`. No shell, PowerShell, command-string interpolation, or fallback launcher may sit between Herdr's pane process and the TUI. -- [ ] Keep `open` and `open-link` as direct argv ending in `herdr open`, +- [ ] Keep `open` as direct argv ending in `herdr open`, `last` as direct argv ending in `herdr last`, `last-newest` as direct argv ending in `herdr last --newest`, and `terminal` as direct argv ending in `herdr terminal`, all using - `./bin/plannotator-tui.exe`. Preserve the `markdown-file` link handler's title, - pattern, and `open-link` action. All actions, panes, the handler, and the build - must be effective on Windows; no inherited Unix gate may disable them. + `./bin/plannotator-tui.exe`. All actions, panes, and the build must be + effective on Windows; no inherited Unix gate may disable them. Since plugin + 0.9.0 ([#69](https://github.com/plannotator/herdr-annotate/issues/69)) the + variant declares no `[[link_handlers]]` and no `open-link` action: Ctrl-click + capture is opt-in through the macOS/Linux `links/` add-on. - [ ] Reuse the shared native runtime with paths valid from `windows-full/` (as `lite/` does with `../bin/`). Verify those paths from an installed checkout, not just the repository root. Preserve shared annotation state and archive @@ -136,8 +137,7 @@ does not establish that a real Herdr pane works. - [ ] Pass a relative `PLANNOTATOR_TUI_FILE` that exists only in the review cwd, then an absolute path with spaces and Unicode. Assert each intended document renders. Verify delivery target variables supplied by the launcher survive - unchanged. Exercise `open`, `open-link` (including a percent-encoded `file://` - path), `last`, and `last-newest` with controlled context/transcript fixtures. Record fixture + unchanged. Exercise `open`, `last`, and `last-newest` with controlled context/transcript fixtures. Record fixture evidence separately from real-agent integration. - [ ] Open Full with inert review-folder `.env` fixtures that would change a launcher process. Full must render the intended document without loading those diff --git a/herdr-plugin.toml b/herdr-plugin.toml index b07d0bc..23bf756 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,6 +1,6 @@ id = "annotate" name = "Annotate" -version = "0.8.0" +version = "0.9.0" min_herdr_version = "0.8.0" description = "Comment on terminal selections and copy annotations as agent context." platforms = ["linux", "macos", "windows"] @@ -96,9 +96,10 @@ placement = "overlay" platforms = ["macos", "linux"] command = ["sh", "-c", "exec bash \"$HERDR_PLUGIN_ROOT/scripts/plannotator-tui.sh\" herdr pane"] -# Both actions run the launcher, which reads Herdr's invocation context: the focused pane's -# folder (open) or the clicked file:// link (open-link), and the focused pane's agent as -# the target the review is sent back to. +# The review actions run the launcher, which reads Herdr's invocation context (the focused +# pane's folder and agent) and sends the review back to that agent. Ctrl-click on a +# file:// Markdown link is not captured here; the opt-in links/ add-on declares that handler and +# runs this plugin's launcher, so a plain install leaves the click to Herdr's default opener. [[actions]] id = "open" title = "Annotate: open here" @@ -107,14 +108,6 @@ contexts = ["workspace", "pane"] platforms = ["macos", "linux"] command = ["./bin/plannotator-tui.exe", "herdr", "open"] -[[actions]] -id = "open-link" -title = "Annotate this file" -description = "Open a Ctrl-clicked Markdown file in plannotator-tui." -contexts = ["pane"] -platforms = ["macos", "linux"] -command = ["./bin/plannotator-tui.exe", "herdr", "open"] - [[actions]] id = "last" title = "Annotate: agent's last message" @@ -139,11 +132,3 @@ description = "Review the focused pane's recent terminal output in plannotator-t contexts = ["pane"] platforms = ["macos", "linux"] command = ["./bin/plannotator-tui.exe", "herdr", "terminal"] - -# Ctrl-click on a file:// Markdown link. Anchored on the scheme so web links never match. -[[link_handlers]] -id = "markdown-file" -title = "Annotate this file" -pattern = "^file://.*\\.(md|markdown|mdx)$" -action = "open-link" -platforms = ["macos", "linux"] diff --git a/links/herdr-plugin.toml b/links/herdr-plugin.toml new file mode 100644 index 0000000..201cf16 --- /dev/null +++ b/links/herdr-plugin.toml @@ -0,0 +1,33 @@ +# Herdr Annotate, Links: opt-in Ctrl-click capture for file:// Markdown links. +# Install with: herdr plugin install plannotator/herdr-annotate/links +# Remove with: herdr plugin uninstall annotate-links +# +# The Annotate plugin no longer claims these clicks itself, because Herdr matches link handlers +# from the manifest before any plugin code runs and offers no way to turn one off. Keeping the +# handler in its own plugin makes capture a choice: install this to send Markdown links to +# Annotate, uninstall it to give them back to Herdr's default opener. +# +# This add-on ships no binary. Its action finds the installed `annotate` plugin and runs that +# plugin's plannotator-tui exactly as the old built-in open-link action did, so it needs the +# Full install (plannotator/herdr-annotate) on macOS or Linux. + +id = "annotate-links" +name = "Annotate Links" +version = "0.9.0" +min_herdr_version = "0.8.0" +description = "Open Ctrl-clicked file:// Markdown links in Annotate (needs the Annotate Full plugin)." +platforms = ["macos", "linux"] + +[[actions]] +id = "open-link" +title = "Annotate this file" +description = "Open a Ctrl-clicked Markdown file in plannotator-tui." +contexts = ["pane"] +command = ["bash", "open-link.sh"] + +# Anchored on the scheme so web links never match. +[[link_handlers]] +id = "markdown-file" +title = "Annotate this file" +pattern = "^file://.*\\.(md|markdown|mdx)$" +action = "open-link" diff --git a/links/open-link.sh b/links/open-link.sh new file mode 100644 index 0000000..6f8cdcb --- /dev/null +++ b/links/open-link.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Open a Ctrl-clicked file:// Markdown link in the installed Annotate plugin. +# +# Herdr runs this with the clicked URL and the focused pane in HERDR_PLUGIN_CONTEXT_JSON. It +# finds the `annotate` plugin's root, then runs that plugin's plannotator-tui with the same +# arguments, working directory and plugin id the old built-in open-link action used. The +# review pane therefore opens inside `annotate`, and feedback goes to the focused agent. +set -euo pipefail + +herdr_bin="${HERDR_BIN_PATH:-herdr}" +install_hint="herdr plugin install plannotator/herdr-annotate" + +fail() { + echo "$2" >&2 + "$herdr_bin" notification show "$1" --body "$2" >/dev/null 2>&1 || true + exit 1 +} + +# Print the decoded value of a top-level JSON string field. `plugin list --plugin` returns one +# plugin, and these keys occur only at the plugin level, so the first match is the right one. +json_string() { + printf '%s' "$2" | sed -nE "s/.*\"$1\":\"(([^\"\\\\]|\\\\.)*)\".*/\\1/p" | awk ' + { + out = "" + while ((i = index($0, "\\")) > 0) { + c = substr($0, i + 1, 1) + if (c == "n") c = "\n"; else if (c == "t") c = "\t"; else if (c == "u") exit 1 + out = out substr($0, 1, i - 1) c + $0 = substr($0, i + 2) + } + print out $0 + }' +} + +listing="$("$herdr_bin" plugin list --plugin annotate --json 2>/dev/null | tr -d '\n' || true)" +root="$(json_string plugin_root "$listing" || true)" + +if [ -z "$root" ]; then + fail "Annotate: plugin not installed" \ + "Ctrl-click needs the Annotate plugin. Install it with: $install_hint" +fi +case "$listing" in + *'"enabled":true'*) ;; + *) fail "Annotate: plugin disabled" \ + "Ctrl-click needs the Annotate plugin enabled. Enable it with: herdr plugin enable annotate" ;; +esac + +tui="$root/bin/plannotator-tui.exe" +if [ ! -x "$tui" ]; then + fail "Annotate: document review unavailable" \ + "Ctrl-click needs the Full Annotate plugin, which includes plannotator-tui. Install it with: $install_hint" +fi + +cd "$root" +export HERDR_PLUGIN_ID=annotate HERDR_PLUGIN_ROOT="$root" +exec "$tui" herdr open diff --git a/lite/herdr-plugin.toml b/lite/herdr-plugin.toml index abfa37b..009e0ea 100644 --- a/lite/herdr-plugin.toml +++ b/lite/herdr-plugin.toml @@ -8,7 +8,7 @@ id = "annotate" name = "Annotate" -version = "0.8.0" +version = "0.9.0" min_herdr_version = "0.8.0" description = "Comment on terminal selections and copy annotations as agent context." platforms = ["linux", "macos", "windows"] diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 13289f5..629bc88 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Smoke-test the plugin the way users get it: fresh install, upgrade from an old commit, lite, -# and the lite -> full swap, exercising both fetched binaries and both panes in a disposable -# Herdr session. Restores whatever `annotate` install was present before it ran. +# the lite -> full swap, and the opt-in links add-on, exercising both fetched binaries and both +# panes in a disposable Herdr session. Restores whatever `annotate` install was present before it ran. # # HERDR_SESSION= bash scripts/smoke.sh [old-ref] # @@ -97,7 +97,7 @@ trap restore EXIT echo "== fresh install: full" install "$spec" -check "actions" "$(actions)" "capture,copy-archive,copy-context,last,last-newest,manage,open,open-link,paste-archive,send-archive,terminal" +check "actions" "$(actions)" "capture,copy-archive,copy-context,last,last-newest,manage,open,paste-archive,send-archive,terminal" check "action programs" "$(programs)" "./bin/herdr-annotate.exe,./bin/plannotator-tui.exe" check "review binary matches pin" "$(tui_version)" "$(tui_pin)" check "native binary matches pin" "$(native_version)" "$(native_pin)" @@ -134,9 +134,26 @@ check "manager pane" "$(manager_renders)" "ok" echo "== swap: lite -> full" install "$spec" -check "actions" "$(actions)" "capture,copy-archive,copy-context,last,last-newest,manage,open,open-link,paste-archive,send-archive,terminal" +check "actions" "$(actions)" "capture,copy-archive,copy-context,last,last-newest,manage,open,paste-archive,send-archive,terminal" check "review binary" "$(tui_version)" "$(tui_pin)" check "native binary" "$(native_version)" "$(native_pin)" +echo "== opt-in links add-on (#69)" +link_handlers() { herdr plugin list --plugin "$1" --json | python3 -c " +import json,sys +plugins=json.load(sys.stdin)['result']['plugins'] +print(','.join(sorted(h['id'] for p in plugins for h in p.get('link_handlers',[]))) or 'none')"; } +check "full declares no link handler" "$(link_handlers annotate)" "none" +if herdr plugin list --plugin annotate-links --json | grep -q '"plugin_id":"annotate-links"'; then + echo " note annotate-links is already installed here; skipping so it is left untouched" +else + herdr plugin install "$spec/links" --yes >/dev/null + check "add-on link handlers" "$(link_handlers annotate-links)" "markdown-file" + check "add-on actions" "$(herdr plugin action list --plugin annotate-links | python3 -c " +import json,sys; print(','.join(sorted(a['action_id'] for a in json.load(sys.stdin)['result']['actions'])))")" "open-link" + herdr plugin uninstall annotate-links >/dev/null + check "add-on removed" "$(link_handlers annotate-links)" "none" +fi + echo "== result: $failures failure(s)" [ "$failures" -eq 0 ] diff --git a/scripts/test-herdr-windows-full-plugin.ps1 b/scripts/test-herdr-windows-full-plugin.ps1 index 310ba59..0c9067c 100644 --- a/scripts/test-herdr-windows-full-plugin.ps1 +++ b/scripts/test-herdr-windows-full-plugin.ps1 @@ -3,7 +3,7 @@ # # Two things are proved here that the manifest test cannot see, because they are Herdr's # behaviour rather than the file's contents: 0.8.2 refuses the variant for its minimum, and -# 0.9.0 accepts it and reports every action, pane and link handler as effective on Windows. +# 0.9.0 accepts it and reports every action and pane as effective on Windows. # The plugin root contains spaces on purpose. $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest @@ -122,7 +122,7 @@ try { # variant installed and the review half silently unreachable, which is the state this # whole variant exists to end. $actionIds = @($plugin.actions | ForEach-Object { $_.id }) - foreach ($id in @("capture", "copy-context", "copy-archive", "paste-archive", "send-archive", "manage", "open", "open-link", "last", "last-newest", "terminal")) { + foreach ($id in @("capture", "copy-context", "copy-archive", "paste-archive", "send-archive", "manage", "open", "last", "last-newest", "terminal")) { Assert-True ($actionIds -contains $id) "Herdr $version omitted action $id" $action = @($plugin.actions | Where-Object { $_.id -ceq $id }) # @() at the call site: an empty array returned from a function unrolls to $null. @@ -130,7 +130,7 @@ try { Assert-True ($platforms.Count -eq 0 -or $platforms -contains "windows") ` "action $id is not effective on Windows: $($platforms -join ',')" } - Assert-True ($actionIds.Count -eq 11) "Herdr $version listed $($actionIds.Count) actions, expected 11" + Assert-True ($actionIds.Count -eq 10) "Herdr $version listed $($actionIds.Count) actions, expected 10" $paneIds = @($plugin.panes | ForEach-Object { $_.id }) foreach ($id in @("editor", "manager", "doc")) { @@ -152,10 +152,13 @@ try { $docCommand[2] -ceq "pane" ) "the doc pane is not direct argv: $($docCommand -join ' ')" - $handler = @($plugin.link_handlers | Where-Object { $_.id -ceq "markdown-file" }) - Assert-True ($handler.Count -eq 1) "Herdr $version omitted the markdown-file link handler" + # Ctrl-click capture is opt-in (#69); Annotate itself must not claim any link. + # Herdr omits an empty list from the JSON, so read it through PSObject under StrictMode. + $handlerProperty = $plugin.PSObject.Properties['link_handlers'] + $handlers = if ($null -eq $handlerProperty -or $null -eq $handlerProperty.Value) { @() } else { @($handlerProperty.Value) } + Assert-True ($handlers.Count -eq 0) "Herdr $version listed $($handlers.Count) link handlers, expected none" - Write-Output "herdr $version accepts windows-full: 11 actions, 3 panes, direct-argv doc pane" + Write-Output "herdr $version accepts windows-full: 10 actions, 3 panes, direct-argv doc pane" } } finally { foreach ($name in $isolatedNames) { diff --git a/scripts/test-herdr-windows-plugin.ps1 b/scripts/test-herdr-windows-plugin.ps1 index a3ddb43..010232c 100644 --- a/scripts/test-herdr-windows-plugin.ps1 +++ b/scripts/test-herdr-windows-plugin.ps1 @@ -67,10 +67,10 @@ try { Assert-True ($plugins.Count -eq 1) "pinned Herdr did not list exactly one Annotate plugin" $plugin = $plugins[0] $actionIds = @($plugin.actions | ForEach-Object { $_.id }) - foreach ($id in @("capture", "copy-context", "manage", "open", "open-link", "last")) { + foreach ($id in @("capture", "copy-context", "manage", "open", "last")) { Assert-True ($actionIds -contains $id) "pinned Herdr omitted action $id" } - foreach ($id in @("open", "open-link", "last")) { + foreach ($id in @("open", "last")) { $action = @($plugin.actions | Where-Object { $_.id -ceq $id }) $platforms = @($action[0].platforms) Assert-True ( diff --git a/scripts/test-windows-full-manifest.py b/scripts/test-windows-full-manifest.py index 3dda5e4..2d340d1 100644 --- a/scripts/test-windows-full-manifest.py +++ b/scripts/test-windows-full-manifest.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Check the gated distributed Full manifest, Windows Full, and development parity.""" +"""Check the gated distributed Full manifest, Windows Full, the Links add-on, and development parity.""" from __future__ import annotations @@ -73,9 +73,12 @@ ] ACTION_COMMANDS = { "open": [PROGRAM, "herdr", "open"], - "open-link": [PROGRAM, "herdr", "open"], "last": [PROGRAM, "herdr", "last"], } +# Ctrl-click capture is opt-in (#69): only the links/ add-on may declare a link handler. +LINKS_ID = "annotate-links" +LINKS_ACTION = ["bash", "open-link.sh"] +LINKS_PATTERN = "^file://.*\\.(md|markdown|mdx)$" DEVELOPMENT_BUILDS = [ ["cargo", "build", "--release", "--manifest-path", "../Cargo.toml"], ["bash", "stage-plannotator-tui.sh"], @@ -195,11 +198,7 @@ def check_distributed(path: Path, version_path: Path, native_version_path: Path) if any("sh" in argument.lower() or "$" in argument for argument in expected): fail(path, f"shell found in actions.{entry_id}: {expected!r}") - handler = entry(path, manifest, "link_handlers", "markdown-file") - if platforms(path, manifest, handler) != FULL_PLATFORMS: - fail(path, "link_handlers.markdown-file platforms are not macOS/Linux") - if handler.get("action") != "open-link": - fail(path, f"markdown-file points to {handler.get('action')!r}") + check_no_link_capture(path, manifest) def check_development(path: Path) -> None: @@ -230,11 +229,53 @@ def check_development(path: Path) -> None: if action.get("command") != expected: fail(path, f"development actions.{entry_id} differs: {action.get('command')!r}") + + +def check_no_link_capture(path: Path, manifest: dict[str, object]) -> None: + """A link handler in an Annotate install would capture Ctrl-click for every user (#69).""" + if manifest.get("link_handlers"): + fail(path, "Annotate must not declare [[link_handlers]]; they belong in links/") + if any( + isinstance(item, dict) and item.get("id") == "open-link" + for item in manifest.get("actions", []) + ): + fail(path, "the open-link action moved to the links/ add-on") + + +def check_links(path: Path, root_path: Path) -> None: + """The opt-in add-on: one handler, one action, no binary of its own.""" + manifest = load(path) + root = load(root_path) + if manifest.get("id") != LINKS_ID: + fail(path, f"id must be {LINKS_ID!r}, found {manifest.get('id')!r}") + if manifest.get("version") != root.get("version"): + fail(path, f"version {manifest.get('version')!r} differs from the root manifest") + if platforms(path, manifest, {}) != FULL_PLATFORMS: + fail(path, f"top-level platforms are {platforms(path, manifest, {})!r}") + if builds(path, manifest): + fail(path, "the add-on must not build or fetch a binary of its own") + for table in ("panes", "events", "startup"): + if manifest.get(table): + fail(path, f"the add-on declares [[{table}]]") + + actions = manifest.get("actions", []) + if not isinstance(actions, list) or len(actions) != 1: + fail(path, "expected exactly one action") + action = entry(path, manifest, "actions", "open-link") + if action.get("command") != LINKS_ACTION: + fail(path, f"unexpected actions.open-link argv: {action.get('command')!r}") + script = path.parent / LINKS_ACTION[1] + if not script.is_file(): + fail(path, f"action script {script} does not exist") + + handlers = manifest.get("link_handlers", []) + if not isinstance(handlers, list) or len(handlers) != 1: + fail(path, "expected exactly one link handler") handler = entry(path, manifest, "link_handlers", "markdown-file") - if "windows" not in platforms(path, manifest, handler): - fail(path, "development markdown-file lost Windows support") + if handler.get("pattern") != LINKS_PATTERN: + fail(path, f"unexpected markdown-file pattern: {handler.get('pattern')!r}") if handler.get("action") != "open-link": - fail(path, f"development markdown-file points to {handler.get('action')!r}") + fail(path, f"markdown-file points to {handler.get('action')!r}") def surface(path: Path, manifest: dict[str, object], table: str) -> set[tuple[object, ...]]: @@ -328,11 +369,7 @@ def check_windows_full(path: Path, root_path: Path) -> None: if doc.get("command") != WINDOWS_FULL_PANE: fail(path, f"panes.doc must be direct argv, found {doc.get('command')!r}") - handler = entry(path, manifest, "link_handlers", "markdown-file") - root_handler = entry(root_path, root, "link_handlers", "markdown-file") - for key in ("title", "pattern", "action"): - if handler.get(key) != root_handler.get(key): - fail(path, f"markdown-file {key} differs from the root manifest") + check_no_link_capture(path, manifest) def main() -> None: @@ -348,6 +385,9 @@ def main() -> None: root / "windows-full" / "herdr-plugin.toml", root / "herdr-plugin.toml", ) + lite = root / "lite" / "herdr-plugin.toml" + check_no_link_capture(lite, load(lite)) + check_links(root / "links" / "herdr-plugin.toml", root / "herdr-plugin.toml") if len(sys.argv) == 2: check_development(Path(sys.argv[1])) diff --git a/skills/plannotator-tui/SKILL.md b/skills/plannotator-tui/SKILL.md index 5cda7ec..fa587e6 100644 --- a/skills/plannotator-tui/SKILL.md +++ b/skills/plannotator-tui/SKILL.md @@ -27,13 +27,16 @@ file is and ask them to review it. Address every item, then continue. -When you list files you want the human to open, print them as `file://` hyperlinks (OSC 8) -so Ctrl-click in Herdr opens them in plannotator-tui: +When you list files you want the human to open, you may print them as `file://` hyperlinks +(OSC 8): ```bash printf '\e]8;;file://%s\e\\%s\e]8;;\e\\\n' "$PWD/docs/plans/auth.md" "docs/plans/auth.md" ``` +Do not promise that Ctrl-click opens them in plannotator-tui. That happens only when the human +installed the opt-in `annotate-links` add-on. To ask for a review, use step 2. + If `plannotator-tui` is not on `PATH`, the raw Herdr command is: ```bash diff --git a/windows-full/herdr-plugin.toml b/windows-full/herdr-plugin.toml index e60c441..ad058ba 100644 --- a/windows-full/herdr-plugin.toml +++ b/windows-full/herdr-plugin.toml @@ -21,7 +21,7 @@ id = "annotate" name = "Annotate" -version = "0.8.0" +version = "0.9.0" min_herdr_version = "0.9.0" description = "Comment on terminal selections and copy annotations as agent context." platforms = ["windows"] @@ -102,9 +102,9 @@ title = "Annotate" placement = "overlay" command = ["./bin/plannotator-tui.exe", "herdr", "pane"] -# Both actions run the TUI directly, which reads Herdr's invocation context: the focused -# pane's folder (open) or the clicked file:// link (open-link), and the focused pane's agent -# as the target the review is sent back to. +# The review actions run the TUI directly, which reads Herdr's invocation context (the focused +# pane's folder and agent) and sends the review back to that agent. Ctrl-click on a +# file:// Markdown link is not captured, so the click keeps Herdr's default behavior. [[actions]] id = "open" title = "Annotate: open here" @@ -112,13 +112,6 @@ description = "Review the focused pane's folder in plannotator-tui and send feed contexts = ["workspace", "pane"] command = ["./bin/plannotator-tui.exe", "herdr", "open"] -[[actions]] -id = "open-link" -title = "Annotate this file" -description = "Open a Ctrl-clicked Markdown file in plannotator-tui." -contexts = ["pane"] -command = ["./bin/plannotator-tui.exe", "herdr", "open"] - [[actions]] id = "last" title = "Annotate: agent's last message" @@ -140,10 +133,3 @@ title = "Annotate: terminal output" description = "Review the focused pane's recent terminal output in plannotator-tui and send feedback back." contexts = ["pane"] command = ["./bin/plannotator-tui.exe", "herdr", "terminal"] - -# Ctrl-click on a file:// Markdown link. Anchored on the scheme so web links never match. -[[link_handlers]] -id = "markdown-file" -title = "Annotate this file" -pattern = "^file://.*\\.(md|markdown|mdx)$" -action = "open-link" From 1f50b6949fb4a382107f876021a78c67727f3b90 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 7 Oct 2026 09:30:39 -0700 Subject: [PATCH 2/4] chore: satisfy clippy assert_is_empty in tests --- rust/src/clipboard.rs | 4 ++-- rust/src/manager.rs | 2 +- rust/src/store.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/rust/src/clipboard.rs b/rust/src/clipboard.rs index 37ba215..32bc2a9 100644 --- a/rust/src/clipboard.rs +++ b/rust/src/clipboard.rs @@ -135,7 +135,7 @@ mod tests { #[test] fn every_platform_has_read_and_write_candidates() { - assert!(!read_commands().is_empty()); - assert!(!write_commands().is_empty()); + assert_ne!(read_commands().len(), 0); + assert_ne!(write_commands().len(), 0); } } diff --git a/rust/src/manager.rs b/rust/src/manager.rs index 673b524..f4331ff 100644 --- a/rust/src/manager.rs +++ b/rust/src/manager.rs @@ -892,7 +892,7 @@ mod tests { app.handle_key(KeyEvent::from(KeyCode::Char('D'))); assert_eq!(load_annotations(&dir).expect("still active").len(), 1); app.handle_key(KeyEvent::from(KeyCode::Char('D'))); - assert!(load_annotations(&dir).expect("cleared").is_empty()); + assert_eq!(load_annotations(&dir).expect("cleared").len(), 0); let _ = fs::remove_dir_all(dir); } } diff --git a/rust/src/store.rs b/rust/src/store.rs index 210da0b..8be7baf 100644 --- a/rust/src/store.rs +++ b/rust/src/store.rs @@ -556,7 +556,7 @@ mod tests { fs::create_dir_all(&parent).expect("temporary parent"); fs::create_dir(&reference).expect("reference directory"); - assert!(load_annotations(&dir).expect("load").is_empty()); + assert_eq!(load_annotations(&dir).expect("load").len(), 0); assert_eq!( fs::metadata(&dir) .expect("state metadata") From 7d4059753d21ba7e659322783f4ca3a38e91bf92 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 7 Oct 2026 09:30:39 -0700 Subject: [PATCH 3/4] fix: count an absent link handler list as empty in the windows-full check refs #69 --- scripts/test-herdr-windows-full-plugin.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/test-herdr-windows-full-plugin.ps1 b/scripts/test-herdr-windows-full-plugin.ps1 index 0c9067c..831b0f1 100644 --- a/scripts/test-herdr-windows-full-plugin.ps1 +++ b/scripts/test-herdr-windows-full-plugin.ps1 @@ -155,7 +155,8 @@ try { # Ctrl-click capture is opt-in (#69); Annotate itself must not claim any link. # Herdr omits an empty list from the JSON, so read it through PSObject under StrictMode. $handlerProperty = $plugin.PSObject.Properties['link_handlers'] - $handlers = if ($null -eq $handlerProperty -or $null -eq $handlerProperty.Value) { @() } else { @($handlerProperty.Value) } + # @() around the whole if: an empty array returned from a statement unrolls to $null. + $handlers = @(if ($null -ne $handlerProperty -and $null -ne $handlerProperty.Value) { $handlerProperty.Value }) Assert-True ($handlers.Count -eq 0) "Herdr $version listed $($handlers.Count) link handlers, expected none" Write-Output "herdr $version accepts windows-full: 10 actions, 3 panes, direct-argv doc pane" From 51abe199668ed458f85bf23af6448005667de5ee Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 7 Oct 2026 09:35:43 -0700 Subject: [PATCH 4/4] feat: support windows full in the links add-on The add-on gains an open-link-windows action and markdown-file-windows handler that run links/open-link.ps1, which resolves the annotate plugin root and runs its staged plannotator-tui. The windows-full pane test now drives that action end to end. refs #69 --- README.md | 6 +- docs/windows-full-acceptance.md | 3 +- links/herdr-plugin.toml | 27 +++++- links/open-link.ps1 | 56 ++++++++++++ scripts/smoke.sh | 6 +- scripts/test-herdr-windows-full-pane.ps1 | 104 ++++++++++++++++++++++- scripts/test-windows-full-manifest.py | 64 +++++++++----- windows-full/herdr-plugin.toml | 3 +- 8 files changed, 237 insertions(+), 32 deletions(-) create mode 100644 links/open-link.ps1 diff --git a/README.md b/README.md index 4712586..b02bd30 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,8 @@ description = "review this pane's recent output" Annotate does not capture Ctrl-click on links by default, so `file://…md` links open the way Herdr normally opens them. To send Ctrl-clicked `file://` Markdown links (`.md`, `.markdown`, -`.mdx`) to Annotate instead, install the Links add-on next to the Full install (macOS and Linux): +`.mdx`) to Annotate instead, install the Links add-on next to the Full install (on Windows, next +to Windows Full): ```sh herdr plugin install plannotator/herdr-annotate/links @@ -241,7 +242,8 @@ To turn it off again: herdr plugin uninstall annotate-links ``` -The add-on has no binary of its own; it opens the file with the Full plugin's plannotator-tui. +The add-on has no binary of its own; it opens the file with the Full (or Windows Full) plugin's +plannotator-tui. If neither is installed, Ctrl-click shows a notification saying which one to install. > **Upgrading from 0.8.0 or earlier:** up to plugin 0.8.0, the Full install captured these clicks > itself. From 0.9.0 it does not diff --git a/docs/windows-full-acceptance.md b/docs/windows-full-acceptance.md index 957bb40..5da60f2 100644 --- a/docs/windows-full-acceptance.md +++ b/docs/windows-full-acceptance.md @@ -50,7 +50,8 @@ requirements, not completed verification. Every box starts unchecked. effective on Windows; no inherited Unix gate may disable them. Since plugin 0.9.0 ([#69](https://github.com/plannotator/herdr-annotate/issues/69)) the variant declares no `[[link_handlers]]` and no `open-link` action: Ctrl-click - capture is opt-in through the macOS/Linux `links/` add-on. + capture is opt-in through the `links/` add-on, whose `open-link-windows` action + runs `links/open-link.ps1` against this variant's staged plannotator-tui. - [ ] Reuse the shared native runtime with paths valid from `windows-full/` (as `lite/` does with `../bin/`). Verify those paths from an installed checkout, not just the repository root. Preserve shared annotation state and archive diff --git a/links/herdr-plugin.toml b/links/herdr-plugin.toml index 201cf16..8ad6240 100644 --- a/links/herdr-plugin.toml +++ b/links/herdr-plugin.toml @@ -8,26 +8,47 @@ # Annotate, uninstall it to give them back to Herdr's default opener. # # This add-on ships no binary. Its action finds the installed `annotate` plugin and runs that -# plugin's plannotator-tui exactly as the old built-in open-link action did, so it needs the -# Full install (plannotator/herdr-annotate) on macOS or Linux. +# plugin's plannotator-tui exactly as the old built-in open-link action did, so it needs a Full +# install: plannotator/herdr-annotate on macOS or Linux, plannotator/herdr-annotate/windows-full +# on Windows. Herdr rejects duplicate ids, so each platform family has its own action and +# handler; Herdr skips a handler whose platforms exclude the current one. id = "annotate-links" name = "Annotate Links" version = "0.9.0" min_herdr_version = "0.8.0" description = "Open Ctrl-clicked file:// Markdown links in Annotate (needs the Annotate Full plugin)." -platforms = ["macos", "linux"] +platforms = ["linux", "macos", "windows"] [[actions]] id = "open-link" title = "Annotate this file" description = "Open a Ctrl-clicked Markdown file in plannotator-tui." contexts = ["pane"] +platforms = ["macos", "linux"] command = ["bash", "open-link.sh"] +# The same invocation idiom as the Windows build hooks: powershell.exe comes from PATH, never +# from the plugin root, and the script path is relative to the root Herdr runs actions from. +[[actions]] +id = "open-link-windows" +title = "Annotate this file" +description = "Open a Ctrl-clicked Markdown file in plannotator-tui." +contexts = ["pane"] +platforms = ["windows"] +command = ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", "open-link.ps1"] + # Anchored on the scheme so web links never match. [[link_handlers]] id = "markdown-file" title = "Annotate this file" pattern = "^file://.*\\.(md|markdown|mdx)$" action = "open-link" +platforms = ["macos", "linux"] + +[[link_handlers]] +id = "markdown-file-windows" +title = "Annotate this file" +pattern = "^file://.*\\.(md|markdown|mdx)$" +action = "open-link-windows" +platforms = ["windows"] diff --git a/links/open-link.ps1 b/links/open-link.ps1 new file mode 100644 index 0000000..2d9a125 --- /dev/null +++ b/links/open-link.ps1 @@ -0,0 +1,56 @@ +# Open a Ctrl-clicked file:// Markdown link in the installed Annotate plugin, on Windows. +# +# The Windows counterpart of open-link.sh. Herdr runs this with the clicked URL and the focused +# pane in HERDR_PLUGIN_CONTEXT_JSON. It finds the `annotate` plugin's root, then runs that +# plugin's plannotator-tui with the same arguments, working directory and plugin id the old +# built-in open-link action of Windows Full used. The review pane therefore opens inside +# `annotate`, and feedback goes to the focused agent. + +# Continue, not Stop: Windows PowerShell turns any stderr from a native command into a +# terminating error under Stop. Every outcome below is checked explicitly instead. +$ErrorActionPreference = "Continue" + +$herdr = if ([string]::IsNullOrEmpty($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH } +$installHint = "herdr plugin install plannotator/herdr-annotate/windows-full" + +function Fail { + param([string]$Title, [string]$Body) + [Console]::Error.WriteLine($Body) + try { & $herdr notification show $Title --body $Body *> $null } catch { } + exit 1 +} + +# Herdr writes UTF-8; Windows PowerShell would otherwise decode it with the OEM code page. +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$plugin = $null +try { + $listing = (& $herdr plugin list --plugin annotate --json 2> $null) -join "`n" + $plugin = @(($listing | ConvertFrom-Json).result.plugins) | + Where-Object { $null -ne $_ -and $_.plugin_id -ceq "annotate" } | + Select-Object -First 1 +} catch { + $plugin = $null +} + +if ($null -eq $plugin -or [string]::IsNullOrEmpty($plugin.plugin_root)) { + Fail "Annotate: plugin not installed" "Ctrl-click needs the Annotate plugin. Install it with: $installHint" +} +if (-not $plugin.enabled) { + Fail "Annotate: plugin disabled" "Ctrl-click needs the Annotate plugin enabled. Enable it with: herdr plugin enable annotate" +} + +# .NET rather than Join-Path/Test-Path: the root may be an extended-length \\?\ path. +$root = [string]$plugin.plugin_root +$tui = [System.IO.Path]::Combine($root, "bin", "plannotator-tui.exe") +if (-not [System.IO.File]::Exists($tui)) { + Fail "Annotate: document review unavailable" "Ctrl-click needs Annotate Windows Full, which includes plannotator-tui. Install it with: $installHint" +} + +$env:HERDR_PLUGIN_ID = "annotate" +$env:HERDR_PLUGIN_ROOT = $root +# Herdr ran the old action from the plugin root; do the same, best effort. +try { [System.IO.Directory]::SetCurrentDirectory($root) } catch { } +try { Set-Location -LiteralPath $root } catch { } +& $tui herdr open +exit $LASTEXITCODE diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 629bc88..ed332e0 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -148,9 +148,9 @@ if herdr plugin list --plugin annotate-links --json | grep -q '"plugin_id":"anno echo " note annotate-links is already installed here; skipping so it is left untouched" else herdr plugin install "$spec/links" --yes >/dev/null - check "add-on link handlers" "$(link_handlers annotate-links)" "markdown-file" - check "add-on actions" "$(herdr plugin action list --plugin annotate-links | python3 -c " -import json,sys; print(','.join(sorted(a['action_id'] for a in json.load(sys.stdin)['result']['actions'])))")" "open-link" + check "add-on link handlers" "$(link_handlers annotate-links)" "markdown-file,markdown-file-windows" + check "add-on Unix action" "$(herdr plugin action list --plugin annotate-links | python3 -c " +import json,sys; print('open-link' if any(a['action_id']=='open-link' for a in json.load(sys.stdin)['result']['actions']) else 'missing')")" "open-link" herdr plugin uninstall annotate-links >/dev/null check "add-on removed" "$(link_handlers annotate-links)" "none" fi diff --git a/scripts/test-herdr-windows-full-pane.ps1 b/scripts/test-herdr-windows-full-pane.ps1 index 9c84388..68a9eae 100644 --- a/scripts/test-herdr-windows-full-pane.ps1 +++ b/scripts/test-herdr-windows-full-pane.ps1 @@ -3,7 +3,8 @@ # # What this proves that no manifest or link test can: plannotator-tui starts as the pane # process, renders a document from a review folder outside the checkout, quits on `q` with -# status zero, and leaves nothing running. The fixture marker is generated per run, so a +# status zero, and leaves nothing running. It then runs the opt-in links/ add-on's Windows +# action the way a Ctrl-click does, and checks that it opens the clicked file in Annotate. The fixture marker is generated per run, so a # stale buffer cannot satisfy the render assertion. # # The binary under test is fetched by the variant's own build command from the real release, @@ -52,7 +53,16 @@ $isolatedNames = @( "HERDR_CLIENT_SOCKET_PATH", "PLANNOTATOR_TUI_BIN", "PLANNOTATOR_TUI_RELEASE_BASE", - "HERDR_ANNOTATE_BIN" + "HERDR_ANNOTATE_BIN", + "HERDR_BIN_PATH", + "HERDR_ENV", + "HERDR_PANE_ID", + "HERDR_PLUGIN_ID", + "HERDR_PLUGIN_ROOT", + "HERDR_PLUGIN_CONTEXT_JSON", + "HERDR_PLUGIN_ACTION_ID", + "HERDR_PLUGIN_LINK_HANDLER_ID", + "HERDR_PLUGIN_CLICKED_URL" ) $oldEnvironment = @{} foreach ($name in $isolatedNames) { @@ -110,6 +120,7 @@ try { [System.IO.Directory]::CreateDirectory($review) | Out-Null [System.IO.Directory]::CreateDirectory((Join-Path $variantRoot "scripts")) | Out-Null [System.IO.Directory]::CreateDirectory((Join-Path $checkout "scripts")) | Out-Null + [System.IO.Directory]::CreateDirectory((Join-Path $checkout "links")) | Out-Null Set-Content -LiteralPath (Join-Path $review $fixture) -Encoding utf8 -Value @( "# $marker", "", @@ -125,7 +136,9 @@ try { @("scripts\fetch-plannotator-tui.ps1", (Join-Path $checkout "scripts\fetch-plannotator-tui.ps1")), @("scripts\fetch-herdr-annotate.ps1", (Join-Path $checkout "scripts\fetch-herdr-annotate.ps1")), @("windows-full\herdr-plugin.toml", (Join-Path $variantRoot "herdr-plugin.toml")), - @("windows-full\scripts\fetch-plannotator-tui.ps1", (Join-Path $variantRoot "scripts\fetch-plannotator-tui.ps1")) + @("windows-full\scripts\fetch-plannotator-tui.ps1", (Join-Path $variantRoot "scripts\fetch-plannotator-tui.ps1")), + @("links\herdr-plugin.toml", (Join-Path $checkout "links\herdr-plugin.toml")), + @("links\open-link.ps1", (Join-Path $checkout "links\open-link.ps1")) )) { [System.IO.File]::Copy((Join-Path $repositoryRoot $pair[0]), $pair[1], $true) } @@ -288,6 +301,91 @@ try { Assert-True ($exits.Count -eq 1) "the isolated server logged no pane exit" Assert-True ($exits[0] -match "code: 0\b") "the review pane exited abnormally: $($exits[0])" Write-Output "[$Label] q closed the review pane with status zero and left no process behind" + + # The opt-in links/ add-on (#69). Herdr must accept it on Windows and route a Markdown + # file:// link to its Windows action; the action is then run exactly as Herdr runs it -- + # powershell.exe from PATH, from the add-on root, with the link-click environment -- and has + # to open the clicked file in Annotate's own doc pane. + $linksRoot = Join-Path $checkout "links" + $linkedLinks = Invoke-Herdr -Arguments @("plugin", "link", $linksRoot, "--enabled") | ConvertFrom-Json + Assert-True ($linkedLinks.result.type -ceq "plugin_linked") "the isolated Herdr did not link the links add-on" + $addOn = $linkedLinks.result.plugin + Assert-True ($addOn.plugin_id -ceq "annotate-links") "the links add-on id is $($addOn.plugin_id)" + $windowsHandler = @($addOn.link_handlers | Where-Object { $_.id -ceq "markdown-file-windows" }) + Assert-True ($windowsHandler.Count -eq 1) "the links add-on lists no markdown-file-windows handler" + Assert-True ($windowsHandler[0].action -ceq "open-link-windows") "markdown-file-windows points elsewhere" + $windowsAction = @($addOn.actions | Where-Object { $_.id -ceq "open-link-windows" }) + Assert-True ($windowsAction.Count -eq 1) "the links add-on lists no open-link-windows action" + $annotateListing = Invoke-Herdr -Arguments @("plugin", "list", "--plugin", "annotate", "--json") | ConvertFrom-Json + $annotatePlugin = @($annotateListing.result.plugins)[0] + $annotateHandlers = $annotatePlugin.PSObject.Properties['link_handlers'] + Assert-True ($null -eq $annotateHandlers -or @($annotateHandlers.Value).Count -eq 0) ` + "Windows Full still declares a link handler" + + $clicked = ([System.Uri]::new((Join-Path $review $fixture))).AbsoluteUri + Assert-True ($clicked -match '^file://.*\.(md|markdown|mdx)$') "the click fixture $clicked does not match the handler" + $panesBefore = @((Invoke-Herdr -Arguments @("pane", "list") | ConvertFrom-Json).result.panes | ForEach-Object { $_.pane_id }) + $env:HERDR_BIN_PATH = $script:resolvedHerdr + $env:HERDR_ENV = "1" + $env:HERDR_PANE_ID = $originPane + $env:HERDR_PLUGIN_ID = "annotate-links" + $env:HERDR_PLUGIN_ROOT = $linksRoot + $env:HERDR_PLUGIN_ACTION_ID = "open-link-windows" + $env:HERDR_PLUGIN_LINK_HANDLER_ID = "markdown-file-windows" + $env:HERDR_PLUGIN_CLICKED_URL = $clicked + $env:HERDR_PLUGIN_CONTEXT_JSON = (@{ + workspace_cwd = $review + focused_pane_id = $originPane + focused_pane_cwd = $review + invocation_source = "link_click" + clicked_url = $clicked + link_handler_id = "markdown-file-windows" + } | ConvertTo-Json -Compress) + Push-Location -LiteralPath $linksRoot + try { + $actionOutput = (& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ` + -File "open-link.ps1" *>&1 | Out-String).Trim() + $actionExit = $LASTEXITCODE + } finally { + Pop-Location + } + Assert-True ($actionExit -eq 0) "the links action exited $actionExit`n$actionOutput" + + $deadline = (Get-Date).AddSeconds(20) + do { + Start-Sleep -Milliseconds 500 + $newPanes = @((Invoke-Herdr -Arguments @("pane", "list") | ConvertFrom-Json).result.panes | + Where-Object { $panesBefore -notcontains $_.pane_id }) + } while ($newPanes.Count -eq 0 -and (Get-Date) -lt $deadline) + Assert-True ($newPanes.Count -eq 1) "the links action opened $($newPanes.Count) panes`n$actionOutput" + $clickPane = $newPanes[0].pane_id + $waited = Invoke-Herdr -Arguments @( + "pane", "wait-output", $clickPane, "--match", $marker, "--timeout", "30000" + ) + Assert-True ($LASTEXITCODE -eq 0) "the clicked file never rendered $marker`: $waited" + Write-Output "[$Label] links add-on opened the clicked file in Annotate ($clicked)" + + Invoke-Herdr -Arguments @("pane", "send-keys", $clickPane, "q") | Out-Null + $deadline = (Get-Date).AddSeconds(20) + do { + Start-Sleep -Milliseconds 500 + $panes = Invoke-Herdr -Arguments @("pane", "list") + } while ($panes -match [regex]::Escape($clickPane) -and (Get-Date) -lt $deadline) + Assert-True ($panes -notmatch [regex]::Escape($clickPane)) "the clicked-file pane stayed open after q" + + # With Annotate disabled the action must refuse and say how to fix it, not fail silently. + Invoke-Herdr -Arguments @("plugin", "disable", "annotate") | Out-Null + Push-Location -LiteralPath $linksRoot + try { + $refusal = (& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ` + -File "open-link.ps1" *>&1 | Out-String).Trim() + $refusalExit = $LASTEXITCODE + } finally { + Pop-Location + } + Assert-True ($refusalExit -eq 1) "the links action exited $refusalExit with Annotate disabled" + Assert-True ($refusal -match "herdr plugin enable annotate") "the disabled-plugin hint is missing: $refusal" + Write-Output "[$Label] links add-on refused with Annotate disabled: $refusal" } finally { if ($null -ne $script:resolvedHerdr) { & $script:resolvedHerdr server stop *>&1 | Out-Null diff --git a/scripts/test-windows-full-manifest.py b/scripts/test-windows-full-manifest.py index 2d340d1..82c33c0 100644 --- a/scripts/test-windows-full-manifest.py +++ b/scripts/test-windows-full-manifest.py @@ -77,8 +77,25 @@ } # Ctrl-click capture is opt-in (#69): only the links/ add-on may declare a link handler. LINKS_ID = "annotate-links" -LINKS_ACTION = ["bash", "open-link.sh"] LINKS_PATTERN = "^file://.*\\.(md|markdown|mdx)$" +# handler id -> (action id, action argv, script, platforms) +LINKS_ROUTES = { + "markdown-file": ("open-link", ["bash", "open-link.sh"], "open-link.sh", FULL_PLATFORMS), + "markdown-file-windows": ( + "open-link-windows", + [ + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + "open-link.ps1", + ], + "open-link.ps1", + {"windows"}, + ), +} DEVELOPMENT_BUILDS = [ ["cargo", "build", "--release", "--manifest-path", "../Cargo.toml"], ["bash", "stage-plannotator-tui.sh"], @@ -243,14 +260,14 @@ def check_no_link_capture(path: Path, manifest: dict[str, object]) -> None: def check_links(path: Path, root_path: Path) -> None: - """The opt-in add-on: one handler, one action, no binary of its own.""" + """The opt-in add-on: one handler and action per platform family, no binary of its own.""" manifest = load(path) root = load(root_path) if manifest.get("id") != LINKS_ID: fail(path, f"id must be {LINKS_ID!r}, found {manifest.get('id')!r}") if manifest.get("version") != root.get("version"): fail(path, f"version {manifest.get('version')!r} differs from the root manifest") - if platforms(path, manifest, {}) != FULL_PLATFORMS: + if platforms(path, manifest, {}) != {"macos", "linux", "windows"}: fail(path, f"top-level platforms are {platforms(path, manifest, {})!r}") if builds(path, manifest): fail(path, "the add-on must not build or fetch a binary of its own") @@ -259,23 +276,32 @@ def check_links(path: Path, root_path: Path) -> None: fail(path, f"the add-on declares [[{table}]]") actions = manifest.get("actions", []) - if not isinstance(actions, list) or len(actions) != 1: - fail(path, "expected exactly one action") - action = entry(path, manifest, "actions", "open-link") - if action.get("command") != LINKS_ACTION: - fail(path, f"unexpected actions.open-link argv: {action.get('command')!r}") - script = path.parent / LINKS_ACTION[1] - if not script.is_file(): - fail(path, f"action script {script} does not exist") - handlers = manifest.get("link_handlers", []) - if not isinstance(handlers, list) or len(handlers) != 1: - fail(path, "expected exactly one link handler") - handler = entry(path, manifest, "link_handlers", "markdown-file") - if handler.get("pattern") != LINKS_PATTERN: - fail(path, f"unexpected markdown-file pattern: {handler.get('pattern')!r}") - if handler.get("action") != "open-link": - fail(path, f"markdown-file points to {handler.get('action')!r}") + if not isinstance(actions, list) or len(actions) != len(LINKS_ROUTES): + fail(path, f"expected exactly {len(LINKS_ROUTES)} actions") + if not isinstance(handlers, list) or len(handlers) != len(LINKS_ROUTES): + fail(path, f"expected exactly {len(LINKS_ROUTES)} link handlers") + covered: set[str] = set() + for handler_id, (action_id, argv, script, expected) in LINKS_ROUTES.items(): + handler = entry(path, manifest, "link_handlers", handler_id) + if handler.get("pattern") != LINKS_PATTERN: + fail(path, f"unexpected {handler_id} pattern: {handler.get('pattern')!r}") + if handler.get("action") != action_id: + fail(path, f"{handler_id} points to {handler.get('action')!r}") + if platforms(path, manifest, handler) != expected: + fail(path, f"{handler_id} platforms are {platforms(path, manifest, handler)!r}") + action = entry(path, manifest, "actions", action_id) + if action.get("command") != argv: + fail(path, f"unexpected actions.{action_id} argv: {action.get('command')!r}") + if platforms(path, manifest, action) != expected: + fail(path, f"actions.{action_id} platforms are {platforms(path, manifest, action)!r}") + if not (path.parent / script).is_file(): + fail(path, f"action script {path.parent / script} does not exist") + if covered & expected: + fail(path, f"{handler_id} overlaps another handler's platforms") + covered |= expected + if covered != {"macos", "linux", "windows"}: + fail(path, f"handlers cover {covered!r}, not every platform") def surface(path: Path, manifest: dict[str, object], table: str) -> set[tuple[object, ...]]: diff --git a/windows-full/herdr-plugin.toml b/windows-full/herdr-plugin.toml index ad058ba..54413d0 100644 --- a/windows-full/herdr-plugin.toml +++ b/windows-full/herdr-plugin.toml @@ -104,7 +104,8 @@ command = ["./bin/plannotator-tui.exe", "herdr", "pane"] # The review actions run the TUI directly, which reads Herdr's invocation context (the focused # pane's folder and agent) and sends the review back to that agent. Ctrl-click on a -# file:// Markdown link is not captured, so the click keeps Herdr's default behavior. +# file:// Markdown link is not captured here; the opt-in links/ add-on declares that handler +# and runs this variant's plannotator-tui, so a plain install keeps Herdr's default behavior. [[actions]] id = "open" title = "Annotate: open here"