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:
Brian Madison 2026-04-25 20:48:36 -05:00
parent 5ff534df94
commit 6e287245ca
7 changed files with 440 additions and 43 deletions

View File

@ -1382,7 +1382,7 @@ async function runTests() {
});
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.skills === 1, 'Result retains verbatim skill count');
assert(
@ -2622,6 +2622,109 @@ async function runTests() {
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
// ============================================================

View File

@ -189,17 +189,12 @@ class Installer {
if (toRemove.length === 0) return;
await this.ideManager.ensureInitialized();
for (const ide of toRemove) {
try {
const handler = this.ideManager.handlers.get(ide);
if (handler) {
await handler.cleanup(paths.projectRoot);
}
} catch (error) {
await prompts.log.warn(`Warning: Failed to remove ${ide}: ${error.message}`);
}
}
// Pass the newly-selected list as remainingIdes so cleanupByList skips
// target_dir wipes for IDEs whose directory is still owned by a peer
// (e.g. removing 'cursor' while 'gemini' remains — both share .agents/skills).
await this.ideManager.cleanupByList(paths.projectRoot, toRemove, {
remainingIdes: [...newlySelected],
});
}
/**
@ -348,13 +343,14 @@ class Installer {
return;
}
for (const ide of validIdes) {
const setupResult = await this.ideManager.setup(ide, paths.projectRoot, paths.bmadDir, {
const setupResults = await this.ideManager.setupBatch(validIdes, paths.projectRoot, paths.bmadDir, {
selectedModules: allModules || [],
verbose: config.verbose,
previousSkillIds,
});
for (const setupResult of setupResults) {
const ide = setupResult.ide;
if (setupResult.success) {
addResult(ide, 'ok', setupResult.detail || '');
} else {

View File

@ -3,10 +3,14 @@ const path = require('node:path');
const semver = require('semver');
const fs = require('../fs-native');
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 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',
'.augment/commands',
'.claude/commands',
@ -33,6 +37,38 @@ const LEGACY_PATHS = [
'.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) {
if (p === '~') return os.homedir();
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
@ -45,15 +81,16 @@ function resolveLegacyPath(projectRoot, p) {
}
async function findStaleLegacyDirs(projectRoot) {
const bmadDir = path.join(projectRoot, BMAD_FOLDER_NAME);
const canonicalIds = await getInstalledCanonicalIds(bmadDir);
const findings = [];
for (const legacyPath of LEGACY_PATHS) {
const resolved = resolveLegacyPath(projectRoot, legacyPath);
if (!(await fs.pathExists(resolved))) continue;
try {
const entries = await fs.readdir(resolved);
const bmadEntries = entries.filter(
(e) => typeof e === 'string' && e.toLowerCase().startsWith('bmad') && !e.toLowerCase().startsWith('bmad-os-'),
);
const bmadEntries = entries.filter((e) => isBmadOwnedEntry(e, canonicalIds));
if (bmadEntries.length > 0) {
findings.push({ path: resolved, displayPath: legacyPath, count: bmadEntries.length });
}

View File

@ -4,6 +4,7 @@ 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');
/**
* Config-driven IDE setup handler
@ -43,16 +44,20 @@ class ConfigDrivenIdeSetup {
async detect(projectDir) {
if (!this.configDir) return false;
const dir = path.join(projectDir || process.cwd(), this.configDir);
if (await fs.pathExists(dir)) {
const root = projectDir || process.cwd();
const dir = path.join(root, this.configDir);
if (!(await fs.pathExists(dir))) return false;
let entries;
try {
const entries = await fs.readdir(dir);
return entries.some((e) => typeof e === 'string' && e.startsWith('bmad'));
entries = await fs.readdir(dir);
} catch {
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' };
}
// 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) {
return this.installToTarget(projectDir, bmadDir, this.installerConfig, options);
}
@ -236,6 +247,11 @@ class ConfigDrivenIdeSetup {
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
if (this.installerConfig?.target_dir) {
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
if (entry.startsWith('bmad-os-')) continue;
// Surgical removal from set, or legacy prefix matching when set is null
const shouldRemove = removalSet ? removalSet.has(entry) : entry.startsWith('bmad');
// Surgical removal from set, or fallback to manifest+prefix detection when null
const shouldRemove = removalSet ? removalSet.has(entry) : isBmadOwnedEntry(entry, null);
if (shouldRemove) {
try {
@ -533,10 +549,9 @@ class ConfigDrivenIdeSetup {
try {
if (await fs.pathExists(candidatePath)) {
const entries = await fs.readdir(candidatePath);
const hasBmad = entries.some(
(e) => typeof e === 'string' && e.toLowerCase().startsWith('bmad') && !e.toLowerCase().startsWith('bmad-os-'),
);
if (hasBmad) {
const ancestorBmadDir = await this._findBmadDir(current);
const canonicalIds = await getInstalledCanonicalIds(ancestorBmadDir);
if (entries.some((e) => isBmadOwnedEntry(e, canonicalIds))) {
return candidatePath;
}
}

View File

@ -160,8 +160,18 @@ class IdeManager {
let detail = '';
if (handlerResult && handlerResult.results) {
const r = handlerResult.results;
const count = r.skillDirectories || r.skills || 0;
if (count > 0) detail = `${count} skills`;
let count = r.skillDirectories || r.skills || 0;
// 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)
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
* @param {string} projectDir - Project directory
@ -198,6 +253,8 @@ class IdeManager {
* @param {string} projectDir - Project directory
* @param {Array<string>} ideList - List of IDE names to clean up
* @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
*/
async cleanupByList(projectDir, ideList, options = {}) {
@ -211,13 +268,27 @@ class IdeManager {
// Build lowercase lookup for case-insensitive matching
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) {
const handler = lowercaseHandlers.get(ideName.toLowerCase());
if (!handler) continue;
const target = handler.installerConfig?.target_dir || null;
const skipTarget = target && remainingTargets.has(target);
const cleanupOptions = skipTarget ? { ...options, skipTarget: true } : options;
try {
await handler.cleanup(projectDir, options);
results.push({ ide: ideName, success: true });
await handler.cleanup(projectDir, cleanupOptions);
results.push({ ide: ideName, success: true, skippedTarget: !!skipTarget });
} catch (error) {
results.push({ ide: ideName, success: false, error: error.message });
}

View File

@ -14,6 +14,20 @@
# Paths verified against each tool's primary docs as of 2026-04-25.
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:
name: "Google Antigravity"
preferred: false
@ -28,6 +42,13 @@ platforms:
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:
name: "Claude Code"
preferred: true
@ -44,7 +65,7 @@ platforms:
codex:
name: "Codex"
preferred: false
preferred: true
installer:
target_dir: .agents/skills
global_target_dir: ~/.codex/skills
@ -56,6 +77,20 @@ platforms:
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:
name: "Crush"
preferred: false
@ -70,6 +105,20 @@ platforms:
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:
name: "Gemini CLI"
preferred: false
@ -79,11 +128,18 @@ platforms:
github-copilot:
name: "GitHub Copilot"
preferred: false
preferred: true
installer:
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:
name: "iFlow"
preferred: false
@ -119,12 +175,47 @@ platforms:
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:
name: "Ona"
preferred: false
installer:
target_dir: .ona/skills
openclaw:
name: "OpenClaw"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
opencode:
name: "OpenCode"
preferred: false
@ -132,6 +223,13 @@ platforms:
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:
name: "Pi"
preferred: false
@ -139,6 +237,13 @@ platforms:
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:
name: "Qoder"
preferred: false
@ -153,6 +258,12 @@ platforms:
target_dir: .qwen/skills
global_target_dir: ~/.qwen/skills
replit:
name: "Replit Agent"
preferred: false
installer:
target_dir: .agents/skills
roo:
name: "Roo Code"
preferred: false
@ -173,9 +284,23 @@ platforms:
installer:
target_dir: .trae/skills
warp:
name: "Warp"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
windsurf:
name: "Windsurf"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
zencoder:
name: "Zencoder"
preferred: false
installer:
target_dir: .zencoder/skills
global_target_dir: ~/.zencoder/skills

View File

@ -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 };