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