feat(installer): recognize new module system (plugin.json#bmad)
Add Strategy 0 to PluginResolver: detect a .claude-plugin/plugin.json
carrying a bmad{} block at the module root and resolve+validate it via
the bmad-module skill's own libs (new bmad-module-lib bridge), so the
installer and runtime skill agree on what a module is. Every resolved
module now carries a format discriminator ('plugin-json' | 'legacy').
OfficialModules routes 'plugin-json' resolutions through a new
_installFromPluginJson path that reuses the skill's copy-plan/flatten/
rewrite/atomic-swap libs, producing an on-disk layout byte-identical to
a bmad-module install while leaving legacy installs untouched. Direct
mode in ui.js prefers a root plugin.json#bmad manifest over SKILL.md
scanning. Adds Test Suite 45 covering detection, legacy fall-through,
and end-to-end install.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8d628b5f1b
commit
9bc76acdcb
|
|
@ -3238,6 +3238,126 @@ async function runTests() {
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 45: New module system (plugin.json#bmad) in the installer
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 45: New module system + legacy detection in custom-module install${colors.reset}\n`);
|
||||||
|
try {
|
||||||
|
const { PluginResolver } = require('../tools/installer/modules/plugin-resolver');
|
||||||
|
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
|
||||||
|
const { readPluginManifest } = require('../tools/installer/modules/bmad-module-lib');
|
||||||
|
const acmeDevlog = path.resolve(__dirname, '../src/core-skills/bmad-module/tests/fixtures/examples/comprehensive/acme-devlog');
|
||||||
|
|
||||||
|
// ---- readPluginManifest: detects bmad block, ignores non-bmad plugin.json ----
|
||||||
|
{
|
||||||
|
const m = await readPluginManifest(acmeDevlog);
|
||||||
|
assert(m && m.bmad && m.bmad.code === 'devlog', 'readPluginManifest reads .claude-plugin/plugin.json#bmad');
|
||||||
|
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-nobmad-'));
|
||||||
|
await fs.ensureDir(path.join(tmp, '.claude-plugin'));
|
||||||
|
await fs.writeFile(path.join(tmp, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'x' }), 'utf8');
|
||||||
|
const none = await readPluginManifest(tmp);
|
||||||
|
assert(none === null, 'readPluginManifest returns null for plugin.json without a bmad block');
|
||||||
|
await fs.remove(tmp).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Strategy 0: plugin.json#bmad resolves as format 'plugin-json' ----
|
||||||
|
{
|
||||||
|
const resolved = await new PluginResolver().resolve(acmeDevlog, { name: 'acme-devlog', source: '.', skills: [] });
|
||||||
|
assert(resolved.length === 1, 'Strategy 0 resolves a single new-system module');
|
||||||
|
const r = resolved[0];
|
||||||
|
assert(r.format === 'plugin-json', 'new-system module carries format: plugin-json');
|
||||||
|
assert(r.code === 'devlog', 'new-system module code comes from bmad.code (not plugin name)');
|
||||||
|
assert(r.version === '0.4.0' && !!r.manifest && !!r.sourceDir, 'new-system module carries version + manifest + sourceDir');
|
||||||
|
assert(!!r.moduleYamlPath && r.moduleYamlPath.endsWith('assets/module.yaml'), 'moduleYamlPath points at source moduleDefinition');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Legacy structure still resolves as format 'legacy' ----
|
||||||
|
{
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-legacy-'));
|
||||||
|
const skill = path.join(tmp, 'skills', 'foo-setup');
|
||||||
|
await fs.ensureDir(path.join(skill, 'assets'));
|
||||||
|
await fs.writeFile(path.join(skill, 'SKILL.md'), '---\nname: foo-setup\ndescription: x\n---\n', 'utf8');
|
||||||
|
await fs.writeFile(path.join(skill, 'assets', 'module.yaml'), 'code: foo\nname: Foo\ndescription: legacy\n', 'utf8');
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(skill, 'assets', 'module-help.csv'),
|
||||||
|
'module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs\n',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const resolved = await new PluginResolver().resolve(tmp, { name: 'foo', skills: ['./skills/foo-setup'] });
|
||||||
|
assert(
|
||||||
|
resolved.length === 1 && resolved[0].format === 'legacy' && resolved[0].strategy === 2,
|
||||||
|
'legacy setup-skill resolves as format: legacy (strategy 2)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// A plugin.json WITHOUT a bmad block must NOT hijack legacy detection.
|
||||||
|
await fs.ensureDir(path.join(tmp, '.claude-plugin'));
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tmp, '.claude-plugin', 'plugin.json'),
|
||||||
|
JSON.stringify({ name: 'foo', skills: ['./skills/foo-setup'] }),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const resolved2 = await new PluginResolver().resolve(tmp, { name: 'foo', source: '.', skills: ['./skills/foo-setup'] });
|
||||||
|
assert(resolved2[0].format === 'legacy', 'plugin.json without bmad block falls through to legacy strategies');
|
||||||
|
await fs.remove(tmp).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- End-to-end install of a new-system module via OfficialModules ----
|
||||||
|
{
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-pj-install-'));
|
||||||
|
const bmadDir = path.join(tmp, '_bmad');
|
||||||
|
await fs.ensureDir(path.join(bmadDir, '_config'));
|
||||||
|
await fs.writeFile(path.join(bmadDir, '_config', 'manifest.yaml'), 'modules: []\n', 'utf8');
|
||||||
|
|
||||||
|
const [resolved] = await new PluginResolver().resolve(acmeDevlog, { name: 'acme-devlog', source: '.', skills: [] });
|
||||||
|
resolved.localPath = acmeDevlog;
|
||||||
|
CustomModuleManager._resolutionCache.set(resolved.code, resolved);
|
||||||
|
|
||||||
|
const om = new OfficialModules();
|
||||||
|
const tracked = [];
|
||||||
|
const result = await om.install('devlog', bmadDir, (p) => tracked.push(p), { skipModuleInstaller: true, moduleConfig: {} });
|
||||||
|
CustomModuleManager._resolutionCache.delete('devlog');
|
||||||
|
|
||||||
|
assert(result.success === true && result.module === 'devlog', 'install() routes plugin-json resolution and succeeds');
|
||||||
|
assert(
|
||||||
|
await fs.pathExists(path.join(bmadDir, 'devlog', 'module.yaml')),
|
||||||
|
'install flattens moduleDefinition → _bmad/devlog/module.yaml',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
await fs.pathExists(path.join(bmadDir, 'devlog', 'module-help.csv')),
|
||||||
|
'install flattens moduleHelpCsv → _bmad/devlog/module-help.csv',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
await fs.pathExists(path.join(bmadDir, 'devlog', '.claude-plugin', 'plugin.json')),
|
||||||
|
'install keeps .claude-plugin/plugin.json',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
await fs.pathExists(path.join(bmadDir, 'devlog', 'skills', 'bmad-devlog-setup', 'SKILL.md')),
|
||||||
|
'install copies declared skills under skills/',
|
||||||
|
);
|
||||||
|
assert(!(await fs.pathExists(path.join(bmadDir, 'devlog', 'tests'))), 'install honors bmad.install.ignore (tests/ excluded)');
|
||||||
|
|
||||||
|
const rewritten = JSON.parse(await fs.readFile(path.join(bmadDir, 'devlog', '.claude-plugin', 'plugin.json'), 'utf8'));
|
||||||
|
assert(rewritten.bmad.moduleDefinition === './module.yaml', 'plugin.json is rewritten to canonical paths');
|
||||||
|
assert(tracked.some((p) => p.endsWith('plugin.json')) && tracked.length > 5, 'install tracks copied files for the files manifest');
|
||||||
|
|
||||||
|
const yamlLib = require('yaml');
|
||||||
|
const mani = yamlLib.parse(await fs.readFile(path.join(bmadDir, '_config', 'manifest.yaml'), 'utf8'));
|
||||||
|
const entry = mani.modules.find((m) => m.name === 'devlog');
|
||||||
|
assert(
|
||||||
|
entry && entry.source === 'custom' && entry.localPath === acmeDevlog,
|
||||||
|
'install registers the module in manifest.yaml (source: custom)',
|
||||||
|
);
|
||||||
|
await fs.remove(tmp).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${colors.red}Test Suite 45 setup failed: ${error.message}${colors.reset}`);
|
||||||
|
console.log(error.stack);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Summary
|
// Summary
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
const path = require('node:path');
|
||||||
|
const { pathToFileURL } = require('node:url');
|
||||||
|
const { getSourcePath } = require('../project-root');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge to the bmad-module skill's ESM libraries.
|
||||||
|
*
|
||||||
|
* The installer is CommonJS; the new module system's install logic lives as
|
||||||
|
* zero-dependency ESM under `src/core-skills/bmad-module/scripts/lib/`. Rather
|
||||||
|
* than reimplement (and risk drifting from) the spec, the installer reuses the
|
||||||
|
* exact same functions the runtime `bmad-module` skill uses to validate a
|
||||||
|
* `.claude-plugin/plugin.json#bmad` manifest and lay a module out on disk.
|
||||||
|
*
|
||||||
|
* This file is the single place that knows the `src/` layout. It lazily
|
||||||
|
* `import()`s each lib once and caches the namespace. `pathToFileURL` makes the
|
||||||
|
* dynamic-import specifier valid on Windows (bare absolute paths are rejected
|
||||||
|
* there).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LIB_REL = ['core-skills', 'bmad-module', 'scripts', 'lib'];
|
||||||
|
|
||||||
|
function libUrl(file) {
|
||||||
|
return pathToFileURL(getSourcePath(...LIB_REL, file)).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _cache = new Map();
|
||||||
|
async function load(file) {
|
||||||
|
if (!_cache.has(file)) {
|
||||||
|
_cache.set(file, await import(libUrl(file)));
|
||||||
|
}
|
||||||
|
return _cache.get(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the subset of skill libs the installer needs to detect, validate, copy,
|
||||||
|
* and finalize a new-system (`plugin.json#bmad`) module. Returns a flat object
|
||||||
|
* of the named exports.
|
||||||
|
*/
|
||||||
|
async function loadBmadModuleLib() {
|
||||||
|
const [pluginJson, installPlan, fsSafe, npmDeps] = await Promise.all([
|
||||||
|
load('plugin-json.mjs'),
|
||||||
|
load('install-plan.mjs'),
|
||||||
|
load('fs-safe.mjs'),
|
||||||
|
load('npm-deps.mjs'),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
// plugin-json.mjs
|
||||||
|
readAndValidateManifest: pluginJson.readAndValidateManifest,
|
||||||
|
RESERVED_CODES: pluginJson.RESERVED_CODES,
|
||||||
|
CODE_REGEX: pluginJson.CODE_REGEX,
|
||||||
|
// install-plan.mjs
|
||||||
|
readUserIgnores: installPlan.readUserIgnores,
|
||||||
|
buildIgnoreMatcher: installPlan.buildIgnoreMatcher,
|
||||||
|
buildCopyPlan: installPlan.buildCopyPlan,
|
||||||
|
rewriteManifestPaths: installPlan.rewriteManifestPaths,
|
||||||
|
validateDeclaredPaths: installPlan.validateDeclaredPaths,
|
||||||
|
// fs-safe.mjs
|
||||||
|
stageCopyPlan: fsSafe.stageCopyPlan,
|
||||||
|
atomicSwapDir: fsSafe.atomicSwapDir,
|
||||||
|
// npm-deps.mjs
|
||||||
|
installModuleDeps: npmDeps.installModuleDeps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read `.claude-plugin/plugin.json` from a directory and return the parsed
|
||||||
|
* object only when it carries a `bmad` block (i.e. it's a new-system module
|
||||||
|
* manifest). Returns null when the file is absent, unparseable, or lacks a
|
||||||
|
* `bmad` key — callers then fall back to the legacy marketplace.json path.
|
||||||
|
* No validation here; resolution validates via readAndValidateManifest.
|
||||||
|
*
|
||||||
|
* @param {string} dir - Absolute path to a candidate module root
|
||||||
|
* @returns {Promise<Object|null>}
|
||||||
|
*/
|
||||||
|
async function readPluginManifest(dir) {
|
||||||
|
const fs = require('../fs-native');
|
||||||
|
const manifestPath = path.join(dir, '.claude-plugin', 'plugin.json');
|
||||||
|
if (!(await fs.pathExists(manifestPath))) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
||||||
|
if (parsed && typeof parsed === 'object' && parsed.bmad && typeof parsed.bmad === 'object') {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Malformed JSON — treat as "not a new-system module" and let the legacy
|
||||||
|
// resolver (or validateDeclaredPaths at install time) surface the problem.
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { loadBmadModuleLib, readPluginManifest };
|
||||||
|
|
@ -5,6 +5,7 @@ const prompts = require('../prompts');
|
||||||
const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root');
|
const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root');
|
||||||
const { CLIUtils } = require('../cli-utils');
|
const { CLIUtils } = require('../cli-utils');
|
||||||
const { ExternalModuleManager } = require('./external-manager');
|
const { ExternalModuleManager } = require('./external-manager');
|
||||||
|
const { loadBmadModuleLib } = require('./bmad-module-lib');
|
||||||
|
|
||||||
class OfficialModules {
|
class OfficialModules {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
|
|
@ -312,6 +313,14 @@ class OfficialModules {
|
||||||
* @param {Object} options - Installation options
|
* @param {Object} options - Installation options
|
||||||
*/
|
*/
|
||||||
async installFromResolution(resolved, bmadDir, fileTrackingCallback = null, options = {}) {
|
async installFromResolution(resolved, bmadDir, fileTrackingCallback = null, options = {}) {
|
||||||
|
// New module system: a .claude-plugin/plugin.json#bmad manifest drives the
|
||||||
|
// copy. Reuse the bmad-module skill's libs so the on-disk result matches a
|
||||||
|
// skill-driven install exactly. Legacy (marketplace.json + module.yaml)
|
||||||
|
// resolutions fall through to the original path below.
|
||||||
|
if (resolved.format === 'plugin-json') {
|
||||||
|
return this._installFromPluginJson(resolved, bmadDir, fileTrackingCallback, options);
|
||||||
|
}
|
||||||
|
|
||||||
const targetPath = path.join(bmadDir, resolved.code);
|
const targetPath = path.join(bmadDir, resolved.code);
|
||||||
|
|
||||||
if (await fs.pathExists(targetPath)) {
|
if (await fs.pathExists(targetPath)) {
|
||||||
|
|
@ -378,6 +387,140 @@ class OfficialModules {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a new-module-system module (resolved from .claude-plugin/plugin.json#bmad).
|
||||||
|
*
|
||||||
|
* Reuses the bmad-module skill's ESM libs (via bmad-module-lib) to build the
|
||||||
|
* curated copy plan, flatten moduleDefinition/moduleHelpCsv to the module root,
|
||||||
|
* rewrite plugin.json to canonical paths, and install npm deps in place — so the
|
||||||
|
* on-disk layout is byte-identical to a `bmad-module install`. Downstream installer
|
||||||
|
* steps (config generation, directory creation, help-catalog merge, manifest/skill
|
||||||
|
* discovery, IDE distribution) then treat it exactly like any other module, since
|
||||||
|
* module.yaml + module-help.csv now sit at the module root.
|
||||||
|
*
|
||||||
|
* @param {Object} resolved - ResolvedModule with format 'plugin-json' (sourceDir, manifest)
|
||||||
|
* @param {string} bmadDir - Target _bmad directory
|
||||||
|
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
||||||
|
* @param {Object} options - Installation options
|
||||||
|
*/
|
||||||
|
async _installFromPluginJson(resolved, bmadDir, fileTrackingCallback = null, options = {}) {
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const lib = await loadBmadModuleLib();
|
||||||
|
const { sourceDir, manifest, code } = { sourceDir: resolved.sourceDir, manifest: resolved.manifest, code: resolved.code };
|
||||||
|
const targetPath = path.join(bmadDir, code);
|
||||||
|
|
||||||
|
// Validate declared paths (throws on traversal / escapes) and build the plan.
|
||||||
|
lib.validateDeclaredPaths(sourceDir, manifest);
|
||||||
|
const userIgnores = await lib.readUserIgnores(sourceDir, manifest);
|
||||||
|
const matchIgnore = lib.buildIgnoreMatcher(userIgnores);
|
||||||
|
const { plan } = await lib.buildCopyPlan(sourceDir, manifest, matchIgnore);
|
||||||
|
const rewrittenManifestJson = lib.rewriteManifestPaths(manifest);
|
||||||
|
|
||||||
|
// Stage on the same filesystem as the target, then atomically swap in.
|
||||||
|
const stagedDir = path.join(bmadDir, `.${code}.bmad-stage-${crypto.randomBytes(6).toString('hex')}`);
|
||||||
|
try {
|
||||||
|
await lib.stageCopyPlan(sourceDir, stagedDir, plan, {
|
||||||
|
'.claude-plugin/plugin.json': rewrittenManifestJson,
|
||||||
|
});
|
||||||
|
await lib.atomicSwapDir(stagedDir, targetPath);
|
||||||
|
} catch (error) {
|
||||||
|
await fs.remove(stagedDir).catch(() => {});
|
||||||
|
throw new Error(`Failed to install ${code}: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track every installed file for the files manifest.
|
||||||
|
if (fileTrackingCallback) {
|
||||||
|
fileTrackingCallback(path.join(targetPath, '.claude-plugin', 'plugin.json'));
|
||||||
|
for (const { destRel } of plan) {
|
||||||
|
fileTrackingCallback(path.join(targetPath, destRel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// npm deps in place (honors bmad.install.skipNpm; non-fatal).
|
||||||
|
try {
|
||||||
|
const dep = await lib.installModuleDeps(targetPath, manifest);
|
||||||
|
if (dep.ran && dep.ok) await prompts.log.info(` Installed npm dependencies for ${code}`);
|
||||||
|
else if (dep.ran && !dep.ok) await prompts.log.warn(` npm install failed for ${code}: ${dep.error}`);
|
||||||
|
} catch (error) {
|
||||||
|
await prompts.log.warn(` npm install failed for ${code}: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn about unmet declared module dependencies (best-effort, non-fatal).
|
||||||
|
await this._warnUnmetModuleDeps(manifest, bmadDir, code);
|
||||||
|
|
||||||
|
// Create directories declared in module.yaml (now flattened at module root).
|
||||||
|
if (!options.skipModuleInstaller) {
|
||||||
|
await this.createModuleDirectories(code, bmadDir, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register in the manifest. Keep the installer's existing custom tagging so
|
||||||
|
// its own update / quick-update path is unaffected — the new-module-system
|
||||||
|
// compliance is about on-disk layout + which manifest format is recognized,
|
||||||
|
// not the _bmad/_config/manifest.yaml source tag.
|
||||||
|
const { Manifest } = require('../core/manifest');
|
||||||
|
const manifestObj = new Manifest();
|
||||||
|
const hasGitClone = !!resolved.repoUrl;
|
||||||
|
const manifestEntry = {
|
||||||
|
version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null),
|
||||||
|
source: 'custom',
|
||||||
|
npmPackage: null,
|
||||||
|
repoUrl: resolved.repoUrl || null,
|
||||||
|
};
|
||||||
|
if (hasGitClone) {
|
||||||
|
manifestEntry.channel = resolved.cloneRef ? 'pinned' : 'next';
|
||||||
|
if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
|
||||||
|
if (resolved.rawInput) manifestEntry.rawSource = resolved.rawInput;
|
||||||
|
}
|
||||||
|
if (resolved.localPath) manifestEntry.localPath = resolved.localPath;
|
||||||
|
await manifestObj.addModule(bmadDir, code, manifestEntry);
|
||||||
|
|
||||||
|
// Surface install hints the way the bmad-module skill does.
|
||||||
|
const claudeOnly = [];
|
||||||
|
if (manifest.hooks) claudeOnly.push('hooks');
|
||||||
|
if (manifest.mcpServers) claudeOnly.push('mcpServers');
|
||||||
|
if (manifest.lspServers) claudeOnly.push('lspServers');
|
||||||
|
if (Array.isArray(manifest.agents) && manifest.agents.length > 0) claudeOnly.push('agents');
|
||||||
|
if (Array.isArray(manifest.commands) && manifest.commands.length > 0) claudeOnly.push('commands');
|
||||||
|
if (claudeOnly.length > 0) {
|
||||||
|
await prompts.log.info(
|
||||||
|
` ${code}: ${claudeOnly.join(', ')} are Claude Code plugin surfaces — copied but not auto-activated. Wire them up via Claude Code's plugin manager.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (manifest.bmad?.install?.postInstallSkill) {
|
||||||
|
await prompts.log.info(` ${code}: next, run the \`${manifest.bmad.install.postInstallSkill}\` skill to finish setup.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
module: code,
|
||||||
|
path: targetPath,
|
||||||
|
versionInfo: { version: manifestEntry.version || '' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort, non-fatal warning for declared bmad.dependencies.modules that
|
||||||
|
* are not present on disk and not selected for install in this run.
|
||||||
|
* @param {Object} manifest - The module's plugin.json manifest
|
||||||
|
* @param {string} bmadDir - Target _bmad directory
|
||||||
|
* @param {string} code - The installing module's code (skip self-reference)
|
||||||
|
*/
|
||||||
|
async _warnUnmetModuleDeps(manifest, bmadDir, code) {
|
||||||
|
const deps = manifest?.bmad?.dependencies?.modules;
|
||||||
|
if (!Array.isArray(deps) || deps.length === 0) return;
|
||||||
|
|
||||||
|
const { CustomModuleManager } = require('./custom-module-manager');
|
||||||
|
for (const dep of deps) {
|
||||||
|
const depCode = typeof dep === 'string' ? dep : dep?.code;
|
||||||
|
if (!depCode || depCode === code) continue;
|
||||||
|
const onDisk = await fs.pathExists(path.join(bmadDir, depCode));
|
||||||
|
const selectedThisRun = CustomModuleManager._resolutionCache.has(depCode);
|
||||||
|
if (onDisk || selectedThisRun) continue;
|
||||||
|
const versionStr = typeof dep === 'object' && dep.version ? ` (${dep.version})` : '';
|
||||||
|
await prompts.log.warn(` ${code} declares a dependency on module '${depCode}'${versionStr} — ensure it is installed.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing module
|
* Update an existing module
|
||||||
* @param {string} moduleName - Name of the module to update
|
* @param {string} moduleName - Name of the module to update
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,25 @@ const fs = require('../fs-native');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const { MODULE_HELP_CSV_HEADER } = require('./module-help-schema');
|
const { MODULE_HELP_CSV_HEADER } = require('./module-help-schema');
|
||||||
|
const { loadBmadModuleLib, readPluginManifest } = require('./bmad-module-lib');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves how to install a plugin from marketplace.json by analyzing
|
* Resolves how to install a plugin by analyzing its on-disk shape.
|
||||||
* where module.yaml and module-help.csv live relative to the listed skills.
|
|
||||||
*
|
*
|
||||||
* Five strategies, tried in order:
|
* Strategy 0 (new module system), tried first:
|
||||||
|
* 0. A `.claude-plugin/plugin.json` carrying a `bmad{}` block at the module
|
||||||
|
* root. Resolved + validated via the bmad-module skill's own libs so the
|
||||||
|
* installer and the runtime skill agree on what a module is.
|
||||||
|
*
|
||||||
|
* Legacy strategies (marketplace.json + module.yaml), tried in order:
|
||||||
* 1. Root module files at the common parent of all skills
|
* 1. Root module files at the common parent of all skills
|
||||||
* 2. A -setup skill with assets/module.yaml + assets/module-help.csv
|
* 2. A -setup skill with assets/module.yaml + assets/module-help.csv
|
||||||
* 3. Single standalone skill with both files in its assets/
|
* 3. Single standalone skill with both files in its assets/
|
||||||
* 4. Multiple standalone skills, each with both files in assets/
|
* 4. Multiple standalone skills, each with both files in assets/
|
||||||
* 5. Fallback: synthesize from marketplace.json + SKILL.md frontmatter
|
* 5. Fallback: synthesize from marketplace.json + SKILL.md frontmatter
|
||||||
|
*
|
||||||
|
* Every resolved module carries a `format` discriminator ('plugin-json' or
|
||||||
|
* 'legacy') so the installer can pick the matching install path.
|
||||||
*/
|
*/
|
||||||
class PluginResolver {
|
class PluginResolver {
|
||||||
/**
|
/**
|
||||||
|
|
@ -27,6 +35,13 @@ class PluginResolver {
|
||||||
* @returns {Promise<ResolvedModule[]>} Array of resolved module definitions
|
* @returns {Promise<ResolvedModule[]>} Array of resolved module definitions
|
||||||
*/
|
*/
|
||||||
async resolve(repoPath, plugin) {
|
async resolve(repoPath, plugin) {
|
||||||
|
// Strategy 0: new module system. Tried before everything else — and before
|
||||||
|
// the no-skills early return below — because new-system modules declare
|
||||||
|
// their skills inside plugin.json rather than via marketplace.json's
|
||||||
|
// skills[] array.
|
||||||
|
const pluginJsonResult = await this._tryPluginJson(repoPath, plugin);
|
||||||
|
if (pluginJsonResult) return pluginJsonResult;
|
||||||
|
|
||||||
const skillRelPaths = plugin.skills || [];
|
const skillRelPaths = plugin.skills || [];
|
||||||
|
|
||||||
// No skills array: legacy behavior - caller should use existing findModuleSource
|
// No skills array: legacy behavior - caller should use existing findModuleSource
|
||||||
|
|
@ -64,6 +79,84 @@ class PluginResolver {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Strategy 0: New Module System (plugin.json#bmad) ───────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect a `.claude-plugin/plugin.json` carrying a `bmad{}` block at the
|
||||||
|
* module root and resolve it via the bmad-module skill's own validator.
|
||||||
|
*
|
||||||
|
* The module root is `plugin.source` (relative to the repo) when given, else
|
||||||
|
* the repo root. Returns a single-element array of a new-format ResolvedModule
|
||||||
|
* on success, or null to fall through to the legacy strategies. Throws when a
|
||||||
|
* plugin.json#bmad is present but invalid — a malformed new-system manifest
|
||||||
|
* should surface, not silently install via the legacy synthesizer.
|
||||||
|
*/
|
||||||
|
async _tryPluginJson(repoPath, plugin) {
|
||||||
|
const repoRoot = path.resolve(repoPath);
|
||||||
|
let moduleRoot = repoRoot;
|
||||||
|
if (plugin.source) {
|
||||||
|
const normalized = String(plugin.source).replace(/^\.\//, '');
|
||||||
|
const abs = path.resolve(repoPath, normalized);
|
||||||
|
// Guard against path traversal out of the repo root.
|
||||||
|
if (abs !== repoRoot && !abs.startsWith(repoRoot + path.sep)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
moduleRoot = abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawManifest = await readPluginManifest(moduleRoot);
|
||||||
|
if (!rawManifest) return null;
|
||||||
|
|
||||||
|
// Validate with the skill's install-time validator (throws BmadModuleError
|
||||||
|
// with a descriptive .message on a bad manifest).
|
||||||
|
const { readAndValidateManifest } = await loadBmadModuleLib();
|
||||||
|
const manifest = await readAndValidateManifest(moduleRoot);
|
||||||
|
|
||||||
|
// Resolve declared skill dirs to absolute existing paths for display only;
|
||||||
|
// the install copy is plan-driven (buildCopyPlan), not skillPaths-driven.
|
||||||
|
const skillPaths = [];
|
||||||
|
if (Array.isArray(manifest.skills)) {
|
||||||
|
for (const rel of manifest.skills) {
|
||||||
|
if (typeof rel !== 'string') continue;
|
||||||
|
const abs = path.resolve(moduleRoot, rel.replace(/^\.\//, ''));
|
||||||
|
if (abs.startsWith(moduleRoot + path.sep) && (await fs.pathExists(abs))) {
|
||||||
|
skillPaths.push(abs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point moduleYamlPath at the source moduleDefinition so the installer's
|
||||||
|
// source-resolution helpers (findModuleSourceByCode → createModuleDirectories,
|
||||||
|
// resolveInstalledModuleYaml) can read the module's declared `directories`.
|
||||||
|
// Install itself flattens this to `_bmad/<code>/module.yaml` via buildCopyPlan.
|
||||||
|
let moduleYamlPath = null;
|
||||||
|
if (typeof manifest.bmad?.moduleDefinition === 'string') {
|
||||||
|
const abs = path.resolve(moduleRoot, manifest.bmad.moduleDefinition.replace(/^\.\//, ''));
|
||||||
|
if (abs.startsWith(moduleRoot + path.sep) && (await fs.pathExists(abs))) {
|
||||||
|
moduleYamlPath = abs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
code: manifest.bmad.code,
|
||||||
|
name: manifest.displayName || manifest.name,
|
||||||
|
version: manifest.bmad.moduleVersion || manifest.version || null,
|
||||||
|
description: manifest.description || plugin.description || '',
|
||||||
|
format: 'plugin-json',
|
||||||
|
strategy: 'plugin-json',
|
||||||
|
pluginName: plugin.name,
|
||||||
|
sourceDir: moduleRoot,
|
||||||
|
manifest,
|
||||||
|
skillPaths,
|
||||||
|
moduleYamlPath,
|
||||||
|
moduleHelpCsvPath: null,
|
||||||
|
synthesizedModuleYaml: null,
|
||||||
|
synthesizedHelpCsv: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Strategy 1: Root Module Files ──────────────────────────────────────────
|
// ─── Strategy 1: Root Module Files ──────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -87,6 +180,7 @@ class PluginResolver {
|
||||||
name: moduleData.name || plugin.name,
|
name: moduleData.name || plugin.name,
|
||||||
version: plugin.version || moduleData.module_version || null,
|
version: plugin.version || moduleData.module_version || null,
|
||||||
description: moduleData.description || plugin.description || '',
|
description: moduleData.description || plugin.description || '',
|
||||||
|
format: 'legacy',
|
||||||
strategy: 1,
|
strategy: 1,
|
||||||
pluginName: plugin.name,
|
pluginName: plugin.name,
|
||||||
moduleYamlPath,
|
moduleYamlPath,
|
||||||
|
|
@ -124,6 +218,7 @@ class PluginResolver {
|
||||||
name: moduleData.name || plugin.name,
|
name: moduleData.name || plugin.name,
|
||||||
version: plugin.version || moduleData.module_version || null,
|
version: plugin.version || moduleData.module_version || null,
|
||||||
description: moduleData.description || plugin.description || '',
|
description: moduleData.description || plugin.description || '',
|
||||||
|
format: 'legacy',
|
||||||
strategy: 2,
|
strategy: 2,
|
||||||
pluginName: plugin.name,
|
pluginName: plugin.name,
|
||||||
moduleYamlPath,
|
moduleYamlPath,
|
||||||
|
|
@ -163,6 +258,7 @@ class PluginResolver {
|
||||||
name: moduleData.name || plugin.name,
|
name: moduleData.name || plugin.name,
|
||||||
version: plugin.version || moduleData.module_version || null,
|
version: plugin.version || moduleData.module_version || null,
|
||||||
description: moduleData.description || plugin.description || '',
|
description: moduleData.description || plugin.description || '',
|
||||||
|
format: 'legacy',
|
||||||
strategy: 3,
|
strategy: 3,
|
||||||
pluginName: plugin.name,
|
pluginName: plugin.name,
|
||||||
moduleYamlPath,
|
moduleYamlPath,
|
||||||
|
|
@ -201,6 +297,7 @@ class PluginResolver {
|
||||||
name: moduleData.name || path.basename(skillPath),
|
name: moduleData.name || path.basename(skillPath),
|
||||||
version: plugin.version || moduleData.module_version || null,
|
version: plugin.version || moduleData.module_version || null,
|
||||||
description: moduleData.description || '',
|
description: moduleData.description || '',
|
||||||
|
format: 'legacy',
|
||||||
strategy: 4,
|
strategy: 4,
|
||||||
pluginName: plugin.name,
|
pluginName: plugin.name,
|
||||||
moduleYamlPath,
|
moduleYamlPath,
|
||||||
|
|
@ -257,6 +354,7 @@ class PluginResolver {
|
||||||
name: moduleName,
|
name: moduleName,
|
||||||
version: plugin.version || null,
|
version: plugin.version || null,
|
||||||
description: plugin.description || '',
|
description: plugin.description || '',
|
||||||
|
format: 'legacy',
|
||||||
strategy: 5,
|
strategy: 5,
|
||||||
pluginName: plugin.name,
|
pluginName: plugin.name,
|
||||||
moduleYamlPath: null,
|
moduleYamlPath: null,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const {
|
||||||
const channelResolver = require('./modules/channel-resolver');
|
const channelResolver = require('./modules/channel-resolver');
|
||||||
const prompts = require('./prompts');
|
const prompts = require('./prompts');
|
||||||
const { parseSetEntries } = require('./set-overrides');
|
const { parseSetEntries } = require('./set-overrides');
|
||||||
|
const { readPluginManifest } = require('./modules/bmad-module-lib');
|
||||||
|
|
||||||
const manifest = new Manifest();
|
const manifest = new Manifest();
|
||||||
|
|
||||||
|
|
@ -1071,6 +1072,7 @@ class UI {
|
||||||
name: plugin.displayName || plugin.name,
|
name: plugin.displayName || plugin.name,
|
||||||
version: plugin.version,
|
version: plugin.version,
|
||||||
description: plugin.description,
|
description: plugin.description,
|
||||||
|
format: 'legacy',
|
||||||
strategy: 0,
|
strategy: 0,
|
||||||
pluginName: plugin.name,
|
pluginName: plugin.name,
|
||||||
skillPaths: [],
|
skillPaths: [],
|
||||||
|
|
@ -1081,32 +1083,39 @@ class UI {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Direct mode: no marketplace.json, scan directory for skills and resolve
|
// Direct mode: no marketplace.json. Prefer a new-system module manifest
|
||||||
|
// at the root (.claude-plugin/plugin.json#bmad); otherwise scan for
|
||||||
|
// SKILL.md directories (legacy direct mode).
|
||||||
|
const rootManifest = await readPluginManifest(sourceResult.rootDir);
|
||||||
const directPlugin = {
|
const directPlugin = {
|
||||||
name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
|
name: rootManifest?.name || sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
|
||||||
source: '.',
|
source: '.',
|
||||||
skills: [],
|
skills: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Scan for SKILL.md directories to populate skills array
|
if (!rootManifest) {
|
||||||
try {
|
// Scan for SKILL.md directories to populate skills array
|
||||||
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
|
try {
|
||||||
for (const entry of entries) {
|
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
|
||||||
if (entry.isDirectory()) {
|
for (const entry of entries) {
|
||||||
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
|
if (entry.isDirectory()) {
|
||||||
if (await fs.pathExists(skillMd)) {
|
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
|
||||||
directPlugin.skills.push(entry.name);
|
if (await fs.pathExists(skillMd)) {
|
||||||
|
directPlugin.skills.push(entry.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (scanError) {
|
||||||
|
s.error('Failed to scan directory');
|
||||||
|
await prompts.log.error(` ${scanError.message}`);
|
||||||
|
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
} catch (scanError) {
|
|
||||||
s.error('Failed to scan directory');
|
|
||||||
await prompts.log.error(` ${scanError.message}`);
|
|
||||||
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (directPlugin.skills.length > 0) {
|
// New-system modules resolve from plugin.json (skills declared inside it,
|
||||||
|
// so an empty skills[] here is expected); legacy modules need ≥1 skill.
|
||||||
|
if (rootManifest || directPlugin.skills.length > 0) {
|
||||||
try {
|
try {
|
||||||
const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
|
const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
|
||||||
allResolved.push(...resolved);
|
allResolved.push(...resolved);
|
||||||
|
|
@ -1227,32 +1236,36 @@ class UI {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Direct mode: scan for SKILL.md directories
|
// Direct mode: prefer a new-system manifest at the root, else scan for
|
||||||
|
// SKILL.md directories (legacy direct mode).
|
||||||
|
const rootManifest = await readPluginManifest(sourceResult.rootDir);
|
||||||
const directPlugin = {
|
const directPlugin = {
|
||||||
name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
|
name: rootManifest?.name || sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
|
||||||
source: '.',
|
source: '.',
|
||||||
skills: [],
|
skills: [],
|
||||||
};
|
};
|
||||||
try {
|
if (!rootManifest) {
|
||||||
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
|
try {
|
||||||
for (const entry of entries) {
|
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
|
||||||
if (entry.isDirectory()) {
|
for (const entry of entries) {
|
||||||
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
|
if (entry.isDirectory()) {
|
||||||
if (await fs.pathExists(skillMd)) {
|
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
|
||||||
directPlugin.skills.push(entry.name);
|
if (await fs.pathExists(skillMd)) {
|
||||||
|
directPlugin.skills.push(entry.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip unreadable directories
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Skip unreadable directories
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (directPlugin.skills.length > 0) {
|
if (rootManifest || directPlugin.skills.length > 0) {
|
||||||
try {
|
try {
|
||||||
const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
|
const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
|
||||||
allResolved.push(...resolved);
|
allResolved.push(...resolved);
|
||||||
} catch {
|
} catch (resolveError) {
|
||||||
// Skip unresolvable
|
await prompts.log.warn(` Could not resolve ${source}: ${resolveError.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue