From 9e607ebb30bf40ebee81e4dc5ea439a6eeb0b663 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Mon, 13 Apr 2026 00:07:15 -0700 Subject: [PATCH] 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';