From 133e881ce0f16c5dbaf43dc47f209d0aadebb697 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sun, 12 Apr 2026 23:32:39 -0700 Subject: [PATCH 1/5] fix: install Cursor rules as mdc files --- scripts/lib/install-targets/cursor-project.js | 5 +++++ scripts/lib/install-targets/helpers.js | 18 +++++++++++++++--- tests/lib/install-targets.test.js | 16 +++++++++++----- tests/scripts/install-apply.test.js | 4 ++-- 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/scripts/lib/install-targets/cursor-project.js b/scripts/lib/install-targets/cursor-project.js index 527ba2a6..2fd3b0ab 100644 --- a/scripts/lib/install-targets/cursor-project.js +++ b/scripts/lib/install-targets/cursor-project.js @@ -40,6 +40,11 @@ module.exports = createInstallTargetAdapter({ repoRoot, sourceRelativePath, destinationDir: path.join(targetRoot, 'rules'), + destinationNameTransform(fileName) { + return fileName.endsWith('.md') + ? `${fileName.slice(0, -3)}.mdc` + : fileName; + }, }); } diff --git a/scripts/lib/install-targets/helpers.js b/scripts/lib/install-targets/helpers.js index fd959aa7..dec17c06 100644 --- a/scripts/lib/install-targets/helpers.js +++ b/scripts/lib/install-targets/helpers.js @@ -181,7 +181,13 @@ function createNamespacedFlatRuleOperations(adapter, moduleId, sourceRelativePat return operations; } -function createFlatRuleOperations({ moduleId, repoRoot, sourceRelativePath, destinationDir }) { +function createFlatRuleOperations({ + moduleId, + repoRoot, + sourceRelativePath, + destinationDir, + destinationNameTransform, +}) { const normalizedSourcePath = normalizeRelativePath(sourceRelativePath); const sourceRoot = path.join(repoRoot || '', normalizedSourcePath); @@ -201,7 +207,10 @@ function createFlatRuleOperations({ moduleId, repoRoot, sourceRelativePath, dest if (entry.isDirectory()) { const relativeFiles = listRelativeFiles(entryPath); for (const relativeFile of relativeFiles) { - const flattenedFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`; + const defaultFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`; + const flattenedFileName = typeof destinationNameTransform === 'function' + ? destinationNameTransform(defaultFileName) + : defaultFileName; operations.push(createManagedOperation({ moduleId, sourceRelativePath: path.join(normalizedSourcePath, namespace, relativeFile), @@ -210,10 +219,13 @@ function createFlatRuleOperations({ moduleId, repoRoot, sourceRelativePath, dest })); } } else if (entry.isFile()) { + const destinationFileName = typeof destinationNameTransform === 'function' + ? destinationNameTransform(entry.name) + : entry.name; operations.push(createManagedOperation({ moduleId, sourceRelativePath: path.join(normalizedSourcePath, entry.name), - destinationPath: path.join(destinationDir, entry.name), + destinationPath: path.join(destinationDir, destinationFileName), strategy: 'flatten-copy', })); } diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index 35c22765..45e4f7d3 100644 --- a/tests/lib/install-targets.test.js +++ b/tests/lib/install-targets.test.js @@ -103,7 +103,7 @@ function runTests() { assert.strictEqual(preserved.strategy, 'flatten-copy'); assert.strictEqual( preserved.destinationPath, - path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md') + path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.mdc') ); })) passed++; else failed++; @@ -126,16 +126,16 @@ function runTests() { assert.ok( plan.operations.some(operation => ( normalizedRelativePath(operation.sourceRelativePath) === 'rules/common/coding-style.md' - && operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md') + && operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.mdc') )), - 'Should flatten common rules into namespaced files' + 'Should flatten common rules into namespaced .mdc files' ); assert.ok( plan.operations.some(operation => ( normalizedRelativePath(operation.sourceRelativePath) === 'rules/typescript/testing.md' - && operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'typescript-testing.md') + && operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'typescript-testing.mdc') )), - 'Should flatten language rules into namespaced files' + 'Should flatten language rules into namespaced .mdc files' ); assert.ok( !plan.operations.some(operation => ( @@ -143,6 +143,12 @@ function runTests() { )), 'Should not preserve nested rule directories for cursor installs' ); + assert.ok( + !plan.operations.some(operation => ( + operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-coding-style.md') + )), + 'Should not emit .md Cursor rule files' + ); })) passed++; else failed++; if (test('plans antigravity remaps for workflows, skills, and flat rules', () => { diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 06cc6cbb..f777c552 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -130,8 +130,8 @@ function runTests() { const result = run(['--target', 'cursor', 'typescript'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); - assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-coding-style.md'))); - assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'typescript-testing.md'))); + assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-coding-style.mdc'))); + assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'typescript-testing.mdc'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'agents', 'architect.md'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'commands', 'plan.md'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json'))); From bb96fdc9dc3d4138a518554cbb8f300e99815846 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sun, 12 Apr 2026 23:38:46 -0700 Subject: [PATCH 2/5] test: wait for http mcp fixtures to accept connections --- tests/hooks/mcp-health-check.test.js | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/hooks/mcp-health-check.test.js b/tests/hooks/mcp-health-check.test.js index 04d4a7b5..92a9804a 100644 --- a/tests/hooks/mcp-health-check.test.js +++ b/tests/hooks/mcp-health-check.test.js @@ -6,6 +6,8 @@ const assert = require('assert'); const fs = require('fs'); +const http = require('http'); +const https = require('https'); const os = require('os'); const path = require('path'); const { spawn, spawnSync } = require('child_process'); @@ -109,6 +111,39 @@ function waitForFile(filePath, timeoutMs = 5000) { } throw new Error(`Timed out waiting for ${filePath}`); } + +function waitForHttpReady(urlString, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + const { protocol } = new URL(urlString); + const client = protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const attempt = () => { + const req = client.request(urlString, { method: 'GET' }, res => { + res.resume(); + resolve(); + }); + + req.setTimeout(250, () => { + req.destroy(new Error('timeout')); + }); + + req.on('error', error => { + if (Date.now() >= deadline) { + reject(new Error(`Timed out waiting for ${urlString}: ${error.message}`)); + return; + } + + setTimeout(attempt, 25); + }); + + req.end(); + }; + + attempt(); + }); +} + async function runTests() { console.log('\n=== Testing mcp-health-check.js ===\n'); @@ -329,6 +364,7 @@ async function runTests() { try { const port = waitForFile(portFile).trim(); + await waitForHttpReady(`http://127.0.0.1:${port}/mcp`); writeConfig(configPath, { mcpServers: { @@ -391,6 +427,7 @@ async function runTests() { try { const port = waitForFile(portFile).trim(); + await waitForHttpReady(`http://127.0.0.1:${port}/mcp`); writeConfig(configPath, { mcpServers: { From 7374ef6a73abdc61f952f18638907e199817fced Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sun, 12 Apr 2026 23:51:58 -0700 Subject: [PATCH 3/5] fix: normalize cursor rule installs --- scripts/lib/install-targets/cursor-project.js | 45 +++++++++++-- scripts/lib/install-targets/helpers.js | 16 +++-- tests/lib/install-targets.test.js | 66 +++++++++++++++++-- tests/scripts/install-apply.test.js | 6 +- 4 files changed, 119 insertions(+), 14 deletions(-) diff --git a/scripts/lib/install-targets/cursor-project.js b/scripts/lib/install-targets/cursor-project.js index 2fd3b0ab..7e8a9ded 100644 --- a/scripts/lib/install-targets/cursor-project.js +++ b/scripts/lib/install-targets/cursor-project.js @@ -1,11 +1,23 @@ +const fs = require('fs'); const path = require('path'); const { createFlatRuleOperations, createInstallTargetAdapter, + createManagedOperation, isForeignPlatformPath, } = require('./helpers'); +function toCursorRuleFileName(fileName, sourceRelativeFile) { + if (path.basename(sourceRelativeFile).toLowerCase() === 'readme.md') { + return null; + } + + return fileName.endsWith('.md') + ? `${fileName.slice(0, -3)}.mdc` + : fileName; +} + module.exports = createInstallTargetAdapter({ id: 'cursor-project', target: 'cursor', @@ -40,14 +52,37 @@ module.exports = createInstallTargetAdapter({ repoRoot, sourceRelativePath, destinationDir: path.join(targetRoot, 'rules'), - destinationNameTransform(fileName) { - return fileName.endsWith('.md') - ? `${fileName.slice(0, -3)}.mdc` - : fileName; - }, + destinationNameTransform: toCursorRuleFileName, }); } + if (sourceRelativePath === '.cursor') { + const cursorRoot = path.join(repoRoot, '.cursor'); + if (!fs.existsSync(cursorRoot) || !fs.statSync(cursorRoot).isDirectory()) { + return []; + } + + const childOperations = fs.readdirSync(cursorRoot, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)) + .filter(entry => entry.name !== 'rules') + .map(entry => createManagedOperation({ + moduleId: module.id, + sourceRelativePath: path.join('.cursor', entry.name), + destinationPath: path.join(targetRoot, entry.name), + strategy: 'preserve-relative-path', + })); + + const ruleOperations = createFlatRuleOperations({ + moduleId: module.id, + repoRoot, + sourceRelativePath: '.cursor/rules', + destinationDir: path.join(targetRoot, 'rules'), + destinationNameTransform: toCursorRuleFileName, + }); + + return [...childOperations, ...ruleOperations]; + } + return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)]; }); }); diff --git a/scripts/lib/install-targets/helpers.js b/scripts/lib/install-targets/helpers.js index dec17c06..a39506e1 100644 --- a/scripts/lib/install-targets/helpers.js +++ b/scripts/lib/install-targets/helpers.js @@ -208,23 +208,31 @@ function createFlatRuleOperations({ const relativeFiles = listRelativeFiles(entryPath); for (const relativeFile of relativeFiles) { const defaultFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`; + const sourceRelativeFile = path.join(normalizedSourcePath, namespace, relativeFile); const flattenedFileName = typeof destinationNameTransform === 'function' - ? destinationNameTransform(defaultFileName) + ? destinationNameTransform(defaultFileName, sourceRelativeFile) : defaultFileName; + if (!flattenedFileName) { + continue; + } operations.push(createManagedOperation({ moduleId, - sourceRelativePath: path.join(normalizedSourcePath, namespace, relativeFile), + sourceRelativePath: sourceRelativeFile, destinationPath: path.join(destinationDir, flattenedFileName), strategy: 'flatten-copy', })); } } else if (entry.isFile()) { + const sourceRelativeFile = path.join(normalizedSourcePath, entry.name); const destinationFileName = typeof destinationNameTransform === 'function' - ? destinationNameTransform(entry.name) + ? destinationNameTransform(entry.name, sourceRelativeFile) : entry.name; + if (!destinationFileName) { + continue; + } operations.push(createManagedOperation({ moduleId, - sourceRelativePath: path.join(normalizedSourcePath, entry.name), + sourceRelativePath: sourceRelativeFile, destinationPath: path.join(destinationDir, destinationFileName), strategy: 'flatten-copy', })); diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index 45e4f7d3..79eceb73 100644 --- a/tests/lib/install-targets.test.js +++ b/tests/lib/install-targets.test.js @@ -90,14 +90,16 @@ function runTests() { assert.strictEqual(plan.targetRoot, path.join(projectRoot, '.cursor')); assert.strictEqual(plan.installStatePath, path.join(projectRoot, '.cursor', 'ecc-install-state.json')); - const flattened = plan.operations.find(operation => operation.sourceRelativePath === '.cursor'); + const hooksJson = plan.operations.find(operation => ( + normalizedRelativePath(operation.sourceRelativePath) === '.cursor/hooks.json' + )); const preserved = plan.operations.find(operation => ( normalizedRelativePath(operation.sourceRelativePath) === 'rules/common/coding-style.md' )); - assert.ok(flattened, 'Should include .cursor scaffold operation'); - assert.strictEqual(flattened.strategy, 'sync-root-children'); - assert.strictEqual(flattened.destinationPath, path.join(projectRoot, '.cursor')); + assert.ok(hooksJson, 'Should preserve non-rule Cursor platform config files'); + assert.strictEqual(hooksJson.strategy, 'preserve-relative-path'); + assert.strictEqual(hooksJson.destinationPath, path.join(projectRoot, '.cursor', 'hooks.json')); assert.ok(preserved, 'Should include flattened rules scaffold operations'); assert.strictEqual(preserved.strategy, 'flatten-copy'); @@ -149,6 +151,62 @@ function runTests() { )), 'Should not emit .md Cursor rule files' ); + assert.ok( + !plan.operations.some(operation => ( + normalizedRelativePath(operation.sourceRelativePath) === 'rules/README.md' + )), + 'Should not install Cursor README docs as runtime rule files' + ); + assert.ok( + !plan.operations.some(operation => ( + normalizedRelativePath(operation.sourceRelativePath) === 'rules/zh/README.md' + )), + 'Should not flatten localized README docs into Cursor rule files' + ); + })) passed++; else failed++; + + if (test('plans cursor platform rule files as .mdc and excludes rule README docs', () => { + const repoRoot = path.join(__dirname, '..', '..'); + const projectRoot = '/workspace/app'; + + const plan = planInstallTargetScaffold({ + target: 'cursor', + repoRoot, + projectRoot, + modules: [ + { + id: 'platform-configs', + paths: ['.cursor'], + }, + ], + }); + + assert.ok( + plan.operations.some(operation => ( + normalizedRelativePath(operation.sourceRelativePath) === '.cursor/rules/common-agents.md' + && operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc') + )), + 'Should rename Cursor platform rule files to .mdc' + ); + assert.ok( + !plan.operations.some(operation => ( + operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.md') + )), + 'Should not preserve .md Cursor platform rule files' + ); + assert.ok( + plan.operations.some(operation => ( + normalizedRelativePath(operation.sourceRelativePath) === '.cursor/hooks.json' + && operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json') + )), + 'Should preserve non-rule Cursor platform config files' + ); + assert.ok( + !plan.operations.some(operation => ( + operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'README.mdc') + )), + 'Should not emit Cursor rule README docs as .mdc files' + ); })) passed++; else failed++; if (test('plans antigravity remaps for workflows, skills, and flat rules', () => { diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index f777c552..92f733d8 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -132,6 +132,9 @@ function runTests() { assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-coding-style.mdc'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'typescript-testing.mdc'))); + assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.mdc'))); + assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md'))); + assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'README.mdc'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'agents', 'architect.md'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'commands', 'plan.md'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json'))); @@ -304,7 +307,8 @@ function runTests() { }); assert.strictEqual(result.code, 0, result.stderr); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json'))); - assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md'))); + assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.mdc'))); + assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md'))); const state = readJson(path.join(projectDir, '.cursor', 'ecc-install-state.json')); assert.strictEqual(state.request.profile, null); From 30f6ae4253f6ba93f1b1dfe8948f0ab4dfe46a1a Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sun, 12 Apr 2026 23:58:59 -0700 Subject: [PATCH 4/5] test: align cursor manifest expectations --- tests/lib/install-manifests.test.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/lib/install-manifests.test.js b/tests/lib/install-manifests.test.js index a7cbba79..faa07244 100644 --- a/tests/lib/install-manifests.test.js +++ b/tests/lib/install-manifests.test.js @@ -116,10 +116,19 @@ function runTests() { assert.ok(plan.operations.length > 0, 'Should include scaffold operations'); assert.ok( plan.operations.some(operation => ( - operation.sourceRelativePath === '.cursor' - && operation.strategy === 'sync-root-children' + operation.sourceRelativePath === '.cursor/hooks.json' + && operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json') + && operation.strategy === 'preserve-relative-path' )), - 'Should flatten the native cursor root' + 'Should preserve non-rule Cursor platform files' + ); + assert.ok( + plan.operations.some(operation => ( + operation.sourceRelativePath === '.cursor/rules/common-agents.md' + && operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc') + && operation.strategy === 'flatten-copy' + )), + 'Should flatten Cursor platform rules into .mdc files' ); })) passed++; else failed++; From 9e607ebb30bf40ebee81e4dc5ea439a6eeb0b663 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Mon, 13 Apr 2026 00:07:15 -0700 Subject: [PATCH 5/5] fix: prefer cursor native hooks during install --- scripts/lib/install-targets/cursor-project.js | 120 ++++++++++++------ tests/lib/install-manifests.test.js | 4 +- tests/lib/install-targets.test.js | 64 ++++++++++ 3 files changed, 150 insertions(+), 38 deletions(-) diff --git a/scripts/lib/install-targets/cursor-project.js b/scripts/lib/install-targets/cursor-project.js index 7e8a9ded..03d19b1d 100644 --- a/scripts/lib/install-targets/cursor-project.js +++ b/scripts/lib/install-targets/cursor-project.js @@ -29,6 +29,7 @@ module.exports = createInstallTargetAdapter({ const modules = Array.isArray(input.modules) ? input.modules : (input.module ? [input.module] : []); + const seenDestinationPaths = new Set(); const { repoRoot, projectRoot, @@ -40,51 +41,98 @@ module.exports = createInstallTargetAdapter({ homeDir, }; const targetRoot = adapter.resolveRoot(planningInput); - - return modules.flatMap(module => { + const entries = modules.flatMap((module, moduleIndex) => { const paths = Array.isArray(module.paths) ? module.paths : []; return paths .filter(p => !isForeignPlatformPath(p, adapter.target)) - .flatMap(sourceRelativePath => { - if (sourceRelativePath === 'rules') { - return createFlatRuleOperations({ - moduleId: module.id, - repoRoot, - sourceRelativePath, - destinationDir: path.join(targetRoot, 'rules'), - destinationNameTransform: toCursorRuleFileName, - }); - } + .map((sourceRelativePath, pathIndex) => ({ + module, + sourceRelativePath, + moduleIndex, + pathIndex, + })); + }).sort((left, right) => { + const getPriority = value => { + if (value === 'rules') { + return 0; + } - if (sourceRelativePath === '.cursor') { - const cursorRoot = path.join(repoRoot, '.cursor'); - if (!fs.existsSync(cursorRoot) || !fs.statSync(cursorRoot).isDirectory()) { - return []; - } + if (value === '.cursor') { + return 1; + } - const childOperations = fs.readdirSync(cursorRoot, { withFileTypes: true }) - .sort((left, right) => left.name.localeCompare(right.name)) - .filter(entry => entry.name !== 'rules') - .map(entry => createManagedOperation({ - moduleId: module.id, - sourceRelativePath: path.join('.cursor', entry.name), - destinationPath: path.join(targetRoot, entry.name), - strategy: 'preserve-relative-path', - })); + return 2; + }; - const ruleOperations = createFlatRuleOperations({ - moduleId: module.id, - repoRoot, - sourceRelativePath: '.cursor/rules', - destinationDir: path.join(targetRoot, 'rules'), - destinationNameTransform: toCursorRuleFileName, - }); + const leftPriority = getPriority(left.sourceRelativePath); + const rightPriority = getPriority(right.sourceRelativePath); + if (leftPriority !== rightPriority) { + return leftPriority - rightPriority; + } - return [...childOperations, ...ruleOperations]; - } + if (left.moduleIndex !== right.moduleIndex) { + return left.moduleIndex - right.moduleIndex; + } - return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)]; + return left.pathIndex - right.pathIndex; + }); + + function takeUniqueOperations(operations) { + return operations.filter(operation => { + if (!operation || !operation.destinationPath) { + return false; + } + + if (seenDestinationPaths.has(operation.destinationPath)) { + return false; + } + + seenDestinationPaths.add(operation.destinationPath); + return true; + }); + } + + return entries.flatMap(({ module, sourceRelativePath }) => { + if (sourceRelativePath === 'rules') { + return takeUniqueOperations(createFlatRuleOperations({ + moduleId: module.id, + repoRoot, + sourceRelativePath, + destinationDir: path.join(targetRoot, 'rules'), + destinationNameTransform: toCursorRuleFileName, + })); + } + + if (sourceRelativePath === '.cursor') { + const cursorRoot = path.join(repoRoot, '.cursor'); + if (!fs.existsSync(cursorRoot) || !fs.statSync(cursorRoot).isDirectory()) { + return []; + } + + const childOperations = fs.readdirSync(cursorRoot, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)) + .filter(entry => entry.name !== 'rules') + .map(entry => createManagedOperation({ + moduleId: module.id, + sourceRelativePath: path.join('.cursor', entry.name), + destinationPath: path.join(targetRoot, entry.name), + strategy: 'preserve-relative-path', + })); + + const ruleOperations = createFlatRuleOperations({ + moduleId: module.id, + repoRoot, + sourceRelativePath: '.cursor/rules', + destinationDir: path.join(targetRoot, 'rules'), + destinationNameTransform: toCursorRuleFileName, }); + + return takeUniqueOperations([...childOperations, ...ruleOperations]); + } + + return takeUniqueOperations([ + adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput), + ]); }); }, }); diff --git a/tests/lib/install-manifests.test.js b/tests/lib/install-manifests.test.js index faa07244..ed0d7c73 100644 --- a/tests/lib/install-manifests.test.js +++ b/tests/lib/install-manifests.test.js @@ -124,11 +124,11 @@ function runTests() { ); assert.ok( plan.operations.some(operation => ( - operation.sourceRelativePath === '.cursor/rules/common-agents.md' + operation.sourceRelativePath === 'rules/common/agents.md' && operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc') && operation.strategy === 'flatten-copy' )), - 'Should flatten Cursor platform rules into .mdc files' + 'Should produce Cursor .mdc rules while preferring rules-core over duplicate platform copies' ); })) passed++; else failed++; diff --git a/tests/lib/install-targets.test.js b/tests/lib/install-targets.test.js index 79eceb73..d34fbcfa 100644 --- a/tests/lib/install-targets.test.js +++ b/tests/lib/install-targets.test.js @@ -209,6 +209,70 @@ function runTests() { ); })) passed++; else failed++; + if (test('deduplicates cursor rule destinations when rules-core and platform-configs overlap', () => { + const repoRoot = path.join(__dirname, '..', '..'); + const projectRoot = '/workspace/app'; + + const plan = planInstallTargetScaffold({ + target: 'cursor', + repoRoot, + projectRoot, + modules: [ + { + id: 'rules-core', + paths: ['rules'], + }, + { + id: 'platform-configs', + paths: ['.cursor'], + }, + ], + }); + + const commonAgentsDestinations = plan.operations.filter(operation => ( + operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc') + )); + + assert.strictEqual(commonAgentsDestinations.length, 1, 'Should keep only one common-agents.mdc operation'); + assert.strictEqual( + normalizedRelativePath(commonAgentsDestinations[0].sourceRelativePath), + 'rules/common/agents.md', + 'Should prefer rules-core when cursor platform rules would collide' + ); + })) passed++; else failed++; + + if (test('prefers native cursor hooks when hooks-runtime and platform-configs overlap', () => { + const repoRoot = path.join(__dirname, '..', '..'); + const projectRoot = '/workspace/app'; + + const plan = planInstallTargetScaffold({ + target: 'cursor', + repoRoot, + projectRoot, + modules: [ + { + id: 'hooks-runtime', + paths: ['hooks', 'scripts/hooks', 'scripts/lib'], + }, + { + id: 'platform-configs', + paths: ['.cursor'], + }, + ], + }); + + const hooksDestinations = plan.operations.filter(operation => ( + operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks') + )); + + assert.strictEqual(hooksDestinations.length, 1, 'Should keep only one .cursor/hooks scaffold operation'); + assert.strictEqual( + normalizedRelativePath(hooksDestinations[0].sourceRelativePath), + '.cursor/hooks', + 'Should prefer native Cursor hooks over generic hooks-runtime hooks' + ); + })) passed++; else failed++; + if (test('plans antigravity remaps for workflows, skills, and flat rules', () => { const repoRoot = path.join(__dirname, '..', '..'); const projectRoot = '/workspace/app';