diff --git a/mcp-server/src/core/direct-functions/rules.js b/mcp-server/src/core/direct-functions/rules.js index 30204449..adee342c 100644 --- a/mcp-server/src/core/direct-functions/rules.js +++ b/mcp-server/src/core/direct-functions/rules.js @@ -93,10 +93,11 @@ export async function rulesDirect(args, log, context = {}) { const errors = removalResults.filter( (r) => r.error && !r.success && !r.skipped ); + const withNotices = removalResults.filter((r) => r.notice); let summary = ''; if (successes.length > 0) { - summary += `Successfully removed rules: ${successes.join(', ')}.`; + summary += `Successfully removed Task Master rules: ${successes.join(', ')}.`; } if (skipped.length > 0) { summary += `Skipped (default or protected): ${skipped.join(', ')}.`; @@ -106,6 +107,9 @@ export async function rulesDirect(args, log, context = {}) { .map((r) => `Error removing ${r.profileName}: ${r.error}`) .join(' '); } + if (withNotices.length > 0) { + summary += ` Notices: ${withNotices.map((r) => `${r.profileName} - ${r.notice}`).join('; ')}.`; + } disableSilentMode(); return { success: errors.length === 0, diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index 1f2aa002..1d73b2b8 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -2837,10 +2837,13 @@ Examples: const errors = removalResults.filter( (r) => r.error && !r.success && !r.skipped ); + const withNotices = removalResults.filter((r) => r.notice); if (successes.length > 0) { console.log( - chalk.green(`Successfully removed rules: ${successes.join(', ')}`) + chalk.green( + `Successfully removed Task Master rules: ${successes.join(', ')}` + ) ); } if (skipped.length > 0) { @@ -2857,6 +2860,13 @@ Examples: ); }); } + // Display notices about preserved files/configurations + if (withNotices.length > 0) { + console.log(chalk.cyan('\nNotices:')); + withNotices.forEach((r) => { + console.log(chalk.cyan(` ${r.profileName}: ${r.notice}`)); + }); + } } }); diff --git a/src/utils/mcp-config-setup.js b/src/utils/mcp-config-setup.js index a788791e..e9f82c4a 100644 --- a/src/utils/mcp-config-setup.js +++ b/src/utils/mcp-config-setup.js @@ -72,6 +72,7 @@ export function setupMCPConfiguration(projectDir, mcpConfigPath) { const hasMCPString = Object.values(mcpConfig.mcpServers).some( (server) => server.args && + Array.isArray(server.args) && server.args.some( (arg) => typeof arg === 'string' && arg.includes('task-master-ai') ) @@ -126,3 +127,118 @@ export function setupMCPConfiguration(projectDir, mcpConfigPath) { // Add note to console about MCP integration log('info', 'MCP server will use the installed task-master-ai package'); } + +/** + * Remove Task Master MCP server configuration from an existing mcp.json file + * Only removes Task Master entries, preserving other MCP servers + * @param {string} projectDir - Target project directory + * @param {string} mcpConfigPath - Relative path to MCP config file (e.g., '.cursor/mcp.json') + * @returns {Object} Result object with success status and details + */ +export function removeTaskMasterMCPConfiguration(projectDir, mcpConfigPath) { + const mcpPath = path.join(projectDir, mcpConfigPath); + + let result = { + success: false, + removed: false, + deleted: false, + error: null, + hasOtherServers: false + }; + + if (!fs.existsSync(mcpPath)) { + result.success = true; + result.removed = false; + log('debug', `[MCP Config] MCP config file does not exist: ${mcpPath}`); + return result; + } + + try { + // Read existing config + const mcpConfig = JSON.parse(fs.readFileSync(mcpPath, 'utf8')); + + if (!mcpConfig.mcpServers) { + result.success = true; + result.removed = false; + log('debug', `[MCP Config] No mcpServers section found in: ${mcpPath}`); + return result; + } + + // Check if Task Master is configured + const hasTaskMaster = + mcpConfig.mcpServers['task-master-ai'] || + Object.values(mcpConfig.mcpServers).some( + (server) => + server.args && + Array.isArray(server.args) && + server.args.some( + (arg) => typeof arg === 'string' && arg.includes('task-master-ai') + ) + ); + + if (!hasTaskMaster) { + result.success = true; + result.removed = false; + log( + 'debug', + `[MCP Config] Task Master not found in MCP config: ${mcpPath}` + ); + return result; + } + + // Remove task-master-ai server + delete mcpConfig.mcpServers['task-master-ai']; + + // Also remove any servers that have task-master-ai in their args + Object.keys(mcpConfig.mcpServers).forEach((serverName) => { + const server = mcpConfig.mcpServers[serverName]; + if ( + server.args && + Array.isArray(server.args) && + server.args.some( + (arg) => typeof arg === 'string' && arg.includes('task-master-ai') + ) + ) { + delete mcpConfig.mcpServers[serverName]; + log( + 'debug', + `[MCP Config] Removed server '${serverName}' containing task-master-ai` + ); + } + }); + + // Check if there are other MCP servers remaining + const remainingServers = Object.keys(mcpConfig.mcpServers); + result.hasOtherServers = remainingServers.length > 0; + + if (result.hasOtherServers) { + // Write back the modified config with remaining servers + fs.writeFileSync(mcpPath, formatJSONWithTabs(mcpConfig) + '\n'); + result.success = true; + result.removed = true; + result.deleted = false; + log( + 'info', + `[MCP Config] Removed Task Master from MCP config, preserving other servers: ${remainingServers.join(', ')}` + ); + } else { + // No other servers, delete the entire file + fs.rmSync(mcpPath, { force: true }); + result.success = true; + result.removed = true; + result.deleted = true; + log( + 'info', + `[MCP Config] Removed MCP config file (no other servers remaining): ${mcpPath}` + ); + } + } catch (error) { + result.error = error.message; + log( + 'error', + `[MCP Config] Failed to remove Task Master from MCP config: ${error.message}` + ); + } + + return result; +} diff --git a/src/utils/rule-transformer.js b/src/utils/rule-transformer.js index cf44e856..95bb6200 100644 --- a/src/utils/rule-transformer.js +++ b/src/utils/rule-transformer.js @@ -10,7 +10,10 @@ import path from 'path'; import { log } from '../../scripts/modules/utils.js'; // Import the shared MCP configuration helper -import { setupMCPConfiguration } from './mcp-config-setup.js'; +import { + setupMCPConfiguration, + removeTaskMasterMCPConfiguration +} from './mcp-config-setup.js'; // Import profile constants (single source of truth) import { RULES_PROFILES } from '../constants/profiles.js'; @@ -275,7 +278,7 @@ export function convertAllRulesToProfileRules(projectDir, profile) { } /** - * Remove profile rules for a specific profile + * Remove only Task Master specific files from a profile, leaving other existing rules intact * @param {string} projectDir - Target project directory * @param {Object} profile - Profile configuration * @returns {Object} Result object @@ -283,49 +286,144 @@ export function convertAllRulesToProfileRules(projectDir, profile) { export function removeProfileRules(projectDir, profile) { const targetDir = path.join(projectDir, profile.rulesDir); const profileDir = path.join(projectDir, profile.profileDir); - const mcpConfigPath = path.join(projectDir, profile.mcpConfigPath); let result = { profileName: profile.profileName, success: false, skipped: false, - error: null + error: null, + filesRemoved: [], + mcpResult: null, + profileDirRemoved: false, + notice: null }; try { - // Remove rules directory + // Check if profile directory exists at all + if (!fs.existsSync(profileDir)) { + result.success = true; + result.skipped = true; + log( + 'debug', + `[Rule Transformer] Profile directory does not exist: ${profileDir}` + ); + return result; + } + + // 1. Remove only Task Master specific files from the rules directory + let hasOtherRulesFiles = false; if (fs.existsSync(targetDir)) { - fs.rmSync(targetDir, { recursive: true, force: true }); - log('debug', `[Rule Transformer] Removed rules directory: ${targetDir}`); + const taskmasterFiles = Object.values(profile.fileMap); + let removedFiles = []; + + // Check all files in the rules directory + const allFiles = fs.readdirSync(targetDir); + for (const file of allFiles) { + if (taskmasterFiles.includes(file)) { + // This is a Task Master file, remove it + const filePath = path.join(targetDir, file); + fs.rmSync(filePath, { force: true }); + removedFiles.push(file); + log('debug', `[Rule Transformer] Removed Task Master file: ${file}`); + } else { + // This is not a Task Master file, leave it + hasOtherRulesFiles = true; + log('debug', `[Rule Transformer] Preserved existing file: ${file}`); + } + } + + result.filesRemoved = removedFiles; + + // Only remove the rules directory if it's empty after removing Task Master files + const remainingFiles = fs.readdirSync(targetDir); + if (remainingFiles.length === 0) { + fs.rmSync(targetDir, { recursive: true, force: true }); + log( + 'debug', + `[Rule Transformer] Removed empty rules directory: ${targetDir}` + ); + } else if (hasOtherRulesFiles) { + result.notice = `Preserved ${remainingFiles.length} existing rule files in ${profile.rulesDir}`; + log('info', `[Rule Transformer] ${result.notice}`); + } } - // Remove MCP config if it exists - if (fs.existsSync(mcpConfigPath)) { - fs.rmSync(mcpConfigPath, { force: true }); - log('debug', `[Rule Transformer] Removed MCP config: ${mcpConfigPath}`); + // 2. Handle MCP configuration - only remove Task Master, preserve other servers + if (profile.mcpConfig !== false) { + result.mcpResult = removeTaskMasterMCPConfiguration( + projectDir, + profile.mcpConfigPath + ); + if (result.mcpResult.hasOtherServers) { + if (!result.notice) { + result.notice = 'Preserved other MCP server configurations'; + } else { + result.notice += '; preserved other MCP server configurations'; + } + } } - // Call removal hook if defined + // 3. Call removal hook if defined (e.g., Roo's custom cleanup) if (typeof profile.onRemoveRulesProfile === 'function') { profile.onRemoveRulesProfile(projectDir); } - // Remove profile directory if empty + // 4. Only remove profile directory if: + // - It's completely empty after all operations, AND + // - All rules removed were Task Master rules (no existing rules preserved), AND + // - MCP config was completely deleted (not just Task Master removed), AND + // - No other files or folders exist in the profile directory if (fs.existsSync(profileDir)) { const remaining = fs.readdirSync(profileDir); - if (remaining.length === 0) { + const allRulesWereTaskMaster = !hasOtherRulesFiles; + const mcpConfigCompletelyDeleted = result.mcpResult?.deleted === true; + + // Check if there are any other files or folders beyond what we expect + const hasOtherFilesOrFolders = remaining.length > 0; + + if ( + remaining.length === 0 && + allRulesWereTaskMaster && + (profile.mcpConfig === false || mcpConfigCompletelyDeleted) && + !hasOtherFilesOrFolders + ) { fs.rmSync(profileDir, { recursive: true, force: true }); + result.profileDirRemoved = true; log( 'debug', - `[Rule Transformer] Removed empty profile directory: ${profileDir}` + `[Rule Transformer] Removed profile directory: ${profileDir} (completely empty, all rules were Task Master rules, and MCP config was completely removed)` ); + } else { + // Determine what was preserved and why + const preservationReasons = []; + if (hasOtherFilesOrFolders) { + preservationReasons.push( + `${remaining.length} existing files/folders` + ); + } + if (hasOtherRulesFiles) { + preservationReasons.push('existing rule files'); + } + if (result.mcpResult?.hasOtherServers) { + preservationReasons.push('other MCP server configurations'); + } + + const preservationMessage = `Preserved ${preservationReasons.join(', ')} in ${profile.profileDir}`; + + if (!result.notice) { + result.notice = preservationMessage; + } else if (!result.notice.includes('Preserved')) { + result.notice += `; ${preservationMessage.toLowerCase()}`; + } + + log('info', `[Rule Transformer] ${preservationMessage}`); } } result.success = true; log( 'debug', - `[Rule Transformer] Successfully removed ${profile.profileName} rules from ${projectDir}` + `[Rule Transformer] Successfully removed ${profile.profileName} Task Master files from ${projectDir}` ); } catch (error) { result.error = error.message; diff --git a/tests/unit/selective-rules-removal.test.js b/tests/unit/selective-rules-removal.test.js new file mode 100644 index 00000000..5f8dded1 --- /dev/null +++ b/tests/unit/selective-rules-removal.test.js @@ -0,0 +1,556 @@ +import fs from 'fs'; +import path from 'path'; +import { jest } from '@jest/globals'; +import { + removeProfileRules, + getRulesProfile +} from '../../src/utils/rule-transformer.js'; +import { removeTaskMasterMCPConfiguration } from '../../src/utils/mcp-config-setup.js'; + +// Mock logger +const mockLog = { + info: jest.fn(), + error: jest.fn(), + debug: jest.fn() +}; + +// Mock the logger import +jest.mock('../../scripts/modules/utils.js', () => ({ + log: (level, message) => mockLog[level]?.(message) +})); + +describe('Selective Rules Removal', () => { + let tempDir; + let mockExistsSync; + let mockRmSync; + let mockReaddirSync; + let mockReadFileSync; + let mockWriteFileSync; + let mockMkdirSync; + + beforeEach(() => { + jest.clearAllMocks(); + + // Create temp directory for testing + tempDir = fs.mkdtempSync(path.join(process.cwd(), 'test-temp-')); + + // Set up spies on fs methods + mockExistsSync = jest.spyOn(fs, 'existsSync'); + mockRmSync = jest.spyOn(fs, 'rmSync').mockImplementation(() => {}); + mockReaddirSync = jest.spyOn(fs, 'readdirSync'); + mockReadFileSync = jest.spyOn(fs, 'readFileSync'); + mockWriteFileSync = jest + .spyOn(fs, 'writeFileSync') + .mockImplementation(() => {}); + mockMkdirSync = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => {}); + }); + + afterEach(() => { + // Clean up temp directory + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch (error) { + // Ignore cleanup errors + } + + // Restore all mocked functions + jest.restoreAllMocks(); + }); + + describe('removeProfileRules - Selective File Removal', () => { + it('should only remove Task Master files, preserving existing rules', () => { + const projectRoot = '/test/project'; + const cursorProfile = getRulesProfile('cursor'); + + // Mock profile directory exists + mockExistsSync.mockImplementation((filePath) => { + if (filePath.includes('.cursor')) return true; + if (filePath.includes('.cursor/rules')) return true; + return false; + }); + + // Mock sequential calls to readdirSync to simulate the removal process + mockReaddirSync + // First call - get initial directory contents + .mockReturnValueOnce([ + 'cursor_rules.mdc', // Task Master file + 'dev_workflow.mdc', // Task Master file + 'self_improve.mdc', // Task Master file + 'taskmaster.mdc', // Task Master file + 'custom_rule.mdc', // Existing file (not Task Master) + 'my_company_rules.mdc' // Existing file (not Task Master) + ]) + // Second call - check remaining files after removal + .mockReturnValueOnce([ + 'custom_rule.mdc', // Remaining existing file + 'my_company_rules.mdc' // Remaining existing file + ]) + // Third call - check profile directory contents + .mockReturnValueOnce(['rules', 'mcp.json']); + + const result = removeProfileRules(projectRoot, cursorProfile); + + expect(result.success).toBe(true); + expect(result.filesRemoved).toEqual([ + 'cursor_rules.mdc', + 'dev_workflow.mdc', + 'self_improve.mdc', + 'taskmaster.mdc' + ]); + expect(result.notice).toContain('Preserved 2 existing rule files'); + + // Verify only Task Master files were removed + expect(mockRmSync).toHaveBeenCalledWith( + path.join(projectRoot, '.cursor/rules/cursor_rules.mdc'), + { force: true } + ); + expect(mockRmSync).toHaveBeenCalledWith( + path.join(projectRoot, '.cursor/rules/dev_workflow.mdc'), + { force: true } + ); + expect(mockRmSync).toHaveBeenCalledWith( + path.join(projectRoot, '.cursor/rules/self_improve.mdc'), + { force: true } + ); + expect(mockRmSync).toHaveBeenCalledWith( + path.join(projectRoot, '.cursor/rules/taskmaster.mdc'), + { force: true } + ); + + // Verify rules directory was NOT removed (still has other files) + expect(mockRmSync).not.toHaveBeenCalledWith( + path.join(projectRoot, '.cursor/rules'), + { recursive: true, force: true } + ); + + // Verify profile directory was NOT removed + expect(mockRmSync).not.toHaveBeenCalledWith( + path.join(projectRoot, '.cursor'), + { recursive: true, force: true } + ); + }); + + it('should remove empty rules directory if only Task Master files existed', () => { + const projectRoot = '/test/project'; + const cursorProfile = getRulesProfile('cursor'); + + // Mock profile directory exists + mockExistsSync.mockImplementation((filePath) => { + if (filePath.includes('.cursor')) return true; + if (filePath.includes('.cursor/rules')) return true; + return false; + }); + + // Mock rules directory with only Task Master files + mockReaddirSync.mockImplementation((dirPath) => { + if (dirPath.includes('.cursor/rules')) { + // Before removal + return [ + 'cursor_rules.mdc', + 'dev_workflow.mdc', + 'self_improve.mdc', + 'taskmaster.mdc' + ]; + } + if (dirPath.includes('.cursor')) { + // After rules removal, only mcp.json remains + return ['mcp.json']; + } + return []; + }); + + // Mock empty directory after removing Task Master files + mockReaddirSync + .mockReturnValueOnce([ + 'cursor_rules.mdc', + 'dev_workflow.mdc', + 'self_improve.mdc', + 'taskmaster.mdc' + ]) + .mockReturnValueOnce([]); // Empty after removal + + const result = removeProfileRules(projectRoot, cursorProfile); + + expect(result.success).toBe(true); + expect(result.filesRemoved).toEqual([ + 'cursor_rules.mdc', + 'dev_workflow.mdc', + 'self_improve.mdc', + 'taskmaster.mdc' + ]); + + // Verify rules directory was removed when empty + expect(mockRmSync).toHaveBeenCalledWith( + path.join(projectRoot, '.cursor/rules'), + { recursive: true, force: true } + ); + }); + + it('should remove entire profile directory if completely empty and all rules were Task Master rules and MCP config deleted', () => { + const projectRoot = '/test/project'; + const cursorProfile = getRulesProfile('cursor'); + + // Mock profile directory exists + mockExistsSync.mockImplementation((filePath) => { + if (filePath.includes('.cursor')) return true; + if (filePath.includes('.cursor/rules')) return true; + if (filePath.includes('mcp.json')) return true; + return false; + }); + + // Mock sequence: rules dir has only Task Master files, then empty, then profile dir empty + mockReaddirSync + .mockReturnValueOnce(['cursor_rules.mdc']) // Only Task Master files + .mockReturnValueOnce([]) // rules dir empty after removal + .mockReturnValueOnce([]); // profile dir empty after all cleanup + + // Mock MCP config with only Task Master (will be completely deleted) + const mockMcpConfig = { + mcpServers: { + 'task-master-ai': { + command: 'npx', + args: ['task-master-ai'] + } + } + }; + mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); + + const result = removeProfileRules(projectRoot, cursorProfile); + + expect(result.success).toBe(true); + expect(result.profileDirRemoved).toBe(true); + expect(result.mcpResult.deleted).toBe(true); + + // Verify profile directory was removed when completely empty and conditions met + expect(mockRmSync).toHaveBeenCalledWith( + path.join(projectRoot, '.cursor'), + { recursive: true, force: true } + ); + }); + + it('should NOT remove profile directory if existing rules were preserved, even if MCP config deleted', () => { + const projectRoot = '/test/project'; + const cursorProfile = getRulesProfile('cursor'); + + // Mock profile directory exists + mockExistsSync.mockImplementation((filePath) => { + if (filePath.includes('.cursor')) return true; + if (filePath.includes('.cursor/rules')) return true; + if (filePath.includes('mcp.json')) return true; + return false; + }); + + // Mock sequence: mixed rules, some remaining after removal, profile dir not empty + mockReaddirSync + .mockReturnValueOnce(['cursor_rules.mdc', 'my_custom_rule.mdc']) // Mixed files + .mockReturnValueOnce(['my_custom_rule.mdc']) // Custom rule remains + .mockReturnValueOnce(['rules', 'mcp.json']); // Profile dir has remaining content + + // Mock MCP config with only Task Master (will be completely deleted) + const mockMcpConfig = { + mcpServers: { + 'task-master-ai': { + command: 'npx', + args: ['task-master-ai'] + } + } + }; + mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); + + const result = removeProfileRules(projectRoot, cursorProfile); + + expect(result.success).toBe(true); + expect(result.profileDirRemoved).toBe(false); + expect(result.mcpResult.deleted).toBe(true); + + // Verify profile directory was NOT removed (existing rules preserved) + expect(mockRmSync).not.toHaveBeenCalledWith( + path.join(projectRoot, '.cursor'), + { recursive: true, force: true } + ); + }); + + it('should NOT remove profile directory if MCP config has other servers, even if all rules were Task Master rules', () => { + const projectRoot = '/test/project'; + const cursorProfile = getRulesProfile('cursor'); + + // Mock profile directory exists + mockExistsSync.mockImplementation((filePath) => { + if (filePath.includes('.cursor')) return true; + if (filePath.includes('.cursor/rules')) return true; + if (filePath.includes('mcp.json')) return true; + return false; + }); + + // Mock sequence: only Task Master rules, rules dir removed, but profile dir not empty due to MCP + mockReaddirSync + .mockReturnValueOnce(['cursor_rules.mdc']) // Only Task Master files + .mockReturnValueOnce([]) // rules dir empty after removal + .mockReturnValueOnce(['mcp.json']); // Profile dir has MCP config remaining + + // Mock MCP config with multiple servers (Task Master will be removed, others preserved) + const mockMcpConfig = { + mcpServers: { + 'task-master-ai': { + command: 'npx', + args: ['task-master-ai'] + }, + 'other-server': { + command: 'node', + args: ['other-server.js'] + } + } + }; + mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); + + const result = removeProfileRules(projectRoot, cursorProfile); + + expect(result.success).toBe(true); + expect(result.profileDirRemoved).toBe(false); + expect(result.mcpResult.deleted).toBe(false); + expect(result.mcpResult.hasOtherServers).toBe(true); + + // Verify profile directory was NOT removed (MCP config preserved) + expect(mockRmSync).not.toHaveBeenCalledWith( + path.join(projectRoot, '.cursor'), + { recursive: true, force: true } + ); + }); + + it('should NOT remove profile directory if other files/folders exist, even if all other conditions are met', () => { + const projectRoot = '/test/project'; + const cursorProfile = getRulesProfile('cursor'); + + // Mock profile directory exists + mockExistsSync.mockImplementation((filePath) => { + if (filePath.includes('.cursor')) return true; + if (filePath.includes('.cursor/rules')) return true; + if (filePath.includes('mcp.json')) return true; + return false; + }); + + // Mock sequence: only Task Master rules, rules dir removed, but profile dir has other files/folders + mockReaddirSync + .mockReturnValueOnce(['cursor_rules.mdc']) // Only Task Master files + .mockReturnValueOnce([]) // rules dir empty after removal + .mockReturnValueOnce(['workflows', 'custom-config.json']); // Profile dir has other files/folders + + // Mock MCP config with only Task Master (will be completely deleted) + const mockMcpConfig = { + mcpServers: { + 'task-master-ai': { + command: 'npx', + args: ['task-master-ai'] + } + } + }; + mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); + + const result = removeProfileRules(projectRoot, cursorProfile); + + expect(result.success).toBe(true); + expect(result.profileDirRemoved).toBe(false); + expect(result.mcpResult.deleted).toBe(true); + expect(result.notice).toContain('Preserved 2 existing files/folders'); + + // Verify profile directory was NOT removed (other files/folders exist) + expect(mockRmSync).not.toHaveBeenCalledWith( + path.join(projectRoot, '.cursor'), + { recursive: true, force: true } + ); + }); + }); + + describe('removeTaskMasterMCPConfiguration - Selective MCP Removal', () => { + it('should only remove Task Master from MCP config, preserving other servers', () => { + const projectRoot = '/test/project'; + const mcpConfigPath = '.cursor/mcp.json'; + + // Mock MCP config with multiple servers + const mockMcpConfig = { + mcpServers: { + 'task-master-ai': { + command: 'npx', + args: ['task-master-ai'] + }, + 'other-server': { + command: 'node', + args: ['other-server.js'] + }, + 'another-server': { + command: 'python', + args: ['server.py'] + } + } + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); + + const result = removeTaskMasterMCPConfiguration( + projectRoot, + mcpConfigPath + ); + + expect(result.success).toBe(true); + expect(result.removed).toBe(true); + expect(result.deleted).toBe(false); + expect(result.hasOtherServers).toBe(true); + + // Verify the file was written back with other servers preserved + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.join(projectRoot, mcpConfigPath), + expect.stringContaining('other-server') + ); + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.join(projectRoot, mcpConfigPath), + expect.stringContaining('another-server') + ); + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.join(projectRoot, mcpConfigPath), + expect.not.stringContaining('task-master-ai') + ); + }); + + it('should delete entire MCP config if Task Master is the only server', () => { + const projectRoot = '/test/project'; + const mcpConfigPath = '.cursor/mcp.json'; + + // Mock MCP config with only Task Master + const mockMcpConfig = { + mcpServers: { + 'task-master-ai': { + command: 'npx', + args: ['task-master-ai'] + } + } + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); + + const result = removeTaskMasterMCPConfiguration( + projectRoot, + mcpConfigPath + ); + + expect(result.success).toBe(true); + expect(result.removed).toBe(true); + expect(result.deleted).toBe(true); + expect(result.hasOtherServers).toBe(false); + + // Verify the entire file was deleted + expect(mockRmSync).toHaveBeenCalledWith( + path.join(projectRoot, mcpConfigPath), + { force: true } + ); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it('should handle MCP config with Task Master in server args', () => { + const projectRoot = '/test/project'; + const mcpConfigPath = '.cursor/mcp.json'; + + // Mock MCP config with Task Master referenced in args + const mockMcpConfig = { + mcpServers: { + 'taskmaster-wrapper': { + command: 'npx', + args: ['-y', '--package=task-master-ai', 'task-master-ai'] + }, + 'other-server': { + command: 'node', + args: ['other-server.js'] + } + } + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); + + const result = removeTaskMasterMCPConfiguration( + projectRoot, + mcpConfigPath + ); + + expect(result.success).toBe(true); + expect(result.removed).toBe(true); + expect(result.hasOtherServers).toBe(true); + + // Verify only the server with task-master-ai in args was removed + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.join(projectRoot, mcpConfigPath), + expect.stringContaining('other-server') + ); + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.join(projectRoot, mcpConfigPath), + expect.not.stringContaining('taskmaster-wrapper') + ); + }); + + it('should handle non-existent MCP config gracefully', () => { + const projectRoot = '/test/project'; + const mcpConfigPath = '.cursor/mcp.json'; + + mockExistsSync.mockReturnValue(false); + + const result = removeTaskMasterMCPConfiguration( + projectRoot, + mcpConfigPath + ); + + expect(result.success).toBe(true); + expect(result.removed).toBe(false); + expect(result.deleted).toBe(false); + expect(result.hasOtherServers).toBe(false); + + // No file operations should have been attempted + expect(mockReadFileSync).not.toHaveBeenCalled(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(mockRmSync).not.toHaveBeenCalled(); + }); + }); + + describe('Integration - Full Profile Removal with Preservation', () => { + it('should handle complete removal scenario with notices', () => { + const projectRoot = '/test/project'; + const cursorProfile = getRulesProfile('cursor'); + + // Mock mixed scenario: some Task Master files, some existing files, other MCP servers + mockExistsSync.mockImplementation((filePath) => { + if (filePath.includes('.cursor')) return true; + if (filePath.includes('mcp.json')) return true; + return false; + }); + + // Mock sequential calls to readdirSync + mockReaddirSync + // First call - get initial directory contents + .mockReturnValueOnce(['cursor_rules.mdc', 'my_custom_rule.mdc']) + // Second call - check remaining files after removal + .mockReturnValueOnce(['my_custom_rule.mdc']) + // Third call - check profile directory contents + .mockReturnValueOnce(['rules', 'mcp.json']); + + // Mock MCP config with multiple servers + const mockMcpConfig = { + mcpServers: { + 'task-master-ai': { command: 'npx', args: ['task-master-ai'] }, + 'other-server': { command: 'node', args: ['other.js'] } + } + }; + mockReadFileSync.mockReturnValue(JSON.stringify(mockMcpConfig)); + + const result = removeProfileRules(projectRoot, cursorProfile); + + expect(result.success).toBe(true); + expect(result.filesRemoved).toEqual(['cursor_rules.mdc']); + expect(result.notice).toContain('Preserved 1 existing rule files'); + expect(result.notice).toContain( + 'preserved other MCP server configurations' + ); + expect(result.mcpResult.hasOtherServers).toBe(true); + expect(result.profileDirRemoved).toBe(false); + }); + }); +});