This commit is contained in:
Hayden Carson 2026-03-07 19:07:31 +08:00 committed by GitHub
commit b1d2824691
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 81 additions and 78 deletions

View File

@ -40,9 +40,6 @@ class GitHubCopilotSetup extends BaseIdeSetup {
await this.ensureDir(agentsDir); await this.ensureDir(agentsDir);
await this.ensureDir(promptsDir); await this.ensureDir(promptsDir);
// Preserve any customised tool permissions from existing files before cleanup
this.existingToolPermissions = await this.collectExistingToolPermissions(projectDir);
// Clean up any existing BMAD files before reinstalling // Clean up any existing BMAD files before reinstalling
await this.cleanup(projectDir); await this.cleanup(projectDir);
@ -58,11 +55,9 @@ class GitHubCopilotSetup extends BaseIdeSetup {
for (const artifact of agentArtifacts) { for (const artifact of agentArtifacts) {
const agentMeta = agentManifest.get(artifact.name); const agentMeta = agentManifest.get(artifact.name);
// Compute fileName first so we can look up any existing tool permissions
const dashName = toDashPath(artifact.relativePath); const dashName = toDashPath(artifact.relativePath);
const fileName = dashName.replace(/\.md$/, '.agent.md'); const fileName = dashName.replace(/\.md$/, '.agent.md');
const toolsStr = this.getToolsForFile(fileName); const agentContent = this.createAgentContent(artifact, agentMeta);
const agentContent = this.createAgentContent(artifact, agentMeta, toolsStr);
const targetPath = path.join(agentsDir, fileName); const targetPath = path.join(agentsDir, fileName);
await this.writeFile(targetPath, agentContent); await this.writeFile(targetPath, agentContent);
agentCount++; agentCount++;
@ -147,9 +142,14 @@ class GitHubCopilotSetup extends BaseIdeSetup {
* @param {Object|undefined} manifestEntry - Agent manifest entry with metadata * @param {Object|undefined} manifestEntry - Agent manifest entry with metadata
* @returns {string} Agent file content * @returns {string} Agent file content
*/ */
createAgentContent(artifact, manifestEntry, toolsStr) { createAgentContent(artifact, manifestEntry) {
if (!artifact?.name) {
throw new Error('Agent artifact must have a name');
}
// Build enriched description from manifest metadata // Build enriched description from manifest metadata
let description; let description;
// Use the raw agent name (e.g., "dev", "pm") for clean @mention selection
const name = artifact.name;
if (manifestEntry) { if (manifestEntry) {
const persona = manifestEntry.displayName || artifact.name; const persona = manifestEntry.displayName || artifact.name;
const title = manifestEntry.title || this.formatTitle(artifact.name); const title = manifestEntry.title || this.formatTitle(artifact.name);
@ -159,13 +159,15 @@ class GitHubCopilotSetup extends BaseIdeSetup {
description = `Activates the ${this.formatTitle(artifact.name)} agent persona.`; description = `Activates the ${this.formatTitle(artifact.name)} agent persona.`;
} }
const safeName = this.escapeYamlSingleQuote(name);
// Build the agent file path for the activation block // Build the agent file path for the activation block
const agentPath = artifact.agentPath || artifact.relativePath; const agentPath = artifact.agentPath || artifact.relativePath;
const agentFilePath = `{project-root}/${this.bmadFolderName}/${agentPath}`; const agentFilePath = `{project-root}/${this.bmadFolderName}/${agentPath}`;
return `--- return `---
name: '${safeName}'
description: '${description.replaceAll("'", "''")}' description: '${description.replaceAll("'", "''")}'
tools: ${toolsStr}
--- ---
You must fully embody this agent's persona and follow all activation instructions exactly as specified. You must fully embody this agent's persona and follow all activation instructions exactly as specified.
@ -197,15 +199,39 @@ You must fully embody this agent's persona and follow all activation instruction
const helpEntries = await this.loadBmadHelp(bmadDir); const helpEntries = await this.loadBmadHelp(bmadDir);
if (helpEntries) { if (helpEntries) {
// Detect duplicate commands to derive unique filenames when multiple entries share one
const commandCounts = new Map();
for (const entry of helpEntries) {
if (!entry.command || !entry['workflow-file']) continue;
commandCounts.set(entry.command, (commandCounts.get(entry.command) || 0) + 1);
}
const seenSlugs = new Set();
for (const entry of helpEntries) { for (const entry of helpEntries) {
const command = entry.command; const command = entry.command;
if (!command) continue; // Skip entries without a command (tech-writer commands have no command column) if (!command) continue; // Skip entries without a command (tech-writer commands have no command column)
const workflowFile = entry['workflow-file']; const workflowFile = entry['workflow-file'];
if (!workflowFile) continue; // Skip entries with no workflow file path if (!workflowFile) continue; // Skip entries with no workflow file path
const promptFileName = `${command}.prompt.md`;
const toolsStr = this.getToolsForFile(promptFileName); // When multiple entries share the same command, derive a unique filename from the entry name
const promptContent = this.createWorkflowPromptContent(entry, workflowFile, toolsStr); let promptFileName;
if (commandCounts.get(command) > 1) {
let slug = entry.name.toLowerCase().replaceAll(/[^a-z0-9]+/g, '-').replaceAll(/^-+|-+$/g, '');
if (!slug) {
slug = `unnamed-${promptCount}`;
}
// Guard against slug collisions
while (seenSlugs.has(slug)) {
slug = `${slug}-${promptCount}`;
}
seenSlugs.add(slug);
promptFileName = `bmad-bmm-${slug}.prompt.md`;
} else {
promptFileName = `${command}.prompt.md`;
}
const promptContent = this.createWorkflowPromptContent(entry, workflowFile);
const promptPath = path.join(promptsDir, promptFileName); const promptPath = path.join(promptsDir, promptFileName);
await this.writeFile(promptPath, promptContent); await this.writeFile(promptPath, promptContent);
promptCount++; promptCount++;
@ -228,8 +254,7 @@ You must fully embody this agent's persona and follow all activation instruction
for (const artifact of agentArtifacts) { for (const artifact of agentArtifacts) {
const agentMeta = agentManifest.get(artifact.name); const agentMeta = agentManifest.get(artifact.name);
const fileName = `bmad-${artifact.name}.prompt.md`; const fileName = `bmad-${artifact.name}.prompt.md`;
const toolsStr = this.getToolsForFile(fileName); const promptContent = this.createAgentActivatorPromptContent(artifact, agentMeta);
const promptContent = this.createAgentActivatorPromptContent(artifact, agentMeta, toolsStr);
const promptPath = path.join(promptsDir, fileName); const promptPath = path.join(promptsDir, fileName);
await this.writeFile(promptPath, promptContent); await this.writeFile(promptPath, promptContent);
promptCount++; promptCount++;
@ -245,8 +270,9 @@ You must fully embody this agent's persona and follow all activation instruction
* @param {string} workflowFile - Workflow file path * @param {string} workflowFile - Workflow file path
* @returns {string} Prompt file content * @returns {string} Prompt file content
*/ */
createWorkflowPromptContent(entry, workflowFile, toolsStr) { createWorkflowPromptContent(entry, workflowFile) {
const description = this.escapeYamlSingleQuote(this.createPromptDescription(entry.name)); const description = this.escapeYamlSingleQuote(this.createPromptDescription(entry.name));
const promptName = this.escapeYamlSingleQuote(entry.name || description);
// bmm/config.yaml is safe to hardcode here: these prompts are only generated when // bmm/config.yaml is safe to hardcode here: these prompts are only generated when
// bmad-help.csv exists (bmm module data), so bmm is guaranteed to be installed. // bmad-help.csv exists (bmm module data), so bmm is guaranteed to be installed.
const configLine = `1. Load {project-root}/${this.bmadFolderName}/bmm/config.yaml and store ALL fields as session variables`; const configLine = `1. Load {project-root}/${this.bmadFolderName}/bmm/config.yaml and store ALL fields as session variables`;
@ -267,13 +293,36 @@ You must fully embody this agent's persona and follow all activation instruction
2. Load and follow the workflow at {project-root}/${workflowFile}`; 2. Load and follow the workflow at {project-root}/${workflowFile}`;
} }
// Build the agent line: use raw agent name to match agent .agent.md name field
const agentName = (entry['agent-name'] || '').trim();
let agentLine = '';
if (agentName) {
agentLine = `\nagent: '${this.escapeYamlSingleQuote(agentName)}'`;
}
// Include options (e.g., "Create Mode", "Validate Mode") when present
const options = (entry.options || '').trim();
let optionsInstruction = '';
if (options) {
// Determine the next step number based on the last numbered step in the body
let nextStepNumber = 4;
const stepMatches = body.match(/(?:^|\n)(\d+)\.\s/g);
if (stepMatches && stepMatches.length > 0) {
const lastMatch = stepMatches.at(-1);
const numberMatch = lastMatch.match(/(\d+)\.\s/);
if (numberMatch) {
nextStepNumber = parseInt(numberMatch[1], 10) + 1;
}
}
optionsInstruction = `\n${nextStepNumber}. Use option: ${options}`;
}
return `--- return `---
description: '${description}' name: '${promptName}'
agent: 'agent' description: '${description}'${agentLine}
tools: ${toolsStr}
--- ---
${body} ${body}${optionsInstruction}
`; `;
} }
@ -341,13 +390,15 @@ ${body}
const cmd = techWriterCommands[entry.name]; const cmd = techWriterCommands[entry.name];
if (!cmd) return null; if (!cmd) return null;
const safeName = this.escapeYamlSingleQuote(entry.name);
const safeDescription = this.escapeYamlSingleQuote(cmd.description); const safeDescription = this.escapeYamlSingleQuote(cmd.description);
const toolsStr = this.getToolsForFile(`${cmd.file}.prompt.md`);
// Use raw agent name to match agent .agent.md name field
const agentLine = `\nagent: '${this.escapeYamlSingleQuote(entry['agent-name'])}'`;
const content = `--- const content = `---
description: '${safeDescription}' name: '${safeName}'
agent: 'agent' description: '${safeDescription}'${agentLine}
tools: ${toolsStr}
--- ---
1. Load {project-root}/${this.bmadFolderName}/bmm/config.yaml and store ALL fields as session variables 1. Load {project-root}/${this.bmadFolderName}/bmm/config.yaml and store ALL fields as session variables
@ -364,7 +415,7 @@ tools: ${toolsStr}
* @param {Object|undefined} manifestEntry - Agent manifest entry * @param {Object|undefined} manifestEntry - Agent manifest entry
* @returns {string} Prompt file content * @returns {string} Prompt file content
*/ */
createAgentActivatorPromptContent(artifact, manifestEntry, toolsStr) { createAgentActivatorPromptContent(artifact, manifestEntry) {
let description; let description;
if (manifestEntry) { if (manifestEntry) {
description = manifestEntry.title || this.formatTitle(artifact.name); description = manifestEntry.title || this.formatTitle(artifact.name);
@ -372,19 +423,21 @@ tools: ${toolsStr}
description = this.formatTitle(artifact.name); description = this.formatTitle(artifact.name);
} }
// Use the raw agent name (e.g., "dev") to match agent .agent.md name field
const name = artifact.name;
const safeName = this.escapeYamlSingleQuote(name);
const safeDescription = this.escapeYamlSingleQuote(description); const safeDescription = this.escapeYamlSingleQuote(description);
const agentPath = artifact.agentPath || artifact.relativePath; const agentPath = artifact.agentPath || artifact.relativePath;
const agentFilePath = `{project-root}/${this.bmadFolderName}/${agentPath}`; const agentFilePath = `{project-root}/${this.bmadFolderName}/${agentPath}`;
// bmm/config.yaml is safe to hardcode: agent activators are only generated from const moduleName = artifact.module || 'bmm';
// bmm agent artifacts, so bmm is guaranteed to be installed.
return `--- return `---
name: '${safeName}'
description: '${safeDescription}' description: '${safeDescription}'
agent: 'agent' agent: '${safeName}'
tools: ${toolsStr}
--- ---
1. Load {project-root}/${this.bmadFolderName}/bmm/config.yaml and store ALL fields as session variables 1. Load {project-root}/${this.bmadFolderName}/${moduleName}/config.yaml and store ALL fields as session variables
2. Load the full agent file from ${agentFilePath} 2. Load the full agent file from ${agentFilePath}
3. Follow ALL activation instructions in the agent file 3. Follow ALL activation instructions in the agent file
4. Display the welcome/greeting as instructed 4. Display the welcome/greeting as instructed
@ -534,56 +587,6 @@ Type \`/bmad-\` in Copilot Chat to see all available BMAD workflows and agent ac
return (value || '').replaceAll("'", "''"); return (value || '').replaceAll("'", "''");
} }
/**
* Scan existing agent and prompt files for customised tool permissions before cleanup.
* Returns a Map<filename, toolsArray> so permissions can be preserved across reinstalls.
* @param {string} projectDir - Project directory
* @returns {Map} Existing tool permissions keyed by filename
*/
async collectExistingToolPermissions(projectDir) {
const permissions = new Map();
const dirs = [
[path.join(projectDir, this.githubDir, this.agentsDir), /^bmad.*\.agent\.md$/],
[path.join(projectDir, this.githubDir, this.promptsDir), /^bmad-.*\.prompt\.md$/],
];
for (const [dir, pattern] of dirs) {
if (!(await fs.pathExists(dir))) continue;
const files = await fs.readdir(dir);
for (const file of files) {
if (!pattern.test(file)) continue;
try {
const content = await fs.readFile(path.join(dir, file), 'utf8');
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!fmMatch) continue;
const frontmatter = yaml.parse(fmMatch[1]);
if (frontmatter && Array.isArray(frontmatter.tools)) {
permissions.set(file, frontmatter.tools);
}
} catch {
// Skip unreadable files
}
}
}
return permissions;
}
/**
* Get the tools array string for a file, preserving any existing customisation.
* Falls back to the default tools if no prior customisation exists.
* @param {string} fileName - Target filename (e.g. 'bmad-agent-bmm-pm.agent.md')
* @returns {string} YAML inline array string
*/
getToolsForFile(fileName) {
const defaultTools = ['read', 'edit', 'search', 'execute'];
const tools = (this.existingToolPermissions && this.existingToolPermissions.get(fileName)) || defaultTools;
return '[' + tools.map((t) => `'${t}'`).join(', ') + ']';
}
/** /**
* Format name as title * Format name as title
*/ */