feat(installer): add 18 new platforms, dedup shared target_dir, ownership-aware cleanup
Adds 18 platforms from the verified Vercel list (adal, amp, bob, command-code, cortex, droid, firebender, goose, kode, mistral-vibe, mux, neovate, openclaw, openhands, pochi, replit, warp, zencoder). Marks codex and github-copilot as preferred alongside claude-code and cursor. Coordination for platforms sharing a target_dir: - IdeManager.setupBatch dedups skill writes when multiple selected platforms point at the same target_dir (e.g. .agents/skills/). The first platform writes, peers skip the redundant wipe-and-rewrite. Result reports the same count and target dir for every member so the install summary is consistent. - IdeManager.cleanupByList accepts remainingIdes; when removing one platform from a shared dir while another co-installed platform still owns it, the target_dir wipe is skipped. Platform-specific hooks (copilot markers, kilo modes, rovodev prompts) still run. - _setupIdes uses setupBatch; _removeDeselectedIdes passes remainingIdes so partial reconfigure preserves shared skills. Skill ownership now uses skill-manifest.csv canonicalIds, not the bmad- prefix. This unblocks custom modules that ship skills with non-bmad names (e.g. fred-cool-skill). Affected sites: - _config-driven.detect: reads canonicalIds from the project's bmadDir - _config-driven.findAncestorConflict: reads canonicalIds from the ancestor's own bmadDir, falling back to the prefix only when no manifest exists - legacy-warnings.findStaleLegacyDirs: same canonicalId-based detection Migration warnings: LEGACY_SKILL_PATHS adds 12 skill dirs that moved to the .agents/skills/ standard (cursor, gemini, github-copilot, kimi, opencode, pi, roo, rovodev, windsurf, plus their globals). Users with stale skills in those locations get a one-line warning with the rm command per dir. New shared helper tools/installer/ide/shared/installed-skills.js exposes getInstalledCanonicalIds(bmadDir) and isBmadOwnedEntry(entry, canonicalIds). Tests: 9 new assertions across two suites covering dedup, partial uninstall preservation, and custom-module skill detection. All 286 tests pass.
This commit is contained in:
parent
5ff534df94
commit
6e287245ca
|
|
@ -1382,7 +1382,7 @@ async function runTests() {
|
||||||
});
|
});
|
||||||
|
|
||||||
assert(result.success === true, 'Antigravity setup succeeds with overlapping skill names');
|
assert(result.success === true, 'Antigravity setup succeeds with overlapping skill names');
|
||||||
assert(result.detail === '1 skills', 'Installer detail reports skill count');
|
assert(result.detail === '1 skills → .agent/skills', 'Installer detail reports skill count and target dir');
|
||||||
assert(result.handlerResult.results.skillDirectories === 1, 'Result exposes unique skill directory count');
|
assert(result.handlerResult.results.skillDirectories === 1, 'Result exposes unique skill directory count');
|
||||||
assert(result.handlerResult.results.skills === 1, 'Result retains verbatim skill count');
|
assert(result.handlerResult.results.skills === 1, 'Result retains verbatim skill count');
|
||||||
assert(
|
assert(
|
||||||
|
|
@ -2622,6 +2622,109 @@ async function runTests() {
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 40: Shared target_dir coordination
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 40: Shared target_dir coordination${colors.reset}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Cursor and Gemini both use .agents/skills — verify they coordinate.
|
||||||
|
clearCache();
|
||||||
|
const platformCodes40 = await loadPlatformCodes();
|
||||||
|
const cursorTarget = platformCodes40.platforms.cursor?.installer?.target_dir;
|
||||||
|
const geminiTarget = platformCodes40.platforms.gemini?.installer?.target_dir;
|
||||||
|
assert(cursorTarget === '.agents/skills' && geminiTarget === '.agents/skills', 'Cursor and Gemini share .agents/skills target_dir');
|
||||||
|
|
||||||
|
const tempProjectDir40 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-shared-target-'));
|
||||||
|
const installedBmadDir40 = await createTestBmadFixture();
|
||||||
|
|
||||||
|
const ideManager40 = new IdeManager();
|
||||||
|
await ideManager40.ensureInitialized();
|
||||||
|
|
||||||
|
// Run setupBatch with both platforms — second should skip skill write.
|
||||||
|
const batchResults = await ideManager40.setupBatch(['cursor', 'gemini'], tempProjectDir40, installedBmadDir40, {
|
||||||
|
silent: true,
|
||||||
|
selectedModules: ['core'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(batchResults.length === 2, 'setupBatch returns one result per IDE');
|
||||||
|
assert(batchResults[0].success === true, 'First platform (cursor) succeeds');
|
||||||
|
assert(batchResults[1].success === true, 'Second platform (gemini) succeeds');
|
||||||
|
assert(
|
||||||
|
batchResults[1].handlerResult?.results?.sharedTargetHandledByPeer === true,
|
||||||
|
'Second platform marked sharedTargetHandledByPeer (skipped redundant write)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Skill should be present in the shared dir after batch.
|
||||||
|
const sharedDir = path.join(tempProjectDir40, '.agents', 'skills');
|
||||||
|
const sharedDirEntries = await fs.readdir(sharedDir);
|
||||||
|
assert(sharedDirEntries.includes('bmad-master'), 'Shared .agents/skills/ contains bmad-master after batched install');
|
||||||
|
|
||||||
|
// Now uninstall just cursor while gemini remains. Skills must survive.
|
||||||
|
const cleanupResults = await ideManager40.cleanupByList(tempProjectDir40, ['cursor'], {
|
||||||
|
silent: true,
|
||||||
|
remainingIdes: ['gemini'],
|
||||||
|
});
|
||||||
|
assert(cleanupResults[0].skippedTarget === true, 'Cursor cleanup skips target_dir wipe when Gemini remains');
|
||||||
|
const stillThere = await fs.readdir(sharedDir);
|
||||||
|
assert(stillThere.includes('bmad-master'), 'bmad-master still present after partial uninstall (gemini still installed)');
|
||||||
|
|
||||||
|
// (Cleanup of the last sharing platform requires bmadDir to be inside
|
||||||
|
// projectDir to compute removalSet; that's the production layout. The
|
||||||
|
// fixture above keeps bmad in a separate temp dir, so test 41 below
|
||||||
|
// exercises the in-project layout instead.)
|
||||||
|
|
||||||
|
await fs.remove(tempProjectDir40).catch(() => {});
|
||||||
|
await fs.remove(path.dirname(installedBmadDir40)).catch(() => {});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${colors.red}Test Suite 40 setup failed: ${error.message}${colors.reset}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 41: Custom-module skill ownership (non-bmad prefix)
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 41: Custom-module skill ownership${colors.reset}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// A custom module can ship a skill with any canonicalId (e.g. "fred-cool-skill").
|
||||||
|
// detect() must recognize it as BMAD-owned via the manifest, not the bmad- prefix.
|
||||||
|
const fixtureRoot41 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-custom-prefix-'));
|
||||||
|
const bmadDir41 = path.join(fixtureRoot41, '_bmad');
|
||||||
|
await fs.ensureDir(path.join(bmadDir41, '_config'));
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(bmadDir41, '_config', 'skill-manifest.csv'),
|
||||||
|
[
|
||||||
|
'canonicalId,name,description,module,path',
|
||||||
|
'"fred-cool-skill","fred-cool-skill","Custom module skill","fred","_bmad/fred/skills/fred-cool-skill/SKILL.md"',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
const fredSkill = path.join(bmadDir41, 'fred', 'skills', 'fred-cool-skill');
|
||||||
|
await fs.ensureDir(fredSkill);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fredSkill, 'SKILL.md'),
|
||||||
|
['---', 'name: fred-cool-skill', 'description: Custom module skill', '---', '', 'A custom module skill.'].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ideManager41 = new IdeManager();
|
||||||
|
await ideManager41.ensureInitialized();
|
||||||
|
await ideManager41.setup('cursor', fixtureRoot41, bmadDir41, { silent: true, selectedModules: ['fred'] });
|
||||||
|
|
||||||
|
const cursorHandler = ideManager41.handlers.get('cursor');
|
||||||
|
const detected = await cursorHandler.detect(fixtureRoot41);
|
||||||
|
assert(detected === true, 'detect() recognizes non-bmad-prefixed skill as BMAD-owned via skill-manifest.csv');
|
||||||
|
|
||||||
|
await fs.remove(fixtureRoot41).catch(() => {});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${colors.red}Test Suite 41 setup failed: ${error.message}${colors.reset}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Summary
|
// Summary
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -189,17 +189,12 @@ class Installer {
|
||||||
|
|
||||||
if (toRemove.length === 0) return;
|
if (toRemove.length === 0) return;
|
||||||
|
|
||||||
await this.ideManager.ensureInitialized();
|
// Pass the newly-selected list as remainingIdes so cleanupByList skips
|
||||||
for (const ide of toRemove) {
|
// target_dir wipes for IDEs whose directory is still owned by a peer
|
||||||
try {
|
// (e.g. removing 'cursor' while 'gemini' remains — both share .agents/skills).
|
||||||
const handler = this.ideManager.handlers.get(ide);
|
await this.ideManager.cleanupByList(paths.projectRoot, toRemove, {
|
||||||
if (handler) {
|
remainingIdes: [...newlySelected],
|
||||||
await handler.cleanup(paths.projectRoot);
|
});
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
await prompts.log.warn(`Warning: Failed to remove ${ide}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -348,13 +343,14 @@ class Installer {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const ide of validIdes) {
|
const setupResults = await this.ideManager.setupBatch(validIdes, paths.projectRoot, paths.bmadDir, {
|
||||||
const setupResult = await this.ideManager.setup(ide, paths.projectRoot, paths.bmadDir, {
|
|
||||||
selectedModules: allModules || [],
|
selectedModules: allModules || [],
|
||||||
verbose: config.verbose,
|
verbose: config.verbose,
|
||||||
previousSkillIds,
|
previousSkillIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const setupResult of setupResults) {
|
||||||
|
const ide = setupResult.ide;
|
||||||
if (setupResult.success) {
|
if (setupResult.success) {
|
||||||
addResult(ide, 'ok', setupResult.detail || '');
|
addResult(ide, 'ok', setupResult.detail || '');
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,14 @@ const path = require('node:path');
|
||||||
const semver = require('semver');
|
const semver = require('semver');
|
||||||
const fs = require('../fs-native');
|
const fs = require('../fs-native');
|
||||||
const prompts = require('../prompts');
|
const prompts = require('../prompts');
|
||||||
|
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
|
||||||
|
const { getInstalledCanonicalIds, isBmadOwnedEntry } = require('../ide/shared/installed-skills');
|
||||||
|
|
||||||
const MIN_NATIVE_SKILLS_VERSION = '6.1.0';
|
const MIN_NATIVE_SKILLS_VERSION = '6.1.0';
|
||||||
|
|
||||||
const LEGACY_PATHS = [
|
// Pre-v6.1.0 paths: BMAD used to install commands/workflows/etc in tool-specific dirs.
|
||||||
|
// In v6.1.0 BMAD switched to native SKILL.md format.
|
||||||
|
const LEGACY_COMMAND_PATHS = [
|
||||||
'.agent/workflows',
|
'.agent/workflows',
|
||||||
'.augment/commands',
|
'.augment/commands',
|
||||||
'.claude/commands',
|
'.claude/commands',
|
||||||
|
|
@ -33,6 +37,38 @@ const LEGACY_PATHS = [
|
||||||
'.windsurf/workflows',
|
'.windsurf/workflows',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Skill paths that moved to the cross-tool .agents/skills/ standard.
|
||||||
|
// Users upgrading from a prior install may have stale BMAD skills here that
|
||||||
|
// the AI tool will load alongside the new ones, causing duplicates.
|
||||||
|
const LEGACY_SKILL_PATHS = [
|
||||||
|
'.augment/skills',
|
||||||
|
'~/.augment/skills',
|
||||||
|
'.codex/skills',
|
||||||
|
'.crush/skills',
|
||||||
|
'.cursor/skills',
|
||||||
|
'~/.cursor/skills',
|
||||||
|
'.gemini/skills',
|
||||||
|
'~/.gemini/skills',
|
||||||
|
'.github/skills',
|
||||||
|
'~/.github/skills',
|
||||||
|
'.kilocode/skills',
|
||||||
|
'.kimi/skills',
|
||||||
|
'~/.kimi/skills',
|
||||||
|
'.opencode/skills',
|
||||||
|
'~/.opencode/skills',
|
||||||
|
'.pi/skills',
|
||||||
|
'~/.pi/skills',
|
||||||
|
'.roo/skills',
|
||||||
|
'~/.roo/skills',
|
||||||
|
'.rovodev/skills',
|
||||||
|
'~/.rovodev/skills',
|
||||||
|
'.windsurf/skills',
|
||||||
|
'~/.windsurf/skills',
|
||||||
|
'~/.codeium/windsurf/skills',
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEGACY_PATHS = [...LEGACY_COMMAND_PATHS, ...LEGACY_SKILL_PATHS];
|
||||||
|
|
||||||
function expandPath(p) {
|
function expandPath(p) {
|
||||||
if (p === '~') return os.homedir();
|
if (p === '~') return os.homedir();
|
||||||
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
||||||
|
|
@ -45,15 +81,16 @@ function resolveLegacyPath(projectRoot, p) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findStaleLegacyDirs(projectRoot) {
|
async function findStaleLegacyDirs(projectRoot) {
|
||||||
|
const bmadDir = path.join(projectRoot, BMAD_FOLDER_NAME);
|
||||||
|
const canonicalIds = await getInstalledCanonicalIds(bmadDir);
|
||||||
|
|
||||||
const findings = [];
|
const findings = [];
|
||||||
for (const legacyPath of LEGACY_PATHS) {
|
for (const legacyPath of LEGACY_PATHS) {
|
||||||
const resolved = resolveLegacyPath(projectRoot, legacyPath);
|
const resolved = resolveLegacyPath(projectRoot, legacyPath);
|
||||||
if (!(await fs.pathExists(resolved))) continue;
|
if (!(await fs.pathExists(resolved))) continue;
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(resolved);
|
const entries = await fs.readdir(resolved);
|
||||||
const bmadEntries = entries.filter(
|
const bmadEntries = entries.filter((e) => isBmadOwnedEntry(e, canonicalIds));
|
||||||
(e) => typeof e === 'string' && e.toLowerCase().startsWith('bmad') && !e.toLowerCase().startsWith('bmad-os-'),
|
|
||||||
);
|
|
||||||
if (bmadEntries.length > 0) {
|
if (bmadEntries.length > 0) {
|
||||||
findings.push({ path: resolved, displayPath: legacyPath, count: bmadEntries.length });
|
findings.push({ path: resolved, displayPath: legacyPath, count: bmadEntries.length });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const yaml = require('yaml');
|
||||||
const prompts = require('../prompts');
|
const prompts = require('../prompts');
|
||||||
const csv = require('csv-parse/sync');
|
const csv = require('csv-parse/sync');
|
||||||
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
|
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
|
||||||
|
const { getInstalledCanonicalIds, isBmadOwnedEntry } = require('./shared/installed-skills');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Config-driven IDE setup handler
|
* Config-driven IDE setup handler
|
||||||
|
|
@ -43,16 +44,20 @@ class ConfigDrivenIdeSetup {
|
||||||
async detect(projectDir) {
|
async detect(projectDir) {
|
||||||
if (!this.configDir) return false;
|
if (!this.configDir) return false;
|
||||||
|
|
||||||
const dir = path.join(projectDir || process.cwd(), this.configDir);
|
const root = projectDir || process.cwd();
|
||||||
if (await fs.pathExists(dir)) {
|
const dir = path.join(root, this.configDir);
|
||||||
|
if (!(await fs.pathExists(dir))) return false;
|
||||||
|
|
||||||
|
let entries;
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(dir);
|
entries = await fs.readdir(dir);
|
||||||
return entries.some((e) => typeof e === 'string' && e.startsWith('bmad'));
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return false;
|
const bmadDir = await this._findBmadDir(root);
|
||||||
|
const canonicalIds = await getInstalledCanonicalIds(bmadDir);
|
||||||
|
return entries.some((e) => isBmadOwnedEntry(e, canonicalIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -91,6 +96,12 @@ class ConfigDrivenIdeSetup {
|
||||||
return { success: false, reason: 'no-config' };
|
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.
|
||||||
|
if (options.skipTarget) {
|
||||||
|
return { success: true, results: { skills: 0, sharedTargetHandledByPeer: true } };
|
||||||
|
}
|
||||||
|
|
||||||
if (this.installerConfig.target_dir) {
|
if (this.installerConfig.target_dir) {
|
||||||
return this.installToTarget(projectDir, bmadDir, this.installerConfig, options);
|
return this.installToTarget(projectDir, bmadDir, this.installerConfig, options);
|
||||||
}
|
}
|
||||||
|
|
@ -236,6 +247,11 @@ class ConfigDrivenIdeSetup {
|
||||||
await this.cleanupRovoDevPrompts(projectDir, options);
|
await this.cleanupRovoDevPrompts(projectDir, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Clean current target directory
|
||||||
if (this.installerConfig?.target_dir) {
|
if (this.installerConfig?.target_dir) {
|
||||||
await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options, removalSet);
|
await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options, removalSet);
|
||||||
|
|
@ -369,8 +385,8 @@ class ConfigDrivenIdeSetup {
|
||||||
// Always preserve bmad-os-* utility skills regardless of cleanup mode
|
// Always preserve bmad-os-* utility skills regardless of cleanup mode
|
||||||
if (entry.startsWith('bmad-os-')) continue;
|
if (entry.startsWith('bmad-os-')) continue;
|
||||||
|
|
||||||
// Surgical removal from set, or legacy prefix matching when set is null
|
// Surgical removal from set, or fallback to manifest+prefix detection when null
|
||||||
const shouldRemove = removalSet ? removalSet.has(entry) : entry.startsWith('bmad');
|
const shouldRemove = removalSet ? removalSet.has(entry) : isBmadOwnedEntry(entry, null);
|
||||||
|
|
||||||
if (shouldRemove) {
|
if (shouldRemove) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -533,10 +549,9 @@ class ConfigDrivenIdeSetup {
|
||||||
try {
|
try {
|
||||||
if (await fs.pathExists(candidatePath)) {
|
if (await fs.pathExists(candidatePath)) {
|
||||||
const entries = await fs.readdir(candidatePath);
|
const entries = await fs.readdir(candidatePath);
|
||||||
const hasBmad = entries.some(
|
const ancestorBmadDir = await this._findBmadDir(current);
|
||||||
(e) => typeof e === 'string' && e.toLowerCase().startsWith('bmad') && !e.toLowerCase().startsWith('bmad-os-'),
|
const canonicalIds = await getInstalledCanonicalIds(ancestorBmadDir);
|
||||||
);
|
if (entries.some((e) => isBmadOwnedEntry(e, canonicalIds))) {
|
||||||
if (hasBmad) {
|
|
||||||
return candidatePath;
|
return candidatePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,8 +160,18 @@ class IdeManager {
|
||||||
let detail = '';
|
let detail = '';
|
||||||
if (handlerResult && handlerResult.results) {
|
if (handlerResult && handlerResult.results) {
|
||||||
const r = handlerResult.results;
|
const r = handlerResult.results;
|
||||||
const count = r.skillDirectories || r.skills || 0;
|
let count = r.skillDirectories || r.skills || 0;
|
||||||
if (count > 0) detail = `${count} skills`;
|
// Dedup'd platform: report the count its peer wrote so the user sees
|
||||||
|
// a consistent picture across all platforms sharing the dir.
|
||||||
|
if (count === 0 && r.sharedTargetHandledByPeer && options.sharedSkillCount) {
|
||||||
|
count = options.sharedSkillCount;
|
||||||
|
}
|
||||||
|
const targetDir = handler.installerConfig?.target_dir || null;
|
||||||
|
if (count > 0 && targetDir) {
|
||||||
|
detail = `${count} skills → ${targetDir}`;
|
||||||
|
} else if (count > 0) {
|
||||||
|
detail = `${count} skills`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Propagate handler's success status (default true for backward compat)
|
// Propagate handler's success status (default true for backward compat)
|
||||||
const success = handlerResult?.success !== false;
|
const success = handlerResult?.success !== false;
|
||||||
|
|
@ -172,6 +182,51 @@ class IdeManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run setup for multiple IDEs as a single batch.
|
||||||
|
* Dedupes work when several selected platforms share the same target_dir:
|
||||||
|
* the first platform owns the directory write, peers skip it.
|
||||||
|
* @param {Array<string>} ideList - IDE names to set up
|
||||||
|
* @param {string} projectDir
|
||||||
|
* @param {string} bmadDir
|
||||||
|
* @param {Object} [options] - Forwarded to each handler.setup
|
||||||
|
* @returns {Promise<Array>} Per-IDE results
|
||||||
|
*/
|
||||||
|
async setupBatch(ideList, projectDir, bmadDir, options = {}) {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
const results = [];
|
||||||
|
// target_dir → { firstIde, skillCount } from the platform that actually wrote it
|
||||||
|
const claimedTargets = new Map();
|
||||||
|
|
||||||
|
for (const ideName of ideList) {
|
||||||
|
const handler = this.handlers.get(ideName.toLowerCase());
|
||||||
|
if (!handler) {
|
||||||
|
results.push(await this.setup(ideName, projectDir, bmadDir, options));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = handler.installerConfig?.target_dir || null;
|
||||||
|
const claim = target ? claimedTargets.get(target) : null;
|
||||||
|
const skipTarget = !!claim;
|
||||||
|
|
||||||
|
const result = await this.setup(ideName, projectDir, bmadDir, {
|
||||||
|
...options,
|
||||||
|
skipTarget,
|
||||||
|
sharedWith: claim?.firstIde || null,
|
||||||
|
sharedTarget: target,
|
||||||
|
sharedSkillCount: claim?.skillCount || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (target && !claim) {
|
||||||
|
const writtenCount = result.handlerResult?.results?.skillDirectories || result.handlerResult?.results?.skills || 0;
|
||||||
|
claimedTargets.set(target, { firstIde: ideName, skillCount: writtenCount });
|
||||||
|
}
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleanup IDE configurations
|
* Cleanup IDE configurations
|
||||||
* @param {string} projectDir - Project directory
|
* @param {string} projectDir - Project directory
|
||||||
|
|
@ -198,6 +253,8 @@ class IdeManager {
|
||||||
* @param {string} projectDir - Project directory
|
* @param {string} projectDir - Project directory
|
||||||
* @param {Array<string>} ideList - List of IDE names to clean up
|
* @param {Array<string>} ideList - List of IDE names to clean up
|
||||||
* @param {Object} [options] - Cleanup options passed through to handlers
|
* @param {Object} [options] - Cleanup options passed through to handlers
|
||||||
|
* options.remainingIdes - IDE names still installed after this cleanup; used
|
||||||
|
* to skip target_dir wipe when a co-installed platform shares the dir.
|
||||||
* @returns {Array} Results array
|
* @returns {Array} Results array
|
||||||
*/
|
*/
|
||||||
async cleanupByList(projectDir, ideList, options = {}) {
|
async cleanupByList(projectDir, ideList, options = {}) {
|
||||||
|
|
@ -211,13 +268,27 @@ class IdeManager {
|
||||||
// Build lowercase lookup for case-insensitive matching
|
// Build lowercase lookup for case-insensitive matching
|
||||||
const lowercaseHandlers = new Map([...this.handlers.entries()].map(([k, v]) => [k.toLowerCase(), v]));
|
const lowercaseHandlers = new Map([...this.handlers.entries()].map(([k, v]) => [k.toLowerCase(), v]));
|
||||||
|
|
||||||
|
// Resolve target_dirs for IDEs that will remain installed after this cleanup
|
||||||
|
const remainingTargets = new Set();
|
||||||
|
if (Array.isArray(options.remainingIdes)) {
|
||||||
|
for (const remaining of options.remainingIdes) {
|
||||||
|
const h = lowercaseHandlers.get(String(remaining).toLowerCase());
|
||||||
|
const t = h?.installerConfig?.target_dir;
|
||||||
|
if (t) remainingTargets.add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const ideName of ideList) {
|
for (const ideName of ideList) {
|
||||||
const handler = lowercaseHandlers.get(ideName.toLowerCase());
|
const handler = lowercaseHandlers.get(ideName.toLowerCase());
|
||||||
if (!handler) continue;
|
if (!handler) continue;
|
||||||
|
|
||||||
|
const target = handler.installerConfig?.target_dir || null;
|
||||||
|
const skipTarget = target && remainingTargets.has(target);
|
||||||
|
const cleanupOptions = skipTarget ? { ...options, skipTarget: true } : options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await handler.cleanup(projectDir, options);
|
await handler.cleanup(projectDir, cleanupOptions);
|
||||||
results.push({ ide: ideName, success: true });
|
results.push({ ide: ideName, success: true, skippedTarget: !!skipTarget });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.push({ ide: ideName, success: false, error: error.message });
|
results.push({ ide: ideName, success: false, error: error.message });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,20 @@
|
||||||
# Paths verified against each tool's primary docs as of 2026-04-25.
|
# Paths verified against each tool's primary docs as of 2026-04-25.
|
||||||
|
|
||||||
platforms:
|
platforms:
|
||||||
|
adal:
|
||||||
|
name: "AdaL"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .adal/skills
|
||||||
|
global_target_dir: ~/.adal/skills
|
||||||
|
|
||||||
|
amp:
|
||||||
|
name: "Sourcegraph Amp"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .agents/skills
|
||||||
|
global_target_dir: ~/.config/agents/skills
|
||||||
|
|
||||||
antigravity:
|
antigravity:
|
||||||
name: "Google Antigravity"
|
name: "Google Antigravity"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
|
@ -28,6 +42,13 @@ platforms:
|
||||||
target_dir: .agents/skills
|
target_dir: .agents/skills
|
||||||
global_target_dir: ~/.agents/skills
|
global_target_dir: ~/.agents/skills
|
||||||
|
|
||||||
|
bob:
|
||||||
|
name: "IBM Bob"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .bob/skills
|
||||||
|
global_target_dir: ~/.bob/skills
|
||||||
|
|
||||||
claude-code:
|
claude-code:
|
||||||
name: "Claude Code"
|
name: "Claude Code"
|
||||||
preferred: true
|
preferred: true
|
||||||
|
|
@ -44,7 +65,7 @@ platforms:
|
||||||
|
|
||||||
codex:
|
codex:
|
||||||
name: "Codex"
|
name: "Codex"
|
||||||
preferred: false
|
preferred: true
|
||||||
installer:
|
installer:
|
||||||
target_dir: .agents/skills
|
target_dir: .agents/skills
|
||||||
global_target_dir: ~/.codex/skills
|
global_target_dir: ~/.codex/skills
|
||||||
|
|
@ -56,6 +77,20 @@ platforms:
|
||||||
target_dir: .codebuddy/skills
|
target_dir: .codebuddy/skills
|
||||||
global_target_dir: ~/.codebuddy/skills
|
global_target_dir: ~/.codebuddy/skills
|
||||||
|
|
||||||
|
command-code:
|
||||||
|
name: "Command Code"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .agents/skills
|
||||||
|
global_target_dir: ~/.agents/skills
|
||||||
|
|
||||||
|
cortex:
|
||||||
|
name: "Snowflake Cortex Code"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .cortex/skills
|
||||||
|
global_target_dir: ~/.snowflake/cortex/skills
|
||||||
|
|
||||||
crush:
|
crush:
|
||||||
name: "Crush"
|
name: "Crush"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
|
@ -70,6 +105,20 @@ platforms:
|
||||||
target_dir: .agents/skills
|
target_dir: .agents/skills
|
||||||
global_target_dir: ~/.agents/skills
|
global_target_dir: ~/.agents/skills
|
||||||
|
|
||||||
|
droid:
|
||||||
|
name: "Factory Droid"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .factory/skills
|
||||||
|
global_target_dir: ~/.factory/skills
|
||||||
|
|
||||||
|
firebender:
|
||||||
|
name: "Firebender"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .firebender/skills
|
||||||
|
global_target_dir: ~/.agents/skills
|
||||||
|
|
||||||
gemini:
|
gemini:
|
||||||
name: "Gemini CLI"
|
name: "Gemini CLI"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
|
@ -79,11 +128,18 @@ platforms:
|
||||||
|
|
||||||
github-copilot:
|
github-copilot:
|
||||||
name: "GitHub Copilot"
|
name: "GitHub Copilot"
|
||||||
preferred: false
|
preferred: true
|
||||||
installer:
|
installer:
|
||||||
target_dir: .agents/skills
|
target_dir: .agents/skills
|
||||||
global_target_dir: ~/.agents/skills
|
global_target_dir: ~/.agents/skills
|
||||||
|
|
||||||
|
goose:
|
||||||
|
name: "Block Goose"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .agents/skills
|
||||||
|
global_target_dir: ~/.config/agents/skills
|
||||||
|
|
||||||
iflow:
|
iflow:
|
||||||
name: "iFlow"
|
name: "iFlow"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
|
@ -119,12 +175,47 @@ platforms:
|
||||||
target_dir: .kiro/skills
|
target_dir: .kiro/skills
|
||||||
global_target_dir: ~/.kiro/skills
|
global_target_dir: ~/.kiro/skills
|
||||||
|
|
||||||
|
kode:
|
||||||
|
name: "Kode"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .kode/skills
|
||||||
|
global_target_dir: ~/.kode/skills
|
||||||
|
|
||||||
|
mistral-vibe:
|
||||||
|
name: "Mistral Vibe"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .agents/skills
|
||||||
|
global_target_dir: ~/.vibe/skills
|
||||||
|
|
||||||
|
mux:
|
||||||
|
name: "Mux"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .agents/skills
|
||||||
|
global_target_dir: ~/.agents/skills
|
||||||
|
|
||||||
|
neovate:
|
||||||
|
name: "Neovate"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .neovate/skills
|
||||||
|
global_target_dir: ~/.neovate/skills
|
||||||
|
|
||||||
ona:
|
ona:
|
||||||
name: "Ona"
|
name: "Ona"
|
||||||
preferred: false
|
preferred: false
|
||||||
installer:
|
installer:
|
||||||
target_dir: .ona/skills
|
target_dir: .ona/skills
|
||||||
|
|
||||||
|
openclaw:
|
||||||
|
name: "OpenClaw"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .agents/skills
|
||||||
|
global_target_dir: ~/.agents/skills
|
||||||
|
|
||||||
opencode:
|
opencode:
|
||||||
name: "OpenCode"
|
name: "OpenCode"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
|
@ -132,6 +223,13 @@ platforms:
|
||||||
target_dir: .agents/skills
|
target_dir: .agents/skills
|
||||||
global_target_dir: ~/.agents/skills
|
global_target_dir: ~/.agents/skills
|
||||||
|
|
||||||
|
openhands:
|
||||||
|
name: "OpenHands"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .agents/skills
|
||||||
|
global_target_dir: ~/.agents/skills
|
||||||
|
|
||||||
pi:
|
pi:
|
||||||
name: "Pi"
|
name: "Pi"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
|
@ -139,6 +237,13 @@ platforms:
|
||||||
target_dir: .agents/skills
|
target_dir: .agents/skills
|
||||||
global_target_dir: ~/.agents/skills
|
global_target_dir: ~/.agents/skills
|
||||||
|
|
||||||
|
pochi:
|
||||||
|
name: "Pochi"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .agents/skills
|
||||||
|
global_target_dir: ~/.agents/skills
|
||||||
|
|
||||||
qoder:
|
qoder:
|
||||||
name: "Qoder"
|
name: "Qoder"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
|
@ -153,6 +258,12 @@ platforms:
|
||||||
target_dir: .qwen/skills
|
target_dir: .qwen/skills
|
||||||
global_target_dir: ~/.qwen/skills
|
global_target_dir: ~/.qwen/skills
|
||||||
|
|
||||||
|
replit:
|
||||||
|
name: "Replit Agent"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .agents/skills
|
||||||
|
|
||||||
roo:
|
roo:
|
||||||
name: "Roo Code"
|
name: "Roo Code"
|
||||||
preferred: false
|
preferred: false
|
||||||
|
|
@ -173,9 +284,23 @@ platforms:
|
||||||
installer:
|
installer:
|
||||||
target_dir: .trae/skills
|
target_dir: .trae/skills
|
||||||
|
|
||||||
|
warp:
|
||||||
|
name: "Warp"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .agents/skills
|
||||||
|
global_target_dir: ~/.agents/skills
|
||||||
|
|
||||||
windsurf:
|
windsurf:
|
||||||
name: "Windsurf"
|
name: "Windsurf"
|
||||||
preferred: false
|
preferred: false
|
||||||
installer:
|
installer:
|
||||||
target_dir: .agents/skills
|
target_dir: .agents/skills
|
||||||
global_target_dir: ~/.agents/skills
|
global_target_dir: ~/.agents/skills
|
||||||
|
|
||||||
|
zencoder:
|
||||||
|
name: "Zencoder"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .zencoder/skills
|
||||||
|
global_target_dir: ~/.zencoder/skills
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
const path = require('node:path');
|
||||||
|
const fs = require('../../fs-native');
|
||||||
|
const csv = require('csv-parse/sync');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the global skill-manifest.csv and return the set of canonicalIds.
|
||||||
|
* These define which directory entries in a target_dir are BMAD-owned, regardless
|
||||||
|
* of whether they happen to start with "bmad-" (custom modules can ship skills
|
||||||
|
* with any prefix, e.g. "fred-cool-skill").
|
||||||
|
*
|
||||||
|
* @param {string} bmadDir - Path to the _bmad install directory
|
||||||
|
* @returns {Promise<Set<string>>} Set of canonicalIds, or empty set if manifest missing
|
||||||
|
*/
|
||||||
|
async function getInstalledCanonicalIds(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 {
|
||||||
|
// Unreadable/invalid manifest — treat as no info
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test whether a directory entry is BMAD-owned.
|
||||||
|
* Prefers the manifest's canonicalIds; falls back to the legacy "bmad" prefix
|
||||||
|
* when no manifest is available (early install, ancestor lookup with no bmad dir).
|
||||||
|
*
|
||||||
|
* @param {string} entry - Directory entry name
|
||||||
|
* @param {Set<string>|null} canonicalIds - From getInstalledCanonicalIds, or null
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isBmadOwnedEntry(entry, canonicalIds) {
|
||||||
|
if (!entry || typeof entry !== 'string') return false;
|
||||||
|
if (entry.toLowerCase().startsWith('bmad-os-')) return false;
|
||||||
|
if (canonicalIds && canonicalIds.size > 0) return canonicalIds.has(entry);
|
||||||
|
return entry.toLowerCase().startsWith('bmad');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getInstalledCanonicalIds, isBmadOwnedEntry };
|
||||||
Loading…
Reference in New Issue