fix(installer): always include bmad-help in Copilot agents picker

Adds a single, deliberate exception to the toml-based agents-only filter:
`bmad-help` is the structural meta-skill across BMAD — the orientation
helper that points users at every other skill. Users invoke it
persona-style ("ask the helper") even though it has no `[agent]`
customize.toml of its own (it isn't a configurable persona).

Implemented as a one-element ALWAYS_AGENT_IDS set rather than a hardcode
in the function body so the exception is named, documented, and
discoverable. The skill is structurally unique — there is no second
meta-help skill — so this is not the start of a growing allowlist; it's
a one-off for the one orientation surface BMAD ships.

Verified on disk: agents picker now shows 21 entries (20 personas via
[agent] in customize.toml + bmad-help). bmad-agent-builder stays
correctly excluded (its customize.toml has [workflow], not [agent]).

Tests: extends Suite 17 with a `bmad-help` fixture (no customize.toml,
must still appear in agents picker). 389 tests pass.

Refs #2267

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jheyworth 2026-04-29 10:53:03 +01:00
parent 75e1fa2323
commit 9ed88e428a
2 changed files with 26 additions and 3 deletions

View File

@ -576,7 +576,7 @@ async function runTests() {
// Extend the fixture to exercise the agents-only filter, which detects // Extend the fixture to exercise the agents-only filter, which detects
// persona agents by the `[agent]` section in each skill's source // persona agents by the `[agent]` section in each skill's source
// customize.toml. Three skill types covered: // customize.toml. Five skill types covered:
// //
// 1. Persona agent — has customize.toml with [agent] → INCLUDED // 1. Persona agent — has customize.toml with [agent] → INCLUDED
// 2. Persona with non-conventional id — also has [agent] → INCLUDED // 2. Persona with non-conventional id — also has [agent] → INCLUDED
@ -585,6 +585,9 @@ async function runTests() {
// persona — has customize.toml with [workflow] → EXCLUDED // persona — has customize.toml with [workflow] → EXCLUDED
// (mirrors `bmad-agent-builder` in the real manifest) // (mirrors `bmad-agent-builder` in the real manifest)
// 4. Workflow skill — no customize.toml at all → EXCLUDED // 4. Workflow skill — no customize.toml at all → EXCLUDED
// 5. `bmad-help` — structural exception via ALWAYS_AGENT_IDS;
// has no customize.toml of its own but surfaces in the
// agents picker because it's the meta-help skill → INCLUDED
const fixtureCsvPath17 = path.join(installedBmadDir17, '_config', 'skill-manifest.csv'); const fixtureCsvPath17 = path.join(installedBmadDir17, '_config', 'skill-manifest.csv');
await fs.writeFile( await fs.writeFile(
fixtureCsvPath17, fixtureCsvPath17,
@ -594,6 +597,7 @@ async function runTests() {
'"bmad-agent-fixture","bmad-agent-fixture","Persona agent — customize.toml has [agent], SHOULD appear","core","_bmad/core/bmad-agent-fixture/SKILL.md"', '"bmad-agent-fixture","bmad-agent-fixture","Persona agent — customize.toml has [agent], SHOULD appear","core","_bmad/core/bmad-agent-fixture/SKILL.md"',
'"bmad-tea","bmad-tea","Non-conventional id but [agent] in customize.toml — SHOULD appear","core","_bmad/core/bmad-tea/SKILL.md"', '"bmad-tea","bmad-tea","Non-conventional id but [agent] in customize.toml — SHOULD appear","core","_bmad/core/bmad-tea/SKILL.md"',
'"bmad-agent-builder","bmad-agent-builder","Skill-builder workflow — id contains -agent- but customize.toml has [workflow] — should NOT appear","core","_bmad/core/bmad-agent-builder/SKILL.md"', '"bmad-agent-builder","bmad-agent-builder","Skill-builder workflow — id contains -agent- but customize.toml has [workflow] — should NOT appear","core","_bmad/core/bmad-agent-builder/SKILL.md"',
'"bmad-help","bmad-help","Meta-help skill — no customize.toml but ALWAYS_AGENT_IDS exception; SHOULD appear in agents picker","core","_bmad/core/bmad-help/SKILL.md"',
'', '',
].join('\n'), ].join('\n'),
); );
@ -603,7 +607,7 @@ async function runTests() {
// SKILL.md files were already populated by createTestBmadFixture (they // SKILL.md files were already populated by createTestBmadFixture (they
// share the bmad-master target_dir layout); only the customize.toml // share the bmad-master target_dir layout); only the customize.toml
// and the new agent fixtures need to be created here. // and the new agent fixtures need to be created here.
for (const id of ['bmad-agent-fixture', 'bmad-tea', 'bmad-agent-builder']) { for (const id of ['bmad-agent-fixture', 'bmad-tea', 'bmad-agent-builder', 'bmad-help']) {
const dir17 = path.join(installedBmadDir17, 'core', id); const dir17 = path.join(installedBmadDir17, 'core', id);
await fs.ensureDir(dir17); await fs.ensureDir(dir17);
await fs.writeFile( await fs.writeFile(
@ -611,6 +615,9 @@ async function runTests() {
['---', `name: ${id}`, `description: fixture for ${id}`, '---', '', `Body of ${id}.`].join('\n'), ['---', `name: ${id}`, `description: fixture for ${id}`, '---', '', `Body of ${id}.`].join('\n'),
); );
} }
// Note: bmad-help intentionally has NO customize.toml — it's the
// structural exception for which the ALWAYS_AGENT_IDS allowlist
// exists.
// [agent] customize.toml for the two persona fixtures. // [agent] customize.toml for the two persona fixtures.
await fs.writeFile( await fs.writeFile(
path.join(installedBmadDir17, 'core', 'bmad-agent-fixture', 'customize.toml'), path.join(installedBmadDir17, 'core', 'bmad-agent-fixture', 'customize.toml'),
@ -672,6 +679,7 @@ async function runTests() {
const agentFileForTea17 = path.join(agentsDir17, 'bmad-tea.agent.md'); const agentFileForTea17 = path.join(agentsDir17, 'bmad-tea.agent.md');
const agentFileForWorkflow17 = path.join(agentsDir17, 'bmad-master.agent.md'); const agentFileForWorkflow17 = path.join(agentsDir17, 'bmad-master.agent.md');
const agentFileForMetaSkill17 = path.join(agentsDir17, 'bmad-agent-builder.agent.md'); const agentFileForMetaSkill17 = path.join(agentsDir17, 'bmad-agent-builder.agent.md');
const agentFileForBmadHelp17 = path.join(agentsDir17, 'bmad-help.agent.md');
assert( assert(
await fs.pathExists(agentFileForPersona17), await fs.pathExists(agentFileForPersona17),
@ -679,6 +687,10 @@ async function runTests() {
); );
assert(await fs.pathExists(agentFileForTea17), 'Non-conventional id with [agent] in customize.toml is included (no allowlist needed)'); assert(await fs.pathExists(agentFileForTea17), 'Non-conventional id with [agent] in customize.toml is included (no allowlist needed)');
assert(!(await fs.pathExists(agentFileForWorkflow17)), 'Workflow skill (no customize.toml) is FILTERED OUT of .github/agents/'); assert(!(await fs.pathExists(agentFileForWorkflow17)), 'Workflow skill (no customize.toml) is FILTERED OUT of .github/agents/');
assert(
await fs.pathExists(agentFileForBmadHelp17),
'bmad-help is INCLUDED in agents picker via ALWAYS_AGENT_IDS exception (structural meta-skill, no customize.toml)',
);
assert( assert(
!(await fs.pathExists(agentFileForMetaSkill17)), !(await fs.pathExists(agentFileForMetaSkill17)),
'Meta-skill with -agent- in id but [workflow] in customize.toml is FILTERED OUT (signal is behavior, not naming)', 'Meta-skill with -agent- in id but [workflow] in customize.toml is FILTERED OUT (signal is behavior, not naming)',

View File

@ -57,6 +57,15 @@ function isSafeCanonicalId(value) {
// OpenCode's native `@skills/<id>` skill-reference syntax. // OpenCode's native `@skills/<id>` skill-reference syntax.
const DEFAULT_COMMANDS_BODY_TEMPLATE = '@skills/{canonicalId}'; const DEFAULT_COMMANDS_BODY_TEMPLATE = '@skills/{canonicalId}';
// `bmad-help` is the structural meta-skill across BMAD: the orientation
// helper that points users at every other skill. It is invoked
// persona-style ("ask the helper") even though it has no [agent]
// customize.toml of its own (it isn't a configurable persona). Surfacing
// it in agents-picker contexts mirrors how users actually reach for it,
// and the inclusion is unique and stable — there is no second meta-help
// skill to encourage growth of this exception.
const ALWAYS_AGENT_IDS = new Set(['bmad-help']);
// Is this skill a persona agent (vs. a workflow/tool/standalone skill)? // 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 // Used by platforms that surface only persona agents (e.g. Copilot's Custom
// Agents picker). Signal: the skill's source `customize.toml` has an // Agents picker). Signal: the skill's source `customize.toml` has an
@ -68,13 +77,15 @@ const DEFAULT_COMMANDS_BODY_TEMPLATE = '@skills/{canonicalId}';
// GDS, WDS, TEA, and correctly excludes meta-skills like // GDS, WDS, TEA, and correctly excludes meta-skills like
// `bmad-agent-builder` (a skill-builder workflow whose canonical id // `bmad-agent-builder` (a skill-builder workflow whose canonical id
// contains `-agent-` but which has no [agent] section because it isn't a // contains `-agent-` but which has no [agent] section because it isn't a
// persona itself). // persona itself). Plus the explicit `ALWAYS_AGENT_IDS` set for the one
// structural exception (`bmad-help`).
// //
// Reading the source toml — at install time the source skill directory // Reading the source toml — at install time the source skill directory
// (resolved from manifest record.path) still exists; cleanup runs later // (resolved from manifest record.path) still exists; cleanup runs later
// in the install flow. // in the install flow.
async function isAgentSkill(record, bmadDir) { async function isAgentSkill(record, bmadDir) {
if (!record?.path || !bmadDir) return false; if (!record?.path || !bmadDir) return false;
if (record.canonicalId && ALWAYS_AGENT_IDS.has(record.canonicalId)) return true;
const bmadFolderName = path.basename(bmadDir); const bmadFolderName = path.basename(bmadDir);
const bmadPrefix = bmadFolderName + '/'; const bmadPrefix = bmadFolderName + '/';
const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path; const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path;