From 707de9023873ada97ac6bd36ad23f604195c3f60 Mon Sep 17 00:00:00 2001 From: nick Date: Fri, 1 May 2026 13:45:25 +0800 Subject: [PATCH 1/4] 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 Co-Authored-By: Happy --- tools/installer/commands/install.js | 2 + tools/installer/commands/uninstall.js | 10 ++++ tools/installer/core/badge.js | 83 +++++++++++++++++++++++++++ tools/installer/core/installer.js | 38 ++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 tools/installer/core/badge.js diff --git a/tools/installer/commands/install.js b/tools/installer/commands/install.js index 1dfe6fb70..61598a2cf 100644 --- a/tools/installer/commands/install.js +++ b/tools/installer/commands/install.js @@ -35,6 +35,7 @@ module.exports = { ['--output-folder ', 'Output folder path relative to project root (default: _bmad-output)'], ['--custom-source ', 'Comma-separated Git URLs or local paths to install custom modules from'], ['-y, --yes', 'Accept all defaults and skip prompts where possible'], + ['--no-badge', 'Skip adding BMAD badge to README'], [ '--channel ', '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); + config.noBadge = options.badge === false; // Handle cancel if (config.actionType === 'cancel') { diff --git a/tools/installer/commands/uninstall.js b/tools/installer/commands/uninstall.js index 727b7b0ef..19b2447ad 100644 --- a/tools/installer/commands/uninstall.js +++ b/tools/installer/commands/uninstall.js @@ -139,6 +139,16 @@ module.exports = { s.start(`Removing BMAD modules & data (${installer.bmadFolderName}/)...`); await installer.uninstallModules(projectDir); 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 = []; diff --git a/tools/installer/core/badge.js b/tools/installer/core/badge.js new file mode 100644 index 000000000..7623c256b --- /dev/null +++ b/tools/installer/core/badge.js @@ -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, +}; diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index 4952c89e1..b6c2d8e52 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -103,6 +103,11 @@ class Installer { 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 await this.renderInstallSummary(results, { 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() * @param {Array} results - Array of {step, status: 'ok'|'error'|'warn', detail} From 4d0b0fdcd12e87b954b3ab5f8670dae88b3325f5 Mon Sep 17 00:00:00 2001 From: nick Date: Fri, 1 May 2026 15:36:50 +0800 Subject: [PATCH 2/4] feat(installer): add badge prompt and auto-create README if missing - Interactive prompt asks "Add BMAD badge to your README?" (default yes) - --no-badge skips the prompt and disables badge - --yes accepts badge by default - If user wants badge but no README exists, creates README.md with project name and badge Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- tools/installer/commands/install.js | 13 ++++++++++++- tools/installer/core/badge.js | 6 ++++++ tools/installer/core/installer.js | 7 ++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/tools/installer/commands/install.js b/tools/installer/commands/install.js index 61598a2cf..4df7981ed 100644 --- a/tools/installer/commands/install.js +++ b/tools/installer/commands/install.js @@ -96,7 +96,18 @@ module.exports = { } const config = await ui.promptInstall(options); - config.noBadge = options.badge === false; + + // Ask about badge unless --no-badge or --yes + if (options.badge === false) { + config.noBadge = true; + } else if (options.yes) { + config.noBadge = false; + } else { + config.noBadge = !(await prompts.confirm({ + message: 'Add BMAD badge to your README?', + default: true, + })); + } // Handle cancel if (config.actionType === 'cancel') { diff --git a/tools/installer/core/badge.js b/tools/installer/core/badge.js index 7623c256b..0a9b250f5 100644 --- a/tools/installer/core/badge.js +++ b/tools/installer/core/badge.js @@ -73,6 +73,11 @@ function removeBadge(content) { .join('\n'); } +function createReadmeWithBadge(owner, repo, projectName) { + const badgeLine = generateBadgeMarkdown(owner, repo); + return `# ${projectName}\n\n${badgeLine}\n`; +} + module.exports = { resolveGitRemote, findReadme, @@ -80,4 +85,5 @@ module.exports = { generateBadgeMarkdown, injectBadge, removeBadge, + createReadmeWithBadge, }; diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index b6c2d8e52..580ecb57d 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -1061,7 +1061,12 @@ class Installer { const readmePath = await badge.findReadme(projectDir); if (!readmePath) { - addResult('Badge', 'warn', 'no README found'); + // No README — create one with the badge + const projectName = path.basename(projectDir); + const content = badge.createReadmeWithBadge(remote.owner, remote.repo, projectName); + const newReadmePath = path.join(projectDir, 'README.md'); + await fs.writeFile(newReadmePath, content, 'utf8'); + addResult('Badge', 'ok', 'created README.md with badge'); return; } From a8666294cbbe543a5e124c149c915031644b6999 Mon Sep 17 00:00:00 2001 From: nick Date: Fri, 1 May 2026 15:54:05 +0800 Subject: [PATCH 3/4] fix(installer): address CodeRabbit review feedback on badge integration - Derive BADGE_PATTERN from BADGE_URL constant (configurable) - Fix git remote regex to support repo names with dots - Add try/catch around badge injection so IO errors don't abort install - Add try/catch around badge cleanup in uninstall (best-effort) - Pass noBadge through quickUpdate flow Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- tools/installer/commands/uninstall.js | 18 +++++++++++------- tools/installer/core/badge.js | 7 +++++-- tools/installer/core/installer.js | 7 ++++++- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/tools/installer/commands/uninstall.js b/tools/installer/commands/uninstall.js index 19b2447ad..dc2055027 100644 --- a/tools/installer/commands/uninstall.js +++ b/tools/installer/commands/uninstall.js @@ -140,14 +140,18 @@ module.exports = { await installer.uninstallModules(projectDir); 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'); + // Remove BMAD badge from README (best-effort) + try { + 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'); + } } + } catch (error) { + await prompts.log.warn(`Badge cleanup skipped: ${error.message}`); } } diff --git a/tools/installer/core/badge.js b/tools/installer/core/badge.js index 0a9b250f5..7c1274779 100644 --- a/tools/installer/core/badge.js +++ b/tools/installer/core/badge.js @@ -3,7 +3,10 @@ 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 escapedBadgeUrl = BADGE_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const BADGE_PATTERN = new RegExp( + `\\[!\\[BMAD\\]\\(${escapedBadgeUrl}/[^)]+\\)\\]\\(https://github\\.com/bmad-code-org/BMAD-METHOD\\)`, +); const README_NAMES = ['README.md', 'readme.md', 'README', 'readme']; function resolveGitRemote(projectDir) { @@ -15,7 +18,7 @@ function resolveGitRemote(projectDir) { }).trim(); // https://github.com/owner/repo.git - const httpsMatch = raw.match(/github\.com[:/]([^/]+)\/([^/.]+)/); + const httpsMatch = raw.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/i); if (httpsMatch) { return { owner: httpsMatch[1], repo: httpsMatch[2] }; } diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index 580ecb57d..6090c2282 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -105,7 +105,11 @@ class Installer { // Inject BMAD badge into README if applicable if (!config.noBadge) { - await this._injectBadgeIfNeeded(paths.projectRoot, addResult); + try { + await this._injectBadgeIfNeeded(paths.projectRoot, addResult); + } catch (error) { + addResult('Badge', 'warn', `skipped: ${error.message}`); + } } // Render consolidated summary @@ -1337,6 +1341,7 @@ class Installer { directory: projectDir, modules: modulesToUpdate, ides: configuredIdes, + noBadge: config.noBadge, coreConfig: quickModules.collectedConfig.core, moduleConfigs: quickModules.collectedConfig, // Forward `--set` overrides so the post-install patch step From a955c8611dd7046008cc23883ae923108325b2bd Mon Sep 17 00:00:00 2001 From: nick Date: Fri, 1 May 2026 19:54:34 +0800 Subject: [PATCH 4/4] docs(installer): add JSDoc docstrings to badge.js Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- tools/installer/core/badge.js | 45 +++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/tools/installer/core/badge.js b/tools/installer/core/badge.js index 7c1274779..af2ae11af 100644 --- a/tools/installer/core/badge.js +++ b/tools/installer/core/badge.js @@ -9,6 +9,12 @@ const BADGE_PATTERN = new RegExp( ); const README_NAMES = ['README.md', 'readme.md', 'README', 'readme']; +/** + * Resolve owner and repo from the project's git remote origin URL. + * Supports HTTPS and SSH formats. + * @param {string} projectDir - Project root directory + * @returns {{ owner: string, repo: string } | null} Parsed owner/repo or null + */ function resolveGitRemote(projectDir) { try { const raw = execSync('git remote get-url origin', { @@ -17,7 +23,6 @@ function resolveGitRemote(projectDir) { stdio: ['pipe', 'pipe', 'pipe'], }).trim(); - // https://github.com/owner/repo.git const httpsMatch = raw.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/i); if (httpsMatch) { return { owner: httpsMatch[1], repo: httpsMatch[2] }; @@ -28,6 +33,12 @@ function resolveGitRemote(projectDir) { return null; } +/** + * Find the first README file in the project directory. + * Checks common README naming variants (case-insensitive). + * @param {string} projectDir - Project root directory + * @returns {Promise} Absolute path to README or null + */ async function findReadme(projectDir) { for (const name of README_NAMES) { const fullPath = path.join(projectDir, name); @@ -38,14 +49,33 @@ async function findReadme(projectDir) { return null; } +/** + * Check whether the content already contains a BMAD badge. + * @param {string} content - README file content + * @returns {boolean} True if badge is present + */ function hasBadge(content) { return BADGE_PATTERN.test(content); } +/** + * Generate the BMAD badge markdown line. + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @returns {string} Badge markdown string + */ function generateBadgeMarkdown(owner, repo) { return `[![BMAD](${BADGE_URL}/${owner}/${repo}.svg)](https://github.com/bmad-code-org/BMAD-METHOD)`; } +/** + * Inject the BMAD badge into README content. + * Places the badge after the first heading, alongside any existing badges. + * @param {string} content - Original README content + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @returns {string} Updated README content with badge + */ function injectBadge(content, owner, repo) { const badgeLine = generateBadgeMarkdown(owner, repo); @@ -64,11 +94,15 @@ function injectBadge(content, owner, repo) { insertAt++; } - // Insert badge line lines.splice(insertAt, 0, badgeLine); return lines.join('\n'); } +/** + * Remove the BMAD badge from README content. + * @param {string} content - README file content + * @returns {string} Cleaned README content without the badge line + */ function removeBadge(content) { return content .split('\n') @@ -76,6 +110,13 @@ function removeBadge(content) { .join('\n'); } +/** + * Create a minimal README.md content with project heading and BMAD badge. + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {string} projectName - Project name for the heading + * @returns {string} New README content + */ function createReadmeWithBadge(owner, repo, projectName) { const badgeLine = generateBadgeMarkdown(owner, repo); return `# ${projectName}\n\n${badgeLine}\n`;