const path = require('node:path'); const fs = require('../fs-native'); const yaml = require('yaml'); const prompts = require('../prompts'); const csv = require('csv-parse/sync'); const { BMAD_FOLDER_NAME } = require('./shared/path-utils'); const { getInstalledCanonicalIds, isBmadOwnedEntry } = require('./shared/installed-skills'); // Reserved OpenCode slash commands. A skill whose canonicalId collides with // one of these is skipped during command-pointer generation so it doesn't // shadow a built-in. const RESERVED_OPENCODE_COMMANDS = new Set([ 'review', 'commit', 'init', 'help', 'skills', 'fast', 'compact', 'clear', 'undo', 'redo', 'edit', 'editor', 'exit', 'quit', 'theme', 'config', 'model', 'session', ]); // Wrap a description for safe insertion into single-line YAML frontmatter. // Leaves plain values untouched; double-quotes (and escapes) anything that // could break YAML parsing or span multiple lines. function yamlSafeSingleLine(value) { const collapsed = String(value) .replaceAll(/[\r\n]+/g, ' ') .trim(); const needsQuoting = /[:#'"\\]/.test(collapsed) || /^[!&*?|>%@`[{]/.test(collapsed); if (!needsQuoting) return collapsed; const escaped = collapsed.replaceAll('\\', '\\\\').replaceAll('"', String.raw`\"`); return `"${escaped}"`; } // Validate that a canonicalId is a safe basename — no path separators, no // parent-dir traversal, no leading dots, only the character set we expect. // Defense-in-depth: the manifest is trusted today, but the value flows // directly into a file path and a malformed entry should not write outside // the commands directory. function isSafeCanonicalId(value) { return typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(value) && !value.includes('..'); } // The exact body the installer would generate for a given description and // canonicalId. Centralised so both the write and the freshness-check paths // agree on the canonical form. function buildCommandPointerBody(description, canonicalId) { return `---\ndescription: ${yamlSafeSingleLine(description)}\n---\n\n@skills/${canonicalId}\n`; } // Heuristic: does an existing pointer file look like our generator's output // (and therefore safe to refresh) versus a user-modified file (which we // preserve)? We check the body shape rather than full equality so that // description-only edits in the manifest can propagate without trampling // hand edits to the body. function looksLikeGeneratorOutput(content, canonicalId) { if (typeof content !== 'string') return false; const trimmed = content.trim(); // Must end with the exact reference line our generator writes. if (!trimmed.endsWith(`@skills/${canonicalId}`)) return false; // Must start with frontmatter containing exactly one description: line. const fmMatch = trimmed.match(/^---\n([\S\s]*?)\n---\n/); if (!fmMatch) return false; const fmLines = fmMatch[1].split('\n').filter((l) => l.length > 0); if (fmLines.length !== 1) return false; if (!fmLines[0].startsWith('description:')) return false; return true; } /** * Config-driven IDE setup handler * * This class provides a standardized way to install BMAD artifacts to IDEs * based on configuration in platform-codes.yaml. It eliminates the need for * individual installer files for each IDE. * * Features: * - Config-driven from platform-codes.yaml * - Verbatim skill installation from skill-manifest.csv * - IDE-specific marker removal (copilot-instructions, kilo modes, rovodev prompts) */ class ConfigDrivenIdeSetup { constructor(platformCode, platformConfig) { this.name = platformCode; this.displayName = platformConfig.name || platformCode; this.preferred = platformConfig.preferred || false; this.platformConfig = platformConfig; this.installerConfig = platformConfig.installer || null; this.bmadFolderName = BMAD_FOLDER_NAME; // Set configDir from target_dir so detect() works this.configDir = this.installerConfig?.target_dir || null; } setBmadFolderName(bmadFolderName) { this.bmadFolderName = bmadFolderName; } /** * Detect whether this IDE already has configuration in the project. * Checks for bmad-prefixed entries in target_dir. * @param {string} projectDir - Project directory * @returns {Promise} */ async detect(projectDir) { if (!this.configDir) return false; const root = projectDir || process.cwd(); const dir = path.join(root, this.configDir); if (!(await fs.pathExists(dir))) return false; let entries; try { entries = await fs.readdir(dir); } catch { return false; } const bmadDir = await this._findBmadDir(root); const canonicalIds = await getInstalledCanonicalIds(bmadDir); return entries.some((e) => isBmadOwnedEntry(e, canonicalIds)); } /** * Main setup method - called by IdeManager * @param {string} projectDir - Project directory * @param {string} bmadDir - BMAD installation directory * @param {Object} options - Setup options * @returns {Promise} Setup result */ async setup(projectDir, bmadDir, options = {}) { // Check for BMAD files in ancestor directories that would cause duplicates if (this.installerConfig?.ancestor_conflict_check) { const conflict = await this.findAncestorConflict(projectDir); if (conflict) { await prompts.log.error( `Found existing BMAD skills in ancestor installation: ${conflict}\n` + ` ${this.name} inherits skills from parent directories, so this would cause duplicates.\n` + ` Please remove the BMAD files from that directory first:\n` + ` rm -rf "${conflict}"/bmad*`, ); return { success: false, reason: 'ancestor-conflict', error: `Ancestor conflict: ${conflict}`, conflictDir: conflict, }; } } if (!options.silent) await prompts.log.info(`Setting up ${this.name}...`); // Clean up any old BMAD installation first await this.cleanup(projectDir, options, bmadDir); if (!this.installerConfig) { return { success: false, reason: 'no-config' }; } // When a peer platform in the same install batch owns this target_dir, // skip the skill write — the peer has already populated it. Command // pointers, however, write to a separate per-IDE directory and must // still be generated for this IDE; they are not deduped across peers. if (options.skipTarget) { const results = { skills: 0, sharedTargetHandledByPeer: true }; if (this.installerConfig.commands_target_dir) { results.commands = await this.installCommandPointers(projectDir, bmadDir, this.installerConfig, options); } return { success: true, results }; } if (this.installerConfig.target_dir) { return this.installToTarget(projectDir, bmadDir, this.installerConfig, options); } return { success: false, reason: 'invalid-config' }; } /** * Install to a single target directory * @param {string} projectDir - Project directory * @param {string} bmadDir - BMAD installation directory * @param {Object} config - Installation configuration * @param {Object} options - Setup options * @returns {Promise} Installation result */ async installToTarget(projectDir, bmadDir, config, options) { const { target_dir } = config; const targetPath = path.join(projectDir, target_dir); await fs.ensureDir(targetPath); this.skillWriteTracker = new Set(); const results = { skills: 0 }; results.skills = await this.installVerbatimSkills(projectDir, bmadDir, targetPath, config); results.skillDirectories = this.skillWriteTracker.size; if (config.commands_target_dir) { results.commands = await this.installCommandPointers(projectDir, bmadDir, config, options); } await this.printSummary(results, target_dir, options); this.skillWriteTracker = null; return { success: true, results }; } /** * Generate per-skill command pointer files for IDEs that surface commands * separately from skills (e.g. OpenCode's `.opencode/commands/.md`). * * Each pointer is a tiny markdown file whose body is `@skills/` * so invoking `/` routes the user straight to the skill instead * of forcing them through a `/skills` menu. * * Skips: * - Names that collide with reserved built-in slash commands. * - canonicalIds that aren't safe basename-only identifiers (defense * against path traversal even though the manifest is currently trusted). * - Existing files whose body looks user-modified (preserves hand edits); * pointer files matching the generator pattern get overwritten so that * description changes in skill-manifest.csv propagate on re-install. * * Per-file write failures are recorded and reported but do not abort the * rest of the install — pointer files are a non-essential adjunct to the * skill copy that already succeeded. * * @param {string} projectDir * @param {string} bmadDir * @param {Object} config - Installer config; reads commands_target_dir. * @param {Object} options - Setup options. forceCommands overwrites existing * files unconditionally (including hand-modified ones). * @returns {Promise} { created, updated, skippedExisting, skippedCollision, skippedInvalidId, writeFailures, fallbackDescription } */ async installCommandPointers(projectDir, bmadDir, config, options = {}) { const result = { created: 0, updated: 0, skippedExisting: 0, skippedCollision: 0, skippedInvalidId: 0, writeFailures: 0, fallbackDescription: 0, }; const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv'); if (!(await fs.pathExists(csvPath))) return result; const commandsPath = path.join(projectDir, config.commands_target_dir); await fs.ensureDir(commandsPath); const csvContent = await fs.readFile(csvPath, 'utf8'); const records = csv.parse(csvContent, { columns: true, skip_empty_lines: true }); for (const record of records) { const canonicalId = record.canonicalId; if (!canonicalId) continue; // Defensive basename validation. canonicalId comes from a trusted // manifest today, but the value flows directly into a file path — // reject anything that could escape commands_target_dir. if (!isSafeCanonicalId(canonicalId)) { result.skippedInvalidId++; continue; } // Reserved-name guard is OpenCode-specific. Other adapters that opt // into commands_target_dir later should declare their own reserved // set rather than inheriting OpenCode's. if (this.name === 'opencode' && RESERVED_OPENCODE_COMMANDS.has(canonicalId)) { result.skippedCollision++; continue; } let description = (record.description || '').trim(); if (!description) { description = `Run the ${canonicalId} skill`; result.fallbackDescription++; } const body = buildCommandPointerBody(description, canonicalId); const commandFile = path.join(commandsPath, `${canonicalId}.md`); // If a pointer file already exists, decide whether to overwrite based // on whether it looks like generator output (description-only diff) or // a user-modified file. forceCommands overrides this protection. if (!options.forceCommands && (await fs.pathExists(commandFile))) { let existing; try { existing = await fs.readFile(commandFile, 'utf8'); } catch { // Treat unreadable as user-owned and skip — safer than overwriting. result.skippedExisting++; continue; } if (existing === body) { // No-op idempotent re-run. result.skippedExisting++; continue; } if (looksLikeGeneratorOutput(existing, canonicalId)) { // Description (or other generated bit) has changed; refresh in place. try { await fs.writeFile(commandFile, body, 'utf8'); result.updated++; } catch (error) { result.writeFailures++; if (!options.silent) { await prompts.log.warn(`Failed to update command pointer ${canonicalId}.md: ${error.message}`); } } continue; } // Hand-modified pointer — preserve it. result.skippedExisting++; continue; } try { await fs.writeFile(commandFile, body, 'utf8'); result.created++; } catch (error) { result.writeFailures++; if (!options.silent) { await prompts.log.warn(`Failed to write command pointer ${canonicalId}.md: ${error.message}`); } } } return result; } /** * Install verbatim native SKILL.md directories from skill-manifest.csv. * Copies the entire source directory as-is into the IDE skill directory. * The source SKILL.md is used directly — no frontmatter transformation or file generation. * @param {string} projectDir - Project directory * @param {string} bmadDir - BMAD installation directory * @param {string} targetPath - Target skills directory * @param {Object} config - Installation configuration * @returns {Promise} Count of skills installed */ async installVerbatimSkills(projectDir, bmadDir, targetPath, config) { const bmadFolderName = path.basename(bmadDir); const bmadPrefix = bmadFolderName + '/'; const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv'); if (!(await fs.pathExists(csvPath))) return 0; const csvContent = await fs.readFile(csvPath, 'utf8'); const records = csv.parse(csvContent, { columns: true, skip_empty_lines: true, }); let count = 0; for (const record of records) { const canonicalId = record.canonicalId; if (!canonicalId) continue; // Derive source directory from path column // path is like "_bmad/bmm/workflows/bmad-quick-flow/bmad-quick-dev-new-preview/SKILL.md" // Strip bmadFolderName prefix and join with bmadDir, then get dirname const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path; const sourceFile = path.join(bmadDir, relativePath); const sourceDir = path.dirname(sourceFile); if (!(await fs.pathExists(sourceDir))) continue; // Clean target before copy to prevent stale files const skillDir = path.join(targetPath, canonicalId); await fs.remove(skillDir); await fs.ensureDir(skillDir); this.skillWriteTracker?.add(canonicalId); // Copy all skill files, filtering OS/editor artifacts recursively const skipPatterns = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']); const skipSuffixes = ['~', '.swp', '.swo', '.bak']; const filter = (src) => { const name = path.basename(src); if (src === sourceDir) return true; if (skipPatterns.has(name)) return false; if (name.startsWith('.') && name !== '.gitkeep') return false; if (skipSuffixes.some((s) => name.endsWith(s))) return false; return true; }; await fs.copy(sourceDir, skillDir, { filter }); count++; } return count; } /** * Print installation summary * @param {Object} results - Installation results * @param {string} targetDir - Target directory (relative) */ async printSummary(results, targetDir, options = {}) { if (options.silent) return; const count = results.skillDirectories || results.skills || 0; if (count > 0) { await prompts.log.success(`${this.name} configured: ${count} skills → ${targetDir}`); } const cmd = results.commands; if (cmd && (cmd.created > 0 || cmd.updated > 0) && this.installerConfig?.commands_target_dir) { const total = cmd.created + cmd.updated; const detail = cmd.updated > 0 ? `${cmd.created} new, ${cmd.updated} refreshed` : `${total}`; await prompts.log.success(`${this.name} commands: ${detail} → ${this.installerConfig.commands_target_dir}`); if (cmd.skippedCollision > 0) { await prompts.log.message(` (${cmd.skippedCollision} skipped — name collides with reserved slash command)`); } if (cmd.writeFailures > 0) { await prompts.log.warn(` (${cmd.writeFailures} pointer writes failed — see warnings above)`); } } } /** * Cleanup IDE configuration * @param {string} projectDir - Project directory */ async cleanup(projectDir, options = {}, bmadDir = null) { const resolvedBmadDir = bmadDir || (await this._findBmadDir(projectDir)); // Build removal set: previously installed skills + removals.txt entries let removalSet; if (options.previousSkillIds && options.previousSkillIds.size > 0) { // Install/update flow: use pre-captured skill IDs (before manifest was overwritten) removalSet = new Set(options.previousSkillIds); if (resolvedBmadDir) { const removals = await this.loadRemovalLists(resolvedBmadDir); for (const entry of removals) removalSet.add(entry); } } else if (resolvedBmadDir) { // Uninstall flow: read from current skill-manifest.csv + removals.txt removalSet = await this._buildUninstallSet(resolvedBmadDir); } else { removalSet = new Set(); } // Strip BMAD markers from copilot-instructions.md if present if (this.name === 'github-copilot') { await this.cleanupCopilotInstructions(projectDir, options); } // Strip BMAD modes from .kilocodemodes if present if (this.name === 'kilo') { await this.cleanupKiloModes(projectDir, options); } // Strip BMAD entries from .rovodev/prompts.yml if present if (this.name === 'rovo-dev') { await this.cleanupRovoDevPrompts(projectDir, options); } // Clean generated command pointer files in commands_target_dir. // Mirrors target_dir cleanup so uninstalls and skill removals don't // leave dangling / commands pointing at missing skills. // Runs regardless of skipTarget — command pointers live in a per-IDE // directory and are not deduped across peers, so a peer-owned shared // skills directory does not protect this IDE's command pointers from // cleanup. The "currently active" set is passed so install-flow cleanup // (where removalSet contains skills that will be re-added moments later) // doesn't trample hand-edited pointers; install-flow cleanup will only // delete pointers for skills that are not in the new manifest. if (this.installerConfig?.commands_target_dir) { // In the install/update flow (signal: previousSkillIds was passed), // spare pointers whose canonicalId is still in the manifest so hand // edits survive a routine reinstall. In the uninstall flow (no // previousSkillIds — full uninstall or per-IDE removal via // cleanupByList), don't spare anything; the IDE itself is going away, // so its pointers should go with it. const isInstallFlow = options.previousSkillIds && options.previousSkillIds.size > 0; const activeSkillIds = isInstallFlow ? await this._readActiveSkillIds(resolvedBmadDir) : new Set(); await this.cleanupCommandPointers(projectDir, this.installerConfig.commands_target_dir, options, removalSet, activeSkillIds); } // Skip target_dir cleanup when a peer platform owns this directory // (set during dedup'd install or when uninstalling one of several // platforms that share the same target_dir). if (options.skipTarget) return; // Clean current target directory if (this.installerConfig?.target_dir) { await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options, removalSet); } } /** * Find the _bmad directory in a project * @param {string} projectDir - Project directory * @returns {string|null} Path to bmad dir or null */ async _findBmadDir(projectDir) { const bmadDir = path.join(projectDir, BMAD_FOLDER_NAME); return (await fs.pathExists(bmadDir)) ? bmadDir : null; } /** * Build the full set of entries to remove for uninstall. * Reads skill-manifest.csv to know exactly what was installed, plus removal lists. * @param {string} bmadDir - BMAD installation directory * @returns {Set} Set of entries to remove */ async _buildUninstallSet(bmadDir) { const removals = await this.loadRemovalLists(bmadDir); // Also add all currently installed skills from skill-manifest.csv const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv'); try { if (await fs.pathExists(csvPath)) { const content = await fs.readFile(csvPath, 'utf8'); const records = csv.parse(content, { columns: true, skip_empty_lines: true }); for (const record of records) { if (record.canonicalId) { removals.add(record.canonicalId); } } } } catch { // If we can't read the manifest, we still have the removal lists } return removals; } /** * Load removal lists from all module sources in the bmad directory. * Each module can have an optional removals.txt listing entries to remove. * @param {string} bmadDir - BMAD installation directory * @returns {Set} Set of entries to remove */ async loadRemovalLists(bmadDir) { const removals = new Set(); const { getProjectRoot } = require('../project-root'); // Read project-level removals.txt (covers core and bmm) const projectRemovalsPath = path.join(getProjectRoot(), 'removals.txt'); await this._readRemovalFile(projectRemovalsPath, removals); // Read per-module removals.txt from installed module directories try { const entries = await fs.readdir(bmadDir); for (const entry of entries) { if (entry.startsWith('_')) continue; const removalPath = path.join(bmadDir, entry, 'removals.txt'); await this._readRemovalFile(removalPath, removals); } } catch { // bmadDir may not exist yet on fresh install } return removals; } /** * Read a removals.txt file and add entries to the set * @param {string} filePath - Path to removals.txt * @param {Set} removals - Set to add entries to */ async _readRemovalFile(filePath, removals) { try { if (await fs.pathExists(filePath)) { const content = await fs.readFile(filePath, 'utf8'); for (const line of content.split('\n')) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { removals.add(trimmed); } } } } catch { // Optional file — ignore errors } } /** * Cleanup generated command pointer files for entries in removalSet. * Symmetric counterpart to installCommandPointers — removes .md * files whose canonicalId is in the set. Removes the commands directory * entirely if it ends up empty. * @param {string} projectDir * @param {string} commandsTargetDir - Relative dir (e.g. .opencode/commands) * @param {Object} options * @param {Set} removalSet - canonicalIds whose pointer files to remove * @param {Set} [activeSkillIds] - canonicalIds present in the * current manifest. Pointers for IDs in this set are spared so an * install-flow cleanup (where removalSet === previousSkillIds and the * same skills are about to be re-installed) doesn't wipe hand-edited * pointer files. Pass an empty set or omit to delete every match in * removalSet (uninstall flow). */ async cleanupCommandPointers(projectDir, commandsTargetDir, options = {}, removalSet = new Set(), activeSkillIds = new Set()) { if (!removalSet || removalSet.size === 0) return; const commandsPath = path.join(projectDir, commandsTargetDir); if (!(await fs.pathExists(commandsPath))) return; let entries; try { entries = await fs.readdir(commandsPath); } catch { return; } for (const entry of entries) { if (!entry.endsWith('.md')) continue; const canonicalId = entry.slice(0, -3); if (!removalSet.has(canonicalId)) continue; // Spare pointers for skills that are still in the manifest; the // install pass will refresh them in place if their content has gone // stale, while preserving hand edits. if (activeSkillIds.has(canonicalId)) continue; try { await fs.remove(path.join(commandsPath, entry)); } catch { // Skip files we can't remove. } } // Remove the commands directory if we emptied it. try { const remaining = await fs.readdir(commandsPath); if (remaining.length === 0) { await fs.remove(commandsPath); } } catch { // Directory may already be gone. } } /** * Read the canonicalIds currently present in the skill-manifest.csv. * Used by cleanup to distinguish "re-install of an existing skill" * (preserve pointer) from "skill truly being removed" (delete pointer). * @param {string|null} bmadDir * @returns {Promise>} */ async _readActiveSkillIds(bmadDir) { const ids = new Set(); if (!bmadDir) return ids; const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv'); if (!(await fs.pathExists(csvPath))) return ids; try { const content = await fs.readFile(csvPath, 'utf8'); const records = csv.parse(content, { columns: true, skip_empty_lines: true }); for (const record of records) { if (record.canonicalId) ids.add(record.canonicalId); } } catch { // Manifest unreadable — return an empty set so cleanup falls back to // the conservative "delete what removalSet says" behavior. } return ids; } /** * Cleanup a specific target directory. * When removalSet is provided, only removes entries in that set. * When removalSet is null (legacy dirs), removes all bmad-prefixed entries. * @param {string} projectDir - Project directory * @param {string} targetDir - Target directory to clean * @param {Object} options - Cleanup options * @param {Set|null} removalSet - Entries to remove, or null for legacy prefix matching */ async cleanupTarget(projectDir, targetDir, options = {}, removalSet = new Set()) { const targetPath = path.join(projectDir, targetDir); if (!(await fs.pathExists(targetPath))) { return; } if (removalSet && removalSet.size === 0) { return; } let entries; try { entries = await fs.readdir(targetPath); } catch { return; } if (!entries || !Array.isArray(entries)) { return; } let removedCount = 0; for (const entry of entries) { if (!entry || typeof entry !== 'string') continue; // Always preserve bmad-os-* utility skills regardless of cleanup mode if (entry.startsWith('bmad-os-')) continue; // Surgical removal from set, or fallback to manifest+prefix detection when null const shouldRemove = removalSet ? removalSet.has(entry) : isBmadOwnedEntry(entry, null); if (shouldRemove) { try { await fs.remove(path.join(targetPath, entry)); removedCount++; } catch { // Skip entries that can't be removed } } } // Only log cleanup when it's not a routine reinstall (legacy dir cleanup or actual removals) // Suppress for current target_dir since it's always cleaned before a fresh write // Remove empty directory after cleanup if (removedCount > 0) { try { const remaining = await fs.readdir(targetPath); if (remaining.length === 0) { await fs.remove(targetPath); } } catch { // Directory may already be gone or in use } } } /** * Strip BMAD-owned content from .github/copilot-instructions.md. * The old custom installer injected content between and markers. * Deletes the file if nothing remains. Restores .bak backup if one exists. */ async cleanupCopilotInstructions(projectDir, options = {}) { const filePath = path.join(projectDir, '.github', 'copilot-instructions.md'); if (!(await fs.pathExists(filePath))) return; try { const content = await fs.readFile(filePath, 'utf8'); const startIdx = content.indexOf(''); const endIdx = content.indexOf(''); if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) return; const cleaned = content.slice(0, startIdx) + content.slice(endIdx + ''.length); if (cleaned.trim().length === 0) { await fs.remove(filePath); const backupPath = `${filePath}.bak`; if (await fs.pathExists(backupPath)) { await fs.rename(backupPath, filePath); if (!options.silent) await prompts.log.message(' Restored copilot-instructions.md from backup'); } } else { await fs.writeFile(filePath, cleaned, 'utf8'); const backupPath = `${filePath}.bak`; if (await fs.pathExists(backupPath)) await fs.remove(backupPath); } if (!options.silent) await prompts.log.message(' Cleaned BMAD markers from copilot-instructions.md'); } catch { if (!options.silent) await prompts.log.warn(' Warning: Could not clean BMAD markers from copilot-instructions.md'); } } /** * Strip BMAD-owned modes from .kilocodemodes. * The old custom kilo.js installer added modes with slug starting with 'bmad-'. * Parses YAML, filters out BMAD modes, rewrites. Leaves file as-is on parse failure. */ async cleanupKiloModes(projectDir, options = {}) { const kiloModesPath = path.join(projectDir, '.kilocodemodes'); if (!(await fs.pathExists(kiloModesPath))) return; const content = await fs.readFile(kiloModesPath, 'utf8'); let config; try { config = yaml.parse(content) || {}; } catch { if (!options.silent) await prompts.log.warn(' Warning: Could not parse .kilocodemodes for cleanup'); return; } if (!Array.isArray(config.customModes)) return; const originalCount = config.customModes.length; config.customModes = config.customModes.filter((mode) => mode && (!mode.slug || !mode.slug.startsWith('bmad-'))); const removedCount = originalCount - config.customModes.length; if (removedCount > 0) { try { await fs.writeFile(kiloModesPath, yaml.stringify(config, { lineWidth: 0 })); if (!options.silent) await prompts.log.message(` Removed ${removedCount} BMAD modes from .kilocodemodes`); } catch { if (!options.silent) await prompts.log.warn(' Warning: Could not write .kilocodemodes during cleanup'); } } } /** * Strip BMAD-owned entries from .rovodev/prompts.yml. * The old custom rovodev.js installer registered workflows in prompts.yml. * Parses YAML, filters out entries with name starting with 'bmad-', rewrites. * Removes the file if no entries remain. */ async cleanupRovoDevPrompts(projectDir, options = {}) { const promptsPath = path.join(projectDir, '.rovodev', 'prompts.yml'); if (!(await fs.pathExists(promptsPath))) return; const content = await fs.readFile(promptsPath, 'utf8'); let config; try { config = yaml.parse(content) || {}; } catch { if (!options.silent) await prompts.log.warn(' Warning: Could not parse prompts.yml for cleanup'); return; } if (!Array.isArray(config.prompts)) return; const originalCount = config.prompts.length; config.prompts = config.prompts.filter((entry) => entry && (!entry.name || !entry.name.startsWith('bmad-'))); const removedCount = originalCount - config.prompts.length; if (removedCount > 0) { try { if (config.prompts.length === 0) { await fs.remove(promptsPath); } else { await fs.writeFile(promptsPath, yaml.stringify(config, { lineWidth: 0 })); } if (!options.silent) await prompts.log.message(` Removed ${removedCount} BMAD entries from prompts.yml`); } catch { if (!options.silent) await prompts.log.warn(' Warning: Could not write prompts.yml during cleanup'); } } } /** * Check ancestor directories for existing BMAD files in the same target_dir. * IDEs like Claude Code inherit commands from parent directories, so an existing * installation in an ancestor would cause duplicate commands. * @param {string} projectDir - Project directory being installed to * @returns {Promise} Path to conflicting directory, or null if clean */ async findAncestorConflict(projectDir) { const targetDir = this.installerConfig?.target_dir; if (!targetDir) return null; const resolvedProject = await fs.realpath(path.resolve(projectDir)); let current = path.dirname(resolvedProject); const root = path.parse(current).root; while (current !== root && current.length > root.length) { const candidatePath = path.join(current, targetDir); try { if (await fs.pathExists(candidatePath)) { const entries = await fs.readdir(candidatePath); const ancestorBmadDir = await this._findBmadDir(current); const canonicalIds = await getInstalledCanonicalIds(ancestorBmadDir); if (entries.some((e) => isBmadOwnedEntry(e, canonicalIds))) { return candidatePath; } } } catch { // Can't read directory — skip } current = path.dirname(current); } return null; } } module.exports = { ConfigDrivenIdeSetup };