973 lines
37 KiB
JavaScript
973 lines
37 KiB
JavaScript
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('..');
|
|
}
|
|
|
|
// Default body template for command pointer files. Used when a platform's
|
|
// installer config doesn't override `commands_body_template`. Matches
|
|
// OpenCode's native `@skills/<id>` skill-reference syntax.
|
|
const DEFAULT_COMMANDS_BODY_TEMPLATE = '@skills/{canonicalId}';
|
|
|
|
// Is this skill a persona agent (vs. a workflow/tool/standalone skill)?
|
|
// Used by platforms that surface only persona agents (e.g. Copilot's Custom
|
|
// Agents picker). Signal: the skill's source `customize.toml` has an
|
|
// `[agent]` section. This is the actual configuration source of truth —
|
|
// every BMAD persona is configured via [agent] in its customize.toml,
|
|
// every workflow uses [workflow], every standalone skill has no
|
|
// customize.toml at all. Verified against the full installed manifest:
|
|
// catches exactly the 20 description-confirmed personas across BMM, CIS,
|
|
// GDS, WDS, TEA, and correctly excludes meta-skills like
|
|
// `bmad-agent-builder` (a skill-builder workflow whose canonical id
|
|
// contains `-agent-` but which has no [agent] section because it isn't a
|
|
// persona itself).
|
|
//
|
|
// Reading the source toml — at install time the source skill directory
|
|
// (resolved from manifest record.path) still exists; cleanup runs later
|
|
// in the install flow.
|
|
async function isAgentSkill(record, bmadDir) {
|
|
if (!record?.path || !bmadDir) return false;
|
|
const bmadFolderName = path.basename(bmadDir);
|
|
const bmadPrefix = bmadFolderName + '/';
|
|
const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path;
|
|
const tomlPath = path.join(bmadDir, path.dirname(relativePath), 'customize.toml');
|
|
if (!(await fs.pathExists(tomlPath))) return false;
|
|
try {
|
|
const content = await fs.readFile(tomlPath, 'utf8');
|
|
return /^\[agent\]/m.test(content);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Resolve placeholders in a body template. Supported placeholders:
|
|
// {canonicalId} — the skill's canonical id
|
|
// {target_dir} — the platform's skill install directory (e.g. .agents/skills)
|
|
// {project-root} — left as a literal placeholder for the model/tool to expand
|
|
// at runtime; consistent with PR #1769's templates.
|
|
function expandBodyTemplate(template, { canonicalId, targetDir }) {
|
|
return template.replaceAll('{canonicalId}', canonicalId).replaceAll('{target_dir}', targetDir);
|
|
}
|
|
|
|
// The exact body the installer would generate for a given description and
|
|
// canonicalId, given the platform's body template. Centralised so both the
|
|
// write and the freshness-check paths agree on the canonical form.
|
|
function buildCommandPointerBody(description, canonicalId, { template, targetDir }) {
|
|
const bodyText = expandBodyTemplate(template, { canonicalId, targetDir });
|
|
return `---\ndescription: ${yamlSafeSingleLine(description)}\n---\n\n${bodyText}\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, { template, targetDir }) {
|
|
if (typeof content !== 'string') return false;
|
|
const trimmed = content.trim();
|
|
const expectedTail = expandBodyTemplate(template, { canonicalId, targetDir }).trim();
|
|
// Must end with the exact body our generator writes (post-expansion).
|
|
if (!trimmed.endsWith(expectedTail)) 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<boolean>}
|
|
*/
|
|
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<Object>} 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<Object>} 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/<name>.md`).
|
|
*
|
|
* Each pointer is a tiny markdown file whose body is `@skills/<canonicalId>`
|
|
* so invoking `/<canonicalId>` 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<Object>} { 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,
|
|
skippedFiltered: 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);
|
|
|
|
// Per-platform pointer-file shape, all overrideable in platform-codes.yaml.
|
|
const extension = config.commands_extension || '.md';
|
|
const template = config.commands_body_template || DEFAULT_COMMANDS_BODY_TEMPLATE;
|
|
const targetDir = config.target_dir;
|
|
const filter = config.commands_filter || null;
|
|
|
|
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;
|
|
}
|
|
|
|
// Optional per-platform filter: surfaces that should only show
|
|
// persona agents (e.g. Copilot's Custom Agents picker) skip
|
|
// workflow/tool skills here so the picker isn't cluttered with
|
|
// 90+ unrelated entries.
|
|
if (filter === 'agents-only' && !(await isAgentSkill(record, bmadDir))) {
|
|
result.skippedFiltered++;
|
|
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, { template, targetDir });
|
|
const commandFile = path.join(commandsPath, `${canonicalId}${extension}`);
|
|
|
|
// 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, { template, targetDir })) {
|
|
// 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}${extension}: ${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}${extension}: ${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<number>} 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 /<canonicalId> 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();
|
|
const extension = this.installerConfig.commands_extension || '.md';
|
|
await this.cleanupCommandPointers(
|
|
projectDir,
|
|
this.installerConfig.commands_target_dir,
|
|
options,
|
|
removalSet,
|
|
activeSkillIds,
|
|
extension,
|
|
);
|
|
}
|
|
|
|
// 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<string>} 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<string>} 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<string>} 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
|
|
* `<canonicalId><extension>` 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<string>} removalSet - canonicalIds whose pointer files to remove
|
|
* @param {Set<string>} [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).
|
|
* @param {string} [extension] - Pointer file extension (default '.md');
|
|
* matches the platform's commands_extension config value so cleanup
|
|
* correctly identifies pointer files for IDEs whose convention isn't .md
|
|
* (e.g. Copilot's `.agent.md`).
|
|
*/
|
|
async cleanupCommandPointers(
|
|
projectDir,
|
|
commandsTargetDir,
|
|
options = {},
|
|
removalSet = new Set(),
|
|
activeSkillIds = new Set(),
|
|
extension = '.md',
|
|
) {
|
|
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(extension)) continue;
|
|
const canonicalId = entry.slice(0, -extension.length);
|
|
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<Set<string>>}
|
|
*/
|
|
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<string>|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 <!-- BMAD:START --> and <!-- BMAD:END --> 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('<!-- BMAD:START -->');
|
|
const endIdx = content.indexOf('<!-- BMAD:END -->');
|
|
|
|
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) return;
|
|
|
|
const cleaned = content.slice(0, startIdx) + content.slice(endIdx + '<!-- BMAD:END -->'.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<string|null>} 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 };
|