Merge pull request #1393 from affaan-m/fix/cursor-rule-mdc-install

fix: install Cursor rules as .mdc files
This commit is contained in:
Affaan Mustafa
2026-04-13 00:52:03 -07:00
committed by GitHub
6 changed files with 318 additions and 32 deletions

View File

@@ -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: {

View File

@@ -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 === 'rules/common/agents.md'
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'rules', 'common-agents.mdc')
&& operation.strategy === 'flatten-copy'
)),
'Should produce Cursor .mdc rules while preferring rules-core over duplicate platform copies'
);
})) passed++; else failed++;

View File

@@ -90,20 +90,22 @@ 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');
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 +128,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 +145,132 @@ 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'
);
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('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', () => {

View File

@@ -130,8 +130,11 @@ 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', '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);