feat(installer): auto-inject BMAD version badge into project README

Lightweight installer integration that adds a dynamic BMAD version badge
to the project README during `bmad install`. The badge is powered by the
existing bmad-badge.vercel.app service (from terryso/bmad-badge, issue #2174).

- New badge.js module: resolves git remote, finds README, injects/removes badge
- Badge is added after install, shown in the install summary
- `--no-badge` flag to opt out
- Badge is removed during `bmad uninstall` when modules are removed
- Idempotent: re-running install skips if badge already present

Closes #2174

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
nick 2026-05-01 13:45:25 +08:00 committed by nick
parent 9debc165aa
commit 707de90238
4 changed files with 133 additions and 0 deletions

View File

@ -35,6 +35,7 @@ module.exports = {
['--output-folder <path>', 'Output folder path relative to project root (default: _bmad-output)'], ['--output-folder <path>', 'Output folder path relative to project root (default: _bmad-output)'],
['--custom-source <sources>', 'Comma-separated Git URLs or local paths to install custom modules from'], ['--custom-source <sources>', 'Comma-separated Git URLs or local paths to install custom modules from'],
['-y, --yes', 'Accept all defaults and skip prompts where possible'], ['-y, --yes', 'Accept all defaults and skip prompts where possible'],
['--no-badge', 'Skip adding BMAD badge to README'],
[ [
'--channel <channel>', '--channel <channel>',
'Apply channel (stable|next) to all external modules being installed. --all-stable and --all-next are aliases.', 'Apply channel (stable|next) to all external modules being installed. --all-stable and --all-next are aliases.',
@ -95,6 +96,7 @@ module.exports = {
} }
const config = await ui.promptInstall(options); const config = await ui.promptInstall(options);
config.noBadge = options.badge === false;
// Handle cancel // Handle cancel
if (config.actionType === 'cancel') { if (config.actionType === 'cancel') {

View File

@ -139,6 +139,16 @@ module.exports = {
s.start(`Removing BMAD modules & data (${installer.bmadFolderName}/)...`); s.start(`Removing BMAD modules & data (${installer.bmadFolderName}/)...`);
await installer.uninstallModules(projectDir); await installer.uninstallModules(projectDir);
s.stop('Modules & data removed'); s.stop('Modules & data removed');
// Remove BMAD badge from README
const badge = require('../core/badge');
const readmePath = await badge.findReadme(projectDir);
if (readmePath) {
const content = await fs.readFile(readmePath, 'utf8');
if (badge.hasBadge(content)) {
await fs.writeFile(readmePath, badge.removeBadge(content), 'utf8');
}
}
} }
const summary = []; const summary = [];

View File

@ -0,0 +1,83 @@
const path = require('node:path');
const { execSync } = require('node:child_process');
const fs = require('../fs-native');
const BADGE_URL = 'https://bmad-badge.vercel.app';
const BADGE_PATTERN = /\[!\[BMAD\]\(https:\/\/bmad-badge\.vercel\.app\/[^\)]+\)\]\(https:\/\/github\.com\/bmad-code-org\/BMAD-METHOD\)/;
const README_NAMES = ['README.md', 'readme.md', 'README', 'readme'];
function resolveGitRemote(projectDir) {
try {
const raw = execSync('git remote get-url origin', {
cwd: projectDir,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
// https://github.com/owner/repo.git
const httpsMatch = raw.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
if (httpsMatch) {
return { owner: httpsMatch[1], repo: httpsMatch[2] };
}
} catch {
// no git remote
}
return null;
}
async function findReadme(projectDir) {
for (const name of README_NAMES) {
const fullPath = path.join(projectDir, name);
if (await fs.pathExists(fullPath)) {
return fullPath;
}
}
return null;
}
function hasBadge(content) {
return BADGE_PATTERN.test(content);
}
function generateBadgeMarkdown(owner, repo) {
return `[![BMAD](${BADGE_URL}/${owner}/${repo}.svg)](https://github.com/bmad-code-org/BMAD-METHOD)`;
}
function injectBadge(content, owner, repo) {
const badgeLine = generateBadgeMarkdown(owner, repo);
const lines = content.split('\n');
// Find the first heading (# title)
let headingEnd = 0;
for (let i = 0; i < lines.length; i++) {
headingEnd = i + 1;
if (lines[i].startsWith('#')) break;
}
// Check if there are existing badges right after the heading
let insertAt = headingEnd;
while (insertAt < lines.length && /^\[!\[.*?\]\(.*?\)\]\(.*?\)/.test(lines[insertAt].trim())) {
insertAt++;
}
// Insert badge line
lines.splice(insertAt, 0, badgeLine);
return lines.join('\n');
}
function removeBadge(content) {
return content
.split('\n')
.filter((line) => !BADGE_PATTERN.test(line.trim()))
.join('\n');
}
module.exports = {
resolveGitRemote,
findReadme,
hasBadge,
generateBadgeMarkdown,
injectBadge,
removeBadge,
};

View File

@ -103,6 +103,11 @@ class Installer {
const restoreResult = await this._restoreUserFiles(paths, updateState); const restoreResult = await this._restoreUserFiles(paths, updateState);
// Inject BMAD badge into README if applicable
if (!config.noBadge) {
await this._injectBadgeIfNeeded(paths.projectRoot, addResult);
}
// Render consolidated summary // Render consolidated summary
await this.renderInstallSummary(results, { await this.renderInstallSummary(results, {
bmadDir: paths.bmadDir, bmadDir: paths.bmadDir,
@ -1038,6 +1043,39 @@ class Installer {
} }
} }
/**
* Inject BMAD version badge into project README if applicable.
* Skipped when --no-badge is set, when no git remote is found,
* or when no README exists.
* @param {string} projectDir - Project root directory
* @param {Function} addResult - Callback to record results
*/
async _injectBadgeIfNeeded(projectDir, addResult) {
const badge = require('../core/badge');
const remote = badge.resolveGitRemote(projectDir);
if (!remote) {
addResult('Badge', 'warn', 'no git remote found');
return;
}
const readmePath = await badge.findReadme(projectDir);
if (!readmePath) {
addResult('Badge', 'warn', 'no README found');
return;
}
const content = await fs.readFile(readmePath, 'utf8');
if (badge.hasBadge(content)) {
addResult('Badge', 'ok', 'already present');
return;
}
const updated = badge.injectBadge(content, remote.owner, remote.repo);
await fs.writeFile(readmePath, updated, 'utf8');
addResult('Badge', 'ok', `added to ${path.basename(readmePath)}`);
}
/** /**
* Render a consolidated install summary using prompts.note() * Render a consolidated install summary using prompts.note()
* @param {Array} results - Array of {step, status: 'ok'|'error'|'warn', detail} * @param {Array} results - Array of {step, status: 'ok'|'error'|'warn', detail}