@b-apm/integration-cursor 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sdju
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @b-apm/integration-cursor
2
+
3
+ > **UNSTABLE:** Early public release. APIs and on-disk layouts may change without a major bump. Not production-ready.
4
+
5
+ Minimal **Cursor** host for bapm (M5 polish + M9 MCP configure).
6
+
7
+ ## Detection vs forced target
8
+
9
+ | Mode | When active | Creates roots? |
10
+ | ------------------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- |
11
+ | **Auto-detect** | `.cursor/` **directory** **or** legacy `.cursorrules` **file** at project root | Only when materialize / configureMcp runs after a positive detect |
12
+ | **Forced** (`--target cursor`) | Explicit CLI/core forced target id, even if detect is false | MAY `mkdir` registered roots (`.cursor/`, `.agents/skills`, `.cursor/rules`, `.cursor/agents`) as needed |
13
+
14
+ Auto-detect without force MUST NOT create `.cursor/` solely for MCP opt-in. Forced activation is owned by Install / CLI; this package’s `detect` stays an honest presence predicate.
15
+
16
+ ## Deploy roots (tg-002 / tg-003)
17
+
18
+ Registered roots:
19
+
20
+ - `.agents/skills` — skills → `<name>/SKILL.md`
21
+ - `.cursor` — companion root (detection + rules/agents + MCP config)
22
+
23
+ ## Materialize routing
24
+
25
+ | Primitive type | Destination |
26
+ | -------------- | -------------------------------- |
27
+ | skill | `.agents/skills/<name>/SKILL.md` |
28
+ | instruction | `.cursor/rules/<name>.mdc` |
29
+ | agent | `.cursor/agents/<name>.md` |
30
+
31
+ Thin copy/write from source content (minimal frontmatter only when content is missing). Writes are idempotent overwrites and never escape registered roots.
32
+
33
+ **Skills / rules / agents materialize does not write `.cursor/mcp.json`.** MCP config is written only via the separate `configureMcp` path when install requests it.
34
+
35
+ `materialize` returns a `MaterializeReport` (`deployedFiles: { path }[]`) via `@b-apm/integration-api` for lock inventory.
36
+
37
+ ## MCP configure (`configureMcp`)
38
+
39
+ When install invokes `configureMcp` with eligible servers:
40
+
41
+ - Writes / updates `.cursor/mcp.json` under the registered `.cursor/` root only
42
+ - Shape: `{ "mcpServers": { "<name>": { "command"|"url"|… } } }` (stdio / http)
43
+ - Idempotent overwrite of bapm-owned server keys (keyed by server name); unknown user keys are preserved when present
44
+ - Returns a `ConfigureMcpReport` (`configPath`, `servers`, `deployedFiles`) for lock `mcp_*` inventory
45
+
46
+ ## Dependencies
47
+
48
+ Depends only on `@b-apm/integration-api` among bapm packages — not `@b-apm/core`.
49
+ Shared materialize helpers (`sanitizeName`, `assertUnderDeployRoots`, `readPrimitiveContent`, …) come from `@b-apm/integration-api`; this package keeps Cursor detect and path routing only.
50
+ Register via `createIntegrationRegistry().register(createCursorIntegration())` in CLI or tests.
51
+
52
+ Third-party authors publishing a custom host for object-map `targets` should prefer a named `createIntegration` factory (see `@b-apm/integration-api` README — loadable export contract). This package’s `createCursorIntegration` remains the Cursor-specific factory accepted by the CLI loader.
@@ -0,0 +1,18 @@
1
+ import { BapmIntegration } from "@b-apm/integration-api";
2
+ //#region src/createCursorIntegration.d.ts
3
+ /**
4
+ * Create the Cursor target.
5
+ * Detect: `.cursor/` directory **or** legacy `.cursorrules` file.
6
+ * Materialize: skills → `.agents/skills/<name>/SKILL.md`,
7
+ * instructions → `.cursor/rules/<name>.mdc`,
8
+ * agents → `.cursor/agents/<name>.md`,
9
+ * commands → `.cursor/commands/<name>.md`,
10
+ * hooks → merge `.cursor/hooks.json` (+ script copy under `.cursor/hooks/`).
11
+ * MCP: optional `configureMcp` writes `.cursor/mcp.json` (not via materialize).
12
+ */
13
+ declare function createCursorIntegration(options?: {
14
+ id?: string;
15
+ deployRoots?: string[];
16
+ }): BapmIntegration;
17
+ //#endregion
18
+ export { createCursorIntegration };
package/dist/index.mjs ADDED
@@ -0,0 +1,366 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { SHARED_COMMAND_FRONTMATTER_KEYS, assertUnderDeployRoots, compileMarkdownReport, copyHookScript, filterFrontmatterKeys, materializeSkill, primitivesList, primitivesMaterialize, readHookOwnershipSidecar, readPrimitiveContent, renderPrimitivesMarkdown, sanitizeName, stripOwnedHookCommands, writeDeployedFile, writeHookOwnershipSidecar } from "@b-apm/integration-api";
4
+ //#region src/createCursorIntegration.ts
5
+ const DEFAULT_DEPLOY_ROOTS = [".agents/skills", ".cursor"];
6
+ const MCP_JSON_REL = ".cursor/mcp.json";
7
+ const HOOKS_JSON_REL = ".cursor/hooks.json";
8
+ const HOOKS_OWNERSHIP_REL = ".cursor/bapm-hooks.json";
9
+ /**
10
+ * Create the Cursor target.
11
+ * Detect: `.cursor/` directory **or** legacy `.cursorrules` file.
12
+ * Materialize: skills → `.agents/skills/<name>/SKILL.md`,
13
+ * instructions → `.cursor/rules/<name>.mdc`,
14
+ * agents → `.cursor/agents/<name>.md`,
15
+ * commands → `.cursor/commands/<name>.md`,
16
+ * hooks → merge `.cursor/hooks.json` (+ script copy under `.cursor/hooks/`).
17
+ * MCP: optional `configureMcp` writes `.cursor/mcp.json` (not via materialize).
18
+ */
19
+ function createCursorIntegration(options) {
20
+ const id = options?.id ?? "cursor";
21
+ const deployRoots = [...options?.deployRoots ?? DEFAULT_DEPLOY_ROOTS];
22
+ return {
23
+ id,
24
+ deployRoots,
25
+ detect: ({ cwd }) => {
26
+ const cursorDir = join(cwd, ".cursor");
27
+ if (existsSync(cursorDir) && statSync(cursorDir).isDirectory()) return true;
28
+ const legacy = join(cwd, ".cursorrules");
29
+ return existsSync(legacy) && statSync(legacy).isFile();
30
+ },
31
+ getDeployRoots: () => [...deployRoots],
32
+ async compile(primitives, context) {
33
+ const content = renderPrimitivesMarkdown({
34
+ primitives: primitivesList(primitives),
35
+ title: "# AGENTS.md"
36
+ });
37
+ return compileMarkdownReport({
38
+ cwd: context.cwd,
39
+ outputFile: context.outputFile ?? "AGENTS.md",
40
+ write: context.write,
41
+ content,
42
+ outsideCwdMessage: "Cursor compile output must be a cwd-relative file path"
43
+ });
44
+ },
45
+ async materialize(primitives, ctx) {
46
+ const cwd = resolve(ctx?.cwd ?? process.cwd());
47
+ const roots = ctx?.deployRoots?.length ? [...ctx.deployRoots] : deployRoots;
48
+ if (!roots.some((r) => r === ".agents/skills" || r.startsWith(".agents/skills"))) throw new Error("cursor target missing .agents/skills deploy root");
49
+ if (!roots.some((r) => r === ".cursor" || r.startsWith(".cursor"))) throw new Error("cursor target missing .cursor deploy root");
50
+ const deployedFiles = [];
51
+ const diagnostics = [];
52
+ const hookPrimitives = [];
53
+ await primitivesMaterialize(primitives, {
54
+ skill(p, { name }) {
55
+ deployedFiles.push(...materializeSkill({
56
+ primitive: p,
57
+ cwd,
58
+ deployRoots: roots,
59
+ destDir: join(".agents", "skills", name)
60
+ }));
61
+ },
62
+ instruction(p, { name }) {
63
+ deployedFiles.push(writeDeployedFile({
64
+ cwd,
65
+ deployRoots: roots,
66
+ destRel: join(".cursor", "rules", `${name}.mdc`),
67
+ content: readPrimitiveContent(p),
68
+ primitive: {
69
+ name: String(p.name),
70
+ packageName: p.packageName
71
+ }
72
+ }));
73
+ },
74
+ agent(p, { name }) {
75
+ deployedFiles.push(writeDeployedFile({
76
+ cwd,
77
+ deployRoots: roots,
78
+ destRel: join(".cursor", "agents", `${name}.md`),
79
+ content: readPrimitiveContent(p),
80
+ primitive: {
81
+ name: String(p.name),
82
+ packageName: p.packageName
83
+ }
84
+ }));
85
+ },
86
+ command(p, { name }) {
87
+ const { content, droppedKeys } = filterFrontmatterKeys(readPrimitiveContent(p), SHARED_COMMAND_FRONTMATTER_KEYS);
88
+ deployedFiles.push(writeDeployedFile({
89
+ cwd,
90
+ deployRoots: roots,
91
+ destRel: join(".cursor", "commands", `${name}.md`),
92
+ content,
93
+ primitive: {
94
+ name: String(p.name),
95
+ packageName: p.packageName
96
+ }
97
+ }));
98
+ if (droppedKeys.length > 0) diagnostics.push({
99
+ code: "CURSOR_COMMAND_FRONTMATTER_DROPPED",
100
+ message: `Dropped non-preserved command frontmatter keys for "${p.name}": ${droppedKeys.join(", ")}`,
101
+ primitive: String(p.name),
102
+ droppedKeys
103
+ });
104
+ },
105
+ hook(p) {
106
+ hookPrimitives.push(p);
107
+ }
108
+ });
109
+ if (hookPrimitives.length > 0) {
110
+ const hookDeployed = materializeCursorHooks({
111
+ cwd,
112
+ roots,
113
+ hooks: hookPrimitives
114
+ });
115
+ deployedFiles.push(...hookDeployed.deployedFiles);
116
+ diagnostics.push(...hookDeployed.diagnostics);
117
+ }
118
+ return {
119
+ targetId: id,
120
+ deployedFiles,
121
+ ...diagnostics.length > 0 ? { diagnostics } : {}
122
+ };
123
+ },
124
+ async configureMcp(servers, ctx) {
125
+ return writeCursorMcpConfig(servers, {
126
+ cwd: resolve(ctx?.cwd ?? process.cwd()),
127
+ deployRoots: ctx?.deployRoots?.length ? [...ctx.deployRoots] : deployRoots,
128
+ targetId: ctx?.targetId ?? id
129
+ });
130
+ }
131
+ };
132
+ }
133
+ function writeCursorMcpConfig(servers, ctx) {
134
+ const { cwd, deployRoots } = ctx;
135
+ if (!deployRoots.some((r) => r === ".cursor" || r.startsWith(".cursor"))) throw new Error("cursor configureMcp requires a registered .cursor deploy root");
136
+ const destFile = join(cwd, ".cursor", "mcp.json");
137
+ assertUnderDeployRoots(cwd, destFile, deployRoots);
138
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
139
+ const existing = readExistingMcpServers(destFile);
140
+ const { entries: incoming, diagnostics } = normalizeServerEntries(servers);
141
+ const next = { ...existing };
142
+ const writtenNames = [];
143
+ for (const [name, entry] of Object.entries(incoming)) {
144
+ next[name] = entry;
145
+ writtenNames.push(name);
146
+ }
147
+ writeFileSync(destFile, `${JSON.stringify({ mcpServers: next }, null, 2)}\n`, "utf8");
148
+ return {
149
+ targetId: ctx.targetId,
150
+ configPath: MCP_JSON_REL,
151
+ servers: writtenNames,
152
+ deployedFiles: [{ path: MCP_JSON_REL }],
153
+ diagnostics
154
+ };
155
+ }
156
+ function readExistingMcpServers(path) {
157
+ if (!existsSync(path)) return {};
158
+ try {
159
+ const raw = JSON.parse(readFileSync(path, "utf8"));
160
+ return raw.mcpServers && typeof raw.mcpServers === "object" ? { ...raw.mcpServers } : {};
161
+ } catch {
162
+ return {};
163
+ }
164
+ }
165
+ function normalizeServerEntries(servers) {
166
+ const out = {};
167
+ const diagnostics = [];
168
+ const list = Array.isArray(servers) ? servers : Object.entries(servers).map(([name, value]) => ({
169
+ ...value,
170
+ name: value.name ?? name
171
+ }));
172
+ for (const server of list) {
173
+ const name = String(server.name ?? "").trim();
174
+ if (!name) continue;
175
+ const entry = toCursorServerEntry(server);
176
+ if (!entry) {
177
+ diagnostics.push({
178
+ code: "CURSOR_PORTABLE_MCP_UNSUPPORTED",
179
+ message: `Cursor cannot faithfully represent portable MCP server "${name}"`,
180
+ server: name
181
+ });
182
+ continue;
183
+ }
184
+ out[name] = entry;
185
+ }
186
+ return {
187
+ entries: out,
188
+ diagnostics
189
+ };
190
+ }
191
+ function toCursorServerEntry(server) {
192
+ const transport = String(server.transport ?? server.type ?? "").toLowerCase();
193
+ const entry = {};
194
+ if (server.format === "agent-plugin") {
195
+ if (transport === "stdio") {
196
+ if (typeof server.command !== "string") return void 0;
197
+ entry.command = server.command;
198
+ if (Array.isArray(server.args)) entry.args = server.args;
199
+ if (typeof server.cwd === "string") entry.cwd = server.cwd;
200
+ entry.type = "stdio";
201
+ } else if (transport === "streamable-http" || transport === "sse") {
202
+ if (typeof server.url !== "string") return void 0;
203
+ entry.url = server.url;
204
+ entry.type = transport === "streamable-http" ? "http" : "sse";
205
+ } else return;
206
+ if (server.env && typeof server.env === "object") entry.env = server.env;
207
+ return entry;
208
+ }
209
+ if (transport === "http" || transport === "sse" || typeof server.url === "string") {
210
+ if (server.url) entry.url = server.url;
211
+ if (transport) entry.type = transport === "sse" ? "sse" : "http";
212
+ } else {
213
+ if (server.command) entry.command = server.command;
214
+ if (Array.isArray(server.args)) entry.args = server.args;
215
+ entry.type = "stdio";
216
+ }
217
+ if (server.env && typeof server.env === "object") entry.env = server.env;
218
+ for (const [key, value] of Object.entries(server)) {
219
+ if ([
220
+ "name",
221
+ "transport",
222
+ "type",
223
+ "command",
224
+ "args",
225
+ "url",
226
+ "env",
227
+ "packageName",
228
+ "registry",
229
+ "format",
230
+ "cwd"
231
+ ].includes(key)) continue;
232
+ if (value !== void 0) entry[key] = value;
233
+ }
234
+ return entry;
235
+ }
236
+ function materializeCursorHooks(args) {
237
+ const { cwd, roots, hooks } = args;
238
+ const deployedFiles = [];
239
+ const diagnostics = [];
240
+ const hooksPath = join(cwd, HOOKS_JSON_REL);
241
+ const ownershipPath = join(cwd, HOOKS_OWNERSHIP_REL);
242
+ assertUnderDeployRoots(cwd, hooksPath, roots);
243
+ assertUnderDeployRoots(cwd, ownershipPath, roots);
244
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
245
+ const doc = readHooksDoc(hooksPath);
246
+ const ownership = readHookOwnershipSidecar(ownershipPath);
247
+ if (doc.hooks && typeof doc.hooks === "object") stripOwnedHookCommands(doc.hooks, ownership);
248
+ const nextOwned = {};
249
+ for (const p of hooks) {
250
+ const name = sanitizeName(String(p.name));
251
+ const srcPath = p.path ? resolve(p.path) : void 0;
252
+ if (!srcPath || !existsSync(srcPath) || !statSync(srcPath).isFile()) {
253
+ diagnostics.push({
254
+ code: "CURSOR_HOOK_SOURCE_MISSING",
255
+ message: `Hook primitive "${p.name}" has no readable source JSON`,
256
+ primitive: String(p.name)
257
+ });
258
+ continue;
259
+ }
260
+ let parsed;
261
+ try {
262
+ parsed = JSON.parse(readFileSync(srcPath, "utf8"));
263
+ } catch (cause) {
264
+ diagnostics.push({
265
+ code: "CURSOR_HOOK_JSON_INVALID",
266
+ message: `Hook "${p.name}" JSON is invalid: ${cause instanceof Error ? cause.message : String(cause)}`,
267
+ primitive: String(p.name)
268
+ });
269
+ continue;
270
+ }
271
+ const sourceHooks = parsed.hooks && typeof parsed.hooks === "object" ? parsed.hooks : {};
272
+ const ownedEntries = [];
273
+ const scripts = [];
274
+ if (!doc.hooks || typeof doc.hooks !== "object") doc.hooks = {};
275
+ if (typeof parsed.version === "number" && doc.version === void 0) doc.version = parsed.version;
276
+ for (const [event, entries] of Object.entries(sourceHooks)) {
277
+ if (!Array.isArray(entries)) continue;
278
+ const destList = Array.isArray(doc.hooks[event]) ? [...doc.hooks[event]] : [];
279
+ for (const entry of entries) {
280
+ if (!entry || typeof entry !== "object") continue;
281
+ const command = typeof entry.command === "string" ? entry.command.trim() : "";
282
+ if (!command) {
283
+ destList.push({ ...entry });
284
+ continue;
285
+ }
286
+ const rewritten = copyHookScript({
287
+ cwd,
288
+ deployRoots: roots,
289
+ hookFile: srcPath,
290
+ command,
291
+ alreadyDeployedNeedle: ".cursor/",
292
+ destRel: `.cursor/hooks/${name}/${basename(command.replace(/^\.\//, ""))}`,
293
+ commandAsDotSlash: true
294
+ });
295
+ const nextEntry = {
296
+ ...entry,
297
+ command: rewritten.commandRel
298
+ };
299
+ destList.push(nextEntry);
300
+ ownedEntries.push({
301
+ event,
302
+ command: rewritten.commandRel
303
+ });
304
+ if (rewritten.scriptRel) {
305
+ scripts.push(rewritten.scriptRel);
306
+ deployedFiles.push({
307
+ path: rewritten.scriptRel,
308
+ primitive: {
309
+ name: String(p.name),
310
+ packageName: p.packageName
311
+ }
312
+ });
313
+ }
314
+ }
315
+ doc.hooks[event] = destList;
316
+ }
317
+ nextOwned[name] = {
318
+ ...p.packageName ? { packageName: p.packageName } : {},
319
+ entries: ownedEntries,
320
+ scripts
321
+ };
322
+ }
323
+ if (doc.version === void 0) doc.version = 1;
324
+ writeFileSync(hooksPath, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
325
+ writeHookOwnershipSidecar(ownershipPath, { owned: nextOwned });
326
+ deployedFiles.push({
327
+ path: HOOKS_JSON_REL,
328
+ ...hooks[0] ? { primitive: {
329
+ name: String(hooks[0].name),
330
+ packageName: hooks[0].packageName
331
+ } } : {}
332
+ });
333
+ deployedFiles.push({
334
+ path: HOOKS_OWNERSHIP_REL,
335
+ ...hooks[0] ? { primitive: {
336
+ name: String(hooks[0].name),
337
+ packageName: hooks[0].packageName
338
+ } } : {}
339
+ });
340
+ return {
341
+ deployedFiles,
342
+ diagnostics
343
+ };
344
+ }
345
+ function readHooksDoc(path) {
346
+ if (!existsSync(path)) return {
347
+ version: 1,
348
+ hooks: {}
349
+ };
350
+ try {
351
+ const raw = JSON.parse(readFileSync(path, "utf8"));
352
+ if (!raw || typeof raw !== "object") return {
353
+ version: 1,
354
+ hooks: {}
355
+ };
356
+ if (!raw.hooks || typeof raw.hooks !== "object") raw.hooks = {};
357
+ return raw;
358
+ } catch {
359
+ return {
360
+ version: 1,
361
+ hooks: {}
362
+ };
363
+ }
364
+ }
365
+ //#endregion
366
+ export { createCursorIntegration };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@b-apm/integration-cursor",
3
+ "version": "0.1.0",
4
+ "description": "Cursor integration for bapm",
5
+ "license": "MIT",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "type": "module",
10
+ "exports": {
11
+ ".": "./dist/index.mjs",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "dependencies": {
18
+ "@b-apm/integration-api": "0.1.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^26.1.2",
22
+ "typescript": "^7.0.2",
23
+ "vite": "npm:@voidzero-dev/vite-plus-core@latest",
24
+ "vite-plus": "latest"
25
+ },
26
+ "engines": {
27
+ "node": ">=22.12.0"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/Sdju/bapm.git",
32
+ "directory": "packages/integration-cursor"
33
+ },
34
+ "homepage": "https://github.com/Sdju/bapm#readme",
35
+ "bugs": {
36
+ "url": "https://github.com/Sdju/bapm/issues"
37
+ },
38
+ "keywords": [
39
+ "unstable",
40
+ "bapm",
41
+ "b-apm"
42
+ ],
43
+ "scripts": {
44
+ "build": "vp pack",
45
+ "dev": "vp pack --watch",
46
+ "test": "vp test",
47
+ "check": "vp check"
48
+ }
49
+ }