feat(installer): provision _bmad/scripts/ and _bmad/custom/ on install

- Declare scriptsDir and customDir in InstallPaths and ensure both exist.
- New _installSharedScripts task copies src/scripts/* -> _bmad/scripts/
  and seeds _bmad/custom/.gitignore with *.user.yaml on fresh installs.
- Exclude scripts/ and custom/ from module-directory scans in
  generateModuleConfigs, mergeModuleHelpCatalogs, and loadExistingConfig
  so neither is treated as a module.
This commit is contained in:
Brian Madison 2026-04-18 13:44:50 -05:00
parent 4a88d321f7
commit 8fb22b1aa8
3 changed files with 46 additions and 8 deletions

View File

@ -21,12 +21,16 @@ class InstallPaths {
const configDir = path.join(bmadDir, '_config'); const configDir = path.join(bmadDir, '_config');
const agentsDir = path.join(configDir, 'agents'); const agentsDir = path.join(configDir, 'agents');
const coreDir = path.join(bmadDir, 'core'); const coreDir = path.join(bmadDir, 'core');
const scriptsDir = path.join(bmadDir, 'scripts');
const customDir = path.join(bmadDir, 'custom');
for (const [dir, label] of [ for (const [dir, label] of [
[bmadDir, 'bmad directory'], [bmadDir, 'bmad directory'],
[configDir, 'config directory'], [configDir, 'config directory'],
[agentsDir, 'agents config directory'], [agentsDir, 'agents config directory'],
[coreDir, 'core module directory'], [coreDir, 'core module directory'],
[scriptsDir, 'shared scripts directory'],
[customDir, 'customizations directory'],
]) { ]) {
await ensureWritableDir(dir, label); await ensureWritableDir(dir, label);
} }
@ -39,6 +43,8 @@ class InstallPaths {
configDir, configDir,
agentsDir, agentsDir,
coreDir, coreDir,
scriptsDir,
customDir,
isUpdate, isUpdate,
}); });
} }

View File

@ -244,6 +244,15 @@ class Installer {
const installTasks = []; const installTasks = [];
installTasks.push({
title: 'Installing shared scripts',
task: async () => {
await this._installSharedScripts(paths);
addResult('Shared scripts', 'ok');
return 'Shared scripts installed';
},
});
if (allModules.length > 0) { if (allModules.length > 0) {
installTasks.push({ installTasks.push({
title: isQuickUpdate ? `Updating ${allModules.length} module(s)` : `Installing ${allModules.length} module(s)`, title: isQuickUpdate ? `Updating ${allModules.length} module(s)` : `Installing ${allModules.length} module(s)`,
@ -558,6 +567,31 @@ class Installer {
return { tempBackupDir, tempModifiedBackupDir }; return { tempBackupDir, tempModifiedBackupDir };
} }
/**
* Copy src/scripts/* _bmad/scripts/ and seed _bmad/custom/.gitignore.
* Agents resolve customizations via these shared scripts; the .gitignore
* ensures *.user.yaml overrides stay out of version control by default.
*/
async _installSharedScripts(paths) {
const srcScriptsDir = path.join(paths.srcDir, 'src', 'scripts');
if (await fs.pathExists(srcScriptsDir)) {
const entries = await fs.readdir(srcScriptsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
const srcFile = path.join(srcScriptsDir, entry.name);
const destFile = path.join(paths.scriptsDir, entry.name);
await fs.copy(srcFile, destFile, { overwrite: true });
this.installedFiles.add(destFile);
}
}
const customGitignore = path.join(paths.customDir, '.gitignore');
if (!(await fs.pathExists(customGitignore))) {
await fs.writeFile(customGitignore, '*.user.yaml\n', 'utf8');
this.installedFiles.add(customGitignore);
}
}
/** /**
* Install official (non-custom) modules. * Install official (non-custom) modules.
* @param {Object} config - Installation configuration * @param {Object} config - Installation configuration
@ -789,9 +823,8 @@ class Installer {
// Get all installed module directories // Get all installed module directories
const entries = await fs.readdir(bmadDir, { withFileTypes: true }); const entries = await fs.readdir(bmadDir, { withFileTypes: true });
const installedModules = entries const nonModuleDirs = new Set(['_config', '_memory', 'docs', 'scripts', 'custom']);
.filter((entry) => entry.isDirectory() && entry.name !== '_config' && entry.name !== 'docs') const installedModules = entries.filter((entry) => entry.isDirectory() && !nonModuleDirs.has(entry.name)).map((entry) => entry.name);
.map((entry) => entry.name);
// Generate config.yaml for each installed module // Generate config.yaml for each installed module
for (const moduleName of installedModules) { for (const moduleName of installedModules) {
@ -917,9 +950,8 @@ class Installer {
// Get all installed module directories // Get all installed module directories
const entries = await fs.readdir(bmadDir, { withFileTypes: true }); const entries = await fs.readdir(bmadDir, { withFileTypes: true });
const installedModules = entries const nonModuleDirs = new Set(['_config', '_memory', 'docs', 'scripts', 'custom']);
.filter((entry) => entry.isDirectory() && entry.name !== '_config' && entry.name !== 'docs' && entry.name !== '_memory') const installedModules = entries.filter((entry) => entry.isDirectory() && !nonModuleDirs.has(entry.name)).map((entry) => entry.name);
.map((entry) => entry.name);
// Add core module to scan (it's installed at root level as _config, but we check src/core-skills) // Add core module to scan (it's installed at root level as _config, but we check src/core-skills)
const coreModulePath = getSourcePath('core-skills'); const coreModulePath = getSourcePath('core-skills');

View File

@ -820,10 +820,10 @@ class OfficialModules {
let foundAny = false; let foundAny = false;
const entries = await fs.readdir(bmadDir, { withFileTypes: true }); const entries = await fs.readdir(bmadDir, { withFileTypes: true });
const nonModuleDirs = new Set(['_config', '_memory', 'docs', 'scripts', 'custom']);
for (const entry of entries) { for (const entry of entries) {
if (entry.isDirectory()) { if (entry.isDirectory()) {
// Skip the _config directory - it's for system use if (nonModuleDirs.has(entry.name)) {
if (entry.name === '_config' || entry.name === '_memory') {
continue; continue;
} }