Skip to content

Commit f113002

Browse files
feat(install): cursor --mode local|marketplace, marketplace staging, and hooks-registration doctor findings (#407)
1 parent 23ee0f5 commit f113002

26 files changed

Lines changed: 2823 additions & 99 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agent-bundle": patch
3+
---
4+
5+
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).

‎docs/audits/2026-09-03-cursor-plugin-hooks-registration.md‎

Lines changed: 236 additions & 0 deletions
Large diffs are not rendered by default.

‎docs/diagnostics.md‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ even when no error diagnostic was reported.
3636
| `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. |
3737
| `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). |
3838
| `AB7xxx` | Project preparation and development rebuilds. |
39-
| `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). |
39+
| `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). |
4040
| `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. |
4141
| `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). |
4242
| `AB8215`–`AB8218` | Workbench read-only host discovery route. |
@@ -779,3 +779,28 @@ artifact epochs. A package build failure never invalidates the artifact epoch
779779
that already committed; it surfaces as one `AB7103` **warning** on the
780780
succeeded build attempt, and the package build retries on the next
781781
invalidation. See `docs/entry-conventions.md` for the dev-watch contract.
782+
783+
## Read-only Doctor Cursor hook registration and marketplace staging (`AB7322`–`AB7324`)
784+
785+
Cursor delivers a plugin's hooks from its `.cursor-plugin/plugin.json` `hooks`
786+
declaration (observed 2026-09-03 on Cursor 3.18.25; see
787+
`docs/audits/2026-09-03-cursor-plugin-hooks-registration.md`). Doctor proves
788+
that registration statically and never writes `~/.cursor/hooks.json`.
789+
790+
| Code | Severity | Meaning | Recovery |
791+
| --- | --- | --- | --- |
792+
| `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. |
793+
| `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. |
794+
| `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. |
795+
796+
The installer side reuses the `AB700x` codes: `AB7002` when `git` is missing
797+
in marketplace mode, `AB7003` when a mode is passed for a non-Cursor host, when
798+
marketplace mode is requested for a bundle without `.cursor-plugin/plugin.json`,
799+
or when the bundle contains nested Git metadata (`.git`, which `git add` would
800+
record as an empty gitlink), `AB7004` when a `git` step fails or the committed
801+
tree does not hold the staged bundle bytes (the installer disables `text`,
802+
`eol`, `filter`, `ident` and `working-tree-encoding` attributes through
803+
`.git/info/attributes`, adds with `core.autocrlf=false`, and proves every
804+
blob id in `git ls-tree -r HEAD` against the staged files; requires Git ≥ 2.29
805+
for `git init --object-format=sha1`), and `AB7005` for staged version or
806+
content collisions (including a working tree that differs from committed HEAD).

‎docs/framework-mode.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,3 +422,37 @@ comparison. It never invokes sudo or changes PATH. `agent-bundle install <host>
422422
`version-mismatch`, `foreign`, or `not-installed` (see the package README's
423423
"Reinstall after a same-version rebuild"). Artifact validation rejects a
424424
built-in target whose required install surface is missing.
425+
426+
### Cursor delivery modes and hook registration (#407)
427+
428+
`agent-bundle install cursor` and the emitted `install.mjs` accept
429+
`--mode local` (default) or `--mode marketplace`:
430+
431+
- **local** safe-copies the bundle into `~/.cursor/plugins/local/<name>`.
432+
Cursor loads the `.cursor-plugin/plugin.json` manifest, its `hooks/hooks.json`,
433+
`mcp.json`, rules, and skills after a reload. Plugin hooks are registered by
434+
the manifest alone: Cursor runs each command from the plugin root with
435+
`${CURSOR_PLUGIN_ROOT}` substituted, and no `~/.cursor/hooks.json` entry is
436+
written or required (observed 2026-09-03 on Cursor 3.18.25 for `preToolUse`,
437+
`postToolUse`, and `stop`; see
438+
`docs/audits/2026-09-03-cursor-plugin-hooks-registration.md`). Customize
439+
shows the plugin with a `Local` badge.
440+
- **marketplace** stages a committed Git repository at
441+
`~/.cursor/agent-bundle/marketplaces/<name>` whose
442+
`.cursor-plugin/marketplace.json` lists the plugin, prints its commit, and
443+
prints the one Cursor-owned step the framework cannot perform
444+
non-interactively: Customize -> Plugins -> "Add Plugins from Local
445+
Repository" -> select that directory -> Install. Cursor then treats the
446+
plugin as marketplace-installed (cached under `~/.cursor/plugins/cache`,
447+
managed from Customize, not badged `Local`). `git` must be on PATH; the
448+
installer fails closed with `AB7002` otherwise and re-runs are idempotent
449+
(`already-installed` with the same commit). The result state is `staged`
450+
until Cursor imports it.
451+
452+
`agent-bundle doctor --host cursor` proves both: `AB7322` reports each local
453+
plugin's manifest hook registration as `registered`, `stale` (a
454+
`${CURSOR_PLUGIN_ROOT}` script is missing), or `missing`; `AB7323` warns when
455+
`~/.cursor/hooks.json` also points into a plugin (duplicate delivery) or is
456+
unparsable; `AB7324` reports a staged marketplace as imported or still
457+
awaiting the Customize step, and `doctor --from` resolves a marketplace-mode
458+
bundle to that staged copy instead of reporting it missing.

‎packages/agent-bundle/README.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ manifests at files inside those payloads without compiling them. Payload files c
117117
| --- | --- |
118118
| `agent-bundle build` | Build a validated artifact from source, plus the declared `dist/` package build. |
119119
| `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). |
120-
| `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. |
120+
| `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. |
121121
| `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`). |
122122
| `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. |
123123
| `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
175175
node ./install.mjs
176176
```
177177

178+
Cursor loads the copied `.cursor-plugin/plugin.json` and its manifest-declared
179+
`hooks/hooks.json` from that directory; plugin hooks run from the plugin root
180+
with `${CURSOR_PLUGIN_ROOT}` substituted and need no `~/.cursor/hooks.json`
181+
entry. `--mode marketplace` instead stages a committed local marketplace
182+
repository at `~/.cursor/agent-bundle/marketplaces/<name>` and prints the
183+
Customize -> Plugins -> "Add Plugins from Local Repository" step that makes
184+
Cursor manage the plugin as a marketplace install; `agent-bundle doctor --host
185+
cursor` reports hook registration (`AB7322`), duplicate user-level delivery
186+
(`AB7323`), and marketplace import state (`AB7324`).
187+
178188
Cursor installation is user-scoped. Claude also accepts `--scope project` and
179189
`--scope local`; Codex is user-scoped. A source-free artifact root is accepted
180190
by `--from` when it contains the selected host target directory.

‎packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,9 @@
533533
"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.",
534534
"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.",
535535
"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.",
536-
"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."
536+
"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.",
537+
"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.",
538+
"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."
537539
]
538540
}
539541
}

‎packages/agent-bundle/src/adapters/cursor.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@ export const cursorManifest = (
462462
});
463463

464464
const metadata = Object.freeze({
465-
adapterRevision: '1.10.0',
465+
adapterRevision: '1.11.0',
466466
observedVersion: capabilityTable.observedCliVersion,
467467
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
468468
});

‎packages/agent-bundle/src/adapters/plugin.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ const artifactValidation = deepFreeze({
235235
});
236236

237237
const metadata = Object.freeze({
238-
adapterRevision: '1.26.0',
238+
adapterRevision: '1.27.0',
239239
observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}+${cursorAdapter.metadata.observedVersion}`,
240240
// Metadata schemas must exactly match the validation contract: each host's
241241
// documents, with one shared Claude-format hook schema (the pinned Codex

‎packages/agent-bundle/src/adapters/portable.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ const schemaValidator = createAdapterValidator();
9292
const validatePlugin = schemaValidator.compile(pluginSchema);
9393
const validateMcp = schemaValidator.compile(mcpSchema);
9494
const metadata = Object.freeze({
95-
adapterRevision: '1.7.0',
95+
adapterRevision: '1.8.0',
9696
observedVersion: capabilityTable.observedSpecificationVersion,
9797
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.version),
9898
});

‎packages/agent-bundle/src/cli.ts‎

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
import type {
2424
installBundle,
2525
InstallHost,
26+
InstallMode,
2627
InstallResult,
2728
InstallScope,
2829
} from './install/install.ts';
@@ -36,6 +37,7 @@ import type {
3637
import type { runHostMcpProxy } from './dev/host-mcp-proxy.ts';
3738
import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts';
3839
import { errorMessage } from './core/errors.ts';
40+
import { formatInstallResult } from './install/format.ts';
3941
import { projectVersionLabel } from './core/project-context.ts';
4042
import { stableJson } from './core/digest.ts';
4143
import type { EvalComparisonDelta, EvalConditionMetrics } from './eval/compare.ts';
@@ -87,6 +89,7 @@ interface InstallCommandOptions {
8789
readonly from: string;
8890
readonly json?: boolean;
8991
readonly replace?: boolean;
92+
readonly mode?: InstallMode;
9093
readonly scope: string;
9194
}
9295

@@ -169,6 +172,11 @@ const installHost = (value: string): InstallHost => {
169172
const collectInstallHost = (value: string, previous: readonly InstallHost[]): readonly InstallHost[] =>
170173
[...previous, installHost(value)];
171174

175+
const installMode = (value: string): InstallMode => {
176+
if (value === 'local' || value === 'marketplace') return value;
177+
throw new TypeError('Install mode must be local or marketplace.');
178+
};
179+
172180
const installScope = (value: string): InstallScope => {
173181
if (value === 'user' || value === 'project' || value === 'local') return value;
174182
throw new TypeError('Install scope must be user, project, or local.');
@@ -277,35 +285,10 @@ const writeHumanPrepack = (output: Output, result: Awaited<ReturnType<typeof pre
277285
);
278286
};
279287

280-
const installStateLabel = (state: InstallResult['state']): string => {
281-
switch (state) {
282-
case 'adopted':
283-
return 'Adopted';
284-
case 'already-installed':
285-
return 'Already installed';
286-
case 'installed':
287-
return 'Installed';
288-
case 'replaced':
289-
return 'Replaced';
290-
default: {
291-
const exhaustive: never = state;
292-
throw new TypeError(`Unknown install state ${String(exhaustive)}.`);
293-
}
294-
}
295-
};
296-
297288
const shortContentHash = (hash: string): string => hash.slice(0, 12);
298289

299290
const writeHumanInstall = (output: Output, result: InstallResult): void => {
300-
const destination = result.destination ?? result.bundleRoot;
301-
const content = result.previousContentHash !== undefined && result.contentHash !== undefined
302-
? ` (content ${shortContentHash(result.previousContentHash)} -> ${shortContentHash(result.contentHash)})`
303-
: result.contentHash === undefined
304-
? ''
305-
: ` (content ${shortContentHash(result.contentHash)})`;
306-
output.write(
307-
`${installStateLabel(result.state)} ${result.plugin}@${result.version} for ${result.host} at ${destination}${content}\n`,
308-
);
291+
output.write(formatInstallResult(result));
309292
};
310293

311294
const describeInstallComparison = (comparison: DoctorInstallComparison): string => {
@@ -640,6 +623,7 @@ export const runCli = async (
640623
'same-version content drift is replaced automatically and foreign installs are always refused',
641624
)
642625
.option('--force', 'Alias for --replace')
626+
.option('--mode <mode>', 'Cursor delivery mode: local (default) or marketplace', installMode)
643627
.option('--json', 'Write one machine-readable JSON document');
644628
installCommand.action(async (
645629
host: InstallHost,
@@ -650,6 +634,7 @@ export const runCli = async (
650634
from: options.from,
651635
host,
652636
replace: options.replace === true || options.force === true,
637+
...(options.mode === undefined ? {} : { mode: options.mode }),
653638
scope: installScope(options.scope),
654639
});
655640
if (options.json === true) writeMachine(stdout, result);

0 commit comments

Comments
 (0)