Skip to content

Commit df0afec

Browse files
Merge pull request #13 from ScriptedAlchemy/feat/cursor-host-adapter
feat(adapters): add a first-class cursor compile target
2 parents 23709ea + e5960ed commit df0afec

16 files changed

Lines changed: 621 additions & 133 deletions

‎.changeset/cursor-host-adapter.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"agent-bundle": minor
3+
---
4+
5+
Add a first-class `cursor` compile target. The standalone Cursor artifact
6+
carries the `.cursor-plugin/plugin.json` manifest with explicit document
7+
pointers, Cursor's auto-discovered typeless `mcp.json`, and shared skills,
8+
scripts, and assets, all validated against the pinned Cursor schemas. The
9+
unified `plugin` bundle now shares one Cursor lowering with the new adapter,
10+
and the target MCP runtime reads shape-discriminated server documents.

‎README.md‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# agent-bundle
22

3-
`agent-bundle` compiles one Agent Bundle project into portable, Codex, and Claude Code artifacts. It discovers skills, validates a typed configuration, bundles local JavaScript/TypeScript entry points, and writes the host-specific metadata needed by each selected target.
3+
`agent-bundle` compiles one Agent Bundle project into portable, Codex, Claude Code, and Cursor artifacts. It discovers skills, validates a typed configuration, bundles local JavaScript/TypeScript entry points, and writes the host-specific metadata needed by each selected target.
44

55
It requires Node.js 22.19 or later.
66

@@ -38,7 +38,8 @@ shared `skills/`, `hooks/`, `mcp/`, `scripts/`, and `assets/` directories,
3838
plus a generated `AGENTS.md` install matrix. Hooks compile once into
3939
host-detecting wrappers that serve Claude Code and Codex; Cursor consumes the
4040
skills and MCP servers. Per-host artifacts remain available as `claude`,
41-
`codex`, and `portable` targets when a host-specific layout is required.
41+
`codex`, `cursor`, and `portable` targets when a host-specific layout is
42+
required.
4243

4344
## Install and build
4445

@@ -145,7 +146,7 @@ export default defineConfig({
145146
version: '1.0.0',
146147
description: 'Review helpers for an agent host.',
147148
},
148-
targets: ['portable', 'codex', 'claude'], // or ['plugin'] for the unified multi-host bundle
149+
targets: ['portable', 'codex', 'claude', 'cursor'], // or ['plugin'] for the unified multi-host bundle
149150
skills: ['skills/*'],
150151
scripts: {
151152
report: './src/report.ts',
@@ -219,11 +220,16 @@ artifact/
219220
.mcp.json
220221
scripts/<name>.mjs
221222
hooks/<name>.mjs
223+
cursor/
224+
.cursor-plugin/plugin.json
225+
mcp.json
226+
scripts/<name>.mjs
227+
skills/<skill>/...
222228
```
223229

224230
`agent-bundle.manifest.json` records each emitted file's path, byte length, and SHA-256 digest. This allows `validate --artifact` and artifact operations to run after the source project is no longer present.
225231

226-
Portable artifacts contain portable plugin, skills, MCP, and App-resource files. Codex and Claude artifacts contain their respective native metadata and generated hook wrappers. Terminal hosts can use normal MCP tools and resources; visual rendering of an MCP App depends on the host supporting the standard resource metadata.
232+
Portable artifacts contain portable plugin, skills, MCP, and App-resource files. Codex and Claude artifacts contain their respective native metadata and generated hook wrappers. Cursor artifacts contain the `.cursor-plugin/plugin.json` manifest, the auto-discovered `mcp.json` (Cursor's typeless server format), and shared skills, scripts, and assets; hooks stay Claude/Codex-only until Cursor's hook stdin contract is pinned. Terminal hosts can use normal MCP tools and resources; visual rendering of an MCP App depends on the host supporting the standard resource metadata.
227233

228234
## Public examples
229235

‎packages/agent-bundle/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# agent-bundle
22

3-
Compile a typed Agent Bundle configuration into portable, Codex, and Claude Code artifacts. Node.js 22.19 or later is required.
3+
Compile a typed Agent Bundle configuration into portable, Codex, Claude Code, and Cursor artifacts. Node.js 22.19 or later is required.
44

55
```sh
66
npm install --save-dev agent-bundle

‎packages/agent-bundle/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "agent-bundle",
33
"version": "0.1.0",
4-
"description": "Compile a typed Agent Bundle configuration into portable, Codex, and Claude Code artifacts.",
4+
"description": "Compile a typed Agent Bundle configuration into portable, Codex, Claude Code, and Cursor artifacts.",
55
"keywords": [
66
"agent",
77
"claude-code",
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"host": "cursor",
3+
"mcp": {
4+
"pathTokens": {
5+
"args": [
6+
"${CURSOR_PLUGIN_ROOT}",
7+
"${workspaceFolder}"
8+
],
9+
"env": [
10+
"${CURSOR_PLUGIN_ROOT}",
11+
"${workspaceFolder}"
12+
],
13+
"headers": [
14+
"${CURSOR_PLUGIN_ROOT}",
15+
"${workspaceFolder}"
16+
],
17+
"url": [
18+
"${CURSOR_PLUGIN_ROOT}",
19+
"${workspaceFolder}"
20+
]
21+
},
22+
"stdio": true,
23+
"streamableHttp": true
24+
},
25+
"observedCliVersion": "2026-08-28",
26+
"plugin": {
27+
"manifest": ".cursor-plugin/plugin.json",
28+
"skills": true
29+
}
30+
}
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import { createTargetDiagnostics } from './diagnostics.ts';
2+
import type { Diagnostic } from '../core/diagnostics.ts';
3+
import { readMcpTransport, unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts';
4+
import { isPlainDataRecord, ownDataValue } from '../core/strict-json.ts';
5+
import {
6+
pathTokens,
7+
type NormalizedMcpServer,
8+
type NormalizedPlugin,
9+
} from '../core/types.ts';
10+
import {
11+
allMcpPathTokenFields,
12+
createMcpPathTokenResolver,
13+
standardMcpPathTokens,
14+
} from '../services/mcp-path-tokens.ts';
15+
import { createTargetMcpRuntime } from '../services/mcp-runtime.ts';
16+
import capabilityTable from './capabilities/cursor-2026-08-28.json' with { type: 'json' };
17+
import schemaProvenance from './schemas/cursor/PROVENANCE.json' with { type: 'json' };
18+
import hooksSchema from './schemas/cursor/hooks.schema.json' with { type: 'json' };
19+
import mcpSchema from './schemas/cursor/mcp.schema.json' with { type: 'json' };
20+
import pluginSchema from './schemas/cursor/plugin.schema.json' with { type: 'json' };
21+
import {
22+
createAdapterValidator,
23+
schemaDescriptorsFrom,
24+
standardArtifactLayout,
25+
standardPluginArtifactPlan,
26+
validateJsonSchemaDocument,
27+
validateModernMcpDocument,
28+
type TargetAdapter,
29+
type TargetArtifactPlan,
30+
} from './types.ts';
31+
32+
const cursorName = 'cursor';
33+
34+
/**
35+
* Cursor's conventional artifact document paths, shared with the unified
36+
* bundle adapter. Cursor auto-discovers `mcp.json` at the plugin root (never
37+
* the Claude-convention `.mcp.json`); the manifest still carries an explicit
38+
* pointer so relocations stay impossible to configure apart. Hooks are not
39+
* emitted by any target until Cursor's hook stdin contract is pinned; the
40+
* hooks document is declared only so a hand-authored one validates against
41+
* the pinned schema.
42+
*/
43+
export const cursorArtifactPaths = Object.freeze({
44+
hooks: 'hooks/hooks.json',
45+
mcp: 'mcp.json',
46+
plugin: '.cursor-plugin/plugin.json',
47+
});
48+
49+
const validator = createAdapterValidator();
50+
const validatePlugin = validator.compile(pluginSchema);
51+
const validateMcp = validator.compile(mcpSchema);
52+
const validateHooks = validator.compile(hooksSchema);
53+
54+
/** The pinned Cursor document validators, shared with the unified bundle adapter. */
55+
export const cursorPluginValidator = validatePlugin;
56+
export const cursorMcpValidator = validateMcp;
57+
export const cursorHooksValidator = validateHooks;
58+
59+
const cursorNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u;
60+
61+
/** True when a plugin name satisfies Cursor's lowercase kebab-case contract. */
62+
export const isValidCursorPluginName = (name: string): boolean =>
63+
cursorNamePattern.test(name) && name.length <= 64;
64+
65+
/** The Cursor hooks document every target emits until the hook stdin contract is pinned. */
66+
export const emptyCursorHooksDocument = Object.freeze({ hooks: {}, version: 1 });
67+
68+
/**
69+
* Cursor documents `${env:NAME}` / `${workspaceFolder}` interpolation and
70+
* `${CURSOR_PLUGIN_ROOT}` for hook commands; the same root variable is the
71+
* best-documented spelling for plugin-contained MCP entry paths.
72+
*/
73+
export const expandCursorToken = (value: string): string => value
74+
.replaceAll(pathTokens.pluginRoot, '${CURSOR_PLUGIN_ROOT}')
75+
.replaceAll(pathTokens.workspaceRoot, '${workspaceFolder}');
76+
77+
export interface CursorMcpServerPlan {
78+
readonly diagnostics: readonly Diagnostic[];
79+
readonly value?: Record<string, unknown>;
80+
}
81+
82+
export interface CursorMcpServerPlanContext {
83+
/** Diagnostic code prefix, e.g. `cursor` or the bundle's `plugin.cursor`. */
84+
readonly codePrefix: string;
85+
readonly errorDiagnostic: (code: string, message: string) => Diagnostic;
86+
}
87+
88+
/** Lowers one normalized MCP server into Cursor's typeless document shape. */
89+
export const planCursorMcpServer = (
90+
server: NormalizedMcpServer,
91+
{ codePrefix, errorDiagnostic }: CursorMcpServerPlanContext,
92+
): CursorMcpServerPlan => {
93+
const transport = readMcpTransport(server);
94+
const transportDiagnostic = unsupportedMcpTransportDiagnostic(server, transport);
95+
if (transportDiagnostic !== undefined) return { diagnostics: [transportDiagnostic] };
96+
const values = [server.command, ...(server.args ?? []), server.url, ...Object.values(server.env ?? {}), ...Object.values(server.headers ?? {})];
97+
if (values.some((value) => value !== undefined && value.includes(pathTokens.pluginData))) {
98+
return {
99+
diagnostics: [errorDiagnostic(
100+
`${codePrefix}.mcp.token`,
101+
`MCP server ${JSON.stringify(server.name)} uses a plugin-data path token with no documented Cursor equivalent.`,
102+
)],
103+
};
104+
}
105+
if (transport === 'stdio') {
106+
if (server.command === undefined) {
107+
return {
108+
diagnostics: [errorDiagnostic(`${codePrefix}.mcp.command`, `MCP server ${JSON.stringify(server.name)} requires a command.`)],
109+
};
110+
}
111+
const args = server.args?.map(expandCursorToken);
112+
if (server.source !== undefined && server.cwd === pathTokens.pluginRoot && args?.[0] !== undefined) {
113+
args[0] = `\${CURSOR_PLUGIN_ROOT}/${args[0]}`;
114+
}
115+
const env = server.env === undefined
116+
? undefined
117+
: Object.fromEntries(Object.entries(server.env).map(([key, value]) => [key, expandCursorToken(value)]));
118+
return {
119+
diagnostics: [],
120+
value: {
121+
...(args === undefined ? {} : { args }),
122+
command: expandCursorToken(server.command),
123+
...(env === undefined ? {} : { env }),
124+
},
125+
};
126+
}
127+
if (server.url === undefined) {
128+
return {
129+
diagnostics: [errorDiagnostic(`${codePrefix}.mcp.url`, `MCP server ${JSON.stringify(server.name)} requires a URL.`)],
130+
};
131+
}
132+
const headers = server.headers === undefined
133+
? undefined
134+
: Object.fromEntries(Object.entries(server.headers).map(([key, value]) => [key, expandCursorToken(value)]));
135+
return {
136+
diagnostics: [],
137+
value: {
138+
...(headers === undefined ? {} : { headers }),
139+
url: expandCursorToken(server.url),
140+
},
141+
};
142+
};
143+
144+
export interface CursorManifestPointers {
145+
readonly hooks?: string;
146+
readonly mcp?: string;
147+
readonly skills?: string;
148+
}
149+
150+
/** Builds the `.cursor-plugin/plugin.json` manifest with explicit document pointers. */
151+
export const cursorManifest = (
152+
model: NormalizedPlugin,
153+
pointers: CursorManifestPointers,
154+
): Record<string, unknown> => ({
155+
description: model.metadata.description ?? model.metadata.name,
156+
displayName: model.metadata.name,
157+
...(pointers.hooks === undefined ? {} : { hooks: pointers.hooks }),
158+
...(pointers.mcp === undefined ? {} : { mcpServers: pointers.mcp }),
159+
name: model.metadata.name,
160+
...(pointers.skills === undefined ? {} : { skills: pointers.skills }),
161+
version: model.metadata.version,
162+
});
163+
164+
const metadata = Object.freeze({
165+
adapterRevision: '1.0.0',
166+
capabilityRevision: capabilityTable.observedCliVersion,
167+
capabilitySha256: 'c9e916ce4caf1865f57078765c27f47a2d225796ac36c1b65ccadf6a5290c86e',
168+
observedVersion: capabilityTable.observedCliVersion,
169+
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
170+
});
171+
172+
const artifactValidation = Object.freeze({
173+
documents: Object.freeze([
174+
Object.freeze({ path: cursorArtifactPaths.hooks, required: false, schema: 'hooks' }),
175+
Object.freeze({ path: cursorArtifactPaths.mcp, required: false, schema: 'mcp' }),
176+
Object.freeze({ path: cursorArtifactPaths.plugin, required: true, schema: 'plugin' }),
177+
]),
178+
schemas: Object.freeze([
179+
Object.freeze({ name: 'hooks', validate: validateJsonSchemaDocument(validateHooks) }),
180+
Object.freeze({ name: 'mcp', validate: validateModernMcpDocument(validateJsonSchemaDocument(validateMcp)) }),
181+
Object.freeze({ name: 'plugin', validate: validateJsonSchemaDocument(validatePlugin) }),
182+
]),
183+
});
184+
185+
/**
186+
* Cursor's MCP document is shape-discriminated: stdio entries declare a
187+
* `command` and remote entries declare a `url`; the format has no `type`
188+
* field. A record declaring both (or neither) has no defined transport.
189+
*/
190+
const cursorServerType = (server: unknown): string | undefined => {
191+
if (!isPlainDataRecord(server)) return undefined;
192+
const command = ownDataValue(server, 'command');
193+
const url = ownDataValue(server, 'url');
194+
if (command === undefined || url === undefined) return undefined;
195+
if (command.found === url.found) return undefined;
196+
if (command.found) return typeof command.value === 'string' ? 'stdio' : undefined;
197+
return typeof url.value === 'string' ? 'streamable-http' : undefined;
198+
};
199+
200+
const mcpRuntime = createTargetMcpRuntime({
201+
manifestPath: cursorArtifactPaths.mcp,
202+
readServerType: cursorServerType,
203+
remoteTypes: ['streamable-http'],
204+
resolveValue: createMcpPathTokenResolver({
205+
knownTokens: Object.freeze([...standardMcpPathTokens, '${CURSOR_PLUGIN_ROOT}', '${workspaceFolder}']),
206+
target: cursorName,
207+
tokens: allMcpPathTokenFields(Object.freeze({
208+
'${CURSOR_PLUGIN_ROOT}': 'pluginRoot',
209+
'${workspaceFolder}': 'workspaceRoot',
210+
})),
211+
}),
212+
});
213+
214+
const { errorDiagnostic, schemaDiagnostics } = createTargetDiagnostics(cursorName, 'Cursor');
215+
216+
const mcpPlanContext: CursorMcpServerPlanContext = Object.freeze({ codePrefix: cursorName, errorDiagnostic });
217+
218+
export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan => {
219+
const isSelected = (targets: readonly string[]): boolean => targets.includes(cursorName);
220+
const diagnostics: Diagnostic[] = [];
221+
const servers: Record<string, Record<string, unknown>> = Object.create(null) as Record<string, Record<string, unknown>>;
222+
for (const server of model.mcpServers) {
223+
if (!isSelected(server.targets)) continue;
224+
const serverPlan = planCursorMcpServer(server, mcpPlanContext);
225+
diagnostics.push(...serverPlan.diagnostics);
226+
if (serverPlan.value !== undefined) servers[server.name] = serverPlan.value;
227+
}
228+
const mcp = Object.keys(servers).length === 0 ? undefined : { mcpServers: servers };
229+
const mcpValid = mcp !== undefined && validateMcp(mcp);
230+
if (mcp !== undefined) diagnostics.push(...schemaDiagnostics('mcp', mcpValid, validateMcp.errors));
231+
232+
const plugin = cursorManifest(model, {
233+
...(mcp !== undefined && mcpValid ? { mcp: `./${cursorArtifactPaths.mcp}` } : {}),
234+
...(model.skills.some((skill) => isSelected(skill.targets)) ? { skills: './skills/' } : {}),
235+
});
236+
diagnostics.push(...schemaDiagnostics('plugin', validatePlugin(plugin), validatePlugin.errors));
237+
238+
return standardPluginArtifactPlan({
239+
diagnostics,
240+
hookDocumentValid: false,
241+
hookEntries: [],
242+
hookManifestPath: cursorArtifactPaths.hooks,
243+
isSelected,
244+
marketplaceRelativePath: '.cursor-plugin/marketplace.json',
245+
marketplaceValid: false,
246+
mcp,
247+
mcpRelativePath: cursorArtifactPaths.mcp,
248+
mcpValid,
249+
model,
250+
plugin,
251+
pluginRelativePath: cursorArtifactPaths.plugin,
252+
targetName: cursorName,
253+
});
254+
};
255+
256+
export const cursorAdapter: TargetAdapter = Object.freeze({
257+
artifactValidation,
258+
artifactLayout: Object.freeze({
259+
assets: standardArtifactLayout.assets,
260+
mcpApps: standardArtifactLayout.mcpApps,
261+
mcpEntries: standardArtifactLayout.mcpEntries,
262+
scripts: standardArtifactLayout.scripts,
263+
skills: standardArtifactLayout.skills,
264+
}),
265+
capabilities: Object.freeze({
266+
mcp: capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp,
267+
skills: capabilityTable.plugin.skills,
268+
}),
269+
metadata,
270+
mcpRuntime,
271+
name: cursorName,
272+
plan: planCursorArtifacts,
273+
});

0 commit comments

Comments
 (0)