BMAD-METHOD/tools/installer/core/manifest-generator.js

1185 lines
47 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const path = require('node:path');
const fs = require('../fs-native');
const yaml = require('yaml');
const crypto = require('node:crypto');
const { resolveInstalledModuleYaml } = require('../project-root');
const { globalUserConfigPath, loadGlobalConfig } = require('../global-config');
const { upsertTomlKey } = require('../set-overrides');
const prompts = require('../prompts');
// Load package.json for version info
const packageJson = require('../../../package.json');
/**
* Generates manifest files for installed skills and agents
*/
class ManifestGenerator {
constructor() {
this.skills = [];
this.agents = [];
this.modules = [];
this.files = [];
this.selectedIdes = [];
}
/**
* Clean text for CSV output by normalizing whitespace.
* Note: Quote escaping is handled by escapeCsv() at write time.
* @param {string} text - Text to clean
* @returns {string} Cleaned text
*/
cleanForCSV(text) {
if (!text) return '';
return text.trim().replaceAll(/\s+/g, ' '); // Normalize all whitespace (including newlines) to single space
}
/**
* Generate all manifests for the installation
* @param {string} bmadDir - _bmad
* @param {Array} selectedModules - Selected modules for installation
* @param {Array} installedFiles - All installed files (optional, for hash tracking)
*/
async generateManifests(bmadDir, selectedModules, installedFiles = [], options = {}) {
// Create _config directory if it doesn't exist
const cfgDir = path.join(bmadDir, '_config');
await fs.ensureDir(cfgDir);
// Store modules list (all modules including preserved ones)
const preservedModules = options.preservedModules || [];
// Scan the bmad directory to find all actually installed modules
const installedModules = await this.scanInstalledModules(bmadDir);
// Since custom modules are now installed the same way as regular modules,
// we don't need to exclude them from manifest generation
const allModules = [...new Set(['core', ...selectedModules, ...preservedModules, ...installedModules])];
this.modules = allModules;
this.updatedModules = allModules; // Include ALL modules (including custom) for scanning
this.bmadDir = bmadDir;
this.bmadFolderName = path.basename(bmadDir); // Get the actual folder name (e.g., '_bmad' or 'bmad')
this.allInstalledFiles = installedFiles;
if (!Object.prototype.hasOwnProperty.call(options, 'ides')) {
throw new Error('ManifestGenerator requires `options.ides` to be provided installer should supply the selected IDEs array.');
}
const resolvedIdes = options.ides ?? [];
if (!Array.isArray(resolvedIdes)) {
throw new TypeError('ManifestGenerator expected `options.ides` to be an array.');
}
// Filter out any undefined/null values from IDE list
this.selectedIdes = resolvedIdes.filter((ide) => ide && typeof ide === 'string');
// Reset files list (defensive: prevent stale data if instance is reused)
this.files = [];
// Collect skills first (populates skillClaimedDirs before legacy collectors run)
await this.collectSkills();
// Collect agent essence from each module's source module.yaml `agents:` array
await this.collectAgentsFromModuleYaml();
// Write manifest files and collect their paths
const [teamConfigPath, userConfigPath] = await this.writeCentralConfig(bmadDir, options.moduleConfigs || {});
// Per-module module.toml floor — shipped defaults + agent roster, read
// by resolve_config.py as the lowest-priority layer. Independent of the
// central config.toml; remains stable across user customizations.
const moduleTomlPaths = await this.writeModuleTomls(bmadDir);
// Task D: route scope:user core answers to ~/.bmad/config.user.toml.
// Identity persists across projects on this machine, so re-installs in
// other directories don't re-prompt for the same name/language.
await this.writeGlobalUserCore(options.moduleConfigs || {});
const manifestFiles = [
await this.writeMainManifest(cfgDir),
await this.writeSkillManifest(cfgDir),
teamConfigPath,
userConfigPath,
...moduleTomlPaths,
await this.writeFilesManifest(cfgDir),
];
await this.ensureCustomConfigStubs(bmadDir);
return {
skills: this.skills.length,
agents: this.agents.length,
files: this.files.length,
manifestFiles: manifestFiles,
};
}
/**
* Recursively walk a module directory tree, collecting native SKILL.md entrypoints.
* A directory is discovered as a skill when it contains a SKILL.md file with
* valid name/description frontmatter (name must match directory name).
* Manifest YAML is loaded only when present — for agent metadata.
* Populates this.skills[] and this.skillClaimedDirs (Set of absolute paths).
*/
async collectSkills() {
this.skills = [];
this.skillClaimedDirs = new Set();
const debug = process.env.BMAD_DEBUG_MANIFEST === 'true';
for (const moduleName of this.updatedModules) {
const modulePath = path.join(this.bmadDir, moduleName);
if (!(await fs.pathExists(modulePath))) continue;
// Recursive walk skipping . and _ prefixed dirs
const walk = async (dir) => {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
// SKILL.md with valid frontmatter is the primary discovery gate
const skillFile = 'SKILL.md';
const skillMdPath = path.join(dir, skillFile);
const dirName = path.basename(dir);
const skillMeta = await this.parseSkillMd(skillMdPath, dir, dirName, debug);
if (skillMeta) {
// Build path relative from module root (points to SKILL.md — the permanent entrypoint)
const relativePath = path.relative(modulePath, dir).split(path.sep).join('/');
const installPath = relativePath
? `${this.bmadFolderName}/${moduleName}/${relativePath}/${skillFile}`
: `${this.bmadFolderName}/${moduleName}/${skillFile}`;
// Native SKILL.md entrypoints always derive canonicalId from directory name.
const canonicalId = dirName;
this.skills.push({
name: skillMeta.name,
description: this.cleanForCSV(skillMeta.description),
module: moduleName,
path: installPath,
canonicalId,
});
// Add to files list
this.files.push({
type: 'skill',
name: skillMeta.name,
module: moduleName,
path: installPath,
});
this.skillClaimedDirs.add(dir);
if (debug) {
console.log(`[DEBUG] collectSkills: claimed skill "${skillMeta.name}" as ${canonicalId} at ${dir}`);
}
}
// Recurse into subdirectories — but not inside a discovered skill
if (!skillMeta) {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
await walk(path.join(dir, entry.name));
}
}
};
await walk(modulePath);
}
if (debug) {
console.log(`[DEBUG] collectSkills: total skills found: ${this.skills.length}, claimed dirs: ${this.skillClaimedDirs.size}`);
}
}
/**
* Parse and validate SKILL.md for a skill directory.
* Returns parsed frontmatter object with name/description, or null if invalid.
* @param {string} skillMdPath - Absolute path to SKILL.md
* @param {string} dir - Skill directory path (for error messages)
* @param {string} dirName - Expected name (must match frontmatter name)
* @param {boolean} debug - Whether to emit debug-level messages
* @returns {Promise<Object|null>} Parsed frontmatter or null
*/
async parseSkillMd(skillMdPath, dir, dirName, debug = false) {
if (!(await fs.pathExists(skillMdPath))) {
if (debug) console.log(`[DEBUG] parseSkillMd: "${dir}" is missing SKILL.md — skipping`);
return null;
}
try {
const rawContent = await fs.readFile(skillMdPath, 'utf8');
const content = rawContent.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
const skillMeta = yaml.parse(frontmatterMatch[1]);
if (
!skillMeta ||
typeof skillMeta !== 'object' ||
typeof skillMeta.name !== 'string' ||
typeof skillMeta.description !== 'string' ||
!skillMeta.name ||
!skillMeta.description
) {
if (debug) console.log(`[DEBUG] parseSkillMd: SKILL.md in "${dir}" is missing name or description (or wrong type) — skipping`);
return null;
}
if (skillMeta.name !== dirName) {
console.error(`Error: SKILL.md name "${skillMeta.name}" does not match directory name "${dirName}" — skipping`);
return null;
}
return skillMeta;
}
if (debug) console.log(`[DEBUG] parseSkillMd: SKILL.md in "${dir}" has no frontmatter — skipping`);
return null;
} catch (error) {
if (debug) console.log(`[DEBUG] parseSkillMd: failed to parse SKILL.md in "${dir}": ${error.message} — skipping`);
return null;
}
}
/**
* Collect agents from each installed module's source module.yaml `agents:` array.
* Essence fields (code, name, title, icon, description) are authored in module.yaml;
* `team` defaults to module code when not set; `module` is always the owning module.
*/
async collectAgentsFromModuleYaml() {
this.agents = [];
const debug = process.env.BMAD_DEBUG_MANIFEST === 'true';
for (const moduleName of this.updatedModules) {
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
if (!moduleYamlPath) {
// External modules live in ~/.bmad/cache/external-modules, not src/modules.
// Warn rather than silently skip so missing agent rosters don't vanish
// from config.toml without notice.
console.warn(
`[warn] collectAgentsFromModuleYaml: could not locate module.yaml for '${moduleName}'. ` +
`Agents declared by this module will not be written to config.toml.`,
);
continue;
}
let moduleDef;
try {
moduleDef = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
} catch (error) {
if (debug) console.log(`[DEBUG] collectAgentsFromModuleYaml: failed to parse ${moduleYamlPath}: ${error.message}`);
continue;
}
if (!moduleDef || !Array.isArray(moduleDef.agents)) continue;
for (const entry of moduleDef.agents) {
if (!entry || typeof entry.code !== 'string') continue;
this.agents.push({
code: entry.code,
name: entry.name || '',
title: entry.title || '',
icon: entry.icon || '',
description: entry.description || '',
module: moduleName,
team: entry.team || moduleName,
});
}
if (debug) {
console.log(
`[DEBUG] collectAgentsFromModuleYaml: ${moduleName} contributed ${moduleDef.agents.length} agents from ${moduleYamlPath}`,
);
}
}
if (debug) {
console.log(`[DEBUG] collectAgentsFromModuleYaml: total agents found: ${this.agents.length}`);
}
}
/**
* Write main manifest as YAML with installation info only
* Fetches fresh version info for all modules
* @returns {string} Path to the manifest file
*/
async writeMainManifest(cfgDir) {
const manifestPath = path.join(cfgDir, 'manifest.yaml');
const installedModuleSet = new Set(this.modules);
// Read existing manifest to preserve install date
let existingInstallDate = null;
const existingModulesMap = new Map();
if (await fs.pathExists(manifestPath)) {
try {
const existingContent = await fs.readFile(manifestPath, 'utf8');
const existingManifest = yaml.parse(existingContent);
// Preserve original install date
if (existingManifest.installation?.installDate) {
existingInstallDate = existingManifest.installation.installDate;
}
// Build map of existing modules for quick lookup
if (existingManifest.modules && Array.isArray(existingManifest.modules)) {
for (const m of existingManifest.modules) {
if (typeof m === 'object' && m.name) {
existingModulesMap.set(m.name, m);
} else if (typeof m === 'string') {
existingModulesMap.set(m, { installDate: existingInstallDate });
}
}
}
} catch {
// If we can't read existing manifest, continue with defaults
}
}
// Fetch fresh version info for all modules
const { Manifest } = require('./manifest');
const manifestObj = new Manifest();
const updatedModules = [];
for (const moduleName of this.modules) {
// Get fresh version info from source
const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, this.bmadDir);
// Get existing install date if available
const existing = existingModulesMap.get(moduleName);
const moduleEntry = {
name: moduleName,
version: versionInfo.version,
installDate: existing?.installDate || new Date().toISOString(),
lastUpdated: new Date().toISOString(),
source: versionInfo.source,
npmPackage: versionInfo.npmPackage,
repoUrl: versionInfo.repoUrl,
};
// Preserve channel/sha from the resolution (external/community/custom)
// or from the existing entry if this is a no-change rewrite.
const channel = versionInfo.channel ?? existing?.channel;
const sha = versionInfo.sha ?? existing?.sha;
if (channel) moduleEntry.channel = channel;
if (sha) moduleEntry.sha = sha;
if (versionInfo.localPath || existing?.localPath) {
moduleEntry.localPath = versionInfo.localPath || existing.localPath;
}
if (versionInfo.rawSource || existing?.rawSource) {
moduleEntry.rawSource = versionInfo.rawSource || existing.rawSource;
}
const regTag = versionInfo.registryApprovedTag ?? existing?.registryApprovedTag;
const regSha = versionInfo.registryApprovedSha ?? existing?.registryApprovedSha;
if (regTag) moduleEntry.registryApprovedTag = regTag;
if (regSha) moduleEntry.registryApprovedSha = regSha;
updatedModules.push(moduleEntry);
}
const manifest = {
installation: {
version: packageJson.version,
installDate: existingInstallDate || new Date().toISOString(),
lastUpdated: new Date().toISOString(),
},
modules: updatedModules,
ides: this.selectedIdes,
};
// Clean the manifest to remove any non-serializable values
const cleanManifest = structuredClone(manifest);
const yamlStr = yaml.stringify(cleanManifest, {
indent: 2,
lineWidth: 0,
sortKeys: false,
});
// Ensure POSIX-compliant final newline
const content = yamlStr.endsWith('\n') ? yamlStr : yamlStr + '\n';
await fs.writeFile(manifestPath, content);
return manifestPath;
}
/**
* Write skill manifest CSV
* @returns {string} Path to the manifest file
*/
async writeSkillManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'skill-manifest.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
let csvContent = 'canonicalId,name,description,module,path\n';
for (const skill of this.skills) {
const row = [
escapeCsv(skill.canonicalId),
escapeCsv(skill.name),
escapeCsv(skill.description),
escapeCsv(skill.module),
escapeCsv(skill.path),
].join(',');
csvContent += row + '\n';
}
await fs.writeFile(csvPath, csvContent);
return csvPath;
}
/**
* Write central _bmad/config.toml as a LEAN OVERRIDE FILE — only values
* the user actually changed from defaults land here. Defaults flow through
* the module.toml floor (written by writeModuleTomls) and the global
* config layer (~/.bmad/...).
*
* Specifically (Phase 1 — tasks D + F):
* - [core] team-scope: emit only keys whose value differs from BOTH the
* module.yaml default AND the global value (if any). Identity that
* equals the global wins via the resolver chain regardless.
* - [core] user-scope (user_name, communication_language): NEVER written
* here. Routed to ~/.bmad/config.user.toml via writeGlobalUserCore.
* - [modules.X]: emit only keys whose value differs from the module's
* processed default. Skip the section entirely if all keys are default.
* - [agents.X]: NEVER emitted. The roster lives in module.toml floor;
* custom agents go in _bmad/custom/config.toml (never touched by us).
*
* Install-owned: both files are regenerated on every install. User
* overrides live in _bmad/custom/config.toml and _bmad/custom/config.user.toml
* (never touched by installer).
*
* @returns {string[]} Paths to the written config files
*/
async writeCentralConfig(bmadDir, moduleConfigs) {
const teamPath = path.join(bmadDir, 'config.toml');
const userPath = path.join(bmadDir, 'config.user.toml');
// Load each module's source module.yaml to determine:
// 1. scope per prompt key (team vs user)
// 2. the canonical module code (for [modules.{code}] section names)
// 3. processed defaults per key (for delta detection)
//
// Pass 1: parse every module.yaml and capture its raw shipped defaults.
// We use those — NOT the user's answered moduleConfigs — to resolve
// cross-key placeholders like `{output_folder}`. Otherwise a user override
// of output_folder would make every derived default (e.g. planning_artifacts)
// match the user's value and get stripped from config.toml as "default",
// even though module.toml's floor still carries the shipped path.
const scopeByModuleKey = {};
const codeByModuleName = {};
const defaultsByModuleKey = {};
const parsedByModule = {};
for (const moduleName of this.updatedModules) {
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
if (!moduleYamlPath) {
console.warn(
`[warn] writeCentralConfig: could not locate module.yaml for '${moduleName}'. ` +
`Answers from this module will default to team scope — user-scoped keys may mis-file into config.toml.`,
);
continue;
}
try {
const parsed = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
if (!parsed || typeof parsed !== 'object') continue;
parsedByModule[moduleName] = parsed;
if (parsed.code) codeByModuleName[moduleName] = parsed.code;
} catch (error) {
console.warn(
`[warn] writeCentralConfig: could not parse module.yaml for '${moduleName}' (${error.message}). ` +
`Answers from this module will default to team scope — user-scoped keys may mis-file into config.toml.`,
);
}
}
// Build the cross-key defaults map (same logic writeModuleTomls uses).
// Shipped defaults only — never user answers.
const crossKeyDefaults = {};
for (const parsed of Object.values(parsedByModule)) {
const raw = extractModuleDefaults(parsed);
for (const [key, value] of Object.entries(raw)) {
if (crossKeyDefaults[key] !== undefined) continue;
let stripped = value;
if (typeof stripped === 'string' && stripped.startsWith('{project-root}/')) {
stripped = stripped.slice('{project-root}/'.length);
}
crossKeyDefaults[key] = stripped;
}
}
// Pass 2: compute scopes and processed defaults using the symmetric map.
for (const [moduleName, parsed] of Object.entries(parsedByModule)) {
scopeByModuleKey[moduleName] = {};
defaultsByModuleKey[moduleName] = {};
for (const [key, value] of Object.entries(parsed)) {
if (!value || typeof value !== 'object' || !('prompt' in value)) continue;
scopeByModuleKey[moduleName][key] = value.scope === 'user' ? 'user' : 'team';
const processedDefault = computeProcessedDefault(value, crossKeyDefaults);
if (processedDefault !== undefined) {
defaultsByModuleKey[moduleName][key] = processedDefault;
}
}
}
// Load the global config snapshot for [core] delta detection. If a key's
// current value equals the global value, no need to duplicate it into the
// project file — the resolver finds it globally.
const globalSnapshot = await loadGlobalConfig().catch(() => ({ merged: {} }));
const globalCore = (globalSnapshot.merged && globalSnapshot.merged.core) || {};
// Core keys are always known (core module.yaml is built-in). These are
// the only keys allowed in [core]; they must be stripped from every
// non-core module bucket because legacy _bmad/{mod}/config.yaml files
// spread core values into each module. Core belongs in [core] only —
// workflows that need user_name/language/etc. read [core] directly.
const coreKeys = new Set(Object.keys(scopeByModuleKey.core || {}));
// Partition a module's answered config into team vs user buckets.
// For non-core modules: strip core keys always; when we know the module's
// own schema, also drop keys it doesn't declare. Unknown-schema modules
// (external / marketplace) fall through with their remaining answers as
// team so they don't vanish from the config.
const partition = (moduleName, cfg, onlyDeclaredKeys = false) => {
const team = {};
const user = {};
const scopes = scopeByModuleKey[moduleName] || {};
const isCore = moduleName === 'core';
for (const [key, value] of Object.entries(cfg || {})) {
if (!isCore && coreKeys.has(key)) continue;
if (onlyDeclaredKeys && !(key in scopes)) continue;
if (scopes[key] === 'user') {
user[key] = value;
} else {
team[key] = value;
}
}
return { team, user };
};
// Drop entries whose value equals an already-known default. Tasks F + D
// both want config.toml to be a *delta* file — anything that matches
// either the module.yaml default or the global config gets resolved
// through the layer chain at read time, so writing it here is dead weight.
const stripDefaults = (entries, perKeyDefaults = {}, fallbackDefaults = {}) => {
const result = {};
for (const [key, value] of Object.entries(entries)) {
const moduleDefault = perKeyDefaults[key];
const fallbackDefault = fallbackDefaults[key];
const isDefault =
(moduleDefault !== undefined && deepEqualScalar(moduleDefault, value)) ||
(fallbackDefault !== undefined && deepEqualScalar(fallbackDefault, value));
if (!isDefault) result[key] = value;
}
return result;
};
const teamHeader = [
'# ─────────────────────────────────────────────────────────────────',
'# Installer-managed. Regenerated on every install — treat as read-only.',
'#',
'# Direct edits to this file will be overwritten on the next install.',
'# To change an install answer durably, re-run the installer (your prior',
'# answers are remembered as defaults). To pin a value regardless of',
'# install answers, or to add custom agents / override descriptors, use:',
'# _bmad/custom/config.toml (team, committed)',
'# _bmad/custom/config.user.toml (personal, gitignored)',
'# Those files are never touched by the installer.',
'# ─────────────────────────────────────────────────────────────────',
'',
];
const userHeader = [
'# ─────────────────────────────────────────────────────────────────',
'# Installer-managed. Regenerated on every install — treat as read-only.',
'# Holds install answers scoped to YOU personally.',
'#',
'# Direct edits to this file will be overwritten on the next install.',
'# To change an answer durably, re-run the installer (your prior answers',
'# are remembered as defaults). For pinned overrides or custom sections',
'# the installer does not know about, use _bmad/custom/config.user.toml',
'# — it is never touched by the installer.',
'# ─────────────────────────────────────────────────────────────────',
'',
];
const teamLines = [...teamHeader];
const userLines = [...userHeader];
// [core] team — emit only deltas from module.yaml default AND global value.
const coreConfig = moduleConfigs.core || {};
const { team: coreTeamRaw } = partition('core', coreConfig);
const coreTeam = stripDefaults(coreTeamRaw, defaultsByModuleKey.core || {}, globalCore);
if (Object.keys(coreTeam).length > 0) {
teamLines.push('[core]');
for (const [key, value] of Object.entries(coreTeam)) {
teamLines.push(`${key} = ${formatTomlValue(value)}`);
}
teamLines.push('');
}
// [core] user-scope: never written to the project user.toml. Task D routes
// these to ~/.bmad/config.user.toml via writeGlobalUserCore (called by
// generateManifests after this method returns). config.user.toml stays
// empty unless the user has manually pinned a per-project override.
// [modules.<code>] — emit only deltas; skip section if no deltas.
for (const moduleName of this.updatedModules) {
if (moduleName === 'core') continue;
const cfg = moduleConfigs[moduleName];
if (!cfg || Object.keys(cfg).length === 0) continue;
const sectionKey = codeByModuleName[moduleName] || moduleName;
const haveSchema = Object.keys(scopeByModuleKey[moduleName] || {}).length > 0;
const { team: modTeamRaw, user: modUserRaw } = partition(moduleName, cfg, haveSchema);
const moduleDefaults = defaultsByModuleKey[moduleName] || {};
const modTeam = stripDefaults(modTeamRaw, moduleDefaults);
const modUser = stripDefaults(modUserRaw, moduleDefaults);
if (Object.keys(modTeam).length > 0) {
teamLines.push(`[modules.${sectionKey}]`);
for (const [key, value] of Object.entries(modTeam)) {
teamLines.push(`${key} = ${formatTomlValue(value)}`);
}
teamLines.push('');
}
if (Object.keys(modUser).length > 0) {
userLines.push(`[modules.${sectionKey}]`);
for (const [key, value] of Object.entries(modUser)) {
userLines.push(`${key} = ${formatTomlValue(value)}`);
}
userLines.push('');
}
}
// [agents.<code>] — intentionally NOT emitted (Task F). The roster lives
// in the per-module module.toml floor. Users who want to override or
// add agents per-project edit _bmad/custom/config.toml; that file is
// never touched by the installer.
const teamContent = teamLines.join('\n').replace(/\n+$/, '\n');
const userContent = userLines.join('\n').replace(/\n+$/, '\n');
await fs.writeFile(teamPath, teamContent);
await fs.writeFile(userPath, userContent);
return [teamPath, userPath];
}
/**
* Write scope:user core values to ~/.bmad/config.user.toml (Task D).
* Merge-preserves any existing global content the user hand-edited.
*
* Why a global write step at all? Identity values (user_name,
* communication_language) are properties of the human, not the project.
* Asking them at every install is friction. Phase 1 stores them globally
* so they're answered once per machine.
*
* @param {object} moduleConfigs - the fully-resolved moduleConfigs map
* @returns {Promise<string|null>} the path written, or null if no values
*/
async writeGlobalUserCore(moduleConfigs) {
const coreScopes = {};
// We need core's module.yaml scope map. Build it lazily here so this
// method is callable without re-doing the writeCentralConfig setup.
const coreYamlPath = await resolveInstalledModuleYaml('core');
if (!coreYamlPath) return null;
try {
const parsed = yaml.parse(await fs.readFile(coreYamlPath, 'utf8'));
if (parsed && typeof parsed === 'object') {
for (const [key, value] of Object.entries(parsed)) {
if (value && typeof value === 'object' && 'prompt' in value) {
coreScopes[key] = value.scope === 'user' ? 'user' : 'team';
}
}
}
} catch {
return null;
}
const userScopeValues = {};
for (const [key, value] of Object.entries(moduleConfigs.core || {})) {
if (coreScopes[key] === 'user' && value !== undefined && value !== null && value !== '') {
userScopeValues[key] = value;
}
}
if (Object.keys(userScopeValues).length === 0) return null;
const globalPath = globalUserConfigPath();
await fs.ensureDir(path.dirname(globalPath));
// Line-surgery upsert into the existing file (or a fresh one with the
// installer header). We only touch the [core] keys we own. Every other
// section, comment, and value passes through byte-for-byte — including
// shapes the previous round-trip parser quietly dropped (arrays,
// single-quoted strings, dotted/quoted keys, \uXXXX escapes, etc.).
let content;
if (await fs.pathExists(globalPath)) {
content = await fs.readFile(globalPath, 'utf8');
} else {
content =
[
'# ─────────────────────────────────────────────────────────────────',
'# Global personal BMad config — values tied to YOU as a user, not',
'# any specific project. Installer writes scope:user identity here',
'# (user_name, communication_language) so re-installs across projects',
"# don't re-ask the same questions.",
'#',
'# Location precedence: $BMAD_HOME if set, else ~/.bmad',
'# Resolver tier: lower than project-level _bmad/*.toml.',
'# ─────────────────────────────────────────────────────────────────',
'',
].join('\n') + '\n';
}
for (const [key, value] of Object.entries(userScopeValues)) {
content = upsertTomlKey(content, '[core]', key, formatTomlValue(value));
}
await fs.writeFile(globalPath, content);
return globalPath;
}
/**
* Write per-module `_bmad/{module}/module.toml` files — the "module floor"
* read by resolve_config.py as the lowest-priority layer.
*
* Each file contains the shipped defaults for one module:
* [modules.{code}] paths and other module-shape values (from
* module.yaml question defaults, with `result:`
* template applied + cross-key placeholders like
* `{output_folder}` resolved against other modules'
* defaults at install time)
* [agents.{agent-code}] one block per agent owned by this module
*
* `{project-root}` is preserved literally — runtime substitution by skills.
* Other cross-key references resolve against module.yaml DEFAULTS only,
* not the user's actual answers. This keeps module.toml stable as a "what
* the module ships" snapshot independent of per-project customization.
* User overrides land in _bmad/config.toml above the floor.
*
* Source of truth is the authored module.yaml. This file is a build artifact
* — regenerated on every install, never hand-edited.
*
* @param {string} bmadDir
* @returns {Promise<string[]>} Paths to all written module.toml files
*/
async writeModuleTomls(bmadDir) {
// Pass 1: parse every installed module.yaml, extract its raw defaults
// (just `{value}` substituted; cross-key placeholders left literal).
const moduleData = [];
for (const moduleName of this.updatedModules) {
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
if (!moduleYamlPath) continue;
let moduleDef;
try {
moduleDef = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
} catch (error) {
console.warn(
`[warn] writeModuleTomls: could not parse module.yaml for '${moduleName}' (${error.message}). ` +
`Skipping module.toml for this module.`,
);
continue;
}
if (!moduleDef || typeof moduleDef !== 'object') continue;
const moduleDir = path.join(bmadDir, moduleName);
if (!(await fs.pathExists(moduleDir))) continue;
const moduleCode = typeof moduleDef.code === 'string' ? moduleDef.code : moduleName;
const rawDefaults = extractModuleDefaults(moduleDef);
moduleData.push({ moduleName, moduleCode, moduleDir, rawDefaults });
}
// Build a flat cross-key lookup map from every module's raw defaults.
// First-define wins (deterministic given sorted updatedModules input).
// Values are stripped of a leading `{project-root}/` so substitutions
// re-compose cleanly when consumed in a `{project-root}/{key}/...` slot
// — matches the installer's processResultTemplate convention.
const crossKeyMap = {};
for (const { rawDefaults } of moduleData) {
for (const [key, value] of Object.entries(rawDefaults)) {
if (crossKeyMap[key] !== undefined) continue;
let stripped = value;
if (typeof stripped === 'string' && stripped.startsWith('{project-root}/')) {
stripped = stripped.slice('{project-root}/'.length);
}
crossKeyMap[key] = stripped;
}
}
// Pass 2: resolve cross-key placeholders in each module's defaults and
// write the final module.toml file.
const written = [];
for (const { moduleName, moduleCode, moduleDir, rawDefaults } of moduleData) {
const resolvedDefaults = {};
for (const [key, value] of Object.entries(rawDefaults)) {
resolvedDefaults[key] = resolveCrossKeyPlaceholders(value, crossKeyMap);
}
const lines = [
'# Module-shipped defaults. Build artifact — do not edit by hand.',
"# Source: this module's module.yaml (authored at source).",
'# Regenerated on every install.',
'#',
'# Read by _bmad/scripts/resolve_config.py as the lowest-priority',
'# floor of the config layer chain. Project _bmad/config.toml and',
'# user overrides in _bmad/custom/ sit above this and win.',
'',
];
if (Object.keys(resolvedDefaults).length > 0) {
// Core's defaults belong under top-level [core] — that's where
// writeCentralConfig emits core deltas and where resolve_config.py
// consumers read core.* from. Everything else gets the per-module
// [modules.<code>] namespace.
const sectionHeader = moduleCode === 'core' ? '[core]' : `[modules.${moduleCode}]`;
lines.push(sectionHeader);
for (const [key, value] of Object.entries(resolvedDefaults)) {
lines.push(`${key} = ${formatTomlValue(value)}`);
}
lines.push('');
}
const moduleAgents = this.agents.filter((a) => a.module === moduleName);
for (const agent of moduleAgents) {
lines.push(`[agents.${agent.code}]`, `module = ${formatTomlValue(agent.module)}`, `team = ${formatTomlValue(agent.team)}`);
if (agent.name) lines.push(`name = ${formatTomlValue(agent.name)}`);
if (agent.title) lines.push(`title = ${formatTomlValue(agent.title)}`);
if (agent.icon) lines.push(`icon = ${formatTomlValue(agent.icon)}`);
if (agent.description) lines.push(`description = ${formatTomlValue(agent.description)}`);
lines.push('');
}
const outputPath = path.join(moduleDir, 'module.toml');
const content = lines.join('\n').replace(/\n+$/, '\n');
await fs.writeFile(outputPath, content);
written.push(outputPath);
}
return written;
}
/**
* Create empty _bmad/custom/config.toml and _bmad/custom/config.user.toml stubs
* on first install only. Installer never touches these files again after creation.
*/
async ensureCustomConfigStubs(bmadDir) {
const customDir = path.join(bmadDir, 'custom');
await fs.ensureDir(customDir);
const stubs = [
{
file: path.join(customDir, 'config.toml'),
header: [
'# Team / enterprise overrides for _bmad/config.toml.',
'# Committed to the repo — applies to every developer on the project.',
'# Tables deep-merge over base config; keyed entries merge by key.',
'# Example: override an agent descriptor, or add a new agent.',
'#',
'# [agents.bmad-agent-pm]',
'# description = "Prefers short, bulleted PRDs over narrative drafts."',
'',
],
},
{
file: path.join(customDir, 'config.user.toml'),
header: [
'# Personal overrides for _bmad/config.toml.',
'# NOT committed (gitignored) — applies only to your local install.',
'# Wins over both base config and team overrides.',
'',
],
},
];
for (const { file, header } of stubs) {
if (await fs.pathExists(file)) continue;
await fs.writeFile(file, header.join('\n'));
}
}
/**
* Write files manifest CSV
*/
/**
* Calculate SHA256 hash of a file
* @param {string} filePath - Path to file
* @returns {string} SHA256 hash
*/
async calculateFileHash(filePath) {
try {
const content = await fs.readFile(filePath);
return crypto.createHash('sha256').update(content).digest('hex');
} catch {
return '';
}
}
/**
* @returns {string} Path to the manifest file
*/
async writeFilesManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'files-manifest.csv');
// Create CSV header with hash column
let csv = 'type,name,module,path,hash\n';
// If we have ALL installed files, use those instead of just workflows/agents/tasks
const allFiles = [];
if (this.allInstalledFiles && this.allInstalledFiles.length > 0) {
// Process all installed files
for (const filePath of this.allInstalledFiles) {
// Store paths relative to bmadDir (no folder prefix)
const relativePath = filePath.replace(this.bmadDir, '').replaceAll('\\', '/').replace(/^\//, '');
const ext = path.extname(filePath).toLowerCase();
const fileName = path.basename(filePath, ext);
// Determine module from path (first directory component)
const pathParts = relativePath.split('/');
const module = pathParts.length > 0 ? pathParts[0] : 'unknown';
// Calculate hash
const hash = await this.calculateFileHash(filePath);
allFiles.push({
type: ext.slice(1) || 'file',
name: fileName,
module: module,
path: relativePath,
hash: hash,
});
}
} else {
// Fallback: use the collected workflows/agents/tasks
for (const file of this.files) {
// Strip the folder prefix if present (for consistency)
const relPath = file.path.replace(this.bmadFolderName + '/', '');
const filePath = path.join(this.bmadDir, relPath);
const hash = await this.calculateFileHash(filePath);
allFiles.push({
...file,
path: relPath,
hash: hash,
});
}
}
// Sort files by module, then type, then name
allFiles.sort((a, b) => {
if (a.module !== b.module) return a.module.localeCompare(b.module);
if (a.type !== b.type) return a.type.localeCompare(b.type);
return a.name.localeCompare(b.name);
});
// Add all files
for (const file of allFiles) {
csv += `"${file.type}","${file.name}","${file.module}","${file.path}","${file.hash}"\n`;
}
await fs.writeFile(csvPath, csv);
return csvPath;
}
/**
* Scan the bmad directory to find all installed modules
* @param {string} bmadDir - Path to bmad directory
* @returns {Array} List of module names
*/
async scanInstalledModules(bmadDir) {
const modules = [];
try {
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
for (const entry of entries) {
// Skip if not a directory or is a special directory
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === '_config') {
continue;
}
// Check if this looks like a module (has agents directory or skill manifests)
const modulePath = path.join(bmadDir, entry.name);
const hasAgents = await fs.pathExists(path.join(modulePath, 'agents'));
const hasSkills = await this._hasSkillMdRecursive(modulePath);
if (hasAgents || hasSkills) {
modules.push(entry.name);
}
}
} catch (error) {
await prompts.log.warn(`Could not scan for installed modules: ${error.message}`);
}
return modules;
}
/**
* Recursively check if a directory tree contains a SKILL.md file.
* Skips directories starting with . or _.
* @param {string} dir - Directory to search
* @returns {boolean} True if a SKILL.md is found
*/
async _hasSkillMdRecursive(dir) {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return false;
}
// Check for SKILL.md in this directory
if (entries.some((e) => !e.isDirectory() && e.name === 'SKILL.md')) return true;
// Recurse into subdirectories
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
if (await this._hasSkillMdRecursive(path.join(dir, entry.name))) return true;
}
return false;
}
}
/**
* Format a JS scalar as a TOML value literal.
* Handles strings (quoted + escaped), booleans, numbers, and arrays of scalars.
* Objects are not expected at this emit path.
*/
/**
* Compute the processed default value for a module.yaml question item.
* Resolves `{key}` cross-references against the flat `crossKeyDefaults` lookup
* (shipped defaults, never user answers — see writeCentralConfig comment).
* Used by writeCentralConfig to detect default-equal values that should NOT
* be re-emitted into the lean config.toml. Matches the lookup table that
* writeModuleTomls uses, so module.toml's floor and config.toml's delta
* detection agree on what "default" means.
*
* Steps:
* 1. Substitute {key} references against crossKeyDefaults (with leading
* "{project-root}/" stripped, matching the installer's
* processResultTemplate behavior).
* 2. Apply the result: template with {value} substituted.
*
* Returns undefined for items without a default.
*
* @param {object} item - one module.yaml question schema
* @param {Record<string, *>} crossKeyDefaults - flat shipped-defaults lookup
* @returns {*} processed default value (string/scalar) or undefined
*/
function computeProcessedDefault(item, crossKeyDefaults) {
if (!item || item.default === undefined || item.default === null) return;
let value = item.default;
if (typeof value === 'string') {
value = value.replaceAll(/{([^}]+)}/g, (match, refKey) => {
if (refKey === 'project-root' || refKey === 'value' || refKey === 'directory_name') {
return match;
}
const replacement = (crossKeyDefaults || {})[refKey];
return replacement === undefined ? match : String(replacement);
});
}
if (typeof item.result === 'string' && value !== undefined) {
return item.result.replaceAll('{value}', String(value));
}
return value;
}
/**
* Resolve `{key}` cross-references in a string value against a flat
* `{key: value}` lookup map. `{project-root}` and `{directory_name}` are
* preserved literal — they're runtime placeholders, substituted by the skill
* or resolver when the value is consumed. Unknown keys are left literal too.
*
* Used by writeModuleTomls so that module.toml's [modules.X] keys carry the
* same shape as the installer's resolved config (e.g.
* `"{project-root}/_bmad-output/planning-artifacts"`) — making the floor a
* drop-in for the central config when the latter omits a value as default.
*
* @param {*} value - typically a string; non-strings returned unchanged
* @param {Record<string, *>} crossKeyMap
* @returns {*}
*/
function resolveCrossKeyPlaceholders(value, crossKeyMap) {
if (typeof value !== 'string') return value;
return value.replaceAll(/{([^}]+)}/g, (match, key) => {
if (key === 'project-root' || key === 'directory_name' || key === 'value') {
return match;
}
const replacement = crossKeyMap[key];
return replacement === undefined ? match : String(replacement);
});
}
/**
* Scalar / shallow equality for delta detection. Handles strings, numbers,
* booleans, and arrays of scalars (the only shapes module.yaml defaults
* produce). Different types compare unequal.
*/
function deepEqualScalar(a, b) {
if (a === b) return true;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((item, i) => deepEqualScalar(item, b[i]));
}
return false;
}
/**
* Module.yaml top-level keys that are metadata, not question knobs. These
* never appear in a module's [modules.{code}] floor section.
*/
const MODULE_DEFAULTS_SKIP = new Set([
'code',
'name',
'description',
'default_selected',
'header',
'subheader',
'agents',
'directories',
'dependencies',
'prompt',
]);
/**
* Extract shipped defaults from a parsed module.yaml. For each question-style
* key (object with a `default` field), capture the default and apply its
* `result:` template with `{value}` substituted. Cross-key placeholders like
* `{output_folder}` are left as literal strings — see writeModuleTomls() doc
* for why and how that's handled in phase 1.
*
* @param {object} moduleDef - parsed module.yaml content
* @returns {Record<string, string|number|boolean|Array<*>>}
*/
function extractModuleDefaults(moduleDef) {
const defaults = {};
for (const [key, value] of Object.entries(moduleDef)) {
if (MODULE_DEFAULTS_SKIP.has(key)) continue;
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
if (!('default' in value)) continue;
let resolved = value.default;
if (typeof value.result === 'string' && resolved !== undefined) {
// Apply `result:` template with `{value}` substituted by default. Leave
// other placeholders (`{project-root}`, `{output_folder}`, ...) literal.
resolved = value.result.replaceAll('{value}', String(resolved));
}
defaults[key] = resolved;
}
return defaults;
}
function formatTomlValue(value) {
if (value === null || value === undefined) return '""';
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (Array.isArray(value)) return `[${value.map((v) => formatTomlValue(v)).join(', ')}]`;
const str = String(value);
const escaped = str
.replaceAll('\\', '\\\\')
.replaceAll('"', String.raw`\"`)
.replaceAll('\n', String.raw`\n`)
.replaceAll('\r', String.raw`\r`)
.replaceAll('\t', String.raw`\t`);
return `"${escaped}"`;
}
module.exports = { ManifestGenerator };