/** * Rule Transformer Module * Handles conversion of Cursor rules to profile rules * * This module procedurally generates .{profile}/rules files from assets/rules files, * eliminating the need to maintain both sets of files manually. */ import fs from 'fs'; import path from 'path'; import { log } from '../../scripts/modules/utils.js'; // Import the shared MCP configuration helper import { setupMCPConfiguration, removeTaskMasterMCPConfiguration } from './mcp-config-setup.js'; // Import profile constants (single source of truth) import { RULE_PROFILES } from '../constants/profiles.js'; // --- Profile Imports --- import * as profilesModule from '../../scripts/profiles/index.js'; export function isValidProfile(profile) { return RULE_PROFILES.includes(profile); } /** * Get rule profile by name * @param {string} name - Profile name * @returns {Object|null} Profile object or null if not found */ export function getRulesProfile(name) { if (!isValidProfile(name)) { return null; } // Get the profile from the imported profiles module const profileKey = `${name}Profile`; const profile = profilesModule[profileKey]; if (!profile) { throw new Error( `Profile not found: static import missing for '${name}'. Valid profiles: ${RULE_PROFILES.join(', ')}` ); } return profile; } /** * Replace basic Cursor terms with profile equivalents */ function replaceBasicTerms(content, conversionConfig) { let result = content; // Apply profile term replacements conversionConfig.profileTerms.forEach((pattern) => { if (typeof pattern.to === 'function') { result = result.replace(pattern.from, pattern.to); } else { result = result.replace(pattern.from, pattern.to); } }); // Apply file extension replacements conversionConfig.fileExtensions.forEach((pattern) => { result = result.replace(pattern.from, pattern.to); }); return result; } /** * Replace Cursor tool references with profile tool equivalents */ function replaceToolReferences(content, conversionConfig) { let result = content; // Basic pattern for direct tool name replacements const toolNames = conversionConfig.toolNames; const toolReferencePattern = new RegExp( `\\b(${Object.keys(toolNames).join('|')})\\b`, 'g' ); // Apply direct tool name replacements result = result.replace(toolReferencePattern, (match, toolName) => { return toolNames[toolName] || toolName; }); // Apply contextual tool replacements conversionConfig.toolContexts.forEach((pattern) => { result = result.replace(pattern.from, pattern.to); }); // Apply tool group replacements conversionConfig.toolGroups.forEach((pattern) => { result = result.replace(pattern.from, pattern.to); }); return result; } /** * Update documentation URLs to point to profile documentation */ function updateDocReferences(content, conversionConfig) { let result = content; // Apply documentation URL replacements conversionConfig.docUrls.forEach((pattern) => { if (typeof pattern.to === 'function') { result = result.replace(pattern.from, pattern.to); } else { result = result.replace(pattern.from, pattern.to); } }); return result; } /** * Update file references in markdown links */ function updateFileReferences(content, conversionConfig) { const { pathPattern, replacement } = conversionConfig.fileReferences; return content.replace(pathPattern, replacement); } /** * Main transformation function that applies all conversions */ function transformCursorToProfileRules( content, conversionConfig, globalReplacements = [] ) { let result = content; // Apply all transformations in appropriate order result = updateFileReferences(result, conversionConfig); result = replaceBasicTerms(result, conversionConfig); result = replaceToolReferences(result, conversionConfig); result = updateDocReferences(result, conversionConfig); // Apply any global/catch-all replacements from the profile // Super aggressive failsafe pass to catch any variations we might have missed // This ensures critical transformations are applied even in contexts we didn't anticipate globalReplacements.forEach((pattern) => { if (typeof pattern.to === 'function') { result = result.replace(pattern.from, pattern.to); } else { result = result.replace(pattern.from, pattern.to); } }); return result; } /** * Convert a single Cursor rule file to profile rule format */ export function convertRuleToProfileRule(sourcePath, targetPath, profile) { const { conversionConfig, globalReplacements } = profile; try { log( 'debug', `Converting Cursor rule ${path.basename(sourcePath)} to ${profile.profileName} rule ${path.basename(targetPath)}` ); // Read source content const content = fs.readFileSync(sourcePath, 'utf8'); // Transform content const transformedContent = transformCursorToProfileRules( content, conversionConfig, globalReplacements ); // Ensure target directory exists const targetDir = path.dirname(targetPath); if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); } // Write transformed content fs.writeFileSync(targetPath, transformedContent); log( 'debug', `Successfully converted ${path.basename(sourcePath)} to ${path.basename(targetPath)}` ); return true; } catch (error) { log( 'error', `Failed to convert rule file ${path.basename(sourcePath)}: ${error.message}` ); return false; } } /** * Convert all Cursor rules to profile rules for a specific profile */ export function convertAllRulesToProfileRules(projectDir, profile) { const sourceDir = path.join(process.cwd(), 'assets', 'rules'); const targetDir = path.join(projectDir, profile.rulesDir); // Ensure target directory exists if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); } // Setup MCP configuration if enabled if (profile.mcpConfig !== false) { setupMCPConfiguration(projectDir, profile.mcpConfigPath); } let success = 0; let failed = 0; // Use fileMap to determine which files to copy const sourceFiles = Object.keys(profile.fileMap); for (const sourceFile of sourceFiles) { try { const sourcePath = path.join(sourceDir, sourceFile); // Check if source file exists if (!fs.existsSync(sourcePath)) { log( 'warn', `[Rule Transformer] Source file not found: ${sourceFile}, skipping` ); continue; } const targetFilename = profile.getTargetRuleFilename ? profile.getTargetRuleFilename(sourceFile) : sourceFile; const targetPath = path.join(targetDir, targetFilename); // Read source content let content = fs.readFileSync(sourcePath, 'utf8'); // Apply transformations content = transformCursorToProfileRules( content, profile.conversionConfig, profile.globalReplacements ); // Write to target fs.writeFileSync(targetPath, content, 'utf8'); success++; log( 'debug', `[Rule Transformer] Converted ${sourceFile} -> ${targetFilename} for ${profile.profileName}` ); } catch (error) { failed++; log( 'error', `[Rule Transformer] Failed to convert ${sourceFile} for ${profile.profileName}: ${error.message}` ); } } // Call post-processing hook if defined (e.g., for Roo's rules-*mode* folders) if (typeof profile.onPostConvertRulesProfile === 'function') { profile.onPostConvertRulesProfile(projectDir); } return { success, failed }; } /** * 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 */ export function removeProfileRules(projectDir, profile) { const targetDir = path.join(projectDir, profile.rulesDir); const profileDir = path.join(projectDir, profile.profileDir); let result = { profileName: profile.profileName, success: false, skipped: false, error: null, filesRemoved: [], mcpResult: null, profileDirRemoved: false, notice: null }; try { // Handle simple profiles (Claude, Codex) that just copy files to root const isSimpleProfile = Object.keys(profile.fileMap).length === 0; if (isSimpleProfile) { // For simple profiles, just call their removal hook and return if (typeof profile.onRemoveRulesProfile === 'function') { profile.onRemoveRulesProfile(projectDir); } result.success = true; log( 'debug', `[Rule Transformer] Successfully removed ${profile.profileName} files from ${projectDir}` ); return result; } // Check if profile directory exists at all (for full profiles) 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)) { 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}`); } } // 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'; } } } // 3. Call removal hook if defined (e.g., Roo's custom cleanup) if (typeof profile.onRemoveRulesProfile === 'function') { profile.onRemoveRulesProfile(projectDir); } // 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); 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 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} Task Master files from ${projectDir}` ); } catch (error) { result.error = error.message; log( 'error', `[Rule Transformer] Failed to remove ${profile.profileName} rules: ${error.message}` ); } return result; }