From 6e287245ca5a7fdc1fa4a719abda4be7e371ff11 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Sat, 25 Apr 2026 20:48:36 -0500 Subject: [PATCH] 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. --- test/test-installation-components.js | 105 +++++++++++++- tools/installer/core/installer.js | 30 ++-- tools/installer/core/legacy-warnings.js | 45 +++++- tools/installer/ide/_config-driven.js | 45 ++++-- tools/installer/ide/manager.js | 79 ++++++++++- tools/installer/ide/platform-codes.yaml | 129 +++++++++++++++++- .../installer/ide/shared/installed-skills.js | 50 +++++++ 7 files changed, 440 insertions(+), 43 deletions(-) create mode 100644 tools/installer/ide/shared/installed-skills.js diff --git a/test/test-installation-components.js b/test/test-installation-components.js index b7da89b0c..21a467200 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -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 // ============================================================ diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index 4ed364c5f..abd9ee9a3 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -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, { - selectedModules: allModules || [], - verbose: config.verbose, - previousSkillIds, - }); + 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 { diff --git a/tools/installer/core/legacy-warnings.js b/tools/installer/core/legacy-warnings.js index 6c1bde03a..d73bbd9a1 100644 --- a/tools/installer/core/legacy-warnings.js +++ b/tools/installer/core/legacy-warnings.js @@ -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 }); } diff --git a/tools/installer/ide/_config-driven.js b/tools/installer/ide/_config-driven.js index 3047f8cfb..737e10862 100644 --- a/tools/installer/ide/_config-driven.js +++ b/tools/installer/ide/_config-driven.js @@ -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)) { - try { - const entries = await fs.readdir(dir); - return entries.some((e) => typeof e === 'string' && e.startsWith('bmad')); - } catch { - 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; } - 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; } } diff --git a/tools/installer/ide/manager.js b/tools/installer/ide/manager.js index ac49a8773..0143319d8 100644 --- a/tools/installer/ide/manager.js +++ b/tools/installer/ide/manager.js @@ -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} ideList - IDE names to set up + * @param {string} projectDir + * @param {string} bmadDir + * @param {Object} [options] - Forwarded to each handler.setup + * @returns {Promise} 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} 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 }); } diff --git a/tools/installer/ide/platform-codes.yaml b/tools/installer/ide/platform-codes.yaml index e9b373bd8..0f49a7fbe 100644 --- a/tools/installer/ide/platform-codes.yaml +++ b/tools/installer/ide/platform-codes.yaml @@ -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 diff --git a/tools/installer/ide/shared/installed-skills.js b/tools/installer/ide/shared/installed-skills.js new file mode 100644 index 000000000..7c68f990f --- /dev/null +++ b/tools/installer/ide/shared/installed-skills.js @@ -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 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|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 };