From db7f96dd933295a3f9fe9d7e166883f3de2cefe6 Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Wed, 12 Aug 2026 20:34:47 -0700 Subject: [PATCH] feat(installer): make compatibility shims optional (#2728) --- docs/how-to/install-bmad.md | 7 +- package.json | 2 +- .../bmad-generate-project-context/SKILL.md | 2 + .../bmad-create-architecture/SKILL.md | 2 + .../v6-shims/bmad-create-prd/SKILL.md | 2 + .../v6-shims/bmad-create-story/SKILL.md | 2 + .../v6-shims/bmad-dev-auto/SKILL.md | 2 + .../v6-shims/bmad-dev-story/SKILL.md | 2 + .../v6-shims/bmad-document-project/SKILL.md | 2 + .../v6-shims/bmad-domain-research/SKILL.md | 2 + .../v6-shims/bmad-edit-prd/SKILL.md | 2 + .../v6-shims/bmad-market-research/SKILL.md | 2 + .../v6-shims/bmad-quick-dev/SKILL.md | 2 + .../v6-shims/bmad-sprint-status/SKILL.md | 2 + .../v6-shims/bmad-technical-research/SKILL.md | 2 + .../v6-shims/bmad-validate-prd/SKILL.md | 2 + .../bmad-editorial-review-prose/SKILL.md | 2 + .../bmad-editorial-review-structure/SKILL.md | 2 + .../v6-shims/bmad-editorial-review/SKILL.md | 2 + .../bmad-review-adversarial-general/SKILL.md | 2 + .../bmad-review-edge-case-hunter/SKILL.md | 2 + .../bmad-review-verification-gap/SKILL.md | 2 + test/test-shim-policy.js | 159 ++++++++++++++++++ tools/installer/commands/install.js | 2 + tools/installer/core/config.js | 3 + tools/installer/core/installer.js | 26 ++- tools/installer/core/manifest-generator.js | 6 + tools/installer/core/manifest.js | 6 + tools/installer/core/shim-policy.js | 91 ++++++++++ tools/installer/modules/official-modules.js | 44 ++++- tools/installer/ui.js | 47 +++++- 31 files changed, 423 insertions(+), 10 deletions(-) create mode 100644 test/test-shim-policy.js create mode 100644 tools/installer/core/shim-policy.js diff --git a/docs/how-to/install-bmad.md b/docs/how-to/install-bmad.md index e96b53aa1..a54c83396 100644 --- a/docs/how-to/install-bmad.md +++ b/docs/how-to/install-bmad.md @@ -28,16 +28,19 @@ Use `npx bmad-method install` to set up BMad in your project. One command handle npx bmad-method install ``` -The interactive flow asks you five things: +The interactive flow asks you six things when the selected modules still ship deprecated compatibility shims: 1. Installation directory (defaults to the current working directory) 2. Which modules to install (checkboxes for core, bmm, bmb, cis, gds, tea) 3. **"Ready to install (all stable)?"** — Yes accepts the latest released tag for every external module 4. Which AI tools/IDEs to integrate with (claude-code, cursor, and others) 5. Per-module config (name, language, output folder) +6. Whether to install deprecated compatibility shim skills (defaults to No on a new installation) Accept the defaults and you land on the latest stable release of every module, configured for your chosen tool. +Existing installations keep their current shim choice during Quick Update. Modify Install lets you change it explicitly. Once a release no longer contains any shims, the installer stops asking and removes previously installed shims through its normal update cleanup. + :::tip[Just want the newest prerelease?] ```bash @@ -126,6 +129,8 @@ Under `--yes`, patch and minor upgrades apply automatically. Majors stay frozen | `--list-tools` | Print all supported tool/IDE IDs (with target directories) and exit. | | `--action ` | `install`, `update`, or `quick-update`. Defaults based on existing install state. | | `--custom-source ` | Install custom modules from Git URLs or local paths | +| `--shims` | Install deprecated compatibility shim skills when selected modules still provide them | +| `--no-shims` | Explicitly omit deprecated compatibility shim skills; on an existing install this removes them during update | | `--channel ` | Apply to all externals (aliased as `--all-stable` / `--all-next`) | | `--all-stable` | Alias for `--channel=stable` | | `--all-next` | Alias for `--channel=next` | diff --git a/package.json b/package.json index 160534570..5208957de 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "rebundle": "node tools/installer/bundlers/bundle-web.js rebundle", "test": "npm run test:refs && npm run test:install && npm run test:urls && npm run test:site-url && npm run test:channels && npm run test:renderer && npm run test:retrospective && npm run test:sprint-planning && npm run test:skills && npm run lint && npm run lint:md && npm run format:check", "test:channels": "node test/test-installer-channels.js", - "test:install": "node test/test-installation-components.js", + "test:install": "node test/test-installation-components.js && node test/test-shim-policy.js", "test:refs": "node test/test-file-refs-csv.js", "test:renderer": "uv run --python 3.11 python -m unittest src/scripts/tests/test_config_utils.py src/scripts/tests/test_resolve_config.py src/scripts/tests/test_resolve_customization.py && node test/test-build-auto-renderer.js", "test:retrospective": "uv run --python 3.11 src/bmm-skills/ship/bmad-retrospective/scripts/tests/test_git_evidence.py && uv run --python 3.11 src/bmm-skills/ship/bmad-retrospective/scripts/tests/test_sprint_status.py", diff --git a/src/bmm-skills/plan/bmad-generate-project-context/SKILL.md b/src/bmm-skills/plan/bmad-generate-project-context/SKILL.md index 05ecccef5..caa3539fb 100644 --- a/src/bmm-skills/plan/bmad-generate-project-context/SKILL.md +++ b/src/bmm-skills/plan/bmad-generate-project-context/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-generate-project-context description: 'Deprecated — forwards to bmad-project-context. Use when the user says "generate project context" or "create project context"' +metadata: + lifecycle: shim --- # DEPRECATED — forwards to bmad-project-context diff --git a/src/bmm-skills/v6-shims/bmad-create-architecture/SKILL.md b/src/bmm-skills/v6-shims/bmad-create-architecture/SKILL.md index 0f962f4df..9dc36091e 100644 --- a/src/bmm-skills/v6-shims/bmad-create-architecture/SKILL.md +++ b/src/bmm-skills/v6-shims/bmad-create-architecture/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-create-architecture description: 'Deprecated — forwards to bmad-architecture (create intent)' +metadata: + lifecycle: shim --- # DEPRECATED — forwards to bmad-architecture (create intent) diff --git a/src/bmm-skills/v6-shims/bmad-create-prd/SKILL.md b/src/bmm-skills/v6-shims/bmad-create-prd/SKILL.md index 3d55e529a..a3a5b794a 100644 --- a/src/bmm-skills/v6-shims/bmad-create-prd/SKILL.md +++ b/src/bmm-skills/v6-shims/bmad-create-prd/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-create-prd description: 'Deprecated — forwards to bmad-prd (create intent)' +metadata: + lifecycle: shim --- # DEPRECATED — forwards to bmad-prd (create intent) diff --git a/src/bmm-skills/v6-shims/bmad-create-story/SKILL.md b/src/bmm-skills/v6-shims/bmad-create-story/SKILL.md index a67ca412e..afe75dab5 100644 --- a/src/bmm-skills/v6-shims/bmad-create-story/SKILL.md +++ b/src/bmm-skills/v6-shims/bmad-create-story/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-create-story description: "Deprecated: `bmad-build` is now the official implementation method. Only use this when explicitly invoked by name" +metadata: + lifecycle: shim --- # Create Story Workflow diff --git a/src/bmm-skills/v6-shims/bmad-dev-auto/SKILL.md b/src/bmm-skills/v6-shims/bmad-dev-auto/SKILL.md index 0ba18b8a3..8aa3a4833 100644 --- a/src/bmm-skills/v6-shims/bmad-dev-auto/SKILL.md +++ b/src/bmm-skills/v6-shims/bmad-dev-auto/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-dev-auto description: "Deprecated: forwards to bmad-build-auto. Do not use unless invoked by name" +metadata: + lifecycle: shim --- # Deprecated Build Auto Alias diff --git a/src/bmm-skills/v6-shims/bmad-dev-story/SKILL.md b/src/bmm-skills/v6-shims/bmad-dev-story/SKILL.md index 0419ee895..b5255167a 100644 --- a/src/bmm-skills/v6-shims/bmad-dev-story/SKILL.md +++ b/src/bmm-skills/v6-shims/bmad-dev-story/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-dev-story description: "Deprecated: `bmad-build` is now the official implementation method. Only use this when explicitly invoked by name" +metadata: + lifecycle: shim --- # Dev Story Workflow diff --git a/src/bmm-skills/v6-shims/bmad-document-project/SKILL.md b/src/bmm-skills/v6-shims/bmad-document-project/SKILL.md index bfe766570..cc9967b63 100644 --- a/src/bmm-skills/v6-shims/bmad-document-project/SKILL.md +++ b/src/bmm-skills/v6-shims/bmad-document-project/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-document-project description: 'Deprecated — forwards to bmad-project-context. Use when the user says "document this project" or "generate project docs"' +metadata: + lifecycle: shim --- # DEPRECATED — forwards to bmad-project-context diff --git a/src/bmm-skills/v6-shims/bmad-domain-research/SKILL.md b/src/bmm-skills/v6-shims/bmad-domain-research/SKILL.md index fc5d0344e..fae037c5b 100644 --- a/src/bmm-skills/v6-shims/bmad-domain-research/SKILL.md +++ b/src/bmm-skills/v6-shims/bmad-domain-research/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-domain-research description: 'Deprecated — forwards to bmad-deep-recon (domain type)' +metadata: + lifecycle: shim --- # DEPRECATED — forwards to bmad-deep-recon (domain type) diff --git a/src/bmm-skills/v6-shims/bmad-edit-prd/SKILL.md b/src/bmm-skills/v6-shims/bmad-edit-prd/SKILL.md index 99aed319c..e340494c5 100644 --- a/src/bmm-skills/v6-shims/bmad-edit-prd/SKILL.md +++ b/src/bmm-skills/v6-shims/bmad-edit-prd/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-edit-prd description: 'Deprecated — forwards to bmad-prd (update intent)' +metadata: + lifecycle: shim --- # DEPRECATED — forwards to bmad-prd (update intent) diff --git a/src/bmm-skills/v6-shims/bmad-market-research/SKILL.md b/src/bmm-skills/v6-shims/bmad-market-research/SKILL.md index b9b630fef..1800495de 100644 --- a/src/bmm-skills/v6-shims/bmad-market-research/SKILL.md +++ b/src/bmm-skills/v6-shims/bmad-market-research/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-market-research description: 'Deprecated — forwards to bmad-deep-recon (market type)' +metadata: + lifecycle: shim --- # DEPRECATED — forwards to bmad-deep-recon (market type) diff --git a/src/bmm-skills/v6-shims/bmad-quick-dev/SKILL.md b/src/bmm-skills/v6-shims/bmad-quick-dev/SKILL.md index e4dc333d9..bcad7d1cb 100644 --- a/src/bmm-skills/v6-shims/bmad-quick-dev/SKILL.md +++ b/src/bmm-skills/v6-shims/bmad-quick-dev/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-quick-dev description: "Deprecated: forwards to bmad-build. Do not use unless invoked by name" +metadata: + lifecycle: shim --- # Deprecated Build Alias diff --git a/src/bmm-skills/v6-shims/bmad-sprint-status/SKILL.md b/src/bmm-skills/v6-shims/bmad-sprint-status/SKILL.md index 3a5112640..53729b07e 100644 --- a/src/bmm-skills/v6-shims/bmad-sprint-status/SKILL.md +++ b/src/bmm-skills/v6-shims/bmad-sprint-status/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-sprint-status description: 'Deprecated — forwards to bmad-sprint-planning (status view)' +metadata: + lifecycle: shim --- # DEPRECATED — forwards to bmad-sprint-planning (status view) diff --git a/src/bmm-skills/v6-shims/bmad-technical-research/SKILL.md b/src/bmm-skills/v6-shims/bmad-technical-research/SKILL.md index c029499e1..eb5ea933b 100644 --- a/src/bmm-skills/v6-shims/bmad-technical-research/SKILL.md +++ b/src/bmm-skills/v6-shims/bmad-technical-research/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-technical-research description: 'Deprecated — forwards to bmad-deep-recon (technical type)' +metadata: + lifecycle: shim --- # DEPRECATED — forwards to bmad-deep-recon (technical type) diff --git a/src/bmm-skills/v6-shims/bmad-validate-prd/SKILL.md b/src/bmm-skills/v6-shims/bmad-validate-prd/SKILL.md index b2b27c987..0d20e8cd2 100644 --- a/src/bmm-skills/v6-shims/bmad-validate-prd/SKILL.md +++ b/src/bmm-skills/v6-shims/bmad-validate-prd/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-validate-prd description: 'Deprecated — forwards to bmad-prd (validate intent)' +metadata: + lifecycle: shim --- # DEPRECATED — forwards to bmad-prd (validate intent) diff --git a/src/core-skills/v6-shims/bmad-editorial-review-prose/SKILL.md b/src/core-skills/v6-shims/bmad-editorial-review-prose/SKILL.md index 9028e311f..70394b926 100644 --- a/src/core-skills/v6-shims/bmad-editorial-review-prose/SKILL.md +++ b/src/core-skills/v6-shims/bmad-editorial-review-prose/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-editorial-review-prose description: 'Deprecated — forwards to bmad-review' +metadata: + lifecycle: shim --- Merged into `bmad-review`. Invoke the `bmad-review` skill on the same content with only the `prose` lens, passing through the same inputs and any `also_consider` areas. Present the findings in the legacy shape: a three-column markdown table `| Original Text | Revised Text | Changes |` — no Pass column, no preamble above the table. If no issues are found, output exactly: `No editorial issues identified`. diff --git a/src/core-skills/v6-shims/bmad-editorial-review-structure/SKILL.md b/src/core-skills/v6-shims/bmad-editorial-review-structure/SKILL.md index 7a5cd61e7..13b5895f8 100644 --- a/src/core-skills/v6-shims/bmad-editorial-review-structure/SKILL.md +++ b/src/core-skills/v6-shims/bmad-editorial-review-structure/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-editorial-review-structure description: 'Deprecated — forwards to bmad-review' +metadata: + lifecycle: shim --- Merged into `bmad-review`. Invoke the `bmad-review` skill on the same content with only the `structure` lens, passing through the same inputs and any `also_consider` areas. Present the findings in the legacy report shape: a `## Document Summary` block (purpose, audience, reader type, structure model, current length), a `## Recommendations` list of numbered `[CUT/MERGE/MOVE/CONDENSE/QUESTION/PRESERVE]` entries each with rationale and word impact, and a closing `## Summary` (total recommendations, estimated reduction) — not the findings table. If no structural issues are found, output exactly: `No substantive changes recommended`. diff --git a/src/core-skills/v6-shims/bmad-editorial-review/SKILL.md b/src/core-skills/v6-shims/bmad-editorial-review/SKILL.md index 10c0f6de2..fd0c472d8 100644 --- a/src/core-skills/v6-shims/bmad-editorial-review/SKILL.md +++ b/src/core-skills/v6-shims/bmad-editorial-review/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-editorial-review description: 'Deprecated — forwards to bmad-review' +metadata: + lifecycle: shim --- Merged into `bmad-review`. Invoke the `bmad-review` skill on the same content with the `structure` and `prose` lenses — both, structure first, so prose runs on top of the structure findings — unless the caller asked for a structure-only or prose-only review, in which case pass only that lens. Pass through any `also_consider` areas, and forward this skill's resolved `[workflow]` fields as pre-resolved values — but only those that resolved to something, since an empty value here means no legacy override exists and bmad-review's own default should stand: `reader_type`, `style_guide`, `review_guidance`, `output_preferences`, `persistent_facts`, `activation_steps_prepend`, `activation_steps_append`, `on_complete`, and `review_output_path` as the report path. Present the findings in the legacy shape: the two-pass findings table `| Pass | Original Text | Revised Text | Changes |` with the purpose/audience read above it and, when the structure pass ran, the reduction summary below it — and no other lens's output. diff --git a/src/core-skills/v6-shims/bmad-review-adversarial-general/SKILL.md b/src/core-skills/v6-shims/bmad-review-adversarial-general/SKILL.md index 1d1b1804e..935425968 100644 --- a/src/core-skills/v6-shims/bmad-review-adversarial-general/SKILL.md +++ b/src/core-skills/v6-shims/bmad-review-adversarial-general/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-review-adversarial-general description: 'Deprecated — forwards to bmad-review' +metadata: + lifecycle: shim --- Merged into `bmad-review`. Invoke the `bmad-review` skill on the same content with only the `adversarial` lens, passing through any `also_consider` areas. Present the findings as a Markdown list — descriptions only, no severity, priority, or ranking; no JSON block. diff --git a/src/core-skills/v6-shims/bmad-review-edge-case-hunter/SKILL.md b/src/core-skills/v6-shims/bmad-review-edge-case-hunter/SKILL.md index 786cea2a8..a0fcd21cf 100644 --- a/src/core-skills/v6-shims/bmad-review-edge-case-hunter/SKILL.md +++ b/src/core-skills/v6-shims/bmad-review-edge-case-hunter/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-review-edge-case-hunter description: 'Deprecated — forwards to bmad-review' +metadata: + lifecycle: shim --- Merged into `bmad-review`. Invoke the `bmad-review` skill on the same content with only the `edge-case-hunter` lens, passing through any `also_consider` areas. Output ONLY the raw findings JSON array in the legacy shape: the four standard fields (plus `kind`/`confidence` on deletion findings), no `lens` field, no markdown wrapping, no extra text. `[]` is valid when nothing is found. diff --git a/src/core-skills/v6-shims/bmad-review-verification-gap/SKILL.md b/src/core-skills/v6-shims/bmad-review-verification-gap/SKILL.md index 4ff1e11c4..12456dbb3 100644 --- a/src/core-skills/v6-shims/bmad-review-verification-gap/SKILL.md +++ b/src/core-skills/v6-shims/bmad-review-verification-gap/SKILL.md @@ -1,6 +1,8 @@ --- name: bmad-review-verification-gap description: 'Deprecated — forwards to bmad-review' +metadata: + lifecycle: shim --- Merged into `bmad-review`. Invoke the `bmad-review` skill on the same content with only the `verification-gap` lens. Present the markdown rendering only (no JSON block), listing any `gap_shape: "other"` findings under an `## Other findings` heading. When there are no findings at all, output exactly this single line: `No verification gaps found.` diff --git a/test/test-shim-policy.js b/test/test-shim-policy.js new file mode 100644 index 000000000..092734881 --- /dev/null +++ b/test/test-shim-policy.js @@ -0,0 +1,159 @@ +const assert = require('node:assert/strict'); +const os = require('node:os'); +const path = require('node:path'); +const yaml = require('yaml'); +const fs = require('../tools/installer/fs-native'); +const prompts = require('../tools/installer/prompts'); +const { ManifestGenerator } = require('../tools/installer/core/manifest-generator'); +const { OfficialModules } = require('../tools/installer/modules/official-modules'); +const { UI } = require('../tools/installer/ui'); +const { discoverShims, inferShimPreference } = require('../tools/installer/core/shim-policy'); + +async function writeSkill(directory, name, lifecycle) { + await fs.ensureDir(directory); + const frontmatter = { + name, + description: lifecycle === 'shim' ? 'Deprecated compatibility entry' : 'Use when testing active behavior', + }; + if (lifecycle) frontmatter.metadata = { lifecycle }; + await fs.writeFile(path.join(directory, 'SKILL.md'), `---\n${yaml.stringify(frontmatter)}---\n\nTest skill.\n`); +} + +async function run() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-shim-policy-')); + + try { + const source = path.join(root, 'source'); + await writeSkill(path.join(source, 'plan', 'active-skill'), 'active-skill'); + await writeSkill(path.join(source, 'plan', 'legacy-name'), 'legacy-name', 'shim'); + + const discovered = await discoverShims(source); + assert.deepEqual( + discovered.map((entry) => entry.id), + ['legacy-name'], + 'shim discovery uses lifecycle metadata rather than directory naming', + ); + + assert.equal(inferShimPreference({ availableShims: discovered, existing: false }), false, 'fresh installations default shims off'); + assert.equal( + inferShimPreference({ availableShims: discovered, installedSkillIds: new Set(['legacy-name']), existing: true }), + true, + 'legacy installations containing a shim preserve it by default', + ); + assert.equal( + inferShimPreference({ + requested: false, + persisted: true, + availableShims: discovered, + installedSkillIds: new Set(['legacy-name']), + existing: true, + }), + false, + 'an explicit user choice disables previously installed shims', + ); + assert.equal( + inferShimPreference({ persisted: true, availableShims: [], installedSkillIds: new Set(['legacy-name']), existing: true }), + false, + 'a shimless incoming release ignores the old enabled preference', + ); + assert.equal( + inferShimPreference({ persisted: false, availableShims: [], installedSkillIds: new Set(), existing: true }), + false, + 'a shimless incoming release also updates installations that already omitted shims', + ); + + const moduleInstaller = new OfficialModules(); + const withoutShims = path.join(root, 'without-shims'); + await moduleInstaller.copyModuleWithFiltering(source, withoutShims, null, {}, { installShims: false }); + assert.equal(await fs.pathExists(path.join(withoutShims, 'plan', 'active-skill', 'SKILL.md')), true); + assert.equal(await fs.pathExists(path.join(withoutShims, 'plan', 'legacy-name')), false); + + const withShims = path.join(root, 'with-shims'); + await moduleInstaller.copyModuleWithFiltering(source, withShims, null, {}, { installShims: true }); + assert.equal(await fs.pathExists(path.join(withShims, 'plan', 'active-skill', 'SKILL.md')), true); + assert.equal(await fs.pathExists(path.join(withShims, 'plan', 'legacy-name', 'SKILL.md')), true); + + const rootShimSource = path.join(root, 'root-shim-source'); + await writeSkill(rootShimSource, 'root-shim', 'shim'); + const rootShimTarget = path.join(root, 'root-shim-target'); + await moduleInstaller.copyModuleWithFiltering(rootShimSource, rootShimTarget, null, {}, { installShims: false }); + assert.equal(await fs.pathExists(path.join(rootShimTarget, 'SKILL.md')), false, 'standalone plugin shims are filtered at their root'); + + const originalDiscover = OfficialModules.prototype.discoverShims; + const originalConfirm = prompts.confirm; + let confirmCalls = 0; + try { + OfficialModules.prototype.discoverShims = async () => []; + prompts.confirm = async () => { + confirmCalls++; + return true; + }; + const selection = await new UI()._selectShimPreference({ + selectedModules: ['core'], + bmadDir: path.join(root, '_bmad'), + existing: true, + options: {}, + channelOptions: null, + }); + assert.equal(selection, undefined); + assert.equal(confirmCalls, 0, 'no shim prompt is shown when the incoming release has no shims'); + + const legacyBmadDir = path.join(root, 'legacy', '_bmad'); + await fs.ensureDir(path.join(legacyBmadDir, '_config')); + await fs.writeFile( + path.join(legacyBmadDir, '_config', 'manifest.yaml'), + yaml.stringify({ installation: { version: '6.11.0' }, modules: [], ides: [] }), + ); + await fs.writeFile( + path.join(legacyBmadDir, '_config', 'skill-manifest.csv'), + 'canonicalId,name,description,module,path\n"legacy-name","legacy-name","Deprecated","core","_bmad/core/legacy-name/SKILL.md"\n', + ); + + OfficialModules.prototype.discoverShims = async () => [{ id: 'legacy-name' }]; + let offeredDefault; + prompts.confirm = async (question) => { + offeredDefault = question.default; + return false; + }; + const disabledByUser = await new UI()._selectShimPreference({ + selectedModules: ['core'], + bmadDir: legacyBmadDir, + existing: true, + options: {}, + channelOptions: null, + }); + assert.equal(offeredDefault, true, 'an existing installation containing shims keeps them enabled by default'); + assert.equal(disabledByUser, false, 'the interactive result records the user explicitly disabling shims'); + } finally { + OfficialModules.prototype.discoverShims = originalDiscover; + prompts.confirm = originalConfirm; + } + + const manifestDir = path.join(root, 'manifest', '_config'); + await fs.ensureDir(manifestDir); + const generator = new ManifestGenerator(); + generator.modules = []; + generator.selectedIdes = []; + generator.bmadDir = path.dirname(manifestDir); + generator.shimsAvailable = false; + generator.installShims = true; + await generator.writeMainManifest(manifestDir); + const shimlessManifest = yaml.parse(await fs.readFile(path.join(manifestDir, 'manifest.yaml'), 'utf8')); + assert.equal('installShims' in shimlessManifest.installation, false, 'dead shim preference is omitted from a shimless manifest'); + + generator.shimsAvailable = true; + generator.installShims = false; + await generator.writeMainManifest(manifestDir); + const availableManifest = yaml.parse(await fs.readFile(path.join(manifestDir, 'manifest.yaml'), 'utf8')); + assert.equal(availableManifest.installation.installShims, false, 'the active user preference is persisted while shims exist'); + + console.log('Shim installation policy tests passed.'); + } finally { + await fs.remove(root).catch(() => {}); + } +} + +run().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tools/installer/commands/install.js b/tools/installer/commands/install.js index 42f6213de..5222fb156 100644 --- a/tools/installer/commands/install.js +++ b/tools/installer/commands/install.js @@ -34,6 +34,8 @@ module.exports = { ['--document-output-language ', 'Language for document output (default: English)'], ['--output-folder ', 'Output folder path relative to project root (default: _bmad-output)'], ['--custom-source ', 'Comma-separated Git URLs or local paths to install custom modules from'], + ['--shims', 'Install deprecated compatibility shim skills when the selected modules provide them'], + ['--no-shims', 'Do not install deprecated compatibility shim skills'], ['-y, --yes', 'Accept all defaults and skip prompts where possible'], [ '--channel ', diff --git a/tools/installer/core/config.js b/tools/installer/core/config.js index 39617de4c..e56553839 100644 --- a/tools/installer/core/config.js +++ b/tools/installer/core/config.js @@ -15,6 +15,7 @@ class Config { quickUpdate, channelOptions, setOverrides, + installShims, }) { this.directory = directory; this.modules = Object.freeze([...modules]); @@ -32,6 +33,7 @@ class Config { // Intentionally NOT integrated with the prompt/template/schema flow; see // `tools/installer/set-overrides.js` for the rationale and tradeoffs. this.setOverrides = setOverrides || {}; + this.installShims = typeof installShims === 'boolean' ? installShims : undefined; Object.freeze(this); } @@ -58,6 +60,7 @@ class Config { quickUpdate: userInput._quickUpdate || false, channelOptions: userInput.channelOptions || null, setOverrides: userInput.setOverrides || {}, + installShims: userInput.installShims, }); } diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index 8f6be10cd..b0430c974 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -13,6 +13,7 @@ const { InstallPaths } = require('./install-paths'); const { ExternalModuleManager } = require('../modules/external-manager'); const { resolveModuleVersion } = require('../modules/version-resolver'); const { MODULE_HELP_CSV_HEADER } = require('../modules/module-help-schema'); +const { inferShimPreference, readInstalledSkillIds } = require('./shim-policy'); const { ExistingInstall } = require('./existing-install'); const { warnPreNativeSkillsLegacy } = require('./legacy-warnings'); @@ -42,6 +43,21 @@ class Installer { const paths = await InstallPaths.create(config); const officialModules = await OfficialModules.build(config, paths); const existingInstall = await ExistingInstall.detect(paths.bmadDir); + const availableShims = await officialModules.discoverShims(config.modules, { + channelOptions: config.channelOptions, + }); + const previousManifest = existingInstall.installed ? await this.manifest.read(paths.bmadDir) : null; + const installedSkillIds = existingInstall.installed ? await readInstalledSkillIds(paths.bmadDir) : new Set(); + const shimPolicy = { + available: availableShims.length > 0, + install: inferShimPreference({ + requested: config.installShims, + persisted: previousManifest?.installShims, + availableShims, + installedSkillIds, + existing: existingInstall.installed, + }), + }; try { await warnPreNativeSkillsLegacy({ @@ -92,6 +108,7 @@ class Installer { addResult, officialModules, previousSkillManifestRows, + shimPolicy, ); await this._setupIdes(config, allModules, paths, addResult, previousSkillIds); @@ -225,9 +242,11 @@ class Installer { addResult, officialModules, previousSkillManifestRows = [], + shimPolicy = null, ) { const isQuickUpdate = config.isQuickUpdate(); const moduleConfigs = officialModules.moduleConfigs; + const resolvedShimPolicy = shimPolicy || { available: false, install: false }; const dirResults = { createdDirs: [], movedDirs: [], createdWdsFolders: [] }; @@ -251,6 +270,7 @@ class Installer { await this._installOfficialModules(config, paths, officialModuleIds, addResult, isQuickUpdate, officialModules, { message, installedModuleNames, + shimPolicy: resolvedShimPolicy, }); return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`; @@ -325,6 +345,8 @@ class Installer { ides: config.ides || [], preservedModules: modulesForCsvPreserve, moduleConfigs, + installShims: resolvedShimPolicy.install, + shimsAvailable: resolvedShimPolicy.available, }); await this._appendPreservedSkillManifestRows(paths.bmadDir, previousSkillManifestRows, preservedModules); @@ -727,7 +749,7 @@ class Installer { * @param {Object} ctx - Shared context: { message, installedModuleNames } */ async _installOfficialModules(config, paths, officialModuleIds, addResult, isQuickUpdate, officialModules, ctx) { - const { message, installedModuleNames } = ctx; + const { message, installedModuleNames, shimPolicy } = ctx; const { CustomModuleManager } = require('../modules/custom-module-manager'); for (const moduleName of officialModuleIds) { @@ -749,6 +771,7 @@ class Installer { installer: this, silent: true, channelOptions: config.channelOptions, + installShims: shimPolicy.install, }, ); @@ -1500,6 +1523,7 @@ class Installer { // (`applySetOverrides`) runs at the end of quick-update too. The // installer.install path applies them after writeCentralConfig. setOverrides: config.setOverrides || {}, + installShims: config.installShims, actionType: 'install', _quickUpdate: true, _preserveModules: skippedModules, diff --git a/tools/installer/core/manifest-generator.js b/tools/installer/core/manifest-generator.js index 039b55f01..e30a676e9 100644 --- a/tools/installer/core/manifest-generator.js +++ b/tools/installer/core/manifest-generator.js @@ -58,6 +58,8 @@ class ManifestGenerator { this.bmadDir = bmadDir; this.bmadFolderName = path.basename(bmadDir); // Get the actual folder name (e.g., '_bmad' or 'bmad') this.allInstalledFiles = installedFiles; + this.installShims = options.installShims === true; + this.shimsAvailable = options.shimsAvailable === true; if (!Object.prototype.hasOwnProperty.call(options, 'ides')) { throw new Error('ManifestGenerator requires `options.ides` to be provided – installer should supply the selected IDEs array.'); @@ -378,6 +380,10 @@ class ManifestGenerator { ides: this.selectedIdes, }; + if (this.shimsAvailable) { + manifest.installation.installShims = this.installShims; + } + // Clean the manifest to remove any non-serializable values const cleanManifest = structuredClone(manifest); diff --git a/tools/installer/core/manifest.js b/tools/installer/core/manifest.js index a7931bf32..5a8477b75 100644 --- a/tools/installer/core/manifest.js +++ b/tools/installer/core/manifest.js @@ -61,6 +61,10 @@ class Manifest { ides: data.ides || [], }; + if (typeof data.installShims === 'boolean') { + manifestData.installation.installShims = data.installShims; + } + // Write YAML manifest // Clean the manifest data to remove any non-serializable values const cleanManifestData = structuredClone(manifestData); @@ -106,6 +110,7 @@ class Manifest { version: manifestData.installation?.version, installDate: manifestData.installation?.installDate, lastUpdated: manifestData.installation?.lastUpdated, + installShims: manifestData.installation?.installShims, modules: moduleNames, // Simple array of module names for backward compatibility modulesDetailed: hasDetailedModules ? modules : null, // New detailed format ides: manifestData.ides || [], @@ -153,6 +158,7 @@ class Manifest { version: manifest.installation?.version, installDate: manifest.installation?.installDate, lastUpdated: manifest.installation?.lastUpdated, + installShims: manifest.installation?.installShims, modules: moduleNames, modulesDetailed: hasDetailedModules ? modules : null, ides: manifest.ides || [], diff --git a/tools/installer/core/shim-policy.js b/tools/installer/core/shim-policy.js new file mode 100644 index 000000000..dc6283dda --- /dev/null +++ b/tools/installer/core/shim-policy.js @@ -0,0 +1,91 @@ +const path = require('node:path'); +const fs = require('../fs-native'); +const yaml = require('yaml'); +const csv = require('csv-parse/sync'); + +function parseSkillMetadata(content) { + const normalized = content.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); + const match = normalized.match(/^---\n([\s\S]*?)\n---/); + if (!match) return null; + + try { + const frontmatter = yaml.parse(match[1]); + return frontmatter && typeof frontmatter === 'object' ? frontmatter : null; + } catch { + return null; + } +} + +function isShimSkill(metadata) { + return metadata?.metadata?.lifecycle === 'shim'; +} + +async function discoverShims(modulePath) { + const shims = []; + + const walk = async (dir) => { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + const skillFile = path.join(dir, 'SKILL.md'); + if (await fs.pathExists(skillFile)) { + const metadata = parseSkillMetadata(await fs.readFile(skillFile, 'utf8')); + if (isShimSkill(metadata)) { + shims.push({ + id: metadata.name || path.basename(dir), + directory: dir, + relativeDirectory: path.relative(modulePath, dir), + }); + } + return; + } + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_')) continue; + await walk(path.join(dir, entry.name)); + } + }; + + await walk(modulePath); + return shims; +} + +async function readInstalledSkillIds(bmadDir) { + const ids = new Set(); + const manifestPath = path.join(bmadDir, '_config', 'skill-manifest.csv'); + if (!(await fs.pathExists(manifestPath))) return ids; + + try { + const content = await fs.readFile(manifestPath, '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 { + // A missing or unreadable legacy manifest means there is no reliable + // evidence that compatibility shims were installed. + } + + return ids; +} + +function inferShimPreference({ requested, persisted, availableShims = [], installedSkillIds = new Set(), existing = false }) { + if (availableShims.length === 0) return false; + if (typeof requested === 'boolean') return requested; + if (typeof persisted === 'boolean') return persisted; + if (!existing) return false; + + return availableShims.some((shim) => installedSkillIds.has(shim.id)); +} + +module.exports = { + discoverShims, + inferShimPreference, + isShimSkill, + parseSkillMetadata, + readInstalledSkillIds, +}; diff --git a/tools/installer/modules/official-modules.js b/tools/installer/modules/official-modules.js index 995b30bac..eea6dd88a 100644 --- a/tools/installer/modules/official-modules.js +++ b/tools/installer/modules/official-modules.js @@ -5,6 +5,7 @@ const prompts = require('../prompts'); const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root'); const { CLIUtils } = require('../cli-utils'); const { ExternalModuleManager } = require('./external-manager'); +const { discoverShims } = require('../core/shim-policy'); class OfficialModules { constructor(options = {}) { @@ -131,6 +132,23 @@ class OfficialModules { return { modules }; } + async discoverShims(moduleNames = [], options = {}) { + const shims = []; + + for (const moduleName of moduleNames) { + const sourcePath = await this.findModuleSource(moduleName, { + silent: true, + channelOptions: options.channelOptions, + }); + if (!sourcePath) continue; + + const moduleShims = await discoverShims(sourcePath); + for (const shim of moduleShims) shims.push({ ...shim, module: moduleName }); + } + + return shims; + } + /** * Get module information from a module path * @param {string} modulePath - Path to the module directory @@ -296,9 +314,13 @@ class OfficialModules { `Aborting to avoid a partial install with no skills.`, ); } - await this._copyResolvedSkills(pluginResolution, targetPath, fileTrackingCallback, options.moduleConfig); + await this._copyResolvedSkills(pluginResolution, targetPath, fileTrackingCallback, options.moduleConfig, { + installShims: options.installShims, + }); } else { - await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig); + await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig, { + installShims: options.installShims, + }); } if (!options.skipModuleInstaller) { @@ -336,7 +358,7 @@ class OfficialModules { * @param {Function} fileTrackingCallback - Optional callback to track installed files * @param {Object} moduleConfig - Module configuration passed to copy filtering */ - async _copyResolvedSkills(resolved, targetPath, fileTrackingCallback = null, moduleConfig = {}) { + async _copyResolvedSkills(resolved, targetPath, fileTrackingCallback = null, moduleConfig = {}, installOptions = {}) { await fs.ensureDir(targetPath); // Copy each skill directory, flattened by leaf name. Leaf names must be @@ -354,7 +376,7 @@ class OfficialModules { } seenLeaves.set(skillDirName, skillPath); const skillTarget = path.join(targetPath, skillDirName); - await this.copyModuleWithFiltering(skillPath, skillTarget, fileTrackingCallback, moduleConfig); + await this.copyModuleWithFiltering(skillPath, skillTarget, fileTrackingCallback, moduleConfig, installOptions); } // Place module-help.csv at the module root. @@ -385,7 +407,9 @@ class OfficialModules { await fs.remove(targetPath); } - await this._copyResolvedSkills(resolved, targetPath, fileTrackingCallback, options.moduleConfig); + await this._copyResolvedSkills(resolved, targetPath, fileTrackingCallback, options.moduleConfig, { + installShims: options.installShims, + }); // Create directories declared in module.yaml (strategies 1-4 may have these) if (!options.skipModuleInstaller) { @@ -522,11 +546,19 @@ class OfficialModules { * @param {Function} fileTrackingCallback - Optional callback to track installed files * @param {Object} moduleConfig - Module configuration with conditional flags */ - async copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback = null, moduleConfig = {}) { + async copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback = null, moduleConfig = {}, installOptions = {}) { // Get all files in source const sourceFiles = await this.getFileList(sourcePath); + const shimDirectories = + installOptions.installShims === false + ? (await discoverShims(sourcePath)).map((shim) => shim.relativeDirectory.split(path.sep).join('/')) + : []; for (const file of sourceFiles) { + const normalizedFile = file.split(path.sep).join('/'); + if (shimDirectories.some((shimDir) => shimDir === '' || normalizedFile === shimDir || normalizedFile.startsWith(`${shimDir}/`))) { + continue; + } // Skip sub-modules directory - these are IDE-specific and handled separately if (file.startsWith('sub-modules/')) { continue; diff --git a/tools/installer/ui.js b/tools/installer/ui.js index eacd95781..9a63bc9ee 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -17,6 +17,7 @@ const { const channelResolver = require('./modules/channel-resolver'); const prompts = require('./prompts'); const { parseSetEntries } = require('./set-overrides'); +const { inferShimPreference, readInstalledSkillIds } = require('./core/shim-policy'); const manifest = new Manifest(); @@ -110,6 +111,33 @@ async function getModuleVersion(moduleCode, { repoUrl = null, registryDefault = * UI utilities for the installer */ class UI { + async _selectShimPreference({ selectedModules, bmadDir, existing, options, channelOptions }) { + const { OfficialModules } = require('./modules/official-modules'); + const officialModules = new OfficialModules({ channelOptions }); + const availableShims = await officialModules.discoverShims(selectedModules, { channelOptions }); + + // The prompt is capability-driven. Once the last shim leaves the incoming + // release this becomes an ordinary empty set, regardless of old state. + if (availableShims.length === 0) return; + + const previousManifest = existing ? await manifest.read(bmadDir) : null; + const installedSkillIds = existing ? await readInstalledSkillIds(bmadDir) : new Set(); + const currentValue = inferShimPreference({ + requested: options.shims, + persisted: previousManifest?.installShims, + availableShims, + installedSkillIds, + existing, + }); + + if (typeof options.shims === 'boolean' || options.yes) return currentValue; + + return prompts.confirm({ + message: `Install ${availableShims.length} deprecated compatibility shim skill(s)?`, + default: currentValue, + }); + } + /** * Warn once for each selected module the registry marks deprecated. * @@ -302,7 +330,7 @@ class UI { throw new Error('No valid actions available for this installation'); } const hasQuickUpdate = choices.some((c) => c.value === 'quick-update'); - const needsFullUpdate = !!options.customSource; + const needsFullUpdate = !!options.customSource || typeof options.shims === 'boolean'; actionType = hasQuickUpdate && !needsFullUpdate ? 'quick-update' : (choices.find((c) => c.value === 'update') || choices[0]).value; await prompts.log.info(`Non-interactive mode (--yes): defaulting to ${actionType}`); } else { @@ -322,6 +350,7 @@ class UI { actionType: 'quick-update', directory: confirmedDirectory, skipPrompts: options.yes || false, + installShims: options.shims, }; } @@ -406,6 +435,13 @@ class UI { ...options, channelOptions, }); + const installShims = await this._selectShimPreference({ + selectedModules, + bmadDir, + existing: true, + options, + channelOptions, + }); // Warn about --pin/--next flags that refer to modules the user didn't // select, or that target bundled modules (core/bmm) where channel @@ -432,6 +468,7 @@ class UI { skipPrompts: options.yes || false, channelOptions, _preserveModules: preservedModules, + installShims, }; } } @@ -487,6 +524,13 @@ class UI { ...options, channelOptions, }); + const installShims = await this._selectShimPreference({ + selectedModules, + bmadDir, + existing: false, + options, + channelOptions, + }); // Warn about --pin/--next flags that refer to modules the user didn't // select, or that target bundled modules (core/bmm) where channel @@ -512,6 +556,7 @@ class UI { setOverrides, skipPrompts: options.yes || false, channelOptions, + installShims, }; }