feat: preserve customised tool permissions across reinstalls

Before this change, reinstalling would overwrite any user-customised
tools arrays in agent and prompt frontmatter with the hardcoded default.

Now the installer reads existing tool permissions from .agent.md and
.prompt.md files before cleanup, and re-applies them to the regenerated
files. Falls back to the default ['read', 'edit', 'search', 'execute']
for new files or files without prior customisation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jheyworth 2026-02-09 09:49:32 +00:00
parent 719fa3f731
commit 88afcb5a88
1 changed files with 72 additions and 15 deletions

View File

@ -40,6 +40,9 @@ 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);
@ -54,11 +57,12 @@ class GitHubCopilotSetup extends BaseIdeSetup {
let agentCount = 0;
for (const artifact of agentArtifacts) {
const agentMeta = agentManifest.get(artifact.name);
const agentContent = this.createAgentContent(artifact, agentMeta);
// Use toDashPath for naming, then replace .md → .agent.md
// 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 targetPath = path.join(agentsDir, fileName);
await this.writeFile(targetPath, agentContent);
agentCount++;
@ -149,7 +153,7 @@ class GitHubCopilotSetup extends BaseIdeSetup {
* @param {Object|undefined} manifestEntry - Agent manifest entry with metadata
* @returns {string} Agent file content
*/
createAgentContent(artifact, manifestEntry) {
createAgentContent(artifact, manifestEntry, toolsStr) {
// Build enriched description from manifest metadata
let description;
if (manifestEntry) {
@ -167,7 +171,7 @@ class GitHubCopilotSetup extends BaseIdeSetup {
return `---
description: '${description.replaceAll("'", "''")}'
tools: ['read', 'edit', 'search', 'execute']
tools: ${toolsStr}
disable-model-invocation: true
---
@ -228,8 +232,10 @@ You must fully embody this agent's persona and follow all activation instruction
const workflowFile = entry['workflow-file'];
if (!workflowFile) continue; // Skip entries with no workflow file path
const promptContent = this.createWorkflowPromptContent(entry, workflowFile);
const promptPath = path.join(promptsDir, `${command}.prompt.md`);
const promptFileName = `${command}.prompt.md`;
const toolsStr = this.getToolsForFile(promptFileName);
const promptContent = this.createWorkflowPromptContent(entry, workflowFile, toolsStr);
const promptPath = path.join(promptsDir, promptFileName);
await this.writeFile(promptPath, promptContent);
promptCount++;
}
@ -239,7 +245,8 @@ You must fully embody this agent's persona and follow all activation instruction
if (entry.command) continue; // Already handled above
const techWriterPrompt = this.createTechWriterPromptContent(entry);
if (techWriterPrompt) {
const promptPath = path.join(promptsDir, `${techWriterPrompt.fileName}.prompt.md`);
const promptFileName = `${techWriterPrompt.fileName}.prompt.md`;
const promptPath = path.join(promptsDir, promptFileName);
await this.writeFile(promptPath, techWriterPrompt.content);
promptCount++;
}
@ -249,10 +256,9 @@ You must fully embody this agent's persona and follow all activation instruction
// Generate agent activator prompts (Pattern D)
for (const artifact of agentArtifacts) {
const agentMeta = agentManifest.get(artifact.name);
const promptContent = this.createAgentActivatorPromptContent(artifact, agentMeta);
// Naming: bmad-{agent-id}.prompt.md
const fileName = `bmad-${artifact.name}.prompt.md`;
const toolsStr = this.getToolsForFile(fileName);
const promptContent = this.createAgentActivatorPromptContent(artifact, agentMeta, toolsStr);
const promptPath = path.join(promptsDir, fileName);
await this.writeFile(promptPath, promptContent);
promptCount++;
@ -268,7 +274,7 @@ 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) {
createWorkflowPromptContent(entry, workflowFile, toolsStr) {
const description = this.escapeYamlSingleQuote(this.createPromptDescription(entry.name));
// 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.
@ -293,7 +299,7 @@ You must fully embody this agent's persona and follow all activation instruction
return `---
description: '${description}'
agent: 'agent'
tools: ['read', 'edit', 'search', 'execute']
tools: ${toolsStr}
---
${body}
@ -365,11 +371,12 @@ ${body}
if (!cmd) return null;
const safeDescription = this.escapeYamlSingleQuote(cmd.description);
const toolsStr = this.getToolsForFile(`${cmd.file}.prompt.md`);
const content = `---
description: '${safeDescription}'
agent: 'agent'
tools: ['read', 'edit', 'search', 'execute']
tools: ${toolsStr}
---
1. Load {project-root}/_bmad/bmm/config.yaml and store ALL fields as session variables
@ -386,7 +393,7 @@ tools: ['read', 'edit', 'search', 'execute']
* @param {Object|undefined} manifestEntry - Agent manifest entry
* @returns {string} Prompt file content
*/
createAgentActivatorPromptContent(artifact, manifestEntry) {
createAgentActivatorPromptContent(artifact, manifestEntry, toolsStr) {
let description;
if (manifestEntry) {
description = manifestEntry.title || this.formatTitle(artifact.name);
@ -403,7 +410,7 @@ tools: ['read', 'edit', 'search', 'execute']
return `---
description: '${safeDescription}'
agent: 'agent'
tools: ['read', 'edit', 'search', 'execute']
tools: ${toolsStr}
---
1. Load {project-root}/_bmad/bmm/config.yaml and store ALL fields as session variables
@ -559,6 +566,56 @@ 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<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
*/