diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 808ee6faa..2f943f4e4 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -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 // ============================================================ diff --git a/tools/installer/modules/bmad-module-lib.js b/tools/installer/modules/bmad-module-lib.js new file mode 100644 index 000000000..8a22c5995 --- /dev/null +++ b/tools/installer/modules/bmad-module-lib.js @@ -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} + */ +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 }; diff --git a/tools/installer/modules/official-modules.js b/tools/installer/modules/official-modules.js index db2933427..3f71796de 100644 --- a/tools/installer/modules/official-modules.js +++ b/tools/installer/modules/official-modules.js @@ -5,6 +5,7 @@ const prompts = require('../prompts'); const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root'); const { CLIUtils } = require('../cli-utils'); const { ExternalModuleManager } = require('./external-manager'); +const { 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 diff --git a/tools/installer/modules/plugin-resolver.js b/tools/installer/modules/plugin-resolver.js index 8cef26d27..6900eed14 100644 --- a/tools/installer/modules/plugin-resolver.js +++ b/tools/installer/modules/plugin-resolver.js @@ -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} 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//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, diff --git a/tools/installer/ui.js b/tools/installer/ui.js index a107fb0fc..2a520144f 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -17,6 +17,7 @@ const { const channelResolver = require('./modules/channel-resolver'); const prompts = require('./prompts'); const { parseSetEntries } = require('./set-overrides'); +const { 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}`); } } }