Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cursor-install-mode-hooks-registration.md
Original file line number Diff line number Diff line change
@@ -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/<name>` and prints the Cursor Customize import step, while local mode keeps the safe copy into `~/.cursor/plugins/local/<name>`. 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).
236 changes: 236 additions & 0 deletions docs/audits/2026-09-03-cursor-plugin-hooks-registration.md

Large diffs are not rendered by default.

27 changes: 26 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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: { <event>: [{ 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/<name>` (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).
34 changes: 34 additions & 0 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,3 +422,37 @@ comparison. It never invokes sudo or changes PATH. `agent-bundle install <host>
`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/<name>`.
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/<name>` 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.
12 changes: 11 additions & 1 deletion packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>` | 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 <host>` | 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. |
Expand Down Expand Up @@ -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/<name>` 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name> (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."
]
}
}
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/adapters/portable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
Expand Down
37 changes: 11 additions & 26 deletions packages/agent-bundle/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
import type {
installBundle,
InstallHost,
InstallMode,
InstallResult,
InstallScope,
} from './install/install.ts';
Expand All @@ -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';
Expand Down Expand Up @@ -87,6 +89,7 @@ interface InstallCommandOptions {
readonly from: string;
readonly json?: boolean;
readonly replace?: boolean;
readonly mode?: InstallMode;
readonly scope: string;
}

Expand Down Expand Up @@ -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.');
Expand Down Expand Up @@ -277,35 +285,10 @@ const writeHumanPrepack = (output: Output, result: Awaited<ReturnType<typeof pre
);
};

const installStateLabel = (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 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 => {
Expand Down Expand Up @@ -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 <mode>', 'Cursor delivery mode: local (default) or marketplace', installMode)
.option('--json', 'Write one machine-readable JSON document');
installCommand.action(async (
host: InstallHost,
Expand All @@ -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);
Expand Down
Loading
Loading