add checks for other rules and other profile folder items before removing

This commit is contained in:
Joe Danziger
2025-05-26 23:59:59 -04:00
parent 3c23797ace
commit 3b9191c8bb
5 changed files with 802 additions and 18 deletions

View File

@@ -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,

View File

@@ -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}`));
});
}
}
});

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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);
});
});
});