diff --git a/.changeset/cursor-install-mode-hooks-registration.md b/.changeset/cursor-install-mode-hooks-registration.md new file mode 100644 index 000000000..d5b5484e0 --- /dev/null +++ b/.changeset/cursor-install-mode-hooks-registration.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Add `--mode local|marketplace` to `agent-bundle install cursor`, the generated installer bin, and the emitted `install.mjs`: marketplace mode stages a committed local `.cursor-plugin/marketplace.json` repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Cursor Customize import step, while local mode keeps the safe copy into `~/.cursor/plugins/local/`. Doctor now proves Cursor hook registration from the plugin manifest (`AB7322`), warns about duplicate `~/.cursor/hooks.json` delivery (`AB7323`), and tracks staged marketplaces to imported (`AB7324`). Documents that plugin-scoped hooks fire without user-level registration, closing #407 (#414). diff --git a/docs/audits/2026-09-03-cursor-plugin-hooks-registration.md b/docs/audits/2026-09-03-cursor-plugin-hooks-registration.md new file mode 100644 index 000000000..d404e80a6 --- /dev/null +++ b/docs/audits/2026-09-03-cursor-plugin-hooks-registration.md @@ -0,0 +1,236 @@ +# Cursor plugin hook registration and marketplace-style install — #407 evidence + +Date: 2026-09-03. Observed desktop client: Cursor 3.18.25 stable (Linux x64, +`/usr/share/cursor`, workbench commit `280eca2911f1774689696e5f1efa5a4f97a87af3`), +run as an isolated instance (dedicated `HOME=/tmp/cursor-407/iso/home`, +dedicated `--user-data-dir`, Xvfb display, workspace `/tmp/cursor-407/iso/ws`). +Observed Agent CLI: `cursor-agent 2026.08.31-4057e58`. Issue #407 was filed +against Cursor **3.16.21** running from `~/.cursor-server` (the remote-server +build); that build is not installed on this machine and could not be +re-observed. + +This note is the source of truth behind the `install cursor --mode` change, +the Doctor `AB7322`–`AB7324` findings, and the #407 closing comment. Every +claim about Cursor cites the live documentation retrieved on 2026-09-02/03 or +a recorded observation. + +## 1. What the live Cursor documentation says + +| Claim | Source | Quote | +| --- | --- | --- | +| Hooks are a Cursor Plugin component, not an Agent Plugins one | https://cursor.com/docs/plugins | "**Hooks** \| Cursor Plugins \| Automation scripts triggered by events" and "**Cursor Plugins**: plugins with a `.cursor-plugin/plugin.json` manifest, which add rules, agents, commands, hooks, and variables" | +| Plugins are one of three hook sources | https://cursor.com/docs/hooks | "Define hooks in `hooks.json` files at the project or user level, or install them through plugins from **Customize**." | +| User hooks run from `~/.cursor/`, project hooks from the project root; plugin working directory is not documented | https://cursor.com/docs/hooks#configuration | "**Project hooks** (`.cursor/hooks.json` in a repository): Run from the **project root**" / "**User hooks** (`~/.cursor/hooks.json`): Run from `~/.cursor/`" (no plugin row) | +| Hook precedence and merge | https://cursor.com/docs/hooks#configuration | "All matching hooks from every source run; when responses conflict, higher-priority sources take precedence during merge" / "Priority order (highest to lowest): Enterprise → Team → Project → User" | +| `${CURSOR_PLUGIN_ROOT}` is expanded in plugin-delivered commands | https://cursor.com/docs/hooks (beforeMCPExecution) | "`command` is the launch string from the server's config and can differ between installs: relative paths, `${CURSOR_PLUGIN_ROOT}` expansion, or an HTTP transport" | +| Cloud agents ignore user-level hooks | https://cursor.com/docs/hooks | "User-level hooks (`~/.cursor/hooks.json`) are not available in cloud agents." | +| Local plugin test location and the load condition | https://cursor.com/docs/plugins#test-plugins-locally | "Before you publish, put either plugin format in `~/.cursor/plugins/local`" … "After a reload, Cursor discovers plugins in this folder if local plugin imports are allowed." … "If a marketplace plugin with the same name is already installed, that install takes precedence over the local copy." | +| Marketplace plugins are Git repositories; the install surface is Customize | https://cursor.com/docs/plugins#installing-plugins | "Plugins are distributed as Git repositories and submitted through the Cursor team." … "1. Open **Customize** in the sidebar. 2. Find the plugin you want to use. 3. Select **Install** and choose a project or user scope." | +| Team marketplaces are a paid feature and import from GitHub | https://cursor.com/docs/plugins#add-a-team-marketplace | "Team marketplaces are available on Teams and Enterprise plans." … "3. Follow the instructions to create a marketplace from scratch, or use "Import from Repo" if importing from GitHub." | +| Install modes | https://cursor.com/docs/plugins#plugin-installation-modes | "**Default Off**: Developers can find the plugin and choose whether to install it." / "**Default On**: The plugin is installed by default, but developers can opt out." / "**Required**: The plugin is always installed and cannot be uninstalled." | +| Multi-plugin repository manifest | https://cursor.com/docs/reference/plugins#cursor-multi-plugin-repositories | "A single Git repository can contain multiple plugins using a **marketplace manifest**. Place it at `.cursor-plugin/marketplace.json` in the repository root." Required fields: `name` "(kebab-case)", `owner` "`name` (required), `email` (optional)", `plugins` "(max 500)"; resolution: "The parser looks for `my-plugin/.cursor-plugin/plugin.json`". | +| The only documented install deeplink is for MCP servers | https://cursor.com/docs/plugins#mcp-apps-deeplinks, https://cursor.com/docs/reference/deeplinks | "`cursor://anysphere.cursor-deeplink/mcp/install?name=$NAME&config=$BASE64_ENCODED_CONFIG`"; the deeplinks reference lists only `prompt`, `command`, and `rule` deeplinks — no plugin or marketplace install deeplink exists. | +| Agent CLI loads local plugin directories and now runs their hooks | https://cursor.com/docs/cli/reference/parameters, https://cursor.com/docs/cli/changelog (August 11, 2026 release) | "`--plugin-dir ` \| Load a local plugin directory (can be specified multiple times)" and "**Plugin hooks run from installed plugins.** Hooks defined by installed plugins, including those loaded with `--plugin-dir`, now execute and refresh when plugins reload." | +| Agent CLI marketplace verbs | `cursor-agent plugin marketplace --help` (2026.08.31-4057e58) | "add [options] Add a plugin marketplace from a git repository URL" / "list" / "remove" / "update"; there is no `plugin install` verb. | +| Desktop changelog | https://cursor.com/changelog (rendered 2026-09-03) | The rendered page lists Aug 13 – Sep 2, 2026 entries (Builds, Origin, Cloud Agents, Self-hosted machines) and contains no plugin or hooks entry; older entries are not server-rendered and were not reachable through the fetch tool. | + +The CLI changelog entry dated **August 11, 2026** ("Plugin hooks run from +installed plugins … now execute") is the one dated vendor statement that +plugin-delivered hooks had been a Cursor-side gap that was fixed after the +3.16 timeframe reported in #407. + +## 2. Ground truth: tracedecay (local, works) versus the emitted pack + +Both plugins were read-only inputs. `~/.cursor/hooks.json` does not exist on +this machine. + +| Aspect | `~/.cursor/plugins/local/tracedecay` (known working) | `cursor` target emitted by agent-bundle (`/tmp/cursor-407/probe-pack`, built from `examples/hooks-and-scripts`-style config) | Delta | +| --- | --- | --- | --- | +| Manifest | `.cursor-plugin/plugin.json` with `"hooks": "hooks/hooks.json"`, `"mcpServers": "mcp.json"`, `rules`, `skills`, `agents`, `commands`, `version: 0.1.0-beta.37` | `.cursor-plugin/plugin.json` with `"hooks": "./hooks/hooks.json"`, `name`, `displayName`, `description`, `version` | `./` prefix only; both resolve to `hooks/hooks.json` | +| Hooks envelope | `{ "version": 1, "hooks": { … } }` | `{ "version": 1, "hooks": { … } }` | none | +| Events | `afterFileEdit`, `afterShellExecution`, `postToolUse`, `preCompact`, `sessionEnd`, `sessionStart`, `stop`, `workspaceOpen` | `preToolUse`, `postToolUse`, `sessionStart`, `stop` | subset; same names | +| Command form | absolute binary: `'/home/zack/.local/bin/tracedecay' hook-cursor-post-tool-use` | `node "${CURSOR_PLUGIN_ROOT}/hooks/after-tool-log-6bd3fb25.mjs"` | ours relies on `${CURSOR_PLUGIN_ROOT}` expansion | +| Matchers | none | `"matcher": "^Shell$"` on pre/postToolUse | ours adds matchers | +| `timeout` | 5 / 30 s | none | none needed | +| `loop_limit` | none | none | none | +| Script executable bit / shebang | n/a (binary) | `.mjs` files, mode 0664, no shebang, invoked through `node` | not required because the command names the interpreter | +| MCP | `mcp.json` `{ command: "/home/zack/.local/bin/tracedecay", args: ["serve", "--path", "${workspaceFolder}"], type: "stdio" }` | `mcp.json` with `${CURSOR_PLUGIN_ROOT}` in args/env | equivalent | + +Conclusion: there is no structural difference that Cursor's documented or +observed loader treats differently. Both shapes fired on 3.18.25 (section 3), +so the emitter was **not** changed to a different hooks shape. The +`hooks/hooks.json` document, `version: 1`, `${CURSOR_PLUGIN_ROOT}` commands, +and `^Shell$` matchers stay the primary emitted contract. + +## 3. Evidence matrix — isolated Cursor 3.18.25 desktop instance + +Setup (`/tmp/cursor-407/iso-setup.sh`): the same marker-logging script was +registered at three locations for the same events — `~/.cursor/hooks.json` +(user), `/.cursor/hooks.json` (project, trusted workspace), and a +plugin-scoped `~/.cursor/plugins/local/hooks-probe/hooks/hooks.json` using +`"${CURSOR_PLUGIN_ROOT}/hooks/hook-log.sh"` — plus the agent-bundle emitted +pack installed as `~/.cursor/plugins/local/ab-hooks-probe`. An agent chat in +the Agents window ran a shell tool call (`cat probe.txt`) and finished; markers +were appended to `/tmp/cursor-407/iso/markers/events.jsonl` (51 lines). + +| Format | Hook location | Surface | Event | Fired? | Recorded `cwd` | +| --- | --- | --- | --- | --- | --- | +| user `hooks.json` | `~/.cursor/hooks.json` | IDE Agents window | workspaceOpen | yes (1) | `~/.cursor` | +| user `hooks.json` | `~/.cursor/hooks.json` | IDE Agents window | preToolUse / beforeShellExecution / afterShellExecution / postToolUse | yes (3 each) | `~/.cursor` | +| user `hooks.json` | `~/.cursor/hooks.json` | IDE Agents window | stop | yes (2) | `~/.cursor` | +| project `hooks.json` | `/.cursor/hooks.json` | IDE Agents window | preToolUse / beforeShellExecution / afterShellExecution / postToolUse | yes (3 each) | `` | +| project `hooks.json` | `/.cursor/hooks.json` | IDE Agents window | stop | yes (2) | `` | +| Cursor Plugin (hand-written probe) | `~/.cursor/plugins/local/hooks-probe` manifest hooks | IDE Agents window | preToolUse / beforeShellExecution / afterShellExecution / postToolUse | yes (3 each) | plugin root | +| Cursor Plugin (hand-written probe) | plugin manifest hooks | IDE Agents window | stop | yes (2) | `` | +| **Cursor Plugin (agent-bundle emitted `cursor` target)** | `~/.cursor/plugins/local/ab-hooks-probe` manifest hooks, `node "${CURSOR_PLUGIN_ROOT}/hooks/*.mjs"` | IDE Agents window | preToolUse (`^Shell$`) | **yes** (`CURSOR_PLUGIN_ROOT` = plugin root) | plugin root | +| **Cursor Plugin (agent-bundle emitted)** | same | IDE Agents window | postToolUse (`^Shell$`) | **yes** | plugin root | +| **Cursor Plugin (agent-bundle emitted)** | same | IDE Agents window | stop | **yes** | `` | +| Cursor Plugin (both) | plugin manifest hooks | IDE Agents window | sessionStart | not observed in this run (the chat session pre-dated the plugin reload; user/project sessionStart were also not observed in the same window) | — | +| Cursor Plugin, `tracedecay` | `~/.cursor/plugins/local/tracedecay` | host IDE (maintainer's daily instance) | afterShellExecution, postToolUse, stop, sessionStart, … | works per maintainer statement (ground truth) | — | +| Agent Plugins (`portable` target, root `plugin.json`) | n/a | any | any | no — the format has no hooks component (docs §1) | — | +| any | any | Agent CLI (`cursor-agent`) | any | not observed: the CLI required Cursor authentication unavailable to the isolated run; the vendor CLI changelog (Aug 11, 2026) states plugin hooks now execute in the CLI | — | + +Read-out: on Cursor 3.18.25 the emitted pack's plugin-scoped hooks fire for +tool events and `stop` exactly like the hand-written probe and tracedecay, +without any `~/.cursor/hooks.json` entry. Plugin hook commands execute with +`cwd` = the plugin root and `${CURSOR_PLUGIN_ROOT}` substituted, so +`${CURSOR_PLUGIN_ROOT}`-relative commands are the correct form (and the one +also used by official marketplace plugins such as `cursor-public/continual-learning`: +`"command": "bun run ${CURSOR_PLUGIN_ROOT}/hooks/continual-learning-stop.ts"`). + +### Root-cause assessment for #407 + +The report is not reproducible on the current stable desktop build. The +evidence points to a Cursor-side, build-specific gap rather than the emitted +shape: + +- #407 observed the gap on **3.16.21 in `~/.cursor-server`** (remote server + build) and saw the same gap on tracedecay ("only `sessionStart` … every other + declared event: 0"), which today fires normally on the same machine. +- Cursor's own CLI changelog (August 11, 2026) records "Plugin hooks run from + installed plugins … now execute", i.e. plugin hook execution was fixed + vendor-side in that window. +- In the 3.18.25 workbench bundle, plugin hooks are gated by + `thirdPartyExtensibilityEnabled` and the `enable_cc_plugin_import` flag + (`/tmp/cursor-407/glass-pluginhooks.txt`); both default to enabled on this + build. A build or account where either was off would load the plugin (rules, + skills, MCP) yet deliver no plugin hooks — matching the #407 symptom. + +Because the emitted shape is correct and now proven, the framework does **not** +write `~/.cursor/hooks.json`. Doing so would run every hook twice on fixed +builds ("All matching hooks from every source run"), is unavailable to cloud +agents, and would have to hard-code absolute plugin paths. Instead Doctor +proves registration (`AB7322`) and flags duplicate delivery (`AB7323`) so the +silent-failure mode #407 describes is detectable. + +## 4. Marketplace layout study (read-only, this machine) + +Sources: `~/.cursor/plugins/marketplaces/**`, `~/.cursor/plugins/cache/**`, +and a copied `state.vscdb` from the real user-data-dir. + +- Marketplace repositories are cloned under + `~/.cursor/plugins/marketplaces/////` + (e.g. `github.com/anthropics/claude-plugins-official/1a2f18b0…`); a + `_staging` directory sits alongside. +- Installed plugin bytes live under + `~/.cursor/plugins/cache////` with an + empty `.cache-complete` receipt next to `.cursor-plugin/`, e.g. + `cache/cursor-public/continual-learning/45c66fde…/.cache-complete`. The + agent-worker bundle confirms the layout constants (`plugins/cache`, + `.cache-complete`, path = `[root, sanitize(marketplaceSlug), sanitize(pluginId), version]`). +- The install registry is not a file: `state.vscdb` holds + `cursor.plugins.installedIds.no-team|` and + `…|no-workspace` values containing **server-assigned numeric plugin ids** + (`[{"id":"677","sources":["user"]}, …]`). Which plugins are enabled is + resolved against the account's dashboard state; the local cache is a + projection of that server decision. There is no local manifest or receipt + that can be written to make Cursor treat a directory as marketplace-installed. +- Official plugins on this machine use exactly the emitted shape: + `.cursor-plugin/plugin.json` with `"hooks": "./hooks/hooks.json"` and + `${CURSOR_PLUGIN_ROOT}` commands. + +## 5. Install strategies evaluated (maintainer's preference order) + +| # | Strategy | Result | +| --- | --- | --- | +| 1 | Official deeplink or CLI that installs a plugin from a marketplace/Git URL | No plugin/marketplace install deeplink is documented (§1). `cursor-agent plugin marketplace add ` requires a **hosted** Git URL and CLI authentication: `add /tmp/cursor-407/mkt-repo` and `add file:///tmp/cursor-407/mkt-repo` both failed with `Failed to resolve git ref "HEAD" for https:///tmp/cursor-407.git … Could not resolve host: tmp` (`/tmp/cursor-407/cli-mkt-add.txt`). Usable only for published packs. | +| 2 | Register the pack in a marketplace Cursor can consume | A local Git repository with `.cursor-plugin/marketplace.json` is the exact input of Customize → Plugins → **"Add Plugins from Local Repository"** (workbench `importLocalMarketplace` / `parseGitHubRepoForPluginsLocally`). The flow is a native folder picker plus an Install click and could not be driven unattended under Xvfb (GTK dialog did not accept synthesized input). This is the marketplace-style path the installer now prepares. | +| 3 | Replicate marketplace-installed state directly | Not viable: enabled-plugin identity is server-side (§4); writing `plugins/cache/**` + `.cache-complete` without a matching server id is not honoured and would be undone by the next dashboard sync. | + +Decision implemented in this change: + +- `agent-bundle install cursor --mode marketplace`, the generated installer + bin, and the emitted `install.mjs --mode marketplace` stage a committed Git + repository at `~/.cursor/agent-bundle/marketplaces/` (marketplace + `-marketplace`, plugin source `plugins/`), print the commit and + the exact Customize step, and are idempotent (`already-installed` with the + same commit). They fail closed with `AB7002` when `git` is missing, + `AB7003` for bundles without `.cursor-plugin/plugin.json` or with nested + `.git` metadata (which `git add` would record as an empty gitlink), and + `AB7005` on version/content collisions (plugin copy or the generated + `marketplace.json` differing, or a working tree that is not what HEAD + commits — checked with `git status --porcelain --untracked-files=all + --ignored=matching`). The commit is made byte-faithful: the repository is + initialised with `--object-format=sha1` (Git ≥ 2.29), `.git/info/attributes` + disables `text`/`eol`/`filter`/`ident`/`working-tree-encoding` for every + path (a bundle-shipped `.gitattributes` or global `core.autocrlf` would + otherwise rewrite bytes into the index while `git status` stayed clean), + `git add` runs with `core.autocrlf=false`, and after the commit every blob + id from `git ls-tree -r -z HEAD` is compared with `sha1("blob \0" + + bytes)` of the staged file — any drift fails closed with `AB7004` and the + staging directory is discarded. The manifest's plugin entry carries only the + pinned `marketplace.schema.json` fields (`name`, `source`, `description`; + `additionalProperties: false`) — the version lives in the copied + `plugins//.cursor-plugin/plugin.json`. Cursor then owns the install + (cached under `plugins/cache///`, managed in + Customize, not badged `Local`); Doctor treats only that marketplace's cache + partition as proof of import. +- `--mode local` (default) keeps the proven safe copy into + `~/.cursor/plugins/local/`. It remains the default because it is the + only path that loads without a UI step and its hooks are proven to fire (§3); + marketplace mode is one Customize click away from the same outcome and is + the recommended distribution shape once a pack is published. +- `agent-bundle doctor --host cursor` reports `AB7322` (hook registration + `registered`/`stale`/`missing`), `AB7323` (duplicate or unparsable + `~/.cursor/hooks.json`), and `AB7324` (staged marketplace imported or still + awaiting the Customize step; `doctor --from` resolves a marketplace-mode + bundle to its staged copy). Import proof requires Cursor's `.cache-complete` + receipt for the staged HEAD commit in that marketplace's cache partition, + and the staged repository itself is verified read-only through `git + cat-file -e ^{commit}` and `git --no-optional-locks status` (a missing + commit object or a dirty tree is `corrupt`, never `registered`). + +## 6. Automated proof added + +- `tests/install.test.ts`: marketplace staging layout and git call sequence, + idempotent re-run with real `git`, content and version collisions, + missing-`git` fail-closed, mode rejection for non-Cursor hosts, CLI + `--mode` pass-through and human output. +- `tests/doctor.test.ts`: `AB7322` registered → duplicate (`AB7323`) → + unparsable user hooks → stale → missing (quoted paths, interpreter script + operands, non-script plugin-relative arguments); plugins without hooks; + `AB7324` unregistered → registered (receipted cache match for the staged + commit) → drifted → corrupt (manifest/plugin schema, unlisted or misnamed + plugin, bad HEAD, missing commit object, dirty tree, missing `.git`), with + stray files in the staging or cache roots ignored. +- `tests/host-install-proof.test.ts` / `packed-host-install-proof.test.ts` + (`pnpm test:host-install:build`): the built fixture installed into an + isolated Cursor home now also asserts `hooksRegistration: + { events: ['sessionStart'], state: 'registered', userHooksJson: 'absent' }` + and runs the emitted `install.mjs --mode marketplace` twice (`Staged`, then + `Already staged` with the same commit) and Doctor's `AB7324` guidance. + +## 7. Product feedback recorded for Cursor + +Text filed as the #407 discrepancy note (URL: https://cursor.com/docs/hooks): +"The hooks documentation lists working directories for project, user, +enterprise, and team hooks but not for plugin-delivered hooks; observed on +3.18.25 they run from the plugin root with `${CURSOR_PLUGIN_ROOT}` expanded. +On 3.16.21 (remote server build) plugin-declared `preToolUse`/`postToolUse`/ +`stop` hooks did not execute while the same commands in `~/.cursor/hooks.json` +did; the CLI changelog of August 11, 2026 records a fix for plugin hooks in +the CLI. Please document the plugin hook working directory and the minimum +build that delivers plugin hooks for tool events, and expose a +non-interactive `cursor-agent plugin marketplace add` for local repositories +(`file://`) so packs can be marketplace-installed without the Customize +folder picker." diff --git a/docs/diagnostics.md b/docs/diagnostics.md index f9d8e6ca3..546a7657b 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -36,7 +36,7 @@ even when no error diagnostic was reported. | `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. | | `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). | | `AB7xxx` | Project preparation and development rebuilds. | -| `AB7300`–`AB7321` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, and foreign-install detection (`AB7321`; see below). | +| `AB7300`–`AB7324` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), and Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below). | | `AB8200`–`AB8209` | Workbench development runtime routes (`/api/runtime/**`): `AB8200` development runtime provider configuration, load, or lifecycle failure, `AB8201` runtime/session/run not available, `AB8202` invalid route path, `AB8203` invalid request shape, `AB8204` stale runtime generation or MCP session revision (409), `AB8205` runtime request could not be completed, `AB8206` Workbench runtime client failure, `AB8207` Agent Document decoding needs the optional `@agent-bundle/runtime` peer (503), `AB8208` stored Flight could not be decoded as an Agent Document (409), `AB8209` decoded Agent Document over the 16 MiB budget (413) or an invalid document response. | | `AB8210`–`AB8214` | Workbench semantic lifecycle replay routes (`/api/lifecycles`, `/api/lifecycles/replays`): `AB8210` invalid path, `AB8211` malformed replay request or native envelope (400, carries the shared validator message), `AB8212` replay unavailable or could not be completed, `AB8213` stale manifest binding (409; the page repairs it with refresh → explicit re-run), `AB8214` replay over the 16 MiB budget (413). | | `AB8215`–`AB8218` | Workbench read-only host discovery route. | @@ -779,3 +779,28 @@ artifact epochs. A package build failure never invalidates the artifact epoch that already committed; it surfaces as one `AB7103` **warning** on the succeeded build attempt, and the package build retries on the next invalidation. See `docs/entry-conventions.md` for the dev-watch contract. + +## Read-only Doctor Cursor hook registration and marketplace staging (`AB7322`–`AB7324`) + +Cursor delivers a plugin's hooks from its `.cursor-plugin/plugin.json` `hooks` +declaration (observed 2026-09-03 on Cursor 3.18.25; see +`docs/audits/2026-09-03-cursor-plugin-hooks-registration.md`). Doctor proves +that registration statically and never writes `~/.cursor/hooks.json`. + +| Code | Severity | Meaning | Recovery | +| --- | --- | --- | --- | +| `AB7322` | info / error | Info: an installed `.cursor-plugin/plugin.json` plugin registers plugin-scoped hooks (events and command count listed) and the script each command executes — `${CURSOR_PLUGIN_ROOT}/…` or any relative path, including an interpreter's entry operand — exists under the plugin root (`hooks.state = registered`). Error: the declared hooks file is missing (`missing`), is not a regular file or not a `{ version, hooks: { : [{ command }] } }` document, or an executed script is absent (`stale`). Documents and scripts are probed with `stat` before any read, so a FIFO cannot stall Doctor. | Reinstall the plugin from a bundle whose emitted hooks document and scripts are intact. | +| `AB7323` | warning | `~/.cursor/hooks.json` registers a command whose executed file (after leading `NAME=value` assignments, `env`, and interpreter options) points into an installed plugin directory — compared on path-component boundaries, case-folded on Windows — so Cursor would deliver that hook twice; or the file is not a valid hooks document. | Remove the plugin-pointing entries or repair the file; manifest registration alone is sufficient. | +| `AB7324` | info / warning / error | A staged marketplace repository under `~/.cursor/agent-bundle/marketplaces/` (from `install cursor --mode marketplace`) is imported by Cursor (matching plugin under `~/.cursor/plugins/cache`; info, `registered`), still awaiting the Customize "Add Plugins from Local Repository" step (warning, `unregistered`), or incomplete (error, `corrupt`: manifests missing or failing the pinned schemas, no resolvable Git HEAD, HEAD naming a commit object that does not exist, or a working tree that differs from committed HEAD — verified read-only through `git cat-file -e` / `git --no-optional-locks status` when `git` is available). | Complete the Customize import, use `--mode local`, or remove the staged directory and rerun the installer. | + +The installer side reuses the `AB700x` codes: `AB7002` when `git` is missing +in marketplace mode, `AB7003` when a mode is passed for a non-Cursor host, when +marketplace mode is requested for a bundle without `.cursor-plugin/plugin.json`, +or when the bundle contains nested Git metadata (`.git`, which `git add` would +record as an empty gitlink), `AB7004` when a `git` step fails or the committed +tree does not hold the staged bundle bytes (the installer disables `text`, +`eol`, `filter`, `ident` and `working-tree-encoding` attributes through +`.git/info/attributes`, adds with `core.autocrlf=false`, and proves every +blob id in `git ls-tree -r HEAD` against the staged files; requires Git ≥ 2.29 +for `git init --object-format=sha1`), and `AB7005` for staged version or +content collisions (including a working tree that differs from committed HEAD). diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 169f345d4..f46abcb7a 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -422,3 +422,37 @@ comparison. It never invokes sudo or changes PATH. `agent-bundle install `version-mismatch`, `foreign`, or `not-installed` (see the package README's "Reinstall after a same-version rebuild"). Artifact validation rejects a built-in target whose required install surface is missing. + +### Cursor delivery modes and hook registration (#407) + +`agent-bundle install cursor` and the emitted `install.mjs` accept +`--mode local` (default) or `--mode marketplace`: + +- **local** safe-copies the bundle into `~/.cursor/plugins/local/`. + Cursor loads the `.cursor-plugin/plugin.json` manifest, its `hooks/hooks.json`, + `mcp.json`, rules, and skills after a reload. Plugin hooks are registered by + the manifest alone: Cursor runs each command from the plugin root with + `${CURSOR_PLUGIN_ROOT}` substituted, and no `~/.cursor/hooks.json` entry is + written or required (observed 2026-09-03 on Cursor 3.18.25 for `preToolUse`, + `postToolUse`, and `stop`; see + `docs/audits/2026-09-03-cursor-plugin-hooks-registration.md`). Customize + shows the plugin with a `Local` badge. +- **marketplace** stages a committed Git repository at + `~/.cursor/agent-bundle/marketplaces/` whose + `.cursor-plugin/marketplace.json` lists the plugin, prints its commit, and + prints the one Cursor-owned step the framework cannot perform + non-interactively: Customize -> Plugins -> "Add Plugins from Local + Repository" -> select that directory -> Install. Cursor then treats the + plugin as marketplace-installed (cached under `~/.cursor/plugins/cache`, + managed from Customize, not badged `Local`). `git` must be on PATH; the + installer fails closed with `AB7002` otherwise and re-runs are idempotent + (`already-installed` with the same commit). The result state is `staged` + until Cursor imports it. + +`agent-bundle doctor --host cursor` proves both: `AB7322` reports each local +plugin's manifest hook registration as `registered`, `stale` (a +`${CURSOR_PLUGIN_ROOT}` script is missing), or `missing`; `AB7323` warns when +`~/.cursor/hooks.json` also points into a plugin (duplicate delivery) or is +unparsable; `AB7324` reports a staged marketplace as imported or still +awaiting the Customize step, and `doctor --from` resolves a marketplace-mode +bundle to that staged copy instead of reporting it missing. diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index ac689e173..2aff8d66b 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -117,7 +117,7 @@ manifests at files inside those payloads without compiling them. Payload files c | --- | --- | | `agent-bundle build` | Build a validated artifact from source, plus the declared `dist/` package build. | | `agent-bundle prepack` | Run the release build, dry-run npm packing without scripts, and verify packaged outputs, artifact hashes, bins, and versions (`--output` and `--json` supported). | -| `agent-bundle install ` | Install a built bundle into Claude, Codex, or Cursor (`--from`, `--scope`, `--replace`/`--force`, and `--json` supported). Same-version content drift of an agent-bundle-managed install is replaced automatically; identical reruns are a no-op. | +| `agent-bundle install ` | Install a built bundle into Claude, Codex, or Cursor (`--from`, `--scope`, `--replace`/`--force`, `--mode local\|marketplace` for Cursor, and `--json` supported). Same-version content drift of an agent-bundle-managed install is replaced automatically; identical reruns are a no-op. | | `agent-bundle doctor` | Read-only host inspection: host probes, installed inventory, and, with `--from`, the installed copy compared against the built artifact by version and content hash (`current`, `stale`, `version-mismatch`, `foreign`, `not-installed`). | | `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. | | `agent-bundle inspect` | Inspect normalized targets and adapter plans from source, with per-target component accounting: which skills, commands, rules, hooks, MCP surfaces, and scripts each host emits and, for every omission, whether the author excluded it or the host's pinned capability judgment (`degraded`/`unavailable`/`prohibited`, with reason) ruled it out. | @@ -175,6 +175,16 @@ agent-bundle install cursor --from artifact/cursor node ./install.mjs ``` +Cursor loads the copied `.cursor-plugin/plugin.json` and its manifest-declared +`hooks/hooks.json` from that directory; plugin hooks run from the plugin root +with `${CURSOR_PLUGIN_ROOT}` substituted and need no `~/.cursor/hooks.json` +entry. `--mode marketplace` instead stages a committed local marketplace +repository at `~/.cursor/agent-bundle/marketplaces/` and prints the +Customize -> Plugins -> "Add Plugins from Local Repository" step that makes +Cursor manage the plugin as a marketplace install; `agent-bundle doctor --host +cursor` reports hook registration (`AB7322`), duplicate user-level delivery +(`AB7323`), and marketplace import state (`AB7324`). + Cursor installation is user-scoped. Claude also accepts `--scope project` and `--scope local`; Codex is user-scoped. A source-free artifact root is accepted by `--from` when it contains the selected host target directory. diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json index d6542c6ca..adc2d27ed 100644 --- a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -533,7 +533,9 @@ "retrieved 2026-09-02: https://cursor.com/docs/hooks documents subagentStop input as subagent_type, status (completed|error|aborted), task, description, summary, duration_ms, message_count, tool_call_count, loop_count, modified_files, and agent_transcript_path (string|null), with output { followup_message } consumed only when status is completed and capped by loop_limit (default 5). The canonical agent/stop deny lowers to followup_message with the reason, mirroring the stop family; no additional-context channel is documented, so Agent.Context fails closed.", "retrieved 2026-09-02: https://cursor.com/docs/hooks documents the common input envelope (conversation_id, generation_id, model, model_id?, model_params?, hook_event_name, cursor_version, workspace_roots, user_email, transcript_path) for every agent hook; generated wrappers require session_id or conversation_id plus the per-event fields and reject anything else only when documented types are violated.", "retrieved 2026-09-02: https://cursor.com/docs/hooks documents preToolUse output permission allow|deny (ask accepted by the schema but not enforced), user_message, agent_message, and updated_input; postToolUse output updated_mcp_tool_output (MCP tools only) and additional_context; sessionStart output env and additional_context (continue/user_message accepted but unenforced); stop output followup_message. The Cursor projections emit exactly those channels.", - "2026-09-02: local cursor-agent 2026.08.31-4057e58 capture attempts for subagentStart and subagentStop could not dispatch because the binary required unavailable authentication; the pinned official contract plus the wrapper fixture replay in tests are the evidence source for the subagent lifecycle envelopes." + "2026-09-02: local cursor-agent 2026.08.31-4057e58 capture attempts for subagentStart and subagentStop could not dispatch because the binary required unavailable authentication; the pinned official contract plus the wrapper fixture replay in tests are the evidence source for the subagent lifecycle envelopes.", + "2026-09-03 (#407): hooks registration is plugin-scoped via .cursor-plugin/plugin.json `hooks` -> hooks/hooks.json; no ~/.cursor/hooks.json entry is emitted or required. Observed on Cursor 3.18.25 (isolated HOME, Agents window, shell tool call): the emitted pack's preToolUse (matcher ^Shell$), postToolUse, and stop hooks fired with CURSOR_PLUGIN_ROOT substituted and cwd = plugin root, identical to the known-loading ~/.cursor/plugins/local/tracedecay and to official marketplace plugins (cursor-public/continual-learning uses `bun run ${CURSOR_PLUGIN_ROOT}/hooks/...`). https://cursor.com/docs/hooks: 'All matching hooks from every source run', so a user-level duplicate would double-fire; Doctor reports it (AB7323). See docs/audits/2026-09-03-cursor-plugin-hooks-registration.md.", + "2026-09-03 (#407): the #407 report (Cursor 3.16.21, ~/.cursor-server) is not reproducible on 3.18.25; https://cursor.com/docs/cli/changelog (August 11, 2026 release) records 'Plugin hooks run from installed plugins ... now execute and refresh when plugins reload', a vendor-side fix in that window. Installation: `install cursor --mode local` copies to ~/.cursor/plugins/local/ (https://cursor.com/docs/plugins#test-plugins-locally); `--mode marketplace` stages a committed .cursor-plugin/marketplace.json repository (https://cursor.com/docs/reference/plugins#cursor-multi-plugin-repositories) for Customize -> 'Add Plugins from Local Repository', because no plugin install deeplink exists and `cursor-agent plugin marketplace add` accepts hosted git URLs only." ] } } diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index eb1569f5b..ca80570aa 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -462,7 +462,7 @@ export const cursorManifest = ( }); const metadata = Object.freeze({ - adapterRevision: '1.10.0', + adapterRevision: '1.11.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 88b2693e9..2e93a7377 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -235,7 +235,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.26.0', + adapterRevision: '1.27.0', observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}+${cursorAdapter.metadata.observedVersion}`, // Metadata schemas must exactly match the validation contract: each host's // documents, with one shared Claude-format hook schema (the pinned Codex diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index a14122f4e..eb5a50b49 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -92,7 +92,7 @@ const schemaValidator = createAdapterValidator(); const validatePlugin = schemaValidator.compile(pluginSchema); const validateMcp = schemaValidator.compile(mcpSchema); const metadata = Object.freeze({ - adapterRevision: '1.7.0', + adapterRevision: '1.8.0', observedVersion: capabilityTable.observedSpecificationVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.version), }); diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 4550f7cde..cb22992de 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -23,6 +23,7 @@ import type { import type { installBundle, InstallHost, + InstallMode, InstallResult, InstallScope, } from './install/install.ts'; @@ -36,6 +37,7 @@ import type { import type { runHostMcpProxy } from './dev/host-mcp-proxy.ts'; import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; import { errorMessage } from './core/errors.ts'; +import { formatInstallResult } from './install/format.ts'; import { projectVersionLabel } from './core/project-context.ts'; import { stableJson } from './core/digest.ts'; import type { EvalComparisonDelta, EvalConditionMetrics } from './eval/compare.ts'; @@ -87,6 +89,7 @@ interface InstallCommandOptions { readonly from: string; readonly json?: boolean; readonly replace?: boolean; + readonly mode?: InstallMode; readonly scope: string; } @@ -169,6 +172,11 @@ const installHost = (value: string): InstallHost => { const collectInstallHost = (value: string, previous: readonly InstallHost[]): readonly InstallHost[] => [...previous, installHost(value)]; +const installMode = (value: string): InstallMode => { + if (value === 'local' || value === 'marketplace') return value; + throw new TypeError('Install mode must be local or marketplace.'); +}; + const installScope = (value: string): InstallScope => { if (value === 'user' || value === 'project' || value === 'local') return value; throw new TypeError('Install scope must be user, project, or local.'); @@ -277,35 +285,10 @@ const writeHumanPrepack = (output: Output, result: Awaited { - switch (state) { - case 'adopted': - return 'Adopted'; - case 'already-installed': - return 'Already installed'; - case 'installed': - return 'Installed'; - case 'replaced': - return 'Replaced'; - default: { - const exhaustive: never = state; - throw new TypeError(`Unknown install state ${String(exhaustive)}.`); - } - } -}; - const shortContentHash = (hash: string): string => hash.slice(0, 12); const writeHumanInstall = (output: Output, result: InstallResult): void => { - const destination = result.destination ?? result.bundleRoot; - const content = result.previousContentHash !== undefined && result.contentHash !== undefined - ? ` (content ${shortContentHash(result.previousContentHash)} -> ${shortContentHash(result.contentHash)})` - : result.contentHash === undefined - ? '' - : ` (content ${shortContentHash(result.contentHash)})`; - output.write( - `${installStateLabel(result.state)} ${result.plugin}@${result.version} for ${result.host} at ${destination}${content}\n`, - ); + output.write(formatInstallResult(result)); }; const describeInstallComparison = (comparison: DoctorInstallComparison): string => { @@ -640,6 +623,7 @@ export const runCli = async ( 'same-version content drift is replaced automatically and foreign installs are always refused', ) .option('--force', 'Alias for --replace') + .option('--mode ', 'Cursor delivery mode: local (default) or marketplace', installMode) .option('--json', 'Write one machine-readable JSON document'); installCommand.action(async ( host: InstallHost, @@ -650,6 +634,7 @@ export const runCli = async ( from: options.from, host, replace: options.replace === true || options.force === true, + ...(options.mode === undefined ? {} : { mode: options.mode }), scope: installScope(options.scope), }); if (options.json === true) writeMachine(stdout, result); diff --git a/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts index ebaa55fc1..fd3affeb9 100644 --- a/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts @@ -1,4 +1,4 @@ -import { lstat, readdir, readFile, realpath } from 'node:fs/promises'; +import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises'; import { dirname, join, relative, resolve } from 'node:path'; import { Ajv, type ErrorObject } from 'ajv/dist/ajv.js'; @@ -224,6 +224,8 @@ const readDocuments = async ( const file = join(pluginDirectory, contract.path); let source: string; try { + // `stat` first: `readFile` on a FIFO or device would block until a writer appears. + if (!(await stat(file)).isFile()) throw new Error(`${contract.path} is not a regular file`); source = await readFile(file, 'utf8'); } catch (error) { if (isErrno(error, 'ENOENT')) { diff --git a/packages/agent-bundle/src/install-entry.ts b/packages/agent-bundle/src/install-entry.ts index 157e80982..b8f3350ed 100644 --- a/packages/agent-bundle/src/install-entry.ts +++ b/packages/agent-bundle/src/install-entry.ts @@ -3,9 +3,11 @@ import { fileURLToPath } from 'node:url'; import { stableJson } from './core/digest.ts'; import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; +import { formatInstallResult } from './install/format.ts'; import { installBundle, type InstallHost, + type InstallMode, type InstallResult, type InstallScope, } from './install/install.ts'; @@ -17,7 +19,7 @@ export interface GeneratedInstallProcessOptions { } const usage = (options: GeneratedInstallProcessOptions): string => [ - `Usage: ${options.name} install [--scope ] [--replace|--force] [--json]`, + `Usage: ${options.name} install [--scope ] [--mode local|marketplace] [--replace|--force] [--json]`, '', `Built hosts: ${options.hosts.join(', ')}`, '', @@ -36,33 +38,8 @@ const diagnosticsFor = (error: unknown): readonly Diagnostic[] => severity: 'error' as const, })]); -const stateLabel = (state: InstallResult['state']): string => { - switch (state) { - case 'adopted': - return 'Adopted'; - case 'already-installed': - return 'Already installed'; - case 'installed': - return 'Installed'; - case 'replaced': - return 'Replaced'; - default: { - const exhaustive: never = state; - throw new TypeError(`Unknown install state ${String(exhaustive)}.`); - } - } -}; - const writeHuman = (result: InstallResult): void => { - const destination = result.destination ?? result.bundleRoot; - const content = result.previousContentHash !== undefined && result.contentHash !== undefined - ? ` (content ${result.previousContentHash.slice(0, 12)} -> ${result.contentHash.slice(0, 12)})` - : result.contentHash === undefined - ? '' - : ` (content ${result.contentHash.slice(0, 12)})`; - process.stdout.write( - `${stateLabel(result.state)} ${result.plugin}@${result.version} for ${result.host} at ${destination}${content}\n`, - ); + process.stdout.write(formatInstallResult(result)); }; const isHost = (value: string): value is InstallHost => @@ -71,10 +48,14 @@ const isHost = (value: string): value is InstallHost => const isScope = (value: string): value is InstallScope => value === 'local' || value === 'project' || value === 'user'; +const isMode = (value: string): value is InstallMode => + value === 'local' || value === 'marketplace'; + interface ParsedInstallArguments { readonly host: InstallHost; readonly json: boolean; readonly replace: boolean; + readonly mode?: InstallMode; readonly scope: InstallScope; } @@ -94,6 +75,7 @@ const parseArguments = ( let json = false; let replace = false; let scope: InstallScope = 'user'; + let mode: InstallMode | undefined; for (let index = 2; index < argv.length; index += 1) { const argument = argv[index]; if (argument === '--json') { @@ -104,6 +86,15 @@ const parseArguments = ( replace = true; continue; } + if (argument === '--mode') { + const value = argv[index + 1]; + if (value === undefined || !isMode(value)) { + throw new TypeError('Install mode must be local or marketplace.'); + } + mode = value; + index += 1; + continue; + } if (argument === '--scope') { const value = argv[index + 1]; if (value === undefined || !isScope(value)) { @@ -115,7 +106,7 @@ const parseArguments = ( } throw new TypeError(`Unknown installer argument ${JSON.stringify(argument)}.`); } - return Object.freeze({ host: candidate, json, replace, scope }); + return Object.freeze({ host: candidate, json, ...(mode === undefined ? {} : { mode }), replace, scope }); }; export const runGeneratedInstallProcess = async ( @@ -143,6 +134,7 @@ export const runGeneratedInstallProcess = async ( from: artifactRoot, host: parsed.host, replace: parsed.replace, + ...(parsed.mode === undefined ? {} : { mode: parsed.mode }), scope: parsed.scope, }); if (parsed.json) process.stdout.write(`${stableJson(result)}\n`); diff --git a/packages/agent-bundle/src/install/cursor-hooks-registration.ts b/packages/agent-bundle/src/install/cursor-hooks-registration.ts new file mode 100644 index 000000000..b355c9008 --- /dev/null +++ b/packages/agent-bundle/src/install/cursor-hooks-registration.ts @@ -0,0 +1,627 @@ +import { readdir, readFile, stat } from 'node:fs/promises'; +import { isAbsolute, join, resolve } from 'node:path'; + +import { Ajv } from 'ajv/dist/ajv.js'; +import addFormats from 'ajv-formats'; +import { Predicate } from 'effect'; + +import hooksSchema from '../adapters/schemas/cursor/hooks.schema.json' with { type: 'json' }; +import marketplaceSchema from '../adapters/schemas/cursor/marketplace.schema.json' with { type: 'json' }; +import pluginSchema from '../adapters/schemas/cursor/plugin.schema.json' with { type: 'json' }; +import type { Diagnostic } from '../core/diagnostics.ts'; +import { isErrno } from '../core/errors.ts'; +import { + cursorMarketplacePluginPath, + cursorMarketplaceRoot, +} from './cursor-marketplace.ts'; + +/** + * Read-only Doctor proof for Cursor hook registration (#407). + * + * Cursor delivers plugin hooks from the plugin manifest (`.cursor-plugin/plugin.json` + * `hooks` -> `hooks/hooks.json`), substituting `${CURSOR_PLUGIN_ROOT}` and running + * each command from the plugin root (observed 2026-09-03, Cursor 3.18.25, isolated + * HOME: preToolUse/postToolUse/stop fired for the emitted pack exactly like the + * known-working ~/.cursor/plugins/local/tracedecay). `~/.cursor/hooks.json` is a + * separate user-level registry; entries there that point into a plugin would run + * the same hook twice, so Doctor reports them as duplicate delivery. + */ + +export type CursorHooksRegistrationState = 'missing' | 'none' | 'registered' | 'stale'; + +export interface CursorHooksRegistration { + readonly commands: number; + readonly duplicates: readonly string[]; + readonly events: readonly string[]; + readonly source?: string; + readonly state: CursorHooksRegistrationState; +} + +const finding = ( + code: `AB73${number}`, + message: string, + recovery: string, + severity: Diagnostic['severity'], +): Diagnostic => Object.freeze({ code, message, recovery, severity, target: 'cursor' }); + +const pluginRootToken = '${CURSOR_PLUGIN_ROOT}'; + +interface ParsedHooksDocument { + readonly commands: readonly string[]; + readonly events: readonly string[]; + readonly prompts: number; +} + +const schemaValidator = new Ajv({ allErrors: true, allowUnionTypes: true, strict: true }); +(addFormats as unknown as (target: Ajv) => void)(schemaValidator); +const validateHooksDocument = schemaValidator.compile(hooksSchema); +const validateMarketplaceDocument = schemaValidator.compile(marketplaceSchema); +const validatePluginDocument = schemaValidator.compile(pluginSchema); + +/** + * Accepts exactly what the pinned hooks.schema.json accepts (documented events, command hooks + * `{ "command": string }` / `{ "type": "command", ... }`, prompt hooks `{ "type": "prompt", "prompt": string }`), + * so AB7322 never reports `registered` for a document the static contract validator rejects. + * Only command hooks have a script path to verify. + */ +const parseHooksDocument = (value: unknown): ParsedHooksDocument | undefined => { + if (!validateHooksDocument(value) || !Predicate.isObject(value)) return undefined; + const hooks = value.hooks; + if (!Predicate.isObject(hooks)) return undefined; + const events: string[] = []; + const commands: string[] = []; + let prompts = 0; + for (const [event, entries] of Object.entries(hooks)) { + if (!Array.isArray(entries)) return undefined; + events.push(event); + for (const entry of entries) { + if (!Predicate.isObject(entry)) return undefined; + if (entry.type === 'prompt') { + prompts += 1; + continue; + } + if (typeof entry.command !== 'string') return undefined; + commands.push(entry.command); + } + } + return { commands, events: events.sort((left, right) => left.localeCompare(right)), prompts }; +}; + +/** + * Minimal POSIX-ish word splitter: whitespace separates words, single/double quotes group (quotes removed), + * and a backslash escapes only a following quote, backslash, or whitespace so Windows paths such as + * `.\plugins\local\foo` survive intact. Enough to keep `"hooks/my hook.mjs"` one token. + */ +const escapable = (char: string | undefined): boolean => char !== undefined && /[\s"'\\]/u.test(char); +const shellWords = (command: string): readonly string[] => { + const words: string[] = []; + let current = ''; + let quote: '"' | "'" | undefined; + let hasWord = false; + for (let index = 0; index < command.length; index += 1) { + const char = command[index] ?? ''; + if (quote !== undefined) { + if (char === quote) { + quote = undefined; + } else if (char === '\\' && quote === '"' && escapable(command[index + 1])) { + index += 1; + current += command[index] ?? ''; + } else { + current += char; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + hasWord = true; + } else if (char === '\\' && escapable(command[index + 1])) { + index += 1; + current += command[index] ?? ''; + hasWord = true; + } else if (/\s/u.test(char)) { + if (hasWord) words.push(current); + current = ''; + hasWord = false; + } else { + current += char; + hasWord = true; + } + } + if (hasWord) words.push(current); + return words; +}; + +/** Interpreters whose first operand is the script Cursor must find under the plugin root. */ +const scriptInterpreters = new Set([ + 'bash', 'bun', 'dash', 'deno', 'node', 'nodejs', 'perl', 'powershell', 'pwsh', 'python', 'python3', 'ruby', 'sh', 'tsx', 'zsh', +]); + +/** Interpreters whose `run` subcommand precedes the script (`bun run ./x.ts`, `deno run -A ./x.ts`). */ +const runSubcommandInterpreters = new Set(['bun', 'deno']); + +/** + * Interpreter options whose operand is inline source or a module name: the command runs no script file at all. + * PowerShell entries are lower-case because its parameters are case-insensitive and are lower-cased before lookup. + */ +const inlineSourceOptions = new Set(['--eval', '--print', '-c', '-command', '-e', '-ec', '-encodedcommand', '-m', '-p']); + +/** Interpreter options that consume the next token (a path or value), which is therefore not the entry script. */ +const valueOptions = new Set([ + '--conditions', '--define', '--env-file', '--experimental-loader', '--import', '--input-type', '--loader', + '--preload', '--require', '--stack-trace-limit', '--title', '-C', '-I', '-W', '-X', '-d', '-o', '-r', + '-configurationname', '-ep', '-ex', '-executionpolicy', '-inputformat', '-outputformat', '-psconsolefile', + '-settingsfile', '-wd', '-workingdirectory', +]); + +const powerShell = new Set(['powershell', 'pwsh']); + +const shellAssignment = /^[A-Za-z_][A-Za-z0-9_]*=/u; + +/** `env [NAME=VALUE]... [OPTION]... COMMAND [ARG]...`: strips the assignments and options ahead of COMMAND. */ +const unwrapEnv = (operands: readonly string[]): readonly string[] | undefined => { + for (let index = 0; index < operands.length; index += 1) { + const token = operands[index] ?? ''; + if (shellAssignment.test(token)) continue; + if (token === '-u' || token === '--unset' || token === '-C' || token === '--chdir') { + index += 1; + continue; + } + if (token === '-S' || token === '--split-string') return undefined; + if (token.startsWith('-') && token !== '-') continue; + return operands.slice(index); + } + return undefined; +}; + +interface ExecutedPath { + /** `executable` is the command word itself (PATH lookup unless it names a path); `operand` is a script argument. */ + readonly kind: 'executable' | 'operand'; + readonly token: string; +} + +/** + * The file a hook command executes: the executable token itself, or, for a known interpreter, the entry-script + * operand after skipping options (value-taking options consume their operand; `--opt=value` consumes nothing; + * PowerShell's `-File ` selects the script). `undefined` when the interpreter runs inline source + * (`node -e`, `sh -c`, `python -m`) or has no operand. Other arguments (`--output ./state/result.json`) are + * runtime inputs/outputs, never the executed file. + */ +const executedPath = (words: readonly string[]): ExecutedPath | undefined => { + // Leading `NAME=value` words are shell assignments (`NODE_ENV=production node ./x.mjs`), not the command. + const tokens = words.slice(words.findIndex((word) => !shellAssignment.test(word))); + const [executable, ...operands] = tokens; + if (executable === undefined || shellAssignment.test(executable)) return undefined; + // Interpreter basenames are matched case-insensitively (`PowerShell.EXE`, `Node.exe` on Windows). + const interpreter = executable.replace(/^.*[\\/]/u, '').replace(/\.exe$/iu, '').toLowerCase(); + if (interpreter === 'env') { + const wrapped = unwrapEnv(operands); + return wrapped === undefined ? undefined : executedPath(wrapped); + } + if (!scriptInterpreters.has(interpreter)) return { kind: 'executable', token: executable }; + const operand = (token: string | undefined): ExecutedPath | undefined => + token === undefined ? undefined : { kind: 'operand', token }; + const isPowerShell = powerShell.has(interpreter); + let runSubcommandSeen = false; + for (let index = 0; index < operands.length; index += 1) { + const token = operands[index] ?? ''; + // `bun run