Compare commits
8 Commits
146416e987
...
c792ef169d
| Author | SHA1 | Date |
|---|---|---|
|
|
c792ef169d | |
|
|
f1b9679232 | |
|
|
42988f598a | |
|
|
3372e95b62 | |
|
|
6c1f9164d0 | |
|
|
e36f219c81 | |
|
|
9debc165aa | |
|
|
65b810a11f |
|
|
@ -31,13 +31,17 @@ npx bmad-method install
|
||||||
The interactive flow asks you five things:
|
The interactive flow asks you five things:
|
||||||
|
|
||||||
1. Installation directory (defaults to the current working directory)
|
1. Installation directory (defaults to the current working directory)
|
||||||
2. Which modules to install (checkboxes for core, bmm, bmb, cis, gds, tea)
|
2. Which modules to install (checkboxes for core, bmm, bmb, cis, gds, tea, bma)
|
||||||
3. **"Ready to install (all stable)?"** — Yes accepts the latest released tag for every external module
|
3. **"Ready to install (all stable)?"** — Yes accepts the latest released tag for every external module
|
||||||
4. Which AI tools/IDEs to integrate with (claude-code, cursor, and others)
|
4. Which AI tools/IDEs to integrate with (claude-code, cursor, and others)
|
||||||
5. Per-module config (name, language, output folder)
|
5. Per-module config (name, language, output folder)
|
||||||
|
|
||||||
Accept the defaults and you land on the latest stable release of every module, configured for your chosen tool.
|
Accept the defaults and you land on the latest stable release of every module, configured for your chosen tool.
|
||||||
|
|
||||||
|
:::caution[BMad Automator constraints]
|
||||||
|
`bma` installs runnable Automator skills only for the Claude Code entrypoint. Codex is supported as a worker target only, and worker sessions currently require `tmux` on macOS.
|
||||||
|
:::
|
||||||
|
|
||||||
:::tip[Just want the newest prerelease?]
|
:::tip[Just want the newest prerelease?]
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -53,7 +57,7 @@ Two independent axes control what ends up on disk.
|
||||||
|
|
||||||
### Axis 1: external module channels
|
### Axis 1: external module channels
|
||||||
|
|
||||||
Every external module — bmb, cis, gds, tea, and any community module — installs on one of three channels:
|
Every external module — bmb, cis, gds, tea, bma, and any community module — installs on one of three channels:
|
||||||
|
|
||||||
| Channel | What gets installed | Who picks this |
|
| Channel | What gets installed | Who picks this |
|
||||||
| ------------------ | ---------------------------------------------------------------------------- | --------------------------------------- |
|
| ------------------ | ---------------------------------------------------------------------------- | --------------------------------------- |
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,24 @@ Enterprise-grade test strategy, automation guidance, and release gate decisions
|
||||||
- NFR assessment, CI setup, and framework scaffolding
|
- NFR assessment, CI setup, and framework scaffolding
|
||||||
- P0-P3 prioritization with optional Playwright Utils and MCP integrations
|
- P0-P3 prioritization with optional Playwright Utils and MCP integrations
|
||||||
|
|
||||||
|
## BMad Automator (Experimental)
|
||||||
|
|
||||||
|
Automates the BMad story build loop with a pure skill bundle sourced from the separate Automator repository.
|
||||||
|
|
||||||
|
- **Code:** `bma`
|
||||||
|
- **npm:** [`bmad-story-automator`](https://www.npmjs.com/package/bmad-story-automator)
|
||||||
|
- **GitHub:** [bmad-code-org/bmad-automator](https://github.com/bmad-code-org/bmad-automator)
|
||||||
|
|
||||||
|
:::caution[Experimental Claude Code-only entrypoint]
|
||||||
|
BMad Automator only runs from Claude Code. It currently supports Claude Code and Codex worker sessions, and requires tmux on macOS.
|
||||||
|
:::
|
||||||
|
|
||||||
|
**Provides:**
|
||||||
|
|
||||||
|
- Story build-cycle automation across story creation, development, QA automation, review, and retrospective
|
||||||
|
- Resumable tmux orchestration state
|
||||||
|
- Claude Code entry skill plus Claude Code/Codex worker-session coordination
|
||||||
|
|
||||||
## Community Modules
|
## Community Modules
|
||||||
|
|
||||||
Community modules and a module marketplace are coming. Check the [BMad GitHub organization](https://github.com/bmad-code-org) for updates.
|
Community modules and a module marketplace are coming. Check the [BMad GitHub organization](https://github.com/bmad-code-org) for updates.
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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**:
|
||||||
|
|
|
||||||
|
|
@ -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,,
|
||||||
|
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ const { Installer } = require('../tools/installer/core/installer');
|
||||||
const { ManifestGenerator } = require('../tools/installer/core/manifest-generator');
|
const { ManifestGenerator } = require('../tools/installer/core/manifest-generator');
|
||||||
const { OfficialModules } = require('../tools/installer/modules/official-modules');
|
const { OfficialModules } = require('../tools/installer/modules/official-modules');
|
||||||
const { IdeManager } = require('../tools/installer/ide/manager');
|
const { IdeManager } = require('../tools/installer/ide/manager');
|
||||||
|
const { ExternalModuleManager } = require('../tools/installer/modules/external-manager');
|
||||||
const { clearCache, loadPlatformCodes } = require('../tools/installer/ide/platform-codes');
|
const { clearCache, loadPlatformCodes } = require('../tools/installer/ide/platform-codes');
|
||||||
|
|
||||||
// ANSI colors
|
// ANSI colors
|
||||||
|
|
@ -85,6 +86,63 @@ async function createTestBmadFixture() {
|
||||||
return fixtureDir;
|
return fixtureDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createAutomatorBmadFixture() {
|
||||||
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-fixture-'));
|
||||||
|
const fixtureDir = path.join(fixtureRoot, '_bmad');
|
||||||
|
await fs.ensureDir(path.join(fixtureDir, '_config'));
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fixtureDir, '_config', 'skill-manifest.csv'),
|
||||||
|
[
|
||||||
|
'canonicalId,name,description,module,path',
|
||||||
|
'"bmad-master","bmad-master","Minimal core skill","core","_bmad/core/bmad-master/SKILL.md"',
|
||||||
|
'"bmad-story-automator","bmad-story-automator","Automator skill","bma","_bmad/bma/bmad-story-automator/SKILL.md"',
|
||||||
|
'"bmad-story-automator-review","bmad-story-automator-review","Automator review skill","bma","_bmad/bma/bmad-story-automator-review/SKILL.md"',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const coreSkillDir = path.join(fixtureDir, 'core', 'bmad-master');
|
||||||
|
await fs.ensureDir(coreSkillDir);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(coreSkillDir, 'SKILL.md'),
|
||||||
|
['---', 'name: bmad-master', 'description: Minimal core skill', '---', '', 'Core skill body.'].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const skillName of ['bmad-story-automator', 'bmad-story-automator-review']) {
|
||||||
|
const skillDir = path.join(fixtureDir, 'bma', skillName);
|
||||||
|
await fs.ensureDir(skillDir);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(skillDir, 'SKILL.md'),
|
||||||
|
['---', `name: ${skillName}`, 'description: Automator skill', '---', '', 'Automator body.'].join('\n'),
|
||||||
|
);
|
||||||
|
await fs.writeFile(path.join(skillDir, 'workflow.md'), `# ${skillName}\n\nAutomator workflow body.\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fixtureDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAutomatorSourceRootFixture() {
|
||||||
|
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-source-'));
|
||||||
|
const sourceRoot = path.join(repoRoot, 'payload', '.claude', 'skills');
|
||||||
|
|
||||||
|
for (const skillName of ['bmad-story-automator', 'bmad-story-automator-review']) {
|
||||||
|
const skillDir = path.join(sourceRoot, skillName);
|
||||||
|
await fs.ensureDir(skillDir);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(skillDir, 'SKILL.md'),
|
||||||
|
['---', `name: ${skillName}`, 'description: Automator skill', '---', '', 'Automator body.'].join('\n'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.ensureDir(path.join(repoRoot, 'source', 'scripts'));
|
||||||
|
await fs.writeFile(path.join(repoRoot, 'source', 'scripts', 'story-automator'), '#!/usr/bin/env bash\n');
|
||||||
|
await fs.ensureDir(path.join(repoRoot, 'source', 'src', 'story_automator'));
|
||||||
|
await fs.writeFile(path.join(repoRoot, 'source', 'src', 'story_automator', 'cli.py'), 'def main():\n return 0\n');
|
||||||
|
|
||||||
|
return { repoRoot, sourceRoot };
|
||||||
|
}
|
||||||
|
|
||||||
async function createSkillCollisionFixture() {
|
async function createSkillCollisionFixture() {
|
||||||
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-skill-collision-'));
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-skill-collision-'));
|
||||||
const fixtureDir = path.join(fixtureRoot, '_bmad');
|
const fixtureDir = path.join(fixtureRoot, '_bmad');
|
||||||
|
|
@ -285,6 +343,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 +363,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 +615,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 +727,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 +2971,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)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -3237,6 +3578,137 @@ async function runTests() {
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 45: Automator External Skill-Only Module
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 45: Automator External Skill-Only Module${colors.reset}\n`);
|
||||||
|
|
||||||
|
let tempProjectDir42;
|
||||||
|
let installedBmadDir42;
|
||||||
|
let automatorSourceFixture42;
|
||||||
|
let runtimeTargetRoot42;
|
||||||
|
try {
|
||||||
|
const externalManager42 = new ExternalModuleManager();
|
||||||
|
const automatorInfo42 = await externalManager42.getModuleByCode('bma');
|
||||||
|
assert(automatorInfo42 !== null, 'BMad Automator is registered as an external module');
|
||||||
|
assert(automatorInfo42.type === 'experimental', 'BMad Automator is marked experimental');
|
||||||
|
assert(automatorInfo42.sourceRoot === 'payload/.claude/skills', 'BMad Automator uses source-root for pure skill payload');
|
||||||
|
assert(
|
||||||
|
automatorInfo42.installTargets.length === 1 && automatorInfo42.installTargets.includes('claude-code'),
|
||||||
|
'BMad Automator is limited to Claude Code skill installation',
|
||||||
|
);
|
||||||
|
const normalizedInfo42 = externalManager42._normalizeModule({
|
||||||
|
name: 'bad-shapes',
|
||||||
|
code: 'bad',
|
||||||
|
repository: 'https://example.com/bad.git',
|
||||||
|
install_targets: 'claude-code',
|
||||||
|
worker_targets: { bad: true },
|
||||||
|
requirements: ['tmux', { bad: true }, false],
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
Array.isArray(normalizedInfo42.installTargets) && normalizedInfo42.installTargets.includes('claude-code'),
|
||||||
|
'External module install targets normalize scalar values to arrays',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
Array.isArray(normalizedInfo42.workerTargets) && normalizedInfo42.workerTargets.length === 0,
|
||||||
|
'External module worker targets drop invalid shapes',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
normalizedInfo42.requirements.length === 2 &&
|
||||||
|
normalizedInfo42.requirements.includes('tmux') &&
|
||||||
|
normalizedInfo42.requirements.includes('false'),
|
||||||
|
'External module requirements normalize scalar array entries',
|
||||||
|
);
|
||||||
|
|
||||||
|
automatorSourceFixture42 = await createAutomatorSourceRootFixture();
|
||||||
|
runtimeTargetRoot42 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-runtime-target-'));
|
||||||
|
const runtimeBmadDir42 = path.join(runtimeTargetRoot42, '_bmad');
|
||||||
|
const officialModules42 = new OfficialModules();
|
||||||
|
officialModules42.findModuleSource = async () => automatorSourceFixture42.sourceRoot;
|
||||||
|
await officialModules42.install('bma', runtimeBmadDir42, null, { skipModuleInstaller: true, silent: true });
|
||||||
|
assert(
|
||||||
|
await fs.pathExists(path.join(runtimeBmadDir42, 'bma', 'bmad-story-automator', 'scripts', 'story-automator')),
|
||||||
|
'BMad Automator source-root install includes runtime helper',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
await fs.pathExists(path.join(runtimeBmadDir42, 'bma', 'bmad-story-automator', 'src', 'story_automator', 'cli.py')),
|
||||||
|
'BMad Automator source-root install includes Python runtime source',
|
||||||
|
);
|
||||||
|
await fs.remove(runtimeTargetRoot42).catch(() => {});
|
||||||
|
runtimeTargetRoot42 = null;
|
||||||
|
|
||||||
|
tempProjectDir42 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-automator-target-'));
|
||||||
|
installedBmadDir42 = await createAutomatorBmadFixture();
|
||||||
|
|
||||||
|
const ideManager42 = new IdeManager();
|
||||||
|
await ideManager42.ensureInitialized();
|
||||||
|
|
||||||
|
const codexResult42 = await ideManager42.setup('codex', tempProjectDir42, installedBmadDir42, {
|
||||||
|
silent: true,
|
||||||
|
selectedModules: ['core', 'bma'],
|
||||||
|
});
|
||||||
|
assert(codexResult42.success === true, 'Codex setup succeeds with automator module selected');
|
||||||
|
assert(
|
||||||
|
await fs.pathExists(path.join(tempProjectDir42, '.agents', 'skills', 'bmad-master', 'SKILL.md')),
|
||||||
|
'Codex setup still installs supported core skills',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!(await fs.pathExists(path.join(tempProjectDir42, '.agents', 'skills', 'bmad-story-automator', 'SKILL.md'))),
|
||||||
|
'Codex setup skips Claude Code-only automator skill',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!(await fs.pathExists(path.join(tempProjectDir42, '.agents', 'skills', 'bmad-story-automator-review', 'SKILL.md'))),
|
||||||
|
'Codex setup skips Claude Code-only automator review skill',
|
||||||
|
);
|
||||||
|
|
||||||
|
const escapeRoot42 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-source-root-'));
|
||||||
|
const escapeRepo42 = path.join(escapeRoot42, 'repo');
|
||||||
|
await fs.ensureDir(escapeRepo42);
|
||||||
|
const escapeManager42 = new ExternalModuleManager();
|
||||||
|
escapeManager42.getModuleByCode = async () => ({
|
||||||
|
code: 'escape',
|
||||||
|
builtIn: false,
|
||||||
|
sourceRoot: '../outside',
|
||||||
|
});
|
||||||
|
escapeManager42.cloneExternalModule = async () => escapeRepo42;
|
||||||
|
let rejectedEscapingSourceRoot42 = false;
|
||||||
|
try {
|
||||||
|
await escapeManager42.findExternalModuleSource('escape');
|
||||||
|
} catch (error) {
|
||||||
|
rejectedEscapingSourceRoot42 = error.message.includes('source-root escapes repository');
|
||||||
|
} finally {
|
||||||
|
await fs.remove(escapeRoot42).catch(() => {});
|
||||||
|
}
|
||||||
|
assert(rejectedEscapingSourceRoot42, 'External module source-root cannot escape cloned repository');
|
||||||
|
|
||||||
|
const claudeResult42 = await ideManager42.setup('claude-code', tempProjectDir42, installedBmadDir42, {
|
||||||
|
silent: true,
|
||||||
|
selectedModules: ['core', 'bma'],
|
||||||
|
});
|
||||||
|
assert(claudeResult42.success === true, 'Claude Code setup succeeds with automator module selected');
|
||||||
|
assert(
|
||||||
|
await fs.pathExists(path.join(tempProjectDir42, '.claude', 'skills', 'bmad-story-automator', 'SKILL.md')),
|
||||||
|
'Claude Code setup installs automator skill',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
await fs.pathExists(path.join(tempProjectDir42, '.claude', 'skills', 'bmad-story-automator-review', 'SKILL.md')),
|
||||||
|
'Claude Code setup installs automator review skill',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
await fs.pathExists(path.join(tempProjectDir42, '.claude', 'skills', 'bmad-story-automator-review', 'workflow.md')),
|
||||||
|
'Claude Code setup copies automator workflow files',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
assert(false, `Automator external skill-only module test succeeds: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
if (tempProjectDir42) await fs.remove(tempProjectDir42).catch(() => {});
|
||||||
|
if (installedBmadDir42) await fs.remove(path.dirname(installedBmadDir42)).catch(() => {});
|
||||||
|
if (automatorSourceFixture42) await fs.remove(automatorSourceFixture42.repoRoot).catch(() => {});
|
||||||
|
if (runtimeTargetRoot42) await fs.remove(runtimeTargetRoot42).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Summary
|
// Summary
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,15 @@
|
||||||
|
|
||||||
## Installing external repo BMad official modules
|
## Installing external repo BMad official modules
|
||||||
|
|
||||||
For external official modules to be discoverable during install, ensure an entry for the external repo is added to external-official-modules.yaml.
|
For external official modules to be discoverable during install, ensure an entry for the external repo is added to the marketplace `registry/official.yaml` source of truth. Add the same entry to `modules/registry-fallback.yaml` only when BMAD-METHOD needs a bundled fallback or a staged registry supplement.
|
||||||
|
|
||||||
For community modules - this will be handled in a different way. This file is only for registration of modules under the bmad-code-org.
|
For community modules - this is handled through the marketplace community registry.
|
||||||
|
|
||||||
|
Use `module-definition` for conventional module repos with `module.yaml`.
|
||||||
|
Use `source-root` for pure skill bundles that should be copied directly into `_bmad/<module-code>/`.
|
||||||
|
This keeps the external repo as the source of truth and avoids vendoring generated skill payloads into BMAD-METHOD.
|
||||||
|
|
||||||
|
Experimental modules can set `type: experimental` and `install-targets` to limit which IDE integrations receive their skills.
|
||||||
|
|
||||||
## Post-Install Notes
|
## Post-Install Notes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,9 @@ class ManifestGenerator {
|
||||||
for (const moduleName of this.updatedModules) {
|
for (const moduleName of this.updatedModules) {
|
||||||
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
||||||
if (!moduleYamlPath) {
|
if (!moduleYamlPath) {
|
||||||
|
if (await this._isSkillOnlyModule(moduleName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// External modules live in ~/.bmad/cache/external-modules, not src/modules.
|
// External modules live in ~/.bmad/cache/external-modules, not src/modules.
|
||||||
// Warn rather than silently skip so missing agent rosters don't vanish
|
// Warn rather than silently skip so missing agent rosters don't vanish
|
||||||
// from config.toml without notice.
|
// from config.toml without notice.
|
||||||
|
|
@ -441,6 +444,9 @@ class ManifestGenerator {
|
||||||
for (const moduleName of this.updatedModules) {
|
for (const moduleName of this.updatedModules) {
|
||||||
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
||||||
if (!moduleYamlPath) {
|
if (!moduleYamlPath) {
|
||||||
|
if (await this._isSkillOnlyModule(moduleName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
console.warn(
|
console.warn(
|
||||||
`[warn] writeCentralConfig: could not locate module.yaml for '${moduleName}'. ` +
|
`[warn] writeCentralConfig: could not locate module.yaml for '${moduleName}'. ` +
|
||||||
`Answers from this module will default to team scope — user-scoped keys may mis-file into config.toml.`,
|
`Answers from this module will default to team scope — user-scoped keys may mis-file into config.toml.`,
|
||||||
|
|
@ -799,6 +805,27 @@ class ManifestGenerator {
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _isSkillOnlyModule(moduleName) {
|
||||||
|
const modulePath = path.join(this.bmadDir, moduleName);
|
||||||
|
if (!(await fs.pathExists(modulePath))) return false;
|
||||||
|
if (await fs.pathExists(path.join(modulePath, 'module.yaml'))) return false;
|
||||||
|
if (!(await this._moduleUsesSourceRoot(moduleName))) return false;
|
||||||
|
return this._hasSkillMdRecursive(modulePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _moduleUsesSourceRoot(moduleName) {
|
||||||
|
if (!this.sourceRootModuleCodes) {
|
||||||
|
try {
|
||||||
|
const { ExternalModuleManager } = require('../modules/external-manager');
|
||||||
|
const externalModules = await new ExternalModuleManager().listAvailable();
|
||||||
|
this.sourceRootModuleCodes = new Set(externalModules.filter((mod) => mod.sourceRoot).map((mod) => mod.code));
|
||||||
|
} catch {
|
||||||
|
this.sourceRootModuleCodes = new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.sourceRootModuleCodes.has(moduleName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
*
|
*
|
||||||
|
|
@ -26,6 +145,8 @@ class ConfigDrivenIdeSetup {
|
||||||
this.platformConfig = platformConfig;
|
this.platformConfig = platformConfig;
|
||||||
this.installerConfig = platformConfig.installer || null;
|
this.installerConfig = platformConfig.installer || null;
|
||||||
this.bmadFolderName = BMAD_FOLDER_NAME;
|
this.bmadFolderName = BMAD_FOLDER_NAME;
|
||||||
|
this.externalModuleManager = null;
|
||||||
|
this.moduleTargetCache = new Map();
|
||||||
|
|
||||||
// Set configDir from target_dir so detect() works
|
// Set configDir from target_dir so detect() works
|
||||||
this.configDir = this.installerConfig?.target_dir || null;
|
this.configDir = this.installerConfig?.target_dir || null;
|
||||||
|
|
@ -97,9 +218,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) {
|
||||||
|
|
@ -123,16 +250,165 @@ class ConfigDrivenIdeSetup {
|
||||||
await fs.ensureDir(targetPath);
|
await fs.ensureDir(targetPath);
|
||||||
|
|
||||||
this.skillWriteTracker = new Set();
|
this.skillWriteTracker = new Set();
|
||||||
|
this.skippedUnsupported = 0;
|
||||||
const results = { skills: 0 };
|
const results = { skills: 0 };
|
||||||
|
|
||||||
results.skills = await this.installVerbatimSkills(projectDir, bmadDir, targetPath, config);
|
results.skills = await this.installVerbatimSkills(projectDir, bmadDir, targetPath, config);
|
||||||
|
results.skippedUnsupported = this.skippedUnsupported || 0;
|
||||||
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;
|
||||||
|
this.skippedUnsupported = 0;
|
||||||
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.
|
||||||
|
|
@ -162,6 +438,11 @@ class ConfigDrivenIdeSetup {
|
||||||
const canonicalId = record.canonicalId;
|
const canonicalId = record.canonicalId;
|
||||||
if (!canonicalId) continue;
|
if (!canonicalId) continue;
|
||||||
|
|
||||||
|
if (!(await this.shouldInstallSkillRecord(record))) {
|
||||||
|
this.skippedUnsupported = (this.skippedUnsupported || 0) + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Derive source directory from path column
|
// Derive source directory from path column
|
||||||
// path is like "_bmad/bmm/workflows/bmad-quick-flow/bmad-quick-dev-new-preview/SKILL.md"
|
// path is like "_bmad/bmm/workflows/bmad-quick-flow/bmad-quick-dev-new-preview/SKILL.md"
|
||||||
// Strip bmadFolderName prefix and join with bmadDir, then get dirname
|
// Strip bmadFolderName prefix and join with bmadDir, then get dirname
|
||||||
|
|
@ -196,6 +477,31 @@ class ConfigDrivenIdeSetup {
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async shouldInstallSkillRecord(record) {
|
||||||
|
const moduleName = record.module;
|
||||||
|
if (!moduleName) return true;
|
||||||
|
|
||||||
|
if (this.moduleTargetCache.has(moduleName)) {
|
||||||
|
const targets = this.moduleTargetCache.get(moduleName);
|
||||||
|
return !targets || targets.includes(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ExternalModuleManager } = require('../modules/external-manager');
|
||||||
|
this.externalModuleManager = this.externalModuleManager || new ExternalModuleManager();
|
||||||
|
let targets = null;
|
||||||
|
try {
|
||||||
|
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleName);
|
||||||
|
targets = moduleInfo?.installTargets?.length ? moduleInfo.installTargets : null;
|
||||||
|
} catch (error) {
|
||||||
|
await prompts.log.warn(
|
||||||
|
`ExternalModuleManager.getModuleByCode failed for module '${moduleName}' while installing ${this.name}; installing skill anyway. ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.moduleTargetCache.set(moduleName, targets);
|
||||||
|
|
||||||
|
return !targets || targets.includes(this.name);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print installation summary
|
* Print installation summary
|
||||||
* @param {Object} results - Installation results
|
* @param {Object} results - Installation results
|
||||||
|
|
@ -207,6 +513,21 @@ 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)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (results.skippedUnsupported > 0) {
|
||||||
|
await prompts.log.warn(`${this.name}: skipped ${results.skippedUnsupported} skill(s) that do not support this IDE`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -247,6 +568,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 +697,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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,15 @@ function normalizeChannelName(raw) {
|
||||||
return VALID_CHANNELS.has(lower) ? lower : null;
|
return VALID_CHANNELS.has(lower) ? lower : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStringList(raw) {
|
||||||
|
if (raw == null || raw === '') return [];
|
||||||
|
const values = Array.isArray(raw) ? raw : [raw];
|
||||||
|
return values
|
||||||
|
.filter((value) => ['string', 'number', 'boolean'].includes(typeof value))
|
||||||
|
.map((value) => String(value).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Conservative quoting for tag names passed to git commands. Tags are
|
* Conservative quoting for tag names passed to git commands. Tags are
|
||||||
* user-typed (--pin) or come from the GitHub API. Only allow the semver
|
* user-typed (--pin) or come from the GitHub API. Only allow the semver
|
||||||
|
|
@ -120,22 +129,41 @@ class ExternalModuleManager {
|
||||||
* @returns {Object} Normalized module info
|
* @returns {Object} Normalized module info
|
||||||
*/
|
*/
|
||||||
_normalizeModule(mod, key) {
|
_normalizeModule(mod, key) {
|
||||||
|
const installTargets = mod.install_targets ?? mod['install-targets'] ?? mod.installTargets;
|
||||||
|
const workerTargets = mod.worker_targets ?? mod['worker-targets'] ?? mod.workerTargets;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: key || mod.name,
|
key: key || mod.name,
|
||||||
url: mod.repository || mod.url,
|
url: mod.repository || mod.url,
|
||||||
moduleDefinition: mod.module_definition || mod['module-definition'],
|
moduleDefinition: mod.module_definition || mod['module-definition'],
|
||||||
|
sourceRoot: mod.source_root || mod['source-root'] || mod.sourceRoot || null,
|
||||||
code: mod.code,
|
code: mod.code,
|
||||||
name: mod.display_name || mod.name,
|
name: mod.display_name || mod.name,
|
||||||
description: mod.description || '',
|
description: mod.description || '',
|
||||||
defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
|
defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
|
||||||
type: mod.type || 'bmad-org',
|
type: mod.type || 'bmad-org',
|
||||||
npmPackage: mod.npm_package || mod.npmPackage || null,
|
npmPackage: mod.npm_package || mod.npmPackage || null,
|
||||||
|
installTargets: normalizeStringList(installTargets),
|
||||||
|
workerTargets: normalizeStringList(workerTargets),
|
||||||
|
requirements: normalizeStringList(mod.requirements),
|
||||||
|
installNote: mod.install_note || mod['install-note'] || mod.installNote || null,
|
||||||
defaultChannel: normalizeChannelName(mod.default_channel || mod.defaultChannel) || 'stable',
|
defaultChannel: normalizeChannelName(mod.default_channel || mod.defaultChannel) || 'stable',
|
||||||
builtIn: mod.built_in === true,
|
builtIn: mod.built_in === true,
|
||||||
isExternal: mod.built_in !== true,
|
isExternal: mod.built_in !== true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _loadFallbackModules() {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(FALLBACK_CONFIG_PATH, 'utf8');
|
||||||
|
const config = yaml.parse(content);
|
||||||
|
if (Array.isArray(config.modules)) return config.modules.map((mod) => this._normalizeModule(mod));
|
||||||
|
return Object.entries(config.modules || {}).map(([key, mod]) => this._normalizeModule(mod, key));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of available modules from the registry
|
* Get list of available modules from the registry
|
||||||
* @returns {Array<Object>} Array of module info objects
|
* @returns {Array<Object>} Array of module info objects
|
||||||
|
|
@ -145,7 +173,14 @@ class ExternalModuleManager {
|
||||||
|
|
||||||
// Remote format: modules is an array
|
// Remote format: modules is an array
|
||||||
if (Array.isArray(config.modules)) {
|
if (Array.isArray(config.modules)) {
|
||||||
return config.modules.map((mod) => this._normalizeModule(mod));
|
const modules = config.modules.map((mod) => this._normalizeModule(mod));
|
||||||
|
const seenCodes = new Set(modules.map((mod) => mod.code));
|
||||||
|
for (const fallbackMod of await this._loadFallbackModules()) {
|
||||||
|
if (!seenCodes.has(fallbackMod.code)) {
|
||||||
|
modules.push(fallbackMod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return modules;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy bundled format: modules is an object map
|
// Legacy bundled format: modules is an object map
|
||||||
|
|
@ -489,6 +524,19 @@ class ExternalModuleManager {
|
||||||
// Clone the external module repo
|
// Clone the external module repo
|
||||||
const cloneDir = await this.cloneExternalModule(moduleCode, options);
|
const cloneDir = await this.cloneExternalModule(moduleCode, options);
|
||||||
|
|
||||||
|
if (moduleInfo.sourceRoot) {
|
||||||
|
const repoRoot = path.resolve(cloneDir);
|
||||||
|
const sourceRoot = path.resolve(repoRoot, moduleInfo.sourceRoot);
|
||||||
|
const relativeSourceRoot = path.relative(repoRoot, sourceRoot);
|
||||||
|
if (relativeSourceRoot === '..' || relativeSourceRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeSourceRoot)) {
|
||||||
|
throw new Error(`External module '${moduleCode}' source-root escapes repository: ${moduleInfo.sourceRoot}`);
|
||||||
|
}
|
||||||
|
if (!(await fs.pathExists(sourceRoot))) {
|
||||||
|
throw new Error(`External module '${moduleCode}' source-root not found: ${moduleInfo.sourceRoot}`);
|
||||||
|
}
|
||||||
|
return sourceRoot;
|
||||||
|
}
|
||||||
|
|
||||||
// The module-definition specifies the path to module.yaml relative to repo root
|
// The module-definition specifies the path to module.yaml relative to repo root
|
||||||
// We need to return the directory containing module.yaml
|
// We need to return the directory containing module.yaml
|
||||||
const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'skills/module.yaml'
|
const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'skills/module.yaml'
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -301,6 +301,7 @@ class OfficialModules {
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
|
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
|
||||||
|
await this.copyAutomatorRuntimeIfNeeded(moduleName, sourcePath, targetPath, fileTrackingCallback);
|
||||||
|
|
||||||
if (!options.skipModuleInstaller) {
|
if (!options.skipModuleInstaller) {
|
||||||
await this.createModuleDirectories(moduleName, bmadDir, options);
|
await this.createModuleDirectories(moduleName, bmadDir, options);
|
||||||
|
|
@ -572,6 +573,29 @@ class OfficialModules {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async copyAutomatorRuntimeIfNeeded(moduleName, sourcePath, targetPath, fileTrackingCallback = null) {
|
||||||
|
if (moduleName !== 'bma') return;
|
||||||
|
|
||||||
|
const storyTarget = path.join(targetPath, 'bmad-story-automator');
|
||||||
|
if (!(await fs.pathExists(path.join(storyTarget, 'SKILL.md')))) return;
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(sourcePath, '..', '..', '..');
|
||||||
|
const runtimeRoot = path.join(repoRoot, 'source');
|
||||||
|
const runtimeParts = [
|
||||||
|
['scripts', 'scripts'],
|
||||||
|
['src', 'src'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [sourceRel, targetRel] of runtimeParts) {
|
||||||
|
const sourceDir = path.join(runtimeRoot, sourceRel);
|
||||||
|
const targetDir = path.join(storyTarget, targetRel);
|
||||||
|
if (!(await fs.pathExists(sourceDir))) {
|
||||||
|
throw new Error(`BMad Automator runtime source missing: source/${sourceRel}`);
|
||||||
|
}
|
||||||
|
await this.copyModuleWithFiltering(sourceDir, targetDir, fileTrackingCallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create directories declared in module.yaml's `directories` key
|
* Create directories declared in module.yaml's `directories` key
|
||||||
* This replaces the security-risky module installer pattern with declarative config
|
* This replaces the security-risky module installer pattern with declarative config
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -50,3 +50,25 @@ modules:
|
||||||
type: bmad-org
|
type: bmad-org
|
||||||
npmPackage: bmad-method-test-architecture-enterprise
|
npmPackage: bmad-method-test-architecture-enterprise
|
||||||
default_channel: stable
|
default_channel: stable
|
||||||
|
|
||||||
|
bmad-automator:
|
||||||
|
url: https://github.com/bmad-code-org/bmad-automator
|
||||||
|
source-root: payload/.claude/skills
|
||||||
|
code: bma
|
||||||
|
name: "BMad Automator (Experimental)"
|
||||||
|
description: "Experimental pure-skill story automation. Runs only from Claude Code; supports Claude Code and Codex worker sessions; requires tmux on macOS."
|
||||||
|
defaultSelected: false
|
||||||
|
type: experimental
|
||||||
|
npmPackage: bmad-story-automator
|
||||||
|
default_channel: stable
|
||||||
|
install-targets:
|
||||||
|
- claude-code
|
||||||
|
worker-targets:
|
||||||
|
- claude-code
|
||||||
|
- codex
|
||||||
|
requirements:
|
||||||
|
- Claude Code entrypoint
|
||||||
|
- Claude Code or Codex worker sessions
|
||||||
|
- tmux
|
||||||
|
- macOS
|
||||||
|
install-note: "Experimental: BMad Automator only works from Claude Code. It currently supports Claude Code and Codex worker sessions, and requires tmux on macOS."
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,7 @@ class UI {
|
||||||
} else {
|
} else {
|
||||||
selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
|
selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
|
||||||
}
|
}
|
||||||
|
await this.showSelectedExternalModuleNotes(selectedModules);
|
||||||
|
|
||||||
// Resolve custom sources from --custom-source flag
|
// Resolve custom sources from --custom-source flag
|
||||||
if (options.customSource) {
|
if (options.customSource) {
|
||||||
|
|
@ -287,6 +288,7 @@ class UI {
|
||||||
|
|
||||||
// Get tool selection
|
// Get tool selection
|
||||||
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||||
|
await this.showSelectedModuleIdeWarnings(selectedModules, toolSelection.ides);
|
||||||
|
|
||||||
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
||||||
...options,
|
...options,
|
||||||
|
|
@ -343,6 +345,7 @@ class UI {
|
||||||
} else {
|
} else {
|
||||||
selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
|
selectedModules = await this.selectAllModules(installedModuleIds, installedModuleVersions, channelOptions);
|
||||||
}
|
}
|
||||||
|
await this.showSelectedExternalModuleNotes(selectedModules);
|
||||||
|
|
||||||
// Resolve custom sources from --custom-source flag
|
// Resolve custom sources from --custom-source flag
|
||||||
if (options.customSource) {
|
if (options.customSource) {
|
||||||
|
|
@ -366,6 +369,7 @@ class UI {
|
||||||
await this._interactiveChannelGate({ options, channelOptions, selectedModules });
|
await this._interactiveChannelGate({ options, channelOptions, selectedModules });
|
||||||
|
|
||||||
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||||
|
await this.showSelectedModuleIdeWarnings(selectedModules, toolSelection.ides);
|
||||||
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
const { moduleConfigs, setOverrides } = await this.collectModuleConfigs(confirmedDirectory, selectedModules, {
|
||||||
...options,
|
...options,
|
||||||
channelOptions,
|
channelOptions,
|
||||||
|
|
@ -954,6 +958,55 @@ class UI {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async showSelectedExternalModuleNotes(selectedModuleIds, externalModules = null) {
|
||||||
|
if (!externalModules) {
|
||||||
|
const externalManager = new ExternalModuleManager();
|
||||||
|
try {
|
||||||
|
externalModules = await externalManager.listAvailable();
|
||||||
|
} catch (error) {
|
||||||
|
await prompts.log.warn(
|
||||||
|
`ExternalModuleManager.listAvailable failed while loading module notes; continuing without external module notes. ${error.message}`,
|
||||||
|
);
|
||||||
|
externalModules = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes = externalModules
|
||||||
|
.filter((mod) => selectedModuleIds.includes(mod.code) && mod.installNote)
|
||||||
|
.map((mod) => `${mod.name}: ${mod.installNote}`);
|
||||||
|
|
||||||
|
for (const note of notes) {
|
||||||
|
await prompts.log.warn(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async showSelectedModuleIdeWarnings(selectedModuleIds, selectedIdes = []) {
|
||||||
|
const externalManager = new ExternalModuleManager();
|
||||||
|
let externalModules = [];
|
||||||
|
try {
|
||||||
|
externalModules = await externalManager.listAvailable();
|
||||||
|
} catch (error) {
|
||||||
|
await prompts.log.warn(
|
||||||
|
`ExternalModuleManager.listAvailable failed while loading IDE compatibility warnings; continuing without external module warnings. ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mod of externalModules) {
|
||||||
|
if (!selectedModuleIds.includes(mod.code) || !mod.installTargets || mod.installTargets.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasInstallTarget = mod.installTargets.some((target) => selectedIdes.includes(target));
|
||||||
|
if (!hasInstallTarget) {
|
||||||
|
await prompts.log.warn(
|
||||||
|
`${mod.name}: runnable skills are installed only for ${mod.installTargets.join(
|
||||||
|
', ',
|
||||||
|
)}. Add that tool selection to use this module.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Browse and select community modules using category drill-down.
|
* Browse and select community modules using category drill-down.
|
||||||
* Featured/promoted modules appear at the top.
|
* Featured/promoted modules appear at the top.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue