Compare commits

..

3 Commits
v6.6.0 ... main

Author SHA1 Message Date
Alex Verkhovsky e36f219c81
refactor(catalog): rename after/before to preceded-by/followed-by (#2360)
* refactor(catalog): rename after/before columns to preceded-by/followed-by

The bare prepositions `after` and `before` had no subject anchor, leaving
the dependency direction ambiguous: "X has Y in its `after` column" reads
plausibly as either "Y comes after X" or "X comes after Y". An LLM
catalog consumer just got the direction wrong because of this.

`preceded-by` / `followed-by` are passive-voice participles whose grammar
locks the subject (the skill in this row) and forces a single reading:
"X is preceded by Y" can only mean Y comes first.

Rename applied to:
- module-help.csv headers (bmm-skills, core-skills)
- bmad-help SKILL.md schema doc + descriptions
- installer.js mergeModuleHelpCatalogs header string
- plugin-resolver.js _buildSynthesizedHelpCsv header string
- bmad-manifest.json keys (bmad-product-brief, bmad-prfaq)
- distillate-format-reference.md example manifest

The separate `required` column continues to carry hard-gate semantics;
the renamed columns are pure soft sequencing hints, as already documented
in bmad-help.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(installer): wrap long header strings per prettier

* feat(installer): warn on non-canonical module-help.csv headers

mergeModuleHelpCatalogs now compares each per-module file's header
against the canonical schema and emits a one-shot prompts.log.warn per
module on drift, naming both the expected and actual header. Data
continues to load positionally so external modules built against the
old after/before schema still install cleanly — the warning is the
maintainer signal to rename their columns.

Centralize the canonical header in modules/module-help-schema.js so the
merger and the synthesizer (PluginResolver._buildSynthesizedHelpCsv)
read the same source of truth; future column renames are one edit.

Verified by installing all four bmad-org external modules
(bmb, cis, gds, tea) — every one ships the legacy after/before header
today and now fires an advisory warning while still merging cleanly
into _bmad/_config/bmad-help.csv with the canonical column names.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:28:50 -07:00
jheyworth 9debc165aa
fix(installer): remove bmad-help from Copilot Custom Agents picker (#2359)
* fix(installer): remove bmad-help from Copilot Custom Agents picker

Per @BMadCode's feedback after #2324 merged: every persona agent's
activation message already advertises bmad-help, so its picker entry is
redundant AND confusing (looks like a peer agent when it's actually the
meta-help). Removes the ALWAYS_AGENT_IDS allowlist exception that put it
there.

The toml-driven filter (the mechanism BMadCode endorsed in his PR review)
remains the sole signal: a skill is a persona iff its source
customize.toml has an [agent] section. bmad-help has no customize.toml,
so under the cleaned-up filter it's correctly excluded.

Tests: replaces the inclusion assertion in Suite 17 with an exclusion
assertion. Suite still covers persona / non-conventional persona /
workflow / meta-skill-with-`-agent-`-in-name cases.

Refs #2324

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: clarify Suite 17 fixture comment per PR review

The fixture creates no customize.toml at all for bmad-help, so the
exclusion path being exercised is the missing-file branch — not the
file-without-[agent]-section branch. Reword the comment accordingly.

Per @augmentcode review on #2359.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:03:02 -05:00
jheyworth 65b810a11f
fix(installer): generate slash-command and Agent pointer files (OpenCode + GitHub Copilot) (#2324)
* fix(installer): generate OpenCode /<skill> slash commands

Adds .opencode/commands/<canonicalId>.md pointer files for each installed
skill so users can invoke skills directly (e.g. /bmad-quick-dev) instead
of going through the /skills menu.

- platform-codes.yaml: add commands_target_dir field for opencode
- _config-driven.js: installCommandPointers() with skip-if-exists default,
  reserved-name collision guard, YAML-safe description quoting
- _config-driven.js: cleanupCommandPointers() for symmetric uninstall
- test-installation-components.js: extend OpenCode suite with assertions
  covering pointer creation, content, and idempotency

OpenCode-only and opt-in via the new yaml field; other adapters unchanged.

Refs #2267

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(installer): address PR #2324 review feedback

Six fixes from CodeRabbit + Augment review on the OpenCode command
pointer generation:

- skipTarget no longer suppresses installCommandPointers in multi-IDE
  shared-target_dir batches. Pointers live in a per-IDE directory and
  are not deduped across peers, so OpenCode must still generate them
  even when a peer (e.g. openhands) won the .agents/skills write race.
- skipTarget no longer suppresses cleanupCommandPointers either, so
  partial uninstalls leave no stale pointers when a peer remains.
- canonicalId is validated as a safe basename before being interpolated
  into a file path (defense in depth against a malformed manifest entry
  writing outside commands_target_dir).
- yamlSafeSingleLine now quotes descriptions starting with `[` or `{`
  so YAML doesn't parse them as a sequence/map.
- Per-record fs.writeFile failures are caught and counted (writeFailures)
  rather than aborting the whole IDE install — pointer files are a
  non-essential adjunct to the skill copy.
- Generator-shaped pointer files are refreshed when the manifest
  description changes; hand-modified files (body diverges from the
  generator pattern) are still preserved unless forceCommands is set.

Tests: extends Suite 8 with description-update propagation; adds new
Suite 40c covering OpenCode + openhands batches in both orderings plus
partial-IDE uninstall pointer cleanup. 308 tests pass (was 296).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(installer): address PR #2324 follow-up nitpicks

Four nitpicks from CodeRabbit's original review that were missed in the
first triage pass:

- Hand-edited pointers now survive the production install flow.
  cleanupCommandPointers spares pointers for canonicalIds that are still
  in the new manifest when called from the install/update flow (signal:
  options.previousSkillIds is set). Uninstall and partial-IDE removal
  flows still wipe pointers as before. The previous behavior wiped every
  pointer in removalSet before installCommandPointers could run, so its
  skip-if-exists guard never fired and hand edits were lost on every
  reinstall — contradicting the docstring's preservation claim.
- RESERVED_OPENCODE_COMMANDS is now gated on this.name === 'opencode'
  so future adapters opting into commands_target_dir don't silently
  inherit OpenCode's reserved-name set.
- printSummary now surfaces results.commands so users see how many
  pointers were created/refreshed/skipped per install, plus a warning
  for any per-file write failures.
- Dropped a dead `typeof entry !== 'string'` check; fs.readdir without
  withFileTypes always yields strings.

Tests: extends Suite 8 with a hand-edit-preservation regression that
calls setup with previousSkillIds (the production shape) and asserts a
sentinel byte sequence in the pointer body survives. 310 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(installer): extend command-pointer generation to Copilot Custom Agents

Re-scopes #2324 to cover the second user-facing pain: GitHub Copilot's
Custom Agents picker, where installed BMAD skills currently don't show up
even though slash commands work natively.

Generalizes the per-platform pointer-file mechanism so the same
installCommandPointers / cleanupCommandPointers code path serves both
OpenCode (slash commands palette) and Copilot (Custom Agents picker), with
all platform-specific shape pushed into platform-codes.yaml as data:

- commands_target_dir       — where pointer files live (existing)
- commands_extension        — file extension (default '.md'; Copilot uses
                              '.agent.md' per VS Code Custom Agents docs)
- commands_body_template    — pointer body, supports {canonicalId} and
                              {target_dir} placeholders. Default matches
                              OpenCode's `@skills/<id>` resolver. Copilot
                              has no such resolver, so its template uses
                              the {project-root}/<target_dir>/<id>/SKILL.md
                              LOAD pattern (consistent with PR #1769).

OpenCode behavior is unchanged. Copilot users now get a per-skill
.github/agents/<canonicalId>.agent.md file that surfaces the skill in the
Custom Agents picker — addressing the "agents being gone" complaint
flagged by enterprise users.

Tests: extends Suite 17 with assertions for Copilot agent pointer
creation, body content (LOAD pattern with {project-root}-rooted path),
and idempotency. 318 tests pass (was 310).

Refs #2267

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(installer): filter Copilot Custom Agents picker to persona agents only

Earlier commit naively wrote a `.github/agents/<id>.agent.md` for every
installed skill, which would clutter the Custom Agents picker with 90+
workflow/tool entries that don't belong there.

Adds an `agents-only` filter that gates the per-skill emission on whether
the canonical id signals a persona agent:

- Primary rule: id contains `-agent-` (e.g. `bmad-agent-pm`,
  `gds-agent-game-dev`, `wds-agent-freya-ux`,
  `bmad-cis-agent-storyteller`).
- Allowlist: `bmad-tea` — TEA's Murat persona uses the bare module code
  rather than the `-agent-` convention. Listed explicitly so the rule
  still surfaces it.

Verified against the full installed manifest (114 skills): catches all
20 description-confirmed personas across BMM, CIS, GDS, WDS, TEA;
excludes all 94 workflows/tools.

Wired through a new yaml field on github-copilot:

  commands_filter: agents-only

OpenCode is unaffected — it has no `commands_filter` set, so the loop
behaves as before (every skill becomes a slash command).

Tests: extends Suite 17 with a multi-skill manifest fixture covering
persona/agent + bmad-tea + workflow cases; asserts persona agents and
bmad-tea get .agent.md files while workflows do not. 322 tests pass.

Refs #2267

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(installer): detect personas via customize.toml [agent] section

Per maintainer review on PR #2324: the `-agent-` naming convention isn't
a load-bearing contract anywhere else in the codebase, and the bmad-tea
allowlist already shows it starting to break. A future persona that
doesn't follow the convention would silently disappear from the Copilot
Custom Agents picker.

Replaces the name-based filter with a behavior-based signal: read each
skill's source `customize.toml` and check for an `[agent]` section. This
is the actual configuration source of truth — every BMAD persona is
configured under `[agent]`, every workflow under `[workflow]`, every
standalone skill has no customize.toml.

Verified on disk against the full installed manifest (114 skills):

- 20 personas detected — exactly the description-confirmed count across
  BMM, CIS, GDS, WDS, TEA. bmad-tea is caught natively (no allowlist).
- 94 workflows/tools correctly excluded.
- `bmad-agent-builder` (meta-skill that builds agent skills) is now
  CORRECTLY excluded — its canonical id contains `-agent-` but its
  customize.toml has [workflow], not [agent], because it isn't a
  persona itself. The previous naming-based filter was including it in
  the agents picker, which would have been a silent UX bug.

`NON_CONVENTIONAL_AGENT_IDS` constant is removed entirely — the toml
signal subsumes it.

Tests: extends Suite 17 with a 4-skill fixture that covers persona +
non-conventional persona + workflow + meta-skill cases. 388 tests pass.

Refs #2267

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 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>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Brian <bmadcode@gmail.com>
2026-04-29 22:13:06 -05:00
12 changed files with 744 additions and 17 deletions

View File

@ -7,8 +7,8 @@
"description": "Produces battle-tested PRFAQ document and optional LLM distillate for PRD input.", "description": "Produces battle-tested PRFAQ document and optional LLM distillate for PRD input.",
"supports-headless": true, "supports-headless": true,
"phase-name": "1-analysis", "phase-name": "1-analysis",
"after": ["brainstorming", "perform-research"], "preceded-by": ["brainstorming", "perform-research"],
"before": ["create-prd"], "followed-by": ["create-prd"],
"is-required": false, "is-required": false,
"output-location": "{planning_artifacts}" "output-location": "{planning_artifacts}"
} }

View File

@ -8,8 +8,8 @@
"description": "Produces executive product brief and optional LLM distillate for PRD input.", "description": "Produces executive product brief and optional LLM distillate for PRD input.",
"supports-headless": true, "supports-headless": true,
"phase-name": "1-analysis", "phase-name": "1-analysis",
"after": ["brainstorming", "perform-research"], "preceded-by": ["brainstorming", "perform-research"],
"before": ["create-prd"], "followed-by": ["create-prd"],
"is-required": true, "is-required": true,
"output-location": "{planning_artifacts}" "output-location": "{planning_artifacts}"
} }

View File

@ -1,4 +1,4 @@
module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs
BMad Method,_meta,,,,,,,,,false,https://docs.bmad-method.org/llms.txt, BMad Method,_meta,,,,,,,,,false,https://docs.bmad-method.org/llms.txt,
BMad Method,bmad-document-project,Document Project,DP,Analyze an existing project to produce useful documentation.,,,anytime,,,false,project-knowledge,* BMad Method,bmad-document-project,Document Project,DP,Analyze an existing project to produce useful documentation.,,,anytime,,,false,project-knowledge,*
BMad Method,bmad-generate-project-context,Generate Project Context,GPC,Scan existing codebase to generate a lean LLM-optimized project-context.md. Essential for brownfield projects.,,,anytime,,,false,output_folder,project context BMad Method,bmad-generate-project-context,Generate Project Context,GPC,Scan existing codebase to generate a lean LLM-optimized project-context.md. Essential for brownfield projects.,,,anytime,,,false,output_folder,project context

1 module skill display-name menu-code description action args phase after preceded-by before followed-by required output-location outputs
2 BMad Method _meta false https://docs.bmad-method.org/llms.txt
3 BMad Method bmad-document-project Document Project DP Analyze an existing project to produce useful documentation. anytime false project-knowledge *
4 BMad Method bmad-generate-project-context Generate Project Context GPC Scan existing codebase to generate a lean LLM-optimized project-context.md. Essential for brownfield projects. anytime false output_folder project context

View File

@ -139,7 +139,7 @@ parts: 1
## Solution Architecture ## Solution Architecture
- Plugins: skill bundles with Anthropic plugin standard as base format + bmad-manifest.json extending for BMAD-specific metadata (installer options, capabilities, help integration, phase ordering, dependencies) - Plugins: skill bundles with Anthropic plugin standard as base format + bmad-manifest.json extending for BMAD-specific metadata (installer options, capabilities, help integration, phase ordering, dependencies)
- Existing manifest example: `{"module-code":"bmm","replaces-skill":"bmad-create-product-brief","capabilities":[{"name":"create-brief","menu-code":"CB","supports-headless":true,"phase-name":"1-analysis","after":["brainstorming"],"before":["create-prd"],"is-required":true}]}` - Existing manifest example: `{"module-code":"bmm","replaces-skill":"bmad-create-product-brief","capabilities":[{"name":"create-brief","menu-code":"CB","supports-headless":true,"phase-name":"1-analysis","preceded-by":["brainstorming"],"followed-by":["create-prd"],"is-required":true}]}`
- Vercel skills CLI handles platform translation; integration pattern (wrap/fork/call) is PRD decision - Vercel skills CLI handles platform translation; integration pattern (wrap/fork/call) is PRD decision
- bmad-setup: global skill scanning installed bmad-manifest.json files, registering capabilities, configuring project settings; always included as base skill in every bundle (solves bootstrapping) - bmad-setup: global skill scanning installed bmad-manifest.json files, registering capabilities, configuring project settings; always included as base skill in every bundle (solves bootstrapping)
- bmad-update: plugin update path without full reinstall; technical approach (diff/replace/preserve customizations) is PRD decision - bmad-update: plugin update path without full reinstall; technical approach (diff/replace/preserve customizations) is PRD decision

View File

@ -33,16 +33,16 @@ When this skill completes, the user should:
The catalog uses this format: The catalog uses this format:
``` ```
module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs
``` ```
**Phases** determine the high-level flow: **Phases** determine the high-level flow:
- `anytime` — available regardless of workflow state - `anytime` — available regardless of workflow state
- Numbered phases (`1-analysis`, `2-planning`, etc.) flow in order; naming varies by module - Numbered phases (`1-analysis`, `2-planning`, etc.) flow in order; naming varies by module
**Dependencies** determine ordering within and across phases: **Sequencing** determines recommended ordering within and across phases (these are soft suggestions, not hard gates — see `required` for gating):
- `after` — skills that should ideally complete before this one - `preceded-by` — skills that should ideally complete before this one
- `before` — skills that should run after this one - `followed-by` — skills that should ideally run after this one
- Format: `skill-name` for single-action skills, `skill-name:action` for multi-action skills - Format: `skill-name` for single-action skills, `skill-name:action` for multi-action skills
**Required gates**: **Required gates**:

View File

@ -1,4 +1,4 @@
module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs
Core,_meta,,,,,,,,,false,https://docs.bmad-method.org/llms.txt, Core,_meta,,,,,,,,,false,https://docs.bmad-method.org/llms.txt,
Core,bmad-brainstorming,Brainstorming,BSP,Use early in ideation or when stuck generating ideas.,,,anytime,,,false,{output_folder}/brainstorming,brainstorming session Core,bmad-brainstorming,Brainstorming,BSP,Use early in ideation or when stuck generating ideas.,,,anytime,,,false,{output_folder}/brainstorming,brainstorming session
Core,bmad-party-mode,Party Mode,PM,Orchestrate multi-agent discussions when you need multiple perspectives or want agents to collaborate.,,,anytime,,,false,, Core,bmad-party-mode,Party Mode,PM,Orchestrate multi-agent discussions when you need multiple perspectives or want agents to collaborate.,,,anytime,,,false,,

1 module skill display-name menu-code description action args phase after preceded-by before followed-by required output-location outputs
2 Core _meta false https://docs.bmad-method.org/llms.txt
3 Core bmad-brainstorming Brainstorming BSP Use early in ideation or when stuck generating ideas. anytime false {output_folder}/brainstorming brainstorming session
4 Core bmad-party-mode Party Mode PM Orchestrate multi-agent discussions when you need multiple perspectives or want agents to collaborate. anytime false

View File

@ -285,6 +285,10 @@ async function runTests() {
const opencodeInstaller = platformCodes.platforms.opencode?.installer; const opencodeInstaller = platformCodes.platforms.opencode?.installer;
assert(opencodeInstaller?.target_dir === '.agents/skills', 'OpenCode target_dir uses native skills path'); assert(opencodeInstaller?.target_dir === '.agents/skills', 'OpenCode target_dir uses native skills path');
assert(
opencodeInstaller?.commands_target_dir === '.opencode/commands',
'OpenCode commands_target_dir is configured for /<skill> slash commands',
);
const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-opencode-test-')); const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-opencode-test-'));
const installedBmadDir = await createTestBmadFixture(); const installedBmadDir = await createTestBmadFixture();
@ -301,6 +305,55 @@ async function runTests() {
const skillFile = path.join(tempProjectDir, '.agents', 'skills', 'bmad-master', 'SKILL.md'); const skillFile = path.join(tempProjectDir, '.agents', 'skills', 'bmad-master', 'SKILL.md');
assert(await fs.pathExists(skillFile), 'OpenCode install writes SKILL.md directory output'); assert(await fs.pathExists(skillFile), 'OpenCode install writes SKILL.md directory output');
// Command pointer assertions: a /<canonicalId> slash command should exist
// for each installed skill so users can invoke skills directly without
// going through the /skills menu.
const commandFile = path.join(tempProjectDir, '.opencode', 'commands', 'bmad-master.md');
assert(await fs.pathExists(commandFile), 'OpenCode install writes per-skill command pointer file');
const commandContent = await fs.readFile(commandFile, 'utf8');
assert(commandContent.includes('@skills/bmad-master'), 'Command pointer body references the skill via @skills/<canonicalId>');
assert(commandContent.includes('description:'), 'Command pointer carries a description in YAML frontmatter');
// Idempotency: re-running install must not duplicate or rewrite pointers.
const result2 = await ideManager.setup('opencode', tempProjectDir, installedBmadDir, {
silent: true,
selectedModules: ['bmm'],
});
assert(result2.success === true, 'Second OpenCode install succeeds (idempotent)');
assert(await fs.pathExists(commandFile), 'Command pointer survives a second install pass');
// Description-update propagation: when the manifest description changes
// and the on-disk pointer still matches the generator pattern, refresh
// the file so users see the updated description.
const csvPath = path.join(installedBmadDir, '_config', 'skill-manifest.csv');
const updatedCsv =
'canonicalId,name,description,module,path\n' +
'"bmad-master","bmad-master","UPDATED description for the test agent","core","_bmad/core/bmad-master/SKILL.md"\n';
await fs.writeFile(csvPath, updatedCsv);
const result3 = await ideManager.setup('opencode', tempProjectDir, installedBmadDir, {
silent: true,
selectedModules: ['bmm'],
});
assert(result3.success === true, 'Third OpenCode install succeeds after description update');
const refreshed = await fs.readFile(commandFile, 'utf8');
assert(refreshed.includes('UPDATED description'), 'Generator-shaped pointer is refreshed when manifest description changes');
// Hand-edit preservation across the production install flow. The
// installer passes previousSkillIds — without the cleanup-side spare,
// hand edits would be wiped here.
const SENTINEL = 'HAND_EDITED_BY_USER_SHOULD_SURVIVE';
const handEditedBody = `---\ndescription: my custom description\n---\n\n${SENTINEL}\n`;
await fs.writeFile(commandFile, handEditedBody);
const result4 = await ideManager.setup('opencode', tempProjectDir, installedBmadDir, {
silent: true,
selectedModules: ['bmm'],
previousSkillIds: new Set(['bmad-master']),
});
assert(result4.success === true, 'Fourth OpenCode install succeeds with hand-edited pointer present');
const afterReinstall = await fs.readFile(commandFile, 'utf8');
assert(afterReinstall.includes(SENTINEL), 'Hand-edited pointer survives a routine reinstall (cleanup spares active-manifest IDs)');
await fs.remove(tempProjectDir); await fs.remove(tempProjectDir);
await fs.remove(path.dirname(installedBmadDir)); await fs.remove(path.dirname(installedBmadDir));
} catch (error) { } catch (error) {
@ -504,10 +557,83 @@ 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 to exercise the agents-only filter, which detects
// persona agents by the `[agent]` section in each skill's source
// customize.toml. Five skill types covered:
//
// 1. Persona agent — has customize.toml with [agent] → INCLUDED
// 2. Persona with non-conventional id — also has [agent] → INCLUDED
// (verifies the filter doesn't depend on `-agent-` naming)
// 3. Meta-skill whose id contains `-agent-` but isn't a
// persona — has customize.toml with [workflow] → EXCLUDED
// (mirrors `bmad-agent-builder` in the real manifest)
// 4. Workflow skill — no customize.toml at all → EXCLUDED
// 5. `bmad-help` — meta-help skill with no customize.toml;
// every persona agent's activation already advertises it,
// so it's correctly excluded from the picker as redundant → EXCLUDED
const fixtureCsvPath17 = path.join(installedBmadDir17, '_config', 'skill-manifest.csv');
await fs.writeFile(
fixtureCsvPath17,
[
'canonicalId,name,description,module,path',
'"bmad-master","bmad-master","Workflow with no customize.toml — should NOT appear in Copilot agents picker","core","_bmad/core/bmad-master/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-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; SHOULD NOT appear in agents picker (toml-driven filter)","core","_bmad/core/bmad-help/SKILL.md"',
'',
].join('\n'),
);
// Materialise the source skill directories so the agents-only filter
// can read their customize.toml. The bmad-master and bmad-agent-builder
// SKILL.md files were already populated by createTestBmadFixture (they
// share the bmad-master target_dir layout); only the customize.toml
// and the new agent fixtures need to be created here.
for (const id of ['bmad-agent-fixture', 'bmad-tea', 'bmad-agent-builder', 'bmad-help']) {
const dir17 = path.join(installedBmadDir17, 'core', id);
await fs.ensureDir(dir17);
await fs.writeFile(
path.join(dir17, 'SKILL.md'),
['---', `name: ${id}`, `description: fixture for ${id}`, '---', '', `Body of ${id}.`].join('\n'),
);
}
// Note: bmad-help intentionally has NO customize.toml — it exercises
// the toml-driven filter's exclusion path (a skill with no
// customize.toml is correctly kept out of the Copilot agents picker).
// [agent] customize.toml for the two persona fixtures.
await fs.writeFile(
path.join(installedBmadDir17, 'core', 'bmad-agent-fixture', 'customize.toml'),
['[agent]', 'name = "Fixture Agent"', 'title = "Test Persona"', ''].join('\n'),
);
await fs.writeFile(
path.join(installedBmadDir17, 'core', 'bmad-tea', 'customize.toml'),
['[agent]', 'name = "Murat"', 'title = "Test Architect"', ''].join('\n'),
);
// [workflow] customize.toml for the meta-skill — its id contains `-agent-`
// but it is NOT a persona (mirrors bmad-agent-builder in production).
await fs.writeFile(
path.join(installedBmadDir17, 'core', 'bmad-agent-builder', 'customize.toml'),
['[workflow]', '', '# Meta-skill that builds agents but is not itself a persona.', ''].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(
@ -543,6 +669,56 @@ 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 (those with [agent]
// in their source customize.toml) get .agent.md files in
// .github/agents/. Workflows and meta-skills with [workflow] (or no
// customize.toml at all) do NOT — the agents-only filter keeps the
// picker uncluttered and the signal is naming-independent.
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');
const agentFileForMetaSkill17 = path.join(agentsDir17, 'bmad-agent-builder.agent.md');
const agentFileForBmadHelp17 = path.join(agentsDir17, 'bmad-help.agent.md');
assert(
await fs.pathExists(agentFileForPersona17),
'Persona agent ([agent] in customize.toml) gets a .agent.md file in .github/agents/',
);
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(agentFileForBmadHelp17)),
'bmad-help is excluded from Copilot agents picker (no customize.toml; allowlist removed per maintainer feedback)',
);
assert(
!(await fs.pathExists(agentFileForMetaSkill17)),
'Meta-skill with -agent- in id but [workflow] in customize.toml is FILTERED OUT (signal is behavior, not naming)',
);
// 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) {
@ -2737,6 +2913,113 @@ async function runTests() {
console.log(''); console.log('');
// ============================================================
// Test Suite 40c: OpenCode command pointers in multi-IDE batches
// ============================================================
// Regression: when OpenCode is the *peer* in a setupBatch sharing
// .agents/skills (e.g. with openhands), the skill write is dedup-skipped
// but the per-IDE .opencode/commands/ pointers must still be generated.
// Symmetrically, partial uninstall while a peer remains must still clean
// up OpenCode's own command pointers.
console.log(`${colors.yellow}Test Suite 40c: OpenCode command pointers in shared-target batches${colors.reset}\n`);
try {
clearCache();
const platformCodes40c = await loadPlatformCodes();
const opencodeTarget40c = platformCodes40c.platforms.opencode?.installer?.target_dir;
const openhandsTarget40c = platformCodes40c.platforms.openhands?.installer?.target_dir;
assert(
opencodeTarget40c === '.agents/skills' && openhandsTarget40c === '.agents/skills',
'OpenCode and OpenHands share .agents/skills target_dir',
);
// Order A: opencode first → opencode is the writer.
const projA = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-opencode-batch-a-'));
const bmadA = await createTestBmadFixture();
const mgrA = new IdeManager();
await mgrA.ensureInitialized();
const resultsA = await mgrA.setupBatch(['opencode', 'openhands'], projA, bmadA, {
silent: true,
selectedModules: ['core'],
});
const cmdA = path.join(projA, '.opencode', 'commands', 'bmad-master.md');
assert(
resultsA.every((r) => r.success === true),
'opencode-first batch: all platforms succeed',
);
assert(await fs.pathExists(cmdA), 'opencode-first batch: command pointer is created');
// Order B: openhands first → opencode is the peer (skipTarget=true).
// Without the fix, the early-return would bypass installCommandPointers.
const projB = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-opencode-batch-b-'));
const bmadB = await createTestBmadFixture();
const mgrB = new IdeManager();
await mgrB.ensureInitialized();
const resultsB = await mgrB.setupBatch(['openhands', 'opencode'], projB, bmadB, {
silent: true,
selectedModules: ['core'],
});
const cmdB = path.join(projB, '.opencode', 'commands', 'bmad-master.md');
const opencodeResultB = resultsB.find((r) => r.ide === 'opencode');
assert(
resultsB.every((r) => r.success === true),
'openhands-first batch: all platforms succeed',
);
assert(
opencodeResultB?.handlerResult?.results?.sharedTargetHandledByPeer === true,
'openhands-first batch: opencode is marked sharedTargetHandledByPeer (skill write deduped)',
);
assert(await fs.pathExists(cmdB), 'openhands-first batch: command pointer is generated even when skill write is deduped');
// Cleanup symmetry: uninstall opencode while openhands remains.
// Uses an in-project bmadDir so the cleanup path can compute removalSet
// from the manifest (the production layout). The cross-temp-dir fixture
// above can't exercise this — same constraint Test Suite 40 documents.
const projC = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-opencode-batch-c-'));
const bmadC = path.join(projC, '_bmad');
await fs.ensureDir(path.join(bmadC, '_config'));
await fs.writeFile(
path.join(bmadC, '_config', 'skill-manifest.csv'),
'canonicalId,name,description,module,path\n' +
'"bmad-master","bmad-master","Minimal test agent fixture","core","_bmad/core/bmad-master/SKILL.md"\n',
);
const skillC = path.join(bmadC, 'core', 'bmad-master');
await fs.ensureDir(skillC);
await fs.writeFile(
path.join(skillC, 'SKILL.md'),
['---', 'name: bmad-master', 'description: Minimal test agent fixture', '---', '', 'You are a test agent.'].join('\n'),
);
const mgrC = new IdeManager();
await mgrC.ensureInitialized();
await mgrC.setupBatch(['openhands', 'opencode'], projC, bmadC, {
silent: true,
selectedModules: ['core'],
});
const cmdC = path.join(projC, '.opencode', 'commands', 'bmad-master.md');
assert(await fs.pathExists(cmdC), 'in-project fixture: pointer is generated for opencode peer');
const cleanupResultsC = await mgrC.cleanupByList(projC, ['opencode'], {
silent: true,
remainingIdes: ['openhands'],
});
assert(cleanupResultsC[0].success !== false, 'opencode partial-uninstall reports success');
const sharedSurvivesC = await fs.pathExists(path.join(projC, '.agents', 'skills', 'bmad-master', 'SKILL.md'));
assert(sharedSurvivesC, 'shared .agents/skills/ survives partial uninstall (peer still uses it)');
assert(!(await fs.pathExists(cmdC)), 'opencode command pointer is removed on partial uninstall even when peer remains');
await fs.remove(projA).catch(() => {});
await fs.remove(path.dirname(bmadA)).catch(() => {});
await fs.remove(projB).catch(() => {});
await fs.remove(path.dirname(bmadB)).catch(() => {});
await fs.remove(projC).catch(() => {});
} catch (error) {
console.log(`${colors.red}Test Suite 40c setup failed: ${error.message}${colors.reset}`);
failed++;
}
console.log('');
// ============================================================ // ============================================================
// Test Suite 41: Custom-module skill ownership (non-bmad prefix) // Test Suite 41: Custom-module skill ownership (non-bmad prefix)
// ============================================================ // ============================================================

View File

@ -12,6 +12,7 @@ const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
const { InstallPaths } = require('./install-paths'); const { InstallPaths } = require('./install-paths');
const { ExternalModuleManager } = require('../modules/external-manager'); const { ExternalModuleManager } = require('../modules/external-manager');
const { resolveModuleVersion } = require('../modules/version-resolver'); const { resolveModuleVersion } = require('../modules/version-resolver');
const { MODULE_HELP_CSV_HEADER } = require('../modules/module-help-schema');
const { ExistingInstall } = require('./existing-install'); const { ExistingInstall } = require('./existing-install');
const { warnPreNativeSkillsLegacy } = require('./legacy-warnings'); const { warnPreNativeSkillsLegacy } = require('./legacy-warnings');
@ -942,7 +943,7 @@ class Installer {
*/ */
async mergeModuleHelpCatalogs(bmadDir, _agentEntries = []) { async mergeModuleHelpCatalogs(bmadDir, _agentEntries = []) {
const allRows = []; const allRows = [];
const headerRow = 'module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs'; const headerRow = MODULE_HELP_CSV_HEADER;
const COLUMN_COUNT = 13; const COLUMN_COUNT = 13;
const PHASE_INDEX = 7; const PHASE_INDEX = 7;
@ -975,9 +976,19 @@ class Installer {
const content = await fs.readFile(helpFilePath, 'utf8'); const content = await fs.readFile(helpFilePath, 'utf8');
const lines = content.split('\n').filter((line) => line.trim() && !line.startsWith('#')); const lines = content.split('\n').filter((line) => line.trim() && !line.startsWith('#'));
let headerWarned = false;
for (const line of lines) { for (const line of lines) {
// Skip header row // Header row: warn on drift from canonical schema, then skip.
// Data rows are loaded positionally regardless, so the warning
// is advisory — the maintainer should rename their columns.
if (line.startsWith('module,')) { if (line.startsWith('module,')) {
if (!headerWarned && line.trim() !== headerRow) {
await prompts.log.warn(
` ${moduleName}/module-help.csv header does not match canonical schema. ` +
`Expected: ${headerRow} | Found: ${line.trim()} | Data loaded positionally.`,
);
headerWarned = true;
}
continue; continue;
} }

View File

@ -6,6 +6,125 @@ const csv = require('csv-parse/sync');
const { BMAD_FOLDER_NAME } = require('./shared/path-utils'); const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
const { getInstalledCanonicalIds, isBmadOwnedEntry } = require('./shared/installed-skills'); const { getInstalledCanonicalIds, isBmadOwnedEntry } = require('./shared/installed-skills');
// Reserved OpenCode slash commands. A skill whose canonicalId collides with
// one of these is skipped during command-pointer generation so it doesn't
// shadow a built-in.
const RESERVED_OPENCODE_COMMANDS = new Set([
'review',
'commit',
'init',
'help',
'skills',
'fast',
'compact',
'clear',
'undo',
'redo',
'edit',
'editor',
'exit',
'quit',
'theme',
'config',
'model',
'session',
]);
// Wrap a description for safe insertion into single-line YAML frontmatter.
// Leaves plain values untouched; double-quotes (and escapes) anything that
// could break YAML parsing or span multiple lines.
function yamlSafeSingleLine(value) {
const collapsed = String(value)
.replaceAll(/[\r\n]+/g, ' ')
.trim();
const needsQuoting = /[:#'"\\]/.test(collapsed) || /^[!&*?|>%@`[{]/.test(collapsed);
if (!needsQuoting) return collapsed;
const escaped = collapsed.replaceAll('\\', '\\\\').replaceAll('"', String.raw`\"`);
return `"${escaped}"`;
}
// Validate that a canonicalId is a safe basename — no path separators, no
// parent-dir traversal, no leading dots, only the character set we expect.
// Defense-in-depth: the manifest is trusted today, but the value flows
// directly into a file path and a malformed entry should not write outside
// the commands directory.
function isSafeCanonicalId(value) {
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}';
// 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). Signal: the skill's source `customize.toml` has an
// `[agent]` section. This is the actual configuration source of truth —
// every BMAD persona is configured via [agent] in its customize.toml,
// every workflow uses [workflow], every standalone skill has no
// customize.toml at all. Verified against the full installed manifest:
// catches exactly the 20 description-confirmed personas across BMM, CIS,
// GDS, WDS, TEA, and correctly excludes meta-skills like
// `bmad-agent-builder` (a skill-builder workflow whose canonical id
// contains `-agent-` but which has no [agent] section because it isn't a
// persona itself).
//
// Reading the source toml — at install time the source skill directory
// (resolved from manifest record.path) still exists; cleanup runs later
// in the install flow.
async function isAgentSkill(record, bmadDir) {
if (!record?.path || !bmadDir) return false;
const bmadFolderName = path.basename(bmadDir);
const bmadPrefix = bmadFolderName + '/';
const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path;
const tomlPath = path.join(bmadDir, path.dirname(relativePath), 'customize.toml');
if (!(await fs.pathExists(tomlPath))) return false;
try {
const content = await fs.readFile(tomlPath, 'utf8');
return /^\[agent\]/m.test(content);
} catch {
return false;
}
}
// 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
// canonicalId, given the platform's body template. Centralised so both the
// write and the freshness-check paths agree on the canonical form.
function buildCommandPointerBody(description, canonicalId, { template, targetDir }) {
const bodyText = expandBodyTemplate(template, { canonicalId, targetDir });
return `---\ndescription: ${yamlSafeSingleLine(description)}\n---\n\n${bodyText}\n`;
}
// Heuristic: does an existing pointer file look like our generator's output
// (and therefore safe to refresh) versus a user-modified file (which we
// preserve)? We check the body shape rather than full equality so that
// description-only edits in the manifest can propagate without trampling
// hand edits to the body.
function looksLikeGeneratorOutput(content, canonicalId, { template, targetDir }) {
if (typeof content !== 'string') return false;
const trimmed = content.trim();
const expectedTail = expandBodyTemplate(template, { canonicalId, targetDir }).trim();
// Must end with the exact body our generator writes (post-expansion).
if (!trimmed.endsWith(expectedTail)) return false;
// Must start with frontmatter containing exactly one description: line.
const fmMatch = trimmed.match(/^---\n([\S\s]*?)\n---\n/);
if (!fmMatch) return false;
const fmLines = fmMatch[1].split('\n').filter((l) => l.length > 0);
if (fmLines.length !== 1) return false;
if (!fmLines[0].startsWith('description:')) return false;
return true;
}
/** /**
* Config-driven IDE setup handler * Config-driven IDE setup handler
* *
@ -97,9 +216,15 @@ class ConfigDrivenIdeSetup {
} }
// When a peer platform in the same install batch owns this target_dir, // When a peer platform in the same install batch owns this target_dir,
// skip the skill write — the peer has already populated it. // skip the skill write — the peer has already populated it. Command
// pointers, however, write to a separate per-IDE directory and must
// still be generated for this IDE; they are not deduped across peers.
if (options.skipTarget) { if (options.skipTarget) {
return { success: true, results: { skills: 0, sharedTargetHandledByPeer: true } }; const results = { skills: 0, sharedTargetHandledByPeer: true };
if (this.installerConfig.commands_target_dir) {
results.commands = await this.installCommandPointers(projectDir, bmadDir, this.installerConfig, options);
}
return { success: true, results };
} }
if (this.installerConfig.target_dir) { if (this.installerConfig.target_dir) {
@ -128,11 +253,157 @@ class ConfigDrivenIdeSetup {
results.skills = await this.installVerbatimSkills(projectDir, bmadDir, targetPath, config); results.skills = await this.installVerbatimSkills(projectDir, bmadDir, targetPath, config);
results.skillDirectories = this.skillWriteTracker.size; results.skillDirectories = this.skillWriteTracker.size;
if (config.commands_target_dir) {
results.commands = await this.installCommandPointers(projectDir, bmadDir, config, options);
}
await this.printSummary(results, target_dir, options); await this.printSummary(results, target_dir, options);
this.skillWriteTracker = null; this.skillWriteTracker = null;
return { success: true, results }; return { success: true, results };
} }
/**
* Generate per-skill command pointer files for IDEs that surface commands
* separately from skills (e.g. OpenCode's `.opencode/commands/<name>.md`).
*
* Each pointer is a tiny markdown file whose body is `@skills/<canonicalId>`
* so invoking `/<canonicalId>` routes the user straight to the skill instead
* of forcing them through a `/skills` menu.
*
* Skips:
* - Names that collide with reserved built-in slash commands.
* - canonicalIds that aren't safe basename-only identifiers (defense
* against path traversal even though the manifest is currently trusted).
* - Existing files whose body looks user-modified (preserves hand edits);
* pointer files matching the generator pattern get overwritten so that
* description changes in skill-manifest.csv propagate on re-install.
*
* Per-file write failures are recorded and reported but do not abort the
* rest of the install pointer files are a non-essential adjunct to the
* skill copy that already succeeded.
*
* @param {string} projectDir
* @param {string} bmadDir
* @param {Object} config - Installer config; reads commands_target_dir.
* @param {Object} options - Setup options. forceCommands overwrites existing
* files unconditionally (including hand-modified ones).
* @returns {Promise<Object>} { created, updated, skippedExisting, skippedCollision, skippedInvalidId, writeFailures, fallbackDescription }
*/
async installCommandPointers(projectDir, bmadDir, config, options = {}) {
const result = {
created: 0,
updated: 0,
skippedExisting: 0,
skippedCollision: 0,
skippedInvalidId: 0,
skippedFiltered: 0,
writeFailures: 0,
fallbackDescription: 0,
};
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
if (!(await fs.pathExists(csvPath))) return result;
const commandsPath = path.join(projectDir, config.commands_target_dir);
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 records = csv.parse(csvContent, { columns: true, skip_empty_lines: true });
for (const record of records) {
const canonicalId = record.canonicalId;
if (!canonicalId) continue;
// Defensive basename validation. canonicalId comes from a trusted
// manifest today, but the value flows directly into a file path —
// reject anything that could escape commands_target_dir.
if (!isSafeCanonicalId(canonicalId)) {
result.skippedInvalidId++;
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' && !(await isAgentSkill(record, bmadDir))) {
result.skippedFiltered++;
continue;
}
// Reserved-name guard is OpenCode-specific. Other adapters that opt
// into commands_target_dir later should declare their own reserved
// set rather than inheriting OpenCode's.
if (this.name === 'opencode' && RESERVED_OPENCODE_COMMANDS.has(canonicalId)) {
result.skippedCollision++;
continue;
}
let description = (record.description || '').trim();
if (!description) {
description = `Run the ${canonicalId} skill`;
result.fallbackDescription++;
}
const body = buildCommandPointerBody(description, canonicalId, { template, targetDir });
const commandFile = path.join(commandsPath, `${canonicalId}${extension}`);
// If a pointer file already exists, decide whether to overwrite based
// on whether it looks like generator output (description-only diff) or
// a user-modified file. forceCommands overrides this protection.
if (!options.forceCommands && (await fs.pathExists(commandFile))) {
let existing;
try {
existing = await fs.readFile(commandFile, 'utf8');
} catch {
// Treat unreadable as user-owned and skip — safer than overwriting.
result.skippedExisting++;
continue;
}
if (existing === body) {
// No-op idempotent re-run.
result.skippedExisting++;
continue;
}
if (looksLikeGeneratorOutput(existing, canonicalId, { template, targetDir })) {
// Description (or other generated bit) has changed; refresh in place.
try {
await fs.writeFile(commandFile, body, 'utf8');
result.updated++;
} catch (error) {
result.writeFailures++;
if (!options.silent) {
await prompts.log.warn(`Failed to update command pointer ${canonicalId}${extension}: ${error.message}`);
}
}
continue;
}
// Hand-modified pointer — preserve it.
result.skippedExisting++;
continue;
}
try {
await fs.writeFile(commandFile, body, 'utf8');
result.created++;
} catch (error) {
result.writeFailures++;
if (!options.silent) {
await prompts.log.warn(`Failed to write command pointer ${canonicalId}${extension}: ${error.message}`);
}
}
}
return result;
}
/** /**
* Install verbatim native SKILL.md directories from skill-manifest.csv. * Install verbatim native SKILL.md directories from skill-manifest.csv.
* Copies the entire source directory as-is into the IDE skill directory. * Copies the entire source directory as-is into the IDE skill directory.
@ -207,6 +478,18 @@ class ConfigDrivenIdeSetup {
if (count > 0) { if (count > 0) {
await prompts.log.success(`${this.name} configured: ${count} skills → ${targetDir}`); await prompts.log.success(`${this.name} configured: ${count} skills → ${targetDir}`);
} }
const cmd = results.commands;
if (cmd && (cmd.created > 0 || cmd.updated > 0) && this.installerConfig?.commands_target_dir) {
const total = cmd.created + cmd.updated;
const detail = cmd.updated > 0 ? `${cmd.created} new, ${cmd.updated} refreshed` : `${total}`;
await prompts.log.success(`${this.name} commands: ${detail}${this.installerConfig.commands_target_dir}`);
if (cmd.skippedCollision > 0) {
await prompts.log.message(` (${cmd.skippedCollision} skipped — name collides with reserved slash command)`);
}
if (cmd.writeFailures > 0) {
await prompts.log.warn(` (${cmd.writeFailures} pointer writes failed — see warnings above)`);
}
}
} }
/** /**
@ -247,6 +530,36 @@ class ConfigDrivenIdeSetup {
await this.cleanupRovoDevPrompts(projectDir, options); await this.cleanupRovoDevPrompts(projectDir, options);
} }
// Clean generated command pointer files in commands_target_dir.
// Mirrors target_dir cleanup so uninstalls and skill removals don't
// leave dangling /<canonicalId> commands pointing at missing skills.
// Runs regardless of skipTarget — command pointers live in a per-IDE
// directory and are not deduped across peers, so a peer-owned shared
// skills directory does not protect this IDE's command pointers from
// cleanup. The "currently active" set is passed so install-flow cleanup
// (where removalSet contains skills that will be re-added moments later)
// doesn't trample hand-edited pointers; install-flow cleanup will only
// delete pointers for skills that are not in the new manifest.
if (this.installerConfig?.commands_target_dir) {
// In the install/update flow (signal: previousSkillIds was passed),
// spare pointers whose canonicalId is still in the manifest so hand
// edits survive a routine reinstall. In the uninstall flow (no
// previousSkillIds — full uninstall or per-IDE removal via
// cleanupByList), don't spare anything; the IDE itself is going away,
// so its pointers should go with it.
const isInstallFlow = options.previousSkillIds && options.previousSkillIds.size > 0;
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,
extension,
);
}
// Skip target_dir cleanup when a peer platform owns this directory // Skip target_dir cleanup when a peer platform owns this directory
// (set during dedup'd install or when uninstalling one of several // (set during dedup'd install or when uninstalling one of several
// platforms that share the same target_dir). // platforms that share the same target_dir).
@ -346,6 +659,97 @@ class ConfigDrivenIdeSetup {
} }
} }
/**
* Cleanup generated command pointer files for entries in removalSet.
* Symmetric counterpart to installCommandPointers removes
* `<canonicalId><extension>` files whose canonicalId is in the set. Removes
* the commands directory entirely if it ends up empty.
* @param {string} projectDir
* @param {string} commandsTargetDir - Relative dir (e.g. .opencode/commands)
* @param {Object} options
* @param {Set<string>} removalSet - canonicalIds whose pointer files to remove
* @param {Set<string>} [activeSkillIds] - canonicalIds present in the
* current manifest. Pointers for IDs in this set are spared so an
* install-flow cleanup (where removalSet === previousSkillIds and the
* 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
* 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(
projectDir,
commandsTargetDir,
options = {},
removalSet = new Set(),
activeSkillIds = new Set(),
extension = '.md',
) {
if (!removalSet || removalSet.size === 0) return;
const commandsPath = path.join(projectDir, commandsTargetDir);
if (!(await fs.pathExists(commandsPath))) return;
let entries;
try {
entries = await fs.readdir(commandsPath);
} catch {
return;
}
for (const entry of entries) {
if (!entry.endsWith(extension)) continue;
const canonicalId = entry.slice(0, -extension.length);
if (!removalSet.has(canonicalId)) continue;
// Spare pointers for skills that are still in the manifest; the
// install pass will refresh them in place if their content has gone
// stale, while preserving hand edits.
if (activeSkillIds.has(canonicalId)) continue;
try {
await fs.remove(path.join(commandsPath, entry));
} catch {
// Skip files we can't remove.
}
}
// Remove the commands directory if we emptied it.
try {
const remaining = await fs.readdir(commandsPath);
if (remaining.length === 0) {
await fs.remove(commandsPath);
}
} catch {
// Directory may already be gone.
}
}
/**
* Read the canonicalIds currently present in the skill-manifest.csv.
* Used by cleanup to distinguish "re-install of an existing skill"
* (preserve pointer) from "skill truly being removed" (delete pointer).
* @param {string|null} bmadDir
* @returns {Promise<Set<string>>}
*/
async _readActiveSkillIds(bmadDir) {
const ids = new Set();
if (!bmadDir) return ids;
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
if (!(await fs.pathExists(csvPath))) return ids;
try {
const content = await fs.readFile(csvPath, 'utf8');
const records = csv.parse(content, { columns: true, skip_empty_lines: true });
for (const record of records) {
if (record.canonicalId) ids.add(record.canonicalId);
}
} catch {
// Manifest unreadable — return an empty set so cleanup falls back to
// the conservative "delete what removalSet says" behavior.
}
return ids;
}
/** /**
* Cleanup a specific target directory. * Cleanup a specific target directory.
* When removalSet is provided, only removes entries in that set. * When removalSet is provided, only removes entries in that set.

View File

@ -132,6 +132,21 @@ 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). Detected by reading each skill's source
# `customize.toml` and checking for an `[agent]` section — that's
# the actual configuration source of truth: every BMAD persona is
# configured under `[agent]`, every workflow under `[workflow]`,
# every standalone skill has no customize.toml. This signal is
# naming-independent, so personas like `bmad-tea` (which doesn't
# follow the `-agent-` convention) are still included, and
# meta-skills like `bmad-agent-builder` (which contains `-agent-`
# but is a skill-builder workflow, not a persona) are correctly
# excluded.
commands_filter: agents-only
goose: goose:
name: "Block Goose" name: "Block Goose"
@ -222,6 +237,7 @@ platforms:
installer: installer:
target_dir: .agents/skills target_dir: .agents/skills
global_target_dir: ~/.agents/skills global_target_dir: ~/.agents/skills
commands_target_dir: .opencode/commands
openhands: openhands:
name: "OpenHands" name: "OpenHands"

View File

@ -0,0 +1,13 @@
/**
* Canonical schema for per-module `module-help.csv` files.
*
* Both the merger (`Installer.mergeModuleHelpCatalogs`) and the synthesizer
* (`PluginResolver._buildSynthesizedHelpCsv`) emit this exact header. The
* merger compares each per-module file's header against this string and
* warns on drift, so any rename here must be matched in external module
* authors' CSVs (or accepted as a positional fall-through with a warning).
*/
const MODULE_HELP_CSV_HEADER =
'module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs';
module.exports = { MODULE_HELP_CSV_HEADER };

View File

@ -1,6 +1,7 @@
const fs = require('../fs-native'); const fs = require('../fs-native');
const path = require('node:path'); const path = require('node:path');
const yaml = require('yaml'); const yaml = require('yaml');
const { MODULE_HELP_CSV_HEADER } = require('./module-help-schema');
/** /**
* Resolves how to install a plugin from marketplace.json by analyzing * Resolves how to install a plugin from marketplace.json by analyzing
@ -338,8 +339,7 @@ class PluginResolver {
* @returns {string} CSV content * @returns {string} CSV content
*/ */
_buildSynthesizedHelpCsv(moduleName, skillInfos) { _buildSynthesizedHelpCsv(moduleName, skillInfos) {
const header = 'module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs'; const rows = [MODULE_HELP_CSV_HEADER];
const rows = [header];
for (const info of skillInfos) { for (const info of skillInfos) {
const displayName = this._formatDisplayName(info.name || info.dirName); const displayName = this._formatDisplayName(info.name || info.dirName);