feat(installer): make compatibility shims optional (#2728)

This commit is contained in:
Alex Verkhovsky
2026-08-12 20:34:47 -07:00
committed by GitHub
parent b70486b9bd
commit db7f96dd93
31 changed files with 423 additions and 10 deletions
+6 -1
View File
@@ -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 <type>` | `install`, `update`, or `quick-update`. Defaults based on existing install state. |
| `--custom-source <urls>` | 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 <stable\|next>` | Apply to all externals (aliased as `--all-stable` / `--all-next`) |
| `--all-stable` | Alias for `--channel=stable` |
| `--all-next` | Alias for `--channel=next` |
+1 -1
View File
@@ -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",
@@ -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
@@ -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)
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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)
@@ -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)
@@ -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
@@ -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)
@@ -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)
@@ -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)
@@ -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`.
@@ -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`.
@@ -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.
@@ -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.
@@ -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.
@@ -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.`
+159
View File
@@ -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;
});
+2
View File
@@ -34,6 +34,8 @@ module.exports = {
['--document-output-language <lang>', 'Language for document output (default: English)'],
['--output-folder <path>', 'Output folder path relative to project root (default: _bmad-output)'],
['--custom-source <sources>', '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 <channel>',
+3
View File
@@ -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,
});
}
+25 -1
View File
@@ -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,
@@ -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);
+6
View File
@@ -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 || [],
+91
View File
@@ -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,
};
+38 -6
View File
@@ -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;
+46 -1
View File
@@ -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,
};
}