diff --git a/tools/cli/installers/lib/ide/github-copilot.js b/tools/cli/installers/lib/ide/github-copilot.js index 059127f81..fb7fb4dbe 100644 --- a/tools/cli/installers/lib/ide/github-copilot.js +++ b/tools/cli/installers/lib/ide/github-copilot.js @@ -40,9 +40,6 @@ class GitHubCopilotSetup extends BaseIdeSetup { await this.ensureDir(agentsDir); 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 await this.cleanup(projectDir); @@ -58,11 +55,9 @@ class GitHubCopilotSetup extends BaseIdeSetup { for (const artifact of agentArtifacts) { const agentMeta = agentManifest.get(artifact.name); - // Compute fileName first so we can look up any existing tool permissions const dashName = toDashPath(artifact.relativePath); const fileName = dashName.replace(/\.md$/, '.agent.md'); - const toolsStr = this.getToolsForFile(fileName); - const agentContent = this.createAgentContent(artifact, agentMeta, toolsStr); + const agentContent = this.createAgentContent(artifact, agentMeta); const targetPath = path.join(agentsDir, fileName); await this.writeFile(targetPath, agentContent); agentCount++; @@ -147,9 +142,14 @@ class GitHubCopilotSetup extends BaseIdeSetup { * @param {Object|undefined} manifestEntry - Agent manifest entry with metadata * @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 let description; + // Use the raw agent name (e.g., "dev", "pm") for clean @mention selection + const name = artifact.name; if (manifestEntry) { const persona = manifestEntry.displayName || 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.`; } + const safeName = this.escapeYamlSingleQuote(name); + // Build the agent file path for the activation block const agentPath = artifact.agentPath || artifact.relativePath; const agentFilePath = `{project-root}/${this.bmadFolderName}/${agentPath}`; return `--- +name: '${safeName}' description: '${description.replaceAll("'", "''")}' -tools: ${toolsStr} --- 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); 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) { const command = entry.command; if (!command) continue; // Skip entries without a command (tech-writer commands have no command column) const workflowFile = entry['workflow-file']; if (!workflowFile) continue; // Skip entries with no workflow file path - const promptFileName = `${command}.prompt.md`; - const toolsStr = this.getToolsForFile(promptFileName); - const promptContent = this.createWorkflowPromptContent(entry, workflowFile, toolsStr); + + // When multiple entries share the same command, derive a unique filename from the entry name + 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); await this.writeFile(promptPath, promptContent); promptCount++; @@ -228,8 +254,7 @@ You must fully embody this agent's persona and follow all activation instruction for (const artifact of agentArtifacts) { const agentMeta = agentManifest.get(artifact.name); const fileName = `bmad-${artifact.name}.prompt.md`; - const toolsStr = this.getToolsForFile(fileName); - const promptContent = this.createAgentActivatorPromptContent(artifact, agentMeta, toolsStr); + const promptContent = this.createAgentActivatorPromptContent(artifact, agentMeta); const promptPath = path.join(promptsDir, fileName); await this.writeFile(promptPath, promptContent); promptCount++; @@ -245,8 +270,9 @@ You must fully embody this agent's persona and follow all activation instruction * @param {string} workflowFile - Workflow file path * @returns {string} Prompt file content */ - createWorkflowPromptContent(entry, workflowFile, toolsStr) { + createWorkflowPromptContent(entry, workflowFile) { 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 // 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`; @@ -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}`; } + // 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 `--- -description: '${description}' -agent: 'agent' -tools: ${toolsStr} +name: '${promptName}' +description: '${description}'${agentLine} --- -${body} +${body}${optionsInstruction} `; } @@ -341,13 +390,15 @@ ${body} const cmd = techWriterCommands[entry.name]; if (!cmd) return null; + const safeName = this.escapeYamlSingleQuote(entry.name); 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 = `--- -description: '${safeDescription}' -agent: 'agent' -tools: ${toolsStr} +name: '${safeName}' +description: '${safeDescription}'${agentLine} --- 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 * @returns {string} Prompt file content */ - createAgentActivatorPromptContent(artifact, manifestEntry, toolsStr) { + createAgentActivatorPromptContent(artifact, manifestEntry) { let description; if (manifestEntry) { description = manifestEntry.title || this.formatTitle(artifact.name); @@ -372,19 +423,21 @@ tools: ${toolsStr} 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 agentPath = artifact.agentPath || artifact.relativePath; const agentFilePath = `{project-root}/${this.bmadFolderName}/${agentPath}`; - // bmm/config.yaml is safe to hardcode: agent activators are only generated from - // bmm agent artifacts, so bmm is guaranteed to be installed. + const moduleName = artifact.module || 'bmm'; return `--- +name: '${safeName}' description: '${safeDescription}' -agent: 'agent' -tools: ${toolsStr} +agent: '${safeName}' --- -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} 3. Follow ALL activation instructions in the agent file 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("'", "''"); } - /** - * Scan existing agent and prompt files for customised tool permissions before cleanup. - * Returns a Map 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 */