bmad-method 6.2.3-next.9 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.claude-plugin/marketplace.json +0 -3
  2. package/README.md +8 -9
  3. package/README_CN.md +1 -1
  4. package/README_VN.md +110 -0
  5. package/package.json +1 -1
  6. package/removals.txt +17 -0
  7. package/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/steps/step-13-responsive-accessibility.md +1 -1
  8. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/data/prd-purpose.md +197 -0
  9. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01-discovery.md +1 -1
  10. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01b-legacy-conversion.md +1 -1
  11. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-02-review.md +1 -1
  12. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-03-edit.md +1 -1
  13. package/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-04-complete.md +1 -3
  14. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-01-document-discovery.md +1 -1
  15. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-02-prd-analysis.md +1 -1
  16. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/steps/step-03-epic-coverage-validation.md +1 -1
  17. package/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/workflow.md +1 -1
  18. package/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/workflow.md +1 -1
  19. package/src/bmm-skills/4-implementation/bmad-agent-dev/SKILL.md +5 -0
  20. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/SKILL.md +29 -0
  21. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/generate-trail.md +38 -0
  22. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-01-orientation.md +105 -0
  23. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-02-walkthrough.md +89 -0
  24. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-03-detail-pass.md +106 -0
  25. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-04-testing.md +74 -0
  26. package/src/bmm-skills/4-implementation/bmad-checkpoint-preview/step-05-wrapup.md +24 -0
  27. package/src/bmm-skills/4-implementation/bmad-code-review/steps/step-01-gather-context.md +38 -15
  28. package/src/bmm-skills/4-implementation/bmad-correct-course/checklist.md +2 -2
  29. package/src/bmm-skills/4-implementation/bmad-correct-course/workflow.md +8 -8
  30. package/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/checklist.md +1 -1
  31. package/src/bmm-skills/4-implementation/bmad-quick-dev/compile-epic-context.md +62 -0
  32. package/src/bmm-skills/4-implementation/bmad-quick-dev/spec-template.md +1 -1
  33. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md +33 -6
  34. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-02-plan.md +20 -8
  35. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-03-implement.md +2 -0
  36. package/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md +16 -4
  37. package/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md +1 -5
  38. package/src/bmm-skills/4-implementation/bmad-retrospective/workflow.md +134 -134
  39. package/src/bmm-skills/4-implementation/bmad-sprint-planning/sprint-status-template.yaml +1 -1
  40. package/src/bmm-skills/4-implementation/bmad-sprint-planning/workflow.md +3 -3
  41. package/src/bmm-skills/4-implementation/bmad-sprint-status/workflow.md +2 -2
  42. package/src/bmm-skills/module-help.csv +2 -0
  43. package/src/core-skills/bmad-help/SKILL.md +4 -2
  44. package/src/core-skills/bmad-party-mode/SKILL.md +8 -6
  45. package/src/core-skills/module-help.csv +1 -0
  46. package/tools/installer/cli-utils.js +18 -9
  47. package/tools/installer/commands/install.js +1 -1
  48. package/tools/installer/core/existing-install.js +2 -8
  49. package/tools/installer/core/install-paths.js +0 -3
  50. package/tools/installer/core/installer.js +180 -463
  51. package/tools/installer/core/manifest-generator.js +8 -14
  52. package/tools/installer/core/manifest.js +94 -102
  53. package/tools/installer/ide/_config-driven.js +149 -38
  54. package/tools/installer/ide/shared/skill-manifest.js +1 -16
  55. package/tools/installer/install-messages.yaml +19 -26
  56. package/tools/installer/modules/community-manager.js +377 -0
  57. package/tools/installer/modules/custom-module-manager.js +644 -0
  58. package/tools/installer/modules/external-manager.js +65 -49
  59. package/tools/installer/modules/official-modules.js +117 -65
  60. package/tools/installer/modules/plugin-resolver.js +398 -0
  61. package/tools/installer/modules/registry-client.js +66 -0
  62. package/tools/installer/{external-official-modules.yaml → modules/registry-fallback.yaml} +3 -12
  63. package/tools/installer/ui.js +549 -666
  64. package/src/bmm-skills/4-implementation/bmad-agent-qa/SKILL.md +0 -61
  65. package/src/bmm-skills/4-implementation/bmad-agent-qa/bmad-skill-manifest.yaml +0 -11
  66. package/src/bmm-skills/4-implementation/bmad-agent-quick-flow-solo-dev/SKILL.md +0 -53
  67. package/src/bmm-skills/4-implementation/bmad-agent-quick-flow-solo-dev/bmad-skill-manifest.yaml +0 -11
  68. package/src/bmm-skills/4-implementation/bmad-agent-sm/SKILL.md +0 -55
  69. package/src/bmm-skills/4-implementation/bmad-agent-sm/bmad-skill-manifest.yaml +0 -11
  70. package/tools/installer/core/custom-module-cache.js +0 -260
  71. package/tools/installer/custom-handler.js +0 -112
  72. package/tools/installer/modules/custom-modules.js +0 -197
@@ -9,7 +9,6 @@ const {
9
9
  loadSkillManifest: loadSkillManifestShared,
10
10
  getCanonicalId: getCanonicalIdShared,
11
11
  getArtifactType: getArtifactTypeShared,
12
- getInstallToBmad: getInstallToBmadShared,
13
12
  } = require('../ide/shared/skill-manifest');
14
13
 
15
14
  // Load package.json for version info
@@ -42,11 +41,6 @@ class ManifestGenerator {
42
41
  return getArtifactTypeShared(manifest, filename);
43
42
  }
44
43
 
45
- /** Delegate to shared skill-manifest module */
46
- getInstallToBmad(manifest, filename) {
47
- return getInstallToBmadShared(manifest, filename);
48
- }
49
-
50
44
  /**
51
45
  * Clean text for CSV output by normalizing whitespace.
52
46
  * Note: Quote escaping is handled by escapeCsv() at write time.
@@ -127,7 +121,7 @@ class ManifestGenerator {
127
121
  * Recursively walk a module directory tree, collecting native SKILL.md entrypoints.
128
122
  * A directory is discovered as a skill when it contains a SKILL.md file with
129
123
  * valid name/description frontmatter (name must match directory name).
130
- * Manifest YAML is loaded only when present — for install_to_bmad and agent metadata.
124
+ * Manifest YAML is loaded only when present — for agent metadata.
131
125
  * Populates this.skills[] and this.skillClaimedDirs (Set of absolute paths).
132
126
  */
133
127
  async collectSkills() {
@@ -156,7 +150,7 @@ class ManifestGenerator {
156
150
  const skillMeta = await this.parseSkillMd(skillMdPath, dir, dirName, debug);
157
151
 
158
152
  if (skillMeta) {
159
- // Load manifest when present (for install_to_bmad and agent metadata)
153
+ // Load manifest when present (for agent metadata)
160
154
  const manifest = await this.loadSkillManifest(dir);
161
155
  const artifactType = this.getArtifactType(manifest, skillFile);
162
156
 
@@ -182,7 +176,6 @@ class ManifestGenerator {
182
176
  module: moduleName,
183
177
  path: installPath,
184
178
  canonicalId,
185
- install_to_bmad: this.getInstallToBmad(manifest, skillFile),
186
179
  });
187
180
 
188
181
  // Add to files list
@@ -377,11 +370,11 @@ class ManifestGenerator {
377
370
  */
378
371
  async writeMainManifest(cfgDir) {
379
372
  const manifestPath = path.join(cfgDir, 'manifest.yaml');
373
+ const installedModuleSet = new Set(this.modules);
380
374
 
381
375
  // Read existing manifest to preserve install date
382
376
  let existingInstallDate = null;
383
377
  const existingModulesMap = new Map();
384
-
385
378
  if (await fs.pathExists(manifestPath)) {
386
379
  try {
387
380
  const existingContent = await fs.readFile(manifestPath, 'utf8');
@@ -419,7 +412,7 @@ class ManifestGenerator {
419
412
  // Get existing install date if available
420
413
  const existing = existingModulesMap.get(moduleName);
421
414
 
422
- updatedModules.push({
415
+ const moduleEntry = {
423
416
  name: moduleName,
424
417
  version: versionInfo.version,
425
418
  installDate: existing?.installDate || new Date().toISOString(),
@@ -427,7 +420,9 @@ class ManifestGenerator {
427
420
  source: versionInfo.source,
428
421
  npmPackage: versionInfo.npmPackage,
429
422
  repoUrl: versionInfo.repoUrl,
430
- });
423
+ };
424
+ if (versionInfo.localPath) moduleEntry.localPath = versionInfo.localPath;
425
+ updatedModules.push(moduleEntry);
431
426
  }
432
427
 
433
428
  const manifest = {
@@ -463,7 +458,7 @@ class ManifestGenerator {
463
458
  const csvPath = path.join(cfgDir, 'skill-manifest.csv');
464
459
  const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
465
460
 
466
- let csvContent = 'canonicalId,name,description,module,path,install_to_bmad\n';
461
+ let csvContent = 'canonicalId,name,description,module,path\n';
467
462
 
468
463
  for (const skill of this.skills) {
469
464
  const row = [
@@ -472,7 +467,6 @@ class ManifestGenerator {
472
467
  escapeCsv(skill.description),
473
468
  escapeCsv(skill.module),
474
469
  escapeCsv(skill.path),
475
- escapeCsv(skill.install_to_bmad),
476
470
  ].join(',');
477
471
  csvContent += row + '\n';
478
472
  }
@@ -97,7 +97,6 @@ class Manifest {
97
97
  lastUpdated: manifestData.installation?.lastUpdated,
98
98
  modules: moduleNames, // Simple array of module names for backward compatibility
99
99
  modulesDetailed: hasDetailedModules ? modules : null, // New detailed format
100
- customModules: manifestData.customModules || [], // Keep for backward compatibility
101
100
  ides: manifestData.ides || [],
102
101
  };
103
102
  } catch (error) {
@@ -182,10 +181,10 @@ class Manifest {
182
181
 
183
182
  // Handle adding a new module with version info
184
183
  if (updates.addModule) {
185
- const { name, version, source, npmPackage, repoUrl } = updates.addModule;
184
+ const { name, version, source, npmPackage, repoUrl, localPath } = updates.addModule;
186
185
  const existing = manifest.modules.find((m) => m.name === name);
187
186
  if (!existing) {
188
- manifest.modules.push({
187
+ const entry = {
189
188
  name,
190
189
  version: version || null,
191
190
  installDate: new Date().toISOString(),
@@ -193,7 +192,9 @@ class Manifest {
193
192
  source: source || 'external',
194
193
  npmPackage: npmPackage || null,
195
194
  repoUrl: repoUrl || null,
196
- });
195
+ };
196
+ if (localPath) entry.localPath = localPath;
197
+ manifest.modules.push(entry);
197
198
  }
198
199
  }
199
200
 
@@ -254,7 +255,6 @@ class Manifest {
254
255
  lastUpdated: manifest.installation?.lastUpdated,
255
256
  modules: moduleNames,
256
257
  modulesDetailed: hasDetailedModules ? modules : null,
257
- customModules: manifest.customModules || [],
258
258
  ides: manifest.ides || [],
259
259
  };
260
260
  }
@@ -282,7 +282,7 @@ class Manifest {
282
282
 
283
283
  if (existingIndex === -1) {
284
284
  // Module doesn't exist, add it
285
- manifest.modules.push({
285
+ const entry = {
286
286
  name: moduleName,
287
287
  version: options.version || null,
288
288
  installDate: new Date().toISOString(),
@@ -290,7 +290,9 @@ class Manifest {
290
290
  source: options.source || 'unknown',
291
291
  npmPackage: options.npmPackage || null,
292
292
  repoUrl: options.repoUrl || null,
293
- });
293
+ };
294
+ if (options.localPath) entry.localPath = options.localPath;
295
+ manifest.modules.push(entry);
294
296
  } else {
295
297
  // Module exists, update its version info
296
298
  const existing = manifest.modules[existingIndex];
@@ -300,6 +302,7 @@ class Manifest {
300
302
  source: options.source || existing.source,
301
303
  npmPackage: options.npmPackage === undefined ? existing.npmPackage : options.npmPackage,
302
304
  repoUrl: options.repoUrl === undefined ? existing.repoUrl : options.repoUrl,
305
+ localPath: options.localPath === undefined ? existing.localPath : options.localPath,
303
306
  lastUpdated: new Date().toISOString(),
304
307
  };
305
308
  }
@@ -783,52 +786,6 @@ class Manifest {
783
786
 
784
787
  return configs;
785
788
  }
786
- /**
787
- * Add a custom module to the manifest with its source path
788
- * @param {string} bmadDir - Path to bmad directory
789
- * @param {Object} customModule - Custom module info
790
- */
791
- async addCustomModule(bmadDir, customModule) {
792
- const manifest = await this.read(bmadDir);
793
- if (!manifest) {
794
- throw new Error('No manifest found');
795
- }
796
-
797
- if (!manifest.customModules) {
798
- manifest.customModules = [];
799
- }
800
-
801
- // Check if custom module already exists
802
- const existingIndex = manifest.customModules.findIndex((m) => m.id === customModule.id);
803
- if (existingIndex === -1) {
804
- // Add new entry
805
- manifest.customModules.push(customModule);
806
- } else {
807
- // Update existing entry
808
- manifest.customModules[existingIndex] = customModule;
809
- }
810
-
811
- await this.update(bmadDir, { customModules: manifest.customModules });
812
- }
813
-
814
- /**
815
- * Remove a custom module from the manifest
816
- * @param {string} bmadDir - Path to bmad directory
817
- * @param {string} moduleId - Module ID to remove
818
- */
819
- async removeCustomModule(bmadDir, moduleId) {
820
- const manifest = await this.read(bmadDir);
821
- if (!manifest || !manifest.customModules) {
822
- return;
823
- }
824
-
825
- const index = manifest.customModules.findIndex((m) => m.id === moduleId);
826
- if (index !== -1) {
827
- manifest.customModules.splice(index, 1);
828
- await this.update(bmadDir, { customModules: manifest.customModules });
829
- }
830
- }
831
-
832
789
  /**
833
790
  * Get module version info from source
834
791
  * @param {string} moduleName - Module name/code
@@ -837,14 +794,13 @@ class Manifest {
837
794
  * @returns {Object} Version info object with version, source, npmPackage, repoUrl
838
795
  */
839
796
  async getModuleVersionInfo(moduleName, bmadDir, moduleSourcePath = null) {
840
- const os = require('node:os');
841
797
  const yaml = require('yaml');
842
798
 
843
- // Built-in modules use BMad version (only core and bmm are in BMAD-METHOD repo)
799
+ // Resolve source type first, then read version with the correct path context
844
800
  if (['core', 'bmm'].includes(moduleName)) {
845
- const bmadVersion = require(path.join(getProjectRoot(), 'package.json')).version;
801
+ const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
846
802
  return {
847
- version: bmadVersion,
803
+ version,
848
804
  source: 'built-in',
849
805
  npmPackage: null,
850
806
  repoUrl: null,
@@ -857,69 +813,105 @@ class Manifest {
857
813
  const moduleInfo = await extMgr.getModuleByCode(moduleName);
858
814
 
859
815
  if (moduleInfo) {
860
- // External module - try to get version from npm registry first, then fall back to cache
861
- let version = null;
862
-
863
- if (moduleInfo.npmPackage) {
864
- // Fetch version from npm registry
865
- try {
866
- version = await this.fetchNpmVersion(moduleInfo.npmPackage);
867
- } catch {
868
- // npm fetch failed, try cache as fallback
869
- }
870
- }
871
-
872
- // If npm didn't work, try reading from cached repo's package.json
873
- if (!version) {
874
- const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules', moduleName);
875
- const packageJsonPath = path.join(cacheDir, 'package.json');
876
-
877
- if (await fs.pathExists(packageJsonPath)) {
878
- try {
879
- const pkg = require(packageJsonPath);
880
- version = pkg.version;
881
- } catch (error) {
882
- await prompts.log.warn(`Failed to read package.json for ${moduleName}: ${error.message}`);
883
- }
884
- }
885
- }
886
-
816
+ // External module: use moduleSourcePath if provided, otherwise fall back to cache
817
+ const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
887
818
  return {
888
- version: version,
819
+ version,
889
820
  source: 'external',
890
821
  npmPackage: moduleInfo.npmPackage || null,
891
822
  repoUrl: moduleInfo.url || null,
892
823
  };
893
824
  }
894
825
 
895
- // Custom module - check cache directory
896
- const cacheDir = path.join(bmadDir, '_config', 'custom', moduleName);
897
- const moduleYamlPath = path.join(cacheDir, 'module.yaml');
826
+ // Check if this is a community module
827
+ const { CommunityModuleManager } = require('../modules/community-manager');
828
+ const communityMgr = new CommunityModuleManager();
829
+ const communityInfo = await communityMgr.getModuleByCode(moduleName);
830
+ if (communityInfo) {
831
+ const communityVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
832
+ return {
833
+ version: communityVersion || communityInfo.version,
834
+ source: 'community',
835
+ npmPackage: communityInfo.npmPackage || null,
836
+ repoUrl: communityInfo.url || null,
837
+ };
838
+ }
898
839
 
899
- if (await fs.pathExists(moduleYamlPath)) {
900
- try {
901
- const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
902
- const moduleConfig = yaml.parse(yamlContent);
903
- return {
904
- version: moduleConfig.version || null,
905
- source: 'custom',
906
- npmPackage: moduleConfig.npmPackage || null,
907
- repoUrl: moduleConfig.repoUrl || null,
908
- };
909
- } catch (error) {
910
- await prompts.log.warn(`Failed to read module.yaml for ${moduleName}: ${error.message}`);
911
- }
840
+ // Check if this is a custom module (from user-provided URL or local path)
841
+ const { CustomModuleManager } = require('../modules/custom-module-manager');
842
+ const customMgr = new CustomModuleManager();
843
+ const resolved = customMgr.getResolution(moduleName);
844
+ const customSource = await customMgr.findModuleSourceByCode(moduleName, { bmadDir });
845
+ if (customSource || resolved) {
846
+ const customVersion = resolved?.version || (await this._readMarketplaceVersion(moduleName, moduleSourcePath));
847
+ return {
848
+ version: customVersion,
849
+ source: 'custom',
850
+ npmPackage: null,
851
+ repoUrl: resolved?.repoUrl || null,
852
+ localPath: resolved?.localPath || null,
853
+ };
912
854
  }
913
855
 
914
856
  // Unknown module
857
+ const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
915
858
  return {
916
- version: null,
859
+ version,
917
860
  source: 'unknown',
918
861
  npmPackage: null,
919
862
  repoUrl: null,
920
863
  };
921
864
  }
922
865
 
866
+ /**
867
+ * Read version from .claude-plugin/marketplace.json for a module
868
+ * @param {string} moduleName - Module code
869
+ * @returns {string|null} Version or null
870
+ */
871
+ async _readMarketplaceVersion(moduleName, moduleSourcePath = null) {
872
+ const os = require('node:os');
873
+ let marketplacePath;
874
+
875
+ if (['core', 'bmm'].includes(moduleName)) {
876
+ marketplacePath = path.join(getProjectRoot(), '.claude-plugin', 'marketplace.json');
877
+ } else if (moduleSourcePath) {
878
+ // Walk up from source path to find marketplace.json
879
+ let dir = moduleSourcePath;
880
+ for (let i = 0; i < 5; i++) {
881
+ const candidate = path.join(dir, '.claude-plugin', 'marketplace.json');
882
+ if (await fs.pathExists(candidate)) {
883
+ marketplacePath = candidate;
884
+ break;
885
+ }
886
+ const parent = path.dirname(dir);
887
+ if (parent === dir) break;
888
+ dir = parent;
889
+ }
890
+ }
891
+
892
+ // Fallback to external module cache
893
+ if (!marketplacePath) {
894
+ const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules', moduleName);
895
+ marketplacePath = path.join(cacheDir, '.claude-plugin', 'marketplace.json');
896
+ }
897
+
898
+ try {
899
+ if (await fs.pathExists(marketplacePath)) {
900
+ const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
901
+ const plugins = data?.plugins;
902
+ if (!Array.isArray(plugins) || plugins.length === 0) return null;
903
+ let best = null;
904
+ for (const p of plugins) {
905
+ if (p.version && (!best || p.version > best)) best = p.version;
906
+ }
907
+ return best;
908
+ }
909
+ } catch {
910
+ // ignore
911
+ }
912
+ return null;
913
+ }
914
+
923
915
  /**
924
916
  * Fetch latest version from npm for a package
925
917
  * @param {string} packageName - npm package name
@@ -86,7 +86,7 @@ class ConfigDrivenIdeSetup {
86
86
  if (!options.silent) await prompts.log.info(`Setting up ${this.name}...`);
87
87
 
88
88
  // Clean up any old BMAD installation first
89
- await this.cleanup(projectDir, options);
89
+ await this.cleanup(projectDir, options, bmadDir);
90
90
 
91
91
  if (!this.installerConfig) {
92
92
  return { success: false, reason: 'no-config' };
@@ -183,18 +183,6 @@ class ConfigDrivenIdeSetup {
183
183
  count++;
184
184
  }
185
185
 
186
- // Post-install cleanup: remove _bmad/ directories for skills with install_to_bmad === "false"
187
- for (const record of records) {
188
- if (record.install_to_bmad === 'false') {
189
- const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path;
190
- const sourceFile = path.join(bmadDir, relativePath);
191
- const sourceDir = path.dirname(sourceFile);
192
- if (await fs.pathExists(sourceDir)) {
193
- await fs.remove(sourceDir);
194
- }
195
- }
196
- }
197
-
198
186
  return count;
199
187
  }
200
188
 
@@ -215,16 +203,42 @@ class ConfigDrivenIdeSetup {
215
203
  * Cleanup IDE configuration
216
204
  * @param {string} projectDir - Project directory
217
205
  */
218
- async cleanup(projectDir, options = {}) {
206
+ async cleanup(projectDir, options = {}, bmadDir = null) {
207
+ const resolvedBmadDir = bmadDir || (await this._findBmadDir(projectDir));
208
+
209
+ // Build removal set: previously installed skills + removals.txt entries
210
+ let removalSet;
211
+ if (options.previousSkillIds && options.previousSkillIds.size > 0) {
212
+ // Install/update flow: use pre-captured skill IDs (before manifest was overwritten)
213
+ removalSet = new Set(options.previousSkillIds);
214
+ if (resolvedBmadDir) {
215
+ const removals = await this.loadRemovalLists(resolvedBmadDir);
216
+ for (const entry of removals) removalSet.add(entry);
217
+ }
218
+ } else if (resolvedBmadDir) {
219
+ // Uninstall flow: read from current skill-manifest.csv + removals.txt
220
+ removalSet = await this._buildUninstallSet(resolvedBmadDir);
221
+ } else {
222
+ removalSet = new Set();
223
+ }
224
+
219
225
  // Migrate legacy target directories (e.g. .opencode/agent → .opencode/agents)
226
+ // Legacy dirs are abandoned entirely, so use prefix matching (null removalSet)
220
227
  if (this.installerConfig?.legacy_targets) {
221
- if (!options.silent) await prompts.log.message(' Migrating legacy directories...');
222
- for (const legacyDir of this.installerConfig.legacy_targets) {
223
- if (this.isGlobalPath(legacyDir)) {
224
- await this.warnGlobalLegacy(legacyDir, options);
225
- } else {
226
- await this.cleanupTarget(projectDir, legacyDir, options);
227
- await this.removeEmptyParents(projectDir, legacyDir);
228
+ const legacyDirsExist = await Promise.all(
229
+ this.installerConfig.legacy_targets.map((d) =>
230
+ this.isGlobalPath(d) ? fs.pathExists(d.replace(/^~/, os.homedir())) : fs.pathExists(path.join(projectDir, d)),
231
+ ),
232
+ );
233
+ if (legacyDirsExist.some(Boolean)) {
234
+ if (!options.silent) await prompts.log.message(' Migrating legacy directories...');
235
+ for (const legacyDir of this.installerConfig.legacy_targets) {
236
+ if (this.isGlobalPath(legacyDir)) {
237
+ await this.warnGlobalLegacy(legacyDir, options);
238
+ } else {
239
+ await this.cleanupTarget(projectDir, legacyDir, options, null);
240
+ await this.removeEmptyParents(projectDir, legacyDir);
241
+ }
228
242
  }
229
243
  }
230
244
  }
@@ -244,9 +258,9 @@ class ConfigDrivenIdeSetup {
244
258
  await this.cleanupRovoDevPrompts(projectDir, options);
245
259
  }
246
260
 
247
- // Clean target directory
261
+ // Clean current target directory
248
262
  if (this.installerConfig?.target_dir) {
249
- await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options);
263
+ await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options, removalSet);
250
264
  }
251
265
  }
252
266
 
@@ -286,23 +300,117 @@ class ConfigDrivenIdeSetup {
286
300
  }
287
301
 
288
302
  /**
289
- * Cleanup a specific target directory
303
+ * Find the _bmad directory in a project
304
+ * @param {string} projectDir - Project directory
305
+ * @returns {string|null} Path to bmad dir or null
306
+ */
307
+ async _findBmadDir(projectDir) {
308
+ const bmadDir = path.join(projectDir, BMAD_FOLDER_NAME);
309
+ return (await fs.pathExists(bmadDir)) ? bmadDir : null;
310
+ }
311
+
312
+ /**
313
+ * Build the full set of entries to remove for uninstall.
314
+ * Reads skill-manifest.csv to know exactly what was installed, plus removal lists.
315
+ * @param {string} bmadDir - BMAD installation directory
316
+ * @returns {Set<string>} Set of entries to remove
317
+ */
318
+ async _buildUninstallSet(bmadDir) {
319
+ const removals = await this.loadRemovalLists(bmadDir);
320
+
321
+ // Also add all currently installed skills from skill-manifest.csv
322
+ const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
323
+ try {
324
+ if (await fs.pathExists(csvPath)) {
325
+ const content = await fs.readFile(csvPath, 'utf8');
326
+ const records = csv.parse(content, { columns: true, skip_empty_lines: true });
327
+ for (const record of records) {
328
+ if (record.canonicalId) {
329
+ removals.add(record.canonicalId);
330
+ }
331
+ }
332
+ }
333
+ } catch {
334
+ // If we can't read the manifest, we still have the removal lists
335
+ }
336
+
337
+ return removals;
338
+ }
339
+
340
+ /**
341
+ * Load removal lists from all module sources in the bmad directory.
342
+ * Each module can have an optional removals.txt listing entries to remove.
343
+ * @param {string} bmadDir - BMAD installation directory
344
+ * @returns {Set<string>} Set of entries to remove
345
+ */
346
+ async loadRemovalLists(bmadDir) {
347
+ const removals = new Set();
348
+ const { getProjectRoot } = require('../project-root');
349
+
350
+ // Read project-level removals.txt (covers core and bmm)
351
+ const projectRemovalsPath = path.join(getProjectRoot(), 'removals.txt');
352
+ await this._readRemovalFile(projectRemovalsPath, removals);
353
+
354
+ // Read per-module removals.txt from installed module directories
355
+ try {
356
+ const entries = await fs.readdir(bmadDir);
357
+ for (const entry of entries) {
358
+ if (entry.startsWith('_')) continue;
359
+ const removalPath = path.join(bmadDir, entry, 'removals.txt');
360
+ await this._readRemovalFile(removalPath, removals);
361
+ }
362
+ } catch {
363
+ // bmadDir may not exist yet on fresh install
364
+ }
365
+
366
+ return removals;
367
+ }
368
+
369
+ /**
370
+ * Read a removals.txt file and add entries to the set
371
+ * @param {string} filePath - Path to removals.txt
372
+ * @param {Set<string>} removals - Set to add entries to
373
+ */
374
+ async _readRemovalFile(filePath, removals) {
375
+ try {
376
+ if (await fs.pathExists(filePath)) {
377
+ const content = await fs.readFile(filePath, 'utf8');
378
+ for (const line of content.split('\n')) {
379
+ const trimmed = line.trim();
380
+ if (trimmed && !trimmed.startsWith('#')) {
381
+ removals.add(trimmed);
382
+ }
383
+ }
384
+ }
385
+ } catch {
386
+ // Optional file — ignore errors
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Cleanup a specific target directory.
392
+ * When removalSet is provided, only removes entries in that set.
393
+ * When removalSet is null (legacy dirs), removes all bmad-prefixed entries.
290
394
  * @param {string} projectDir - Project directory
291
395
  * @param {string} targetDir - Target directory to clean
396
+ * @param {Object} options - Cleanup options
397
+ * @param {Set<string>|null} removalSet - Entries to remove, or null for legacy prefix matching
292
398
  */
293
- async cleanupTarget(projectDir, targetDir, options = {}) {
399
+ async cleanupTarget(projectDir, targetDir, options = {}, removalSet = new Set()) {
294
400
  const targetPath = path.join(projectDir, targetDir);
295
401
 
296
402
  if (!(await fs.pathExists(targetPath))) {
297
403
  return;
298
404
  }
299
405
 
300
- // Remove all bmad* files
406
+ if (removalSet && removalSet.size === 0) {
407
+ return;
408
+ }
409
+
301
410
  let entries;
302
411
  try {
303
412
  entries = await fs.readdir(targetPath);
304
413
  } catch {
305
- // Directory exists but can't be read - skip cleanup
306
414
  return;
307
415
  }
308
416
 
@@ -313,23 +421,26 @@ class ConfigDrivenIdeSetup {
313
421
  let removedCount = 0;
314
422
 
315
423
  for (const entry of entries) {
316
- if (!entry || typeof entry !== 'string') {
317
- continue;
318
- }
319
- if (entry.startsWith('bmad') && !entry.startsWith('bmad-os-')) {
320
- const entryPath = path.join(targetPath, entry);
424
+ if (!entry || typeof entry !== 'string') continue;
425
+
426
+ // Always preserve bmad-os-* utility skills regardless of cleanup mode
427
+ if (entry.startsWith('bmad-os-')) continue;
428
+
429
+ // Surgical removal from set, or legacy prefix matching when set is null
430
+ const shouldRemove = removalSet ? removalSet.has(entry) : entry.startsWith('bmad');
431
+
432
+ if (shouldRemove) {
321
433
  try {
322
- await fs.remove(entryPath);
434
+ await fs.remove(path.join(targetPath, entry));
323
435
  removedCount++;
324
436
  } catch {
325
- // Skip entries that can't be removed (broken symlinks, permission errors)
437
+ // Skip entries that can't be removed
326
438
  }
327
439
  }
328
440
  }
329
441
 
330
- if (removedCount > 0 && !options.silent) {
331
- await prompts.log.message(` Cleaned ${removedCount} BMAD files from ${targetDir}`);
332
- }
442
+ // Only log cleanup when it's not a routine reinstall (legacy dir cleanup or actual removals)
443
+ // Suppress for current target_dir since it's always cleaned before a fresh write
333
444
 
334
445
  // Remove empty directory after cleanup
335
446
  if (removedCount > 0) {
@@ -339,7 +450,7 @@ class ConfigDrivenIdeSetup {
339
450
  await fs.remove(targetPath);
340
451
  }
341
452
  } catch {
342
- // Directory may already be gone or in use — skip
453
+ // Directory may already be gone or in use
343
454
  }
344
455
  }
345
456
  }
@@ -54,19 +54,4 @@ function getArtifactType(manifest, filename) {
54
54
  return null;
55
55
  }
56
56
 
57
- /**
58
- * Get the install_to_bmad flag for a specific file from a loaded skill manifest.
59
- * @param {Object|null} manifest - Loaded manifest (from loadSkillManifest)
60
- * @param {string} filename - Source filename to look up
61
- * @returns {boolean} install_to_bmad value (defaults to true)
62
- */
63
- function getInstallToBmad(manifest, filename) {
64
- if (!manifest) return true;
65
- // Single-entry manifest applies to all files in the directory
66
- if (manifest.__single) return manifest.__single.install_to_bmad !== false;
67
- // Multi-entry: look up by filename directly
68
- if (manifest[filename]) return manifest[filename].install_to_bmad !== false;
69
- return true;
70
- }
71
-
72
- module.exports = { loadSkillManifest, getCanonicalId, getArtifactType, getInstallToBmad };
57
+ module.exports = { loadSkillManifest, getCanonicalId, getArtifactType };