Skip to content

Commit 4a4a7c9

Browse files
nickclaude
andcommitted
feat(installer): auto-inject BMAD version badge into project README
- Add badge.js module for git remote resolution, README detection, and badge injection - Integrate badge prompt into install flow with --no-badge opt-out - Support badge update when owner/repo changes on re-install - Auto-create README.md with badge if missing - Pass badge config through Config.build() for both install and quick-update paths Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9debc16 commit 4a4a7c9

5 files changed

Lines changed: 244 additions & 0 deletions

File tree

tools/installer/commands/install.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ module.exports = {
3535
['--output-folder <path>', 'Output folder path relative to project root (default: _bmad-output)'],
3636
['--custom-source <sources>', 'Comma-separated Git URLs or local paths to install custom modules from'],
3737
['-y, --yes', 'Accept all defaults and skip prompts where possible'],
38+
['--no-badge', 'Skip adding BMAD badge to README'],
3839
[
3940
'--channel <channel>',
4041
'Apply channel (stable|next) to all external modules being installed. --all-stable and --all-next are aliases.',
@@ -96,6 +97,41 @@ module.exports = {
9697

9798
const config = await ui.promptInstall(options);
9899

100+
// Ask about badge unless --no-badge or --yes
101+
if (options.badge === false) {
102+
config.noBadge = true;
103+
} else if (options.yes) {
104+
config.noBadge = false;
105+
} else {
106+
config.noBadge = !(await prompts.confirm({
107+
message: 'Add BMAD badge to your README?',
108+
default: true,
109+
}));
110+
}
111+
112+
// Resolve owner/repo for badge (git remote → prompt fallback)
113+
if (!config.noBadge) {
114+
const badge = require('../core/badge');
115+
let remote = badge.resolveGitRemote(config.directory);
116+
if (!remote) {
117+
const input = await prompts.text({
118+
message: 'Enter your GitHub owner/repo for the badge (e.g., nick/my-project):',
119+
placeholder: 'owner/repo',
120+
validate: (v) => (!v || !v.includes('/') ? 'Format: owner/repo' : undefined),
121+
});
122+
if (input) {
123+
const [owner, repo] = input.split('/');
124+
remote = { owner, repo };
125+
}
126+
}
127+
if (remote) {
128+
config.badgeOwner = remote.owner;
129+
config.badgeRepo = remote.repo;
130+
} else {
131+
config.noBadge = true;
132+
}
133+
}
134+
99135
// Handle cancel
100136
if (config.actionType === 'cancel') {
101137
await prompts.log.warn('Installation cancelled.');

tools/installer/commands/uninstall.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,20 @@ module.exports = {
139139
s.start(`Removing BMAD modules & data (${installer.bmadFolderName}/)...`);
140140
await installer.uninstallModules(projectDir);
141141
s.stop('Modules & data removed');
142+
143+
// Remove BMAD badge from README (best-effort)
144+
try {
145+
const badge = require('../core/badge');
146+
const readmePath = await badge.findReadme(projectDir);
147+
if (readmePath) {
148+
const content = await fs.readFile(readmePath, 'utf8');
149+
if (badge.hasBadge(content)) {
150+
await fs.writeFile(readmePath, badge.removeBadge(content), 'utf8');
151+
}
152+
}
153+
} catch (error) {
154+
await prompts.log.warn(`Badge cleanup skipped: ${error.message}`);
155+
}
142156
}
143157

144158
const summary = [];

tools/installer/core/badge.js

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
const path = require('node:path');
2+
const { execSync } = require('node:child_process');
3+
const fs = require('../fs-native');
4+
5+
const BADGE_URL = 'https://bmad-badge.vercel.app';
6+
const escapedBadgeUrl = BADGE_URL.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
7+
const BADGE_PATTERN = new RegExp(`\\[!\\[BMAD\\]\\(${escapedBadgeUrl}/[^)]+\\)\\]\\(https://github\\.com/bmad-code-org/BMAD-METHOD\\)`);
8+
const README_NAMES = ['README.md', 'readme.md', 'README', 'readme'];
9+
10+
/**
11+
* Resolve owner and repo from the project's git remote origin URL.
12+
* Supports HTTPS and SSH formats.
13+
* @param {string} projectDir - Project root directory
14+
* @returns {{ owner: string, repo: string } | null} Parsed owner/repo or null
15+
*/
16+
function resolveGitRemote(projectDir) {
17+
try {
18+
const raw = execSync('git remote get-url origin', {
19+
cwd: projectDir,
20+
encoding: 'utf8',
21+
stdio: ['pipe', 'pipe', 'pipe'],
22+
}).trim();
23+
24+
const httpsMatch = raw.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/i);
25+
if (httpsMatch) {
26+
return { owner: httpsMatch[1], repo: httpsMatch[2] };
27+
}
28+
} catch {
29+
// no git remote
30+
}
31+
return null;
32+
}
33+
34+
/**
35+
* Find the first README file in the project directory.
36+
* Checks common README naming variants (case-insensitive).
37+
* @param {string} projectDir - Project root directory
38+
* @returns {Promise<string | null>} Absolute path to README or null
39+
*/
40+
async function findReadme(projectDir) {
41+
for (const name of README_NAMES) {
42+
const fullPath = path.join(projectDir, name);
43+
if (await fs.pathExists(fullPath)) {
44+
return fullPath;
45+
}
46+
}
47+
return null;
48+
}
49+
50+
/**
51+
* Check whether the content already contains a BMAD badge.
52+
* @param {string} content - README file content
53+
* @returns {boolean} True if badge is present
54+
*/
55+
function hasBadge(content) {
56+
return BADGE_PATTERN.test(content);
57+
}
58+
59+
/**
60+
* Generate the BMAD badge markdown line.
61+
* @param {string} owner - Repository owner
62+
* @param {string} repo - Repository name
63+
* @returns {string} Badge markdown string
64+
*/
65+
function generateBadgeMarkdown(owner, repo) {
66+
return `[![BMAD](${BADGE_URL}/${owner}/${repo}.svg)](https://github.com/bmad-code-org/BMAD-METHOD)`;
67+
}
68+
69+
/**
70+
* Inject the BMAD badge into README content.
71+
* Places the badge after the first heading, alongside any existing badges.
72+
* @param {string} content - Original README content
73+
* @param {string} owner - Repository owner
74+
* @param {string} repo - Repository name
75+
* @returns {string} Updated README content with badge
76+
*/
77+
function injectBadge(content, owner, repo) {
78+
const badgeLine = generateBadgeMarkdown(owner, repo);
79+
80+
const lines = content.split('\n');
81+
82+
// Find the first heading (# title)
83+
let headingEnd = 0;
84+
for (const [i, line] of lines.entries()) {
85+
headingEnd = i + 1;
86+
if (line.startsWith('#')) break;
87+
}
88+
89+
// Check if there are existing badges right after the heading
90+
let insertAt = headingEnd;
91+
while (insertAt < lines.length && /^\[!\[.*?\]\(.*?\)\]\(.*?\)/.test(lines[insertAt].trim())) {
92+
insertAt++;
93+
}
94+
95+
lines.splice(insertAt, 0, badgeLine);
96+
return lines.join('\n');
97+
}
98+
99+
/**
100+
* Remove the BMAD badge from README content.
101+
* @param {string} content - README file content
102+
* @returns {string} Cleaned README content without the badge line
103+
*/
104+
function removeBadge(content) {
105+
return content
106+
.split('\n')
107+
.filter((line) => !BADGE_PATTERN.test(line.trim()))
108+
.join('\n');
109+
}
110+
111+
/**
112+
* Create a minimal README.md content with project heading and BMAD badge.
113+
* @param {string} owner - Repository owner
114+
* @param {string} repo - Repository name
115+
* @param {string} projectName - Project name for the heading
116+
* @returns {string} New README content
117+
*/
118+
function createReadmeWithBadge(owner, repo, projectName) {
119+
const badgeLine = generateBadgeMarkdown(owner, repo);
120+
return `# ${projectName}\n\n${badgeLine}\n`;
121+
}
122+
123+
module.exports = {
124+
resolveGitRemote,
125+
findReadme,
126+
hasBadge,
127+
generateBadgeMarkdown,
128+
injectBadge,
129+
removeBadge,
130+
createReadmeWithBadge,
131+
};

tools/installer/core/config.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ class Config {
1515
quickUpdate,
1616
channelOptions,
1717
setOverrides,
18+
noBadge,
19+
badgeOwner,
20+
badgeRepo,
1821
}) {
1922
this.directory = directory;
2023
this.modules = Object.freeze([...modules]);
@@ -32,6 +35,9 @@ class Config {
3235
// Intentionally NOT integrated with the prompt/template/schema flow; see
3336
// `tools/installer/set-overrides.js` for the rationale and tradeoffs.
3437
this.setOverrides = setOverrides || {};
38+
this.noBadge = noBadge || false;
39+
this.badgeOwner = badgeOwner || null;
40+
this.badgeRepo = badgeRepo || null;
3541
Object.freeze(this);
3642
}
3743

@@ -58,6 +64,9 @@ class Config {
5864
quickUpdate: userInput._quickUpdate || false,
5965
channelOptions: userInput.channelOptions || null,
6066
setOverrides: userInput.setOverrides || {},
67+
noBadge: userInput.noBadge || false,
68+
badgeOwner: userInput.badgeOwner || null,
69+
badgeRepo: userInput.badgeRepo || null,
6170
});
6271
}
6372

tools/installer/core/installer.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,15 @@ class Installer {
103103

104104
const restoreResult = await this._restoreUserFiles(paths, updateState);
105105

106+
// Inject BMAD badge into README if applicable
107+
if (!config.noBadge) {
108+
try {
109+
await this._injectBadgeIfNeeded(paths.projectRoot, addResult, config);
110+
} catch (error) {
111+
addResult('Badge', 'warn', `skipped: ${error.message}`);
112+
}
113+
}
114+
106115
// Render consolidated summary
107116
await this.renderInstallSummary(results, {
108117
bmadDir: paths.bmadDir,
@@ -1038,6 +1047,48 @@ class Installer {
10381047
}
10391048
}
10401049

1050+
/**
1051+
* Inject BMAD version badge into project README.
1052+
* Uses owner/repo from config (resolved in UI layer).
1053+
* @param {string} projectDir - Project root directory
1054+
* @param {Function} addResult - Callback to record results
1055+
* @param {Object} config - Installation config with badgeOwner/badgeRepo
1056+
*/
1057+
async _injectBadgeIfNeeded(projectDir, addResult, config) {
1058+
const badge = require('../core/badge');
1059+
1060+
const owner = config.badgeOwner;
1061+
const repo = config.badgeRepo;
1062+
if (!owner || !repo) {
1063+
addResult('Badge', 'warn', 'no owner/repo provided');
1064+
return;
1065+
}
1066+
1067+
const readmePath = await badge.findReadme(projectDir);
1068+
if (!readmePath) {
1069+
const projectName = path.basename(projectDir);
1070+
const content = badge.createReadmeWithBadge(owner, repo, projectName);
1071+
const newReadmePath = path.join(projectDir, 'README.md');
1072+
await fs.writeFile(newReadmePath, content, 'utf8');
1073+
addResult('Badge', 'ok', 'created README.md with badge');
1074+
return;
1075+
}
1076+
1077+
const content = await fs.readFile(readmePath, 'utf8');
1078+
if (badge.hasBadge(content)) {
1079+
// Update badge if owner/repo changed
1080+
const updated = badge.removeBadge(content);
1081+
const injected = badge.injectBadge(updated, owner, repo);
1082+
await fs.writeFile(readmePath, injected, 'utf8');
1083+
addResult('Badge', 'ok', `updated in ${path.basename(readmePath)}`);
1084+
return;
1085+
}
1086+
1087+
const updated = badge.injectBadge(content, owner, repo);
1088+
await fs.writeFile(readmePath, updated, 'utf8');
1089+
addResult('Badge', 'ok', `added to ${path.basename(readmePath)}`);
1090+
}
1091+
10411092
/**
10421093
* Render a consolidated install summary using prompts.note()
10431094
* @param {Array} results - Array of {step, status: 'ok'|'error'|'warn', detail}
@@ -1294,6 +1345,9 @@ class Installer {
12941345
directory: projectDir,
12951346
modules: modulesToUpdate,
12961347
ides: configuredIdes,
1348+
noBadge: config.noBadge,
1349+
badgeOwner: config.badgeOwner,
1350+
badgeRepo: config.badgeRepo,
12971351
coreConfig: quickModules.collectedConfig.core,
12981352
moduleConfigs: quickModules.collectedConfig,
12991353
// Forward `--set` overrides so the post-install patch step

0 commit comments

Comments
 (0)