@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.
- package/README.md +84 -0
- package/dist/cursor-adapter.d.ts +225 -0
- package/dist/cursor-adapter.d.ts.map +1 -0
- package/dist/cursor-adapter.js +1018 -0
- package/dist/cursor-adapter.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/scan-local-skills.d.ts +12 -0
- package/dist/scan-local-skills.d.ts.map +1 -0
- package/dist/scan-local-skills.js +93 -0
- package/dist/scan-local-skills.js.map +1 -0
- package/package.json +42 -0
|
@@ -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
|