feat: Add Cursor IDE custom slash commands support (#1215)
This commit is contained in:
7
.changeset/cursor-slash-commands.md
Normal file
7
.changeset/cursor-slash-commands.md
Normal file
@@ -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.
|
||||||
@@ -1,5 +1,134 @@
|
|||||||
// Cursor conversion profile for rule-transformer
|
// 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
|
// Create and export cursor profile using the base factory
|
||||||
export const cursorProfile = createProfile({
|
export const cursorProfile = createProfile({
|
||||||
@@ -8,5 +137,10 @@ export const cursorProfile = createProfile({
|
|||||||
url: 'cursor.so',
|
url: 'cursor.so',
|
||||||
docsUrl: 'docs.cursor.com',
|
docsUrl: 'docs.cursor.com',
|
||||||
targetExtension: '.mdc', // Cursor keeps .mdc extension
|
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 };
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function convertAllRulesToProfileRules(projectRoot, profile) {
|
|||||||
if (typeof profile.onAddRulesProfile === 'function') {
|
if (typeof profile.onAddRulesProfile === 'function') {
|
||||||
try {
|
try {
|
||||||
const assetsDir = getAssetsDir();
|
const assetsDir = getAssetsDir();
|
||||||
profile.onAddRulesProfile(projectRoot, assetsDir);
|
profile.onAddRulesProfile(targetDir, assetsDir);
|
||||||
log(
|
log(
|
||||||
'debug',
|
'debug',
|
||||||
`[Rule Transformer] Called onAddRulesProfile for ${profile.profileName}`
|
`[Rule Transformer] Called onAddRulesProfile for ${profile.profileName}`
|
||||||
@@ -305,7 +305,7 @@ export function convertAllRulesToProfileRules(projectRoot, profile) {
|
|||||||
if (typeof profile.onPostConvertRulesProfile === 'function') {
|
if (typeof profile.onPostConvertRulesProfile === 'function') {
|
||||||
try {
|
try {
|
||||||
const assetsDir = getAssetsDir();
|
const assetsDir = getAssetsDir();
|
||||||
profile.onPostConvertRulesProfile(projectRoot, assetsDir);
|
profile.onPostConvertRulesProfile(targetDir, assetsDir);
|
||||||
log(
|
log(
|
||||||
'debug',
|
'debug',
|
||||||
`[Rule Transformer] Called onPostConvertRulesProfile for ${profile.profileName}`
|
`[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)
|
// 1. Call onRemoveRulesProfile first (for custom cleanup like removing assets)
|
||||||
if (typeof profile.onRemoveRulesProfile === 'function') {
|
if (typeof profile.onRemoveRulesProfile === 'function') {
|
||||||
try {
|
try {
|
||||||
profile.onRemoveRulesProfile(projectRoot);
|
profile.onRemoveRulesProfile(targetDir);
|
||||||
log(
|
log(
|
||||||
'debug',
|
'debug',
|
||||||
`[Rule Transformer] Called onRemoveRulesProfile for ${profile.profileName}`
|
`[Rule Transformer] Called onRemoveRulesProfile for ${profile.profileName}`
|
||||||
|
|||||||
@@ -54,4 +54,33 @@ describe('Cursor Profile Initialization Functionality', () => {
|
|||||||
);
|
);
|
||||||
expect(cursorProfile.conversionConfig.toolNames.search).toBe('search');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,18 +8,37 @@ jest.mock('child_process', () => ({
|
|||||||
execSync: jest.fn()
|
execSync: jest.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock console methods
|
// Mock console methods to avoid chalk issues
|
||||||
jest.mock('console', () => ({
|
const mockLog = jest.fn();
|
||||||
|
const originalConsole = global.console;
|
||||||
|
const mockConsole = {
|
||||||
log: jest.fn(),
|
log: jest.fn(),
|
||||||
info: jest.fn(),
|
info: jest.fn(),
|
||||||
warn: jest.fn(),
|
warn: jest.fn(),
|
||||||
error: jest.fn(),
|
error: jest.fn(),
|
||||||
clear: 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', () => {
|
describe('Cursor Integration', () => {
|
||||||
let tempDir;
|
let tempDir;
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
global.console = originalConsole;
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
@@ -75,4 +94,127 @@ describe('Cursor Integration', () => {
|
|||||||
{ recursive: true }
|
{ 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user