@pulsemcp/air-adapter-cursor 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1018 @@
1
+ import { execSync } from "child_process";
2
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, copyFileSync, rmSync, statSync, } from "fs";
3
+ import { join, dirname, relative } from "path";
4
+ import { buildManifest, deleteManifest, diffManifest, getManifestPath, loadManifest, writeManifest, parseQualifiedId, resolveReference, } from "@pulsemcp/air-core";
5
+ import { scanLocalSkills } from "./scan-local-skills.js";
6
+ /** Matches a value that contains a `${VAR}` reference anywhere within it. */
7
+ const CONTAINS_VAR_RE = /\$\{[^}]+\}/;
8
+ /**
9
+ * Cursor's built-in `${...}` interpolation tokens. These are NOT environment
10
+ * variables and must be left untouched when rewriting AIR `${VAR}` refs into
11
+ * Cursor's `${env:VAR}` form — wrapping them would break Cursor's own
12
+ * expansion.
13
+ */
14
+ const CURSOR_BUILTIN_VARS = new Set([
15
+ "userHome",
16
+ "workspaceFolder",
17
+ "workspaceFolderBasename",
18
+ "pathSeparator",
19
+ ]);
20
+ export class CursorAdapter {
21
+ name = "cursor";
22
+ displayName = "Cursor";
23
+ /**
24
+ * Map AIR lifecycle event names to Cursor `hooks.json` event names.
25
+ *
26
+ * Accepts both snake_case AIR names and camelCase Cursor event names as
27
+ * identity mappings, so hook authors targeting the Cursor runtime can write
28
+ * Cursor-native event names directly without translating to snake_case.
29
+ *
30
+ * The only AIR event without a Cursor equivalent is `notification` — it is
31
+ * intentionally absent, so `reconcileHooks` warns and skips registration when
32
+ * it encounters it. Cursor-only events (beforeShellExecution, afterFileEdit,
33
+ * etc.) have no AIR analog and are therefore not generated by AIR, but
34
+ * user-authored hooks targeting them are preserved.
35
+ */
36
+ static AIR_TO_CURSOR_EVENT = {
37
+ // snake_case AIR names
38
+ session_start: "sessionStart",
39
+ session_end: "sessionEnd",
40
+ pre_tool_call: "preToolUse",
41
+ post_tool_call: "postToolUse",
42
+ user_prompt_submit: "beforeSubmitPrompt",
43
+ stop: "stop",
44
+ subagent_stop: "subagentStop",
45
+ pre_compact: "preCompact",
46
+ // camelCase Cursor event names (identity — hook authors targeting Cursor
47
+ // often write these directly).
48
+ sessionStart: "sessionStart",
49
+ sessionEnd: "sessionEnd",
50
+ preToolUse: "preToolUse",
51
+ postToolUse: "postToolUse",
52
+ postToolUseFailure: "postToolUseFailure",
53
+ subagentStart: "subagentStart",
54
+ subagentStop: "subagentStop",
55
+ beforeShellExecution: "beforeShellExecution",
56
+ afterShellExecution: "afterShellExecution",
57
+ beforeMCPExecution: "beforeMCPExecution",
58
+ afterMCPExecution: "afterMCPExecution",
59
+ beforeReadFile: "beforeReadFile",
60
+ afterFileEdit: "afterFileEdit",
61
+ beforeSubmitPrompt: "beforeSubmitPrompt",
62
+ preCompact: "preCompact",
63
+ afterAgentResponse: "afterAgentResponse",
64
+ afterAgentThought: "afterAgentThought",
65
+ beforeTabFileRead: "beforeTabFileRead",
66
+ afterTabFileEdit: "afterTabFileEdit",
67
+ workspaceOpen: "workspaceOpen",
68
+ };
69
+ /** Canonical AIR snake_case event names with a Cursor mapping. */
70
+ static AIR_EVENT_NAMES = [
71
+ "session_start",
72
+ "session_end",
73
+ "pre_tool_call",
74
+ "post_tool_call",
75
+ "user_prompt_submit",
76
+ "stop",
77
+ "subagent_stop",
78
+ "pre_compact",
79
+ ];
80
+ async isAvailable() {
81
+ try {
82
+ execSync("which cursor-agent", { stdio: "pipe" });
83
+ return true;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ }
89
+ generateConfig(artifacts, root, _workDir) {
90
+ const pluginActivations = root?.default_plugins
91
+ ? this.resolveActivations(artifacts.plugins, root.default_plugins, "plugin")
92
+ : [];
93
+ const plugins = {};
94
+ for (const a of pluginActivations)
95
+ plugins[a.qualified] = artifacts.plugins[a.qualified];
96
+ // Merge plugin-declared MCP servers and skills into root defaults (additive).
97
+ // All incoming IDs are qualified (post-canonicalization at composition time),
98
+ // so we deduplicate on qualified IDs.
99
+ const mcpQualSet = new Set(root?.default_mcp_servers ?? []);
100
+ const skillQualSet = new Set(root?.default_skills ?? []);
101
+ for (const plugin of Object.values(plugins)) {
102
+ if (plugin.mcp_servers) {
103
+ for (const id of plugin.mcp_servers)
104
+ mcpQualSet.add(id);
105
+ }
106
+ if (plugin.skills) {
107
+ for (const id of plugin.skills)
108
+ skillQualSet.add(id);
109
+ }
110
+ }
111
+ const mcpActivations = this.resolveActivations(artifacts.mcp, [...mcpQualSet], "MCP server");
112
+ const skillActivations = this.resolveActivations(artifacts.skills, [...skillQualSet], "skill");
113
+ const mcpServers = {};
114
+ for (const a of mcpActivations)
115
+ mcpServers[a.short] = artifacts.mcp[a.qualified];
116
+ const mcpConfig = this.translateMcpServersByShort(mcpServers);
117
+ const pluginConfigs = pluginActivations.map((a) => this.translatePlugin(a.short, artifacts.plugins[a.qualified]));
118
+ const skillPaths = skillActivations.map((a) => artifacts.skills[a.qualified].path);
119
+ return {
120
+ agent: "cursor",
121
+ mcpConfig,
122
+ pluginConfigs,
123
+ skillPaths,
124
+ env: {},
125
+ };
126
+ }
127
+ buildStartCommand(config) {
128
+ // Cursor auto-discovers `.cursor/mcp.json`, `.cursor/hooks.json`, and
129
+ // `.cursor/skills/` relative to its working directory. Pointing the working
130
+ // directory at the prepared directory loads everything; no extra flags are
131
+ // required.
132
+ return {
133
+ command: "cursor-agent",
134
+ args: [],
135
+ env: config.env,
136
+ cwd: config.workDir,
137
+ };
138
+ }
139
+ /**
140
+ * Prepare a working directory for a Cursor CLI session.
141
+ *
142
+ * Writes `.cursor/mcp.json` (MCP servers under `mcpServers`) and
143
+ * `.cursor/hooks.json` (hook registrations under `hooks.<event>`), injects
144
+ * skills + references into `.cursor/skills/<name>/`, copies path-based hooks
145
+ * into `.cursor/hooks/<name>/`, and returns the start command.
146
+ *
147
+ * Inputs to activation lists (root defaults, overrides, plugin-declared
148
+ * primitives) are accepted as either qualified (`@scope/id`) or short form;
149
+ * ambiguous short forms are rejected. Filesystem materialization uses
150
+ * shortnames — Cursor's config files, `.cursor/skills/`, `.cursor/hooks/`,
151
+ * and the manifest are scope-naive. Two activated qualified IDs that share
152
+ * a shortname hard-fail with a clear "add one to exclude" message.
153
+ *
154
+ * NOTE: `configFiles` is intentionally returned empty. AIR `${VAR}`
155
+ * references in MCP env/headers are rewritten to Cursor's native
156
+ * `${env:VAR}` interpolation at translation time, so there is no resolvable
157
+ * `${VAR}` left for AIR's transform/validation pipeline. The `.cursor/mcp.json`
158
+ * we wrote above is deliberately not surfaced as a config file.
159
+ */
160
+ async prepareSession(artifacts, targetDir, options) {
161
+ const root = options?.root;
162
+ const skillPaths = [];
163
+ const hookPaths = [];
164
+ const prevManifest = loadManifest(targetDir);
165
+ // 1. Resolve which artifacts to activate (overrides take precedence over root defaults)
166
+ let mcpServerIds = options?.mcpServerOverrides ?? root?.default_mcp_servers ?? undefined;
167
+ let skillIds = options?.skillOverrides ?? root?.default_skills ?? [];
168
+ let hookIds = options?.hookOverrides ?? root?.default_hooks ?? [];
169
+ // 1b. Merge subagent roots' artifacts if applicable.
170
+ const subagentRoots = this.resolveSubagentRoots(root, artifacts, options);
171
+ if (subagentRoots.length > 0) {
172
+ const merged = this.mergeSubagentArtifacts(subagentRoots, mcpServerIds, skillIds);
173
+ if (!options?.mcpServerOverrides) {
174
+ mcpServerIds = merged.mcpServerIds;
175
+ }
176
+ if (!options?.skillOverrides) {
177
+ skillIds = merged.skillIds;
178
+ }
179
+ }
180
+ // 1c. Resolve plugins and merge their declared artifacts (additive)
181
+ const pluginIds = options?.pluginOverrides ?? root?.default_plugins ?? undefined;
182
+ const pluginActivations = pluginIds?.length
183
+ ? this.resolveActivations(artifacts.plugins, pluginIds, "plugin")
184
+ : [];
185
+ const plugins = {};
186
+ for (const a of pluginActivations)
187
+ plugins[a.qualified] = artifacts.plugins[a.qualified];
188
+ const mcpSet = new Set(mcpServerIds ?? []);
189
+ const skillSet = new Set(skillIds);
190
+ const hookSet = new Set(hookIds);
191
+ for (const plugin of Object.values(plugins)) {
192
+ if (plugin.mcp_servers)
193
+ for (const id of plugin.mcp_servers)
194
+ mcpSet.add(id);
195
+ if (plugin.skills)
196
+ for (const id of plugin.skills)
197
+ skillSet.add(id);
198
+ if (plugin.hooks)
199
+ for (const id of plugin.hooks)
200
+ hookSet.add(id);
201
+ }
202
+ if (mcpSet.size > 0 || mcpServerIds !== undefined)
203
+ mcpServerIds = [...mcpSet];
204
+ skillIds = [...skillSet];
205
+ hookIds = [...hookSet];
206
+ // 2. Resolve activations: qualified ID + shortname per artifact.
207
+ // Throws on unknown IDs, ambiguous shortnames, and shortname collisions.
208
+ const skillActs = this.resolveActivations(artifacts.skills, skillIds, "skill");
209
+ const mcpActs = mcpServerIds
210
+ ? this.resolveActivations(artifacts.mcp, mcpServerIds, "MCP server")
211
+ : [];
212
+ const hookActs = this.resolveActivations(artifacts.hooks, hookIds, "hook");
213
+ const skillShortIds = skillActs.map((a) => a.short);
214
+ const hookShortIds = hookActs.map((a) => a.short);
215
+ const mcpShortIds = mcpActs.map((a) => a.short);
216
+ // 3. Reconcile against prior manifest using shortnames — those are the keys
217
+ // used for filesystem materialization and stored in the manifest.
218
+ const diff = diffManifest(prevManifest, {
219
+ skills: skillShortIds,
220
+ hooks: hookShortIds,
221
+ mcpServers: mcpShortIds,
222
+ });
223
+ for (const staleSkillId of diff.staleSkills) {
224
+ const staleDir = join(targetDir, ".cursor", "skills", staleSkillId);
225
+ if (existsSync(staleDir)) {
226
+ rmSync(staleDir, { recursive: true, force: true });
227
+ }
228
+ }
229
+ for (const staleHookId of diff.staleHooks) {
230
+ const staleDir = join(targetDir, ".cursor", "hooks", staleHookId);
231
+ if (existsSync(staleDir)) {
232
+ rmSync(staleDir, { recursive: true, force: true });
233
+ }
234
+ }
235
+ // 4. Inject skills + references into .cursor/skills/<short>/
236
+ const materializedSkillShortIds = [];
237
+ for (const a of skillActs) {
238
+ const skill = artifacts.skills[a.qualified];
239
+ const skillTargetDir = join(targetDir, ".cursor", "skills", a.short);
240
+ if (existsSync(skillTargetDir)) {
241
+ materializedSkillShortIds.push(a.short);
242
+ continue;
243
+ }
244
+ const skillSourceDir = skill.path;
245
+ if (!existsSync(skillSourceDir)) {
246
+ console.warn(this.missingSourceDirMessage("skill", a.qualified, skillSourceDir));
247
+ continue;
248
+ }
249
+ this.copyDirRecursive(skillSourceDir, skillTargetDir);
250
+ skillPaths.push(skillTargetDir);
251
+ materializedSkillShortIds.push(a.short);
252
+ if (skill.references && skill.references.length > 0) {
253
+ this.copyReferences(skill.references, skillTargetDir, artifacts);
254
+ }
255
+ }
256
+ // 5. Validate and inject path-based hooks into .cursor/hooks/<short>/.
257
+ const prevHookIds = new Set(prevManifest?.hooks ?? []);
258
+ const registeredHookShortIds = [];
259
+ const registeredHookActivations = [];
260
+ for (const a of hookActs) {
261
+ const hook = artifacts.hooks[a.qualified];
262
+ const hookTargetDir = join(targetDir, ".cursor", "hooks", a.short);
263
+ const alreadyExists = existsSync(hookTargetDir);
264
+ if (alreadyExists && !prevHookIds.has(a.short)) {
265
+ continue;
266
+ }
267
+ if (!alreadyExists) {
268
+ const hookSourceDir = hook.path;
269
+ if (!existsSync(hookSourceDir)) {
270
+ console.warn(this.missingSourceDirMessage("hook", a.qualified, hookSourceDir));
271
+ continue;
272
+ }
273
+ this.copyDirRecursive(hookSourceDir, hookTargetDir);
274
+ if (hook.references && hook.references.length > 0) {
275
+ this.copyReferences(hook.references, hookTargetDir, artifacts);
276
+ }
277
+ }
278
+ hookPaths.push(hookTargetDir);
279
+ registeredHookShortIds.push(a.short);
280
+ registeredHookActivations.push({ short: a.short, qualified: a.qualified });
281
+ }
282
+ // 6. Write `.cursor/mcp.json`: AIR-managed MCP servers, merged into any
283
+ // user-authored config (full replacement of AIR-owned keys; user keys
284
+ // preserved; stale AIR keys removed).
285
+ const translatedServers = {};
286
+ for (const a of mcpActs)
287
+ translatedServers[a.short] = artifacts.mcp[a.qualified];
288
+ this.writeCursorMcpConfig(targetDir, this.translateMcpServersByShort(translatedServers), diff.staleMcpServers);
289
+ // 6b. Write `.cursor/hooks.json`: AIR-owned hook registrations (tagged with
290
+ // `_air_hook_id`), merged into any user-authored hooks.
291
+ const managedHookIds = new Set([
292
+ ...diff.staleHooks,
293
+ ...registeredHookShortIds,
294
+ ]);
295
+ this.writeCursorHooksConfig(targetDir, hookPaths, managedHookIds);
296
+ // 7. Persist the updated manifest (shortnames — keyed by filesystem dir).
297
+ // Only record skills/hooks that were actually materialized so the manifest
298
+ // does not claim ownership of artifacts AIR skipped (e.g. a missing source dir).
299
+ writeManifest(buildManifest(targetDir, {
300
+ adapter: this.name,
301
+ skills: materializedSkillShortIds,
302
+ hooks: registeredHookShortIds,
303
+ mcpServers: mcpShortIds,
304
+ }));
305
+ // 8. Generate ephemeral subagent context for the session.
306
+ // Cursor has no `--append-system-prompt` equivalent, so this is surfaced
307
+ // to the caller via `subagentContext` rather than the start command.
308
+ let subagentContext;
309
+ if (subagentRoots.length > 0) {
310
+ subagentContext = this.buildSubagentContext(subagentRoots);
311
+ }
312
+ // 9. Build start command (working directory = prepared directory).
313
+ const config = this.generateConfig(artifacts, undefined, targetDir);
314
+ const startCommand = this.buildStartCommand({
315
+ ...config,
316
+ workDir: targetDir,
317
+ });
318
+ return {
319
+ // Empty by design — see method doc: AIR `${VAR}` references are rewritten
320
+ // to Cursor's native `${env:VAR}` interpolation at translation time, so
321
+ // there is nothing left for AIR's JSON transform/validation pipeline to
322
+ // resolve. The `.cursor/mcp.json` and `.cursor/hooks.json` we wrote above
323
+ // are deliberately not surfaced as config files.
324
+ configFiles: [],
325
+ skillPaths,
326
+ hookPaths,
327
+ hookActivations: registeredHookActivations,
328
+ startCommand,
329
+ subagentContext,
330
+ };
331
+ }
332
+ /**
333
+ * Enumerate skills checked into `<targetDir>/.cursor/skills/`. Cursor loads
334
+ * these directly from the filesystem regardless of AIR's involvement, so
335
+ * they're always active and must not be overwritten or removed. The TUI uses
336
+ * this list to surface them as read-only entries.
337
+ */
338
+ async listLocalArtifacts(targetDir) {
339
+ return { skills: scanLocalSkills(targetDir) };
340
+ }
341
+ /**
342
+ * Remove every artifact AIR has previously written into `targetDir`.
343
+ *
344
+ * Reads the per-target manifest, deletes each tracked skill / hook
345
+ * directory, removes tracked MCP server keys from `.cursor/mcp.json`, and
346
+ * removes AIR-managed hook entries from `.cursor/hooks.json`. When every
347
+ * category is cleaned, the manifest itself is deleted; partial cleans (any
348
+ * `keep*` flag set) update the manifest with the kept entries so future runs
349
+ * continue to track them.
350
+ *
351
+ * Items in the manifest that no longer exist on disk are silently skipped
352
+ * — the manifest can drift if a user removed files manually between runs.
353
+ */
354
+ async cleanSession(targetDir, options) {
355
+ const dryRun = options?.dryRun ?? false;
356
+ const cleanSkills = !(options?.keepSkills ?? false);
357
+ const cleanHooks = !(options?.keepHooks ?? false);
358
+ const cleanMcpServers = !(options?.keepMcpServers ?? false);
359
+ const fullClean = cleanSkills && cleanHooks && cleanMcpServers;
360
+ const manifestPath = getManifestPath(targetDir);
361
+ const manifestFileExists = existsSync(manifestPath);
362
+ const manifest = loadManifest(targetDir);
363
+ if (!manifest) {
364
+ let corruptManifestRemoved = false;
365
+ if (manifestFileExists && fullClean && !dryRun) {
366
+ corruptManifestRemoved = deleteManifest(targetDir);
367
+ }
368
+ return {
369
+ removedSkills: [],
370
+ removedHooks: [],
371
+ removedMcpServers: [],
372
+ mcpConfigPath: null,
373
+ settingsPath: null,
374
+ manifestPath,
375
+ manifestExisted: manifestFileExists,
376
+ manifestRemoved: corruptManifestRemoved,
377
+ };
378
+ }
379
+ const mcpPath = join(targetDir, ".cursor", "mcp.json");
380
+ const hooksPath = join(targetDir, ".cursor", "hooks.json");
381
+ const removedSkills = [];
382
+ if (cleanSkills) {
383
+ for (const id of manifest.skills) {
384
+ const dir = join(targetDir, ".cursor", "skills", id);
385
+ if (!existsSync(dir))
386
+ continue;
387
+ if (!dryRun)
388
+ rmSync(dir, { recursive: true, force: true });
389
+ removedSkills.push(id);
390
+ }
391
+ }
392
+ const removedHooks = [];
393
+ if (cleanHooks) {
394
+ for (const id of manifest.hooks) {
395
+ const dir = join(targetDir, ".cursor", "hooks", id);
396
+ if (!existsSync(dir))
397
+ continue;
398
+ if (!dryRun)
399
+ rmSync(dir, { recursive: true, force: true });
400
+ removedHooks.push(id);
401
+ }
402
+ }
403
+ // Prune AIR-owned MCP servers from `.cursor/mcp.json`.
404
+ const removedMcpServers = [];
405
+ let mcpConfigPath = null;
406
+ if (cleanMcpServers && manifest.mcpServers.length > 0 && existsSync(mcpPath)) {
407
+ const presentMcpIds = this.mcpServerIdsPresent(mcpPath, manifest.mcpServers);
408
+ if (presentMcpIds.length > 0) {
409
+ if (!dryRun) {
410
+ mcpConfigPath = this.pruneCursorMcpConfig(mcpPath, presentMcpIds);
411
+ }
412
+ else {
413
+ mcpConfigPath = mcpPath;
414
+ }
415
+ for (const id of presentMcpIds)
416
+ removedMcpServers.push(id);
417
+ }
418
+ }
419
+ // Prune AIR-owned hook entries from `.cursor/hooks.json`.
420
+ let settingsPath = null;
421
+ if (cleanHooks && manifest.hooks.length > 0 && existsSync(hooksPath)) {
422
+ if (this.hookEntriesPresent(hooksPath, new Set(manifest.hooks))) {
423
+ if (!dryRun) {
424
+ settingsPath = this.pruneCursorHooksConfig(hooksPath, new Set(manifest.hooks));
425
+ }
426
+ else {
427
+ settingsPath = hooksPath;
428
+ }
429
+ }
430
+ }
431
+ let manifestRemoved = false;
432
+ if (fullClean) {
433
+ if (!dryRun) {
434
+ manifestRemoved = deleteManifest(targetDir);
435
+ }
436
+ else {
437
+ manifestRemoved = manifestFileExists;
438
+ }
439
+ }
440
+ else if (!dryRun) {
441
+ writeManifest(buildManifest(targetDir, {
442
+ adapter: manifest.adapter ?? this.name,
443
+ skills: cleanSkills ? [] : manifest.skills,
444
+ hooks: cleanHooks ? [] : manifest.hooks,
445
+ mcpServers: cleanMcpServers ? [] : manifest.mcpServers,
446
+ }));
447
+ }
448
+ return {
449
+ removedSkills,
450
+ removedHooks,
451
+ removedMcpServers,
452
+ // Cursor splits MCP servers (`.cursor/mcp.json`) and hooks
453
+ // (`.cursor/hooks.json`) across two files; report them separately.
454
+ mcpConfigPath,
455
+ settingsPath,
456
+ manifestPath,
457
+ manifestExisted: true,
458
+ manifestRemoved,
459
+ };
460
+ }
461
+ /**
462
+ * Read `.cursor/mcp.json` and return the subset of `ids` whose key is
463
+ * actually present under `mcpServers`. Returns an empty list if the file
464
+ * can't be parsed — we'd rather under-report than claim to have removed
465
+ * entries we never touched.
466
+ */
467
+ mcpServerIdsPresent(mcpPath, ids) {
468
+ const existing = this.readJson(mcpPath);
469
+ const servers = existing.mcpServers ?? {};
470
+ return ids.filter((id) => Object.prototype.hasOwnProperty.call(servers, id));
471
+ }
472
+ /**
473
+ * Return true if `.cursor/hooks.json` contains at least one AIR-owned hook
474
+ * entry (`_air_hook_id` ∈ `managedHookIds`). Used to avoid rewriting a file
475
+ * we wouldn't actually change.
476
+ */
477
+ hookEntriesPresent(hooksPath, managedHookIds) {
478
+ const config = this.readJson(hooksPath);
479
+ const hooks = config.hooks ?? {};
480
+ for (const entries of Object.values(hooks)) {
481
+ if (!Array.isArray(entries))
482
+ continue;
483
+ for (const e of entries) {
484
+ if (this.isManagedHookEntry(e, managedHookIds))
485
+ return true;
486
+ }
487
+ }
488
+ return false;
489
+ }
490
+ /**
491
+ * Resolve subagent roots from the root's default_subagent_roots.
492
+ * IDs are already qualified after composition-time canonicalization.
493
+ */
494
+ resolveSubagentRoots(root, artifacts, options) {
495
+ if (options?.skipSubagentMerge)
496
+ return [];
497
+ if (!root?.default_subagent_roots?.length)
498
+ return [];
499
+ const resolved = [];
500
+ for (const id of root.default_subagent_roots) {
501
+ const res = resolveReference(artifacts.roots, id, undefined);
502
+ if (res.status === "ok") {
503
+ resolved.push(artifacts.roots[res.qualified]);
504
+ }
505
+ }
506
+ return resolved;
507
+ }
508
+ /**
509
+ * Merge subagent roots' default_mcp_servers and default_skills into the
510
+ * parent's activated sets (union, preserving order with parent first).
511
+ */
512
+ mergeSubagentArtifacts(subagentRoots, parentMcpServerIds, parentSkillIds) {
513
+ const mcpSet = new Set(parentMcpServerIds ?? []);
514
+ const skillSet = new Set(parentSkillIds);
515
+ for (const sub of subagentRoots) {
516
+ if (sub.default_mcp_servers) {
517
+ for (const id of sub.default_mcp_servers)
518
+ mcpSet.add(id);
519
+ }
520
+ if (sub.default_skills) {
521
+ for (const id of sub.default_skills)
522
+ skillSet.add(id);
523
+ }
524
+ }
525
+ return {
526
+ mcpServerIds: parentMcpServerIds !== undefined || mcpSet.size > 0 ? [...mcpSet] : undefined,
527
+ skillIds: [...skillSet],
528
+ };
529
+ }
530
+ /**
531
+ * Build a system prompt section describing the subagent root dependencies.
532
+ */
533
+ buildSubagentContext(subagentRoots) {
534
+ const lines = [
535
+ "## Subagent Root Dependencies",
536
+ "",
537
+ "This session includes capabilities from the following subagent roots.",
538
+ "Their skills and MCP servers have been merged into your session.",
539
+ "",
540
+ ];
541
+ for (const sub of subagentRoots) {
542
+ lines.push(`### ${sub.display_name || "Subagent"}`);
543
+ lines.push("");
544
+ lines.push(`**Description**: ${sub.description}`);
545
+ if (sub.default_mcp_servers?.length) {
546
+ lines.push(`**MCP Servers**: ${sub.default_mcp_servers.join(", ")}`);
547
+ }
548
+ if (sub.default_skills?.length) {
549
+ lines.push(`**Skills**: ${sub.default_skills.join(", ")}`);
550
+ }
551
+ if (sub.subdirectory) {
552
+ lines.push(`**Subdirectory**: ${sub.subdirectory}`);
553
+ }
554
+ lines.push("");
555
+ }
556
+ return lines.join("\n");
557
+ }
558
+ /**
559
+ * Translate a shortname-keyed MCP server map into Cursor's `mcpServers`
560
+ * shape (a plain object ready for JSON serialization). Callers convert
561
+ * qualified IDs to shortnames before invoking this — Cursor's config is
562
+ * scope-naive.
563
+ *
564
+ * Secret handling is Cursor-native: AIR `${VAR}` references in any string
565
+ * value (command, args, env values, url, headers) are rewritten to Cursor's
566
+ * `${env:VAR}` interpolation, which Cursor expands from the host environment
567
+ * at launch (see `toCursorVars`). Because Cursor's interpolation works
568
+ * anywhere in a string, partial (`"Bearer ${TOKEN}"`) and renamed
569
+ * (`KEY = "${OTHER}"`) references all forward cleanly — there are no
570
+ * unforwardable shapes, so no warnings are emitted.
571
+ */
572
+ translateMcpServersByShort(servers) {
573
+ const out = {};
574
+ for (const [name, server] of Object.entries(servers)) {
575
+ out[name] = this.translateMcpServer(server);
576
+ }
577
+ return out;
578
+ }
579
+ translateMcpServer(server) {
580
+ if (server.type === "stdio") {
581
+ const out = { command: this.toCursorVars(server.command) };
582
+ if (server.args && server.args.length > 0) {
583
+ out.args = server.args.map((a) => this.toCursorVars(a));
584
+ }
585
+ if (server.env && Object.keys(server.env).length > 0) {
586
+ const env = {};
587
+ for (const [key, value] of Object.entries(server.env)) {
588
+ env[key] = this.toCursorVars(value);
589
+ }
590
+ out.env = env;
591
+ }
592
+ return out;
593
+ }
594
+ // Remote server (sse or streamable-http). Cursor models both as a URL-based
595
+ // server and auto-detects the transport from the URL.
596
+ const out = { url: this.toCursorVars(server.url) };
597
+ if (server.headers && Object.keys(server.headers).length > 0) {
598
+ const headers = {};
599
+ for (const [key, value] of Object.entries(server.headers)) {
600
+ headers[key] = this.toCursorVars(value);
601
+ }
602
+ out.headers = headers;
603
+ }
604
+ // NOTE: AIR's detailed OAuth config (clientId/scopes/redirectUri/…) has no
605
+ // static Cursor equivalent — Cursor performs interactive OAuth via
606
+ // `cursor-agent mcp login <name>`. The gap is documented in the adapter README.
607
+ return out;
608
+ }
609
+ /**
610
+ * Rewrite AIR `${VAR}` references into Cursor's native `${env:VAR}`
611
+ * interpolation. Each `${...}` token is wrapped unless it is already a
612
+ * Cursor `${env:...}` reference or one of Cursor's built-in interpolation
613
+ * tokens (`${userHome}`, `${workspaceFolder}`, …), which must be left
614
+ * untouched. Strings without a `${...}` token are returned unchanged.
615
+ */
616
+ toCursorVars(value) {
617
+ if (typeof value !== "string")
618
+ return value;
619
+ if (!CONTAINS_VAR_RE.test(value))
620
+ return value;
621
+ return value.replace(/\$\{([^}]+)\}/g, (full, inner) => {
622
+ if (inner.startsWith("env:"))
623
+ return full; // already a Cursor env ref
624
+ if (CURSOR_BUILTIN_VARS.has(inner))
625
+ return full; // Cursor built-in token
626
+ return `\${env:${inner}}`;
627
+ });
628
+ }
629
+ /**
630
+ * Translate an AIR plugin to a Cursor-facing descriptor.
631
+ *
632
+ * Cursor's marketplace plugins are remote-installed and have no local package
633
+ * file an adapter can materialize. AIR therefore treats plugins as composition
634
+ * sugar: a plugin's declared MCP servers / skills / hooks are expanded into the
635
+ * activation set and materialized as their underlying Cursor-native artifacts.
636
+ * This descriptor is informational only.
637
+ */
638
+ translatePlugin(shortId, plugin) {
639
+ return {
640
+ name: shortId,
641
+ description: plugin.description,
642
+ ...(plugin.version && { version: plugin.version }),
643
+ };
644
+ }
645
+ /**
646
+ * Resolve a list of activation IDs (each qualified or short) into qualified
647
+ * IDs paired with shortnames suitable for filesystem materialization.
648
+ *
649
+ * Throws on:
650
+ * - unknown IDs (after attempting both qualified and short-form lookup)
651
+ * - ambiguous short references (multiple scopes contribute the shortname)
652
+ * - shortname collisions in the activation set itself (two qualified IDs
653
+ * with the same shortname can't share a single materialization dir)
654
+ */
655
+ resolveActivations(pool, ids, artifactType) {
656
+ const acts = [];
657
+ const errors = [];
658
+ const shortToQualified = new Map();
659
+ for (const id of ids) {
660
+ const res = resolveReference(pool, id, undefined);
661
+ if (res.status === "missing") {
662
+ errors.push(`Unknown ${artifactType} ID "${id}". Available: ${this.formatPoolKeys(pool)}.`);
663
+ continue;
664
+ }
665
+ if (res.status === "ambiguous") {
666
+ errors.push(`${artifactType} reference "${id}" is ambiguous — candidates: ` +
667
+ `${res.candidates.join(", ")}. Use the qualified form to disambiguate.`);
668
+ continue;
669
+ }
670
+ const qualified = res.qualified;
671
+ const { id: short } = parseQualifiedId(qualified);
672
+ const prior = shortToQualified.get(short);
673
+ if (prior !== undefined && prior !== qualified) {
674
+ errors.push(`${artifactType} shortname collision: both "${prior}" and "${qualified}" ` +
675
+ `are activated and would write to the same target name "${short}". ` +
676
+ `Add one to air.json#exclude or activate only one of them.`);
677
+ continue;
678
+ }
679
+ if (prior === qualified)
680
+ continue; // dedup
681
+ shortToQualified.set(short, qualified);
682
+ acts.push({ qualified, short });
683
+ }
684
+ if (errors.length > 0) {
685
+ throw new Error(errors.length === 1 ? errors[0] : `Activation errors:\n - ${errors.join("\n - ")}`);
686
+ }
687
+ return acts;
688
+ }
689
+ /**
690
+ * Build the warning emitted when a registered artifact's `path` does not
691
+ * exist on disk at materialization time. The qualified ID encodes the
692
+ * declaring catalog's scope, so a reviewer can trace the offending entry
693
+ * back to its index file. Materialization is skipped for this artifact and
694
+ * the rest of the session proceeds.
695
+ */
696
+ missingSourceDirMessage(artifactType, qualified, resolvedPath) {
697
+ return (`warning: ${artifactType} "${qualified}" declares path "${resolvedPath}" but that directory does not exist — skipping. ` +
698
+ `The catalog that contributed "${qualified}" registered a path AIR cannot materialize. ` +
699
+ `Fix the \`path\` field in the catalog's index file (or exclude the artifact in air.json) to restore the ${artifactType}.`);
700
+ }
701
+ formatPoolKeys(pool) {
702
+ const keys = Object.keys(pool);
703
+ if (keys.length === 0)
704
+ return "(none)";
705
+ if (keys.length > 8) {
706
+ return `${keys.slice(0, 8).join(", ")}, … (${keys.length} total)`;
707
+ }
708
+ return keys.join(", ");
709
+ }
710
+ /**
711
+ * Copy referenced documents into a references/ subdirectory of the target.
712
+ * `refIds` are qualified IDs (post-canonicalization).
713
+ */
714
+ copyReferences(refIds, targetDir, artifacts) {
715
+ const refsTargetDir = join(targetDir, "references");
716
+ for (const refId of refIds) {
717
+ const ref = artifacts.references[refId];
718
+ if (!ref)
719
+ continue;
720
+ const refSourcePath = ref.path;
721
+ if (existsSync(refSourcePath)) {
722
+ const refTargetPath = join(refsTargetDir, ref.path.split("/").pop() || ref.path);
723
+ mkdirSync(dirname(refTargetPath), { recursive: true });
724
+ copyFileSync(refSourcePath, refTargetPath);
725
+ }
726
+ }
727
+ }
728
+ // ============================================================
729
+ // `.cursor/mcp.json` and `.cursor/hooks.json` read / merge / write
730
+ // ============================================================
731
+ /** Parse an existing JSON config, returning `{}` when absent/unparseable. */
732
+ readJson(path) {
733
+ if (!existsSync(path))
734
+ return {};
735
+ try {
736
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
737
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
738
+ ? parsed
739
+ : {};
740
+ }
741
+ catch {
742
+ return {};
743
+ }
744
+ }
745
+ writeJson(path, value) {
746
+ mkdirSync(dirname(path), { recursive: true });
747
+ writeFileSync(path, JSON.stringify(value, null, 2) + "\n");
748
+ }
749
+ /**
750
+ * Merge AIR-managed MCP servers into `.cursor/mcp.json`, preserving any
751
+ * user-authored config. Keys in `staleMcpIds` are removed; keys in
752
+ * `translatedServers` are set/replaced; other servers and top-level keys pass
753
+ * through. A run that leaves the config empty deletes the file rather than
754
+ * leaving an empty stub. Returns the path written, or null if the file was
755
+ * deleted / not created.
756
+ */
757
+ writeCursorMcpConfig(targetDir, translatedServers, staleMcpIds) {
758
+ const mcpPath = join(targetDir, ".cursor", "mcp.json");
759
+ const config = this.readJson(mcpPath);
760
+ const servers = config.mcpServers ?? {};
761
+ for (const id of staleMcpIds)
762
+ delete servers[id];
763
+ for (const [id, cfg] of Object.entries(translatedServers))
764
+ servers[id] = cfg;
765
+ if (Object.keys(servers).length > 0) {
766
+ config.mcpServers = servers;
767
+ }
768
+ else {
769
+ delete config.mcpServers;
770
+ }
771
+ if (Object.keys(config).length === 0) {
772
+ if (existsSync(mcpPath))
773
+ rmSync(mcpPath, { force: true });
774
+ return null;
775
+ }
776
+ this.writeJson(mcpPath, config);
777
+ return mcpPath;
778
+ }
779
+ /**
780
+ * Merge AIR-owned hook registrations into `.cursor/hooks.json`, preserving
781
+ * any user-authored hooks. AIR-owned entries (tagged with `_air_hook_id`)
782
+ * whose ID is in `managedHookIds` are pruned, then the current selection is
783
+ * registered. A run that leaves no hooks deletes the file (an empty hooks
784
+ * config is a useless stub) unless the user kept other top-level keys.
785
+ * Returns the path written, or null if the file was deleted / not created.
786
+ */
787
+ writeCursorHooksConfig(targetDir, newHookPaths, managedHookIds) {
788
+ const hooksPath = join(targetDir, ".cursor", "hooks.json");
789
+ const config = this.readJson(hooksPath);
790
+ const hooks = config.hooks ?? {};
791
+ // Prune AIR-owned entries whose ID is managed by this run.
792
+ for (const event of Object.keys(hooks)) {
793
+ const entries = hooks[event];
794
+ if (!Array.isArray(entries))
795
+ continue;
796
+ const kept = entries.filter((e) => !this.isManagedHookEntry(e, managedHookIds));
797
+ if (kept.length === 0) {
798
+ delete hooks[event];
799
+ }
800
+ else {
801
+ hooks[event] = kept;
802
+ }
803
+ }
804
+ // Register the current hook selection.
805
+ for (const hookPath of newHookPaths) {
806
+ const hookJsonPath = join(hookPath, "HOOK.json");
807
+ if (!existsSync(hookJsonPath))
808
+ continue;
809
+ let hookJson;
810
+ try {
811
+ hookJson = JSON.parse(readFileSync(hookJsonPath, "utf-8"));
812
+ }
813
+ catch {
814
+ continue;
815
+ }
816
+ const hookId = hookPath.split(/[\\/]/).filter(Boolean).pop() || "";
817
+ const rawEvent = hookJson.event;
818
+ const cursorEvent = typeof rawEvent === "string"
819
+ ? CursorAdapter.AIR_TO_CURSOR_EVENT[rawEvent]
820
+ : undefined;
821
+ if (!cursorEvent) {
822
+ if (rawEvent !== undefined) {
823
+ console.warn(`warning: hook "${hookId}" declares unrecognized event "${String(rawEvent)}" — skipping registration in .cursor/hooks.json. ` +
824
+ `Supported events: ${this.supportedEventList()}.`);
825
+ }
826
+ continue;
827
+ }
828
+ if (!hookJson.command)
829
+ continue;
830
+ const hookRelDir = relative(targetDir, hookPath);
831
+ const command = this.buildHookCommand(hookRelDir, hookPath, hookJson.command, hookJson.args);
832
+ const hookEntry = { command };
833
+ if (typeof hookJson.matcher === "string" && hookJson.matcher.length > 0) {
834
+ hookEntry.matcher = hookJson.matcher;
835
+ }
836
+ if (hookJson.timeout_seconds != null) {
837
+ hookEntry.timeout = hookJson.timeout_seconds;
838
+ }
839
+ hookEntry._air_hook_id = hookId;
840
+ if (!hooks[cursorEvent]) {
841
+ hooks[cursorEvent] = [];
842
+ }
843
+ hooks[cursorEvent].push(hookEntry);
844
+ }
845
+ if (Object.keys(hooks).length > 0) {
846
+ config.hooks = hooks;
847
+ if (config.version === undefined)
848
+ config.version = 1;
849
+ this.writeJson(hooksPath, config);
850
+ return hooksPath;
851
+ }
852
+ // No hooks remain. Drop the now-empty `hooks` key; if nothing meaningful is
853
+ // left (only `version`, or the object is empty), delete the file instead of
854
+ // leaving a useless stub. Preserve any other user-authored top-level keys.
855
+ delete config.hooks;
856
+ const remaining = Object.keys(config).filter((k) => k !== "version");
857
+ if (remaining.length === 0) {
858
+ if (existsSync(hooksPath))
859
+ rmSync(hooksPath, { force: true });
860
+ return null;
861
+ }
862
+ this.writeJson(hooksPath, config);
863
+ return hooksPath;
864
+ }
865
+ isManagedHookEntry(entry, managedHookIds) {
866
+ if (!entry || typeof entry !== "object")
867
+ return false;
868
+ const id = entry._air_hook_id;
869
+ if (typeof id !== "string")
870
+ return false;
871
+ return managedHookIds.has(id);
872
+ }
873
+ /**
874
+ * Remove `mcpIds` from `mcpServers` in `.cursor/mcp.json`, preserving
875
+ * user-authored entries and other top-level fields. Returns the path of the
876
+ * file that was rewritten, or null if the file became empty and was deleted.
877
+ */
878
+ pruneCursorMcpConfig(mcpPath, mcpIds) {
879
+ const config = this.readJson(mcpPath);
880
+ const servers = config.mcpServers ?? {};
881
+ for (const id of mcpIds)
882
+ delete servers[id];
883
+ if (Object.keys(servers).length > 0) {
884
+ config.mcpServers = servers;
885
+ }
886
+ else {
887
+ delete config.mcpServers;
888
+ }
889
+ if (Object.keys(config).length === 0) {
890
+ rmSync(mcpPath, { force: true });
891
+ return null;
892
+ }
893
+ this.writeJson(mcpPath, config);
894
+ return mcpPath;
895
+ }
896
+ /**
897
+ * Remove AIR-managed hook entries (matched by `_air_hook_id` ∈
898
+ * `managedHookIds`) from `.cursor/hooks.json`, preserving user-authored
899
+ * entries and other top-level fields. Returns the path of the file that was
900
+ * rewritten, or null if no hooks remain and the file was deleted.
901
+ */
902
+ pruneCursorHooksConfig(hooksPath, managedHookIds) {
903
+ const config = this.readJson(hooksPath);
904
+ const hooks = config.hooks ?? {};
905
+ for (const event of Object.keys(hooks)) {
906
+ const entries = hooks[event];
907
+ if (!Array.isArray(entries))
908
+ continue;
909
+ const kept = entries.filter((e) => !this.isManagedHookEntry(e, managedHookIds));
910
+ if (kept.length === 0) {
911
+ delete hooks[event];
912
+ }
913
+ else {
914
+ hooks[event] = kept;
915
+ }
916
+ }
917
+ if (Object.keys(hooks).length > 0) {
918
+ config.hooks = hooks;
919
+ this.writeJson(hooksPath, config);
920
+ return hooksPath;
921
+ }
922
+ delete config.hooks;
923
+ const remaining = Object.keys(config).filter((k) => k !== "version");
924
+ if (remaining.length === 0) {
925
+ rmSync(hooksPath, { force: true });
926
+ return null;
927
+ }
928
+ this.writeJson(hooksPath, config);
929
+ return hooksPath;
930
+ }
931
+ /**
932
+ * Build a shell command string from HOOK.json's command and args fields.
933
+ *
934
+ * Hook authors write paths relative to their own hook directory. Cursor
935
+ * invokes hooks with a working directory that is not guaranteed to be the
936
+ * project root, so hook-relative paths are anchored to the repository root
937
+ * via `"$(git rev-parse --show-toplevel)/.cursor/hooks/<id>/<path>"`. The
938
+ * double quotes keep the path safe for project directories with spaces.
939
+ *
940
+ * - `command` is anchored if it starts with `./` (explicit hook-relative).
941
+ * - Each `args` entry is anchored if it looks like a path AND the file
942
+ * exists under the hook's installed directory.
943
+ *
944
+ * Args that are not rewritten and contain shell metacharacters are
945
+ * single-quoted for safety.
946
+ */
947
+ buildHookCommand(hookRelDir, hookAbsDir, command, args) {
948
+ let cmd;
949
+ if (command.startsWith("./")) {
950
+ cmd = this.anchorHookPath(join(hookRelDir, command.slice(2)));
951
+ }
952
+ else {
953
+ cmd = command;
954
+ }
955
+ if (args && args.length > 0) {
956
+ const rewritten = args.map((a) => this.rewriteHookArgPath(a, hookRelDir, hookAbsDir));
957
+ const escaped = rewritten.map((r) => r.anchored
958
+ ? this.anchorHookPath(r.value)
959
+ : /[\s;&|`$"'\\]/.test(r.value)
960
+ ? `'${r.value.replace(/'/g, "'\\''")}'`
961
+ : r.value);
962
+ cmd += " " + escaped.join(" ");
963
+ }
964
+ return cmd;
965
+ }
966
+ /**
967
+ * Wrap a project-root-relative path in `"$(git rev-parse --show-toplevel)/..."`
968
+ * so the resolved path is independent of the cwd at hook invocation. Double
969
+ * quotes are required so the command substitution runs; characters that are
970
+ * special inside double quotes (`$`, `` ` ``, `"`, `\`) are escaped in the
971
+ * path component so an unusual hook ID or filename can't break out of the
972
+ * quoting.
973
+ */
974
+ anchorHookPath(projectRelPath) {
975
+ const safe = projectRelPath.replace(/[\\"$`]/g, "\\$&");
976
+ return `"$(git rev-parse --show-toplevel)/${safe}"`;
977
+ }
978
+ /**
979
+ * If `arg` is a hook-relative path that points at a real file under the
980
+ * hook's installed directory, return its project-root-relative form
981
+ * (`.cursor/hooks/<id>/<path>`) flagged as anchored so the caller can wrap it.
982
+ * Otherwise return `arg` unchanged with `anchored: false`.
983
+ */
984
+ rewriteHookArgPath(arg, hookRelDir, hookAbsDir) {
985
+ if (!arg)
986
+ return { value: arg, anchored: false };
987
+ if (arg.startsWith("-") || arg.startsWith("/") || arg.startsWith("~")) {
988
+ return { value: arg, anchored: false };
989
+ }
990
+ const hasExplicitPrefix = arg.startsWith("./");
991
+ const candidate = hasExplicitPrefix ? arg.slice(2) : arg;
992
+ if (!hasExplicitPrefix && !candidate.includes("/")) {
993
+ return { value: arg, anchored: false };
994
+ }
995
+ if (!existsSync(join(hookAbsDir, candidate))) {
996
+ return { value: arg, anchored: false };
997
+ }
998
+ return { value: join(hookRelDir, candidate), anchored: true };
999
+ }
1000
+ /** Human-readable list of the supported AIR hook event names (snake_case only). */
1001
+ supportedEventList() {
1002
+ return CursorAdapter.AIR_EVENT_NAMES.join(", ");
1003
+ }
1004
+ copyDirRecursive(src, dest) {
1005
+ mkdirSync(dest, { recursive: true });
1006
+ for (const entry of readdirSync(src)) {
1007
+ const srcPath = join(src, entry);
1008
+ const destPath = join(dest, entry);
1009
+ if (statSync(srcPath).isDirectory()) {
1010
+ this.copyDirRecursive(srcPath, destPath);
1011
+ }
1012
+ else {
1013
+ copyFileSync(srcPath, destPath);
1014
+ }
1015
+ }
1016
+ }
1017
+ }
1018
+ //# sourceMappingURL=cursor-adapter.js.map