diff --git a/.changeset/cursor-slash-commands.md b/.changeset/cursor-slash-commands.md new file mode 100644 index 00000000..b4c74573 --- /dev/null +++ b/.changeset/cursor-slash-commands.md @@ -0,0 +1,7 @@ +--- +"task-master-ai": minor +--- + +Add Cursor IDE custom slash command support + +Expose Task Master commands as Cursor slash commands by copying assets/claude/commands to .cursor/commands on profile add and cleaning up on remove. diff --git a/src/profiles/cursor.js b/src/profiles/cursor.js index 8d9f7a91..29808c2d 100644 --- a/src/profiles/cursor.js +++ b/src/profiles/cursor.js @@ -1,5 +1,134 @@ // Cursor conversion profile for rule-transformer -import { createProfile, COMMON_TOOL_MAPPINGS } from './base-profile.js'; +import path from 'path'; +import fs from 'fs'; +import { log } from '../../scripts/modules/utils.js'; +import { createProfile } from './base-profile.js'; + +// Helper copy; use cpSync when available, fallback to manual recursion +function copyRecursiveSync(src, dest) { + if (fs.cpSync) { + try { + fs.cpSync(src, dest, { recursive: true, force: true }); + return; + } catch (err) { + throw new Error(`Failed to copy ${src} to ${dest}: ${err.message}`); + } + } + const exists = fs.existsSync(src); + let stats = null; + let isDirectory = false; + + if (exists) { + try { + stats = fs.statSync(src); + isDirectory = stats.isDirectory(); + } catch (err) { + // Handle TOCTOU race condition - treat as non-existent/not-a-directory + isDirectory = false; + } + } + + if (isDirectory) { + try { + if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true }); + for (const child of fs.readdirSync(src)) { + copyRecursiveSync(path.join(src, child), path.join(dest, child)); + } + } catch (err) { + throw new Error( + `Failed to copy directory ${src} to ${dest}: ${err.message}` + ); + } + } else { + try { + // ensure parent exists for file copies + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); + } catch (err) { + throw new Error(`Failed to copy file ${src} to ${dest}: ${err.message}`); + } + } +} + +// Helper function to recursively remove directory +function removeDirectoryRecursive(dirPath) { + if (fs.existsSync(dirPath)) { + try { + fs.rmSync(dirPath, { recursive: true, force: true }); + return true; + } catch (err) { + log('error', `Failed to remove directory ${dirPath}: ${err.message}`); + return false; + } + } + return true; +} + +// Resolve the Cursor profile directory from either project root, profile root, or rules dir +function resolveCursorProfileDir(baseDir) { + const base = path.basename(baseDir); + // If called with .../.cursor/rules -> return .../.cursor + if (base === 'rules' && path.basename(path.dirname(baseDir)) === '.cursor') { + return path.dirname(baseDir); + } + // If called with .../.cursor -> return as-is + if (base === '.cursor') return baseDir; + // Otherwise assume project root and append .cursor + return path.join(baseDir, '.cursor'); +} + +// Lifecycle functions for Cursor profile +function onAddRulesProfile(targetDir, assetsDir) { + // Copy commands directory recursively + const commandsSourceDir = path.join(assetsDir, 'claude', 'commands'); + const profileDir = resolveCursorProfileDir(targetDir); + const commandsDestDir = path.join(profileDir, 'commands'); + + if (!fs.existsSync(commandsSourceDir)) { + log( + 'warn', + `[Cursor] Source commands directory does not exist: ${commandsSourceDir}` + ); + return; + } + + try { + // Ensure fresh state to avoid stale command files + try { + fs.rmSync(commandsDestDir, { recursive: true, force: true }); + log( + 'debug', + `[Cursor] Removed existing commands directory: ${commandsDestDir}` + ); + } catch (deleteErr) { + // Directory might not exist, which is fine + log( + 'debug', + `[Cursor] Commands directory did not exist or could not be removed: ${deleteErr.message}` + ); + } + + copyRecursiveSync(commandsSourceDir, commandsDestDir); + log('debug', `[Cursor] Copied commands directory to ${commandsDestDir}`); + } catch (err) { + log( + 'error', + `[Cursor] An error occurred during commands copy: ${err.message}` + ); + } +} + +function onRemoveRulesProfile(targetDir) { + // Remove .cursor/commands directory recursively + const profileDir = resolveCursorProfileDir(targetDir); + const commandsDir = path.join(profileDir, 'commands'); + if (removeDirectoryRecursive(commandsDir)) { + log( + 'debug', + `[Cursor] Ensured commands directory removed at ${commandsDir}` + ); + } +} // Create and export cursor profile using the base factory export const cursorProfile = createProfile({ @@ -8,5 +137,10 @@ export const cursorProfile = createProfile({ url: 'cursor.so', docsUrl: 'docs.cursor.com', targetExtension: '.mdc', // Cursor keeps .mdc extension - supportsRulesSubdirectories: true + supportsRulesSubdirectories: true, + onAdd: onAddRulesProfile, + onRemove: onRemoveRulesProfile }); + +// Export lifecycle functions separately to avoid naming conflicts +export { onAddRulesProfile, onRemoveRulesProfile }; diff --git a/src/utils/rule-transformer.js b/src/utils/rule-transformer.js index b1a6a818..39e69997 100644 --- a/src/utils/rule-transformer.js +++ b/src/utils/rule-transformer.js @@ -210,7 +210,7 @@ export function convertAllRulesToProfileRules(projectRoot, profile) { if (typeof profile.onAddRulesProfile === 'function') { try { const assetsDir = getAssetsDir(); - profile.onAddRulesProfile(projectRoot, assetsDir); + profile.onAddRulesProfile(targetDir, assetsDir); log( 'debug', `[Rule Transformer] Called onAddRulesProfile for ${profile.profileName}` @@ -305,7 +305,7 @@ export function convertAllRulesToProfileRules(projectRoot, profile) { if (typeof profile.onPostConvertRulesProfile === 'function') { try { const assetsDir = getAssetsDir(); - profile.onPostConvertRulesProfile(projectRoot, assetsDir); + profile.onPostConvertRulesProfile(targetDir, assetsDir); log( 'debug', `[Rule Transformer] Called onPostConvertRulesProfile for ${profile.profileName}` @@ -347,7 +347,7 @@ export function removeProfileRules(projectRoot, profile) { // 1. Call onRemoveRulesProfile first (for custom cleanup like removing assets) if (typeof profile.onRemoveRulesProfile === 'function') { try { - profile.onRemoveRulesProfile(projectRoot); + profile.onRemoveRulesProfile(targetDir); log( 'debug', `[Rule Transformer] Called onRemoveRulesProfile for ${profile.profileName}` diff --git a/tests/integration/profiles/cursor-init-functionality.test.js b/tests/integration/profiles/cursor-init-functionality.test.js index a07ea38f..b17239bf 100644 --- a/tests/integration/profiles/cursor-init-functionality.test.js +++ b/tests/integration/profiles/cursor-init-functionality.test.js @@ -54,4 +54,33 @@ describe('Cursor Profile Initialization Functionality', () => { ); expect(cursorProfile.conversionConfig.toolNames.search).toBe('search'); }); + + test('cursor.js has lifecycle functions for command copying', () => { + // Check that the source file contains our new lifecycle functions + expect(cursorProfileContent).toContain('function onAddRulesProfile'); + expect(cursorProfileContent).toContain('function onRemoveRulesProfile'); + expect(cursorProfileContent).toContain('copyRecursiveSync'); + expect(cursorProfileContent).toContain('removeDirectoryRecursive'); + }); + + test('cursor.js copies commands from claude/commands to .cursor/commands', () => { + // Check that the onAddRulesProfile function copies from the correct source + expect(cursorProfileContent).toContain( + "path.join(assetsDir, 'claude', 'commands')" + ); + // Destination path is built via a resolver to handle both project root and rules dir + expect(cursorProfileContent).toContain('resolveCursorProfileDir('); + expect(cursorProfileContent).toMatch( + /path\.join\(\s*profileDir\s*,\s*['"]commands['"]\s*\)/ + ); + expect(cursorProfileContent).toContain( + 'copyRecursiveSync(commandsSourceDir, commandsDestDir)' + ); + + // Check that lifecycle functions are properly registered with the profile + expect(cursorProfile.onAddRulesProfile).toBeDefined(); + expect(cursorProfile.onRemoveRulesProfile).toBeDefined(); + expect(typeof cursorProfile.onAddRulesProfile).toBe('function'); + expect(typeof cursorProfile.onRemoveRulesProfile).toBe('function'); + }); }); diff --git a/tests/unit/profiles/cursor-integration.test.js b/tests/unit/profiles/cursor-integration.test.js index eb962184..6a7900a0 100644 --- a/tests/unit/profiles/cursor-integration.test.js +++ b/tests/unit/profiles/cursor-integration.test.js @@ -8,18 +8,37 @@ jest.mock('child_process', () => ({ execSync: jest.fn() })); -// Mock console methods -jest.mock('console', () => ({ +// Mock console methods to avoid chalk issues +const mockLog = jest.fn(); +const originalConsole = global.console; +const mockConsole = { log: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(), clear: jest.fn() +}; +global.console = mockConsole; + +// Mock utils logger to avoid chalk dependency issues +await jest.unstable_mockModule('../../../scripts/modules/utils.js', () => ({ + default: undefined, + log: mockLog, + isSilentMode: () => false })); +// Import the cursor profile after mocking +const { cursorProfile, onAddRulesProfile, onRemoveRulesProfile } = await import( + '../../../src/profiles/cursor.js' +); + describe('Cursor Integration', () => { let tempDir; + afterAll(() => { + global.console = originalConsole; + }); + beforeEach(() => { jest.clearAllMocks(); @@ -75,4 +94,127 @@ describe('Cursor Integration', () => { { recursive: true } ); }); + + test('cursor profile has lifecycle functions for command copying', () => { + // Assert that the profile exports the lifecycle functions + expect(typeof onAddRulesProfile).toBe('function'); + expect(typeof onRemoveRulesProfile).toBe('function'); + expect(cursorProfile.onAddRulesProfile).toBe(onAddRulesProfile); + expect(cursorProfile.onRemoveRulesProfile).toBe(onRemoveRulesProfile); + }); + + describe('command copying lifecycle', () => { + let mockAssetsDir; + let mockTargetDir; + + beforeEach(() => { + mockAssetsDir = path.join(tempDir, 'assets'); + mockTargetDir = path.join(tempDir, 'target'); + + // Reset all mocks + jest.clearAllMocks(); + + // Mock fs methods for the lifecycle functions + jest.spyOn(fs, 'existsSync').mockImplementation((filePath) => { + const pathStr = filePath.toString(); + if (pathStr.includes('claude/commands')) { + return true; // Mock that source commands exist + } + return false; + }); + + jest.spyOn(fs, 'mkdirSync').mockImplementation(() => {}); + jest.spyOn(fs, 'readdirSync').mockImplementation(() => ['tm']); + jest + .spyOn(fs, 'statSync') + .mockImplementation(() => ({ isDirectory: () => true })); + jest.spyOn(fs, 'copyFileSync').mockImplementation(() => {}); + jest.spyOn(fs, 'rmSync').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('onAddRulesProfile copies commands from assets to .cursor/commands', () => { + // Detect if cpSync exists and set up appropriate spy + if (fs.cpSync) { + const cpSpy = jest.spyOn(fs, 'cpSync').mockImplementation(() => {}); + + // Act + onAddRulesProfile(mockTargetDir, mockAssetsDir); + + // Assert + expect(fs.existsSync).toHaveBeenCalledWith( + path.join(mockAssetsDir, 'claude', 'commands') + ); + expect(cpSpy).toHaveBeenCalledWith( + path.join(mockAssetsDir, 'claude', 'commands'), + path.join(mockTargetDir, '.cursor', 'commands'), + expect.objectContaining({ recursive: true, force: true }) + ); + } else { + // Act + onAddRulesProfile(mockTargetDir, mockAssetsDir); + + // Assert + expect(fs.existsSync).toHaveBeenCalledWith( + path.join(mockAssetsDir, 'claude', 'commands') + ); + expect(fs.mkdirSync).toHaveBeenCalledWith( + path.join(mockTargetDir, '.cursor', 'commands'), + { recursive: true } + ); + expect(fs.copyFileSync).toHaveBeenCalled(); + } + }); + + test('onAddRulesProfile handles missing source directory gracefully', () => { + // Arrange - mock source directory not existing + jest.spyOn(fs, 'existsSync').mockImplementation(() => false); + + // Act + onAddRulesProfile(mockTargetDir, mockAssetsDir); + + // Assert - should not attempt to copy anything + expect(fs.mkdirSync).not.toHaveBeenCalled(); + expect(fs.copyFileSync).not.toHaveBeenCalled(); + }); + + test('onRemoveRulesProfile removes .cursor/commands directory', () => { + // Arrange - mock directory exists + jest.spyOn(fs, 'existsSync').mockImplementation(() => true); + + // Act + onRemoveRulesProfile(mockTargetDir); + + // Assert + expect(fs.rmSync).toHaveBeenCalledWith( + path.join(mockTargetDir, '.cursor', 'commands'), + { recursive: true, force: true } + ); + }); + + test('onRemoveRulesProfile handles missing directory gracefully', () => { + // Arrange - mock directory doesn't exist + jest.spyOn(fs, 'existsSync').mockImplementation(() => false); + + // Act + onRemoveRulesProfile(mockTargetDir); + + // Assert - should still return true but not attempt removal + expect(fs.rmSync).not.toHaveBeenCalled(); + }); + + test('onRemoveRulesProfile handles removal errors gracefully', () => { + // Arrange - mock directory exists but removal fails + jest.spyOn(fs, 'existsSync').mockImplementation(() => true); + jest.spyOn(fs, 'rmSync').mockImplementation(() => { + throw new Error('Permission denied'); + }); + + // Act & Assert - should not throw + expect(() => onRemoveRulesProfile(mockTargetDir)).not.toThrow(); + }); + }); });