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:
pbean 2026-06-01 13:14:14 -07:00
parent 8d628b5f1b
commit 9bc76acdcb
5 changed files with 498 additions and 33 deletions

View File

@ -3238,6 +3238,126 @@ async function runTests() {
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
// ============================================================

View File

@ -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 };

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 { loadBmadModuleLib } = require('./bmad-module-lib');
class OfficialModules {
constructor(options = {}) {
@ -312,6 +313,14 @@ class OfficialModules {
* @param {Object} options - Installation 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);
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
* @param {string} moduleName - Name of the module to update

View File

@ -2,17 +2,25 @@ const fs = require('../fs-native');
const path = require('node:path');
const yaml = require('yaml');
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
* where module.yaml and module-help.csv live relative to the listed skills.
* Resolves how to install a plugin by analyzing its on-disk shape.
*
* 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
* 2. A -setup skill with assets/module.yaml + assets/module-help.csv
* 3. Single standalone skill with both files in its assets/
* 4. Multiple standalone skills, each with both files in assets/
* 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 {
/**
@ -27,6 +35,13 @@ class PluginResolver {
* @returns {Promise<ResolvedModule[]>} Array of resolved module definitions
*/
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 || [];
// No skills array: legacy behavior - caller should use existing findModuleSource
@ -64,6 +79,84 @@ class PluginResolver {
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 ──────────────────────────────────────────
/**
@ -87,6 +180,7 @@ class PluginResolver {
name: moduleData.name || plugin.name,
version: plugin.version || moduleData.module_version || null,
description: moduleData.description || plugin.description || '',
format: 'legacy',
strategy: 1,
pluginName: plugin.name,
moduleYamlPath,
@ -124,6 +218,7 @@ class PluginResolver {
name: moduleData.name || plugin.name,
version: plugin.version || moduleData.module_version || null,
description: moduleData.description || plugin.description || '',
format: 'legacy',
strategy: 2,
pluginName: plugin.name,
moduleYamlPath,
@ -163,6 +258,7 @@ class PluginResolver {
name: moduleData.name || plugin.name,
version: plugin.version || moduleData.module_version || null,
description: moduleData.description || plugin.description || '',
format: 'legacy',
strategy: 3,
pluginName: plugin.name,
moduleYamlPath,
@ -201,6 +297,7 @@ class PluginResolver {
name: moduleData.name || path.basename(skillPath),
version: plugin.version || moduleData.module_version || null,
description: moduleData.description || '',
format: 'legacy',
strategy: 4,
pluginName: plugin.name,
moduleYamlPath,
@ -257,6 +354,7 @@ class PluginResolver {
name: moduleName,
version: plugin.version || null,
description: plugin.description || '',
format: 'legacy',
strategy: 5,
pluginName: plugin.name,
moduleYamlPath: null,

View File

@ -17,6 +17,7 @@ const {
const channelResolver = require('./modules/channel-resolver');
const prompts = require('./prompts');
const { parseSetEntries } = require('./set-overrides');
const { readPluginManifest } = require('./modules/bmad-module-lib');
const manifest = new Manifest();
@ -1071,6 +1072,7 @@ class UI {
name: plugin.displayName || plugin.name,
version: plugin.version,
description: plugin.description,
format: 'legacy',
strategy: 0,
pluginName: plugin.name,
skillPaths: [],
@ -1081,32 +1083,39 @@ class UI {
}
}
} 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 = {
name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
name: rootManifest?.name || sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
source: '.',
skills: [],
};
// Scan for SKILL.md directories to populate skills array
try {
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
if (await fs.pathExists(skillMd)) {
directPlugin.skills.push(entry.name);
if (!rootManifest) {
// Scan for SKILL.md directories to populate skills array
try {
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
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 {
const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
allResolved.push(...resolved);
@ -1227,32 +1236,36 @@ class UI {
continue;
}
} 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 = {
name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
name: rootManifest?.name || sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
source: '.',
skills: [],
};
try {
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
if (await fs.pathExists(skillMd)) {
directPlugin.skills.push(entry.name);
if (!rootManifest) {
try {
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
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 {
const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
allResolved.push(...resolved);
} catch {
// Skip unresolvable
} catch (resolveError) {
await prompts.log.warn(` Could not resolve ${source}: ${resolveError.message}`);
}
}
}