From 7374ef6a73abdc61f952f18638907e199817fced Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sun, 12 Apr 2026 23:51:58 -0700 Subject: [PATCH] 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);