fix: normalize cursor rule installs

This commit is contained in:
Affaan Mustafa
2026-04-12 23:51:58 -07:00
parent bb96fdc9dc
commit 7374ef6a73
4 changed files with 119 additions and 14 deletions

View File

@@ -1,11 +1,23 @@
const fs = require('fs');
const path = require('path'); const path = require('path');
const { const {
createFlatRuleOperations, createFlatRuleOperations,
createInstallTargetAdapter, createInstallTargetAdapter,
createManagedOperation,
isForeignPlatformPath, isForeignPlatformPath,
} = require('./helpers'); } = 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({ module.exports = createInstallTargetAdapter({
id: 'cursor-project', id: 'cursor-project',
target: 'cursor', target: 'cursor',
@@ -40,14 +52,37 @@ module.exports = createInstallTargetAdapter({
repoRoot, repoRoot,
sourceRelativePath, sourceRelativePath,
destinationDir: path.join(targetRoot, 'rules'), destinationDir: path.join(targetRoot, 'rules'),
destinationNameTransform(fileName) { destinationNameTransform: toCursorRuleFileName,
return fileName.endsWith('.md')
? `${fileName.slice(0, -3)}.mdc`
: fileName;
},
}); });
} }
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)]; return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
}); });
}); });

View File

@@ -208,23 +208,31 @@ function createFlatRuleOperations({
const relativeFiles = listRelativeFiles(entryPath); const relativeFiles = listRelativeFiles(entryPath);
for (const relativeFile of relativeFiles) { for (const relativeFile of relativeFiles) {
const defaultFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`; const defaultFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`;
const sourceRelativeFile = path.join(normalizedSourcePath, namespace, relativeFile);
const flattenedFileName = typeof destinationNameTransform === 'function' const flattenedFileName = typeof destinationNameTransform === 'function'
? destinationNameTransform(defaultFileName) ? destinationNameTransform(defaultFileName, sourceRelativeFile)
: defaultFileName; : defaultFileName;
if (!flattenedFileName) {
continue;
}
operations.push(createManagedOperation({ operations.push(createManagedOperation({
moduleId, moduleId,
sourceRelativePath: path.join(normalizedSourcePath, namespace, relativeFile), sourceRelativePath: sourceRelativeFile,
destinationPath: path.join(destinationDir, flattenedFileName), destinationPath: path.join(destinationDir, flattenedFileName),
strategy: 'flatten-copy', strategy: 'flatten-copy',
})); }));
} }
} else if (entry.isFile()) { } else if (entry.isFile()) {
const sourceRelativeFile = path.join(normalizedSourcePath, entry.name);
const destinationFileName = typeof destinationNameTransform === 'function' const destinationFileName = typeof destinationNameTransform === 'function'
? destinationNameTransform(entry.name) ? destinationNameTransform(entry.name, sourceRelativeFile)
: entry.name; : entry.name;
if (!destinationFileName) {
continue;
}
operations.push(createManagedOperation({ operations.push(createManagedOperation({
moduleId, moduleId,
sourceRelativePath: path.join(normalizedSourcePath, entry.name), sourceRelativePath: sourceRelativeFile,
destinationPath: path.join(destinationDir, destinationFileName), destinationPath: path.join(destinationDir, destinationFileName),
strategy: 'flatten-copy', strategy: 'flatten-copy',
})); }));

View File

@@ -90,14 +90,16 @@ function runTests() {
assert.strictEqual(plan.targetRoot, path.join(projectRoot, '.cursor')); assert.strictEqual(plan.targetRoot, path.join(projectRoot, '.cursor'));
assert.strictEqual(plan.installStatePath, path.join(projectRoot, '.cursor', 'ecc-install-state.json')); 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 => ( const preserved = plan.operations.find(operation => (
normalizedRelativePath(operation.sourceRelativePath) === 'rules/common/coding-style.md' normalizedRelativePath(operation.sourceRelativePath) === 'rules/common/coding-style.md'
)); ));
assert.ok(flattened, 'Should include .cursor scaffold operation'); assert.ok(hooksJson, 'Should preserve non-rule Cursor platform config files');
assert.strictEqual(flattened.strategy, 'sync-root-children'); assert.strictEqual(hooksJson.strategy, 'preserve-relative-path');
assert.strictEqual(flattened.destinationPath, path.join(projectRoot, '.cursor')); assert.strictEqual(hooksJson.destinationPath, path.join(projectRoot, '.cursor', 'hooks.json'));
assert.ok(preserved, 'Should include flattened rules scaffold operations'); assert.ok(preserved, 'Should include flattened rules scaffold operations');
assert.strictEqual(preserved.strategy, 'flatten-copy'); assert.strictEqual(preserved.strategy, 'flatten-copy');
@@ -149,6 +151,62 @@ function runTests() {
)), )),
'Should not emit .md Cursor rule files' '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++; })) passed++; else failed++;
if (test('plans antigravity remaps for workflows, skills, and flat rules', () => { if (test('plans antigravity remaps for workflows, skills, and flat rules', () => {

View File

@@ -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', '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', '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', 'agents', 'architect.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'commands', 'plan.md'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'commands', 'plan.md')));
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
@@ -304,7 +307,8 @@ function runTests() {
}); });
assert.strictEqual(result.code, 0, result.stderr); 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', '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')); const state = readJson(path.join(projectDir, '.cursor', 'ecc-install-state.json'));
assert.strictEqual(state.request.profile, null); assert.strictEqual(state.request.profile, null);