Compare commits
No commits in common. "5e0a8ea2f39ffac157599daf17027d8fba632ebd" and "46a3d854f360343f18ede8b3519749d3b6732f38" have entirely different histories.
5e0a8ea2f3
...
46a3d854f3
|
|
@ -557,39 +557,10 @@ async function runTests() {
|
||||||
const copilotInstaller = platformCodes17.platforms['github-copilot']?.installer;
|
const copilotInstaller = platformCodes17.platforms['github-copilot']?.installer;
|
||||||
|
|
||||||
assert(copilotInstaller?.target_dir === '.agents/skills', 'GitHub Copilot target_dir uses native skills path');
|
assert(copilotInstaller?.target_dir === '.agents/skills', 'GitHub Copilot target_dir uses native skills path');
|
||||||
assert(
|
|
||||||
copilotInstaller?.commands_target_dir === '.github/agents',
|
|
||||||
'GitHub Copilot commands_target_dir is configured for the Custom Agents picker',
|
|
||||||
);
|
|
||||||
assert(copilotInstaller?.commands_extension === '.agent.md', 'GitHub Copilot uses .agent.md extension for Custom Agents files');
|
|
||||||
assert(
|
|
||||||
typeof copilotInstaller?.commands_body_template === 'string' && copilotInstaller.commands_body_template.includes('{canonicalId}'),
|
|
||||||
'GitHub Copilot defines a commands_body_template with {canonicalId} placeholder',
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
copilotInstaller?.commands_filter === 'agents-only',
|
|
||||||
'GitHub Copilot filters Custom Agents picker to persona agents only (agents-only)',
|
|
||||||
);
|
|
||||||
|
|
||||||
const tempProjectDir17 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-copilot-test-'));
|
const tempProjectDir17 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-copilot-test-'));
|
||||||
const installedBmadDir17 = await createTestBmadFixture();
|
const installedBmadDir17 = await createTestBmadFixture();
|
||||||
|
|
||||||
// Extend the fixture: add a persona-style agent skill (`-agent-` in id)
|
|
||||||
// and the `bmad-tea` non-conventional agent so we can verify that the
|
|
||||||
// agents-only filter routes them to .github/agents/ while leaving the
|
|
||||||
// workflow-style `bmad-master` out.
|
|
||||||
const fixtureCsvPath17 = path.join(installedBmadDir17, '_config', 'skill-manifest.csv');
|
|
||||||
await fs.writeFile(
|
|
||||||
fixtureCsvPath17,
|
|
||||||
[
|
|
||||||
'canonicalId,name,description,module,path',
|
|
||||||
'"bmad-master","bmad-master","Workflow-style fixture (no -agent- in id, should NOT appear in Copilot agents picker)","core","_bmad/core/bmad-master/SKILL.md"',
|
|
||||||
'"bmad-agent-fixture","bmad-agent-fixture","Persona agent fixture (-agent- in id, SHOULD appear in Copilot agents picker)","core","_bmad/core/bmad-agent-fixture/SKILL.md"',
|
|
||||||
'"bmad-tea","bmad-tea","Non-conventional persona agent fixture (Murat-style, SHOULD appear despite no -agent- segment)","core","_bmad/core/bmad-tea/SKILL.md"',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const copilotInstructionsPath17 = path.join(tempProjectDir17, '.github', 'copilot-instructions.md');
|
const copilotInstructionsPath17 = path.join(tempProjectDir17, '.github', 'copilot-instructions.md');
|
||||||
await fs.ensureDir(path.dirname(copilotInstructionsPath17));
|
await fs.ensureDir(path.dirname(copilotInstructionsPath17));
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
|
|
@ -625,44 +596,6 @@ async function runTests() {
|
||||||
'GitHub Copilot setup preserves user content in copilot-instructions.md',
|
'GitHub Copilot setup preserves user content in copilot-instructions.md',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Custom Agents picker integration: persona agents (and bmad-tea) get
|
|
||||||
// .agent.md files in .github/agents/. Workflows do NOT — the
|
|
||||||
// agents-only filter keeps the picker uncluttered.
|
|
||||||
const agentsDir17 = path.join(tempProjectDir17, '.github', 'agents');
|
|
||||||
const agentFileForPersona17 = path.join(agentsDir17, 'bmad-agent-fixture.agent.md');
|
|
||||||
const agentFileForTea17 = path.join(agentsDir17, 'bmad-tea.agent.md');
|
|
||||||
const agentFileForWorkflow17 = path.join(agentsDir17, 'bmad-master.agent.md');
|
|
||||||
|
|
||||||
assert(await fs.pathExists(agentFileForPersona17), 'Persona agent (-agent- in id) gets a .agent.md file in .github/agents/');
|
|
||||||
assert(await fs.pathExists(agentFileForTea17), 'bmad-tea persona (non-conventional id) is allowlisted into .github/agents/');
|
|
||||||
assert(
|
|
||||||
!(await fs.pathExists(agentFileForWorkflow17)),
|
|
||||||
'Workflow skill (no -agent- in id, not in allowlist) is FILTERED OUT of .github/agents/',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Body content of the persona agent file: frontmatter description +
|
|
||||||
// LOAD pattern referencing the skill's SKILL.md path under target_dir.
|
|
||||||
const personaAgentContent17 = await fs.readFile(agentFileForPersona17, 'utf8');
|
|
||||||
assert(
|
|
||||||
personaAgentContent17.includes('description:'),
|
|
||||||
'Copilot agent pointer carries a description in YAML frontmatter (drives the agents picker label)',
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
personaAgentContent17.includes('{project-root}/.agents/skills/bmad-agent-fixture/SKILL.md'),
|
|
||||||
'Copilot agent pointer body resolves to the skill via LOAD {project-root}/<target_dir>/<id>/SKILL.md',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Idempotency: re-running setup must not duplicate or rewrite the agent
|
|
||||||
// pointer when the source manifest is unchanged, AND must not start
|
|
||||||
// emitting workflow-skill agent files.
|
|
||||||
const result17b = await ideManager17.setup('github-copilot', tempProjectDir17, installedBmadDir17, {
|
|
||||||
silent: true,
|
|
||||||
selectedModules: ['bmm'],
|
|
||||||
});
|
|
||||||
assert(result17b.success === true, 'Second GitHub Copilot install succeeds (idempotent)');
|
|
||||||
assert(await fs.pathExists(agentFileForPersona17), 'Persona agent pointer survives a second install pass');
|
|
||||||
assert(!(await fs.pathExists(agentFileForWorkflow17)), 'Workflow skill remains filtered out of agents picker on second install');
|
|
||||||
|
|
||||||
await fs.remove(tempProjectDir17);
|
await fs.remove(tempProjectDir17);
|
||||||
await fs.remove(path.dirname(installedBmadDir17));
|
await fs.remove(path.dirname(installedBmadDir17));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -52,44 +52,11 @@ function isSafeCanonicalId(value) {
|
||||||
return typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(value) && !value.includes('..');
|
return typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(value) && !value.includes('..');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default body template for command pointer files. Used when a platform's
|
|
||||||
// installer config doesn't override `commands_body_template`. Matches
|
|
||||||
// OpenCode's native `@skills/<id>` skill-reference syntax.
|
|
||||||
const DEFAULT_COMMANDS_BODY_TEMPLATE = '@skills/{canonicalId}';
|
|
||||||
|
|
||||||
// Persona-agent id outside the `-agent-` naming convention.
|
|
||||||
// TEA's Murat is the only persona whose canonical id is the bare module code
|
|
||||||
// rather than `<module>-agent-<role>`. Listed here so platforms that filter
|
|
||||||
// for "agents only" (e.g. GitHub Copilot's Custom Agents picker) include it.
|
|
||||||
const NON_CONVENTIONAL_AGENT_IDS = new Set(['bmad-tea']);
|
|
||||||
|
|
||||||
// Is this skill a persona agent (vs. a workflow/tool/standalone skill)?
|
|
||||||
// Used by platforms that surface only persona agents (e.g. Copilot's Custom
|
|
||||||
// Agents picker). Rule: canonical id contains `-agent-` OR is in the
|
|
||||||
// known non-conventional allowlist. Tested against the full installed
|
|
||||||
// manifest — catches all 20 description-confirmed personas across BMM,
|
|
||||||
// CIS, GDS, WDS, TEA without false positives.
|
|
||||||
function isAgentSkill(canonicalId) {
|
|
||||||
if (typeof canonicalId !== 'string') return false;
|
|
||||||
if (NON_CONVENTIONAL_AGENT_IDS.has(canonicalId)) return true;
|
|
||||||
return canonicalId.includes('-agent-');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve placeholders in a body template. Supported placeholders:
|
|
||||||
// {canonicalId} — the skill's canonical id
|
|
||||||
// {target_dir} — the platform's skill install directory (e.g. .agents/skills)
|
|
||||||
// {project-root} — left as a literal placeholder for the model/tool to expand
|
|
||||||
// at runtime; consistent with PR #1769's templates.
|
|
||||||
function expandBodyTemplate(template, { canonicalId, targetDir }) {
|
|
||||||
return template.replaceAll('{canonicalId}', canonicalId).replaceAll('{target_dir}', targetDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The exact body the installer would generate for a given description and
|
// The exact body the installer would generate for a given description and
|
||||||
// canonicalId, given the platform's body template. Centralised so both the
|
// canonicalId. Centralised so both the write and the freshness-check paths
|
||||||
// write and the freshness-check paths agree on the canonical form.
|
// agree on the canonical form.
|
||||||
function buildCommandPointerBody(description, canonicalId, { template, targetDir }) {
|
function buildCommandPointerBody(description, canonicalId) {
|
||||||
const bodyText = expandBodyTemplate(template, { canonicalId, targetDir });
|
return `---\ndescription: ${yamlSafeSingleLine(description)}\n---\n\n@skills/${canonicalId}\n`;
|
||||||
return `---\ndescription: ${yamlSafeSingleLine(description)}\n---\n\n${bodyText}\n`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heuristic: does an existing pointer file look like our generator's output
|
// Heuristic: does an existing pointer file look like our generator's output
|
||||||
|
|
@ -97,12 +64,11 @@ function buildCommandPointerBody(description, canonicalId, { template, targetDir
|
||||||
// preserve)? We check the body shape rather than full equality so that
|
// preserve)? We check the body shape rather than full equality so that
|
||||||
// description-only edits in the manifest can propagate without trampling
|
// description-only edits in the manifest can propagate without trampling
|
||||||
// hand edits to the body.
|
// hand edits to the body.
|
||||||
function looksLikeGeneratorOutput(content, canonicalId, { template, targetDir }) {
|
function looksLikeGeneratorOutput(content, canonicalId) {
|
||||||
if (typeof content !== 'string') return false;
|
if (typeof content !== 'string') return false;
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
const expectedTail = expandBodyTemplate(template, { canonicalId, targetDir }).trim();
|
// Must end with the exact reference line our generator writes.
|
||||||
// Must end with the exact body our generator writes (post-expansion).
|
if (!trimmed.endsWith(`@skills/${canonicalId}`)) return false;
|
||||||
if (!trimmed.endsWith(expectedTail)) return false;
|
|
||||||
// Must start with frontmatter containing exactly one description: line.
|
// Must start with frontmatter containing exactly one description: line.
|
||||||
const fmMatch = trimmed.match(/^---\n([\S\s]*?)\n---\n/);
|
const fmMatch = trimmed.match(/^---\n([\S\s]*?)\n---\n/);
|
||||||
if (!fmMatch) return false;
|
if (!fmMatch) return false;
|
||||||
|
|
@ -283,7 +249,6 @@ class ConfigDrivenIdeSetup {
|
||||||
skippedExisting: 0,
|
skippedExisting: 0,
|
||||||
skippedCollision: 0,
|
skippedCollision: 0,
|
||||||
skippedInvalidId: 0,
|
skippedInvalidId: 0,
|
||||||
skippedFiltered: 0,
|
|
||||||
writeFailures: 0,
|
writeFailures: 0,
|
||||||
fallbackDescription: 0,
|
fallbackDescription: 0,
|
||||||
};
|
};
|
||||||
|
|
@ -294,12 +259,6 @@ class ConfigDrivenIdeSetup {
|
||||||
const commandsPath = path.join(projectDir, config.commands_target_dir);
|
const commandsPath = path.join(projectDir, config.commands_target_dir);
|
||||||
await fs.ensureDir(commandsPath);
|
await fs.ensureDir(commandsPath);
|
||||||
|
|
||||||
// Per-platform pointer-file shape, all overrideable in platform-codes.yaml.
|
|
||||||
const extension = config.commands_extension || '.md';
|
|
||||||
const template = config.commands_body_template || DEFAULT_COMMANDS_BODY_TEMPLATE;
|
|
||||||
const targetDir = config.target_dir;
|
|
||||||
const filter = config.commands_filter || null;
|
|
||||||
|
|
||||||
const csvContent = await fs.readFile(csvPath, 'utf8');
|
const csvContent = await fs.readFile(csvPath, 'utf8');
|
||||||
const records = csv.parse(csvContent, { columns: true, skip_empty_lines: true });
|
const records = csv.parse(csvContent, { columns: true, skip_empty_lines: true });
|
||||||
|
|
||||||
|
|
@ -315,15 +274,6 @@ class ConfigDrivenIdeSetup {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional per-platform filter: surfaces that should only show
|
|
||||||
// persona agents (e.g. Copilot's Custom Agents picker) skip
|
|
||||||
// workflow/tool skills here so the picker isn't cluttered with
|
|
||||||
// 90+ unrelated entries.
|
|
||||||
if (filter === 'agents-only' && !isAgentSkill(canonicalId)) {
|
|
||||||
result.skippedFiltered++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reserved-name guard is OpenCode-specific. Other adapters that opt
|
// Reserved-name guard is OpenCode-specific. Other adapters that opt
|
||||||
// into commands_target_dir later should declare their own reserved
|
// into commands_target_dir later should declare their own reserved
|
||||||
// set rather than inheriting OpenCode's.
|
// set rather than inheriting OpenCode's.
|
||||||
|
|
@ -338,8 +288,8 @@ class ConfigDrivenIdeSetup {
|
||||||
result.fallbackDescription++;
|
result.fallbackDescription++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = buildCommandPointerBody(description, canonicalId, { template, targetDir });
|
const body = buildCommandPointerBody(description, canonicalId);
|
||||||
const commandFile = path.join(commandsPath, `${canonicalId}${extension}`);
|
const commandFile = path.join(commandsPath, `${canonicalId}.md`);
|
||||||
|
|
||||||
// If a pointer file already exists, decide whether to overwrite based
|
// If a pointer file already exists, decide whether to overwrite based
|
||||||
// on whether it looks like generator output (description-only diff) or
|
// on whether it looks like generator output (description-only diff) or
|
||||||
|
|
@ -359,7 +309,7 @@ class ConfigDrivenIdeSetup {
|
||||||
result.skippedExisting++;
|
result.skippedExisting++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (looksLikeGeneratorOutput(existing, canonicalId, { template, targetDir })) {
|
if (looksLikeGeneratorOutput(existing, canonicalId)) {
|
||||||
// Description (or other generated bit) has changed; refresh in place.
|
// Description (or other generated bit) has changed; refresh in place.
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(commandFile, body, 'utf8');
|
await fs.writeFile(commandFile, body, 'utf8');
|
||||||
|
|
@ -367,7 +317,7 @@ class ConfigDrivenIdeSetup {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
result.writeFailures++;
|
result.writeFailures++;
|
||||||
if (!options.silent) {
|
if (!options.silent) {
|
||||||
await prompts.log.warn(`Failed to update command pointer ${canonicalId}${extension}: ${error.message}`);
|
await prompts.log.warn(`Failed to update command pointer ${canonicalId}.md: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -383,7 +333,7 @@ class ConfigDrivenIdeSetup {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
result.writeFailures++;
|
result.writeFailures++;
|
||||||
if (!options.silent) {
|
if (!options.silent) {
|
||||||
await prompts.log.warn(`Failed to write command pointer ${canonicalId}${extension}: ${error.message}`);
|
await prompts.log.warn(`Failed to write command pointer ${canonicalId}.md: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -536,15 +486,7 @@ class ConfigDrivenIdeSetup {
|
||||||
// so its pointers should go with it.
|
// so its pointers should go with it.
|
||||||
const isInstallFlow = options.previousSkillIds && options.previousSkillIds.size > 0;
|
const isInstallFlow = options.previousSkillIds && options.previousSkillIds.size > 0;
|
||||||
const activeSkillIds = isInstallFlow ? await this._readActiveSkillIds(resolvedBmadDir) : new Set();
|
const activeSkillIds = isInstallFlow ? await this._readActiveSkillIds(resolvedBmadDir) : new Set();
|
||||||
const extension = this.installerConfig.commands_extension || '.md';
|
await this.cleanupCommandPointers(projectDir, this.installerConfig.commands_target_dir, options, removalSet, activeSkillIds);
|
||||||
await this.cleanupCommandPointers(
|
|
||||||
projectDir,
|
|
||||||
this.installerConfig.commands_target_dir,
|
|
||||||
options,
|
|
||||||
removalSet,
|
|
||||||
activeSkillIds,
|
|
||||||
extension,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip target_dir cleanup when a peer platform owns this directory
|
// Skip target_dir cleanup when a peer platform owns this directory
|
||||||
|
|
@ -648,9 +590,9 @@ class ConfigDrivenIdeSetup {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleanup generated command pointer files for entries in removalSet.
|
* Cleanup generated command pointer files for entries in removalSet.
|
||||||
* Symmetric counterpart to installCommandPointers — removes
|
* Symmetric counterpart to installCommandPointers — removes <canonicalId>.md
|
||||||
* `<canonicalId><extension>` files whose canonicalId is in the set. Removes
|
* files whose canonicalId is in the set. Removes the commands directory
|
||||||
* the commands directory entirely if it ends up empty.
|
* entirely if it ends up empty.
|
||||||
* @param {string} projectDir
|
* @param {string} projectDir
|
||||||
* @param {string} commandsTargetDir - Relative dir (e.g. .opencode/commands)
|
* @param {string} commandsTargetDir - Relative dir (e.g. .opencode/commands)
|
||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
|
|
@ -661,19 +603,8 @@ class ConfigDrivenIdeSetup {
|
||||||
* same skills are about to be re-installed) doesn't wipe hand-edited
|
* same skills are about to be re-installed) doesn't wipe hand-edited
|
||||||
* pointer files. Pass an empty set or omit to delete every match in
|
* pointer files. Pass an empty set or omit to delete every match in
|
||||||
* removalSet (uninstall flow).
|
* removalSet (uninstall flow).
|
||||||
* @param {string} [extension] - Pointer file extension (default '.md');
|
|
||||||
* matches the platform's commands_extension config value so cleanup
|
|
||||||
* correctly identifies pointer files for IDEs whose convention isn't .md
|
|
||||||
* (e.g. Copilot's `.agent.md`).
|
|
||||||
*/
|
*/
|
||||||
async cleanupCommandPointers(
|
async cleanupCommandPointers(projectDir, commandsTargetDir, options = {}, removalSet = new Set(), activeSkillIds = new Set()) {
|
||||||
projectDir,
|
|
||||||
commandsTargetDir,
|
|
||||||
options = {},
|
|
||||||
removalSet = new Set(),
|
|
||||||
activeSkillIds = new Set(),
|
|
||||||
extension = '.md',
|
|
||||||
) {
|
|
||||||
if (!removalSet || removalSet.size === 0) return;
|
if (!removalSet || removalSet.size === 0) return;
|
||||||
|
|
||||||
const commandsPath = path.join(projectDir, commandsTargetDir);
|
const commandsPath = path.join(projectDir, commandsTargetDir);
|
||||||
|
|
@ -687,8 +618,8 @@ class ConfigDrivenIdeSetup {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.endsWith(extension)) continue;
|
if (!entry.endsWith('.md')) continue;
|
||||||
const canonicalId = entry.slice(0, -extension.length);
|
const canonicalId = entry.slice(0, -3);
|
||||||
if (!removalSet.has(canonicalId)) continue;
|
if (!removalSet.has(canonicalId)) continue;
|
||||||
// Spare pointers for skills that are still in the manifest; the
|
// Spare pointers for skills that are still in the manifest; the
|
||||||
// install pass will refresh them in place if their content has gone
|
// install pass will refresh them in place if their content has gone
|
||||||
|
|
|
||||||
|
|
@ -132,17 +132,6 @@ platforms:
|
||||||
installer:
|
installer:
|
||||||
target_dir: .agents/skills
|
target_dir: .agents/skills
|
||||||
global_target_dir: ~/.agents/skills
|
global_target_dir: ~/.agents/skills
|
||||||
commands_target_dir: .github/agents
|
|
||||||
commands_extension: .agent.md
|
|
||||||
commands_body_template: "LOAD the FULL {project-root}/{target_dir}/{canonicalId}/SKILL.md, READ its entire contents and follow its directions exactly!"
|
|
||||||
# The Custom Agents picker should only show persona agents (not
|
|
||||||
# workflows/tools). BMAD's persona agents are conventionally named
|
|
||||||
# with an `-agent-` segment in their canonical id (e.g.
|
|
||||||
# `bmad-agent-pm`, `gds-agent-game-dev`, `wds-agent-freya-ux`,
|
|
||||||
# `bmad-cis-agent-storyteller`). The one exception is `bmad-tea` —
|
|
||||||
# TEA's Murat persona uses the module code as its id rather than the
|
|
||||||
# `-agent-` convention. This filter keeps the picker uncluttered.
|
|
||||||
commands_filter: agents-only
|
|
||||||
|
|
||||||
goose:
|
goose:
|
||||||
name: "Block Goose"
|
name: "Block Goose"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue