update semantics and terminology from 'brand rules' to 'rules profiles'

This commit is contained in:
Joe Danziger
2025-05-26 19:07:10 -04:00
parent ba55615d55
commit 9db5f78da3
29 changed files with 918 additions and 513 deletions

32
src/constants/profiles.js Normal file
View File

@@ -0,0 +1,32 @@
/**
* @typedef {'cline' | 'cursor' | 'roo' | 'windsurf'} RulesProfile
*/
/**
* Available rules profiles for project initialization and rules command
*
* ⚠️ SINGLE SOURCE OF TRUTH: This is the authoritative list of all supported rules profiles.
* This constant is used directly throughout the codebase (previously aliased as PROFILE_NAMES).
*
* @type {RulesProfile[]}
* @description Defines possible rules profile sets:
* - cline: Cline IDE rules
* - cursor: Cursor IDE rules (default)
* - roo: Roo Code IDE rules
* - windsurf: Windsurf IDE rules
*
* To add a new rules profile:
* 1. Add the profile name to this array
* 2. Create a profile file in scripts/profiles/{profile}.js
* 3. Export it as {profile}Profile in scripts/profiles/index.js
*/
export const RULES_PROFILES = ['cline', 'cursor', 'roo', 'windsurf'];
/**
* Check if a given rules profile is valid
* @param {string} rulesProfile - The rules profile to check
* @returns {boolean} True if the rules profile is valid, false otherwise
*/
export function isValidRulesProfile(rulesProfile) {
return RULES_PROFILES.includes(rulesProfile);
}

View File

@@ -1,35 +0,0 @@
/**
* @typedef {'cursor' | 'roo' | 'windsurf' | 'cline'} BrandRule
*/
/**
* Available brand rules for project initialization
*
* @type {BrandRule[]}
* @description Defines possible brand rule sets:
* - cursor: Cursor IDE rules (default)
* - roo: Roo Code IDE rules
* - windsurf: Windsurf IDE rules
* - cline: Cline IDE rules
*
* To add a new brand:
* 1. Add the brand name to this array
* 2. Create a profile file in scripts/profiles/{brand}.js
* 3. Export it in scripts/profiles/index.js
* 4. Add it to BRAND_PROFILES in src/utils/rule-transformer.js
*/
export const BRAND_RULE_OPTIONS = [
'cursor',
'roo',
'windsurf',
'cline'
];
/**
* Check if a given brand rule is valid
* @param {string} brandRule - The brand rule to check
* @returns {boolean} True if the brand rule is valid, false otherwise
*/
export function isValidBrandRule(brandRule) {
return BRAND_RULE_OPTIONS.includes(brandRule);
}

View File

@@ -2,19 +2,19 @@ import chalk from 'chalk';
import boxen from 'boxen';
/**
* Confirm removing brand rules (destructive operation)
* @param {string[]} brands - Array of brand names to remove
* Confirm removing profile rules (destructive operation)
* @param {string[]} profiles - Array of profile names to remove
* @returns {Promise<boolean>} - Promise resolving to true if user confirms, false otherwise
*/
async function confirmRulesRemove(brands) {
const brandList = brands
async function confirmProfilesRemove(profiles) {
const profileList = profiles
.map((b) => b.charAt(0).toUpperCase() + b.slice(1))
.join(', ');
console.log(
boxen(
chalk.yellow(
`WARNING: This will permanently delete all rules and configuration for: ${brandList}.
This will remove the entire .[brand] directory for each selected brand.\n\nAre you sure you want to proceed?`
`WARNING: This will permanently delete all rules and configuration for: ${profileList}.
This will remove the entire .[profile] directory for each selected profile.\n\nAre you sure you want to proceed?`
),
{ padding: 1, borderColor: 'yellow', borderStyle: 'round' }
)
@@ -31,4 +31,4 @@ This will remove the entire .[brand] directory for each selected brand.\n\nAre y
return confirm;
}
export { confirmRulesRemove };
export { confirmProfilesRemove };

View File

@@ -3,10 +3,12 @@ import path from 'path';
import { log } from '../../scripts/modules/utils.js';
// Structure matches project conventions (see scripts/init.js)
export function setupMCPConfiguration(configDir) {
const mcpPath = path.join(configDir, 'mcp.json');
export function setupMCPConfiguration(projectDir, mcpConfigPath) {
// Build the full path to the MCP config file
const mcpPath = path.join(projectDir, mcpConfigPath);
const configDir = path.dirname(mcpPath);
log('info', 'Setting up MCP configuration for brand integration...');
log('info', `Setting up MCP configuration at ${mcpPath}...`);
// New MCP config to be added - references the installed package
const newMCPServer = {
@@ -26,10 +28,12 @@ export function setupMCPConfiguration(configDir) {
}
}
};
// Create config directory if it doesn't exist
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
if (fs.existsSync(mcpPath)) {
log(
'info',
@@ -94,6 +98,6 @@ export function setupMCPConfiguration(configDir) {
mcpServers: newMCPServer
};
fs.writeFileSync(mcpPath, JSON.stringify(newMCPConfig, null, 4));
log('success', 'Created MCP configuration file');
log('success', `Created MCP configuration file at ${mcpPath}`);
}
}
}

View File

@@ -1,45 +1,59 @@
/**
* Rule Transformer Module
* Handles conversion of Cursor rules to brand rules
* Handles conversion of Cursor rules to profile rules
*
* This module procedurally generates .{brand}/rules files from assets/rules files,
* 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 { fileURLToPath } from 'url';
import { log } from '../../scripts/modules/utils.js';
// Import the shared MCP configuration helper
import { setupMCPConfiguration } from './mcp-utils.js';
// --- Centralized Brand Helpers ---
import { clineProfile, cursorProfile, rooProfile, windsurfProfile } from '../../scripts/profiles/index.js';
// Import profile constants (single source of truth)
import { RULES_PROFILES } from '../constants/profiles.js';
export const BRAND_PROFILES = {
cline: clineProfile,
cursor: cursorProfile,
roo: rooProfile,
windsurf: windsurfProfile
};
// --- Profile Imports ---
import * as profilesModule from '../../scripts/profiles/index.js';
export const BRAND_NAMES = Object.keys(BRAND_PROFILES);
export function isValidBrand(brand) {
return BRAND_NAMES.includes(brand);
}
export function getBrandProfile(brand) {
return BRAND_PROFILES[brand];
export function isValidProfile(profile) {
return RULES_PROFILES.includes(profile);
}
/**
* Replace basic Cursor terms with brand equivalents
* Get rules 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: ${RULES_PROFILES.join(', ')}`
);
}
return profile;
}
/**
* Replace basic Cursor terms with profile equivalents
*/
function replaceBasicTerms(content, conversionConfig) {
let result = content;
// Apply brand term replacements
conversionConfig.brandTerms.forEach((pattern) => {
// Apply profile term replacements
conversionConfig.profileTerms.forEach((pattern) => {
if (typeof pattern.to === 'function') {
result = result.replace(pattern.from, pattern.to);
} else {
@@ -56,7 +70,7 @@ function replaceBasicTerms(content, conversionConfig) {
}
/**
* Replace Cursor tool references with brand tool equivalents
* Replace Cursor tool references with profile tool equivalents
*/
function replaceToolReferences(content, conversionConfig) {
let result = content;
@@ -87,7 +101,7 @@ function replaceToolReferences(content, conversionConfig) {
}
/**
* Update documentation URLs to point to brand documentation
* Update documentation URLs to point to profile documentation
*/
function updateDocReferences(content, conversionConfig) {
let result = content;
@@ -115,8 +129,7 @@ function updateFileReferences(content, conversionConfig) {
/**
* Main transformation function that applies all conversions
*/
// Main transformation function that applies all conversions, now brand-generic
function transformCursorToBrandRules(
function transformCursorToProfileRules(
content,
conversionConfig,
globalReplacements = []
@@ -128,7 +141,7 @@ function transformCursorToBrandRules(
result = updateDocReferences(result, conversionConfig);
result = updateFileReferences(result, conversionConfig);
// Apply any global/catch-all replacements from the brand profile
// 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) => {
@@ -143,21 +156,21 @@ function transformCursorToBrandRules(
}
/**
* Convert a single Cursor rule file to brand rule format
* Convert a single Cursor rule file to profile rule format
*/
function convertRuleToBrandRule(sourcePath, targetPath, profile) {
const { conversionConfig, brandName, globalReplacements } = profile;
export function convertRuleToProfileRule(sourcePath, targetPath, profile) {
const { conversionConfig, globalReplacements } = profile;
try {
log(
'debug',
`Converting Cursor rule ${path.basename(sourcePath)} to ${brandName} rule ${path.basename(targetPath)}`
`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 = transformCursorToBrandRules(
const transformedContent = transformCursorToProfileRules(
content,
conversionConfig,
globalReplacements
@@ -187,152 +200,141 @@ function convertRuleToBrandRule(sourcePath, targetPath, profile) {
}
/**
* Process all Cursor rules and convert to brand rules
* Convert all Cursor rules to profile rules for a specific profile
*/
function convertAllRulesToBrandRules(projectDir, profile) {
const { fileMap, brandName, rulesDir, mcpConfig, mcpConfigName } = profile;
// Use assets/rules as the source of rules instead of .cursor/rules
const cursorRulesDir = path.join(projectDir, 'assets', 'rules');
const brandRulesDir = path.join(projectDir, rulesDir);
export function convertAllRulesToProfileRules(projectDir, profile) {
const sourceDir = fileURLToPath(new URL('../../assets/rules', import.meta.url));
const targetDir = path.join(projectDir, profile.rulesDir);
if (!fs.existsSync(cursorRulesDir)) {
log('warn', `Cursor rules directory not found: ${cursorRulesDir}`);
return { success: 0, failed: 0 };
// Ensure target directory exists
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
// Ensure brand rules directory exists
if (!fs.existsSync(brandRulesDir)) {
fs.mkdirSync(brandRulesDir, { recursive: true });
log('debug', `Created ${brandName} rules directory: ${brandRulesDir}`);
// Also create MCP configuration in the brand directory if enabled
if (mcpConfig !== false) {
const brandDir = profile.brandDir;
setupMCPConfiguration(path.join(projectDir, brandDir), mcpConfigName);
}
// Setup MCP configuration if enabled
if (profile.mcpConfig !== false) {
setupMCPConfiguration(
projectDir,
profile.mcpConfigPath
);
}
// Count successful and failed conversions
let success = 0;
let failed = 0;
// Process each file from assets/rules listed in fileMap
const getTargetRuleFilename = profile.getTargetRuleFilename || ((f) => f);
Object.keys(profile.fileMap).forEach((file) => {
const sourcePath = path.join(cursorRulesDir, file);
if (fs.existsSync(sourcePath)) {
const targetFilename = getTargetRuleFilename(file);
const targetPath = path.join(brandRulesDir, targetFilename);
// Use fileMap to determine which files to copy
const sourceFiles = Object.keys(profile.fileMap);
// Convert the file
if (convertRuleToBrandRule(sourcePath, targetPath, profile)) {
success++;
} else {
failed++;
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;
}
} else {
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(
'warn',
`File listed in fileMap not found in rules dir: ${sourcePath}`
'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}`
);
}
});
log(
'debug',
`Rule conversion complete: ${success} successful, ${failed} failed`
);
}
// Call post-processing hook if defined (e.g., for Roo's rules-*mode* folders)
if (typeof profile.onPostConvertBrandRules === 'function') {
profile.onPostConvertBrandRules(projectDir);
if (typeof profile.onPostConvertRulesProfile === 'function') {
profile.onPostConvertRulesProfile(projectDir);
}
return { success, failed };
}
/**
* Remove a brand's rules directory, its mcp.json, and the parent brand folder recursively.
* @param {string} projectDir - The root directory of the project
* @param {object} profile - The brand profile object
* @returns {boolean} - True if removal succeeded, false otherwise
* Remove profile rules for a specific profile
* @param {string} projectDir - Target project directory
* @param {Object} profile - Profile configuration
* @returns {Object} Result object
*/
function removeBrandRules(projectDir, profile) {
const { brandName, rulesDir, mcpConfig, mcpConfigName } = profile;
const brandDir = profile.brandDir;
const brandRulesDir = path.join(projectDir, rulesDir);
const mcpPath = path.join(projectDir, brandDir, mcpConfigName);
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);
const result = {
brandName,
mcpConfigRemoved: false,
rulesDirRemoved: false,
brandFolderRemoved: false,
let result = {
profileName: profile.profileName,
success: false,
skipped: false,
error: null,
success: false // Overall success for this brand
error: null
};
if (mcpConfig !== false && fs.existsSync(mcpPath)) {
try {
fs.unlinkSync(mcpPath);
result.mcpConfigRemoved = true;
} catch (e) {
const errorMessage = `Failed to remove MCP configuration at ${mcpPath}: ${e.message}`;
log('warn', errorMessage);
result.error = result.error
? `${result.error}; ${errorMessage}`
: errorMessage;
}
}
// Remove rules directory
if (fs.existsSync(brandRulesDir)) {
try {
fs.rmSync(brandRulesDir, { recursive: true, force: true });
result.rulesDirRemoved = true;
} catch (e) {
const errorMessage = `Failed to remove rules directory at ${brandRulesDir}: ${e.message}`;
log('warn', errorMessage);
result.error = result.error
? `${result.error}; ${errorMessage}`
: errorMessage;
}
}
// Remove brand folder
try {
fs.rmSync(brandDir, { recursive: true, force: true });
result.brandFolderRemoved = true;
} catch (e) {
const errorMessage = `Failed to remove brand folder at ${brandDir}: ${e.message}`;
log('warn', errorMessage);
result.error = result.error
? `${result.error}; ${errorMessage}`
: errorMessage;
}
// Call onRemoveBrandRules hook if present
if (typeof profile.onRemoveBrandRules === 'function') {
try {
profile.onRemoveBrandRules(projectDir);
} catch (e) {
const errorMessage = `Error in onRemoveBrandRules for ${brandName}: ${e.message}`;
log('warn', errorMessage);
result.error = result.error
? `${result.error}; ${errorMessage}`
: errorMessage;
// Remove rules directory
if (fs.existsSync(targetDir)) {
fs.rmSync(targetDir, { recursive: true, force: true });
log('debug', `[Rule Transformer] Removed rules directory: ${targetDir}`);
}
// Remove MCP config if it exists
if (fs.existsSync(mcpConfigPath)) {
fs.rmSync(mcpConfigPath, { force: true });
log('debug', `[Rule Transformer] Removed MCP config: ${mcpConfigPath}`);
}
// Call removal hook if defined
if (typeof profile.onRemoveRulesProfile === 'function') {
profile.onRemoveRulesProfile(projectDir);
}
// Remove profile directory if empty
if (fs.existsSync(profileDir)) {
const remaining = fs.readdirSync(profileDir);
if (remaining.length === 0) {
fs.rmSync(profileDir, { recursive: true, force: true });
log(
'debug',
`[Rule Transformer] Removed empty profile directory: ${profileDir}`
);
}
}
result.success = true;
log(
'debug',
`[Rule Transformer] Successfully removed ${profile.profileName} rules from ${projectDir}`
);
} catch (error) {
result.error = error.message;
log(
'error',
`[Rule Transformer] Failed to remove ${profile.profileName} rules: ${error.message}`
);
}
result.success =
result.mcpConfigRemoved ||
result.rulesDirRemoved ||
result.brandFolderRemoved;
return result;
}
export {
convertAllRulesToBrandRules,
convertRuleToBrandRule,
removeBrandRules
};

View File

@@ -1,13 +1,13 @@
import readline from 'readline';
import inquirer from 'inquirer';
import chalk from 'chalk';
import { BRAND_PROFILES, BRAND_NAMES } from './rule-transformer.js';
import { log } from '../../scripts/modules/utils.js';
import { getRulesProfile } from './rule-transformer.js';
import { RULES_PROFILES } from '../constants/profiles.js';
// Dynamically generate availableBrandRules from BRAND_NAMES and brand profiles
const availableBrandRules = BRAND_NAMES.map((name) => {
const displayName =
BRAND_PROFILES[name]?.brandName ||
name.charAt(0).toUpperCase() + name.slice(1);
// Dynamically generate availableRulesProfiles from RULES_PROFILES
const availableRulesProfiles = RULES_PROFILES.map((name) => {
const displayName = getProfileDisplayName(name);
return {
name: name === 'cursor' ? `${displayName} (default)` : displayName,
value: name
@@ -15,18 +15,26 @@ const availableBrandRules = BRAND_NAMES.map((name) => {
});
/**
* Runs the interactive rules setup flow (brand rules selection only)
* @returns {Promise<string[]>} The selected brand rules
* Get the display name for a profile
*/
function getProfileDisplayName(name) {
const profile = getRulesProfile(name);
return profile?.profileName || name.charAt(0).toUpperCase() + name.slice(1);
}
/**
* Runs the interactive rules setup flow (profile rules selection only)
* @returns {Promise<string[]>} The selected profile rules
*/
/**
* Launches an interactive prompt for selecting which brand rules to include in your project.
* Launches an interactive prompt for selecting which profile rules to include in your project.
*
* This function dynamically lists all available brands (from BRAND_PROFILES) and presents them as checkboxes.
* The user must select at least one brand (default: cursor). The result is an array of selected brand names.
* This function dynamically lists all available profiles (from RULES_PROFILES) and presents them as checkboxes.
* The user must select at least one profile (default: cursor). The result is an array of selected profile names.
*
* Used by both project initialization (init) and the CLI 'task-master rules setup' command to ensure DRY, consistent UX.
*
* @returns {Promise<string[]>} Array of selected brand rule names (e.g., ['cursor', 'windsurf'])
* @returns {Promise<string[]>} Array of selected profile rule names (e.g., ['cursor', 'windsurf'])
*/
export async function runInteractiveRulesSetup() {
console.log(
@@ -34,14 +42,14 @@ export async function runInteractiveRulesSetup() {
'\nRules help enforce best practices and conventions for Task Master.'
)
);
const brandRulesQuestion = {
const rulesProfilesQuestion = {
type: 'checkbox',
name: 'brandRules',
name: 'rulesProfiles',
message: 'Which IDEs would you like rules included for?',
choices: availableBrandRules,
choices: availableRulesProfiles,
default: ['cursor'],
validate: (input) => input.length > 0 || 'You must select at least one.'
};
const { brandRules } = await inquirer.prompt([brandRulesQuestion]);
return brandRules;
}
const { rulesProfiles } = await inquirer.prompt([rulesProfilesQuestion]);
return rulesProfiles;
}