Unify and streamline profile system architecture (#853)
* move claude rules and commands to assets/claude * update claude profile to copy assets/claude to .claude * fix formatting * feat(profiles): Implement unified profile system - Convert Claude and Codex profiles to use createProfile() factory - Remove simple vs complex profile distinction in rule transformer - Unify convertAllRulesToProfileRules() to handle all profiles consistently - Fix mcpConfigPath construction in base-profile.js for null mcpConfigName - Update terminology from 'simpleProfiles' to 'assetOnlyProfiles' throughout - Ensure Claude .claude directory copying works in both CLI and MCP contexts - All profiles now follow same execution flow with proper lifecycle functions Changes: - src/profiles/claude.js: Convert to createProfile() factory pattern - src/profiles/codex.js: Convert to createProfile() factory pattern - src/utils/rule-transformer.js: Unified profile handling logic - src/utils/profiles.js: Remove simple profile categorization - src/profiles/base-profile.js: Fix mcpConfigPath construction - scripts/modules/commands.js: Update variable naming - tests/: Update all tests for unified system and terminology Fixes Claude profile asset copying issue in MCP context. All tests passing (617 passed, 11 skipped). * re-checkin claude files * fix formatting * chore: clean up test Claude rules files * chore: add changeset for unified profile system * add claude files back * add changeset * restore proper gitignore * remove claude agents file from root * remove incorrect doc * simplify profiles and update tests * update changeset * update changeset * remove profile specific code * streamline profiles with defaults and update tests * update changeset * add newline at end of gitignore * restore changes * streamline profiles with defaults; update tests and add vscode test * update rule profile tests * update wording for clearer profile management * refactor and clarify terminology * use original projectRoot var name * revert param desc * use updated claude assets from neno * add "YOUR_" before api key here * streamline codex profile * add gemini profile * update gemini profile * update tests * relocate function * update rules interactive setup Gemini desc * remove duplicative code * add comma
This commit is contained in:
@@ -47,15 +47,15 @@ export function setupMCPConfiguration(projectRoot, mcpConfigPath) {
|
||||
command: 'npx',
|
||||
args: ['-y', '--package=task-master-ai', 'task-master-ai'],
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: 'ANTHROPIC_API_KEY_HERE',
|
||||
PERPLEXITY_API_KEY: 'PERPLEXITY_API_KEY_HERE',
|
||||
OPENAI_API_KEY: 'OPENAI_API_KEY_HERE',
|
||||
GOOGLE_API_KEY: 'GOOGLE_API_KEY_HERE',
|
||||
XAI_API_KEY: 'XAI_API_KEY_HERE',
|
||||
OPENROUTER_API_KEY: 'OPENROUTER_API_KEY_HERE',
|
||||
MISTRAL_API_KEY: 'MISTRAL_API_KEY_HERE',
|
||||
AZURE_OPENAI_API_KEY: 'AZURE_OPENAI_API_KEY_HERE',
|
||||
OLLAMA_API_KEY: 'OLLAMA_API_KEY_HERE'
|
||||
ANTHROPIC_API_KEY: 'YOUR_ANTHROPIC_API_KEY_HERE',
|
||||
PERPLEXITY_API_KEY: 'YOUR_PERPLEXITY_API_KEY_HERE',
|
||||
OPENAI_API_KEY: 'YOUR_OPENAI_KEY_HERE',
|
||||
GOOGLE_API_KEY: 'YOUR_GOOGLE_KEY_HERE',
|
||||
XAI_API_KEY: 'YOUR_XAI_KEY_HERE',
|
||||
OPENROUTER_API_KEY: 'YOUR_OPENROUTER_KEY_HERE',
|
||||
MISTRAL_API_KEY: 'YOUR_MISTRAL_KEY_HERE',
|
||||
AZURE_OPENAI_API_KEY: 'YOUR_AZURE_KEY_HERE',
|
||||
OLLAMA_API_KEY: 'YOUR_OLLAMA_API_KEY_HERE'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,24 +16,48 @@ import { RULE_PROFILES } from '../constants/profiles.js';
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Detect which profiles are currently installed in the project
|
||||
* @param {string} projectRoot - Project root directory
|
||||
* @returns {string[]} Array of installed profile names
|
||||
* Get the display name for a profile
|
||||
* @param {string} profileName - The profile name
|
||||
* @returns {string} - The display name
|
||||
*/
|
||||
export function getProfileDisplayName(profileName) {
|
||||
try {
|
||||
const profile = getRulesProfile(profileName);
|
||||
return profile.displayName || profileName;
|
||||
} catch (error) {
|
||||
return profileName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get installed profiles in the project directory
|
||||
* @param {string} projectRoot - Project directory path
|
||||
* @returns {string[]} - Array of installed profile names
|
||||
*/
|
||||
export function getInstalledProfiles(projectRoot) {
|
||||
const installedProfiles = [];
|
||||
|
||||
for (const profileName of RULE_PROFILES) {
|
||||
const profileConfig = getRulesProfile(profileName);
|
||||
if (!profileConfig) continue;
|
||||
try {
|
||||
const profile = getRulesProfile(profileName);
|
||||
const profileDir = path.join(projectRoot, profile.profileDir);
|
||||
|
||||
// Check if the profile directory exists
|
||||
const profileDir = path.join(projectRoot, profileConfig.profileDir);
|
||||
const rulesDir = path.join(projectRoot, profileConfig.rulesDir);
|
||||
|
||||
// A profile is considered installed if either the profile dir or rules dir exists
|
||||
if (fs.existsSync(profileDir) || fs.existsSync(rulesDir)) {
|
||||
installedProfiles.push(profileName);
|
||||
// Check if profile directory exists (skip root directory check)
|
||||
if (profile.profileDir === '.' || fs.existsSync(profileDir)) {
|
||||
// Check if any files from the profile's fileMap exist
|
||||
const rulesDir = path.join(projectRoot, profile.rulesDir);
|
||||
if (fs.existsSync(rulesDir)) {
|
||||
const ruleFiles = Object.values(profile.fileMap);
|
||||
const hasRuleFiles = ruleFiles.some((ruleFile) =>
|
||||
fs.existsSync(path.join(rulesDir, ruleFile))
|
||||
);
|
||||
if (hasRuleFiles) {
|
||||
installedProfiles.push(profileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip profiles that can't be loaded
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,32 +65,29 @@ export function getInstalledProfiles(projectRoot) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if removing the specified profiles would result in no profiles remaining
|
||||
* Check if removing specified profiles would leave no profiles installed
|
||||
* @param {string} projectRoot - Project root directory
|
||||
* @param {string[]} profilesToRemove - Array of profile names to remove
|
||||
* @returns {boolean} True if removal would result in no profiles remaining
|
||||
* @returns {boolean} - True if removal would leave no profiles
|
||||
*/
|
||||
export function wouldRemovalLeaveNoProfiles(projectRoot, profilesToRemove) {
|
||||
const installedProfiles = getInstalledProfiles(projectRoot);
|
||||
|
||||
// If no profiles are currently installed, removal cannot leave no profiles
|
||||
if (installedProfiles.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const remainingProfiles = installedProfiles.filter(
|
||||
(profile) => !profilesToRemove.includes(profile)
|
||||
);
|
||||
|
||||
return remainingProfiles.length === 0 && installedProfiles.length > 0;
|
||||
return remainingProfiles.length === 0;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROFILE SETUP
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get the display name for a profile
|
||||
*/
|
||||
function getProfileDisplayName(name) {
|
||||
const profile = getRulesProfile(name);
|
||||
return profile?.displayName || name.charAt(0).toUpperCase() + name.slice(1);
|
||||
}
|
||||
|
||||
// Note: Profile choices are now generated dynamically within runInteractiveProfilesSetup()
|
||||
// to ensure proper alphabetical sorting and pagination configuration
|
||||
|
||||
@@ -86,26 +107,32 @@ export async function runInteractiveProfilesSetup() {
|
||||
const displayName = getProfileDisplayName(profileName);
|
||||
const profile = getRulesProfile(profileName);
|
||||
|
||||
// Determine description based on profile type
|
||||
// Determine description based on profile capabilities
|
||||
let description;
|
||||
if (Object.keys(profile.fileMap).length === 0) {
|
||||
// Simple profiles (Claude, Codex) - specify the target file
|
||||
const targetFileName =
|
||||
profileName === 'claude' ? 'CLAUDE.md' : 'AGENTS.md';
|
||||
description = `Integration guide (${targetFileName})`;
|
||||
} else {
|
||||
// Full profiles with rules - check if they have MCP config
|
||||
const hasMcpConfig = profile.mcpConfig === true;
|
||||
if (hasMcpConfig) {
|
||||
// Special case for Roo to mention agent modes
|
||||
if (profileName === 'roo') {
|
||||
description = 'Rule profile, MCP config, and agent modes';
|
||||
} else {
|
||||
description = 'Rule profile and MCP config';
|
||||
}
|
||||
const hasRules = Object.keys(profile.fileMap).length > 0;
|
||||
const hasMcpConfig = profile.mcpConfig === true;
|
||||
|
||||
if (!profile.includeDefaultRules) {
|
||||
// Integration guide profiles (claude, codex, gemini) - don't include standard coding rules
|
||||
if (profileName === 'claude') {
|
||||
description = 'Integration guide with Task Master slash commands';
|
||||
} else if (profileName === 'codex') {
|
||||
description = 'Comprehensive Task Master integration guide';
|
||||
} else if (profileName === 'gemini') {
|
||||
description = 'Integration guide and MCP config';
|
||||
} else {
|
||||
description = 'Rule profile';
|
||||
description = 'Integration guide';
|
||||
}
|
||||
} else if (hasRules && hasMcpConfig) {
|
||||
// Full rule profiles with MCP config
|
||||
if (profileName === 'roo') {
|
||||
description = 'Rule profile, MCP config, and agent modes';
|
||||
} else {
|
||||
description = 'Rule profile and MCP config';
|
||||
}
|
||||
} else if (hasRules) {
|
||||
// Rule profiles without MCP config
|
||||
description = 'Rule profile';
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -170,14 +197,13 @@ export async function runInteractiveProfilesSetup() {
|
||||
*/
|
||||
export function generateProfileSummary(profileName, addResult) {
|
||||
const profileConfig = getRulesProfile(profileName);
|
||||
const isSimpleProfile = Object.keys(profileConfig.fileMap).length === 0;
|
||||
|
||||
if (isSimpleProfile) {
|
||||
// Simple profiles like Claude and Codex only copy AGENTS.md
|
||||
const targetFileName = profileName === 'claude' ? 'CLAUDE.md' : 'AGENTS.md';
|
||||
return `Summary for ${profileName}: Integration guide copied to ${targetFileName}`;
|
||||
if (!profileConfig.includeDefaultRules) {
|
||||
// Integration guide profiles (claude, codex, gemini)
|
||||
return `Summary for ${profileName}: Integration guide installed.`;
|
||||
} else {
|
||||
return `Summary for ${profileName}: ${addResult.success} rules added, ${addResult.failed} failed.`;
|
||||
// Rule profiles with coding guidelines
|
||||
return `Summary for ${profileName}: ${addResult.success} files processed, ${addResult.failed} failed.`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,9 +214,6 @@ export function generateProfileSummary(profileName, addResult) {
|
||||
* @returns {string} Formatted summary message
|
||||
*/
|
||||
export function generateProfileRemovalSummary(profileName, removeResult) {
|
||||
const profileConfig = getRulesProfile(profileName);
|
||||
const isSimpleProfile = Object.keys(profileConfig.fileMap).length === 0;
|
||||
|
||||
if (removeResult.skipped) {
|
||||
return `Summary for ${profileName}: Skipped (default or protected files)`;
|
||||
}
|
||||
@@ -199,13 +222,18 @@ export function generateProfileRemovalSummary(profileName, removeResult) {
|
||||
return `Summary for ${profileName}: Failed to remove - ${removeResult.error}`;
|
||||
}
|
||||
|
||||
if (isSimpleProfile) {
|
||||
// Simple profiles like Claude and Codex only have an integration guide
|
||||
const targetFileName = profileName === 'claude' ? 'CLAUDE.md' : 'AGENTS.md';
|
||||
return `Summary for ${profileName}: Integration guide (${targetFileName}) removed`;
|
||||
const profileConfig = getRulesProfile(profileName);
|
||||
|
||||
if (!profileConfig.includeDefaultRules) {
|
||||
// Integration guide profiles (claude, codex, gemini)
|
||||
const baseMessage = `Summary for ${profileName}: Integration guide removed`;
|
||||
if (removeResult.notice) {
|
||||
return `${baseMessage} (${removeResult.notice})`;
|
||||
}
|
||||
return baseMessage;
|
||||
} else {
|
||||
// Full profiles have rules directories and potentially MCP configs
|
||||
const baseMessage = `Summary for ${profileName}: Rules directory removed`;
|
||||
// Rule profiles with coding guidelines
|
||||
const baseMessage = `Summary for ${profileName}: Rule profile removed`;
|
||||
if (removeResult.notice) {
|
||||
return `${baseMessage} (${removeResult.notice})`;
|
||||
}
|
||||
@@ -220,7 +248,6 @@ export function generateProfileRemovalSummary(profileName, removeResult) {
|
||||
*/
|
||||
export function categorizeProfileResults(addResults) {
|
||||
const successfulProfiles = [];
|
||||
const simpleProfiles = [];
|
||||
let totalSuccess = 0;
|
||||
let totalFailed = 0;
|
||||
|
||||
@@ -228,22 +255,15 @@ export function categorizeProfileResults(addResults) {
|
||||
totalSuccess += r.success;
|
||||
totalFailed += r.failed;
|
||||
|
||||
const profileConfig = getRulesProfile(r.profileName);
|
||||
const isSimpleProfile = Object.keys(profileConfig.fileMap).length === 0;
|
||||
|
||||
if (isSimpleProfile) {
|
||||
// Simple profiles are successful if they completed without error
|
||||
simpleProfiles.push(r.profileName);
|
||||
} else if (r.success > 0) {
|
||||
// Full profiles are successful if they added rules
|
||||
// All profiles are considered successful if they completed without major errors
|
||||
if (r.success > 0 || r.failed === 0) {
|
||||
successfulProfiles.push(r.profileName);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
successfulProfiles,
|
||||
simpleProfiles,
|
||||
allSuccessfulProfiles: [...successfulProfiles, ...simpleProfiles],
|
||||
allSuccessfulProfiles: successfulProfiles,
|
||||
totalSuccess,
|
||||
totalFailed
|
||||
};
|
||||
|
||||
@@ -199,97 +199,130 @@ export function convertRuleToProfileRule(sourcePath, targetPath, profile) {
|
||||
* Convert all Cursor rules to profile rules for a specific profile
|
||||
*/
|
||||
export function convertAllRulesToProfileRules(projectRoot, profile) {
|
||||
// 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 post-processing hook and return
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const assetsDir = path.join(__dirname, '..', '..', 'assets');
|
||||
|
||||
if (typeof profile.onPostConvertRulesProfile === 'function') {
|
||||
profile.onPostConvertRulesProfile(projectRoot, assetsDir);
|
||||
}
|
||||
return { success: 1, failed: 0 };
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const sourceDir = path.join(__dirname, '..', '..', 'assets', 'rules');
|
||||
const targetDir = path.join(projectRoot, 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(projectRoot, profile.mcpConfigPath);
|
||||
}
|
||||
const assetsDir = path.join(__dirname, '..', '..', 'assets');
|
||||
|
||||
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) {
|
||||
// 1. Call onAddRulesProfile first (for pre-processing like copying assets)
|
||||
if (typeof profile.onAddRulesProfile === 'function') {
|
||||
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.fileMap[sourceFile];
|
||||
const targetPath = path.join(targetDir, targetFilename);
|
||||
|
||||
// Ensure target subdirectory exists (for rules like taskmaster/dev_workflow.md)
|
||||
const targetFileDir = path.dirname(targetPath);
|
||||
if (!fs.existsSync(targetFileDir)) {
|
||||
fs.mkdirSync(targetFileDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Read source content
|
||||
let content = fs.readFileSync(sourcePath, 'utf8');
|
||||
|
||||
// Apply transformations
|
||||
content = transformRuleContent(
|
||||
content,
|
||||
profile.conversionConfig,
|
||||
profile.globalReplacements
|
||||
);
|
||||
|
||||
// Write to target
|
||||
fs.writeFileSync(targetPath, content, 'utf8');
|
||||
success++;
|
||||
|
||||
profile.onAddRulesProfile(projectRoot, assetsDir);
|
||||
log(
|
||||
'debug',
|
||||
`[Rule Transformer] Converted ${sourceFile} -> ${targetFilename} for ${profile.profileName}`
|
||||
`[Rule Transformer] Called onAddRulesProfile for ${profile.profileName}`
|
||||
);
|
||||
} catch (error) {
|
||||
failed++;
|
||||
log(
|
||||
'error',
|
||||
`[Rule Transformer] Failed to convert ${sourceFile} for ${profile.profileName}: ${error.message}`
|
||||
`[Rule Transformer] onAddRulesProfile failed for ${profile.profileName}: ${error.message}`
|
||||
);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle fileMap-based rule conversion (if any)
|
||||
const sourceFiles = Object.keys(profile.fileMap);
|
||||
if (sourceFiles.length > 0) {
|
||||
// Only create rules directory if we have files to copy
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
for (const sourceFile of sourceFiles) {
|
||||
// Determine if this is an asset file (not a rule file)
|
||||
const isAssetFile = !sourceFile.startsWith('rules/');
|
||||
|
||||
try {
|
||||
// Use explicit path from fileMap - assets/ is the base directory
|
||||
const sourcePath = path.join(assetsDir, sourceFile);
|
||||
|
||||
// Check if source file exists
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
log(
|
||||
'warn',
|
||||
`[Rule Transformer] Source file not found: ${sourcePath}, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetFilename = profile.fileMap[sourceFile];
|
||||
const targetPath = path.join(targetDir, targetFilename);
|
||||
|
||||
// Ensure target subdirectory exists (for rules like taskmaster/dev_workflow.md)
|
||||
const targetFileDir = path.dirname(targetPath);
|
||||
if (!fs.existsSync(targetFileDir)) {
|
||||
fs.mkdirSync(targetFileDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Read source content
|
||||
let content = fs.readFileSync(sourcePath, 'utf8');
|
||||
|
||||
// Apply transformations (only if this is a rule file, not an asset file)
|
||||
if (!isAssetFile) {
|
||||
content = transformRuleContent(
|
||||
content,
|
||||
profile.conversionConfig,
|
||||
profile.globalReplacements
|
||||
);
|
||||
}
|
||||
|
||||
// Write to target
|
||||
fs.writeFileSync(targetPath, content, 'utf8');
|
||||
success++;
|
||||
|
||||
log(
|
||||
'debug',
|
||||
`[Rule Transformer] ${isAssetFile ? 'Copied' : 'Converted'} ${sourceFile} -> ${targetFilename} for ${profile.profileName}`
|
||||
);
|
||||
} catch (error) {
|
||||
failed++;
|
||||
log(
|
||||
'error',
|
||||
`[Rule Transformer] Failed to ${isAssetFile ? 'copy' : 'convert'} ${sourceFile} for ${profile.profileName}: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Setup MCP configuration (if enabled)
|
||||
if (profile.mcpConfig !== false) {
|
||||
try {
|
||||
setupMCPConfiguration(projectRoot, profile.mcpConfigPath);
|
||||
log(
|
||||
'debug',
|
||||
`[Rule Transformer] Setup MCP configuration for ${profile.profileName}`
|
||||
);
|
||||
} catch (error) {
|
||||
log(
|
||||
'error',
|
||||
`[Rule Transformer] MCP setup failed for ${profile.profileName}: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Call post-processing hook if defined (e.g., for Roo's rules-*mode* folders)
|
||||
// 4. Call post-conversion hook (for finalization)
|
||||
if (typeof profile.onPostConvertRulesProfile === 'function') {
|
||||
const assetsDir = path.join(__dirname, '..', '..', 'assets');
|
||||
profile.onPostConvertRulesProfile(projectRoot, assetsDir);
|
||||
try {
|
||||
profile.onPostConvertRulesProfile(projectRoot, assetsDir);
|
||||
log(
|
||||
'debug',
|
||||
`[Rule Transformer] Called onPostConvertRulesProfile for ${profile.profileName}`
|
||||
);
|
||||
} catch (error) {
|
||||
log(
|
||||
'error',
|
||||
`[Rule Transformer] onPostConvertRulesProfile failed for ${profile.profileName}: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { success, failed };
|
||||
// Ensure we return at least 1 success for profiles that only use lifecycle functions
|
||||
return { success: Math.max(success, 1), failed };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,176 +347,147 @@ export function removeProfileRules(projectRoot, profile) {
|
||||
};
|
||||
|
||||
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') {
|
||||
// 1. Call onRemoveRulesProfile first (for custom cleanup like removing assets)
|
||||
if (typeof profile.onRemoveRulesProfile === 'function') {
|
||||
try {
|
||||
profile.onRemoveRulesProfile(projectRoot);
|
||||
log(
|
||||
'debug',
|
||||
`[Rule Transformer] Called onRemoveRulesProfile for ${profile.profileName}`
|
||||
);
|
||||
} catch (error) {
|
||||
log(
|
||||
'error',
|
||||
`[Rule Transformer] onRemoveRulesProfile failed for ${profile.profileName}: ${error.message}`
|
||||
);
|
||||
}
|
||||
result.success = true;
|
||||
log(
|
||||
'debug',
|
||||
`[Rule Transformer] Successfully removed ${profile.profileName} files from ${projectRoot}`
|
||||
);
|
||||
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;
|
||||
}
|
||||
// 2. Remove fileMap-based files (if any)
|
||||
const sourceFiles = Object.keys(profile.fileMap);
|
||||
if (sourceFiles.length > 0) {
|
||||
// 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);
|
||||
const removedFiles = [];
|
||||
let hasOtherRulesFiles = false;
|
||||
|
||||
// Helper function to recursively check and remove Task Master files
|
||||
function processDirectory(dirPath, relativePath = '') {
|
||||
const items = fs.readdirSync(dirPath);
|
||||
if (fs.existsSync(targetDir)) {
|
||||
// Get list of files we're responsible for
|
||||
const taskMasterFiles = sourceFiles.map(
|
||||
(sourceFile) => profile.fileMap[sourceFile]
|
||||
);
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(dirPath, item);
|
||||
const relativeItemPath = relativePath
|
||||
? path.join(relativePath, item)
|
||||
: item;
|
||||
const stat = fs.statSync(itemPath);
|
||||
// Get all files in the rules directory
|
||||
const allFiles = fs.readdirSync(targetDir, { recursive: true });
|
||||
const allFilePaths = allFiles
|
||||
.filter((file) => {
|
||||
const fullPath = path.join(targetDir, file);
|
||||
return fs.statSync(fullPath).isFile();
|
||||
})
|
||||
.map((file) => file.toString()); // Ensure it's a string
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Recursively process subdirectory
|
||||
processDirectory(itemPath, relativeItemPath);
|
||||
|
||||
// Check if directory is empty after processing and remove if so
|
||||
// Remove only Task Master files
|
||||
for (const taskMasterFile of taskMasterFiles) {
|
||||
const filePath = path.join(targetDir, taskMasterFile);
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const remainingItems = fs.readdirSync(itemPath);
|
||||
if (remainingItems.length === 0) {
|
||||
fs.rmSync(itemPath, { recursive: true, force: true });
|
||||
log(
|
||||
'debug',
|
||||
`[Rule Transformer] Removed empty directory: ${relativeItemPath}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Directory might have been removed already, ignore
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (taskmasterFiles.includes(relativeItemPath)) {
|
||||
// This is a Task Master file, remove it
|
||||
fs.rmSync(itemPath, { force: true });
|
||||
removedFiles.push(relativeItemPath);
|
||||
fs.rmSync(filePath, { force: true });
|
||||
result.filesRemoved.push(taskMasterFile);
|
||||
log(
|
||||
'debug',
|
||||
`[Rule Transformer] Removed Task Master file: ${relativeItemPath}`
|
||||
`[Rule Transformer] Removed Task Master file: ${taskMasterFile}`
|
||||
);
|
||||
} else {
|
||||
// This is not a Task Master file, leave it
|
||||
hasOtherRulesFiles = true;
|
||||
} catch (error) {
|
||||
log(
|
||||
'debug',
|
||||
`[Rule Transformer] Preserved existing file: ${relativeItemPath}`
|
||||
'error',
|
||||
`[Rule Transformer] Failed to remove ${taskMasterFile}: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process the rules directory recursively
|
||||
processDirectory(targetDir);
|
||||
|
||||
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}`
|
||||
// Check for other (non-Task Master) files
|
||||
const remainingFiles = allFilePaths.filter(
|
||||
(file) => !taskMasterFiles.includes(file)
|
||||
);
|
||||
} 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(
|
||||
projectRoot,
|
||||
profile.mcpConfigPath
|
||||
);
|
||||
if (result.mcpResult.hasOtherServers) {
|
||||
if (!result.notice) {
|
||||
result.notice = 'Preserved other MCP server configurations';
|
||||
} else {
|
||||
result.notice += '; preserved other MCP server configurations';
|
||||
hasOtherRulesFiles = remainingFiles.length > 0;
|
||||
|
||||
// Remove empty directories or note preserved files
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Call removal hook if defined (e.g., Roo's custom cleanup)
|
||||
if (typeof profile.onRemoveRulesProfile === 'function') {
|
||||
profile.onRemoveRulesProfile(projectRoot);
|
||||
}
|
||||
|
||||
// 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;
|
||||
// 3. Handle MCP configuration - only remove Task Master, preserve other servers
|
||||
if (profile.mcpConfig !== false) {
|
||||
try {
|
||||
result.mcpResult = removeTaskMasterMCPConfiguration(
|
||||
projectRoot,
|
||||
profile.mcpConfigPath
|
||||
);
|
||||
if (result.mcpResult.hasOtherServers) {
|
||||
if (!result.notice) {
|
||||
result.notice = 'Preserved other MCP server configurations';
|
||||
} else {
|
||||
result.notice += '; preserved other MCP server configurations';
|
||||
}
|
||||
}
|
||||
log(
|
||||
'debug',
|
||||
`[Rule Transformer] Removed profile directory: ${profileDir} (completely empty, all rules were Task Master rules, and MCP config was completely removed)`
|
||||
`[Rule Transformer] Processed MCP configuration for ${profile.profileName}`
|
||||
);
|
||||
} else {
|
||||
// Determine what was preserved and why
|
||||
const preservationReasons = [];
|
||||
if (hasOtherFilesOrFolders) {
|
||||
preservationReasons.push(
|
||||
`${remaining.length} existing files/folders`
|
||||
} catch (error) {
|
||||
log(
|
||||
'error',
|
||||
`[Rule Transformer] MCP cleanup failed for ${profile.profileName}: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check if we should remove the entire profile directory
|
||||
if (fs.existsSync(profileDir)) {
|
||||
const remainingContents = fs.readdirSync(profileDir);
|
||||
if (remainingContents.length === 0 && profile.profileDir !== '.') {
|
||||
// Only remove profile directory if it's empty and not root directory
|
||||
try {
|
||||
fs.rmSync(profileDir, { recursive: true, force: true });
|
||||
result.profileDirRemoved = true;
|
||||
log(
|
||||
'debug',
|
||||
`[Rule Transformer] Removed empty profile directory: ${profileDir}`
|
||||
);
|
||||
} catch (error) {
|
||||
log(
|
||||
'error',
|
||||
`[Rule Transformer] Failed to remove profile directory ${profileDir}: ${error.message}`
|
||||
);
|
||||
}
|
||||
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}`;
|
||||
|
||||
} else if (remainingContents.length > 0) {
|
||||
// Profile directory has remaining files/folders, add notice
|
||||
const preservedNotice = `Preserved ${remainingContents.length} existing files/folders in ${profile.profileDir}`;
|
||||
if (!result.notice) {
|
||||
result.notice = preservationMessage;
|
||||
} else if (!result.notice.includes('Preserved')) {
|
||||
result.notice += `; ${preservationMessage.toLowerCase()}`;
|
||||
result.notice = preservedNotice;
|
||||
} else {
|
||||
result.notice += `; ${preservedNotice.toLowerCase()}`;
|
||||
}
|
||||
|
||||
log('info', `[Rule Transformer] ${preservationMessage}`);
|
||||
log('info', `[Rule Transformer] ${preservedNotice}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user