Compare commits
5 Commits
58874fa3f0
...
4e6d20c790
| Author | SHA1 | Date |
|---|---|---|
|
|
4e6d20c790 | |
|
|
513f440a23 | |
|
|
1040c3c306 | |
|
|
ed9dea9058 | |
|
|
9e3d32080b |
|
|
@ -3,12 +3,20 @@
|
||||||
"owner": {
|
"owner": {
|
||||||
"name": "Brian (BMad) Madison"
|
"name": "Brian (BMad) Madison"
|
||||||
},
|
},
|
||||||
|
"description": "Breakthrough Method of Agile AI-driven Development — a full-lifecycle framework with agents and workflows for analysis, planning, architecture, and implementation.",
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": "https://github.com/bmad-code-org/BMAD-METHOD",
|
||||||
|
"repository": "https://github.com/bmad-code-org/BMAD-METHOD",
|
||||||
|
"keywords": ["bmad", "agile", "ai", "orchestrator", "development", "methodology", "agents"],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "bmad-pro-skills",
|
"name": "bmad-pro-skills",
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"description": "Next level skills for power users — advanced prompting techniques, agent management, and more.",
|
"description": "Next level skills for power users — advanced prompting techniques, agent management, and more.",
|
||||||
"version": "6.3.0",
|
"version": "6.3.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Brian (BMad) Madison"
|
||||||
|
},
|
||||||
"skills": [
|
"skills": [
|
||||||
"./src/core-skills/bmad-help",
|
"./src/core-skills/bmad-help",
|
||||||
"./src/core-skills/bmad-init",
|
"./src/core-skills/bmad-init",
|
||||||
|
|
@ -29,6 +37,9 @@
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"description": "Full-lifecycle AI development framework — agents and workflows for product analysis, planning, architecture, and implementation.",
|
"description": "Full-lifecycle AI development framework — agents and workflows for product analysis, planning, architecture, and implementation.",
|
||||||
"version": "6.3.0",
|
"version": "6.3.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Brian (BMad) Madison"
|
||||||
|
},
|
||||||
"skills": [
|
"skills": [
|
||||||
"./src/bmm-skills/1-analysis/bmad-product-brief",
|
"./src/bmm-skills/1-analysis/bmad-product-brief",
|
||||||
"./src/bmm-skills/1-analysis/bmad-agent-analyst",
|
"./src/bmm-skills/1-analysis/bmad-agent-analyst",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"name": "bmad-method",
|
|
||||||
"version": "6.2.2",
|
|
||||||
"description": "Breakthrough Method of Agile AI-driven Development — a full-lifecycle framework with agents and workflows for analysis, planning, architecture, and implementation. The core BMad Method.",
|
|
||||||
"author": {
|
|
||||||
"name": "Brian (BMad) Madison"
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"homepage": "https://github.com/bmad-code-org/BMAD-METHOD",
|
|
||||||
"repository": "https://github.com/bmad-code-org/BMAD-METHOD",
|
|
||||||
"keywords": ["bmad", "agile", "ai", "orchestrator", "development", "methodology", "agents"]
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,7 @@ on:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "src/**"
|
- "src/**"
|
||||||
- "tools/cli/**"
|
- "tools/installer/**"
|
||||||
- "package.json"
|
- "package.json"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ IDs d'outils disponibles pour l’option `--tools` :
|
||||||
|
|
||||||
**Recommandés :** `claude-code`, `cursor`
|
**Recommandés :** `claude-code`, `cursor`
|
||||||
|
|
||||||
Exécutez `npx bmad-method install` de manière interactive une fois pour voir la liste complète actuelle des outils pris en charge, ou consultez la [configuration des codes de la plateforme](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/cli/installers/lib/ide/platform-codes.yaml).
|
Exécutez `npx bmad-method install` de manière interactive une fois pour voir la liste complète actuelle des outils pris en charge, ou consultez la [configuration des codes de la plateforme](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/installer/ide/platform-codes.yaml).
|
||||||
|
|
||||||
## Modes d'installation
|
## Modes d'installation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,19 @@ Requires [Node.js](https://nodejs.org) v20+ and `npx` (included with npm).
|
||||||
| `--user-name <name>` | Name for agents to use | System username |
|
| `--user-name <name>` | Name for agents to use | System username |
|
||||||
| `--communication-language <lang>` | Agent communication language | English |
|
| `--communication-language <lang>` | Agent communication language | English |
|
||||||
| `--document-output-language <lang>` | Document output language | English |
|
| `--document-output-language <lang>` | Document output language | English |
|
||||||
| `--output-folder <path>` | Output folder path | _bmad-output |
|
| `--output-folder <path>` | Output folder path (see resolution rules below) | `_bmad-output` |
|
||||||
|
|
||||||
|
#### Output Folder Path Resolution
|
||||||
|
|
||||||
|
The value passed to `--output-folder` (or entered interactively) is resolved according to these rules:
|
||||||
|
|
||||||
|
| Input type | Example | Resolved as |
|
||||||
|
|------------|---------|-------------|
|
||||||
|
| Relative path (default) | `_bmad-output` | `<project-root>/_bmad-output` |
|
||||||
|
| Relative path with traversal | `../../shared-outputs` | Normalized absolute path — e.g. `/Users/me/shared-outputs` |
|
||||||
|
| Absolute path | `/Users/me/shared-outputs` | Used as-is — project root is **not** prepended |
|
||||||
|
|
||||||
|
The resolved path is what agents and workflows use at runtime when writing output files. Using an absolute path or a traversal-based relative path lets you direct all generated artifacts to a directory outside your project tree — useful for shared or monorepo setups.
|
||||||
|
|
||||||
### Other Options
|
### Other Options
|
||||||
|
|
||||||
|
|
@ -61,7 +73,7 @@ Available tool IDs for the `--tools` flag:
|
||||||
|
|
||||||
**Preferred:** `claude-code`, `cursor`
|
**Preferred:** `claude-code`, `cursor`
|
||||||
|
|
||||||
Run `npx bmad-method install` interactively once to see the full current list of supported tools, or check the [platform codes configuration](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/cli/installers/lib/ide/platform-codes.yaml).
|
Run `npx bmad-method install` interactively once to see the full current list of supported tools, or check the [platform codes configuration](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/installer/ide/platform-codes.yaml).
|
||||||
|
|
||||||
## Installation Modes
|
## Installation Modes
|
||||||
|
|
||||||
|
|
@ -141,6 +153,7 @@ Invalid values will either:
|
||||||
|
|
||||||
:::tip[Best Practices]
|
:::tip[Best Practices]
|
||||||
- Use absolute paths for `--directory` to avoid ambiguity
|
- Use absolute paths for `--directory` to avoid ambiguity
|
||||||
|
- Use an absolute path for `--output-folder` when you want artifacts written outside the project tree (e.g. a shared monorepo outputs directory)
|
||||||
- Test flags locally before using in CI/CD pipelines
|
- Test flags locally before using in CI/CD pipelines
|
||||||
- Combine with `-y` for truly unattended installations
|
- Combine with `-y` for truly unattended installations
|
||||||
- Use `--debug` if you encounter issues during installation
|
- Use `--debug` if you encounter issues during installation
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ sidebar:
|
||||||
|
|
||||||
**推荐:** `claude-code`、`cursor`
|
**推荐:** `claude-code`、`cursor`
|
||||||
|
|
||||||
运行一次 `npx bmad-method install` 交互式安装以查看完整的当前支持工具列表,或查看 [平台代码配置](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/cli/installers/lib/ide/platform-codes.yaml)。
|
运行一次 `npx bmad-method install` 交互式安装以查看完整的当前支持工具列表,或查看 [平台代码配置](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/installer/ide/platform-codes.yaml)。
|
||||||
|
|
||||||
## 安装模式
|
## 安装模式
|
||||||
|
|
||||||
|
|
|
||||||
14
package.json
14
package.json
|
|
@ -18,14 +18,14 @@
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Brian (BMad) Madison",
|
"author": "Brian (BMad) Madison",
|
||||||
"main": "tools/cli/bmad-cli.js",
|
"main": "tools/installer/bmad-cli.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
"bmad": "tools/bmad-npx-wrapper.js",
|
"bmad": "tools/installer/bmad-cli.js",
|
||||||
"bmad-method": "tools/bmad-npx-wrapper.js"
|
"bmad-method": "tools/installer/bmad-cli.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"bmad:install": "node tools/cli/bmad-cli.js install",
|
"bmad:install": "node tools/installer/bmad-cli.js install",
|
||||||
"bmad:uninstall": "node tools/cli/bmad-cli.js uninstall",
|
"bmad:uninstall": "node tools/installer/bmad-cli.js uninstall",
|
||||||
"docs:build": "node tools/build-docs.mjs",
|
"docs:build": "node tools/build-docs.mjs",
|
||||||
"docs:dev": "astro dev --root website",
|
"docs:dev": "astro dev --root website",
|
||||||
"docs:fix-links": "node tools/fix-doc-links.js",
|
"docs:fix-links": "node tools/fix-doc-links.js",
|
||||||
|
|
@ -34,13 +34,13 @@
|
||||||
"format:check": "prettier --check \"**/*.{js,cjs,mjs,json,yaml}\"",
|
"format:check": "prettier --check \"**/*.{js,cjs,mjs,json,yaml}\"",
|
||||||
"format:fix": "prettier --write \"**/*.{js,cjs,mjs,json,yaml}\"",
|
"format:fix": "prettier --write \"**/*.{js,cjs,mjs,json,yaml}\"",
|
||||||
"format:fix:staged": "prettier --write",
|
"format:fix:staged": "prettier --write",
|
||||||
"install:bmad": "node tools/cli/bmad-cli.js install",
|
"install:bmad": "node tools/installer/bmad-cli.js install",
|
||||||
"lint": "eslint . --ext .js,.cjs,.mjs,.yaml --max-warnings=0",
|
"lint": "eslint . --ext .js,.cjs,.mjs,.yaml --max-warnings=0",
|
||||||
"lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
|
"lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
|
||||||
"lint:md": "markdownlint-cli2 \"**/*.md\"",
|
"lint:md": "markdownlint-cli2 \"**/*.md\"",
|
||||||
"prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
|
"prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
|
||||||
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run validate:refs && npm run validate:skills",
|
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run validate:refs && npm run validate:skills",
|
||||||
"rebundle": "node tools/cli/bundlers/bundle-web.js rebundle",
|
"rebundle": "node tools/installer/bundlers/bundle-web.js rebundle",
|
||||||
"test": "npm run test:refs && npm run test:install && npm run lint && npm run lint:md && npm run format:check",
|
"test": "npm run test:refs && npm run test:install && npm run lint && npm run lint:md && npm run format:check",
|
||||||
"test:install": "node test/test-installation-components.js",
|
"test:install": "node test/test-installation-components.js",
|
||||||
"test:refs": "node test/test-file-refs-csv.js",
|
"test:refs": "node test/test-file-refs-csv.js",
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ parts: 1
|
||||||
- Deferred: CI/CD integration, telemetry for module authors, air-gapped enterprise install, zip bundle integrity verification (checksums/signing), deeper non-technical platform integrations
|
- Deferred: CI/CD integration, telemetry for module authors, air-gapped enterprise install, zip bundle integrity verification (checksums/signing), deeper non-technical platform integrations
|
||||||
|
|
||||||
## Current Installer (migration context)
|
## Current Installer (migration context)
|
||||||
- Entry: `tools/cli/bmad-cli.js` (Commander.js) → `tools/cli/installers/lib/core/installer.js`
|
- Entry: `tools/installer/bmad-cli.js` (Commander.js) → `tools/installer/core/installer.js`
|
||||||
- Platforms: `platform-codes.yaml` (~20 platforms with target dirs, legacy dirs, template types, special flags)
|
- Platforms: `platform-codes.yaml` (~20 platforms with target dirs, legacy dirs, template types, special flags)
|
||||||
- Manifests: CSV files (skill/workflow/agent-manifest.csv) are current source of truth, not JSON
|
- Manifests: CSV files (skill/workflow/agent-manifest.csv) are current source of truth, not JSON
|
||||||
- External modules: `external-official-modules.yaml` (CIS, GDS, TEA, WDS) from npm with semver
|
- External modules: `external-official-modules.yaml` (CIS, GDS, TEA, WDS) from npm with semver
|
||||||
|
|
|
||||||
|
|
@ -166,9 +166,27 @@ def resolve_project_root_placeholder(value, project_root):
|
||||||
"""Replace {project-root} placeholder with actual path."""
|
"""Replace {project-root} placeholder with actual path."""
|
||||||
if not value or not isinstance(value, str):
|
if not value or not isinstance(value, str):
|
||||||
return value
|
return value
|
||||||
if '{project-root}' in value:
|
if '{project-root}' not in value:
|
||||||
return value.replace('{project-root}', str(project_root))
|
return value
|
||||||
return value
|
|
||||||
|
# Strip the {project-root} token to inspect what remains, so we can
|
||||||
|
# correctly handle absolute paths stored as "{project-root}//absolute/path"
|
||||||
|
# (produced by the "{project-root}/{value}" template applied to an absolute value).
|
||||||
|
suffix = value.replace('{project-root}', '', 1)
|
||||||
|
|
||||||
|
# Strip the one path separator that follows the token (if any)
|
||||||
|
if suffix.startswith('/') or suffix.startswith('\\'):
|
||||||
|
remainder = suffix[1:]
|
||||||
|
else:
|
||||||
|
remainder = suffix
|
||||||
|
|
||||||
|
if os.path.isabs(remainder):
|
||||||
|
# The original value was an absolute path stored with a {project-root}/ prefix.
|
||||||
|
# Return the absolute path directly — no joining needed.
|
||||||
|
return remainder
|
||||||
|
|
||||||
|
# Relative path: join with project root and normalize to resolve any .. segments.
|
||||||
|
return os.path.normpath(os.path.join(str(project_root), remainder))
|
||||||
|
|
||||||
|
|
||||||
def parse_var_specs(vars_string):
|
def parse_var_specs(vars_string):
|
||||||
|
|
@ -222,9 +240,22 @@ def apply_result_template(var_def, raw_value, context):
|
||||||
if not result_template:
|
if not result_template:
|
||||||
return raw_value
|
return raw_value
|
||||||
|
|
||||||
|
# If the user supplied an absolute path and the template would prefix it with
|
||||||
|
# "{project-root}/", skip the template entirely to avoid producing a broken path
|
||||||
|
# like "/my/project//absolute/path".
|
||||||
|
if isinstance(raw_value, str) and os.path.isabs(raw_value):
|
||||||
|
return raw_value
|
||||||
|
|
||||||
ctx = dict(context)
|
ctx = dict(context)
|
||||||
ctx['value'] = raw_value
|
ctx['value'] = raw_value
|
||||||
return expand_template(result_template, ctx)
|
result = expand_template(result_template, ctx)
|
||||||
|
|
||||||
|
# Normalize the resulting path to resolve any ".." segments (e.g. when the user
|
||||||
|
# entered a relative path such as "../../outside-dir").
|
||||||
|
if isinstance(result, str) and '{' not in result and os.path.isabs(result):
|
||||||
|
result = os.path.normpath(result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,37 @@ class TestResolveProjectRootPlaceholder(unittest.TestCase):
|
||||||
def test_non_string(self):
|
def test_non_string(self):
|
||||||
self.assertEqual(resolve_project_root_placeholder(42, Path('/test')), 42)
|
self.assertEqual(resolve_project_root_placeholder(42, Path('/test')), 42)
|
||||||
|
|
||||||
|
def test_absolute_path_stored_with_prefix(self):
|
||||||
|
"""Absolute output_folder entered by user is stored as '{project-root}//abs/path'
|
||||||
|
by the '{project-root}/{value}' template. It must resolve to '/abs/path', not
|
||||||
|
'/project//abs/path'."""
|
||||||
|
result = resolve_project_root_placeholder(
|
||||||
|
'{project-root}//Users/me/outside', Path('/Users/me/myproject')
|
||||||
|
)
|
||||||
|
self.assertEqual(result, '/Users/me/outside')
|
||||||
|
|
||||||
|
def test_relative_path_with_traversal_is_normalized(self):
|
||||||
|
"""A relative path like '../../sibling' produces '{project-root}/../../sibling'
|
||||||
|
after the template. It must resolve to the normalized absolute path, not the
|
||||||
|
un-normalized string '/project/../../sibling'."""
|
||||||
|
result = resolve_project_root_placeholder(
|
||||||
|
'{project-root}/../../sibling', Path('/Users/me/myproject')
|
||||||
|
)
|
||||||
|
self.assertEqual(result, '/Users/sibling')
|
||||||
|
|
||||||
|
def test_relative_path_one_level_up(self):
|
||||||
|
result = resolve_project_root_placeholder(
|
||||||
|
'{project-root}/../outside-outputs', Path('/project/root')
|
||||||
|
)
|
||||||
|
self.assertEqual(result, '/project/outside-outputs')
|
||||||
|
|
||||||
|
def test_standard_relative_path_unchanged(self):
|
||||||
|
"""Normal in-project relative paths continue to work correctly."""
|
||||||
|
result = resolve_project_root_placeholder(
|
||||||
|
'{project-root}/_bmad-output', Path('/project/root')
|
||||||
|
)
|
||||||
|
self.assertEqual(result, '/project/root/_bmad-output')
|
||||||
|
|
||||||
|
|
||||||
class TestExpandTemplate(unittest.TestCase):
|
class TestExpandTemplate(unittest.TestCase):
|
||||||
|
|
||||||
|
|
@ -147,6 +178,39 @@ class TestApplyResultTemplate(unittest.TestCase):
|
||||||
result = apply_result_template(var_def, 'English', {})
|
result = apply_result_template(var_def, 'English', {})
|
||||||
self.assertEqual(result, 'English')
|
self.assertEqual(result, 'English')
|
||||||
|
|
||||||
|
def test_absolute_value_skips_project_root_template(self):
|
||||||
|
"""When the user enters an absolute path, the '{project-root}/{value}' template
|
||||||
|
must not be applied — doing so would produce '/project//absolute/path'."""
|
||||||
|
var_def = {'result': '{project-root}/{value}'}
|
||||||
|
result = apply_result_template(
|
||||||
|
var_def, '/Users/me/shared-outputs', {'project-root': '/Users/me/myproject'}
|
||||||
|
)
|
||||||
|
self.assertEqual(result, '/Users/me/shared-outputs')
|
||||||
|
|
||||||
|
def test_relative_traversal_value_is_normalized(self):
|
||||||
|
"""A relative path like '../../outside' combined with the project-root template
|
||||||
|
must produce a clean normalized absolute path, not '/project/../../outside'."""
|
||||||
|
var_def = {'result': '{project-root}/{value}'}
|
||||||
|
result = apply_result_template(
|
||||||
|
var_def, '../../outside-dir', {'project-root': '/Users/me/myproject'}
|
||||||
|
)
|
||||||
|
self.assertEqual(result, '/Users/outside-dir')
|
||||||
|
|
||||||
|
def test_relative_one_level_up_is_normalized(self):
|
||||||
|
var_def = {'result': '{project-root}/{value}'}
|
||||||
|
result = apply_result_template(
|
||||||
|
var_def, '../sibling-outputs', {'project-root': '/project/root'}
|
||||||
|
)
|
||||||
|
self.assertEqual(result, '/project/sibling-outputs')
|
||||||
|
|
||||||
|
def test_normal_relative_value_unchanged(self):
|
||||||
|
"""Standard in-project relative paths still produce the expected joined path."""
|
||||||
|
var_def = {'result': '{project-root}/{value}'}
|
||||||
|
result = apply_result_template(
|
||||||
|
var_def, '_bmad-output', {'project-root': '/project/root'}
|
||||||
|
)
|
||||||
|
self.assertEqual(result, '/project/root/_bmad-output')
|
||||||
|
|
||||||
|
|
||||||
class TestLoadModuleYaml(unittest.TestCase):
|
class TestLoadModuleYaml(unittest.TestCase):
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,202 @@
|
||||||
---
|
---
|
||||||
name: bmad-party-mode
|
name: bmad-party-mode
|
||||||
description: 'Orchestrates group discussions between all installed BMAD agents, enabling natural multi-agent conversations. Use when user requests party mode.'
|
description: 'Multi-agent roundtable with independent sub-agent voices. Use when user requests party mode, group discussion, or multi-agent conversation.'
|
||||||
---
|
---
|
||||||
|
|
||||||
Follow the instructions in ./workflow.md.
|
# Party Mode
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Facilitate dynamic multi-agent roundtable discussions where installed BMAD agents collaborate as **independent sub-agents** — each spawned as its own process with genuine independent thinking. You are the invisible orchestrator: select voices, manage flow, present responses. Never speak as the agents yourself. Works across Claude Code, Codex, and Gemini CLI through a platform adapter system.
|
||||||
|
|
||||||
|
### Hard Constraints
|
||||||
|
|
||||||
|
These rules are inviolable across all platforms:
|
||||||
|
|
||||||
|
1. **One agent = one sub-agent process.** Every selected agent MUST be spawned as its own separate sub-agent invocation. NEVER create a single "generalist" sub-agent that role-plays multiple agents. NEVER delegate the entire roundtable to one sub-agent. The whole point is independent thinking per agent.
|
||||||
|
2. **Always show initial responses.** Pass 1 (initial) agent responses MUST be presented to the user in full. Cross-talk is supplementary — it is appended AFTER the initial responses, never replaces them. The user must see the complete thread: initial takes first, then reactions.
|
||||||
|
3. **Orchestrator never speaks as agents.** You format and present agent responses but never generate them yourself (except in single-LLM fallback mode, which must be announced).
|
||||||
|
|
||||||
|
## On Activation
|
||||||
|
|
||||||
|
### Platform Detection
|
||||||
|
|
||||||
|
Detect platform and load the corresponding adapter:
|
||||||
|
|
||||||
|
| Platform | Detection Signal | Adapter |
|
||||||
|
|---|---|---|
|
||||||
|
| **Claude Code** | `Agent` tool available | `./adapters/claude-code.md` |
|
||||||
|
| **Codex** | Inside Codex CLI, or `.codex/` exists | `./adapters/codex.md` |
|
||||||
|
| **Gemini CLI** | Inside Gemini CLI, or `.gemini/` exists | `./adapters/gemini.md` |
|
||||||
|
|
||||||
|
Default to Claude Code if uncertain. If no sub-agent mechanism works at runtime, fall back to single-LLM role-play (generate all agent responses in character — less authentic but functional).
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Load from `{project-root}/_bmad/config.yaml` and `{project-root}/_bmad/config.user.yaml` if present. Sensible defaults for anything not configured.
|
||||||
|
|
||||||
|
- `{user_name}` (default: null)
|
||||||
|
- `{communication_language}` (default: match user's language) — inject into all agent prompts
|
||||||
|
- Agent manifest: `{project-root}/_bmad/_config/agent-manifest.csv`
|
||||||
|
|
||||||
|
### Agent Manifest
|
||||||
|
|
||||||
|
Parse each CSV row for merged personality profiles:
|
||||||
|
|
||||||
|
| Field | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| name | System identifier |
|
||||||
|
| displayName | Conversational name |
|
||||||
|
| title | Role/position |
|
||||||
|
| icon | Emoji identifier |
|
||||||
|
| role | Capabilities summary |
|
||||||
|
| identity | Background and expertise depth |
|
||||||
|
| communicationStyle | Voice and tone guide |
|
||||||
|
| principles | Values and decision philosophy |
|
||||||
|
| module | Source module |
|
||||||
|
| path | Agent file location (merge additional data if readable) |
|
||||||
|
|
||||||
|
Structure each profile as a **spawn-ready prompt block** using `./references/agent-prompt-template.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ ORCHESTRATOR (you — main context) │
|
||||||
|
│ Scores agents · Manages state · Adapts rounds │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ Per round: │
|
||||||
|
│ 1. Score & select agents (1-3) │
|
||||||
|
│ 2. Calibrate round (model, depth, cross-talk?) │
|
||||||
|
│ 3. Spawn sub-agents via platform adapter │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ Agent A │ │ Agent B │ │ Agent C │ │
|
||||||
|
│ │(parallel)│ │(parallel)│ │(parallel)│ │
|
||||||
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||||
|
│ └─────────────┴─────────────┘ │
|
||||||
|
│ 4. Assess quality · Optional cross-talk pass │
|
||||||
|
│ 5. Present round · Update state │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Three stages — initialize, orchestrate, exit — with the adaptive conversation loop in stage 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Selection: Scoring Algorithm
|
||||||
|
|
||||||
|
For each candidate agent, compute a **relevance score** (0-10) based on:
|
||||||
|
|
||||||
|
| Factor | Weight | How to Assess |
|
||||||
|
|---|---|---|
|
||||||
|
| **Expertise match** | 4 | Overlap between user's topic and agent's `role` + `identity` keywords |
|
||||||
|
| **Complementarity** | 3 | Does this agent add a distinct angle vs. already-selected agents? |
|
||||||
|
| **Recency penalty** | 2 | Reduce score by 1 for each of the last 2 consecutive rounds this agent appeared |
|
||||||
|
| **User affinity** | 1 | Bonus if user has addressed or praised this agent recently |
|
||||||
|
|
||||||
|
**Selection flow:**
|
||||||
|
1. Score all agents against the current message
|
||||||
|
2. Select the highest-scoring agent as **primary**
|
||||||
|
3. Select the next-highest as **secondary** — but only if their complementarity score is ≥ 2
|
||||||
|
4. Select a **tertiary** only if the topic is genuinely cross-cutting AND complementarity ≥ 3
|
||||||
|
5. For simple/factual questions, use primary only — don't force a roundtable
|
||||||
|
|
||||||
|
**Override rules:**
|
||||||
|
- User names a specific agent → that agent is primary regardless of score; add 1-2 complementary voices
|
||||||
|
- Same agent dominated 3+ consecutive rounds → cap their score at 5
|
||||||
|
- If all scores are below 3 → pick the closest match and acknowledge the topic is outside the team's core expertise
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Round Calibration
|
||||||
|
|
||||||
|
Before spawning, calibrate the round based on the input:
|
||||||
|
|
||||||
|
| Signal | Agents | Depth | Cross-talk | Model hint |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Quick factual question | 1 | Brief | No | Fast/cheap if available |
|
||||||
|
| Standard discussion | 2 | Medium | If perspectives diverge | Default |
|
||||||
|
| Complex/ambiguous problem | 2-3 | Deep | Yes | Default |
|
||||||
|
| Debate or controversy | 2-3 | Full | Mandatory | Default |
|
||||||
|
| Fun / personality banter | 2-3 | Character-heavy | Yes (playful) | Default |
|
||||||
|
| Circular discussion detected | 1 (authority) | Summary + new angle | No | Default |
|
||||||
|
| Follow-up on previous round | 1-2 | Builds on prior | Only if new tension | Default |
|
||||||
|
|
||||||
|
"Model hint" is advisory — the platform adapter decides if it supports model switching (e.g., Claude Code can use `haiku` for fast rounds).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Character Fidelity
|
||||||
|
|
||||||
|
Each agent response must use their documented `communicationStyle`, reflect their `principles`, and draw from their `identity`. Prefix with `icon` and **displayName**.
|
||||||
|
|
||||||
|
**Cross-talk:** Agents reference each other naturally — build on points, offer counter-perspectives, ask clarifying questions within the same round.
|
||||||
|
|
||||||
|
**Anti-patterns — avoid these:**
|
||||||
|
- Agents agreeing just to be polite or restating each other
|
||||||
|
- All agents saying the same thing in different words
|
||||||
|
- Breaking character to explain orchestration mechanics
|
||||||
|
- Agents hedging everything with "that's a great point" filler
|
||||||
|
- Generic AI assistant tone leaking through personality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Question Protocol
|
||||||
|
|
||||||
|
| Type | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Agent asks user directly | Present response, **stop the round**, wait for user input |
|
||||||
|
| Agent-to-agent question | Resolve in the cross-talk pass |
|
||||||
|
| Rhetorical | Continue naturally |
|
||||||
|
| Multiple agents ask user | Present all, then consolidate into one clear prompt to the user |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Moderation
|
||||||
|
|
||||||
|
The orchestrator handles moderation transparently:
|
||||||
|
|
||||||
|
- **Circular discussion** → Summarize the impasse, spawn an authority agent to propose resolution
|
||||||
|
- **Topic drift** → Frame the next prompt to reconnect tangent to main thread
|
||||||
|
- **One agent dominating** → Lower their score; bring in different voices
|
||||||
|
- **Low-value round** → Fewer agents, shorter prompts, signal "keep it brief" in the agent prompt
|
||||||
|
- **Energy drop** → Inject a provocative angle or bring in a contrarian voice
|
||||||
|
- **User disengagement** (short replies, long gaps) → Ask directly: continue, change topic, or exit?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conversation Momentum
|
||||||
|
|
||||||
|
Track implicitly across rounds:
|
||||||
|
|
||||||
|
- **High momentum** — Multiple agents engaged, user asking follow-ups, perspectives diverging productively → lean into it, allow longer responses, encourage cross-talk
|
||||||
|
- **Steady** — Normal flow → standard calibration
|
||||||
|
- **Low momentum** — Repetitive takes, user giving minimal input, agents converging → rotate voices, introduce a contrarian, shorten rounds, or ask the user what they'd like to explore
|
||||||
|
|
||||||
|
Don't announce momentum tracking. Just adapt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exit Conditions
|
||||||
|
|
||||||
|
Trigger exit when user sends: `*exit`, `goodbye`, `end party`, `quit`, or `[E]`.
|
||||||
|
|
||||||
|
Never auto-exit. If conversation winds down, ask directly rather than assuming.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage Routing
|
||||||
|
|
||||||
|
### Stage 1: Initialize
|
||||||
|
Load agents, build profiles, create platform agent files if needed, activate.
|
||||||
|
→ Load `./steps/step-01-initialize.md`
|
||||||
|
|
||||||
|
### Stage 2: Orchestrate (conversation loop)
|
||||||
|
Score → calibrate → spawn → assess → present → repeat.
|
||||||
|
→ Load `./steps/step-02-orchestrate.md`
|
||||||
|
|
||||||
|
### Stage 3: Exit
|
||||||
|
Brief farewells, session highlights, return to parent if applicable.
|
||||||
|
→ Load `./steps/step-03-exit.md`
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Platform Adapter: Claude Code
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
| Feature | Support |
|
||||||
|
|---|---|
|
||||||
|
| Parallel sub-agents | Yes — multiple `Agent` tool calls in one response |
|
||||||
|
| Nested sub-agents | Yes (not needed for party mode) |
|
||||||
|
| Inline prompt injection | Yes — full prompt passed at spawn time |
|
||||||
|
| Pre-defined agent files | Not required |
|
||||||
|
| Model selection | Yes — `model` parameter per agent |
|
||||||
|
| Tool access in sub-agents | Based on `subagent_type` |
|
||||||
|
|
||||||
|
## How to Spawn a Party Mode Agent
|
||||||
|
|
||||||
|
Use the **Agent tool** for each selected agent:
|
||||||
|
|
||||||
|
```
|
||||||
|
Agent tool call:
|
||||||
|
description: "{displayName} responds to discussion"
|
||||||
|
subagent_type: "general-purpose"
|
||||||
|
prompt: <assembled from ./references/agent-prompt-template.md>
|
||||||
|
model: <optional — see Model Selection below>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key parameters:**
|
||||||
|
- `description` — Short label: e.g., "Winston responds to architecture question"
|
||||||
|
- `subagent_type` — Always `"general-purpose"` for party mode agents
|
||||||
|
- `prompt` — Fully assembled agent prompt with personality, context, depth signal, and user message
|
||||||
|
- `model` — Optional override (see below)
|
||||||
|
|
||||||
|
## Model Selection Strategy
|
||||||
|
|
||||||
|
Claude Code supports per-agent model selection. Use this to optimize cost and speed:
|
||||||
|
|
||||||
|
| Round calibration | Model | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Depth: "brief", simple factual question | `"haiku"` | Fast, cheap — no need for heavy reasoning |
|
||||||
|
| Depth: "standard", normal discussion | Omit (inherit current) | Default model handles this well |
|
||||||
|
| Depth: "deep", complex analysis | Omit (inherit current) | Full capability needed |
|
||||||
|
| Cross-talk reactions (2-3 sentences) | `"haiku"` | Short reactive responses don't need heavy models |
|
||||||
|
| Farewell responses | `"haiku"` | 1-2 sentences of in-character goodbye |
|
||||||
|
|
||||||
|
Only use `"haiku"` when the response is genuinely simple. When in doubt, omit the parameter.
|
||||||
|
|
||||||
|
## Parallel Execution
|
||||||
|
|
||||||
|
To spawn agents in parallel, include **multiple Agent tool calls in a single response message**. Claude Code executes them concurrently and returns all results together.
|
||||||
|
|
||||||
|
Example for a 3-agent round:
|
||||||
|
```
|
||||||
|
Response contains:
|
||||||
|
Agent call 1: description="Winston responds", prompt=<winston_prompt>
|
||||||
|
Agent call 2: description="Maya responds", prompt=<maya_prompt>
|
||||||
|
Agent call 3: description="Rex responds", prompt=<rex_prompt>
|
||||||
|
```
|
||||||
|
|
||||||
|
All three run simultaneously. Collect all results before presenting to user.
|
||||||
|
|
||||||
|
## Cross-Talk Pass
|
||||||
|
|
||||||
|
For cross-talk, spawn agents **sequentially** (one Agent call per response) so each can see previous outputs. Include Pass 1 responses in the prompt under "Other Agents' Responses This Round".
|
||||||
|
|
||||||
|
Consider using `model: "haiku"` for cross-talk since responses are short reactions.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Sub-agents return text results to the orchestrator — not visible to user until presented
|
||||||
|
- Each sub-agent gets a fresh context (no conversation history — include relevant context in prompt)
|
||||||
|
- Sub-agents should NOT use tools — instruct them to respond with text only
|
||||||
|
- Token cost scales linearly with agents spawned per round
|
||||||
|
|
||||||
|
## Optimization
|
||||||
|
|
||||||
|
- Single agent for simple questions — skip parallel overhead
|
||||||
|
- Keep conversation context under 400 words
|
||||||
|
- Use `"haiku"` for brief/reactive rounds to save tokens and time
|
||||||
|
- The orchestrator's context window is the bottleneck in long sessions — maintain the compaction state block diligently
|
||||||
|
- If a spawn fails, present remaining agents normally — don't retry or block
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
# Platform Adapter: Codex (OpenAI)
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
| Feature | Support |
|
||||||
|
|---|---|
|
||||||
|
| Parallel sub-agents | Yes — parallel by default, up to `agents.max_threads` (default 6) |
|
||||||
|
| Nested sub-agents | No — `agents.max_depth` defaults to 1 |
|
||||||
|
| Inline prompt injection | Yes — via natural language spawning |
|
||||||
|
| Pre-defined agent files | Supported — `.codex/agents/*.toml` for persistent definitions |
|
||||||
|
| Model selection | Via TOML `model` field per agent definition |
|
||||||
|
| Tool access in sub-agents | Inherited from parent + per-agent overrides |
|
||||||
|
|
||||||
|
## Setup: Pre-Defined Agent Files (Recommended)
|
||||||
|
|
||||||
|
During Stage 1 (Initialize), generate a TOML file for each BMAD agent in `.codex/agents/`:
|
||||||
|
|
||||||
|
**Skip if files already exist and manifest hasn't changed** (same agent count and names).
|
||||||
|
|
||||||
|
**Template — `.codex/agents/{agent-name}.toml`:**
|
||||||
|
```toml
|
||||||
|
name = "{name}"
|
||||||
|
description = "{displayName} - {title}. {role}"
|
||||||
|
|
||||||
|
developer_instructions = """
|
||||||
|
You are {displayName} ({title}), a BMAD agent in a collaborative roundtable discussion.
|
||||||
|
|
||||||
|
Your Personality:
|
||||||
|
- Icon: {icon}
|
||||||
|
- Role: {role}
|
||||||
|
- Identity: {identity}
|
||||||
|
- Communication Style: {communicationStyle}
|
||||||
|
- Principles: {principles}
|
||||||
|
|
||||||
|
Instructions:
|
||||||
|
- Respond as {displayName}. Your genuine expert perspective — not a safe, hedged AI answer.
|
||||||
|
- Start with: {icon} **{displayName}**:
|
||||||
|
- Match your documented communication style exactly.
|
||||||
|
- Scale response length to the substance of your point.
|
||||||
|
- If you disagree with another agent, say so directly.
|
||||||
|
- If you have nothing substantial to add, say so in one sentence.
|
||||||
|
- Respond in {communication_language}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
sandbox_mode = "read-only"
|
||||||
|
```
|
||||||
|
|
||||||
|
`sandbox_mode = "read-only"` — party agents think and respond, they don't modify files.
|
||||||
|
|
||||||
|
## How to Spawn a Party Mode Agent
|
||||||
|
|
||||||
|
**Option A — Reference pre-defined agents (recommended):**
|
||||||
|
```
|
||||||
|
Spawn agent "{name}" with this context:
|
||||||
|
Discussion so far: {conversation_context_summary}
|
||||||
|
Depth: {brief | standard | deep}
|
||||||
|
User's message: {user_message}
|
||||||
|
Respond in character. Start with {icon} **{displayName}**:
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B — Natural language spawning (dynamic):**
|
||||||
|
```
|
||||||
|
Spawn an agent to respond as {displayName} with this context:
|
||||||
|
[conversation context summary]
|
||||||
|
User's message: {user_message}
|
||||||
|
The agent should respond in character as {displayName}, starting with {icon} **{displayName}**:
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Execution
|
||||||
|
|
||||||
|
Codex runs sub-agents **in parallel by default**. Spawn multiple together:
|
||||||
|
|
||||||
|
```
|
||||||
|
Spawn these agents in parallel and wait for all results:
|
||||||
|
1. Agent "{agent_a_name}" — respond to: {user_message} with context: {context}
|
||||||
|
2. Agent "{agent_b_name}" — respond to: {user_message} with context: {context}
|
||||||
|
```
|
||||||
|
|
||||||
|
Executes concurrently (up to `max_threads`) and consolidates results.
|
||||||
|
|
||||||
|
## Presentation: Always Show Pass 1
|
||||||
|
|
||||||
|
**CRITICAL:** Pass 1 agent responses MUST be presented to the user in full BEFORE any cross-talk. Cross-talk is supplementary — it adds reactions to the thread, it does NOT replace initial responses. The user must see:
|
||||||
|
|
||||||
|
1. Each agent's independent initial take (Pass 1)
|
||||||
|
2. Then any cross-talk reactions (Pass 2) as follow-up remarks
|
||||||
|
|
||||||
|
Never skip, summarize, or fold Pass 1 into the cross-talk output.
|
||||||
|
|
||||||
|
## Cross-Talk Pass
|
||||||
|
|
||||||
|
Cross-talk is an **additional pass** — spawn sequentially with previous agents' responses as context:
|
||||||
|
|
||||||
|
```
|
||||||
|
Spawn agent "{agent_b_name}" with this additional context:
|
||||||
|
Other agents said this round:
|
||||||
|
{agent_a_response}
|
||||||
|
|
||||||
|
React briefly — agree, challenge, or build on one specific point. 2-3 sentences max.
|
||||||
|
```
|
||||||
|
|
||||||
|
The cross-talk responses are appended AFTER the Pass 1 responses when presenting to the user.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- `agents.max_threads` default 6 — more than enough for 2-3 party agents
|
||||||
|
- `agents.max_depth` of 1 — party agents cannot delegate further (not needed)
|
||||||
|
- Approval requests from sub-agents surface to user — `sandbox_mode = "read-only"` minimizes interruptions
|
||||||
|
- Token cost proportional to agents spawned
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
Generated `.codex/agents/*.toml` files persist between sessions for reuse. During exit (Stage 3), note this briefly if files were created. Do NOT delete automatically.
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
# Platform Adapter: Gemini CLI
|
||||||
|
|
||||||
|
## Hard Constraint: One Agent = One Sub-Agent
|
||||||
|
|
||||||
|
**NEVER create a single "generalist" or "roundtable" sub-agent that role-plays multiple agents.** This is the #1 failure mode on Gemini CLI. Each BMAD agent MUST be invoked as its own separate `@agent_name` call. The entire value of party mode is that each agent is a genuinely independent process with its own thinking.
|
||||||
|
|
||||||
|
**Wrong (DO NOT DO THIS):**
|
||||||
|
```
|
||||||
|
@generalist "Act as Winston, Maya, and Rex and have a roundtable discussion about..."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Right:**
|
||||||
|
```
|
||||||
|
@winston "Discussion context: ... User's message: ..."
|
||||||
|
@maya "Discussion context: ... User's message: ..."
|
||||||
|
@rex "Discussion context: ... User's message: ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
If you find yourself creating a single sub-agent to simulate the roundtable, STOP — you are breaking party mode's core promise.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
| Feature | Support |
|
||||||
|
|---|---|
|
||||||
|
| Parallel sub-agents | No — sequential execution only |
|
||||||
|
| Nested sub-agents | No |
|
||||||
|
| Inline prompt injection | No — agents must be pre-defined as `.gemini/agents/*.md` |
|
||||||
|
| Pre-defined agent files | **Required** — one file per BMAD agent |
|
||||||
|
| Model selection | Via `model` field in agent definition |
|
||||||
|
| Tool access in sub-agents | Explicit whitelist via `tools` field |
|
||||||
|
|
||||||
|
## Setup: Pre-Defined Agent Files (Required)
|
||||||
|
|
||||||
|
Gemini CLI **requires** agent definitions as individual markdown files before invocation. During Stage 1, generate **one file per BMAD agent** — NOT a single combined file:
|
||||||
|
|
||||||
|
**Skip if files already exist and manifest hasn't changed** (same agent count and names).
|
||||||
|
|
||||||
|
**Template — `.gemini/agents/{agent-name}.md`:**
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: {name}
|
||||||
|
description: "{displayName} - {title}. {role}"
|
||||||
|
kind: local
|
||||||
|
tools:
|
||||||
|
- read_file
|
||||||
|
- grep_search
|
||||||
|
model: gemini-2.5-pro
|
||||||
|
temperature: 0.7
|
||||||
|
max_turns: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
You are {displayName} ({title}), a BMAD agent in a collaborative roundtable discussion.
|
||||||
|
|
||||||
|
## Your Personality
|
||||||
|
- **Icon:** {icon}
|
||||||
|
- **Role:** {role}
|
||||||
|
- **Identity:** {identity}
|
||||||
|
- **Communication Style:** {communicationStyle}
|
||||||
|
- **Principles:** {principles}
|
||||||
|
|
||||||
|
## Standing Instructions
|
||||||
|
- Respond as {displayName}. Your genuine expert perspective — not a safe, hedged AI answer.
|
||||||
|
- Start every response with: {icon} **{displayName}**:
|
||||||
|
- Match your documented communication style exactly.
|
||||||
|
- Scale response length to the substance of your point.
|
||||||
|
- If you disagree with another agent, say so directly.
|
||||||
|
- If you have nothing substantial to add, say so in one sentence.
|
||||||
|
- Respond in {communication_language}.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configuration notes:**
|
||||||
|
- `kind: local` — runs on the local machine
|
||||||
|
- `tools` — Minimal read-only tools. Omit entirely to inherit all parent tools (less secure).
|
||||||
|
- `temperature: 0.7` — Slightly elevated for personality variation
|
||||||
|
- `max_turns: 5` — Safe ceiling; party agents complete in 1-2 turns
|
||||||
|
|
||||||
|
**Verification after setup:** Confirm that `.gemini/agents/` contains one `.md` file per BMAD agent (not a single combined file). The file count should match the agent count from the manifest.
|
||||||
|
|
||||||
|
## How to Spawn a Party Mode Agent
|
||||||
|
|
||||||
|
Each agent is invoked **individually** with `@{name}`:
|
||||||
|
|
||||||
|
```
|
||||||
|
@{name} Discussion context: {conversation_context_summary}
|
||||||
|
|
||||||
|
Depth: {brief | standard | deep}
|
||||||
|
User's message: {user_message}
|
||||||
|
|
||||||
|
Respond in character as {displayName}.
|
||||||
|
```
|
||||||
|
|
||||||
|
**For a 2-agent round, this means TWO separate `@` invocations:**
|
||||||
|
```
|
||||||
|
@winston Discussion context: The team is debating API architecture...
|
||||||
|
Depth: standard
|
||||||
|
User's message: What do you think about microservices vs monolith?
|
||||||
|
Respond in character as Winston.
|
||||||
|
|
||||||
|
@maya Discussion context: The team is debating API architecture. Winston suggested...
|
||||||
|
Depth: standard
|
||||||
|
User's message: What do you think about microservices vs monolith?
|
||||||
|
Respond in character as Maya.
|
||||||
|
```
|
||||||
|
|
||||||
|
**For a 3-agent round, THREE separate `@` invocations.** Never combine agents.
|
||||||
|
|
||||||
|
## Sequential Execution Strategy
|
||||||
|
|
||||||
|
Gemini CLI executes sub-agents **sequentially**. Turn this into an advantage:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. @primary_agent {prompt} → collect response, present to user
|
||||||
|
2. @secondary_agent {prompt + primary's response} → collect response, present to user
|
||||||
|
3. (Optional) @tertiary_agent {prompt + both responses} → collect response, present to user
|
||||||
|
4. Show [E] exit option
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this works:** Each subsequent agent naturally sees prior responses — **free cross-talk**. No separate cross-talk pass needed. The sequential format creates a natural conversation flow where each agent builds on or reacts to what came before.
|
||||||
|
|
||||||
|
**Present each response as it arrives** so the user isn't waiting on a blank screen.
|
||||||
|
|
||||||
|
**Latency management:**
|
||||||
|
- Default to 2 agents per round to keep wait times reasonable
|
||||||
|
- Use 3 agents only for genuinely complex/cross-cutting topics
|
||||||
|
- For "brief" depth rounds, consider 1 agent only
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **One `@agent_name` call = one agent.** Never bundle multiple agents into one call.
|
||||||
|
- Sub-agents **cannot call other sub-agents** — no nested delegation
|
||||||
|
- `max_turns` caps internal steps — set low for simple responses
|
||||||
|
- Agent definition files **must exist before invocation**
|
||||||
|
- `.gemini/agents/` directory must exist — create if needed
|
||||||
|
- Gemini CLI sub-agents feature is **experimental** — behavior may evolve
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
Generated `.gemini/agents/*.md` files persist between sessions. Note briefly during exit if files were created. Do NOT delete automatically.
|
||||||
|
|
||||||
|
## Fallback
|
||||||
|
|
||||||
|
If file creation fails or `@agent_name` invocation is unavailable, fall back to **single-LLM role-play**: the orchestrator generates responses in character within its own context. **This must be announced to the user** — e.g., "Sub-agent spawning isn't available, so I'll role-play the agents directly. Responses won't have independent thinking."
|
||||||
|
|
||||||
|
Single-LLM role-play is always the LAST resort, never the first approach. Always attempt individual `@agent_name` invocations first.
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
# Universal Agent Prompt Template
|
||||||
|
|
||||||
|
This template is assembled by the orchestrator for each sub-agent spawn. The platform adapter determines HOW the prompt is delivered; this defines WHAT it contains.
|
||||||
|
|
||||||
|
## Template
|
||||||
|
|
||||||
|
```
|
||||||
|
You are {displayName} ({title}), a BMAD agent in a collaborative roundtable discussion.
|
||||||
|
|
||||||
|
## Your Personality
|
||||||
|
- **Icon:** {icon}
|
||||||
|
- **Role:** {role}
|
||||||
|
- **Identity:** {identity}
|
||||||
|
- **Communication Style:** {communicationStyle}
|
||||||
|
- **Principles:** {principles}
|
||||||
|
|
||||||
|
## Discussion Context
|
||||||
|
{conversation_context_summary}
|
||||||
|
|
||||||
|
## Other Agents' Responses This Round
|
||||||
|
{pass_1_responses_if_cross_talk_pass — empty on first pass}
|
||||||
|
|
||||||
|
## User's Message
|
||||||
|
{user_message}
|
||||||
|
|
||||||
|
## Response Calibration
|
||||||
|
Depth: {brief | standard | deep}
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
Respond as {displayName}. Your genuine expert perspective — not a safe, hedged AI answer.
|
||||||
|
- Start with: {icon} **{displayName}**:
|
||||||
|
- Match your documented communication style exactly.
|
||||||
|
- Scale response length to match the Depth signal above and the substance of your point.
|
||||||
|
- If you disagree with another agent, say so directly — don't soften with "great point, but..."
|
||||||
|
- If you have nothing substantial to add beyond what's been said, say so in one sentence rather than restating others' points.
|
||||||
|
- If you want to ask the USER a direct question, make it the last thing you say.
|
||||||
|
- Respond in {communication_language}.
|
||||||
|
- Do NOT use any tools. Only provide your response as text output.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Farewell Variant
|
||||||
|
|
||||||
|
Used during Stage 3 (exit):
|
||||||
|
|
||||||
|
```
|
||||||
|
You are {displayName} ({title}). The party mode roundtable is ending.
|
||||||
|
|
||||||
|
## Your Personality
|
||||||
|
- **Icon:** {icon}
|
||||||
|
- **Role:** {role}
|
||||||
|
- **Identity:** {identity}
|
||||||
|
- **Communication Style:** {communicationStyle}
|
||||||
|
- **Principles:** {principles}
|
||||||
|
|
||||||
|
## Session Summary
|
||||||
|
{brief summary of key topics discussed and this agent's contributions}
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
Give a farewell in 1-2 sentences that references something specific from the discussion — a point you made, something another agent said, or a question the user raised.
|
||||||
|
Stay in character. Start with: {icon} **{displayName}**:
|
||||||
|
Respond in {communication_language}. Do NOT use any tools.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Assembly Notes
|
||||||
|
|
||||||
|
- **Conversation context** — Keep under 400 words. Each sub-agent gets a fresh context window; every token here is multiplied by agents spawned per round.
|
||||||
|
- **Depth signal** — Set by the orchestrator's round calibration. "brief" = 2-4 sentences, "standard" = a few paragraphs, "deep" = full analysis.
|
||||||
|
- **Pass 1 responses** — Empty for the initial parallel pass; populated only during cross-talk.
|
||||||
|
- **Personality fields** — From the merged profile built during initialization (manifest CSV + agent file data).
|
||||||
|
- **Anti-pattern guard** — The "nothing substantial to add" instruction prevents the common failure mode of agents restating each other to seem participatory. This is critical for round quality.
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
# Step 1: Agent Loading and Party Mode Initialization
|
|
||||||
|
|
||||||
## MANDATORY EXECUTION RULES (READ FIRST):
|
|
||||||
|
|
||||||
- ✅ YOU ARE A PARTY MODE FACILITATOR, not just a workflow executor
|
|
||||||
- 🎯 CREATE ENGAGING ATMOSPHERE for multi-agent collaboration
|
|
||||||
- 📋 LOAD COMPLETE AGENT ROSTER from manifest with merged personalities
|
|
||||||
- 🔍 PARSE AGENT DATA for conversation orchestration
|
|
||||||
- 💬 INTRODUCE DIVERSE AGENT SAMPLE to kick off discussion
|
|
||||||
- ✅ YOU MUST ALWAYS SPEAK OUTPUT In your Agent communication style with the config `{communication_language}`
|
|
||||||
|
|
||||||
## EXECUTION PROTOCOLS:
|
|
||||||
|
|
||||||
- 🎯 Show agent loading process before presenting party activation
|
|
||||||
- ⚠️ Present [C] continue option after agent roster is loaded
|
|
||||||
- 💾 ONLY save when user chooses C (Continue)
|
|
||||||
- 📖 Update frontmatter `stepsCompleted: [1]` before loading next step
|
|
||||||
- 🚫 FORBIDDEN to start conversation until C is selected
|
|
||||||
|
|
||||||
## CONTEXT BOUNDARIES:
|
|
||||||
|
|
||||||
- Agent manifest CSV is available at `{project-root}/_bmad/_config/agent-manifest.csv`
|
|
||||||
- User configuration from config.yaml is loaded and resolved
|
|
||||||
- Party mode is standalone interactive workflow
|
|
||||||
- All agent data is available for conversation orchestration
|
|
||||||
|
|
||||||
## YOUR TASK:
|
|
||||||
|
|
||||||
Load the complete agent roster from manifest and initialize party mode with engaging introduction.
|
|
||||||
|
|
||||||
## AGENT LOADING SEQUENCE:
|
|
||||||
|
|
||||||
### 1. Load Agent Manifest
|
|
||||||
|
|
||||||
Begin agent loading process:
|
|
||||||
|
|
||||||
"Now initializing **Party Mode** with our complete BMAD agent roster! Let me load up all our talented agents and get them ready for an amazing collaborative discussion.
|
|
||||||
|
|
||||||
**Agent Manifest Loading:**"
|
|
||||||
|
|
||||||
Load and parse the agent manifest CSV from `{project-root}/_bmad/_config/agent-manifest.csv`
|
|
||||||
|
|
||||||
### 2. Extract Agent Data
|
|
||||||
|
|
||||||
Parse CSV to extract complete agent information for each entry:
|
|
||||||
|
|
||||||
**Agent Data Points:**
|
|
||||||
|
|
||||||
- **name** (agent identifier for system calls)
|
|
||||||
- **displayName** (agent's persona name for conversations)
|
|
||||||
- **title** (formal position and role description)
|
|
||||||
- **icon** (visual identifier emoji)
|
|
||||||
- **role** (capabilities and expertise summary)
|
|
||||||
- **identity** (background and specialization details)
|
|
||||||
- **communicationStyle** (how they communicate and express themselves)
|
|
||||||
- **principles** (decision-making philosophy and values)
|
|
||||||
- **module** (source module organization)
|
|
||||||
- **path** (file location reference)
|
|
||||||
|
|
||||||
### 3. Build Agent Roster
|
|
||||||
|
|
||||||
Create complete agent roster with merged personalities:
|
|
||||||
|
|
||||||
**Roster Building Process:**
|
|
||||||
|
|
||||||
- Combine manifest data with agent file configurations
|
|
||||||
- Merge personality traits, capabilities, and communication styles
|
|
||||||
- Validate agent availability and configuration completeness
|
|
||||||
- Organize agents by expertise domains for intelligent selection
|
|
||||||
|
|
||||||
### 4. Party Mode Activation
|
|
||||||
|
|
||||||
Generate enthusiastic party mode introduction:
|
|
||||||
|
|
||||||
"🎉 PARTY MODE ACTIVATED! 🎉
|
|
||||||
|
|
||||||
Welcome {{user_name}}! I'm excited to facilitate an incredible multi-agent discussion with our complete BMAD team. All our specialized agents are online and ready to collaborate, bringing their unique expertise and perspectives to whatever you'd like to explore.
|
|
||||||
|
|
||||||
**Our Collaborating Agents Include:**
|
|
||||||
|
|
||||||
[Display 3-4 diverse agents to showcase variety]:
|
|
||||||
|
|
||||||
- [Icon Emoji] **[Agent Name]** ([Title]): [Brief role description]
|
|
||||||
- [Icon Emoji] **[Agent Name]** ([Title]): [Brief role description]
|
|
||||||
- [Icon Emoji] **[Agent Name]** ([Title]): [Brief role description]
|
|
||||||
|
|
||||||
**[Total Count] agents** are ready to contribute their expertise!
|
|
||||||
|
|
||||||
**What would you like to discuss with the team today?**"
|
|
||||||
|
|
||||||
### 5. Present Continue Option
|
|
||||||
|
|
||||||
After agent loading and introduction:
|
|
||||||
|
|
||||||
"**Agent roster loaded successfully!** All our BMAD experts are excited to collaborate with you.
|
|
||||||
|
|
||||||
**Ready to start the discussion?**
|
|
||||||
[C] Continue - Begin multi-agent conversation
|
|
||||||
|
|
||||||
### 6. Handle Continue Selection
|
|
||||||
|
|
||||||
#### If 'C' (Continue):
|
|
||||||
|
|
||||||
- Update frontmatter: `stepsCompleted: [1]`
|
|
||||||
- Set `agents_loaded: true` and `party_active: true`
|
|
||||||
- Load: `./step-02-discussion-orchestration.md`
|
|
||||||
|
|
||||||
## SUCCESS METRICS:
|
|
||||||
|
|
||||||
✅ Agent manifest successfully loaded and parsed
|
|
||||||
✅ Complete agent roster built with merged personalities
|
|
||||||
✅ Engaging party mode introduction created
|
|
||||||
✅ Diverse agent sample showcased for user
|
|
||||||
✅ [C] continue option presented and handled correctly
|
|
||||||
✅ Frontmatter updated with agent loading status
|
|
||||||
✅ Proper routing to discussion orchestration step
|
|
||||||
|
|
||||||
## FAILURE MODES:
|
|
||||||
|
|
||||||
❌ Failed to load or parse agent manifest CSV
|
|
||||||
❌ Incomplete agent data extraction or roster building
|
|
||||||
❌ Generic or unengaging party mode introduction
|
|
||||||
❌ Not showcasing diverse agent capabilities
|
|
||||||
❌ Not presenting [C] continue option after loading
|
|
||||||
❌ Starting conversation without user selection
|
|
||||||
|
|
||||||
## AGENT LOADING PROTOCOLS:
|
|
||||||
|
|
||||||
- Validate CSV format and required columns
|
|
||||||
- Handle missing or incomplete agent entries gracefully
|
|
||||||
- Cross-reference manifest with actual agent files
|
|
||||||
- Prepare agent selection logic for intelligent conversation routing
|
|
||||||
|
|
||||||
## NEXT STEP:
|
|
||||||
|
|
||||||
After user selects 'C', load `./step-02-discussion-orchestration.md` to begin the interactive multi-agent conversation with intelligent agent selection and natural conversation flow.
|
|
||||||
|
|
||||||
Remember: Create an engaging, party-like atmosphere while maintaining professional expertise and intelligent conversation orchestration!
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Stage 1: Initialize Party Mode
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Detect platform, load agents, build spawn-ready profiles with expertise vectors, create platform-specific definitions if needed, and launch — all in a single turn.
|
||||||
|
|
||||||
|
## Sequence
|
||||||
|
|
||||||
|
### 1. Detect Platform
|
||||||
|
|
||||||
|
Determine which AI CLI platform you are running on:
|
||||||
|
|
||||||
|
- **Claude Code** → `Agent` tool is available in your tool list
|
||||||
|
- **Codex** → You are inside OpenAI Codex CLI, or `.codex/` directory exists at project root
|
||||||
|
- **Gemini CLI** → You are inside Gemini CLI, or `.gemini/` directory exists at project root
|
||||||
|
|
||||||
|
Load the corresponding adapter: `./adapters/{platform}.md`
|
||||||
|
|
||||||
|
Default to **Claude Code** if uncertain. If no sub-agent mechanism works at runtime, fall back to single-LLM role-play.
|
||||||
|
|
||||||
|
### 2. Load Agent Manifest
|
||||||
|
|
||||||
|
Read and parse `{project-root}/_bmad/_config/agent-manifest.csv`.
|
||||||
|
|
||||||
|
**If missing or empty:** Tell the user party mode requires installed BMAD agents with a configured manifest. Suggest they check their `_bmad/_config/` setup. End the workflow.
|
||||||
|
|
||||||
|
### 3. Build Personality Profiles
|
||||||
|
|
||||||
|
For each agent in the manifest:
|
||||||
|
|
||||||
|
1. **Merge data** — Combine CSV fields into a complete profile. If the agent's `path` points to a readable file, merge additional personality data from that file.
|
||||||
|
|
||||||
|
2. **Extract expertise vectors** — From `role`, `identity`, and any merged file data, identify 3-5 expertise keywords per agent (e.g., "architecture", "testing", "ux", "security", "devops"). Store these for fast scoring during orchestration.
|
||||||
|
|
||||||
|
3. **Structure as spawn-ready prompt** — Use `./references/agent-prompt-template.md`. Each profile must be rich enough that a sub-agent can convincingly embody the agent's voice without any other context.
|
||||||
|
|
||||||
|
4. **Validate profile completeness** — Each profile must have at minimum: `displayName`, `icon`, `role`, and `communicationStyle`. Flag incomplete profiles and note them but don't block on them.
|
||||||
|
|
||||||
|
5. **Group by expertise domain** — Organize agents into overlapping domain clusters for fast selection. An agent can belong to multiple domains.
|
||||||
|
|
||||||
|
### 4. Create Platform-Specific Agent Definitions (if required)
|
||||||
|
|
||||||
|
Check the loaded adapter for setup requirements:
|
||||||
|
|
||||||
|
- **Claude Code** — No pre-creation needed. Agents are spawned inline.
|
||||||
|
- **Codex** — Generate `.codex/agents/{name}.toml` per agent using the adapter's template. Create directory if needed. **Skip if files already exist and the manifest hasn't changed** (compare agent count and names as a quick fingerprint).
|
||||||
|
- **Gemini CLI** — Generate `.gemini/agents/{name}.md` per agent using the adapter's template. Create directory if needed. **Skip if files already exist and the manifest hasn't changed.**
|
||||||
|
|
||||||
|
If file creation fails, note it and proceed — fallback is single-LLM role-play.
|
||||||
|
|
||||||
|
### 5. Activate Party Mode
|
||||||
|
|
||||||
|
Introduce the session with energy and personality:
|
||||||
|
|
||||||
|
- Welcome the user by name (if configured)
|
||||||
|
- Show 3-4 diverse agents from the roster (across different expertise domains) with their icon, name, title, and a one-line personality flavor
|
||||||
|
- State total agent count
|
||||||
|
- Briefly explain: *each agent thinks independently as its own process — this is a genuine roundtable, not one AI playing pretend*
|
||||||
|
- Invite the user's first topic or question — they can address specific agents by name or throw a topic to the whole group
|
||||||
|
|
||||||
|
**Tone:** Enthusiastic but not overwrought. A team of experts ready to collaborate.
|
||||||
|
|
||||||
|
### 6. Initialize State Block
|
||||||
|
|
||||||
|
Create the first `[PARTY MODE STATE]` block:
|
||||||
|
|
||||||
|
```
|
||||||
|
[PARTY MODE STATE]
|
||||||
|
Platform: {platform} | Adapter: {adapter}
|
||||||
|
Agent count: {N}
|
||||||
|
Roster: [{icon} {displayName} ({title}) — expertise: {keywords}] × N
|
||||||
|
Domain clusters: {domain → agent names}
|
||||||
|
Round: 0
|
||||||
|
Momentum: starting
|
||||||
|
[/PARTY MODE STATE]
|
||||||
|
```
|
||||||
|
|
||||||
|
Include enough data to reconstruct spawn prompts post-compaction.
|
||||||
|
|
||||||
|
### 7. Transition
|
||||||
|
|
||||||
|
Immediately proceed to `./step-02-orchestrate.md`.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Error | Action |
|
||||||
|
|---|---|
|
||||||
|
| Missing CSV columns on an agent | Skip that agent, note briefly, continue |
|
||||||
|
| Fewer than 2 agents available | Warn that party mode works best with multiple agents, proceed |
|
||||||
|
| Agent file at `path` not readable | Use manifest data alone for that agent's profile |
|
||||||
|
| Platform agent file creation fails | Fall back to single-LLM role-play, inform user |
|
||||||
|
| Profile missing required fields | Exclude from roster, note which agent and what's missing |
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
# Step 2: Discussion Orchestration and Multi-Agent Conversation
|
|
||||||
|
|
||||||
## MANDATORY EXECUTION RULES (READ FIRST):
|
|
||||||
|
|
||||||
- ✅ YOU ARE A CONVERSATION ORCHESTRATOR, not just a response generator
|
|
||||||
- 🎯 SELECT RELEVANT AGENTS based on topic analysis and expertise matching
|
|
||||||
- 📋 MAINTAIN CHARACTER CONSISTENCY using merged agent personalities
|
|
||||||
- 🔍 ENABLE NATURAL CROSS-TALK between agents for dynamic conversation
|
|
||||||
- ✅ YOU MUST ALWAYS SPEAK OUTPUT In your Agent communication style with the config `{communication_language}`
|
|
||||||
|
|
||||||
## EXECUTION PROTOCOLS:
|
|
||||||
|
|
||||||
- 🎯 Analyze user input for intelligent agent selection before responding
|
|
||||||
- ⚠️ Present [E] exit option after each agent response round
|
|
||||||
- 💾 Continue conversation until user selects E (Exit)
|
|
||||||
- 📖 Maintain conversation state and context throughout session
|
|
||||||
- 🚫 FORBIDDEN to exit until E is selected or exit trigger detected
|
|
||||||
|
|
||||||
## CONTEXT BOUNDARIES:
|
|
||||||
|
|
||||||
- Complete agent roster with merged personalities is available
|
|
||||||
- User topic and conversation history guide agent selection
|
|
||||||
- Exit triggers: `*exit`, `goodbye`, `end party`, `quit`
|
|
||||||
|
|
||||||
## YOUR TASK:
|
|
||||||
|
|
||||||
Orchestrate dynamic multi-agent conversations with intelligent agent selection, natural cross-talk, and authentic character portrayal.
|
|
||||||
|
|
||||||
## DISCUSSION ORCHESTRATION SEQUENCE:
|
|
||||||
|
|
||||||
### 1. User Input Analysis
|
|
||||||
|
|
||||||
For each user message or topic:
|
|
||||||
|
|
||||||
**Input Analysis Process:**
|
|
||||||
"Analyzing your message for the perfect agent collaboration..."
|
|
||||||
|
|
||||||
**Analysis Criteria:**
|
|
||||||
|
|
||||||
- Domain expertise requirements (technical, business, creative, etc.)
|
|
||||||
- Complexity level and depth needed
|
|
||||||
- Conversation context and previous agent contributions
|
|
||||||
- User's specific agent mentions or requests
|
|
||||||
|
|
||||||
### 2. Intelligent Agent Selection
|
|
||||||
|
|
||||||
Select 2-3 most relevant agents based on analysis:
|
|
||||||
|
|
||||||
**Selection Logic:**
|
|
||||||
|
|
||||||
- **Primary Agent**: Best expertise match for core topic
|
|
||||||
- **Secondary Agent**: Complementary perspective or alternative approach
|
|
||||||
- **Tertiary Agent**: Cross-domain insight or devil's advocate (if beneficial)
|
|
||||||
|
|
||||||
**Priority Rules:**
|
|
||||||
|
|
||||||
- If user names specific agent → Prioritize that agent + 1-2 complementary agents
|
|
||||||
- Rotate agent participation over time to ensure inclusive discussion
|
|
||||||
- Balance expertise domains for comprehensive perspectives
|
|
||||||
|
|
||||||
### 3. In-Character Response Generation
|
|
||||||
|
|
||||||
Generate authentic responses for each selected agent:
|
|
||||||
|
|
||||||
**Character Consistency:**
|
|
||||||
|
|
||||||
- Apply agent's exact communication style from merged data
|
|
||||||
- Reflect their principles and values in reasoning
|
|
||||||
- Draw from their identity and role for authentic expertise
|
|
||||||
- Maintain their unique voice and personality traits
|
|
||||||
|
|
||||||
**Response Structure:**
|
|
||||||
[For each selected agent]:
|
|
||||||
|
|
||||||
"[Icon Emoji] **[Agent Name]**: [Authentic in-character response]
|
|
||||||
|
|
||||||
[Bash: .claude/hooks/bmad-speak.sh \"[Agent Name]\" \"[Their response]\"]"
|
|
||||||
|
|
||||||
### 4. Natural Cross-Talk Integration
|
|
||||||
|
|
||||||
Enable dynamic agent-to-agent interactions:
|
|
||||||
|
|
||||||
**Cross-Talk Patterns:**
|
|
||||||
|
|
||||||
- Agents can reference each other by name: "As [Another Agent] mentioned..."
|
|
||||||
- Building on previous points: "[Another Agent] makes a great point about..."
|
|
||||||
- Respectful disagreements: "I see it differently than [Another Agent]..."
|
|
||||||
- Follow-up questions between agents: "How would you handle [specific aspect]?"
|
|
||||||
|
|
||||||
**Conversation Flow:**
|
|
||||||
|
|
||||||
- Allow natural conversational progression
|
|
||||||
- Enable agents to ask each other questions
|
|
||||||
- Maintain professional yet engaging discourse
|
|
||||||
- Include personality-driven humor and quirks when appropriate
|
|
||||||
|
|
||||||
### 5. Question Handling Protocol
|
|
||||||
|
|
||||||
Manage different types of questions appropriately:
|
|
||||||
|
|
||||||
**Direct Questions to User:**
|
|
||||||
When an agent asks the user a specific question:
|
|
||||||
|
|
||||||
- End that response round immediately after the question
|
|
||||||
- Clearly highlight: **[Agent Name] asks: [Their question]**
|
|
||||||
- Display: _[Awaiting user response...]_
|
|
||||||
- WAIT for user input before continuing
|
|
||||||
|
|
||||||
**Rhetorical Questions:**
|
|
||||||
Agents can ask thinking-aloud questions without pausing conversation flow.
|
|
||||||
|
|
||||||
**Inter-Agent Questions:**
|
|
||||||
Allow natural back-and-forth within the same response round for dynamic interaction.
|
|
||||||
|
|
||||||
### 6. Response Round Completion
|
|
||||||
|
|
||||||
After generating all agent responses for the round, let the user know he can speak naturally with the agents, an then show this menu opion"
|
|
||||||
|
|
||||||
`[E] Exit Party Mode - End the collaborative session`
|
|
||||||
|
|
||||||
### 7. Exit Condition Checking
|
|
||||||
|
|
||||||
Check for exit conditions before continuing:
|
|
||||||
|
|
||||||
**Automatic Triggers:**
|
|
||||||
|
|
||||||
- User message contains: `*exit`, `goodbye`, `end party`, `quit`
|
|
||||||
- Immediate agent farewells and workflow termination
|
|
||||||
|
|
||||||
**Natural Conclusion:**
|
|
||||||
|
|
||||||
- Conversation seems naturally concluding
|
|
||||||
- Confirm if the user wants to exit party mode and go back to where they were or continue chatting. Do it in a conversational way with an agent in the party.
|
|
||||||
|
|
||||||
### 8. Handle Exit Selection
|
|
||||||
|
|
||||||
#### If 'E' (Exit Party Mode):
|
|
||||||
|
|
||||||
- Read fully and follow: `./step-03-graceful-exit.md`
|
|
||||||
|
|
||||||
## SUCCESS METRICS:
|
|
||||||
|
|
||||||
✅ Intelligent agent selection based on topic analysis
|
|
||||||
✅ Authentic in-character responses maintained consistently
|
|
||||||
✅ Natural cross-talk and agent interactions enabled
|
|
||||||
✅ Question handling protocol followed correctly
|
|
||||||
✅ [E] exit option presented after each response round
|
|
||||||
✅ Conversation context and state maintained throughout
|
|
||||||
✅ Graceful conversation flow without abrupt interruptions
|
|
||||||
|
|
||||||
## FAILURE MODES:
|
|
||||||
|
|
||||||
❌ Generic responses without character consistency
|
|
||||||
❌ Poor agent selection not matching topic expertise
|
|
||||||
❌ Ignoring user questions or exit triggers
|
|
||||||
❌ Not enabling natural agent cross-talk and interactions
|
|
||||||
❌ Continuing conversation without user input when questions asked
|
|
||||||
|
|
||||||
## CONVERSATION ORCHESTRATION PROTOCOLS:
|
|
||||||
|
|
||||||
- Maintain conversation memory and context across rounds
|
|
||||||
- Rotate agent participation for inclusive discussions
|
|
||||||
- Handle topic drift while maintaining productivity
|
|
||||||
- Balance fun and professional collaboration
|
|
||||||
- Enable learning and knowledge sharing between agents
|
|
||||||
|
|
||||||
## MODERATION GUIDELINES:
|
|
||||||
|
|
||||||
**Quality Control:**
|
|
||||||
|
|
||||||
- If discussion becomes circular, have bmad-master summarize and redirect
|
|
||||||
- Ensure all agents stay true to their merged personalities
|
|
||||||
- Handle disagreements constructively and professionally
|
|
||||||
- Maintain respectful and inclusive conversation environment
|
|
||||||
|
|
||||||
**Flow Management:**
|
|
||||||
|
|
||||||
- Guide conversation toward productive outcomes
|
|
||||||
- Encourage diverse perspectives and creative thinking
|
|
||||||
- Balance depth with breadth of discussion
|
|
||||||
- Adapt conversation pace to user engagement level
|
|
||||||
|
|
||||||
## NEXT STEP:
|
|
||||||
|
|
||||||
When user selects 'E' or exit conditions are met, load `./step-03-graceful-exit.md` to provide satisfying agent farewells and conclude the party mode session.
|
|
||||||
|
|
||||||
Remember: Orchestrate engaging, intelligent conversations while maintaining authentic agent personalities and natural interaction patterns!
|
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
# Stage 2: Conversation Orchestration
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Run the adaptive conversation loop: for each user message, score and select agents, calibrate the round, spawn sub-agents, assess quality, optionally cross-talk, present, and update state.
|
||||||
|
|
||||||
|
## Compaction Survival
|
||||||
|
|
||||||
|
Long sessions will hit context limits. Maintain a running state block — **replace** (never append) every 3 rounds or on significant topic shifts:
|
||||||
|
|
||||||
|
```
|
||||||
|
[PARTY MODE STATE]
|
||||||
|
Platform: {platform} | Adapter: {adapter}
|
||||||
|
Round: {N}
|
||||||
|
Momentum: {high | steady | low}
|
||||||
|
Roster: [{icon} {displayName} — expertise: {keywords}, last_round: {N}] × all agents
|
||||||
|
Active recent: [{agents from last 2 rounds}]
|
||||||
|
Topics covered: [{brief list with round numbers}]
|
||||||
|
Current thread: {what the conversation is about now}
|
||||||
|
Key positions: [{agent}: {stance}] — only for live disagreements
|
||||||
|
User signals: {favored agents, expressed interests, engagement level}
|
||||||
|
Rotation: [{agent}: {rounds participated}/{total rounds}] — flag if any > 60%
|
||||||
|
[/PARTY MODE STATE]
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep under 350 words. Must contain enough agent data to reconstruct spawn prompts post-compaction.
|
||||||
|
|
||||||
|
## Conversation Loop
|
||||||
|
|
||||||
|
### 1. Check Exit
|
||||||
|
|
||||||
|
If the user's message matches an exit trigger (`*exit`, `goodbye`, `end party`, `quit`, `[E]`), go to `./step-03-exit.md`. Don't spawn agents for an exit message.
|
||||||
|
|
||||||
|
### 2. Analyze Input
|
||||||
|
|
||||||
|
Assess in ~5 seconds of thought:
|
||||||
|
|
||||||
|
| Dimension | Question |
|
||||||
|
|---|---|
|
||||||
|
| **Domain** | What expertise area(s) does this touch? |
|
||||||
|
| **Complexity** | Quick take, standard discussion, or deep dive? |
|
||||||
|
| **Continuity** | Builds on current thread or new topic? |
|
||||||
|
| **Directed** | Did the user name a specific agent? |
|
||||||
|
| **Tension potential** | Will perspectives likely diverge? |
|
||||||
|
| **Momentum** | Is conversation energy rising, steady, or dropping? |
|
||||||
|
|
||||||
|
### 3. Score & Select Agents
|
||||||
|
|
||||||
|
Apply the scoring algorithm from SKILL.md:
|
||||||
|
|
||||||
|
1. For each agent, compute relevance score (0-10) using expertise match (×4), complementarity (×3), recency penalty (×2), and user affinity (×1)
|
||||||
|
2. Select primary (highest score)
|
||||||
|
3. Select secondary only if complementarity ≥ 2
|
||||||
|
4. Select tertiary only if genuinely cross-cutting AND complementarity ≥ 3
|
||||||
|
5. Simple questions → primary only
|
||||||
|
|
||||||
|
**Quick check:** If the same agent would be primary for the 4th consecutive round, cap their score at 5 and re-evaluate.
|
||||||
|
|
||||||
|
### 4. Calibrate Round
|
||||||
|
|
||||||
|
Based on input analysis, set round parameters:
|
||||||
|
|
||||||
|
- **Agent count:** 1-3 (from scoring)
|
||||||
|
- **Depth signal:** "brief" / "standard" / "deep" — inject into agent prompts
|
||||||
|
- **Cross-talk:** pre-decide yes/no/conditional (see scoring below)
|
||||||
|
- **Model hint:** "fast" for trivial questions, "default" otherwise — adapter decides if actionable
|
||||||
|
|
||||||
|
### 5. Build Conversation Context
|
||||||
|
|
||||||
|
Compose a concise context block for injection into agent prompts:
|
||||||
|
|
||||||
|
- Current discussion thread (2-3 sentences)
|
||||||
|
- Key positions taken by agents so far (if relevant)
|
||||||
|
- The user's current message (verbatim)
|
||||||
|
|
||||||
|
**Keep under 400 words.** Each sub-agent gets a fresh context window — every token here is multiplied by agents spawned.
|
||||||
|
|
||||||
|
### 6. Spawn Agents (Pass 1)
|
||||||
|
|
||||||
|
Use the platform adapter's spawning mechanism. **Each selected agent MUST be spawned as its own separate sub-agent invocation.** Never create a single sub-agent that role-plays multiple agents.
|
||||||
|
|
||||||
|
- **Claude Code:** Multiple `Agent` tool calls in a single response → parallel execution
|
||||||
|
- **Codex:** Request parallel agent spawning → parallel by default
|
||||||
|
- **Gemini CLI:** Sequential `@agent_name` invocations — one per agent, never combined. Present each response as it arrives.
|
||||||
|
|
||||||
|
For each agent, assemble the prompt from `./references/agent-prompt-template.md` with their personality profile, conversation context, depth signal, and the user's message.
|
||||||
|
|
||||||
|
**If a spawn fails:** Present the remaining agents' responses normally. Note the failure only if it affects the round's coherence. Don't retry — the round can succeed with fewer voices.
|
||||||
|
|
||||||
|
### 7. Cross-Talk Decision (Scored)
|
||||||
|
|
||||||
|
**Skip on Gemini CLI** — sequential execution provides cross-talk naturally.
|
||||||
|
|
||||||
|
**On Claude Code / Codex**, score cross-talk value:
|
||||||
|
|
||||||
|
| Signal | Points |
|
||||||
|
|---|---|
|
||||||
|
| Agents took opposing positions | +3 |
|
||||||
|
| One agent raised a point in another's domain | +2 |
|
||||||
|
| User explicitly asked for debate/discussion | +3 |
|
||||||
|
| Agents' responses are complementary (no tension) | -2 |
|
||||||
|
| Round already has 3 agents | -1 |
|
||||||
|
| Simple/factual question | -3 |
|
||||||
|
|
||||||
|
**Threshold:** Cross-talk if score ≥ 2.
|
||||||
|
|
||||||
|
**Cross-talk prompt addition:**
|
||||||
|
```
|
||||||
|
Other agents said this round:
|
||||||
|
{agent_responses_from_pass_1}
|
||||||
|
|
||||||
|
React briefly — agree, challenge, or build on one specific point. 2-3 sentences max. Don't repeat yourself.
|
||||||
|
```
|
||||||
|
|
||||||
|
Spawn 1-2 agents max for cross-talk. Prefer agents whose domains were referenced by others.
|
||||||
|
|
||||||
|
### 8. Assess Round Quality
|
||||||
|
|
||||||
|
Before presenting, quick sanity check:
|
||||||
|
|
||||||
|
- **Redundancy:** If 2+ agents said essentially the same thing, present the richer version and briefly summarize the agreement rather than showing redundant full responses
|
||||||
|
- **Length:** If a response is disproportionately long for the question's complexity, mentally note for next round's depth calibration
|
||||||
|
- **Direct questions:** Did any agent ask the user a question?
|
||||||
|
|
||||||
|
### 9. Present Round
|
||||||
|
|
||||||
|
**CRITICAL: Always show Pass 1 responses first.** Cross-talk is supplementary — it adds to the thread, never replaces it. The user must see the complete conversation: initial takes, then reactions.
|
||||||
|
|
||||||
|
**Presentation order:**
|
||||||
|
1. **Pass 1 responses** — primary → secondary → tertiary (each agent's initial, independent take)
|
||||||
|
2. **Cross-talk responses** (if any) — presented as natural follow-up reactions after a visual separator
|
||||||
|
|
||||||
|
**Formatting:**
|
||||||
|
- Each response prefixed with the agent's `{icon} **{displayName}**:`
|
||||||
|
- Clear visual separation between agents
|
||||||
|
- Between Pass 1 and cross-talk, use a brief separator (e.g., a horizontal rule or a line like "---") but do NOT label it "cross-talk" — just let the reactions flow naturally as follow-up remarks
|
||||||
|
|
||||||
|
**If any agent asked the user a direct question:** Present responses up to that point and **stop**. If multiple agents asked questions, consolidate into one clear prompt.
|
||||||
|
|
||||||
|
Otherwise, end with a light touch — don't always repeat the same boilerplate. Vary between:
|
||||||
|
- A brief thread-pulling question from the orchestrator
|
||||||
|
- Simply: `[E] Exit Party Mode`
|
||||||
|
- Nothing extra if the agents' responses naturally invite continuation
|
||||||
|
|
||||||
|
### 10. Update State
|
||||||
|
|
||||||
|
Refresh the `[PARTY MODE STATE]` block if:
|
||||||
|
- 3 rounds have passed since last update, OR
|
||||||
|
- Topic shifted significantly, OR
|
||||||
|
- Momentum changed
|
||||||
|
|
||||||
|
## Momentum Adaptation
|
||||||
|
|
||||||
|
| Momentum | Signals | Orchestrator Response |
|
||||||
|
|---|---|---|
|
||||||
|
| **High** | User asks follow-ups, multi-sentence input, names agents | Allow longer responses, encourage cross-talk, maintain current voices |
|
||||||
|
| **Steady** | Normal engagement, varied topics | Standard calibration |
|
||||||
|
| **Low** | Short replies ("ok", "sure"), long gaps, repetitive topics | Rotate voices, shorten rounds, introduce contrarian angle, or ask user directly |
|
||||||
|
| **Declining** | Was high, now dropping | Acknowledge the shift — new topic? deeper on something? wrap up? |
|
||||||
|
|
||||||
|
Adapt silently. Never announce "momentum is low" — just adjust behavior.
|
||||||
|
|
||||||
|
## Error Recovery
|
||||||
|
|
||||||
|
| Failure | Response |
|
||||||
|
|---|---|
|
||||||
|
| Sub-agent spawn fails | Present remaining agents, note briefly if coherence affected |
|
||||||
|
| All spawns fail | Fall back to single-LLM role-play for this round, note the degradation |
|
||||||
|
| Agent returns empty/garbage | Skip that response, proceed with others |
|
||||||
|
| Platform adapter unavailable | Switch to single-LLM role-play, inform user once |
|
||||||
|
| Context approaching limits | Force a state block update, trim conversation context aggressively |
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Stage 3: Graceful Exit
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Conclude the session with authentic agent farewells, a brief session summary with highlights, and a clean handoff.
|
||||||
|
|
||||||
|
## Sequence
|
||||||
|
|
||||||
|
### 1. Compile Session Highlights
|
||||||
|
|
||||||
|
Before farewells, identify:
|
||||||
|
- **Key insight** — The single most valuable takeaway from the discussion
|
||||||
|
- **Best exchange** — The most productive agent interaction (disagreement resolved, idea built upon, etc.)
|
||||||
|
- **Top contributors** — 2-3 agents who drove the most value (not just spoke the most)
|
||||||
|
|
||||||
|
### 2. Agent Farewells
|
||||||
|
|
||||||
|
Select the 2-3 top contributors. Spawn each as a sub-agent using the platform adapter with a farewell prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
You are {displayName} ({title}). The party mode roundtable is ending.
|
||||||
|
|
||||||
|
{personality_profile}
|
||||||
|
|
||||||
|
Session summary: {brief summary of key topics and your contributions}
|
||||||
|
|
||||||
|
Give a farewell in 1-2 sentences that references something specific from the discussion — a point you made, something another agent said, or a question the user raised.
|
||||||
|
Stay in character. Start with: {icon} **{displayName}**:
|
||||||
|
Respond in {communication_language}. Do NOT use any tools.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Platform behavior:**
|
||||||
|
- **Claude Code / Codex** — Spawn farewell agents in parallel
|
||||||
|
- **Gemini CLI** — Spawn sequentially; 2 agents max to keep exit fast
|
||||||
|
|
||||||
|
### 3. Session Wrap-Up
|
||||||
|
|
||||||
|
As the orchestrator, present a compact summary:
|
||||||
|
|
||||||
|
```
|
||||||
|
**Session Highlights**
|
||||||
|
- {key insight from the discussion}
|
||||||
|
- {notable exchange or decision point}
|
||||||
|
- Rounds: {N} | Agents heard: {list of unique agents who participated}
|
||||||
|
```
|
||||||
|
|
||||||
|
Close with one short sentence — thank the user naturally and note agents are available anytime.
|
||||||
|
|
||||||
|
End with: **Party Mode Complete.**
|
||||||
|
|
||||||
|
### 4. Cleanup Notes
|
||||||
|
|
||||||
|
Platform-specific agent definition files (`.codex/agents/*.toml`, `.gemini/agents/*.md`) are **not deleted** — they persist for future sessions. Note this briefly if files were created during this session.
|
||||||
|
|
||||||
|
### 5. Return Protocol
|
||||||
|
|
||||||
|
If party mode was invoked from a parent workflow:
|
||||||
|
1. Identify the parent workflow step that triggered this sub-workflow
|
||||||
|
2. Re-read that file to restore context
|
||||||
|
3. Resume the parent workflow from where it left off
|
||||||
|
4. Present any menus or options the parent workflow expects
|
||||||
|
|
||||||
|
If standalone: end cleanly. Do not continue unless the user initiates.
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
# Step 3: Graceful Exit and Party Mode Conclusion
|
|
||||||
|
|
||||||
## MANDATORY EXECUTION RULES (READ FIRST):
|
|
||||||
|
|
||||||
- ✅ YOU ARE A PARTY MODE COORDINATOR concluding an engaging session
|
|
||||||
- 🎯 PROVIDE SATISFYING AGENT FAREWELLS in authentic character voices
|
|
||||||
- 📋 EXPRESS GRATITUDE to user for collaborative participation
|
|
||||||
- 🔍 ACKNOWLEDGE SESSION HIGHLIGHTS and key insights gained
|
|
||||||
- 💬 MAINTAIN POSITIVE ATMOSPHERE until the very end
|
|
||||||
- ✅ YOU MUST ALWAYS SPEAK OUTPUT In your Agent communication style with the config `{communication_language}`
|
|
||||||
|
|
||||||
## EXECUTION PROTOCOLS:
|
|
||||||
|
|
||||||
- 🎯 Generate characteristic agent goodbyes that reflect their personalities
|
|
||||||
- ⚠️ Complete workflow exit after farewell sequence
|
|
||||||
- 💾 Update frontmatter with final workflow completion
|
|
||||||
- 📖 Clean up any active party mode state or temporary data
|
|
||||||
- 🚫 FORBIDDEN abrupt exits without proper agent farewells
|
|
||||||
|
|
||||||
## CONTEXT BOUNDARIES:
|
|
||||||
|
|
||||||
- Party mode session is concluding naturally or via user request
|
|
||||||
- Complete agent roster and conversation history are available
|
|
||||||
- User has participated in collaborative multi-agent discussion
|
|
||||||
- Final workflow completion and state cleanup required
|
|
||||||
|
|
||||||
## YOUR TASK:
|
|
||||||
|
|
||||||
Provide satisfying agent farewells and conclude the party mode session with gratitude and positive closure.
|
|
||||||
|
|
||||||
## GRACEFUL EXIT SEQUENCE:
|
|
||||||
|
|
||||||
### 1. Acknowledge Session Conclusion
|
|
||||||
|
|
||||||
Begin exit process with warm acknowledgment:
|
|
||||||
|
|
||||||
"What an incredible collaborative session! Thank you {{user_name}} for engaging with our BMAD agent team in this dynamic discussion. Your questions and insights brought out the best in our agents and led to some truly valuable perspectives.
|
|
||||||
|
|
||||||
**Before we wrap up, let a few of our agents say goodbye...**"
|
|
||||||
|
|
||||||
### 2. Generate Agent Farewells
|
|
||||||
|
|
||||||
Select 2-3 agents who were most engaged or representative of the discussion:
|
|
||||||
|
|
||||||
**Farewell Selection Criteria:**
|
|
||||||
|
|
||||||
- Agents who made significant contributions to the discussion
|
|
||||||
- Agents with distinct personalities that provide memorable goodbyes
|
|
||||||
- Mix of expertise domains to showcase collaborative diversity
|
|
||||||
- Agents who can reference session highlights meaningfully
|
|
||||||
|
|
||||||
**Agent Farewell Format:**
|
|
||||||
|
|
||||||
For each selected agent:
|
|
||||||
|
|
||||||
"[Icon Emoji] **[Agent Name]**: [Characteristic farewell reflecting their personality, communication style, and role. May reference session highlights, express gratitude, or offer final insights related to their expertise domain.]
|
|
||||||
|
|
||||||
[Bash: .claude/hooks/bmad-speak.sh \"[Agent Name]\" \"[Their farewell message]\"]"
|
|
||||||
|
|
||||||
**Example Farewells:**
|
|
||||||
|
|
||||||
- **Architect/Winston**: "It's been a pleasure architecting solutions with you today! Remember to build on solid foundations and always consider scalability. Until next time! 🏗️"
|
|
||||||
- **Innovator/Creative Agent**: "What an inspiring creative journey! Don't let those innovative ideas fade - nurture them and watch them grow. Keep thinking outside the box! 🎨"
|
|
||||||
- **Strategist/Business Agent**: "Excellent strategic collaboration today! The insights we've developed will serve you well. Keep analyzing, keep optimizing, and keep winning! 📈"
|
|
||||||
|
|
||||||
### 3. Session Highlight Summary
|
|
||||||
|
|
||||||
Briefly acknowledge key discussion outcomes:
|
|
||||||
|
|
||||||
**Session Recognition:**
|
|
||||||
"**Session Highlights:** Today we explored [main topic] through [number] different perspectives, generating valuable insights on [key outcomes]. The collaboration between our [relevant expertise domains] agents created a comprehensive understanding that wouldn't have been possible with any single viewpoint."
|
|
||||||
|
|
||||||
### 4. Final Party Mode Conclusion
|
|
||||||
|
|
||||||
End with enthusiastic and appreciative closure:
|
|
||||||
|
|
||||||
"🎊 **Party Mode Session Complete!** 🎊
|
|
||||||
|
|
||||||
Thank you for bringing our BMAD agents together in this unique collaborative experience. The diverse perspectives, expert insights, and dynamic interactions we've shared demonstrate the power of multi-agent thinking.
|
|
||||||
|
|
||||||
**Our agents learned from each other and from you** - that's what makes these collaborative sessions so valuable!
|
|
||||||
|
|
||||||
**Ready for your next challenge**? Whether you need more focused discussions with specific agents or want to bring the whole team together again, we're always here to help you tackle complex problems through collaborative intelligence.
|
|
||||||
|
|
||||||
**Until next time - keep collaborating, keep innovating, and keep enjoying the power of multi-agent teamwork!** 🚀"
|
|
||||||
|
|
||||||
### 5. Complete Workflow Exit
|
|
||||||
|
|
||||||
Final workflow completion steps:
|
|
||||||
|
|
||||||
**Frontmatter Update:**
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
stepsCompleted: [1, 2, 3]
|
|
||||||
user_name: '{{user_name}}'
|
|
||||||
date: '{{date}}'
|
|
||||||
agents_loaded: true
|
|
||||||
party_active: false
|
|
||||||
workflow_completed: true
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
**State Cleanup:**
|
|
||||||
|
|
||||||
- Clear any active conversation state
|
|
||||||
- Reset agent selection cache
|
|
||||||
- Mark party mode workflow as completed
|
|
||||||
|
|
||||||
### 6. Exit Workflow
|
|
||||||
|
|
||||||
Execute final workflow termination:
|
|
||||||
|
|
||||||
"[PARTY MODE WORKFLOW COMPLETE]
|
|
||||||
|
|
||||||
Thank you for using BMAD Party Mode for collaborative multi-agent discussions!"
|
|
||||||
|
|
||||||
## SUCCESS METRICS:
|
|
||||||
|
|
||||||
✅ Satisfying agent farewells generated in authentic character voices
|
|
||||||
✅ Session highlights and contributions acknowledged meaningfully
|
|
||||||
✅ Positive and appreciative closure atmosphere maintained
|
|
||||||
✅ Frontmatter properly updated with workflow completion
|
|
||||||
✅ All workflow state cleaned up appropriately
|
|
||||||
✅ User left with positive impression of collaborative experience
|
|
||||||
|
|
||||||
## FAILURE MODES:
|
|
||||||
|
|
||||||
❌ Generic or impersonal agent farewells without character consistency
|
|
||||||
❌ Missing acknowledgment of session contributions or insights
|
|
||||||
❌ Abrupt exit without proper closure or appreciation
|
|
||||||
❌ Not updating workflow completion status in frontmatter
|
|
||||||
❌ Leaving party mode state active after conclusion
|
|
||||||
❌ Negative or dismissive tone during exit process
|
|
||||||
|
|
||||||
## EXIT PROTOCOLS:
|
|
||||||
|
|
||||||
- Ensure all agents have opportunity to say goodbye appropriately
|
|
||||||
- Maintain the positive, collaborative atmosphere established during session
|
|
||||||
- Reference specific discussion highlights when possible for personalization
|
|
||||||
- Express genuine appreciation for user's participation and engagement
|
|
||||||
- Leave user with encouragement for future collaborative sessions
|
|
||||||
|
|
||||||
## RETURN PROTOCOL:
|
|
||||||
|
|
||||||
If this workflow was invoked from within a parent workflow:
|
|
||||||
|
|
||||||
1. Identify the parent workflow step or instructions file that invoked you
|
|
||||||
2. Re-read that file now to restore context
|
|
||||||
3. Resume from where the parent workflow directed you to invoke this sub-workflow
|
|
||||||
4. Present any menus or options the parent workflow requires after sub-workflow completion
|
|
||||||
|
|
||||||
Do not continue conversationally - explicitly return to parent workflow control flow.
|
|
||||||
|
|
||||||
## WORKFLOW COMPLETION:
|
|
||||||
|
|
||||||
After farewell sequence and final closure:
|
|
||||||
|
|
||||||
- All party mode workflow steps completed successfully
|
|
||||||
- Agent roster and conversation state properly finalized
|
|
||||||
- User expressed gratitude and positive session conclusion
|
|
||||||
- Multi-agent collaboration demonstrated value and effectiveness
|
|
||||||
- Workflow ready for next party mode session activation
|
|
||||||
|
|
||||||
Congratulations on facilitating a successful multi-agent collaborative discussion through BMAD Party Mode! 🎉
|
|
||||||
|
|
||||||
The user has experienced the power of bringing diverse expert perspectives together to tackle complex topics through intelligent conversation orchestration and authentic agent interactions.
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
---
|
|
||||||
---
|
|
||||||
|
|
||||||
# Party Mode Workflow
|
|
||||||
|
|
||||||
**Goal:** Orchestrates group discussions between all installed BMAD agents, enabling natural multi-agent conversations
|
|
||||||
|
|
||||||
**Your Role:** You are a party mode facilitator and multi-agent conversation orchestrator. You bring together diverse BMAD agents for collaborative discussions, managing the flow of conversation while maintaining each agent's unique personality and expertise - while still utilizing the configured {communication_language}.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## WORKFLOW ARCHITECTURE
|
|
||||||
|
|
||||||
This uses **micro-file architecture** with **sequential conversation orchestration**:
|
|
||||||
|
|
||||||
- Step 01 loads agent manifest and initializes party mode
|
|
||||||
- Step 02 orchestrates the ongoing multi-agent discussion
|
|
||||||
- Step 03 handles graceful party mode exit
|
|
||||||
- Conversation state tracked in frontmatter
|
|
||||||
- Agent personalities maintained through merged manifest data
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## INITIALIZATION
|
|
||||||
|
|
||||||
### Configuration Loading
|
|
||||||
|
|
||||||
Load config from `{project-root}/_bmad/core/config.yaml` and resolve:
|
|
||||||
|
|
||||||
- `project_name`, `output_folder`, `user_name`
|
|
||||||
- `communication_language`, `document_output_language`, `user_skill_level`
|
|
||||||
- `date` as a system-generated value
|
|
||||||
- Agent manifest path: `{project-root}/_bmad/_config/agent-manifest.csv`
|
|
||||||
|
|
||||||
### Paths
|
|
||||||
|
|
||||||
- `agent_manifest_path` = `{project-root}/_bmad/_config/agent-manifest.csv`
|
|
||||||
- `standalone_mode` = `true` (party mode is an interactive workflow)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## AGENT MANIFEST PROCESSING
|
|
||||||
|
|
||||||
### Agent Data Extraction
|
|
||||||
|
|
||||||
Parse CSV manifest to extract agent entries with complete information:
|
|
||||||
|
|
||||||
- **name** (agent identifier)
|
|
||||||
- **displayName** (agent's persona name)
|
|
||||||
- **title** (formal position)
|
|
||||||
- **icon** (visual identifier emoji)
|
|
||||||
- **role** (capabilities summary)
|
|
||||||
- **identity** (background/expertise)
|
|
||||||
- **communicationStyle** (how they communicate)
|
|
||||||
- **principles** (decision-making philosophy)
|
|
||||||
- **module** (source module)
|
|
||||||
- **path** (file location)
|
|
||||||
|
|
||||||
### Agent Roster Building
|
|
||||||
|
|
||||||
Build complete agent roster with merged personalities for conversation orchestration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## EXECUTION
|
|
||||||
|
|
||||||
Execute party mode activation and conversation orchestration:
|
|
||||||
|
|
||||||
### Party Mode Activation
|
|
||||||
|
|
||||||
**Your Role:** You are a party mode facilitator creating an engaging multi-agent conversation environment.
|
|
||||||
|
|
||||||
**Welcome Activation:**
|
|
||||||
|
|
||||||
"🎉 PARTY MODE ACTIVATED! 🎉
|
|
||||||
|
|
||||||
Welcome {{user_name}}! All BMAD agents are here and ready for a dynamic group discussion. I've brought together our complete team of experts, each bringing their unique perspectives and capabilities.
|
|
||||||
|
|
||||||
**Let me introduce our collaborating agents:**
|
|
||||||
|
|
||||||
[Load agent roster and display 2-3 most diverse agents as examples]
|
|
||||||
|
|
||||||
**What would you like to discuss with the team today?**"
|
|
||||||
|
|
||||||
### Agent Selection Intelligence
|
|
||||||
|
|
||||||
For each user message or topic:
|
|
||||||
|
|
||||||
**Relevance Analysis:**
|
|
||||||
|
|
||||||
- Analyze the user's message/question for domain and expertise requirements
|
|
||||||
- Identify which agents would naturally contribute based on their role, capabilities, and principles
|
|
||||||
- Consider conversation context and previous agent contributions
|
|
||||||
- Select 2-3 most relevant agents for balanced perspective
|
|
||||||
|
|
||||||
**Priority Handling:**
|
|
||||||
|
|
||||||
- If user addresses specific agent by name, prioritize that agent + 1-2 complementary agents
|
|
||||||
- Rotate agent selection to ensure diverse participation over time
|
|
||||||
- Enable natural cross-talk and agent-to-agent interactions
|
|
||||||
|
|
||||||
### Conversation Orchestration
|
|
||||||
|
|
||||||
Load step: `./steps/step-02-discussion-orchestration.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## WORKFLOW STATES
|
|
||||||
|
|
||||||
### Frontmatter Tracking
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
stepsCompleted: [1]
|
|
||||||
user_name: '{{user_name}}'
|
|
||||||
date: '{{date}}'
|
|
||||||
agents_loaded: true
|
|
||||||
party_active: true
|
|
||||||
exit_triggers: ['*exit', 'goodbye', 'end party', 'quit']
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ROLE-PLAYING GUIDELINES
|
|
||||||
|
|
||||||
### Character Consistency
|
|
||||||
|
|
||||||
- Maintain strict in-character responses based on merged personality data
|
|
||||||
- Use each agent's documented communication style consistently
|
|
||||||
- Reference agent memories and context when relevant
|
|
||||||
- Allow natural disagreements and different perspectives
|
|
||||||
- Include personality-driven quirks and occasional humor
|
|
||||||
|
|
||||||
### Conversation Flow
|
|
||||||
|
|
||||||
- Enable agents to reference each other naturally by name or role
|
|
||||||
- Maintain professional discourse while being engaging
|
|
||||||
- Respect each agent's expertise boundaries
|
|
||||||
- Allow cross-talk and building on previous points
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## QUESTION HANDLING PROTOCOL
|
|
||||||
|
|
||||||
### Direct Questions to User
|
|
||||||
|
|
||||||
When an agent asks the user a specific question:
|
|
||||||
|
|
||||||
- End that response round immediately after the question
|
|
||||||
- Clearly highlight the questioning agent and their question
|
|
||||||
- Wait for user response before any agent continues
|
|
||||||
|
|
||||||
### Inter-Agent Questions
|
|
||||||
|
|
||||||
Agents can question each other and respond naturally within the same round for dynamic conversation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## EXIT CONDITIONS
|
|
||||||
|
|
||||||
### Automatic Triggers
|
|
||||||
|
|
||||||
Exit party mode when user message contains any exit triggers:
|
|
||||||
|
|
||||||
- `*exit`, `goodbye`, `end party`, `quit`
|
|
||||||
|
|
||||||
### Graceful Conclusion
|
|
||||||
|
|
||||||
If conversation naturally concludes:
|
|
||||||
|
|
||||||
- Ask user if they'd like to continue or end party mode
|
|
||||||
- Exit gracefully when user indicates completion
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MODERATION NOTES
|
|
||||||
|
|
||||||
**Quality Control:**
|
|
||||||
|
|
||||||
- If discussion becomes circular, have bmad-master summarize and redirect
|
|
||||||
- Balance fun and productivity based on conversation tone
|
|
||||||
- Ensure all agents stay true to their merged personalities
|
|
||||||
- Exit gracefully when user indicates completion
|
|
||||||
|
|
||||||
**Conversation Management:**
|
|
||||||
|
|
||||||
- Rotate agent participation to ensure inclusive discussion
|
|
||||||
- Handle topic drift while maintaining productive conversation
|
|
||||||
- Facilitate cross-agent collaboration and knowledge sharing
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const { loadSkillManifest, getInstallToBmad } = require('../tools/cli/installers/lib/ide/shared/skill-manifest');
|
const { loadSkillManifest, getInstallToBmad } = require('../tools/installer/ide/shared/skill-manifest');
|
||||||
|
|
||||||
// ANSI colors
|
// ANSI colors
|
||||||
const colors = {
|
const colors = {
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,9 @@
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const { ConfigCollector } = require('../tools/cli/installers/lib/core/config-collector');
|
const { ManifestGenerator } = require('../tools/installer/core/manifest-generator');
|
||||||
const { ManifestGenerator } = require('../tools/cli/installers/lib/core/manifest-generator');
|
const { IdeManager } = require('../tools/installer/ide/manager');
|
||||||
const { IdeManager } = require('../tools/cli/installers/lib/ide/manager');
|
const { clearCache, loadPlatformCodes } = require('../tools/installer/ide/platform-codes');
|
||||||
const { clearCache, loadPlatformCodes } = require('../tools/cli/installers/lib/ide/platform-codes');
|
|
||||||
|
|
||||||
// ANSI colors
|
// ANSI colors
|
||||||
const colors = {
|
const colors = {
|
||||||
|
|
@ -149,8 +148,6 @@ async function runTests() {
|
||||||
|
|
||||||
assert(windsurfInstaller?.target_dir === '.windsurf/skills', 'Windsurf target_dir uses native skills path');
|
assert(windsurfInstaller?.target_dir === '.windsurf/skills', 'Windsurf target_dir uses native skills path');
|
||||||
|
|
||||||
assert(windsurfInstaller?.skill_format === true, 'Windsurf installer enables native skill output');
|
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(windsurfInstaller?.legacy_targets) && windsurfInstaller.legacy_targets.includes('.windsurf/workflows'),
|
Array.isArray(windsurfInstaller?.legacy_targets) && windsurfInstaller.legacy_targets.includes('.windsurf/workflows'),
|
||||||
'Windsurf installer cleans legacy workflow output',
|
'Windsurf installer cleans legacy workflow output',
|
||||||
|
|
@ -197,8 +194,6 @@ async function runTests() {
|
||||||
|
|
||||||
assert(kiroInstaller?.target_dir === '.kiro/skills', 'Kiro target_dir uses native skills path');
|
assert(kiroInstaller?.target_dir === '.kiro/skills', 'Kiro target_dir uses native skills path');
|
||||||
|
|
||||||
assert(kiroInstaller?.skill_format === true, 'Kiro installer enables native skill output');
|
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(kiroInstaller?.legacy_targets) && kiroInstaller.legacy_targets.includes('.kiro/steering'),
|
Array.isArray(kiroInstaller?.legacy_targets) && kiroInstaller.legacy_targets.includes('.kiro/steering'),
|
||||||
'Kiro installer cleans legacy steering output',
|
'Kiro installer cleans legacy steering output',
|
||||||
|
|
@ -245,8 +240,6 @@ async function runTests() {
|
||||||
|
|
||||||
assert(antigravityInstaller?.target_dir === '.agent/skills', 'Antigravity target_dir uses native skills path');
|
assert(antigravityInstaller?.target_dir === '.agent/skills', 'Antigravity target_dir uses native skills path');
|
||||||
|
|
||||||
assert(antigravityInstaller?.skill_format === true, 'Antigravity installer enables native skill output');
|
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(antigravityInstaller?.legacy_targets) && antigravityInstaller.legacy_targets.includes('.agent/workflows'),
|
Array.isArray(antigravityInstaller?.legacy_targets) && antigravityInstaller.legacy_targets.includes('.agent/workflows'),
|
||||||
'Antigravity installer cleans legacy workflow output',
|
'Antigravity installer cleans legacy workflow output',
|
||||||
|
|
@ -293,8 +286,6 @@ async function runTests() {
|
||||||
|
|
||||||
assert(auggieInstaller?.target_dir === '.augment/skills', 'Auggie target_dir uses native skills path');
|
assert(auggieInstaller?.target_dir === '.augment/skills', 'Auggie target_dir uses native skills path');
|
||||||
|
|
||||||
assert(auggieInstaller?.skill_format === true, 'Auggie installer enables native skill output');
|
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(auggieInstaller?.legacy_targets) && auggieInstaller.legacy_targets.includes('.augment/commands'),
|
Array.isArray(auggieInstaller?.legacy_targets) && auggieInstaller.legacy_targets.includes('.augment/commands'),
|
||||||
'Auggie installer cleans legacy command output',
|
'Auggie installer cleans legacy command output',
|
||||||
|
|
@ -346,8 +337,6 @@ async function runTests() {
|
||||||
|
|
||||||
assert(opencodeInstaller?.target_dir === '.opencode/skills', 'OpenCode target_dir uses native skills path');
|
assert(opencodeInstaller?.target_dir === '.opencode/skills', 'OpenCode target_dir uses native skills path');
|
||||||
|
|
||||||
assert(opencodeInstaller?.skill_format === true, 'OpenCode installer enables native skill output');
|
|
||||||
|
|
||||||
assert(opencodeInstaller?.ancestor_conflict_check === true, 'OpenCode installer enables ancestor conflict checks');
|
assert(opencodeInstaller?.ancestor_conflict_check === true, 'OpenCode installer enables ancestor conflict checks');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
|
|
@ -412,8 +401,6 @@ async function runTests() {
|
||||||
|
|
||||||
assert(claudeInstaller?.target_dir === '.claude/skills', 'Claude Code target_dir uses native skills path');
|
assert(claudeInstaller?.target_dir === '.claude/skills', 'Claude Code target_dir uses native skills path');
|
||||||
|
|
||||||
assert(claudeInstaller?.skill_format === true, 'Claude Code installer enables native skill output');
|
|
||||||
|
|
||||||
assert(claudeInstaller?.ancestor_conflict_check === true, 'Claude Code installer enables ancestor conflict checks');
|
assert(claudeInstaller?.ancestor_conflict_check === true, 'Claude Code installer enables ancestor conflict checks');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
|
|
@ -505,8 +492,6 @@ async function runTests() {
|
||||||
|
|
||||||
assert(codexInstaller?.target_dir === '.agents/skills', 'Codex target_dir uses native skills path');
|
assert(codexInstaller?.target_dir === '.agents/skills', 'Codex target_dir uses native skills path');
|
||||||
|
|
||||||
assert(codexInstaller?.skill_format === true, 'Codex installer enables native skill output');
|
|
||||||
|
|
||||||
assert(codexInstaller?.ancestor_conflict_check === true, 'Codex installer enables ancestor conflict checks');
|
assert(codexInstaller?.ancestor_conflict_check === true, 'Codex installer enables ancestor conflict checks');
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
|
|
@ -595,8 +580,6 @@ async function runTests() {
|
||||||
|
|
||||||
assert(cursorInstaller?.target_dir === '.cursor/skills', 'Cursor target_dir uses native skills path');
|
assert(cursorInstaller?.target_dir === '.cursor/skills', 'Cursor target_dir uses native skills path');
|
||||||
|
|
||||||
assert(cursorInstaller?.skill_format === true, 'Cursor installer enables native skill output');
|
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(cursorInstaller?.legacy_targets) && cursorInstaller.legacy_targets.includes('.cursor/commands'),
|
Array.isArray(cursorInstaller?.legacy_targets) && cursorInstaller.legacy_targets.includes('.cursor/commands'),
|
||||||
'Cursor installer cleans legacy command output',
|
'Cursor installer cleans legacy command output',
|
||||||
|
|
@ -649,8 +632,6 @@ async function runTests() {
|
||||||
|
|
||||||
assert(rooInstaller?.target_dir === '.roo/skills', 'Roo target_dir uses native skills path');
|
assert(rooInstaller?.target_dir === '.roo/skills', 'Roo target_dir uses native skills path');
|
||||||
|
|
||||||
assert(rooInstaller?.skill_format === true, 'Roo installer enables native skill output');
|
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(rooInstaller?.legacy_targets) && rooInstaller.legacy_targets.includes('.roo/commands'),
|
Array.isArray(rooInstaller?.legacy_targets) && rooInstaller.legacy_targets.includes('.roo/commands'),
|
||||||
'Roo installer cleans legacy command output',
|
'Roo installer cleans legacy command output',
|
||||||
|
|
@ -757,8 +738,6 @@ async function runTests() {
|
||||||
|
|
||||||
assert(copilotInstaller?.target_dir === '.github/skills', 'GitHub Copilot target_dir uses native skills path');
|
assert(copilotInstaller?.target_dir === '.github/skills', 'GitHub Copilot target_dir uses native skills path');
|
||||||
|
|
||||||
assert(copilotInstaller?.skill_format === true, 'GitHub Copilot installer enables native skill output');
|
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(copilotInstaller?.legacy_targets) && copilotInstaller.legacy_targets.includes('.github/agents'),
|
Array.isArray(copilotInstaller?.legacy_targets) && copilotInstaller.legacy_targets.includes('.github/agents'),
|
||||||
'GitHub Copilot installer cleans legacy agents output',
|
'GitHub Copilot installer cleans legacy agents output',
|
||||||
|
|
@ -839,8 +818,6 @@ async function runTests() {
|
||||||
|
|
||||||
assert(clineInstaller?.target_dir === '.cline/skills', 'Cline target_dir uses native skills path');
|
assert(clineInstaller?.target_dir === '.cline/skills', 'Cline target_dir uses native skills path');
|
||||||
|
|
||||||
assert(clineInstaller?.skill_format === true, 'Cline installer enables native skill output');
|
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(clineInstaller?.legacy_targets) && clineInstaller.legacy_targets.includes('.clinerules/workflows'),
|
Array.isArray(clineInstaller?.legacy_targets) && clineInstaller.legacy_targets.includes('.clinerules/workflows'),
|
||||||
'Cline installer cleans legacy workflow output',
|
'Cline installer cleans legacy workflow output',
|
||||||
|
|
@ -901,8 +878,6 @@ async function runTests() {
|
||||||
|
|
||||||
assert(codebuddyInstaller?.target_dir === '.codebuddy/skills', 'CodeBuddy target_dir uses native skills path');
|
assert(codebuddyInstaller?.target_dir === '.codebuddy/skills', 'CodeBuddy target_dir uses native skills path');
|
||||||
|
|
||||||
assert(codebuddyInstaller?.skill_format === true, 'CodeBuddy installer enables native skill output');
|
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(codebuddyInstaller?.legacy_targets) && codebuddyInstaller.legacy_targets.includes('.codebuddy/commands'),
|
Array.isArray(codebuddyInstaller?.legacy_targets) && codebuddyInstaller.legacy_targets.includes('.codebuddy/commands'),
|
||||||
'CodeBuddy installer cleans legacy command output',
|
'CodeBuddy installer cleans legacy command output',
|
||||||
|
|
@ -961,8 +936,6 @@ async function runTests() {
|
||||||
|
|
||||||
assert(crushInstaller?.target_dir === '.crush/skills', 'Crush target_dir uses native skills path');
|
assert(crushInstaller?.target_dir === '.crush/skills', 'Crush target_dir uses native skills path');
|
||||||
|
|
||||||
assert(crushInstaller?.skill_format === true, 'Crush installer enables native skill output');
|
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(crushInstaller?.legacy_targets) && crushInstaller.legacy_targets.includes('.crush/commands'),
|
Array.isArray(crushInstaller?.legacy_targets) && crushInstaller.legacy_targets.includes('.crush/commands'),
|
||||||
'Crush installer cleans legacy command output',
|
'Crush installer cleans legacy command output',
|
||||||
|
|
@ -1021,8 +994,6 @@ async function runTests() {
|
||||||
|
|
||||||
assert(traeInstaller?.target_dir === '.trae/skills', 'Trae target_dir uses native skills path');
|
assert(traeInstaller?.target_dir === '.trae/skills', 'Trae target_dir uses native skills path');
|
||||||
|
|
||||||
assert(traeInstaller?.skill_format === true, 'Trae installer enables native skill output');
|
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(traeInstaller?.legacy_targets) && traeInstaller.legacy_targets.includes('.trae/rules'),
|
Array.isArray(traeInstaller?.legacy_targets) && traeInstaller.legacy_targets.includes('.trae/rules'),
|
||||||
'Trae installer cleans legacy rules output',
|
'Trae installer cleans legacy rules output',
|
||||||
|
|
@ -1138,8 +1109,6 @@ async function runTests() {
|
||||||
|
|
||||||
assert(geminiInstaller?.target_dir === '.gemini/skills', 'Gemini target_dir uses native skills path');
|
assert(geminiInstaller?.target_dir === '.gemini/skills', 'Gemini target_dir uses native skills path');
|
||||||
|
|
||||||
assert(geminiInstaller?.skill_format === true, 'Gemini installer enables native skill output');
|
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(geminiInstaller?.legacy_targets) && geminiInstaller.legacy_targets.includes('.gemini/commands'),
|
Array.isArray(geminiInstaller?.legacy_targets) && geminiInstaller.legacy_targets.includes('.gemini/commands'),
|
||||||
'Gemini installer cleans legacy commands output',
|
'Gemini installer cleans legacy commands output',
|
||||||
|
|
@ -1196,7 +1165,6 @@ async function runTests() {
|
||||||
const iflowInstaller = platformCodes24.platforms.iflow?.installer;
|
const iflowInstaller = platformCodes24.platforms.iflow?.installer;
|
||||||
|
|
||||||
assert(iflowInstaller?.target_dir === '.iflow/skills', 'iFlow target_dir uses native skills path');
|
assert(iflowInstaller?.target_dir === '.iflow/skills', 'iFlow target_dir uses native skills path');
|
||||||
assert(iflowInstaller?.skill_format === true, 'iFlow installer enables native skill output');
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(iflowInstaller?.legacy_targets) && iflowInstaller.legacy_targets.includes('.iflow/commands'),
|
Array.isArray(iflowInstaller?.legacy_targets) && iflowInstaller.legacy_targets.includes('.iflow/commands'),
|
||||||
'iFlow installer cleans legacy commands output',
|
'iFlow installer cleans legacy commands output',
|
||||||
|
|
@ -1246,7 +1214,6 @@ async function runTests() {
|
||||||
const qwenInstaller = platformCodes25.platforms.qwen?.installer;
|
const qwenInstaller = platformCodes25.platforms.qwen?.installer;
|
||||||
|
|
||||||
assert(qwenInstaller?.target_dir === '.qwen/skills', 'QwenCoder target_dir uses native skills path');
|
assert(qwenInstaller?.target_dir === '.qwen/skills', 'QwenCoder target_dir uses native skills path');
|
||||||
assert(qwenInstaller?.skill_format === true, 'QwenCoder installer enables native skill output');
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(qwenInstaller?.legacy_targets) && qwenInstaller.legacy_targets.includes('.qwen/commands'),
|
Array.isArray(qwenInstaller?.legacy_targets) && qwenInstaller.legacy_targets.includes('.qwen/commands'),
|
||||||
'QwenCoder installer cleans legacy commands output',
|
'QwenCoder installer cleans legacy commands output',
|
||||||
|
|
@ -1296,7 +1263,6 @@ async function runTests() {
|
||||||
const rovoInstaller = platformCodes26.platforms['rovo-dev']?.installer;
|
const rovoInstaller = platformCodes26.platforms['rovo-dev']?.installer;
|
||||||
|
|
||||||
assert(rovoInstaller?.target_dir === '.rovodev/skills', 'Rovo Dev target_dir uses native skills path');
|
assert(rovoInstaller?.target_dir === '.rovodev/skills', 'Rovo Dev target_dir uses native skills path');
|
||||||
assert(rovoInstaller?.skill_format === true, 'Rovo Dev installer enables native skill output');
|
|
||||||
assert(
|
assert(
|
||||||
Array.isArray(rovoInstaller?.legacy_targets) && rovoInstaller.legacy_targets.includes('.rovodev/workflows'),
|
Array.isArray(rovoInstaller?.legacy_targets) && rovoInstaller.legacy_targets.includes('.rovodev/workflows'),
|
||||||
'Rovo Dev installer cleans legacy workflows output',
|
'Rovo Dev installer cleans legacy workflows output',
|
||||||
|
|
@ -1432,8 +1398,6 @@ async function runTests() {
|
||||||
const piInstaller = platformCodes28.platforms.pi?.installer;
|
const piInstaller = platformCodes28.platforms.pi?.installer;
|
||||||
|
|
||||||
assert(piInstaller?.target_dir === '.pi/skills', 'Pi target_dir uses native skills path');
|
assert(piInstaller?.target_dir === '.pi/skills', 'Pi target_dir uses native skills path');
|
||||||
assert(piInstaller?.skill_format === true, 'Pi installer enables native skill output');
|
|
||||||
assert(piInstaller?.template_type === 'default', 'Pi installer uses default skill template');
|
|
||||||
|
|
||||||
tempProjectDir28 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-pi-test-'));
|
tempProjectDir28 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-pi-test-'));
|
||||||
installedBmadDir28 = await createTestBmadFixture();
|
installedBmadDir28 = await createTestBmadFixture();
|
||||||
|
|
@ -1648,93 +1612,6 @@ async function runTests() {
|
||||||
// skill-manifest.csv should include the native agent entrypoint
|
// skill-manifest.csv should include the native agent entrypoint
|
||||||
const skillManifestCsv29 = await fs.readFile(path.join(tempFixture29, '_config', 'skill-manifest.csv'), 'utf8');
|
const skillManifestCsv29 = await fs.readFile(path.join(tempFixture29, '_config', 'skill-manifest.csv'), 'utf8');
|
||||||
assert(skillManifestCsv29.includes('bmad-tea'), 'skill-manifest.csv includes native type:agent SKILL.md entrypoint');
|
assert(skillManifestCsv29.includes('bmad-tea'), 'skill-manifest.csv includes native type:agent SKILL.md entrypoint');
|
||||||
|
|
||||||
// --- Agents at non-agents/ paths (regression test for BMM/CIS layouts) ---
|
|
||||||
// Create a second fixture with agents at paths like bmm/1-analysis/bmad-agent-analyst/
|
|
||||||
const tempFixture29b = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-agent-paths-'));
|
|
||||||
await fs.ensureDir(path.join(tempFixture29b, '_config'));
|
|
||||||
|
|
||||||
// Agent at bmm-style path: bmm/1-analysis/bmad-agent-analyst/
|
|
||||||
const bmmAgentDir = path.join(tempFixture29b, 'bmm', '1-analysis', 'bmad-agent-analyst');
|
|
||||||
await fs.ensureDir(bmmAgentDir);
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(bmmAgentDir, 'bmad-skill-manifest.yaml'),
|
|
||||||
[
|
|
||||||
'type: agent',
|
|
||||||
'name: bmad-agent-analyst',
|
|
||||||
'displayName: Mary',
|
|
||||||
'title: Business Analyst',
|
|
||||||
'role: Strategic Business Analyst',
|
|
||||||
'module: bmm',
|
|
||||||
].join('\n') + '\n',
|
|
||||||
);
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(bmmAgentDir, 'SKILL.md'),
|
|
||||||
'---\nname: bmad-agent-analyst\ndescription: Business Analyst agent\n---\n\nAnalyst agent.\n',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Agent at cis-style path: cis/skills/bmad-cis-agent-brainstorming-coach/
|
|
||||||
const cisAgentDir = path.join(tempFixture29b, 'cis', 'skills', 'bmad-cis-agent-brainstorming-coach');
|
|
||||||
await fs.ensureDir(cisAgentDir);
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(cisAgentDir, 'bmad-skill-manifest.yaml'),
|
|
||||||
[
|
|
||||||
'type: agent',
|
|
||||||
'name: bmad-cis-agent-brainstorming-coach',
|
|
||||||
'displayName: Carson',
|
|
||||||
'title: Brainstorming Specialist',
|
|
||||||
'role: Master Facilitator',
|
|
||||||
'module: cis',
|
|
||||||
].join('\n') + '\n',
|
|
||||||
);
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(cisAgentDir, 'SKILL.md'),
|
|
||||||
'---\nname: bmad-cis-agent-brainstorming-coach\ndescription: Brainstorming coach\n---\n\nCoach.\n',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Agent at standard agents/ path (GDS-style): gds/agents/gds-agent-game-dev/
|
|
||||||
const gdsAgentDir = path.join(tempFixture29b, 'gds', 'agents', 'gds-agent-game-dev');
|
|
||||||
await fs.ensureDir(gdsAgentDir);
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(gdsAgentDir, 'bmad-skill-manifest.yaml'),
|
|
||||||
[
|
|
||||||
'type: agent',
|
|
||||||
'name: gds-agent-game-dev',
|
|
||||||
'displayName: Link',
|
|
||||||
'title: Game Developer',
|
|
||||||
'role: Senior Game Dev',
|
|
||||||
'module: gds',
|
|
||||||
].join('\n') + '\n',
|
|
||||||
);
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(gdsAgentDir, 'SKILL.md'),
|
|
||||||
'---\nname: gds-agent-game-dev\ndescription: Game developer agent\n---\n\nGame dev.\n',
|
|
||||||
);
|
|
||||||
|
|
||||||
const generator29b = new ManifestGenerator();
|
|
||||||
await generator29b.generateManifests(tempFixture29b, ['bmm', 'cis', 'gds'], [], { ides: [] });
|
|
||||||
|
|
||||||
// All three agents should appear in agents[] regardless of directory layout
|
|
||||||
const bmmAgent = generator29b.agents.find((a) => a.name === 'bmad-agent-analyst');
|
|
||||||
assert(bmmAgent !== undefined, 'Agent at bmm/1-analysis/ path appears in agents[]');
|
|
||||||
assert(bmmAgent && bmmAgent.module === 'bmm', 'BMM agent module field comes from manifest file');
|
|
||||||
assert(bmmAgent && bmmAgent.path.includes('bmm/1-analysis/bmad-agent-analyst'), 'BMM agent path reflects actual directory layout');
|
|
||||||
|
|
||||||
const cisAgent = generator29b.agents.find((a) => a.name === 'bmad-cis-agent-brainstorming-coach');
|
|
||||||
assert(cisAgent !== undefined, 'Agent at cis/skills/ path appears in agents[]');
|
|
||||||
assert(cisAgent && cisAgent.module === 'cis', 'CIS agent module field comes from manifest file');
|
|
||||||
|
|
||||||
const gdsAgent = generator29b.agents.find((a) => a.name === 'gds-agent-game-dev');
|
|
||||||
assert(gdsAgent !== undefined, 'Agent at gds/agents/ path appears in agents[]');
|
|
||||||
assert(gdsAgent && gdsAgent.module === 'gds', 'GDS agent module field comes from manifest file');
|
|
||||||
|
|
||||||
// agent-manifest.csv should contain all three
|
|
||||||
const agentCsv29b = await fs.readFile(path.join(tempFixture29b, '_config', 'agent-manifest.csv'), 'utf8');
|
|
||||||
assert(agentCsv29b.includes('bmad-agent-analyst'), 'agent-manifest.csv includes BMM-layout agent');
|
|
||||||
assert(agentCsv29b.includes('bmad-cis-agent-brainstorming-coach'), 'agent-manifest.csv includes CIS-layout agent');
|
|
||||||
assert(agentCsv29b.includes('gds-agent-game-dev'), 'agent-manifest.csv includes GDS-layout agent');
|
|
||||||
|
|
||||||
await fs.remove(tempFixture29b).catch(() => {});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
assert(false, 'Unified skill scanner test succeeds', error.message);
|
assert(false, 'Unified skill scanner test succeeds', error.message);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1861,8 +1738,6 @@ async function runTests() {
|
||||||
const onaInstaller = platformCodes32.platforms.ona?.installer;
|
const onaInstaller = platformCodes32.platforms.ona?.installer;
|
||||||
|
|
||||||
assert(onaInstaller?.target_dir === '.ona/skills', 'Ona target_dir uses native skills path');
|
assert(onaInstaller?.target_dir === '.ona/skills', 'Ona target_dir uses native skills path');
|
||||||
assert(onaInstaller?.skill_format === true, 'Ona installer enables native skill output');
|
|
||||||
assert(onaInstaller?.template_type === 'default', 'Ona installer uses default skill template');
|
|
||||||
|
|
||||||
tempProjectDir32 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-ona-test-'));
|
tempProjectDir32 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-ona-test-'));
|
||||||
installedBmadDir32 = await createTestBmadFixture();
|
installedBmadDir32 = await createTestBmadFixture();
|
||||||
|
|
@ -1941,93 +1816,6 @@ async function runTests() {
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Test Suite 33: ConfigCollector Prompt Normalization
|
|
||||||
// ============================================================
|
|
||||||
console.log(`${colors.yellow}Test Suite 33: ConfigCollector Prompt Normalization${colors.reset}\n`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const teaModuleConfig33 = {
|
|
||||||
test_artifacts: {
|
|
||||||
default: '_bmad-output/test-artifacts',
|
|
||||||
},
|
|
||||||
test_design_output: {
|
|
||||||
prompt: 'Where should test design documents be stored?',
|
|
||||||
default: 'test-design',
|
|
||||||
result: '{test_artifacts}/{value}',
|
|
||||||
},
|
|
||||||
test_review_output: {
|
|
||||||
prompt: 'Where should test review reports be stored?',
|
|
||||||
default: 'test-reviews',
|
|
||||||
result: '{test_artifacts}/{value}',
|
|
||||||
},
|
|
||||||
trace_output: {
|
|
||||||
prompt: 'Where should traceability reports be stored?',
|
|
||||||
default: 'traceability',
|
|
||||||
result: '{test_artifacts}/{value}',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const collector33 = new ConfigCollector();
|
|
||||||
collector33.currentProjectDir = path.join(os.tmpdir(), 'bmad-config-normalization');
|
|
||||||
collector33.allAnswers = {};
|
|
||||||
collector33.collectedConfig = {
|
|
||||||
tea: {
|
|
||||||
test_artifacts: '_bmad-output/test-artifacts',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
collector33.existingConfig = {
|
|
||||||
tea: {
|
|
||||||
test_artifacts: '_bmad-output/test-artifacts',
|
|
||||||
test_design_output: '_bmad-output/test-artifacts/test-design',
|
|
||||||
test_review_output: '_bmad-output/test-artifacts/test-reviews',
|
|
||||||
trace_output: '_bmad-output/test-artifacts/traceability',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const testDesignQuestion33 = await collector33.buildQuestion(
|
|
||||||
'tea',
|
|
||||||
'test_design_output',
|
|
||||||
teaModuleConfig33.test_design_output,
|
|
||||||
teaModuleConfig33,
|
|
||||||
);
|
|
||||||
const testReviewQuestion33 = await collector33.buildQuestion(
|
|
||||||
'tea',
|
|
||||||
'test_review_output',
|
|
||||||
teaModuleConfig33.test_review_output,
|
|
||||||
teaModuleConfig33,
|
|
||||||
);
|
|
||||||
const traceQuestion33 = await collector33.buildQuestion('tea', 'trace_output', teaModuleConfig33.trace_output, teaModuleConfig33);
|
|
||||||
|
|
||||||
assert(testDesignQuestion33.default === 'test-design', 'ConfigCollector normalizes existing test_design_output prompt default');
|
|
||||||
assert(testReviewQuestion33.default === 'test-reviews', 'ConfigCollector normalizes existing test_review_output prompt default');
|
|
||||||
assert(traceQuestion33.default === 'traceability', 'ConfigCollector normalizes existing trace_output prompt default');
|
|
||||||
|
|
||||||
collector33.allAnswers = {
|
|
||||||
tea_test_artifacts: '_bmad-output/test-artifacts',
|
|
||||||
};
|
|
||||||
|
|
||||||
assert(
|
|
||||||
collector33.processResultTemplate(teaModuleConfig33.test_design_output.result, testDesignQuestion33.default) ===
|
|
||||||
'_bmad-output/test-artifacts/test-design',
|
|
||||||
'ConfigCollector re-applies test_design_output template without duplicating prefix',
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
collector33.processResultTemplate(teaModuleConfig33.test_review_output.result, testReviewQuestion33.default) ===
|
|
||||||
'_bmad-output/test-artifacts/test-reviews',
|
|
||||||
'ConfigCollector re-applies test_review_output template without duplicating prefix',
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
collector33.processResultTemplate(teaModuleConfig33.trace_output.result, traceQuestion33.default) ===
|
|
||||||
'_bmad-output/test-artifacts/traceability',
|
|
||||||
'ConfigCollector re-applies trace_output template without duplicating prefix',
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
assert(false, 'ConfigCollector prompt normalization test succeeds', error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Summary
|
// Summary
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ function assert(condition, testName, errorMessage = '') {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// These regexes are extracted from ModuleManager.vendorWorkflowDependencies()
|
// These regexes are extracted from ModuleManager.vendorWorkflowDependencies()
|
||||||
// in tools/cli/installers/lib/modules/manager.js
|
// in tools/installer/modules/manager.js
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Source regex (line ~1081) — uses non-capturing group for _bmad
|
// Source regex (line ~1081) — uses non-capturing group for _bmad
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BMad Method CLI - Direct execution wrapper for npx
|
|
||||||
* This file ensures proper execution when run via npx from GitHub or npm registry
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { execFileSync } = require('node:child_process');
|
|
||||||
const path = require('node:path');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
|
|
||||||
// Check if we're running in an npx temporary directory
|
|
||||||
const isNpxExecution = __dirname.includes('_npx') || __dirname.includes('.npm');
|
|
||||||
|
|
||||||
if (isNpxExecution) {
|
|
||||||
// Running via npx - spawn child process to preserve user's working directory
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const bmadCliPath = path.join(__dirname, 'cli', 'bmad-cli.js');
|
|
||||||
|
|
||||||
if (!fs.existsSync(bmadCliPath)) {
|
|
||||||
console.error('Error: Could not find bmad-cli.js at', bmadCliPath);
|
|
||||||
console.error('Current directory:', __dirname);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Execute CLI from user's working directory (process.cwd()), not npm cache
|
|
||||||
execFileSync('node', [bmadCliPath, ...args], {
|
|
||||||
stdio: 'inherit',
|
|
||||||
cwd: process.cwd(), // This preserves the user's working directory
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
process.exit(error.status || 1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Local execution - use require
|
|
||||||
require('./cli/bmad-cli.js');
|
|
||||||
}
|
|
||||||
|
|
@ -1,743 +0,0 @@
|
||||||
const fs = require('fs-extra');
|
|
||||||
const path = require('node:path');
|
|
||||||
const glob = require('glob');
|
|
||||||
const yaml = require('yaml');
|
|
||||||
const prompts = require('../../../lib/prompts');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dependency Resolver for BMAD modules
|
|
||||||
* Handles cross-module dependencies and ensures all required files are included
|
|
||||||
*/
|
|
||||||
class DependencyResolver {
|
|
||||||
constructor() {
|
|
||||||
this.dependencies = new Map();
|
|
||||||
this.resolvedFiles = new Set();
|
|
||||||
this.missingDependencies = new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve all dependencies for selected modules
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @param {Array} selectedModules - Modules explicitly selected by user
|
|
||||||
* @param {Object} options - Resolution options
|
|
||||||
* @returns {Object} Resolution results with all required files
|
|
||||||
*/
|
|
||||||
async resolve(bmadDir, selectedModules = [], options = {}) {
|
|
||||||
if (options.verbose) {
|
|
||||||
await prompts.log.info('Resolving module dependencies...');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always include core as base
|
|
||||||
const modulesToProcess = new Set(['core', ...selectedModules]);
|
|
||||||
|
|
||||||
// First pass: collect all explicitly selected files
|
|
||||||
const primaryFiles = await this.collectPrimaryFiles(bmadDir, modulesToProcess, options);
|
|
||||||
|
|
||||||
// Second pass: parse and resolve dependencies
|
|
||||||
const allDependencies = await this.parseDependencies(primaryFiles);
|
|
||||||
|
|
||||||
// Third pass: resolve dependency paths and collect files
|
|
||||||
const resolvedDeps = await this.resolveDependencyPaths(bmadDir, allDependencies);
|
|
||||||
|
|
||||||
// Fourth pass: check for transitive dependencies
|
|
||||||
const transitiveDeps = await this.resolveTransitiveDependencies(bmadDir, resolvedDeps);
|
|
||||||
|
|
||||||
// Combine all files
|
|
||||||
const allFiles = new Set([...primaryFiles.map((f) => f.path), ...resolvedDeps, ...transitiveDeps]);
|
|
||||||
|
|
||||||
// Organize by module
|
|
||||||
const organizedFiles = this.organizeByModule(bmadDir, allFiles);
|
|
||||||
|
|
||||||
// Report results (only in verbose mode)
|
|
||||||
if (options.verbose) {
|
|
||||||
await this.reportResults(organizedFiles, selectedModules);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
primaryFiles,
|
|
||||||
dependencies: resolvedDeps,
|
|
||||||
transitiveDependencies: transitiveDeps,
|
|
||||||
allFiles: [...allFiles],
|
|
||||||
byModule: organizedFiles,
|
|
||||||
missing: [...this.missingDependencies],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collect primary files from selected modules
|
|
||||||
*/
|
|
||||||
async collectPrimaryFiles(bmadDir, modules, options = {}) {
|
|
||||||
const files = [];
|
|
||||||
const { moduleManager } = options;
|
|
||||||
|
|
||||||
for (const module of modules) {
|
|
||||||
// Skip external modules - they're installed from cache, not from source
|
|
||||||
if (moduleManager && (await moduleManager.isExternalModule(module))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle both source (src/) and installed (bmad/) directory structures
|
|
||||||
let moduleDir;
|
|
||||||
|
|
||||||
// Check if this is a source directory (has 'src' subdirectory)
|
|
||||||
const srcDir = path.join(bmadDir, 'src');
|
|
||||||
if (await fs.pathExists(srcDir)) {
|
|
||||||
// Source directory structure: src/core-skills or src/bmm-skills
|
|
||||||
if (module === 'core') {
|
|
||||||
moduleDir = path.join(srcDir, 'core-skills');
|
|
||||||
} else if (module === 'bmm') {
|
|
||||||
moduleDir = path.join(srcDir, 'bmm-skills');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!moduleDir) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(moduleDir))) {
|
|
||||||
await prompts.log.warn('Module directory not found: ' + moduleDir);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect agents
|
|
||||||
const agentsDir = path.join(moduleDir, 'agents');
|
|
||||||
if (await fs.pathExists(agentsDir)) {
|
|
||||||
const agentFiles = await glob.glob('*.md', { cwd: agentsDir });
|
|
||||||
for (const file of agentFiles) {
|
|
||||||
const agentPath = path.join(agentsDir, file);
|
|
||||||
|
|
||||||
// Check for localskip attribute
|
|
||||||
const content = await fs.readFile(agentPath, 'utf8');
|
|
||||||
const hasLocalSkip = content.match(/<agent[^>]*\slocalskip="true"[^>]*>/);
|
|
||||||
if (hasLocalSkip) {
|
|
||||||
continue; // Skip agents marked for web-only
|
|
||||||
}
|
|
||||||
|
|
||||||
files.push({
|
|
||||||
path: agentPath,
|
|
||||||
type: 'agent',
|
|
||||||
module,
|
|
||||||
name: path.basename(file, '.md'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect tasks
|
|
||||||
const tasksDir = path.join(moduleDir, 'tasks');
|
|
||||||
if (await fs.pathExists(tasksDir)) {
|
|
||||||
const taskFiles = await glob.glob('*.md', { cwd: tasksDir });
|
|
||||||
for (const file of taskFiles) {
|
|
||||||
files.push({
|
|
||||||
path: path.join(tasksDir, file),
|
|
||||||
type: 'task',
|
|
||||||
module,
|
|
||||||
name: path.basename(file, '.md'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse dependencies from file content
|
|
||||||
*/
|
|
||||||
async parseDependencies(files) {
|
|
||||||
const allDeps = new Set();
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const content = await fs.readFile(file.path, 'utf8');
|
|
||||||
|
|
||||||
// Parse YAML frontmatter for explicit dependencies
|
|
||||||
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
||||||
if (frontmatterMatch) {
|
|
||||||
try {
|
|
||||||
// Pre-process to handle backticks in YAML values
|
|
||||||
let yamlContent = frontmatterMatch[1];
|
|
||||||
// Quote values with backticks to make them valid YAML
|
|
||||||
yamlContent = yamlContent.replaceAll(/: `([^`]+)`/g, ': "$1"');
|
|
||||||
|
|
||||||
const frontmatter = yaml.parse(yamlContent);
|
|
||||||
if (frontmatter.dependencies) {
|
|
||||||
const deps = Array.isArray(frontmatter.dependencies) ? frontmatter.dependencies : [frontmatter.dependencies];
|
|
||||||
|
|
||||||
for (const dep of deps) {
|
|
||||||
allDeps.add({
|
|
||||||
from: file.path,
|
|
||||||
dependency: dep,
|
|
||||||
type: 'explicit',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for template dependencies
|
|
||||||
if (frontmatter.template) {
|
|
||||||
const templates = Array.isArray(frontmatter.template) ? frontmatter.template : [frontmatter.template];
|
|
||||||
for (const template of templates) {
|
|
||||||
allDeps.add({
|
|
||||||
from: file.path,
|
|
||||||
dependency: template,
|
|
||||||
type: 'template',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
await prompts.log.warn('Failed to parse frontmatter in ' + file.name + ': ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse content for command references (cross-module dependencies)
|
|
||||||
const commandRefs = this.parseCommandReferences(content);
|
|
||||||
for (const ref of commandRefs) {
|
|
||||||
allDeps.add({
|
|
||||||
from: file.path,
|
|
||||||
dependency: ref,
|
|
||||||
type: 'command',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse for file path references
|
|
||||||
const fileRefs = this.parseFileReferences(content);
|
|
||||||
for (const ref of fileRefs) {
|
|
||||||
// Determine type based on path format
|
|
||||||
// Paths starting with bmad/ are absolute references to the bmad installation
|
|
||||||
const depType = ref.startsWith('bmad/') ? 'bmad-path' : 'file';
|
|
||||||
allDeps.add({
|
|
||||||
from: file.path,
|
|
||||||
dependency: ref,
|
|
||||||
type: depType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return allDeps;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse command references from content
|
|
||||||
*/
|
|
||||||
parseCommandReferences(content) {
|
|
||||||
const refs = new Set();
|
|
||||||
|
|
||||||
// Match @task-{name} or @agent-{name} or @{module}-{type}-{name}
|
|
||||||
const commandPattern = /@(task-|agent-|bmad-)([a-z0-9-]+)/g;
|
|
||||||
let match;
|
|
||||||
|
|
||||||
while ((match = commandPattern.exec(content)) !== null) {
|
|
||||||
refs.add(match[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match file paths like bmad/core/agents/analyst
|
|
||||||
const pathPattern = /bmad\/(core|bmm|cis)\/(agents|tasks)\/([a-z0-9-]+)/g;
|
|
||||||
|
|
||||||
while ((match = pathPattern.exec(content)) !== null) {
|
|
||||||
refs.add(match[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...refs];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse file path references from content
|
|
||||||
*/
|
|
||||||
parseFileReferences(content) {
|
|
||||||
const refs = new Set();
|
|
||||||
|
|
||||||
// Match relative paths like ../templates/file.yaml or ./data/file.md
|
|
||||||
const relativePattern = /['"](\.\.?\/[^'"]+\.(md|yaml|yml|xml|json|txt|csv))['"]/g;
|
|
||||||
let match;
|
|
||||||
|
|
||||||
while ((match = relativePattern.exec(content)) !== null) {
|
|
||||||
refs.add(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse exec attributes in command tags
|
|
||||||
const execPattern = /exec="([^"]+)"/g;
|
|
||||||
while ((match = execPattern.exec(content)) !== null) {
|
|
||||||
let execPath = match[1];
|
|
||||||
if (execPath && execPath !== '*') {
|
|
||||||
// Remove {project-root} prefix to get the actual path
|
|
||||||
// Usage is like {project-root}/bmad/core/tasks/foo.md
|
|
||||||
if (execPath.includes('{project-root}')) {
|
|
||||||
execPath = execPath.replace('{project-root}', '');
|
|
||||||
}
|
|
||||||
refs.add(execPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse tmpl attributes in command tags
|
|
||||||
const tmplPattern = /tmpl="([^"]+)"/g;
|
|
||||||
while ((match = tmplPattern.exec(content)) !== null) {
|
|
||||||
let tmplPath = match[1];
|
|
||||||
if (tmplPath && tmplPath !== '*') {
|
|
||||||
// Remove {project-root} prefix to get the actual path
|
|
||||||
// Usage is like {project-root}/bmad/core/tasks/foo.md
|
|
||||||
if (tmplPath.includes('{project-root}')) {
|
|
||||||
tmplPath = tmplPath.replace('{project-root}', '');
|
|
||||||
}
|
|
||||||
refs.add(tmplPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...refs];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve dependency paths to actual files
|
|
||||||
*/
|
|
||||||
async resolveDependencyPaths(bmadDir, dependencies) {
|
|
||||||
const resolved = new Set();
|
|
||||||
|
|
||||||
for (const dep of dependencies) {
|
|
||||||
const resolvedPaths = await this.resolveSingleDependency(bmadDir, dep);
|
|
||||||
for (const path of resolvedPaths) {
|
|
||||||
resolved.add(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a single dependency to file paths
|
|
||||||
*/
|
|
||||||
async resolveSingleDependency(bmadDir, dep) {
|
|
||||||
const paths = [];
|
|
||||||
|
|
||||||
switch (dep.type) {
|
|
||||||
case 'explicit':
|
|
||||||
case 'file': {
|
|
||||||
let depPath = dep.dependency;
|
|
||||||
|
|
||||||
// Handle {project-root} prefix if present
|
|
||||||
if (depPath.includes('{project-root}')) {
|
|
||||||
// Remove {project-root} and resolve as bmad path
|
|
||||||
depPath = depPath.replace('{project-root}', '');
|
|
||||||
|
|
||||||
if (depPath.startsWith('bmad/')) {
|
|
||||||
const bmadPath = depPath.replace(/^bmad\//, '');
|
|
||||||
|
|
||||||
// Handle glob patterns
|
|
||||||
if (depPath.includes('*')) {
|
|
||||||
// Extract the base path and pattern
|
|
||||||
const pathParts = bmadPath.split('/');
|
|
||||||
const module = pathParts[0];
|
|
||||||
const filePattern = pathParts.at(-1);
|
|
||||||
const middlePath = pathParts.slice(1, -1).join('/');
|
|
||||||
|
|
||||||
let basePath;
|
|
||||||
if (module === 'core') {
|
|
||||||
basePath = path.join(bmadDir, 'core', middlePath);
|
|
||||||
} else {
|
|
||||||
basePath = path.join(bmadDir, 'modules', module, middlePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await fs.pathExists(basePath)) {
|
|
||||||
const files = await glob.glob(filePattern, { cwd: basePath });
|
|
||||||
for (const file of files) {
|
|
||||||
paths.push(path.join(basePath, file));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Direct path
|
|
||||||
if (bmadPath.startsWith('core/')) {
|
|
||||||
const corePath = path.join(bmadDir, bmadPath);
|
|
||||||
if (await fs.pathExists(corePath)) {
|
|
||||||
paths.push(corePath);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const parts = bmadPath.split('/');
|
|
||||||
const module = parts[0];
|
|
||||||
const rest = parts.slice(1).join('/');
|
|
||||||
const modulePath = path.join(bmadDir, 'modules', module, rest);
|
|
||||||
|
|
||||||
if (await fs.pathExists(modulePath)) {
|
|
||||||
paths.push(modulePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Regular relative path handling
|
|
||||||
const sourceDir = path.dirname(dep.from);
|
|
||||||
|
|
||||||
// Handle glob patterns
|
|
||||||
if (depPath.includes('*')) {
|
|
||||||
const basePath = path.resolve(sourceDir, path.dirname(depPath));
|
|
||||||
const pattern = path.basename(depPath);
|
|
||||||
|
|
||||||
if (await fs.pathExists(basePath)) {
|
|
||||||
const files = await glob.glob(pattern, { cwd: basePath });
|
|
||||||
for (const file of files) {
|
|
||||||
paths.push(path.join(basePath, file));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Direct file reference
|
|
||||||
const fullPath = path.resolve(sourceDir, depPath);
|
|
||||||
if (await fs.pathExists(fullPath)) {
|
|
||||||
paths.push(fullPath);
|
|
||||||
} else {
|
|
||||||
this.missingDependencies.add(`${depPath} (referenced by ${path.basename(dep.from)})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'command': {
|
|
||||||
// Resolve command references to actual files
|
|
||||||
const commandPath = await this.resolveCommandToPath(bmadDir, dep.dependency);
|
|
||||||
if (commandPath) {
|
|
||||||
paths.push(commandPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'bmad-path': {
|
|
||||||
// Resolve bmad/ paths (from {project-root}/bmad/... references)
|
|
||||||
// These are paths relative to the src directory structure
|
|
||||||
const bmadPath = dep.dependency.replace(/^bmad\//, '');
|
|
||||||
|
|
||||||
// Try to resolve as if it's in src structure
|
|
||||||
// bmad/core/tasks/foo.md -> src/core-skills/tasks/foo.md
|
|
||||||
// bmad/bmm/tasks/bar.md -> src/bmm-skills/tasks/bar.md (bmm is directly under src/)
|
|
||||||
// bmad/cis/agents/bar.md -> src/modules/cis/agents/bar.md
|
|
||||||
|
|
||||||
if (bmadPath.startsWith('core/')) {
|
|
||||||
const corePath = path.join(bmadDir, bmadPath);
|
|
||||||
if (await fs.pathExists(corePath)) {
|
|
||||||
paths.push(corePath);
|
|
||||||
} else {
|
|
||||||
// Not found, but don't report as missing since it might be installed later
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// It's a module path like bmm/tasks/foo.md or cis/agents/bar.md
|
|
||||||
const parts = bmadPath.split('/');
|
|
||||||
const module = parts[0];
|
|
||||||
const rest = parts.slice(1).join('/');
|
|
||||||
let modulePath;
|
|
||||||
if (module === 'bmm') {
|
|
||||||
// bmm is directly under src/
|
|
||||||
modulePath = path.join(bmadDir, module, rest);
|
|
||||||
} else {
|
|
||||||
// Other modules are under modules/
|
|
||||||
modulePath = path.join(bmadDir, 'modules', module, rest);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await fs.pathExists(modulePath)) {
|
|
||||||
paths.push(modulePath);
|
|
||||||
} else {
|
|
||||||
// Not found, but don't report as missing since it might be installed later
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'template': {
|
|
||||||
// Resolve template references
|
|
||||||
let templateDep = dep.dependency;
|
|
||||||
|
|
||||||
// Handle {project-root} prefix if present
|
|
||||||
if (templateDep.includes('{project-root}')) {
|
|
||||||
// Remove {project-root} and treat as bmad-path
|
|
||||||
templateDep = templateDep.replace('{project-root}', '');
|
|
||||||
|
|
||||||
// Now resolve as a bmad path
|
|
||||||
if (templateDep.startsWith('bmad/')) {
|
|
||||||
const bmadPath = templateDep.replace(/^bmad\//, '');
|
|
||||||
|
|
||||||
if (bmadPath.startsWith('core/')) {
|
|
||||||
const corePath = path.join(bmadDir, bmadPath);
|
|
||||||
if (await fs.pathExists(corePath)) {
|
|
||||||
paths.push(corePath);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Module path like cis/templates/brainstorm.md
|
|
||||||
const parts = bmadPath.split('/');
|
|
||||||
const module = parts[0];
|
|
||||||
const rest = parts.slice(1).join('/');
|
|
||||||
const modulePath = path.join(bmadDir, 'modules', module, rest);
|
|
||||||
|
|
||||||
if (await fs.pathExists(modulePath)) {
|
|
||||||
paths.push(modulePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Regular relative template path
|
|
||||||
const sourceDir = path.dirname(dep.from);
|
|
||||||
const templatePath = path.resolve(sourceDir, templateDep);
|
|
||||||
|
|
||||||
if (await fs.pathExists(templatePath)) {
|
|
||||||
paths.push(templatePath);
|
|
||||||
} else {
|
|
||||||
this.missingDependencies.add(`Template: ${dep.dependency}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// No default
|
|
||||||
}
|
|
||||||
|
|
||||||
return paths;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve command reference to file path
|
|
||||||
*/
|
|
||||||
async resolveCommandToPath(bmadDir, command) {
|
|
||||||
// Parse command format: @task-name or @agent-name or bmad/module/type/name
|
|
||||||
|
|
||||||
if (command.startsWith('@task-')) {
|
|
||||||
const taskName = command.slice(6);
|
|
||||||
// Search all modules for this task
|
|
||||||
for (const module of ['core', 'bmm', 'cis']) {
|
|
||||||
const taskPath =
|
|
||||||
module === 'core'
|
|
||||||
? path.join(bmadDir, 'core', 'tasks', `${taskName}.md`)
|
|
||||||
: path.join(bmadDir, 'modules', module, 'tasks', `${taskName}.md`);
|
|
||||||
if (await fs.pathExists(taskPath)) {
|
|
||||||
return taskPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (command.startsWith('@agent-')) {
|
|
||||||
const agentName = command.slice(7);
|
|
||||||
// Search all modules for this agent
|
|
||||||
for (const module of ['core', 'bmm', 'cis']) {
|
|
||||||
const agentPath =
|
|
||||||
module === 'core'
|
|
||||||
? path.join(bmadDir, 'core', 'agents', `${agentName}.md`)
|
|
||||||
: path.join(bmadDir, 'modules', module, 'agents', `${agentName}.md`);
|
|
||||||
if (await fs.pathExists(agentPath)) {
|
|
||||||
return agentPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (command.startsWith('bmad/')) {
|
|
||||||
// Direct path reference
|
|
||||||
const parts = command.split('/');
|
|
||||||
if (parts.length >= 4) {
|
|
||||||
const [, module, type, ...nameParts] = parts;
|
|
||||||
const name = nameParts.join('/'); // Handle nested paths
|
|
||||||
|
|
||||||
// Check if name already has extension
|
|
||||||
const fileName = name.endsWith('.md') ? name : `${name}.md`;
|
|
||||||
|
|
||||||
const filePath =
|
|
||||||
module === 'core' ? path.join(bmadDir, 'core', type, fileName) : path.join(bmadDir, 'modules', module, type, fileName);
|
|
||||||
if (await fs.pathExists(filePath)) {
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't report as missing if it's a self-reference within the module being installed
|
|
||||||
if (!command.includes('cis') || command.includes('brain')) {
|
|
||||||
// Only report missing if it's a true external dependency
|
|
||||||
// this.missingDependencies.add(`Command: ${command}`);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve transitive dependencies (dependencies of dependencies)
|
|
||||||
*/
|
|
||||||
async resolveTransitiveDependencies(bmadDir, directDeps) {
|
|
||||||
const transitive = new Set();
|
|
||||||
const processed = new Set();
|
|
||||||
|
|
||||||
// Process each direct dependency
|
|
||||||
for (const depPath of directDeps) {
|
|
||||||
if (processed.has(depPath)) continue;
|
|
||||||
processed.add(depPath);
|
|
||||||
|
|
||||||
// Only process markdown and YAML files for transitive deps
|
|
||||||
if ((depPath.endsWith('.md') || depPath.endsWith('.yaml') || depPath.endsWith('.yml')) && (await fs.pathExists(depPath))) {
|
|
||||||
const content = await fs.readFile(depPath, 'utf8');
|
|
||||||
const subDeps = await this.parseDependencies([
|
|
||||||
{
|
|
||||||
path: depPath,
|
|
||||||
type: 'dependency',
|
|
||||||
module: this.getModuleFromPath(bmadDir, depPath),
|
|
||||||
name: path.basename(depPath),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const resolvedSubDeps = await this.resolveDependencyPaths(bmadDir, subDeps);
|
|
||||||
for (const subDep of resolvedSubDeps) {
|
|
||||||
if (!directDeps.has(subDep)) {
|
|
||||||
transitive.add(subDep);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return transitive;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get module name from file path
|
|
||||||
*/
|
|
||||||
getModuleFromPath(bmadDir, filePath) {
|
|
||||||
const relative = path.relative(bmadDir, filePath);
|
|
||||||
const parts = relative.split(path.sep);
|
|
||||||
|
|
||||||
// Handle source directory structure (src/core-skills, src/bmm-skills, or src/modules/xxx)
|
|
||||||
if (parts[0] === 'src') {
|
|
||||||
if (parts[1] === 'core-skills') {
|
|
||||||
return 'core';
|
|
||||||
} else if (parts[1] === 'bmm-skills') {
|
|
||||||
return 'bmm';
|
|
||||||
} else if (parts[1] === 'modules' && parts.length > 2) {
|
|
||||||
return parts[2];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's in modules directory (installed structure)
|
|
||||||
if (parts[0] === 'modules' && parts.length > 1) {
|
|
||||||
return parts[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise return the first part (core, etc.)
|
|
||||||
// But don't return 'src' as a module name
|
|
||||||
if (parts[0] === 'src') {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
return parts[0] || 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Organize files by module
|
|
||||||
*/
|
|
||||||
organizeByModule(bmadDir, files) {
|
|
||||||
const organized = {};
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const module = this.getModuleFromPath(bmadDir, file);
|
|
||||||
if (!organized[module]) {
|
|
||||||
organized[module] = {
|
|
||||||
agents: [],
|
|
||||||
tasks: [],
|
|
||||||
tools: [],
|
|
||||||
templates: [],
|
|
||||||
data: [],
|
|
||||||
other: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get relative path correctly based on module structure
|
|
||||||
let moduleBase;
|
|
||||||
|
|
||||||
// Check if file is in source directory structure
|
|
||||||
if (file.includes('/src/core-skills/') || file.includes('/src/bmm-skills/')) {
|
|
||||||
if (module === 'core') {
|
|
||||||
moduleBase = path.join(bmadDir, 'src', 'core-skills');
|
|
||||||
} else if (module === 'bmm') {
|
|
||||||
moduleBase = path.join(bmadDir, 'src', 'bmm-skills');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
moduleBase = module === 'core' ? path.join(bmadDir, 'core') : path.join(bmadDir, 'modules', module);
|
|
||||||
}
|
|
||||||
|
|
||||||
const relative = path.relative(moduleBase, file);
|
|
||||||
|
|
||||||
if (relative.startsWith('agents/') || file.includes('/agents/')) {
|
|
||||||
organized[module].agents.push(file);
|
|
||||||
} else if (relative.startsWith('tasks/') || file.includes('/tasks/')) {
|
|
||||||
organized[module].tasks.push(file);
|
|
||||||
} else if (relative.startsWith('tools/') || file.includes('/tools/')) {
|
|
||||||
organized[module].tools.push(file);
|
|
||||||
} else if (relative.includes('data/')) {
|
|
||||||
organized[module].data.push(file);
|
|
||||||
} else {
|
|
||||||
organized[module].other.push(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return organized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Report resolution results
|
|
||||||
*/
|
|
||||||
async reportResults(organized, selectedModules) {
|
|
||||||
await prompts.log.success('Dependency resolution complete');
|
|
||||||
|
|
||||||
for (const [module, files] of Object.entries(organized)) {
|
|
||||||
const isSelected = selectedModules.includes(module) || module === 'core';
|
|
||||||
const totalFiles =
|
|
||||||
files.agents.length + files.tasks.length + files.tools.length + files.templates.length + files.data.length + files.other.length;
|
|
||||||
|
|
||||||
if (totalFiles > 0) {
|
|
||||||
await prompts.log.info(` ${module.toUpperCase()} module:`);
|
|
||||||
await prompts.log.message(` Status: ${isSelected ? 'Selected' : 'Dependencies only'}`);
|
|
||||||
|
|
||||||
if (files.agents.length > 0) {
|
|
||||||
await prompts.log.message(` Agents: ${files.agents.length}`);
|
|
||||||
}
|
|
||||||
if (files.tasks.length > 0) {
|
|
||||||
await prompts.log.message(` Tasks: ${files.tasks.length}`);
|
|
||||||
}
|
|
||||||
if (files.templates.length > 0) {
|
|
||||||
await prompts.log.message(` Templates: ${files.templates.length}`);
|
|
||||||
}
|
|
||||||
if (files.data.length > 0) {
|
|
||||||
await prompts.log.message(` Data files: ${files.data.length}`);
|
|
||||||
}
|
|
||||||
if (files.other.length > 0) {
|
|
||||||
await prompts.log.message(` Other files: ${files.other.length}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.missingDependencies.size > 0) {
|
|
||||||
await prompts.log.warn('Missing dependencies:');
|
|
||||||
for (const missing of this.missingDependencies) {
|
|
||||||
await prompts.log.warn(` - ${missing}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a bundle for web deployment
|
|
||||||
* @param {Object} resolution - Resolution results from resolve()
|
|
||||||
* @returns {Object} Bundle data ready for web
|
|
||||||
*/
|
|
||||||
async createWebBundle(resolution) {
|
|
||||||
const bundle = {
|
|
||||||
metadata: {
|
|
||||||
created: new Date().toISOString(),
|
|
||||||
modules: Object.keys(resolution.byModule),
|
|
||||||
totalFiles: resolution.allFiles.length,
|
|
||||||
},
|
|
||||||
agents: {},
|
|
||||||
tasks: {},
|
|
||||||
templates: {},
|
|
||||||
data: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Bundle all files by type
|
|
||||||
for (const filePath of resolution.allFiles) {
|
|
||||||
if (!(await fs.pathExists(filePath))) continue;
|
|
||||||
|
|
||||||
const content = await fs.readFile(filePath, 'utf8');
|
|
||||||
const relative = path.relative(path.dirname(resolution.primaryFiles[0]?.path || '.'), filePath);
|
|
||||||
|
|
||||||
if (filePath.includes('/agents/')) {
|
|
||||||
bundle.agents[relative] = content;
|
|
||||||
} else if (filePath.includes('/tasks/')) {
|
|
||||||
bundle.tasks[relative] = content;
|
|
||||||
} else if (filePath.includes('template')) {
|
|
||||||
bundle.templates[relative] = content;
|
|
||||||
} else {
|
|
||||||
bundle.data[relative] = content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bundle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { DependencyResolver };
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
const path = require('node:path');
|
|
||||||
const fs = require('fs-extra');
|
|
||||||
const yaml = require('yaml');
|
|
||||||
const { Manifest } = require('./manifest');
|
|
||||||
|
|
||||||
class Detector {
|
|
||||||
/**
|
|
||||||
* Detect existing BMAD installation
|
|
||||||
* @param {string} bmadDir - Path to bmad directory
|
|
||||||
* @returns {Object} Installation status and details
|
|
||||||
*/
|
|
||||||
async detect(bmadDir) {
|
|
||||||
const result = {
|
|
||||||
installed: false,
|
|
||||||
path: bmadDir,
|
|
||||||
version: null,
|
|
||||||
hasCore: false,
|
|
||||||
modules: [],
|
|
||||||
ides: [],
|
|
||||||
customModules: [],
|
|
||||||
manifest: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if bmad directory exists
|
|
||||||
if (!(await fs.pathExists(bmadDir))) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for manifest using the Manifest class
|
|
||||||
const manifest = new Manifest();
|
|
||||||
const manifestData = await manifest.read(bmadDir);
|
|
||||||
if (manifestData) {
|
|
||||||
result.manifest = manifestData;
|
|
||||||
result.version = manifestData.version;
|
|
||||||
result.installed = true;
|
|
||||||
// Copy custom modules if they exist
|
|
||||||
if (manifestData.customModules) {
|
|
||||||
result.customModules = manifestData.customModules;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for core
|
|
||||||
const corePath = path.join(bmadDir, 'core');
|
|
||||||
if (await fs.pathExists(corePath)) {
|
|
||||||
result.hasCore = true;
|
|
||||||
|
|
||||||
// Try to get core version from config
|
|
||||||
const coreConfigPath = path.join(corePath, 'config.yaml');
|
|
||||||
if (await fs.pathExists(coreConfigPath)) {
|
|
||||||
try {
|
|
||||||
const configContent = await fs.readFile(coreConfigPath, 'utf8');
|
|
||||||
const config = yaml.parse(configContent);
|
|
||||||
if (!result.version && config.version) {
|
|
||||||
result.version = config.version;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore config read errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for modules
|
|
||||||
// If manifest exists, use it as the source of truth for installed modules
|
|
||||||
// Otherwise fall back to directory scanning (legacy installations)
|
|
||||||
if (manifestData && manifestData.modules && manifestData.modules.length > 0) {
|
|
||||||
// Use manifest module list - these are officially installed modules
|
|
||||||
for (const moduleId of manifestData.modules) {
|
|
||||||
const modulePath = path.join(bmadDir, moduleId);
|
|
||||||
const moduleConfigPath = path.join(modulePath, 'config.yaml');
|
|
||||||
|
|
||||||
const moduleInfo = {
|
|
||||||
id: moduleId,
|
|
||||||
path: modulePath,
|
|
||||||
version: 'unknown',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (await fs.pathExists(moduleConfigPath)) {
|
|
||||||
try {
|
|
||||||
const configContent = await fs.readFile(moduleConfigPath, 'utf8');
|
|
||||||
const config = yaml.parse(configContent);
|
|
||||||
moduleInfo.version = config.version || 'unknown';
|
|
||||||
moduleInfo.name = config.name || moduleId;
|
|
||||||
moduleInfo.description = config.description;
|
|
||||||
} catch {
|
|
||||||
// Ignore config read errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.modules.push(moduleInfo);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback: scan directory for modules (legacy installations without manifest)
|
|
||||||
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config') {
|
|
||||||
const modulePath = path.join(bmadDir, entry.name);
|
|
||||||
const moduleConfigPath = path.join(modulePath, 'config.yaml');
|
|
||||||
|
|
||||||
// Only treat it as a module if it has a config.yaml
|
|
||||||
if (await fs.pathExists(moduleConfigPath)) {
|
|
||||||
const moduleInfo = {
|
|
||||||
id: entry.name,
|
|
||||||
path: modulePath,
|
|
||||||
version: 'unknown',
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const configContent = await fs.readFile(moduleConfigPath, 'utf8');
|
|
||||||
const config = yaml.parse(configContent);
|
|
||||||
moduleInfo.version = config.version || 'unknown';
|
|
||||||
moduleInfo.name = config.name || entry.name;
|
|
||||||
moduleInfo.description = config.description;
|
|
||||||
} catch {
|
|
||||||
// Ignore config read errors
|
|
||||||
}
|
|
||||||
|
|
||||||
result.modules.push(moduleInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for IDE configurations from manifest
|
|
||||||
if (result.manifest && result.manifest.ides) {
|
|
||||||
// Filter out any undefined/null values
|
|
||||||
result.ides = result.manifest.ides.filter((ide) => ide && typeof ide === 'string');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as installed if we found core or modules
|
|
||||||
if (result.hasCore || result.modules.length > 0) {
|
|
||||||
result.installed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect legacy installation (_bmad-method, .bmm, .cis)
|
|
||||||
* @param {string} projectDir - Project directory to check
|
|
||||||
* @returns {Object} Legacy installation details
|
|
||||||
*/
|
|
||||||
async detectLegacy(projectDir) {
|
|
||||||
const result = {
|
|
||||||
hasLegacy: false,
|
|
||||||
legacyCore: false,
|
|
||||||
legacyModules: [],
|
|
||||||
paths: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check for legacy core (_bmad-method)
|
|
||||||
const legacyCorePath = path.join(projectDir, '_bmad-method');
|
|
||||||
if (await fs.pathExists(legacyCorePath)) {
|
|
||||||
result.hasLegacy = true;
|
|
||||||
result.legacyCore = true;
|
|
||||||
result.paths.push(legacyCorePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for legacy modules (directories starting with .)
|
|
||||||
const entries = await fs.readdir(projectDir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (
|
|
||||||
entry.isDirectory() &&
|
|
||||||
entry.name.startsWith('.') &&
|
|
||||||
entry.name !== '_bmad-method' &&
|
|
||||||
!entry.name.startsWith('.git') &&
|
|
||||||
!entry.name.startsWith('.vscode') &&
|
|
||||||
!entry.name.startsWith('.idea')
|
|
||||||
) {
|
|
||||||
const modulePath = path.join(projectDir, entry.name);
|
|
||||||
const moduleManifestPath = path.join(modulePath, 'install-manifest.yaml');
|
|
||||||
|
|
||||||
// Check if it's likely a BMAD module
|
|
||||||
if ((await fs.pathExists(moduleManifestPath)) || (await fs.pathExists(path.join(modulePath, 'config.yaml')))) {
|
|
||||||
result.hasLegacy = true;
|
|
||||||
result.legacyModules.push({
|
|
||||||
name: entry.name.slice(1), // Remove leading dot
|
|
||||||
path: modulePath,
|
|
||||||
});
|
|
||||||
result.paths.push(modulePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if migration from legacy is needed
|
|
||||||
* @param {string} projectDir - Project directory
|
|
||||||
* @returns {Object} Migration requirements
|
|
||||||
*/
|
|
||||||
async checkMigrationNeeded(projectDir) {
|
|
||||||
const bmadDir = path.join(projectDir, 'bmad');
|
|
||||||
const current = await this.detect(bmadDir);
|
|
||||||
const legacy = await this.detectLegacy(projectDir);
|
|
||||||
|
|
||||||
return {
|
|
||||||
needed: legacy.hasLegacy && !current.installed,
|
|
||||||
canMigrate: legacy.hasLegacy,
|
|
||||||
legacy: legacy,
|
|
||||||
current: current,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect legacy BMAD v4 .bmad-method folder
|
|
||||||
* @param {string} projectDir - Project directory to check
|
|
||||||
* @returns {{ hasLegacyV4: boolean, offenders: string[] }}
|
|
||||||
*/
|
|
||||||
async detectLegacyV4(projectDir) {
|
|
||||||
const offenders = [];
|
|
||||||
|
|
||||||
// Check for .bmad-method folder
|
|
||||||
const bmadMethodPath = path.join(projectDir, '.bmad-method');
|
|
||||||
if (await fs.pathExists(bmadMethodPath)) {
|
|
||||||
offenders.push(bmadMethodPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { hasLegacyV4: offenders.length > 0, offenders };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { Detector };
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
const path = require('node:path');
|
|
||||||
const fs = require('fs-extra');
|
|
||||||
const yaml = require('yaml');
|
|
||||||
const prompts = require('../../../lib/prompts');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages IDE configuration persistence
|
|
||||||
* Saves and loads IDE-specific configurations to/from bmad/_config/ides/
|
|
||||||
*/
|
|
||||||
class IdeConfigManager {
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get path to IDE config directory
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @returns {string} Path to IDE config directory
|
|
||||||
*/
|
|
||||||
getIdeConfigDir(bmadDir) {
|
|
||||||
return path.join(bmadDir, '_config', 'ides');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get path to specific IDE config file
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @param {string} ideName - IDE name (e.g., 'claude-code')
|
|
||||||
* @returns {string} Path to IDE config file
|
|
||||||
*/
|
|
||||||
getIdeConfigPath(bmadDir, ideName) {
|
|
||||||
return path.join(this.getIdeConfigDir(bmadDir), `${ideName}.yaml`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save IDE configuration
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @param {string} ideName - IDE name
|
|
||||||
* @param {Object} configuration - IDE-specific configuration object
|
|
||||||
*/
|
|
||||||
async saveIdeConfig(bmadDir, ideName, configuration) {
|
|
||||||
const configDir = this.getIdeConfigDir(bmadDir);
|
|
||||||
await fs.ensureDir(configDir);
|
|
||||||
|
|
||||||
const configPath = this.getIdeConfigPath(bmadDir, ideName);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
// Check if config already exists to preserve configured_date
|
|
||||||
let configuredDate = now;
|
|
||||||
if (await fs.pathExists(configPath)) {
|
|
||||||
try {
|
|
||||||
const existing = await this.loadIdeConfig(bmadDir, ideName);
|
|
||||||
if (existing && existing.configured_date) {
|
|
||||||
configuredDate = existing.configured_date;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors reading existing config
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const configData = {
|
|
||||||
ide: ideName,
|
|
||||||
configured_date: configuredDate,
|
|
||||||
last_updated: now,
|
|
||||||
configuration: configuration || {},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clean the config to remove any non-serializable values (like functions)
|
|
||||||
const cleanConfig = structuredClone(configData);
|
|
||||||
|
|
||||||
const yamlContent = yaml.stringify(cleanConfig, {
|
|
||||||
indent: 2,
|
|
||||||
lineWidth: 0,
|
|
||||||
sortKeys: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure POSIX-compliant final newline
|
|
||||||
const content = yamlContent.endsWith('\n') ? yamlContent : yamlContent + '\n';
|
|
||||||
await fs.writeFile(configPath, content, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load IDE configuration
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @param {string} ideName - IDE name
|
|
||||||
* @returns {Object|null} IDE configuration or null if not found
|
|
||||||
*/
|
|
||||||
async loadIdeConfig(bmadDir, ideName) {
|
|
||||||
const configPath = this.getIdeConfigPath(bmadDir, ideName);
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(configPath))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = await fs.readFile(configPath, 'utf8');
|
|
||||||
const config = yaml.parse(content);
|
|
||||||
return config;
|
|
||||||
} catch (error) {
|
|
||||||
await prompts.log.warn(`Failed to load IDE config for ${ideName}: ${error.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load all IDE configurations
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @returns {Object} Map of IDE name to configuration
|
|
||||||
*/
|
|
||||||
async loadAllIdeConfigs(bmadDir) {
|
|
||||||
const configDir = this.getIdeConfigDir(bmadDir);
|
|
||||||
const configs = {};
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(configDir))) {
|
|
||||||
return configs;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const files = await fs.readdir(configDir);
|
|
||||||
for (const file of files) {
|
|
||||||
if (file.endsWith('.yaml')) {
|
|
||||||
const ideName = file.replace('.yaml', '');
|
|
||||||
const config = await this.loadIdeConfig(bmadDir, ideName);
|
|
||||||
if (config) {
|
|
||||||
configs[ideName] = config.configuration;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
await prompts.log.warn(`Failed to load IDE configs: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return configs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if IDE has saved configuration
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @param {string} ideName - IDE name
|
|
||||||
* @returns {boolean} True if configuration exists
|
|
||||||
*/
|
|
||||||
async hasIdeConfig(bmadDir, ideName) {
|
|
||||||
const configPath = this.getIdeConfigPath(bmadDir, ideName);
|
|
||||||
return await fs.pathExists(configPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete IDE configuration
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @param {string} ideName - IDE name
|
|
||||||
*/
|
|
||||||
async deleteIdeConfig(bmadDir, ideName) {
|
|
||||||
const configPath = this.getIdeConfigPath(bmadDir, ideName);
|
|
||||||
if (await fs.pathExists(configPath)) {
|
|
||||||
await fs.remove(configPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { IdeConfigManager };
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,657 +0,0 @@
|
||||||
const path = require('node:path');
|
|
||||||
const fs = require('fs-extra');
|
|
||||||
const prompts = require('../../../lib/prompts');
|
|
||||||
const { getSourcePath } = require('../../../lib/project-root');
|
|
||||||
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base class for IDE-specific setup
|
|
||||||
* All IDE handlers should extend this class
|
|
||||||
*/
|
|
||||||
class BaseIdeSetup {
|
|
||||||
constructor(name, displayName = null, preferred = false) {
|
|
||||||
this.name = name;
|
|
||||||
this.displayName = displayName || name; // Human-readable name for UI
|
|
||||||
this.preferred = preferred; // Whether this IDE should be shown in preferred list
|
|
||||||
this.configDir = null; // Override in subclasses
|
|
||||||
this.rulesDir = null; // Override in subclasses
|
|
||||||
this.configFile = null; // Override in subclasses when detection is file-based
|
|
||||||
this.detectionPaths = []; // Additional paths that indicate the IDE is configured
|
|
||||||
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the bmad folder name for placeholder replacement
|
|
||||||
* @param {string} bmadFolderName - The bmad folder name
|
|
||||||
*/
|
|
||||||
setBmadFolderName(bmadFolderName) {
|
|
||||||
this.bmadFolderName = bmadFolderName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main setup method - must be implemented by subclasses
|
|
||||||
* @param {string} projectDir - Project directory
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @param {Object} options - Setup options
|
|
||||||
*/
|
|
||||||
async setup(projectDir, bmadDir, options = {}) {
|
|
||||||
throw new Error(`setup() must be implemented by ${this.name} handler`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup IDE configuration
|
|
||||||
* @param {string} projectDir - Project directory
|
|
||||||
*/
|
|
||||||
async cleanup(projectDir, options = {}) {
|
|
||||||
// Default implementation - can be overridden
|
|
||||||
if (this.configDir) {
|
|
||||||
const configPath = path.join(projectDir, this.configDir);
|
|
||||||
if (await fs.pathExists(configPath)) {
|
|
||||||
const bmadRulesPath = path.join(configPath, BMAD_FOLDER_NAME);
|
|
||||||
if (await fs.pathExists(bmadRulesPath)) {
|
|
||||||
await fs.remove(bmadRulesPath);
|
|
||||||
if (!options.silent) await prompts.log.message(`Removed ${this.name} BMAD configuration`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install a custom agent launcher - subclasses should override
|
|
||||||
* @param {string} projectDir - Project directory
|
|
||||||
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
|
|
||||||
* @param {string} agentPath - Path to compiled agent (relative to project root)
|
|
||||||
* @param {Object} metadata - Agent metadata
|
|
||||||
* @returns {Object|null} Info about created command, or null if not supported
|
|
||||||
*/
|
|
||||||
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
|
|
||||||
// Default implementation - subclasses can override
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect whether this IDE already has configuration in the project
|
|
||||||
* Subclasses can override for custom logic
|
|
||||||
* @param {string} projectDir - Project directory
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
async detect(projectDir) {
|
|
||||||
const pathsToCheck = [];
|
|
||||||
|
|
||||||
if (this.configDir) {
|
|
||||||
pathsToCheck.push(path.join(projectDir, this.configDir));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.configFile) {
|
|
||||||
pathsToCheck.push(path.join(projectDir, this.configFile));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(this.detectionPaths)) {
|
|
||||||
for (const candidate of this.detectionPaths) {
|
|
||||||
if (!candidate) continue;
|
|
||||||
const resolved = path.isAbsolute(candidate) ? candidate : path.join(projectDir, candidate);
|
|
||||||
pathsToCheck.push(resolved);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const candidate of pathsToCheck) {
|
|
||||||
if (await fs.pathExists(candidate)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of agents from BMAD installation
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @returns {Array} List of agent files
|
|
||||||
*/
|
|
||||||
async getAgents(bmadDir) {
|
|
||||||
const agents = [];
|
|
||||||
|
|
||||||
// Get core agents
|
|
||||||
const coreAgentsPath = path.join(bmadDir, 'core', 'agents');
|
|
||||||
if (await fs.pathExists(coreAgentsPath)) {
|
|
||||||
const coreAgents = await this.scanDirectory(coreAgentsPath, '.md');
|
|
||||||
agents.push(
|
|
||||||
...coreAgents.map((a) => ({
|
|
||||||
...a,
|
|
||||||
module: 'core',
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get module agents
|
|
||||||
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config' && entry.name !== 'agents') {
|
|
||||||
const moduleAgentsPath = path.join(bmadDir, entry.name, 'agents');
|
|
||||||
if (await fs.pathExists(moduleAgentsPath)) {
|
|
||||||
const moduleAgents = await this.scanDirectory(moduleAgentsPath, '.md');
|
|
||||||
agents.push(
|
|
||||||
...moduleAgents.map((a) => ({
|
|
||||||
...a,
|
|
||||||
module: entry.name,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get standalone agents from bmad/agents/ directory
|
|
||||||
const standaloneAgentsDir = path.join(bmadDir, 'agents');
|
|
||||||
if (await fs.pathExists(standaloneAgentsDir)) {
|
|
||||||
const agentDirs = await fs.readdir(standaloneAgentsDir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const agentDir of agentDirs) {
|
|
||||||
if (!agentDir.isDirectory()) continue;
|
|
||||||
|
|
||||||
const agentDirPath = path.join(standaloneAgentsDir, agentDir.name);
|
|
||||||
const agentFiles = await fs.readdir(agentDirPath);
|
|
||||||
|
|
||||||
for (const file of agentFiles) {
|
|
||||||
if (!file.endsWith('.md')) continue;
|
|
||||||
if (file.includes('.customize.')) continue;
|
|
||||||
|
|
||||||
const filePath = path.join(agentDirPath, file);
|
|
||||||
const content = await fs.readFile(filePath, 'utf8');
|
|
||||||
|
|
||||||
if (content.includes('localskip="true"')) continue;
|
|
||||||
|
|
||||||
agents.push({
|
|
||||||
name: file.replace('.md', ''),
|
|
||||||
path: filePath,
|
|
||||||
relativePath: path.relative(standaloneAgentsDir, filePath),
|
|
||||||
filename: file,
|
|
||||||
module: 'standalone', // Mark as standalone agent
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return agents;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of tasks from BMAD installation
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @param {boolean} standaloneOnly - If true, only return standalone tasks
|
|
||||||
* @returns {Array} List of task files
|
|
||||||
*/
|
|
||||||
async getTasks(bmadDir, standaloneOnly = false) {
|
|
||||||
const tasks = [];
|
|
||||||
|
|
||||||
// Get core tasks (scan for both .md and .xml)
|
|
||||||
const coreTasksPath = path.join(bmadDir, 'core', 'tasks');
|
|
||||||
if (await fs.pathExists(coreTasksPath)) {
|
|
||||||
const coreTasks = await this.scanDirectoryWithStandalone(coreTasksPath, ['.md', '.xml']);
|
|
||||||
tasks.push(
|
|
||||||
...coreTasks.map((t) => ({
|
|
||||||
...t,
|
|
||||||
module: 'core',
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get module tasks
|
|
||||||
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config' && entry.name !== 'agents') {
|
|
||||||
const moduleTasksPath = path.join(bmadDir, entry.name, 'tasks');
|
|
||||||
if (await fs.pathExists(moduleTasksPath)) {
|
|
||||||
const moduleTasks = await this.scanDirectoryWithStandalone(moduleTasksPath, ['.md', '.xml']);
|
|
||||||
tasks.push(
|
|
||||||
...moduleTasks.map((t) => ({
|
|
||||||
...t,
|
|
||||||
module: entry.name,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by standalone if requested
|
|
||||||
if (standaloneOnly) {
|
|
||||||
return tasks.filter((t) => t.standalone === true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return tasks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of tools from BMAD installation
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @param {boolean} standaloneOnly - If true, only return standalone tools
|
|
||||||
* @returns {Array} List of tool files
|
|
||||||
*/
|
|
||||||
async getTools(bmadDir, standaloneOnly = false) {
|
|
||||||
const tools = [];
|
|
||||||
|
|
||||||
// Get core tools (scan for both .md and .xml)
|
|
||||||
const coreToolsPath = path.join(bmadDir, 'core', 'tools');
|
|
||||||
if (await fs.pathExists(coreToolsPath)) {
|
|
||||||
const coreTools = await this.scanDirectoryWithStandalone(coreToolsPath, ['.md', '.xml']);
|
|
||||||
tools.push(
|
|
||||||
...coreTools.map((t) => ({
|
|
||||||
...t,
|
|
||||||
module: 'core',
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get module tools
|
|
||||||
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config' && entry.name !== 'agents') {
|
|
||||||
const moduleToolsPath = path.join(bmadDir, entry.name, 'tools');
|
|
||||||
if (await fs.pathExists(moduleToolsPath)) {
|
|
||||||
const moduleTools = await this.scanDirectoryWithStandalone(moduleToolsPath, ['.md', '.xml']);
|
|
||||||
tools.push(
|
|
||||||
...moduleTools.map((t) => ({
|
|
||||||
...t,
|
|
||||||
module: entry.name,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by standalone if requested
|
|
||||||
if (standaloneOnly) {
|
|
||||||
return tools.filter((t) => t.standalone === true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return tools;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of workflows from BMAD installation
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @param {boolean} standaloneOnly - If true, only return standalone workflows
|
|
||||||
* @returns {Array} List of workflow files
|
|
||||||
*/
|
|
||||||
async getWorkflows(bmadDir, standaloneOnly = false) {
|
|
||||||
const workflows = [];
|
|
||||||
|
|
||||||
// Get core workflows
|
|
||||||
const coreWorkflowsPath = path.join(bmadDir, 'core', 'workflows');
|
|
||||||
if (await fs.pathExists(coreWorkflowsPath)) {
|
|
||||||
const coreWorkflows = await this.findWorkflowFiles(coreWorkflowsPath);
|
|
||||||
workflows.push(
|
|
||||||
...coreWorkflows.map((w) => ({
|
|
||||||
...w,
|
|
||||||
module: 'core',
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get module workflows
|
|
||||||
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config' && entry.name !== 'agents') {
|
|
||||||
const moduleWorkflowsPath = path.join(bmadDir, entry.name, 'workflows');
|
|
||||||
if (await fs.pathExists(moduleWorkflowsPath)) {
|
|
||||||
const moduleWorkflows = await this.findWorkflowFiles(moduleWorkflowsPath);
|
|
||||||
workflows.push(
|
|
||||||
...moduleWorkflows.map((w) => ({
|
|
||||||
...w,
|
|
||||||
module: entry.name,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by standalone if requested
|
|
||||||
if (standaloneOnly) {
|
|
||||||
return workflows.filter((w) => w.standalone === true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return workflows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively find workflow.md files
|
|
||||||
* @param {string} dir - Directory to search
|
|
||||||
* @param {string} [rootDir] - Original root directory (used internally for recursion)
|
|
||||||
* @returns {Array} List of workflow file info objects
|
|
||||||
*/
|
|
||||||
async findWorkflowFiles(dir, rootDir = null) {
|
|
||||||
rootDir = rootDir || dir;
|
|
||||||
const workflows = [];
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(dir))) {
|
|
||||||
return workflows;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
// Recursively search subdirectories
|
|
||||||
const subWorkflows = await this.findWorkflowFiles(fullPath, rootDir);
|
|
||||||
workflows.push(...subWorkflows);
|
|
||||||
} else if (entry.isFile() && entry.name === 'workflow.md') {
|
|
||||||
// Read workflow.md frontmatter to get name and standalone property
|
|
||||||
try {
|
|
||||||
const content = await fs.readFile(fullPath, 'utf8');
|
|
||||||
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
||||||
if (!frontmatterMatch) continue;
|
|
||||||
|
|
||||||
const workflowData = yaml.parse(frontmatterMatch[1]);
|
|
||||||
|
|
||||||
if (workflowData && workflowData.name) {
|
|
||||||
// Workflows are standalone by default unless explicitly false
|
|
||||||
const standalone = workflowData.standalone !== false && workflowData.standalone !== 'false';
|
|
||||||
workflows.push({
|
|
||||||
name: workflowData.name,
|
|
||||||
path: fullPath,
|
|
||||||
relativePath: path.relative(rootDir, fullPath),
|
|
||||||
filename: entry.name,
|
|
||||||
description: workflowData.description || '',
|
|
||||||
standalone: standalone,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip invalid workflow files
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return workflows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan a directory for files with specific extension(s)
|
|
||||||
* @param {string} dir - Directory to scan
|
|
||||||
* @param {string|Array<string>} ext - File extension(s) to match (e.g., '.md' or ['.md', '.xml'])
|
|
||||||
* @param {string} [rootDir] - Original root directory (used internally for recursion)
|
|
||||||
* @returns {Array} List of file info objects
|
|
||||||
*/
|
|
||||||
async scanDirectory(dir, ext, rootDir = null) {
|
|
||||||
rootDir = rootDir || dir;
|
|
||||||
const files = [];
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(dir))) {
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize ext to array
|
|
||||||
const extensions = Array.isArray(ext) ? ext : [ext];
|
|
||||||
|
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
// Recursively scan subdirectories
|
|
||||||
const subFiles = await this.scanDirectory(fullPath, ext, rootDir);
|
|
||||||
files.push(...subFiles);
|
|
||||||
} else if (entry.isFile()) {
|
|
||||||
// Check if file matches any of the extensions
|
|
||||||
const matchedExt = extensions.find((e) => entry.name.endsWith(e));
|
|
||||||
if (matchedExt) {
|
|
||||||
files.push({
|
|
||||||
name: path.basename(entry.name, matchedExt),
|
|
||||||
path: fullPath,
|
|
||||||
relativePath: path.relative(rootDir, fullPath),
|
|
||||||
filename: entry.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan a directory for files with specific extension(s) and check standalone attribute
|
|
||||||
* @param {string} dir - Directory to scan
|
|
||||||
* @param {string|Array<string>} ext - File extension(s) to match (e.g., '.md' or ['.md', '.xml'])
|
|
||||||
* @param {string} [rootDir] - Original root directory (used internally for recursion)
|
|
||||||
* @returns {Array} List of file info objects with standalone property
|
|
||||||
*/
|
|
||||||
async scanDirectoryWithStandalone(dir, ext, rootDir = null) {
|
|
||||||
rootDir = rootDir || dir;
|
|
||||||
const files = [];
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(dir))) {
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize ext to array
|
|
||||||
const extensions = Array.isArray(ext) ? ext : [ext];
|
|
||||||
|
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
// Recursively scan subdirectories
|
|
||||||
const subFiles = await this.scanDirectoryWithStandalone(fullPath, ext, rootDir);
|
|
||||||
files.push(...subFiles);
|
|
||||||
} else if (entry.isFile()) {
|
|
||||||
// Check if file matches any of the extensions
|
|
||||||
const matchedExt = extensions.find((e) => entry.name.endsWith(e));
|
|
||||||
if (matchedExt) {
|
|
||||||
// Read file content to check for standalone attribute
|
|
||||||
// All non-internal files are considered standalone by default
|
|
||||||
let standalone = true;
|
|
||||||
try {
|
|
||||||
const content = await fs.readFile(fullPath, 'utf8');
|
|
||||||
|
|
||||||
// Skip internal/engine files (not user-facing)
|
|
||||||
if (content.includes('internal="true"')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for explicit standalone: false
|
|
||||||
if (entry.name.endsWith('.xml')) {
|
|
||||||
// For XML files, check for standalone="false" attribute
|
|
||||||
const tagMatch = content.match(/<(task|tool)[^>]*standalone="false"/);
|
|
||||||
standalone = !tagMatch;
|
|
||||||
} else if (entry.name.endsWith('.md')) {
|
|
||||||
// For MD files, parse YAML frontmatter
|
|
||||||
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
||||||
if (frontmatterMatch) {
|
|
||||||
try {
|
|
||||||
const yaml = require('yaml');
|
|
||||||
const frontmatter = yaml.parse(frontmatterMatch[1]);
|
|
||||||
standalone = frontmatter.standalone !== false && frontmatter.standalone !== 'false';
|
|
||||||
} catch {
|
|
||||||
// If YAML parsing fails, default to standalone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// No frontmatter means standalone (default)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If we can't read the file, default to standalone
|
|
||||||
standalone = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
files.push({
|
|
||||||
name: path.basename(entry.name, matchedExt),
|
|
||||||
path: fullPath,
|
|
||||||
relativePath: path.relative(rootDir, fullPath),
|
|
||||||
filename: entry.name,
|
|
||||||
standalone: standalone,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create IDE command/rule file from agent or task
|
|
||||||
* @param {string} content - File content
|
|
||||||
* @param {Object} metadata - File metadata
|
|
||||||
* @param {string} projectDir - The actual project directory path
|
|
||||||
* @returns {string} Processed content
|
|
||||||
*/
|
|
||||||
processContent(content, metadata = {}, projectDir = null) {
|
|
||||||
// Replace placeholders
|
|
||||||
let processed = content;
|
|
||||||
|
|
||||||
// Only replace {project-root} if a specific projectDir is provided
|
|
||||||
// Otherwise leave the placeholder intact
|
|
||||||
// Note: Don't add trailing slash - paths in source include leading slash
|
|
||||||
if (projectDir) {
|
|
||||||
processed = processed.replaceAll('{project-root}', projectDir);
|
|
||||||
}
|
|
||||||
processed = processed.replaceAll('{module}', metadata.module || 'core');
|
|
||||||
processed = processed.replaceAll('{agent}', metadata.name || '');
|
|
||||||
processed = processed.replaceAll('{task}', metadata.name || '');
|
|
||||||
|
|
||||||
return processed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure directory exists
|
|
||||||
* @param {string} dirPath - Directory path
|
|
||||||
*/
|
|
||||||
async ensureDir(dirPath) {
|
|
||||||
await fs.ensureDir(dirPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write file with content (replaces _bmad placeholder)
|
|
||||||
* @param {string} filePath - File path
|
|
||||||
* @param {string} content - File content
|
|
||||||
*/
|
|
||||||
async writeFile(filePath, content) {
|
|
||||||
// Replace _bmad placeholder if present
|
|
||||||
if (typeof content === 'string' && content.includes('_bmad')) {
|
|
||||||
content = content.replaceAll('_bmad', this.bmadFolderName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace escape sequence _bmad with literal _bmad
|
|
||||||
if (typeof content === 'string' && content.includes('_bmad')) {
|
|
||||||
content = content.replaceAll('_bmad', '_bmad');
|
|
||||||
}
|
|
||||||
await this.ensureDir(path.dirname(filePath));
|
|
||||||
await fs.writeFile(filePath, content, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy file from source to destination (replaces _bmad placeholder in text files)
|
|
||||||
* @param {string} source - Source file path
|
|
||||||
* @param {string} dest - Destination file path
|
|
||||||
*/
|
|
||||||
async copyFile(source, dest) {
|
|
||||||
// List of text file extensions that should have placeholder replacement
|
|
||||||
const textExtensions = ['.md', '.yaml', '.yml', '.txt', '.json', '.js', '.ts', '.html', '.css', '.sh', '.bat', '.csv'];
|
|
||||||
const ext = path.extname(source).toLowerCase();
|
|
||||||
|
|
||||||
await this.ensureDir(path.dirname(dest));
|
|
||||||
|
|
||||||
// Check if this is a text file that might contain placeholders
|
|
||||||
if (textExtensions.includes(ext)) {
|
|
||||||
try {
|
|
||||||
// Read the file content
|
|
||||||
let content = await fs.readFile(source, 'utf8');
|
|
||||||
|
|
||||||
// Replace _bmad placeholder with actual folder name
|
|
||||||
if (content.includes('_bmad')) {
|
|
||||||
content = content.replaceAll('_bmad', this.bmadFolderName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace escape sequence _bmad with literal _bmad
|
|
||||||
if (content.includes('_bmad')) {
|
|
||||||
content = content.replaceAll('_bmad', '_bmad');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write to dest with replaced content
|
|
||||||
await fs.writeFile(dest, content, 'utf8');
|
|
||||||
} catch {
|
|
||||||
// If reading as text fails, fall back to regular copy
|
|
||||||
await fs.copy(source, dest, { overwrite: true });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Binary file or other file type - just copy directly
|
|
||||||
await fs.copy(source, dest, { overwrite: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if path exists
|
|
||||||
* @param {string} pathToCheck - Path to check
|
|
||||||
* @returns {boolean} True if path exists
|
|
||||||
*/
|
|
||||||
async exists(pathToCheck) {
|
|
||||||
return await fs.pathExists(pathToCheck);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Alias for exists method
|
|
||||||
* @param {string} pathToCheck - Path to check
|
|
||||||
* @returns {boolean} True if path exists
|
|
||||||
*/
|
|
||||||
async pathExists(pathToCheck) {
|
|
||||||
return await fs.pathExists(pathToCheck);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read file content
|
|
||||||
* @param {string} filePath - File path
|
|
||||||
* @returns {string} File content
|
|
||||||
*/
|
|
||||||
async readFile(filePath) {
|
|
||||||
return await fs.readFile(filePath, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format name as title
|
|
||||||
* @param {string} name - Name to format
|
|
||||||
* @returns {string} Formatted title
|
|
||||||
*/
|
|
||||||
formatTitle(name) {
|
|
||||||
return name
|
|
||||||
.split('-')
|
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
||||||
.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flatten a relative path to a single filename for flat slash command naming
|
|
||||||
* @deprecated Use toColonPath() or toDashPath() from shared/path-utils.js instead
|
|
||||||
* Example: 'module/agents/name.md' -> 'bmad-module-agents-name.md'
|
|
||||||
* Used by IDEs that ignore directory structure for slash commands (e.g., Antigravity, Codex)
|
|
||||||
* @param {string} relativePath - Relative path to flatten
|
|
||||||
* @returns {string} Flattened filename with 'bmad-' prefix
|
|
||||||
*/
|
|
||||||
flattenFilename(relativePath) {
|
|
||||||
const sanitized = relativePath.replaceAll(/[/\\]/g, '-');
|
|
||||||
return `bmad-${sanitized}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create agent configuration file
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @param {Object} agent - Agent information
|
|
||||||
*/
|
|
||||||
async createAgentConfig(bmadDir, agent) {
|
|
||||||
const agentConfigDir = path.join(bmadDir, '_config', 'agents');
|
|
||||||
await this.ensureDir(agentConfigDir);
|
|
||||||
|
|
||||||
// Load agent config template
|
|
||||||
const templatePath = getSourcePath('utility', 'models', 'agent-config-template.md');
|
|
||||||
const templateContent = await this.readFile(templatePath);
|
|
||||||
|
|
||||||
const configContent = `# Agent Config: ${agent.name}
|
|
||||||
|
|
||||||
${templateContent}`;
|
|
||||||
|
|
||||||
const configPath = path.join(agentConfigDir, `${agent.module}-${agent.name}.md`);
|
|
||||||
await this.writeFile(configPath, configContent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { BaseIdeSetup };
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
const fs = require('fs-extra');
|
|
||||||
const path = require('node:path');
|
|
||||||
const yaml = require('yaml');
|
|
||||||
|
|
||||||
const PLATFORM_CODES_PATH = path.join(__dirname, 'platform-codes.yaml');
|
|
||||||
|
|
||||||
let _cachedPlatformCodes = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the platform codes configuration from YAML
|
|
||||||
* @returns {Object} Platform codes configuration
|
|
||||||
*/
|
|
||||||
async function loadPlatformCodes() {
|
|
||||||
if (_cachedPlatformCodes) {
|
|
||||||
return _cachedPlatformCodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(PLATFORM_CODES_PATH))) {
|
|
||||||
throw new Error(`Platform codes configuration not found at: ${PLATFORM_CODES_PATH}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = await fs.readFile(PLATFORM_CODES_PATH, 'utf8');
|
|
||||||
_cachedPlatformCodes = yaml.parse(content);
|
|
||||||
return _cachedPlatformCodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get platform information by code
|
|
||||||
* @param {string} platformCode - Platform code (e.g., 'claude-code', 'cursor')
|
|
||||||
* @returns {Object|null} Platform info or null if not found
|
|
||||||
*/
|
|
||||||
function getPlatformInfo(platformCode) {
|
|
||||||
if (!_cachedPlatformCodes) {
|
|
||||||
throw new Error('Platform codes not loaded. Call loadPlatformCodes() first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return _cachedPlatformCodes.platforms[platformCode] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all preferred platforms
|
|
||||||
* @returns {Promise<Array>} Array of preferred platform codes
|
|
||||||
*/
|
|
||||||
async function getPreferredPlatforms() {
|
|
||||||
const config = await loadPlatformCodes();
|
|
||||||
return Object.entries(config.platforms)
|
|
||||||
.filter(([_, info]) => info.preferred)
|
|
||||||
.map(([code, _]) => code);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all platform codes by category
|
|
||||||
* @param {string} category - Category to filter by (ide, cli, tool, etc.)
|
|
||||||
* @returns {Promise<Array>} Array of platform codes in the category
|
|
||||||
*/
|
|
||||||
async function getPlatformsByCategory(category) {
|
|
||||||
const config = await loadPlatformCodes();
|
|
||||||
return Object.entries(config.platforms)
|
|
||||||
.filter(([_, info]) => info.category === category)
|
|
||||||
.map(([code, _]) => code);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all platforms with installer config
|
|
||||||
* @returns {Promise<Array>} Array of platform codes that have installer config
|
|
||||||
*/
|
|
||||||
async function getConfigDrivenPlatforms() {
|
|
||||||
const config = await loadPlatformCodes();
|
|
||||||
return Object.entries(config.platforms)
|
|
||||||
.filter(([_, info]) => info.installer)
|
|
||||||
.map(([code, _]) => code);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get platforms that use custom installers (no installer config)
|
|
||||||
* @returns {Promise<Array>} Array of platform codes with custom installers
|
|
||||||
*/
|
|
||||||
async function getCustomInstallerPlatforms() {
|
|
||||||
const config = await loadPlatformCodes();
|
|
||||||
return Object.entries(config.platforms)
|
|
||||||
.filter(([_, info]) => !info.installer)
|
|
||||||
.map(([code, _]) => code);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the cached platform codes (useful for testing)
|
|
||||||
*/
|
|
||||||
function clearCache() {
|
|
||||||
_cachedPlatformCodes = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
loadPlatformCodes,
|
|
||||||
getPlatformInfo,
|
|
||||||
getPreferredPlatforms,
|
|
||||||
getPlatformsByCategory,
|
|
||||||
getConfigDrivenPlatforms,
|
|
||||||
getCustomInstallerPlatforms,
|
|
||||||
clearCache,
|
|
||||||
};
|
|
||||||
|
|
@ -1,341 +0,0 @@
|
||||||
# BMAD Platform Codes Configuration
|
|
||||||
# Central configuration for all platform/IDE codes used in the BMAD system
|
|
||||||
#
|
|
||||||
# This file defines:
|
|
||||||
# 1. Platform metadata (name, preferred status, category, description)
|
|
||||||
# 2. Installer configuration (target directories, templates, artifact types)
|
|
||||||
#
|
|
||||||
# Format:
|
|
||||||
# code: Platform identifier used internally
|
|
||||||
# name: Display name shown to users
|
|
||||||
# preferred: Whether this platform is shown as a recommended option on install
|
|
||||||
# category: Type of platform (ide, cli, tool, service)
|
|
||||||
# description: Brief description of the platform
|
|
||||||
# installer: Installation configuration (optional - omit for custom installers)
|
|
||||||
|
|
||||||
platforms:
|
|
||||||
antigravity:
|
|
||||||
name: "Google Antigravity"
|
|
||||||
preferred: false
|
|
||||||
category: ide
|
|
||||||
description: "Google's AI development environment"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .agent/workflows
|
|
||||||
target_dir: .agent/skills
|
|
||||||
template_type: antigravity
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
auggie:
|
|
||||||
name: "Auggie"
|
|
||||||
preferred: false
|
|
||||||
category: cli
|
|
||||||
description: "AI development tool"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .augment/commands
|
|
||||||
target_dir: .augment/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
claude-code:
|
|
||||||
name: "Claude Code"
|
|
||||||
preferred: true
|
|
||||||
category: cli
|
|
||||||
description: "Anthropic's official CLI for Claude"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .claude/commands
|
|
||||||
target_dir: .claude/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
ancestor_conflict_check: true
|
|
||||||
|
|
||||||
cline:
|
|
||||||
name: "Cline"
|
|
||||||
preferred: false
|
|
||||||
category: ide
|
|
||||||
description: "AI coding assistant"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .clinerules/workflows
|
|
||||||
target_dir: .cline/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
codex:
|
|
||||||
name: "Codex"
|
|
||||||
preferred: false
|
|
||||||
category: cli
|
|
||||||
description: "OpenAI Codex integration"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .codex/prompts
|
|
||||||
- ~/.codex/prompts
|
|
||||||
target_dir: .agents/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
ancestor_conflict_check: true
|
|
||||||
artifact_types: [agents, workflows, tasks]
|
|
||||||
|
|
||||||
codebuddy:
|
|
||||||
name: "CodeBuddy"
|
|
||||||
preferred: false
|
|
||||||
category: ide
|
|
||||||
description: "Tencent Cloud Code Assistant - AI-powered coding companion"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .codebuddy/commands
|
|
||||||
target_dir: .codebuddy/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
crush:
|
|
||||||
name: "Crush"
|
|
||||||
preferred: false
|
|
||||||
category: ide
|
|
||||||
description: "AI development assistant"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .crush/commands
|
|
||||||
target_dir: .crush/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
cursor:
|
|
||||||
name: "Cursor"
|
|
||||||
preferred: true
|
|
||||||
category: ide
|
|
||||||
description: "AI-first code editor"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .cursor/commands
|
|
||||||
target_dir: .cursor/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
gemini:
|
|
||||||
name: "Gemini CLI"
|
|
||||||
preferred: false
|
|
||||||
category: cli
|
|
||||||
description: "Google's CLI for Gemini"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .gemini/commands
|
|
||||||
target_dir: .gemini/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
github-copilot:
|
|
||||||
name: "GitHub Copilot"
|
|
||||||
preferred: false
|
|
||||||
category: ide
|
|
||||||
description: "GitHub's AI pair programmer"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .github/agents
|
|
||||||
- .github/prompts
|
|
||||||
target_dir: .github/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
iflow:
|
|
||||||
name: "iFlow"
|
|
||||||
preferred: false
|
|
||||||
category: ide
|
|
||||||
description: "AI workflow automation"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .iflow/commands
|
|
||||||
target_dir: .iflow/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
kilo:
|
|
||||||
name: "KiloCoder"
|
|
||||||
preferred: false
|
|
||||||
category: ide
|
|
||||||
description: "AI coding platform"
|
|
||||||
suspended: "Kilo Code does not yet support the Agent Skills standard. Support is paused until they implement it. See https://github.com/kilocode/kilo-code/issues for updates."
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .kilocode/workflows
|
|
||||||
target_dir: .kilocode/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
kiro:
|
|
||||||
name: "Kiro"
|
|
||||||
preferred: false
|
|
||||||
category: ide
|
|
||||||
description: "Amazon's AI-powered IDE"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .kiro/steering
|
|
||||||
target_dir: .kiro/skills
|
|
||||||
template_type: kiro
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
ona:
|
|
||||||
name: "Ona"
|
|
||||||
preferred: false
|
|
||||||
category: ide
|
|
||||||
description: "Ona AI development environment"
|
|
||||||
installer:
|
|
||||||
target_dir: .ona/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
opencode:
|
|
||||||
name: "OpenCode"
|
|
||||||
preferred: false
|
|
||||||
category: ide
|
|
||||||
description: "OpenCode terminal coding assistant"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .opencode/agents
|
|
||||||
- .opencode/commands
|
|
||||||
- .opencode/agent
|
|
||||||
- .opencode/command
|
|
||||||
target_dir: .opencode/skills
|
|
||||||
template_type: opencode
|
|
||||||
skill_format: true
|
|
||||||
ancestor_conflict_check: true
|
|
||||||
|
|
||||||
pi:
|
|
||||||
name: "Pi"
|
|
||||||
preferred: false
|
|
||||||
category: cli
|
|
||||||
description: "Provider-agnostic terminal-native AI coding agent"
|
|
||||||
installer:
|
|
||||||
target_dir: .pi/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
qoder:
|
|
||||||
name: "Qoder"
|
|
||||||
preferred: false
|
|
||||||
category: ide
|
|
||||||
description: "Qoder AI coding assistant"
|
|
||||||
installer:
|
|
||||||
target_dir: .qoder/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
qwen:
|
|
||||||
name: "QwenCoder"
|
|
||||||
preferred: false
|
|
||||||
category: ide
|
|
||||||
description: "Qwen AI coding assistant"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .qwen/commands
|
|
||||||
target_dir: .qwen/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
roo:
|
|
||||||
name: "Roo Code"
|
|
||||||
preferred: false
|
|
||||||
category: ide
|
|
||||||
description: "Enhanced Cline fork"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .roo/commands
|
|
||||||
target_dir: .roo/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
rovo-dev:
|
|
||||||
name: "Rovo Dev"
|
|
||||||
preferred: false
|
|
||||||
category: ide
|
|
||||||
description: "Atlassian's Rovo development environment"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .rovodev/workflows
|
|
||||||
target_dir: .rovodev/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
trae:
|
|
||||||
name: "Trae"
|
|
||||||
preferred: false
|
|
||||||
category: ide
|
|
||||||
description: "AI coding tool"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .trae/rules
|
|
||||||
target_dir: .trae/skills
|
|
||||||
template_type: default
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
windsurf:
|
|
||||||
name: "Windsurf"
|
|
||||||
preferred: false
|
|
||||||
category: ide
|
|
||||||
description: "AI-powered IDE with cascade flows"
|
|
||||||
installer:
|
|
||||||
legacy_targets:
|
|
||||||
- .windsurf/workflows
|
|
||||||
target_dir: .windsurf/skills
|
|
||||||
template_type: windsurf
|
|
||||||
skill_format: true
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Installer Config Schema
|
|
||||||
# ============================================================================
|
|
||||||
#
|
|
||||||
# installer:
|
|
||||||
# target_dir: string # Directory where artifacts are installed
|
|
||||||
# template_type: string # Default template type to use
|
|
||||||
# header_template: string (optional) # Override for header/frontmatter template
|
|
||||||
# body_template: string (optional) # Override for body/content template
|
|
||||||
# legacy_targets: array (optional) # Old target dirs to clean up on reinstall (migration)
|
|
||||||
# - string # Relative path, e.g. .opencode/agent
|
|
||||||
# targets: array (optional) # For multi-target installations
|
|
||||||
# - target_dir: string
|
|
||||||
# template_type: string
|
|
||||||
# artifact_types: [agents, workflows, tasks, tools]
|
|
||||||
# artifact_types: array (optional) # Filter which artifacts to install (default: all)
|
|
||||||
# skip_existing: boolean (optional) # Skip files that already exist (default: false)
|
|
||||||
# skill_format: boolean (optional) # Use directory-per-skill output: <name>/SKILL.md
|
|
||||||
# # with clean frontmatter (name + description, unquoted)
|
|
||||||
# ancestor_conflict_check: boolean (optional) # Refuse install when ancestor dir has BMAD files
|
|
||||||
# # in the same target_dir (for IDEs that inherit
|
|
||||||
# # skills from parent directories)
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Platform Categories
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
categories:
|
|
||||||
ide:
|
|
||||||
name: "Integrated Development Environment"
|
|
||||||
description: "Full-featured code editors with AI assistance"
|
|
||||||
|
|
||||||
cli:
|
|
||||||
name: "Command Line Interface"
|
|
||||||
description: "Terminal-based tools"
|
|
||||||
|
|
||||||
tool:
|
|
||||||
name: "Development Tool"
|
|
||||||
description: "Standalone development utilities"
|
|
||||||
|
|
||||||
service:
|
|
||||||
name: "Cloud Service"
|
|
||||||
description: "Cloud-based development platforms"
|
|
||||||
|
|
||||||
extension:
|
|
||||||
name: "Editor Extension"
|
|
||||||
description: "Plugins for existing editors"
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Naming Conventions and Rules
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
conventions:
|
|
||||||
code_format: "lowercase-kebab-case"
|
|
||||||
name_format: "Title Case"
|
|
||||||
max_code_length: 20
|
|
||||||
allowed_characters: "a-z0-9-"
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
default-workflow-yaml.md
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
const fs = require('fs-extra');
|
|
||||||
const path = require('node:path');
|
|
||||||
const yaml = require('yaml');
|
|
||||||
const prompts = require('../../../lib/prompts');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages external official modules defined in external-official-modules.yaml
|
|
||||||
* These are modules hosted in external repositories that can be installed
|
|
||||||
*
|
|
||||||
* @class ExternalModuleManager
|
|
||||||
*/
|
|
||||||
class ExternalModuleManager {
|
|
||||||
constructor() {
|
|
||||||
this.externalModulesConfigPath = path.join(__dirname, '../../../external-official-modules.yaml');
|
|
||||||
this.cachedModules = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and parse the external-official-modules.yaml file
|
|
||||||
* @returns {Object} Parsed YAML content with modules object
|
|
||||||
*/
|
|
||||||
async loadExternalModulesConfig() {
|
|
||||||
if (this.cachedModules) {
|
|
||||||
return this.cachedModules;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = await fs.readFile(this.externalModulesConfigPath, 'utf8');
|
|
||||||
const config = yaml.parse(content);
|
|
||||||
this.cachedModules = config;
|
|
||||||
return config;
|
|
||||||
} catch (error) {
|
|
||||||
await prompts.log.warn(`Failed to load external modules config: ${error.message}`);
|
|
||||||
return { modules: {} };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of available external modules
|
|
||||||
* @returns {Array<Object>} Array of module info objects
|
|
||||||
*/
|
|
||||||
async listAvailable() {
|
|
||||||
const config = await this.loadExternalModulesConfig();
|
|
||||||
const modules = [];
|
|
||||||
|
|
||||||
for (const [key, moduleConfig] of Object.entries(config.modules || {})) {
|
|
||||||
modules.push({
|
|
||||||
key,
|
|
||||||
url: moduleConfig.url,
|
|
||||||
moduleDefinition: moduleConfig['module-definition'],
|
|
||||||
code: moduleConfig.code,
|
|
||||||
name: moduleConfig.name,
|
|
||||||
header: moduleConfig.header,
|
|
||||||
subheader: moduleConfig.subheader,
|
|
||||||
description: moduleConfig.description || '',
|
|
||||||
defaultSelected: moduleConfig.defaultSelected === true,
|
|
||||||
type: moduleConfig.type || 'community', // bmad-org or community
|
|
||||||
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
|
|
||||||
isExternal: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return modules;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get module info by code
|
|
||||||
* @param {string} code - The module code (e.g., 'cis')
|
|
||||||
* @returns {Object|null} Module info or null if not found
|
|
||||||
*/
|
|
||||||
async getModuleByCode(code) {
|
|
||||||
const modules = await this.listAvailable();
|
|
||||||
return modules.find((m) => m.code === code) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get module info by key
|
|
||||||
* @param {string} key - The module key (e.g., 'bmad-creative-intelligence-suite')
|
|
||||||
* @returns {Object|null} Module info or null if not found
|
|
||||||
*/
|
|
||||||
async getModuleByKey(key) {
|
|
||||||
const config = await this.loadExternalModulesConfig();
|
|
||||||
const moduleConfig = config.modules?.[key];
|
|
||||||
|
|
||||||
if (!moduleConfig) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
url: moduleConfig.url,
|
|
||||||
moduleDefinition: moduleConfig['module-definition'],
|
|
||||||
code: moduleConfig.code,
|
|
||||||
name: moduleConfig.name,
|
|
||||||
header: moduleConfig.header,
|
|
||||||
subheader: moduleConfig.subheader,
|
|
||||||
description: moduleConfig.description || '',
|
|
||||||
defaultSelected: moduleConfig.defaultSelected === true,
|
|
||||||
type: moduleConfig.type || 'community', // bmad-org or community
|
|
||||||
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
|
|
||||||
isExternal: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a module code exists in external modules
|
|
||||||
* @param {string} code - The module code to check
|
|
||||||
* @returns {boolean} True if the module exists
|
|
||||||
*/
|
|
||||||
async hasModule(code) {
|
|
||||||
const module = await this.getModuleByCode(code);
|
|
||||||
return module !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the URL for a module by code
|
|
||||||
* @param {string} code - The module code
|
|
||||||
* @returns {string|null} The URL or null if not found
|
|
||||||
*/
|
|
||||||
async getModuleUrl(code) {
|
|
||||||
const module = await this.getModuleByCode(code);
|
|
||||||
return module ? module.url : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the module definition path for a module by code
|
|
||||||
* @param {string} code - The module code
|
|
||||||
* @returns {string|null} The module definition path or null if not found
|
|
||||||
*/
|
|
||||||
async getModuleDefinition(code) {
|
|
||||||
const module = await this.getModuleByCode(code);
|
|
||||||
return module ? module.moduleDefinition : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { ExternalModuleManager };
|
|
||||||
|
|
@ -1,928 +0,0 @@
|
||||||
const path = require('node:path');
|
|
||||||
const fs = require('fs-extra');
|
|
||||||
const yaml = require('yaml');
|
|
||||||
const prompts = require('../../../lib/prompts');
|
|
||||||
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
|
|
||||||
const { ExternalModuleManager } = require('./external-manager');
|
|
||||||
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages the installation, updating, and removal of BMAD modules.
|
|
||||||
* Handles module discovery, dependency resolution, and configuration processing.
|
|
||||||
*
|
|
||||||
* @class ModuleManager
|
|
||||||
* @requires fs-extra
|
|
||||||
* @requires yaml
|
|
||||||
* @requires prompts
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const manager = new ModuleManager();
|
|
||||||
* const modules = await manager.listAvailable();
|
|
||||||
* await manager.install('core-module', '/path/to/bmad');
|
|
||||||
*/
|
|
||||||
class ModuleManager {
|
|
||||||
constructor(options = {}) {
|
|
||||||
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
|
|
||||||
this.customModulePaths = new Map(); // Initialize custom module paths
|
|
||||||
this.externalModuleManager = new ExternalModuleManager(); // For external official modules
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the bmad folder name for placeholder replacement
|
|
||||||
* @param {string} bmadFolderName - The bmad folder name
|
|
||||||
*/
|
|
||||||
setBmadFolderName(bmadFolderName) {
|
|
||||||
this.bmadFolderName = bmadFolderName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the core configuration for access during module installation
|
|
||||||
* @param {Object} coreConfig - Core configuration object
|
|
||||||
*/
|
|
||||||
setCoreConfig(coreConfig) {
|
|
||||||
this.coreConfig = coreConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set custom module paths for priority lookup
|
|
||||||
* @param {Map<string, string>} customModulePaths - Map of module ID to source path
|
|
||||||
*/
|
|
||||||
setCustomModulePaths(customModulePaths) {
|
|
||||||
this.customModulePaths = customModulePaths;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy a file to the target location
|
|
||||||
* @param {string} sourcePath - Source file path
|
|
||||||
* @param {string} targetPath - Target file path
|
|
||||||
* @param {boolean} overwrite - Whether to overwrite existing files (default: true)
|
|
||||||
*/
|
|
||||||
async copyFileWithPlaceholderReplacement(sourcePath, targetPath, overwrite = true) {
|
|
||||||
await fs.copy(sourcePath, targetPath, { overwrite });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy a directory recursively
|
|
||||||
* @param {string} sourceDir - Source directory path
|
|
||||||
* @param {string} targetDir - Target directory path
|
|
||||||
* @param {boolean} overwrite - Whether to overwrite existing files (default: true)
|
|
||||||
*/
|
|
||||||
async copyDirectoryWithPlaceholderReplacement(sourceDir, targetDir, overwrite = true) {
|
|
||||||
await fs.ensureDir(targetDir);
|
|
||||||
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const sourcePath = path.join(sourceDir, entry.name);
|
|
||||||
const targetPath = path.join(targetDir, entry.name);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
await this.copyDirectoryWithPlaceholderReplacement(sourcePath, targetPath, overwrite);
|
|
||||||
} else {
|
|
||||||
await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, overwrite);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all available modules (excluding core which is always installed)
|
|
||||||
* bmm is the only built-in module, directly under src/bmm-skills
|
|
||||||
* All other modules come from external-official-modules.yaml
|
|
||||||
* @returns {Object} Object with modules array and customModules array
|
|
||||||
*/
|
|
||||||
async listAvailable() {
|
|
||||||
const modules = [];
|
|
||||||
const customModules = [];
|
|
||||||
|
|
||||||
// Add built-in bmm module (directly under src/bmm-skills)
|
|
||||||
const bmmPath = getSourcePath('bmm-skills');
|
|
||||||
if (await fs.pathExists(bmmPath)) {
|
|
||||||
const bmmInfo = await this.getModuleInfo(bmmPath, 'bmm', 'src/bmm-skills');
|
|
||||||
if (bmmInfo) {
|
|
||||||
modules.push(bmmInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for cached custom modules in _config/custom/
|
|
||||||
if (this.bmadDir) {
|
|
||||||
const customCacheDir = path.join(this.bmadDir, '_config', 'custom');
|
|
||||||
if (await fs.pathExists(customCacheDir)) {
|
|
||||||
const cacheEntries = await fs.readdir(customCacheDir, { withFileTypes: true });
|
|
||||||
for (const entry of cacheEntries) {
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
const cachePath = path.join(customCacheDir, entry.name);
|
|
||||||
const moduleInfo = await this.getModuleInfo(cachePath, entry.name, '_config/custom');
|
|
||||||
if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id) && !customModules.some((m) => m.id === moduleInfo.id)) {
|
|
||||||
moduleInfo.isCustom = true;
|
|
||||||
moduleInfo.fromCache = true;
|
|
||||||
customModules.push(moduleInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { modules, customModules };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get module information from a module path
|
|
||||||
* @param {string} modulePath - Path to the module directory
|
|
||||||
* @param {string} defaultName - Default name for the module
|
|
||||||
* @param {string} sourceDescription - Description of where the module was found
|
|
||||||
* @returns {Object|null} Module info or null if not a valid module
|
|
||||||
*/
|
|
||||||
async getModuleInfo(modulePath, defaultName, sourceDescription) {
|
|
||||||
// Check for module structure (module.yaml OR custom.yaml)
|
|
||||||
const moduleConfigPath = path.join(modulePath, 'module.yaml');
|
|
||||||
const rootCustomConfigPath = path.join(modulePath, 'custom.yaml');
|
|
||||||
let configPath = null;
|
|
||||||
|
|
||||||
if (await fs.pathExists(moduleConfigPath)) {
|
|
||||||
configPath = moduleConfigPath;
|
|
||||||
} else if (await fs.pathExists(rootCustomConfigPath)) {
|
|
||||||
configPath = rootCustomConfigPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if this doesn't look like a module
|
|
||||||
if (!configPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as custom if it's using custom.yaml OR if it's outside src/bmm or src/core
|
|
||||||
const isCustomSource =
|
|
||||||
sourceDescription !== 'src/bmm-skills' && sourceDescription !== 'src/core-skills' && sourceDescription !== 'src/modules';
|
|
||||||
const moduleInfo = {
|
|
||||||
id: defaultName,
|
|
||||||
path: modulePath,
|
|
||||||
name: defaultName
|
|
||||||
.split('-')
|
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
||||||
.join(' '),
|
|
||||||
description: 'BMAD Module',
|
|
||||||
version: '5.0.0',
|
|
||||||
source: sourceDescription,
|
|
||||||
isCustom: configPath === rootCustomConfigPath || isCustomSource,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Read module config for metadata
|
|
||||||
try {
|
|
||||||
const configContent = await fs.readFile(configPath, 'utf8');
|
|
||||||
const config = yaml.parse(configContent);
|
|
||||||
|
|
||||||
// Use the code property as the id if available
|
|
||||||
if (config.code) {
|
|
||||||
moduleInfo.id = config.code;
|
|
||||||
}
|
|
||||||
|
|
||||||
moduleInfo.name = config.name || moduleInfo.name;
|
|
||||||
moduleInfo.description = config.description || moduleInfo.description;
|
|
||||||
moduleInfo.version = config.version || moduleInfo.version;
|
|
||||||
moduleInfo.dependencies = config.dependencies || [];
|
|
||||||
moduleInfo.defaultSelected = config.default_selected === undefined ? false : config.default_selected;
|
|
||||||
} catch (error) {
|
|
||||||
await prompts.log.warn(`Failed to read config for ${defaultName}: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return moduleInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the source path for a module by searching all possible locations
|
|
||||||
* @param {string} moduleCode - Code of the module to find (from module.yaml)
|
|
||||||
* @returns {string|null} Path to the module source or null if not found
|
|
||||||
*/
|
|
||||||
async findModuleSource(moduleCode, options = {}) {
|
|
||||||
const projectRoot = getProjectRoot();
|
|
||||||
|
|
||||||
// First check custom module paths if they exist
|
|
||||||
if (this.customModulePaths && this.customModulePaths.has(moduleCode)) {
|
|
||||||
return this.customModulePaths.get(moduleCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for built-in bmm module (directly under src/bmm-skills)
|
|
||||||
if (moduleCode === 'bmm') {
|
|
||||||
const bmmPath = getSourcePath('bmm-skills');
|
|
||||||
if (await fs.pathExists(bmmPath)) {
|
|
||||||
return bmmPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check external official modules
|
|
||||||
const externalSource = await this.findExternalModuleSource(moduleCode, options);
|
|
||||||
if (externalSource) {
|
|
||||||
return externalSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a module is an external official module
|
|
||||||
* @param {string} moduleCode - Code of the module to check
|
|
||||||
* @returns {boolean} True if the module is external
|
|
||||||
*/
|
|
||||||
async isExternalModule(moduleCode) {
|
|
||||||
return await this.externalModuleManager.hasModule(moduleCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the cache directory for external modules
|
|
||||||
* @returns {string} Path to the external modules cache directory
|
|
||||||
*/
|
|
||||||
getExternalCacheDir() {
|
|
||||||
const os = require('node:os');
|
|
||||||
const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules');
|
|
||||||
return cacheDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clone an external module repository to cache
|
|
||||||
* @param {string} moduleCode - Code of the external module
|
|
||||||
* @returns {string} Path to the cloned repository
|
|
||||||
*/
|
|
||||||
async cloneExternalModule(moduleCode, options = {}) {
|
|
||||||
const { execSync } = require('node:child_process');
|
|
||||||
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleCode);
|
|
||||||
|
|
||||||
if (!moduleInfo) {
|
|
||||||
throw new Error(`External module '${moduleCode}' not found in external-official-modules.yaml`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheDir = this.getExternalCacheDir();
|
|
||||||
const moduleCacheDir = path.join(cacheDir, moduleCode);
|
|
||||||
const silent = options.silent || false;
|
|
||||||
|
|
||||||
// Create cache directory if it doesn't exist
|
|
||||||
await fs.ensureDir(cacheDir);
|
|
||||||
|
|
||||||
// Helper to create a spinner or a no-op when silent
|
|
||||||
const createSpinner = async () => {
|
|
||||||
if (silent) {
|
|
||||||
return {
|
|
||||||
start() {},
|
|
||||||
stop() {},
|
|
||||||
error() {},
|
|
||||||
message() {},
|
|
||||||
cancel() {},
|
|
||||||
clear() {},
|
|
||||||
get isSpinning() {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
get isCancelled() {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return await prompts.spinner();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track if we need to install dependencies
|
|
||||||
let needsDependencyInstall = false;
|
|
||||||
let wasNewClone = false;
|
|
||||||
|
|
||||||
// Check if already cloned
|
|
||||||
if (await fs.pathExists(moduleCacheDir)) {
|
|
||||||
// Try to update if it's a git repo
|
|
||||||
const fetchSpinner = await createSpinner();
|
|
||||||
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
|
|
||||||
try {
|
|
||||||
const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
|
||||||
// Fetch and reset to remote - works better with shallow clones than pull
|
|
||||||
execSync('git fetch origin --depth 1', {
|
|
||||||
cwd: moduleCacheDir,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
||||||
});
|
|
||||||
execSync('git reset --hard origin/HEAD', {
|
|
||||||
cwd: moduleCacheDir,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
||||||
});
|
|
||||||
const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
|
||||||
|
|
||||||
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
|
|
||||||
// Force dependency install if we got new code
|
|
||||||
if (currentRef !== newRef) {
|
|
||||||
needsDependencyInstall = true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.name}`);
|
|
||||||
// If update fails, remove and re-clone
|
|
||||||
await fs.remove(moduleCacheDir);
|
|
||||||
wasNewClone = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
wasNewClone = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone if not exists or was removed
|
|
||||||
if (wasNewClone) {
|
|
||||||
const fetchSpinner = await createSpinner();
|
|
||||||
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
|
|
||||||
try {
|
|
||||||
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
||||||
});
|
|
||||||
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
|
|
||||||
} catch (error) {
|
|
||||||
fetchSpinner.error(`Failed to fetch ${moduleInfo.name}`);
|
|
||||||
throw new Error(`Failed to clone external module '${moduleCode}': ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install dependencies if package.json exists
|
|
||||||
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
|
|
||||||
const nodeModulesPath = path.join(moduleCacheDir, 'node_modules');
|
|
||||||
if (await fs.pathExists(packageJsonPath)) {
|
|
||||||
// Install if node_modules doesn't exist, or if package.json is newer (dependencies changed)
|
|
||||||
const nodeModulesMissing = !(await fs.pathExists(nodeModulesPath));
|
|
||||||
|
|
||||||
// Force install if we updated or cloned new
|
|
||||||
if (needsDependencyInstall || wasNewClone || nodeModulesMissing) {
|
|
||||||
const installSpinner = await createSpinner();
|
|
||||||
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
|
|
||||||
try {
|
|
||||||
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
|
||||||
cwd: moduleCacheDir,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
timeout: 120_000, // 2 minute timeout
|
|
||||||
});
|
|
||||||
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
|
|
||||||
} catch (error) {
|
|
||||||
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
|
|
||||||
if (!silent) await prompts.log.warn(` ${error.message}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Check if package.json is newer than node_modules
|
|
||||||
let packageJsonNewer = false;
|
|
||||||
try {
|
|
||||||
const packageStats = await fs.stat(packageJsonPath);
|
|
||||||
const nodeModulesStats = await fs.stat(nodeModulesPath);
|
|
||||||
packageJsonNewer = packageStats.mtime > nodeModulesStats.mtime;
|
|
||||||
} catch {
|
|
||||||
// If stat fails, assume we need to install
|
|
||||||
packageJsonNewer = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (packageJsonNewer) {
|
|
||||||
const installSpinner = await createSpinner();
|
|
||||||
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
|
|
||||||
try {
|
|
||||||
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
|
||||||
cwd: moduleCacheDir,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
timeout: 120_000, // 2 minute timeout
|
|
||||||
});
|
|
||||||
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
|
|
||||||
} catch (error) {
|
|
||||||
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
|
|
||||||
if (!silent) await prompts.log.warn(` ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return moduleCacheDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the source path for an external module
|
|
||||||
* @param {string} moduleCode - Code of the external module
|
|
||||||
* @returns {string|null} Path to the module source or null if not found
|
|
||||||
*/
|
|
||||||
async findExternalModuleSource(moduleCode, options = {}) {
|
|
||||||
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleCode);
|
|
||||||
|
|
||||||
if (!moduleInfo) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone the external module repo
|
|
||||||
const cloneDir = await this.cloneExternalModule(moduleCode, options);
|
|
||||||
|
|
||||||
// The module-definition specifies the path to module.yaml relative to repo root
|
|
||||||
// We need to return the directory containing module.yaml
|
|
||||||
const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'src/module.yaml'
|
|
||||||
const moduleDir = path.dirname(path.join(cloneDir, moduleDefinitionPath));
|
|
||||||
|
|
||||||
return moduleDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install a module
|
|
||||||
* @param {string} moduleName - Code of the module to install (from module.yaml)
|
|
||||||
* @param {string} bmadDir - Target bmad directory
|
|
||||||
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
|
||||||
* @param {Object} options - Additional installation options
|
|
||||||
* @param {Array<string>} options.installedIDEs - Array of IDE codes that were installed
|
|
||||||
* @param {Object} options.moduleConfig - Module configuration from config collector
|
|
||||||
* @param {Object} options.logger - Logger instance for output
|
|
||||||
*/
|
|
||||||
async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
|
|
||||||
const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent });
|
|
||||||
const targetPath = path.join(bmadDir, moduleName);
|
|
||||||
|
|
||||||
// Check if source module exists
|
|
||||||
if (!sourcePath) {
|
|
||||||
// Provide a more user-friendly error message
|
|
||||||
throw new Error(
|
|
||||||
`Source for module '${moduleName}' is not available. It will be retained but cannot be updated without its source files.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a custom module and read its custom.yaml values
|
|
||||||
let customConfig = null;
|
|
||||||
const rootCustomConfigPath = path.join(sourcePath, 'custom.yaml');
|
|
||||||
|
|
||||||
if (await fs.pathExists(rootCustomConfigPath)) {
|
|
||||||
try {
|
|
||||||
const customContent = await fs.readFile(rootCustomConfigPath, 'utf8');
|
|
||||||
customConfig = yaml.parse(customContent);
|
|
||||||
} catch (error) {
|
|
||||||
await prompts.log.warn(`Failed to read custom.yaml for ${moduleName}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this is a custom module, merge its values into the module config
|
|
||||||
if (customConfig) {
|
|
||||||
options.moduleConfig = { ...options.moduleConfig, ...customConfig };
|
|
||||||
if (options.logger) {
|
|
||||||
await options.logger.log(` Merged custom configuration for ${moduleName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already installed
|
|
||||||
if (await fs.pathExists(targetPath)) {
|
|
||||||
await fs.remove(targetPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy module files with filtering
|
|
||||||
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
|
|
||||||
|
|
||||||
// Create directories declared in module.yaml (unless explicitly skipped)
|
|
||||||
if (!options.skipModuleInstaller) {
|
|
||||||
await this.createModuleDirectories(moduleName, bmadDir, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture version info for manifest
|
|
||||||
const { Manifest } = require('../core/manifest');
|
|
||||||
const manifestObj = new Manifest();
|
|
||||||
const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath);
|
|
||||||
|
|
||||||
await manifestObj.addModule(bmadDir, moduleName, {
|
|
||||||
version: versionInfo.version,
|
|
||||||
source: versionInfo.source,
|
|
||||||
npmPackage: versionInfo.npmPackage,
|
|
||||||
repoUrl: versionInfo.repoUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
module: moduleName,
|
|
||||||
path: targetPath,
|
|
||||||
versionInfo,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing module
|
|
||||||
* @param {string} moduleName - Name of the module to update
|
|
||||||
* @param {string} bmadDir - Target bmad directory
|
|
||||||
* @param {boolean} force - Force update (overwrite modifications)
|
|
||||||
*/
|
|
||||||
async update(moduleName, bmadDir, force = false, options = {}) {
|
|
||||||
const sourcePath = await this.findModuleSource(moduleName);
|
|
||||||
const targetPath = path.join(bmadDir, moduleName);
|
|
||||||
|
|
||||||
// Check if source module exists
|
|
||||||
if (!sourcePath) {
|
|
||||||
throw new Error(`Module '${moduleName}' not found in any source location`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if module is installed
|
|
||||||
if (!(await fs.pathExists(targetPath))) {
|
|
||||||
throw new Error(`Module '${moduleName}' is not installed`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (force) {
|
|
||||||
// Force update - remove and reinstall
|
|
||||||
await fs.remove(targetPath);
|
|
||||||
return await this.install(moduleName, bmadDir, null, { installer: options.installer });
|
|
||||||
} else {
|
|
||||||
// Selective update - preserve user modifications
|
|
||||||
await this.syncModule(sourcePath, targetPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
module: moduleName,
|
|
||||||
path: targetPath,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a module
|
|
||||||
* @param {string} moduleName - Name of the module to remove
|
|
||||||
* @param {string} bmadDir - Target bmad directory
|
|
||||||
*/
|
|
||||||
async remove(moduleName, bmadDir) {
|
|
||||||
const targetPath = path.join(bmadDir, moduleName);
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(targetPath))) {
|
|
||||||
throw new Error(`Module '${moduleName}' is not installed`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.remove(targetPath);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
module: moduleName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a module is installed
|
|
||||||
* @param {string} moduleName - Name of the module
|
|
||||||
* @param {string} bmadDir - Target bmad directory
|
|
||||||
* @returns {boolean} True if module is installed
|
|
||||||
*/
|
|
||||||
async isInstalled(moduleName, bmadDir) {
|
|
||||||
const targetPath = path.join(bmadDir, moduleName);
|
|
||||||
return await fs.pathExists(targetPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get installed module info
|
|
||||||
* @param {string} moduleName - Name of the module
|
|
||||||
* @param {string} bmadDir - Target bmad directory
|
|
||||||
* @returns {Object|null} Module info or null if not installed
|
|
||||||
*/
|
|
||||||
async getInstalledInfo(moduleName, bmadDir) {
|
|
||||||
const targetPath = path.join(bmadDir, moduleName);
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(targetPath))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const configPath = path.join(targetPath, 'config.yaml');
|
|
||||||
const moduleInfo = {
|
|
||||||
id: moduleName,
|
|
||||||
path: targetPath,
|
|
||||||
installed: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (await fs.pathExists(configPath)) {
|
|
||||||
try {
|
|
||||||
const configContent = await fs.readFile(configPath, 'utf8');
|
|
||||||
const config = yaml.parse(configContent);
|
|
||||||
Object.assign(moduleInfo, config);
|
|
||||||
} catch (error) {
|
|
||||||
await prompts.log.warn(`Failed to read installed module config: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return moduleInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy module with filtering for localskip agents and conditional content
|
|
||||||
* @param {string} sourcePath - Source module path
|
|
||||||
* @param {string} targetPath - Target module path
|
|
||||||
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
|
||||||
* @param {Object} moduleConfig - Module configuration with conditional flags
|
|
||||||
*/
|
|
||||||
async copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback = null, moduleConfig = {}) {
|
|
||||||
// Get all files in source
|
|
||||||
const sourceFiles = await this.getFileList(sourcePath);
|
|
||||||
|
|
||||||
for (const file of sourceFiles) {
|
|
||||||
// Skip sub-modules directory - these are IDE-specific and handled separately
|
|
||||||
if (file.startsWith('sub-modules/')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip sidecar directories - these contain agent-specific assets not needed at install time
|
|
||||||
const isInSidecarDirectory = path
|
|
||||||
.dirname(file)
|
|
||||||
.split('/')
|
|
||||||
.some((dir) => dir.toLowerCase().endsWith('-sidecar'));
|
|
||||||
|
|
||||||
if (isInSidecarDirectory) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip module.yaml at root - it's only needed at install time
|
|
||||||
if (file === 'module.yaml') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip module root config.yaml only - generated by config collector with actual values
|
|
||||||
// Workflow-level config.yaml (e.g. workflows/orchestrate-story/config.yaml) must be copied
|
|
||||||
// for custom modules that use workflow-specific configuration
|
|
||||||
if (file === 'config.yaml') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceFile = path.join(sourcePath, file);
|
|
||||||
const targetFile = path.join(targetPath, file);
|
|
||||||
|
|
||||||
// Check if this is an agent file
|
|
||||||
if (file.startsWith('agents/') && file.endsWith('.md')) {
|
|
||||||
// Read the file to check for localskip
|
|
||||||
const content = await fs.readFile(sourceFile, 'utf8');
|
|
||||||
|
|
||||||
// Check for localskip="true" in the agent tag
|
|
||||||
const agentMatch = content.match(/<agent[^>]*\slocalskip="true"[^>]*>/);
|
|
||||||
if (agentMatch) {
|
|
||||||
await prompts.log.message(` Skipping web-only agent: ${path.basename(file)}`);
|
|
||||||
continue; // Skip this agent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy the file with placeholder replacement
|
|
||||||
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
|
|
||||||
|
|
||||||
// Track the file if callback provided
|
|
||||||
if (fileTrackingCallback) {
|
|
||||||
fileTrackingCallback(targetFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find all .md agent files recursively in a directory
|
|
||||||
* @param {string} dir - Directory to search
|
|
||||||
* @returns {Array} List of .md agent file paths
|
|
||||||
*/
|
|
||||||
async findAgentMdFiles(dir) {
|
|
||||||
const agentFiles = [];
|
|
||||||
|
|
||||||
async function searchDirectory(searchDir) {
|
|
||||||
const entries = await fs.readdir(searchDir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(searchDir, entry.name);
|
|
||||||
|
|
||||||
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
||||||
agentFiles.push(fullPath);
|
|
||||||
} else if (entry.isDirectory()) {
|
|
||||||
await searchDirectory(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await searchDirectory(dir);
|
|
||||||
return agentFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create directories declared in module.yaml's `directories` key
|
|
||||||
* This replaces the security-risky module installer pattern with declarative config
|
|
||||||
* During updates, if a directory path changed, moves the old directory to the new path
|
|
||||||
* @param {string} moduleName - Name of the module
|
|
||||||
* @param {string} bmadDir - Target bmad directory
|
|
||||||
* @param {Object} options - Installation options
|
|
||||||
* @param {Object} options.moduleConfig - Module configuration from config collector
|
|
||||||
* @param {Object} options.existingModuleConfig - Previous module config (for detecting path changes during updates)
|
|
||||||
* @param {Object} options.coreConfig - Core configuration
|
|
||||||
* @returns {Promise<{createdDirs: string[], movedDirs: string[], createdWdsFolders: string[]}>} Created directories info
|
|
||||||
*/
|
|
||||||
async createModuleDirectories(moduleName, bmadDir, options = {}) {
|
|
||||||
const moduleConfig = options.moduleConfig || {};
|
|
||||||
const existingModuleConfig = options.existingModuleConfig || {};
|
|
||||||
const projectRoot = path.dirname(bmadDir);
|
|
||||||
const emptyResult = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
|
|
||||||
|
|
||||||
// Special handling for core module - it's in src/core-skills not src/modules
|
|
||||||
let sourcePath;
|
|
||||||
if (moduleName === 'core') {
|
|
||||||
sourcePath = getSourcePath('core-skills');
|
|
||||||
} else {
|
|
||||||
sourcePath = await this.findModuleSource(moduleName, { silent: true });
|
|
||||||
if (!sourcePath) {
|
|
||||||
return emptyResult; // No source found, skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read module.yaml to find the `directories` key
|
|
||||||
const moduleYamlPath = path.join(sourcePath, 'module.yaml');
|
|
||||||
if (!(await fs.pathExists(moduleYamlPath))) {
|
|
||||||
return emptyResult; // No module.yaml, skip
|
|
||||||
}
|
|
||||||
|
|
||||||
let moduleYaml;
|
|
||||||
try {
|
|
||||||
const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
|
|
||||||
moduleYaml = yaml.parse(yamlContent);
|
|
||||||
} catch {
|
|
||||||
return emptyResult; // Invalid YAML, skip
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!moduleYaml || !moduleYaml.directories) {
|
|
||||||
return emptyResult; // No directories declared, skip
|
|
||||||
}
|
|
||||||
|
|
||||||
const directories = moduleYaml.directories;
|
|
||||||
const wdsFolders = moduleYaml.wds_folders || [];
|
|
||||||
const createdDirs = [];
|
|
||||||
const movedDirs = [];
|
|
||||||
const createdWdsFolders = [];
|
|
||||||
|
|
||||||
for (const dirRef of directories) {
|
|
||||||
// Parse variable reference like "{design_artifacts}"
|
|
||||||
const varMatch = dirRef.match(/^\{([^}]+)\}$/);
|
|
||||||
if (!varMatch) {
|
|
||||||
// Not a variable reference, skip
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const configKey = varMatch[1];
|
|
||||||
const dirValue = moduleConfig[configKey];
|
|
||||||
if (!dirValue || typeof dirValue !== 'string') {
|
|
||||||
continue; // No value or not a string, skip
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip {project-root}/ prefix if present
|
|
||||||
let dirPath = dirValue.replace(/^\{project-root\}\/?/, '');
|
|
||||||
|
|
||||||
// Handle remaining {project-root} anywhere in the path
|
|
||||||
dirPath = dirPath.replaceAll('{project-root}', '');
|
|
||||||
|
|
||||||
// Resolve to absolute path
|
|
||||||
const fullPath = path.join(projectRoot, dirPath);
|
|
||||||
|
|
||||||
// Validate path is within project root (prevent directory traversal)
|
|
||||||
const normalizedPath = path.normalize(fullPath);
|
|
||||||
const normalizedRoot = path.normalize(projectRoot);
|
|
||||||
if (!normalizedPath.startsWith(normalizedRoot + path.sep) && normalizedPath !== normalizedRoot) {
|
|
||||||
const color = await prompts.getColor();
|
|
||||||
await prompts.log.warn(color.yellow(`${configKey} path escapes project root, skipping: ${dirPath}`));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if directory path changed from previous config (update/modify scenario)
|
|
||||||
const oldDirValue = existingModuleConfig[configKey];
|
|
||||||
let oldFullPath = null;
|
|
||||||
let oldDirPath = null;
|
|
||||||
if (oldDirValue && typeof oldDirValue === 'string') {
|
|
||||||
// F3: Normalize both values before comparing to avoid false negatives
|
|
||||||
// from trailing slashes, separator differences, or prefix format variations
|
|
||||||
let normalizedOld = oldDirValue.replace(/^\{project-root\}\/?/, '');
|
|
||||||
normalizedOld = path.normalize(normalizedOld.replaceAll('{project-root}', ''));
|
|
||||||
const normalizedNew = path.normalize(dirPath);
|
|
||||||
|
|
||||||
if (normalizedOld !== normalizedNew) {
|
|
||||||
oldDirPath = normalizedOld;
|
|
||||||
oldFullPath = path.join(projectRoot, oldDirPath);
|
|
||||||
const normalizedOldAbsolute = path.normalize(oldFullPath);
|
|
||||||
if (!normalizedOldAbsolute.startsWith(normalizedRoot + path.sep) && normalizedOldAbsolute !== normalizedRoot) {
|
|
||||||
oldFullPath = null; // Old path escapes project root, ignore it
|
|
||||||
}
|
|
||||||
|
|
||||||
// F13: Prevent parent/child move (e.g. docs/planning → docs/planning/v2)
|
|
||||||
if (oldFullPath) {
|
|
||||||
const normalizedNewAbsolute = path.normalize(fullPath);
|
|
||||||
if (
|
|
||||||
normalizedOldAbsolute.startsWith(normalizedNewAbsolute + path.sep) ||
|
|
||||||
normalizedNewAbsolute.startsWith(normalizedOldAbsolute + path.sep)
|
|
||||||
) {
|
|
||||||
const color = await prompts.getColor();
|
|
||||||
await prompts.log.warn(
|
|
||||||
color.yellow(
|
|
||||||
`${configKey}: cannot move between parent/child paths (${oldDirPath} / ${dirPath}), creating new directory instead`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
oldFullPath = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dirName = configKey.replaceAll('_', ' ');
|
|
||||||
|
|
||||||
if (oldFullPath && (await fs.pathExists(oldFullPath)) && !(await fs.pathExists(fullPath))) {
|
|
||||||
// Path changed and old dir exists → move old to new location
|
|
||||||
// F1: Use fs.move() instead of fs.rename() for cross-device/volume support
|
|
||||||
// F2: Wrap in try/catch — fallback to creating new dir on failure
|
|
||||||
try {
|
|
||||||
await fs.ensureDir(path.dirname(fullPath));
|
|
||||||
await fs.move(oldFullPath, fullPath);
|
|
||||||
movedDirs.push(`${dirName}: ${oldDirPath} → ${dirPath}`);
|
|
||||||
} catch (moveError) {
|
|
||||||
const color = await prompts.getColor();
|
|
||||||
await prompts.log.warn(
|
|
||||||
color.yellow(
|
|
||||||
`Failed to move ${oldDirPath} → ${dirPath}: ${moveError.message}\n Creating new directory instead. Please move contents from the old directory manually.`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await fs.ensureDir(fullPath);
|
|
||||||
createdDirs.push(`${dirName}: ${dirPath}`);
|
|
||||||
}
|
|
||||||
} else if (oldFullPath && (await fs.pathExists(oldFullPath)) && (await fs.pathExists(fullPath))) {
|
|
||||||
// F5: Both old and new directories exist — warn user about potential orphaned documents
|
|
||||||
const color = await prompts.getColor();
|
|
||||||
await prompts.log.warn(
|
|
||||||
color.yellow(
|
|
||||||
`${dirName}: path changed but both directories exist:\n Old: ${oldDirPath}\n New: ${dirPath}\n Old directory may contain orphaned documents — please review and merge manually.`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (!(await fs.pathExists(fullPath))) {
|
|
||||||
// New directory doesn't exist yet → create it
|
|
||||||
createdDirs.push(`${dirName}: ${dirPath}`);
|
|
||||||
await fs.ensureDir(fullPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create WDS subfolders if this is the design_artifacts directory
|
|
||||||
if (configKey === 'design_artifacts' && wdsFolders.length > 0) {
|
|
||||||
for (const subfolder of wdsFolders) {
|
|
||||||
const subPath = path.join(fullPath, subfolder);
|
|
||||||
if (!(await fs.pathExists(subPath))) {
|
|
||||||
await fs.ensureDir(subPath);
|
|
||||||
createdWdsFolders.push(subfolder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { createdDirs, movedDirs, createdWdsFolders };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Private: Process module configuration
|
|
||||||
* @param {string} modulePath - Path to installed module
|
|
||||||
* @param {string} moduleName - Module name
|
|
||||||
*/
|
|
||||||
async processModuleConfig(modulePath, moduleName) {
|
|
||||||
const configPath = path.join(modulePath, 'config.yaml');
|
|
||||||
|
|
||||||
if (await fs.pathExists(configPath)) {
|
|
||||||
try {
|
|
||||||
let configContent = await fs.readFile(configPath, 'utf8');
|
|
||||||
|
|
||||||
// Replace path placeholders
|
|
||||||
configContent = configContent.replaceAll('{project-root}', `bmad/${moduleName}`);
|
|
||||||
configContent = configContent.replaceAll('{module}', moduleName);
|
|
||||||
|
|
||||||
await fs.writeFile(configPath, configContent, 'utf8');
|
|
||||||
} catch (error) {
|
|
||||||
await prompts.log.warn(`Failed to process module config: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Private: Sync module files (preserving user modifications)
|
|
||||||
* @param {string} sourcePath - Source module path
|
|
||||||
* @param {string} targetPath - Target module path
|
|
||||||
*/
|
|
||||||
async syncModule(sourcePath, targetPath) {
|
|
||||||
// Get list of all source files
|
|
||||||
const sourceFiles = await this.getFileList(sourcePath);
|
|
||||||
|
|
||||||
for (const file of sourceFiles) {
|
|
||||||
const sourceFile = path.join(sourcePath, file);
|
|
||||||
const targetFile = path.join(targetPath, file);
|
|
||||||
|
|
||||||
// Check if target file exists and has been modified
|
|
||||||
if (await fs.pathExists(targetFile)) {
|
|
||||||
const sourceStats = await fs.stat(sourceFile);
|
|
||||||
const targetStats = await fs.stat(targetFile);
|
|
||||||
|
|
||||||
// Skip if target is newer (user modified)
|
|
||||||
if (targetStats.mtime > sourceStats.mtime) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy file with placeholder replacement
|
|
||||||
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Private: Get list of all files in a directory
|
|
||||||
* @param {string} dir - Directory path
|
|
||||||
* @param {string} baseDir - Base directory for relative paths
|
|
||||||
* @returns {Array} List of relative file paths
|
|
||||||
*/
|
|
||||||
async getFileList(dir, baseDir = dir) {
|
|
||||||
const files = [];
|
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
const subFiles = await this.getFileList(fullPath, baseDir);
|
|
||||||
files.push(...subFiles);
|
|
||||||
} else {
|
|
||||||
files.push(path.relative(baseDir, fullPath));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { ModuleManager };
|
|
||||||
|
|
@ -1,213 +0,0 @@
|
||||||
const fs = require('fs-extra');
|
|
||||||
const yaml = require('yaml');
|
|
||||||
const path = require('node:path');
|
|
||||||
const packageJson = require('../../../package.json');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration utility class
|
|
||||||
*/
|
|
||||||
class Config {
|
|
||||||
/**
|
|
||||||
* Load a YAML configuration file
|
|
||||||
* @param {string} configPath - Path to config file
|
|
||||||
* @returns {Object} Parsed configuration
|
|
||||||
*/
|
|
||||||
async loadYaml(configPath) {
|
|
||||||
if (!(await fs.pathExists(configPath))) {
|
|
||||||
throw new Error(`Configuration file not found: ${configPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = await fs.readFile(configPath, 'utf8');
|
|
||||||
return yaml.parse(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save configuration to YAML file
|
|
||||||
* @param {string} configPath - Path to config file
|
|
||||||
* @param {Object} config - Configuration object
|
|
||||||
*/
|
|
||||||
async saveYaml(configPath, config) {
|
|
||||||
const yamlContent = yaml.dump(config, {
|
|
||||||
indent: 2,
|
|
||||||
lineWidth: 120,
|
|
||||||
noRefs: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await fs.ensureDir(path.dirname(configPath));
|
|
||||||
// Ensure POSIX-compliant final newline
|
|
||||||
const content = yamlContent.endsWith('\n') ? yamlContent : yamlContent + '\n';
|
|
||||||
await fs.writeFile(configPath, content, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process configuration file (replace placeholders)
|
|
||||||
* @param {string} configPath - Path to config file
|
|
||||||
* @param {Object} replacements - Replacement values
|
|
||||||
*/
|
|
||||||
async processConfig(configPath, replacements = {}) {
|
|
||||||
let content = await fs.readFile(configPath, 'utf8');
|
|
||||||
|
|
||||||
// Standard replacements
|
|
||||||
const standardReplacements = {
|
|
||||||
'{project-root}': replacements.root || '',
|
|
||||||
'{module}': replacements.module || '',
|
|
||||||
'{version}': replacements.version || packageJson.version,
|
|
||||||
'{date}': new Date().toISOString().split('T')[0],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Apply all replacements
|
|
||||||
const allReplacements = { ...standardReplacements, ...replacements };
|
|
||||||
|
|
||||||
for (const [placeholder, value] of Object.entries(allReplacements)) {
|
|
||||||
if (typeof placeholder === 'string' && typeof value === 'string') {
|
|
||||||
const regex = new RegExp(placeholder.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`), 'g');
|
|
||||||
content = content.replace(regex, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.writeFile(configPath, content, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge configurations
|
|
||||||
* @param {Object} base - Base configuration
|
|
||||||
* @param {Object} override - Override configuration
|
|
||||||
* @returns {Object} Merged configuration
|
|
||||||
*/
|
|
||||||
mergeConfigs(base, override) {
|
|
||||||
return this.deepMerge(base, override);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deep merge two objects
|
|
||||||
* @param {Object} target - Target object
|
|
||||||
* @param {Object} source - Source object
|
|
||||||
* @returns {Object} Merged object
|
|
||||||
*/
|
|
||||||
deepMerge(target, source) {
|
|
||||||
const output = { ...target };
|
|
||||||
|
|
||||||
if (this.isObject(target) && this.isObject(source)) {
|
|
||||||
for (const key of Object.keys(source)) {
|
|
||||||
if (this.isObject(source[key])) {
|
|
||||||
if (key in target) {
|
|
||||||
output[key] = this.deepMerge(target[key], source[key]);
|
|
||||||
} else {
|
|
||||||
output[key] = source[key];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
output[key] = source[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if value is an object
|
|
||||||
* @param {*} item - Item to check
|
|
||||||
* @returns {boolean} True if object
|
|
||||||
*/
|
|
||||||
isObject(item) {
|
|
||||||
return item && typeof item === 'object' && !Array.isArray(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate configuration against schema
|
|
||||||
* @param {Object} config - Configuration to validate
|
|
||||||
* @param {Object} schema - Validation schema
|
|
||||||
* @returns {Object} Validation result
|
|
||||||
*/
|
|
||||||
validateConfig(config, schema) {
|
|
||||||
const errors = [];
|
|
||||||
const warnings = [];
|
|
||||||
|
|
||||||
// Check required fields
|
|
||||||
if (schema.required) {
|
|
||||||
for (const field of schema.required) {
|
|
||||||
if (!(field in config)) {
|
|
||||||
errors.push(`Missing required field: ${field}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check field types
|
|
||||||
if (schema.properties) {
|
|
||||||
for (const [field, spec] of Object.entries(schema.properties)) {
|
|
||||||
if (field in config) {
|
|
||||||
const value = config[field];
|
|
||||||
const expectedType = spec.type;
|
|
||||||
|
|
||||||
if (expectedType === 'array' && !Array.isArray(value)) {
|
|
||||||
errors.push(`Field '${field}' should be an array`);
|
|
||||||
} else if (expectedType === 'object' && !this.isObject(value)) {
|
|
||||||
errors.push(`Field '${field}' should be an object`);
|
|
||||||
} else if (expectedType === 'string' && typeof value !== 'string') {
|
|
||||||
errors.push(`Field '${field}' should be a string`);
|
|
||||||
} else if (expectedType === 'number' && typeof value !== 'number') {
|
|
||||||
errors.push(`Field '${field}' should be a number`);
|
|
||||||
} else if (expectedType === 'boolean' && typeof value !== 'boolean') {
|
|
||||||
errors.push(`Field '${field}' should be a boolean`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check enum values
|
|
||||||
if (spec.enum && !spec.enum.includes(value)) {
|
|
||||||
errors.push(`Field '${field}' must be one of: ${spec.enum.join(', ')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: errors.length === 0,
|
|
||||||
errors,
|
|
||||||
warnings,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get configuration value with fallback
|
|
||||||
* @param {Object} config - Configuration object
|
|
||||||
* @param {string} path - Dot-notation path to value
|
|
||||||
* @param {*} defaultValue - Default value if not found
|
|
||||||
* @returns {*} Configuration value
|
|
||||||
*/
|
|
||||||
getValue(config, path, defaultValue = null) {
|
|
||||||
const keys = path.split('.');
|
|
||||||
let current = config;
|
|
||||||
|
|
||||||
for (const key of keys) {
|
|
||||||
if (current && typeof current === 'object' && key in current) {
|
|
||||||
current = current[key];
|
|
||||||
} else {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set configuration value
|
|
||||||
* @param {Object} config - Configuration object
|
|
||||||
* @param {string} path - Dot-notation path to value
|
|
||||||
* @param {*} value - Value to set
|
|
||||||
*/
|
|
||||||
setValue(config, path, value) {
|
|
||||||
const keys = path.split('.');
|
|
||||||
const lastKey = keys.pop();
|
|
||||||
let current = config;
|
|
||||||
|
|
||||||
for (const key of keys) {
|
|
||||||
if (!(key in current) || typeof current[key] !== 'object') {
|
|
||||||
current[key] = {};
|
|
||||||
}
|
|
||||||
current = current[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
current[lastKey] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { Config };
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
const fs = require('fs-extra');
|
|
||||||
const path = require('node:path');
|
|
||||||
const yaml = require('yaml');
|
|
||||||
const { getProjectRoot } = require('./project-root');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Platform Codes Manager
|
|
||||||
* Loads and provides access to the centralized platform codes configuration
|
|
||||||
*/
|
|
||||||
class PlatformCodes {
|
|
||||||
constructor() {
|
|
||||||
this.configPath = path.join(getProjectRoot(), 'tools', 'platform-codes.yaml');
|
|
||||||
this.loadConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the platform codes configuration
|
|
||||||
*/
|
|
||||||
loadConfig() {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(this.configPath)) {
|
|
||||||
const content = fs.readFileSync(this.configPath, 'utf8');
|
|
||||||
this.config = yaml.parse(content);
|
|
||||||
} else {
|
|
||||||
console.warn(`Platform codes config not found at ${this.configPath}`);
|
|
||||||
this.config = { platforms: {} };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error loading platform codes: ${error.message}`);
|
|
||||||
this.config = { platforms: {} };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all platform codes
|
|
||||||
* @returns {Object} All platform configurations
|
|
||||||
*/
|
|
||||||
getAllPlatforms() {
|
|
||||||
return this.config.platforms || {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a specific platform configuration
|
|
||||||
* @param {string} code - Platform code
|
|
||||||
* @returns {Object|null} Platform configuration or null if not found
|
|
||||||
*/
|
|
||||||
getPlatform(code) {
|
|
||||||
return this.config.platforms[code] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a platform code is valid
|
|
||||||
* @param {string} code - Platform code to validate
|
|
||||||
* @returns {boolean} True if valid
|
|
||||||
*/
|
|
||||||
isValidPlatform(code) {
|
|
||||||
return code in this.config.platforms;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all preferred platforms
|
|
||||||
* @returns {Array} Array of preferred platform codes
|
|
||||||
*/
|
|
||||||
getPreferredPlatforms() {
|
|
||||||
return Object.entries(this.config.platforms)
|
|
||||||
.filter(([, config]) => config.preferred)
|
|
||||||
.map(([code]) => code);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get platforms by category
|
|
||||||
* @param {string} category - Category to filter by
|
|
||||||
* @returns {Array} Array of platform codes in the category
|
|
||||||
*/
|
|
||||||
getPlatformsByCategory(category) {
|
|
||||||
return Object.entries(this.config.platforms)
|
|
||||||
.filter(([, config]) => config.category === category)
|
|
||||||
.map(([code]) => code);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get platform display name
|
|
||||||
* @param {string} code - Platform code
|
|
||||||
* @returns {string} Display name or code if not found
|
|
||||||
*/
|
|
||||||
getDisplayName(code) {
|
|
||||||
const platform = this.getPlatform(code);
|
|
||||||
return platform ? platform.name : code;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate platform code format
|
|
||||||
* @param {string} code - Platform code to validate
|
|
||||||
* @returns {boolean} True if format is valid
|
|
||||||
*/
|
|
||||||
isValidFormat(code) {
|
|
||||||
const conventions = this.config.conventions || {};
|
|
||||||
const pattern = conventions.allowed_characters || 'a-z0-9-';
|
|
||||||
const maxLength = conventions.max_code_length || 20;
|
|
||||||
|
|
||||||
const regex = new RegExp(`^[${pattern}]+$`);
|
|
||||||
return regex.test(code) && code.length <= maxLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all platform codes as array
|
|
||||||
* @returns {Array} Array of platform codes
|
|
||||||
*/
|
|
||||||
getCodes() {
|
|
||||||
return Object.keys(this.config.platforms);
|
|
||||||
}
|
|
||||||
config = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
module.exports = new PlatformCodes();
|
|
||||||
|
|
@ -6,7 +6,7 @@ Create a reference documentation page at `docs/reference/modules.md` that lists
|
||||||
|
|
||||||
## Source of Truth
|
## Source of Truth
|
||||||
|
|
||||||
Read `tools/cli/external-official-modules.yaml` — this is the authoritative registry of official external modules. Use the module names, codes, npm package names, and repository URLs from this file.
|
Read `tools/installer/external-official-modules.yaml` — this is the authoritative registry of official external modules. Use the module names, codes, npm package names, and repository URLs from this file.
|
||||||
|
|
||||||
## Research Step
|
## Research Step
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
const { program } = require('commander');
|
const { program } = require('commander');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const { execSync } = require('node:child_process');
|
const { execSync } = require('node:child_process');
|
||||||
const semver = require('semver');
|
const semver = require('semver');
|
||||||
const prompts = require('./lib/prompts');
|
const prompts = require('./prompts');
|
||||||
|
|
||||||
// The installer flow uses many sequential @clack/prompts, each adding keypress
|
// The installer flow uses many sequential @clack/prompts, each adding keypress
|
||||||
// listeners to stdin. Raise the limit to avoid spurious EventEmitter warnings.
|
// listeners to stdin. Raise the limit to avoid spurious EventEmitter warnings.
|
||||||
|
|
@ -8,7 +8,7 @@ const CLIUtils = {
|
||||||
*/
|
*/
|
||||||
getVersion() {
|
getVersion() {
|
||||||
try {
|
try {
|
||||||
const packageJson = require(path.join(__dirname, '..', '..', '..', 'package.json'));
|
const packageJson = require(path.join(__dirname, '..', '..', 'package.json'));
|
||||||
return packageJson.version || 'Unknown';
|
return packageJson.version || 'Unknown';
|
||||||
} catch {
|
} catch {
|
||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
|
|
@ -16,10 +16,9 @@ const CLIUtils = {
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display BMAD logo using @clack intro + box
|
* Display BMAD logo and version using @clack intro + box
|
||||||
* @param {boolean} _clearScreen - Deprecated, ignored (no longer clears screen)
|
|
||||||
*/
|
*/
|
||||||
async displayLogo(_clearScreen = true) {
|
async displayLogo() {
|
||||||
const version = this.getVersion();
|
const version = this.getVersion();
|
||||||
const color = await prompts.getColor();
|
const color = await prompts.getColor();
|
||||||
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const prompts = require('../lib/prompts');
|
const prompts = require('../prompts');
|
||||||
const { Installer } = require('../installers/lib/core/installer');
|
const { Installer } = require('../core/installer');
|
||||||
const { UI } = require('../lib/ui');
|
const { UI } = require('../ui');
|
||||||
|
|
||||||
const installer = new Installer();
|
const installer = new Installer();
|
||||||
const ui = new UI();
|
const ui = new UI();
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const prompts = require('../lib/prompts');
|
const prompts = require('../prompts');
|
||||||
const { Installer } = require('../installers/lib/core/installer');
|
const { Installer } = require('../core/installer');
|
||||||
const { Manifest } = require('../installers/lib/core/manifest');
|
const { Manifest } = require('../core/manifest');
|
||||||
const { UI } = require('../lib/ui');
|
const { UI } = require('../ui');
|
||||||
|
|
||||||
const installer = new Installer();
|
const installer = new Installer();
|
||||||
const manifest = new Manifest();
|
const manifest = new Manifest();
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const prompts = require('../lib/prompts');
|
const prompts = require('../prompts');
|
||||||
const { Installer } = require('../installers/lib/core/installer');
|
const { Installer } = require('../core/installer');
|
||||||
|
|
||||||
const installer = new Installer();
|
const installer = new Installer();
|
||||||
|
|
||||||
|
|
@ -62,9 +62,9 @@ module.exports = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingInstall = await installer.getStatus(projectDir);
|
const existingInstall = await installer.getStatus(projectDir);
|
||||||
const version = existingInstall.version || 'unknown';
|
const version = existingInstall.installed ? existingInstall.version : 'unknown';
|
||||||
const modules = (existingInstall.modules || []).map((m) => m.id || m.name).join(', ');
|
const modules = existingInstall.moduleIds.join(', ');
|
||||||
const ides = (existingInstall.ides || []).join(', ');
|
const ides = existingInstall.ides.join(', ');
|
||||||
|
|
||||||
const outputFolder = await installer.getOutputFolder(projectDir);
|
const outputFolder = await installer.getOutputFolder(projectDir);
|
||||||
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* Clean install configuration built from user input.
|
||||||
|
* User input comes from either UI answers or headless CLI flags.
|
||||||
|
*/
|
||||||
|
class Config {
|
||||||
|
constructor({ directory, modules, ides, skipPrompts, verbose, actionType, coreConfig, moduleConfigs, quickUpdate }) {
|
||||||
|
this.directory = directory;
|
||||||
|
this.modules = Object.freeze([...modules]);
|
||||||
|
this.ides = Object.freeze([...ides]);
|
||||||
|
this.skipPrompts = skipPrompts;
|
||||||
|
this.verbose = verbose;
|
||||||
|
this.actionType = actionType;
|
||||||
|
this.coreConfig = coreConfig;
|
||||||
|
this.moduleConfigs = moduleConfigs;
|
||||||
|
this._quickUpdate = quickUpdate;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a clean install config from raw user input.
|
||||||
|
* @param {Object} userInput - UI answers or CLI flags
|
||||||
|
* @returns {Config}
|
||||||
|
*/
|
||||||
|
static build(userInput) {
|
||||||
|
const modules = [...(userInput.modules || [])];
|
||||||
|
if (userInput.installCore && !modules.includes('core')) {
|
||||||
|
modules.unshift('core');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Config({
|
||||||
|
directory: userInput.directory,
|
||||||
|
modules,
|
||||||
|
ides: userInput.skipIde ? [] : [...(userInput.ides || [])],
|
||||||
|
skipPrompts: userInput.skipPrompts || false,
|
||||||
|
verbose: userInput.verbose || false,
|
||||||
|
actionType: userInput.actionType,
|
||||||
|
coreConfig: userInput.coreConfig || {},
|
||||||
|
moduleConfigs: userInput.moduleConfigs || null,
|
||||||
|
quickUpdate: userInput._quickUpdate || false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hasCoreConfig() {
|
||||||
|
return this.coreConfig && Object.keys(this.coreConfig).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
isQuickUpdate() {
|
||||||
|
return this._quickUpdate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Config };
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
const prompts = require('../../../lib/prompts');
|
const prompts = require('../prompts');
|
||||||
|
|
||||||
class CustomModuleCache {
|
class CustomModuleCache {
|
||||||
constructor(bmadDir) {
|
constructor(bmadDir) {
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
const path = require('node:path');
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const yaml = require('yaml');
|
||||||
|
const { Manifest } = require('./manifest');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable snapshot of an existing BMAD installation.
|
||||||
|
* Pure query object — no filesystem operations after construction.
|
||||||
|
*/
|
||||||
|
class ExistingInstall {
|
||||||
|
#version;
|
||||||
|
|
||||||
|
constructor({ installed, version, hasCore, modules, ides, customModules }) {
|
||||||
|
this.installed = installed;
|
||||||
|
this.#version = version;
|
||||||
|
this.hasCore = hasCore;
|
||||||
|
this.modules = Object.freeze(modules.map((m) => Object.freeze({ ...m })));
|
||||||
|
this.moduleIds = Object.freeze(this.modules.map((m) => m.id));
|
||||||
|
this.ides = Object.freeze([...ides]);
|
||||||
|
this.customModules = Object.freeze([...customModules]);
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
get version() {
|
||||||
|
if (!this.installed) {
|
||||||
|
throw new Error('version is not available when nothing is installed');
|
||||||
|
}
|
||||||
|
return this.#version;
|
||||||
|
}
|
||||||
|
|
||||||
|
static empty() {
|
||||||
|
return new ExistingInstall({
|
||||||
|
installed: false,
|
||||||
|
version: null,
|
||||||
|
hasCore: false,
|
||||||
|
modules: [],
|
||||||
|
ides: [],
|
||||||
|
customModules: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a bmad directory and return an immutable snapshot of what's installed.
|
||||||
|
* @param {string} bmadDir - Path to bmad directory
|
||||||
|
* @returns {Promise<ExistingInstall>}
|
||||||
|
*/
|
||||||
|
static async detect(bmadDir) {
|
||||||
|
if (!(await fs.pathExists(bmadDir))) {
|
||||||
|
return ExistingInstall.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = null;
|
||||||
|
let hasCore = false;
|
||||||
|
const modules = [];
|
||||||
|
let ides = [];
|
||||||
|
let customModules = [];
|
||||||
|
|
||||||
|
const manifest = new Manifest();
|
||||||
|
const manifestData = await manifest.read(bmadDir);
|
||||||
|
if (manifestData) {
|
||||||
|
version = manifestData.version;
|
||||||
|
if (manifestData.customModules) {
|
||||||
|
customModules = manifestData.customModules;
|
||||||
|
}
|
||||||
|
if (manifestData.ides) {
|
||||||
|
ides = manifestData.ides.filter((ide) => ide && typeof ide === 'string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const corePath = path.join(bmadDir, 'core');
|
||||||
|
if (await fs.pathExists(corePath)) {
|
||||||
|
hasCore = true;
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
const coreConfigPath = path.join(corePath, 'config.yaml');
|
||||||
|
if (await fs.pathExists(coreConfigPath)) {
|
||||||
|
try {
|
||||||
|
const configContent = await fs.readFile(coreConfigPath, 'utf8');
|
||||||
|
const config = yaml.parse(configContent);
|
||||||
|
if (config.version) {
|
||||||
|
version = config.version;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore config read errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifestData && manifestData.modules && manifestData.modules.length > 0) {
|
||||||
|
for (const moduleId of manifestData.modules) {
|
||||||
|
const modulePath = path.join(bmadDir, moduleId);
|
||||||
|
const moduleConfigPath = path.join(modulePath, 'config.yaml');
|
||||||
|
|
||||||
|
const moduleInfo = {
|
||||||
|
id: moduleId,
|
||||||
|
path: modulePath,
|
||||||
|
version: 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (await fs.pathExists(moduleConfigPath)) {
|
||||||
|
try {
|
||||||
|
const configContent = await fs.readFile(moduleConfigPath, 'utf8');
|
||||||
|
const config = yaml.parse(configContent);
|
||||||
|
moduleInfo.version = config.version || 'unknown';
|
||||||
|
moduleInfo.name = config.name || moduleId;
|
||||||
|
moduleInfo.description = config.description;
|
||||||
|
} catch {
|
||||||
|
// Ignore config read errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modules.push(moduleInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const installed = hasCore || modules.length > 0 || !!manifestData;
|
||||||
|
|
||||||
|
if (!installed) {
|
||||||
|
return ExistingInstall.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExistingInstall({ installed, version, hasCore, modules, ides, customModules });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ExistingInstall };
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
const path = require('node:path');
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const { getProjectRoot } = require('../project-root');
|
||||||
|
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
|
||||||
|
|
||||||
|
class InstallPaths {
|
||||||
|
static async create(config) {
|
||||||
|
const srcDir = getProjectRoot();
|
||||||
|
await assertReadableDir(srcDir, 'BMAD source root');
|
||||||
|
|
||||||
|
const pkgPath = path.join(srcDir, 'package.json');
|
||||||
|
await assertReadableFile(pkgPath, 'package.json');
|
||||||
|
const version = require(pkgPath).version;
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(config.directory);
|
||||||
|
await ensureWritableDir(projectRoot, 'project root');
|
||||||
|
|
||||||
|
const bmadDir = path.join(projectRoot, BMAD_FOLDER_NAME);
|
||||||
|
const isUpdate = await fs.pathExists(bmadDir);
|
||||||
|
|
||||||
|
const configDir = path.join(bmadDir, '_config');
|
||||||
|
const agentsDir = path.join(configDir, 'agents');
|
||||||
|
const customCacheDir = path.join(configDir, 'custom');
|
||||||
|
const coreDir = path.join(bmadDir, 'core');
|
||||||
|
|
||||||
|
for (const [dir, label] of [
|
||||||
|
[bmadDir, 'bmad directory'],
|
||||||
|
[configDir, 'config directory'],
|
||||||
|
[agentsDir, 'agents config directory'],
|
||||||
|
[customCacheDir, 'custom modules cache'],
|
||||||
|
[coreDir, 'core module directory'],
|
||||||
|
]) {
|
||||||
|
await ensureWritableDir(dir, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InstallPaths({
|
||||||
|
srcDir,
|
||||||
|
version,
|
||||||
|
projectRoot,
|
||||||
|
bmadDir,
|
||||||
|
configDir,
|
||||||
|
agentsDir,
|
||||||
|
customCacheDir,
|
||||||
|
coreDir,
|
||||||
|
isUpdate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
Object.assign(this, props);
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
manifestFile() {
|
||||||
|
return path.join(this.configDir, 'manifest.yaml');
|
||||||
|
}
|
||||||
|
agentManifest() {
|
||||||
|
return path.join(this.configDir, 'agent-manifest.csv');
|
||||||
|
}
|
||||||
|
filesManifest() {
|
||||||
|
return path.join(this.configDir, 'files-manifest.csv');
|
||||||
|
}
|
||||||
|
helpCatalog() {
|
||||||
|
return path.join(this.configDir, 'bmad-help.csv');
|
||||||
|
}
|
||||||
|
moduleDir(name) {
|
||||||
|
return path.join(this.bmadDir, name);
|
||||||
|
}
|
||||||
|
moduleConfig(name) {
|
||||||
|
return path.join(this.bmadDir, name, 'config.yaml');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertReadableDir(dirPath, label) {
|
||||||
|
const stat = await fs.stat(dirPath).catch(() => null);
|
||||||
|
if (!stat) {
|
||||||
|
throw new Error(`${label} does not exist: ${dirPath}`);
|
||||||
|
}
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
throw new Error(`${label} is not a directory: ${dirPath}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.access(dirPath, fs.constants.R_OK);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} is not readable: ${dirPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertReadableFile(filePath, label) {
|
||||||
|
const stat = await fs.stat(filePath).catch(() => null);
|
||||||
|
if (!stat) {
|
||||||
|
throw new Error(`${label} does not exist: ${filePath}`);
|
||||||
|
}
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
throw new Error(`${label} is not a file: ${filePath}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.access(filePath, fs.constants.R_OK);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} is not readable: ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWritableDir(dirPath, label) {
|
||||||
|
const stat = await fs.stat(dirPath).catch(() => null);
|
||||||
|
if (stat && !stat.isDirectory()) {
|
||||||
|
throw new Error(`${label} exists but is not a directory: ${dirPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.ensureDir(dirPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'EACCES') {
|
||||||
|
throw new Error(`${label}: permission denied creating directory: ${dirPath}`);
|
||||||
|
}
|
||||||
|
if (error.code === 'ENOSPC') {
|
||||||
|
throw new Error(`${label}: no space left on device: ${dirPath}`);
|
||||||
|
}
|
||||||
|
throw new Error(`${label}: cannot create directory: ${dirPath} (${error.message})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(dirPath, fs.constants.R_OK | fs.constants.W_OK);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} is not writable: ${dirPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { InstallPaths };
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,8 +3,8 @@ const fs = require('fs-extra');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
const csv = require('csv-parse/sync');
|
const csv = require('csv-parse/sync');
|
||||||
const { getSourcePath, getModulePath } = require('../../../lib/project-root');
|
const { getSourcePath, getModulePath } = require('../project-root');
|
||||||
const prompts = require('../../../lib/prompts');
|
const prompts = require('../prompts');
|
||||||
const {
|
const {
|
||||||
loadSkillManifest: loadSkillManifestShared,
|
loadSkillManifest: loadSkillManifestShared,
|
||||||
getCanonicalId: getCanonicalIdShared,
|
getCanonicalId: getCanonicalIdShared,
|
||||||
|
|
@ -13,7 +13,7 @@ const {
|
||||||
} = require('../ide/shared/skill-manifest');
|
} = require('../ide/shared/skill-manifest');
|
||||||
|
|
||||||
// Load package.json for version info
|
// Load package.json for version info
|
||||||
const packageJson = require('../../../../../package.json');
|
const packageJson = require('../../../package.json');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates manifest files for installed skills and agents
|
* Generates manifest files for installed skills and agents
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
const { getProjectRoot } = require('../../../lib/project-root');
|
const { getProjectRoot } = require('../project-root');
|
||||||
const prompts = require('../../../lib/prompts');
|
const prompts = require('../prompts');
|
||||||
|
|
||||||
class Manifest {
|
class Manifest {
|
||||||
/**
|
/**
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const prompts = require('../../../lib/prompts');
|
const prompts = require('./prompts');
|
||||||
/**
|
/**
|
||||||
* Handler for custom content (custom.yaml)
|
* Handler for custom content (custom.yaml)
|
||||||
* Discovers custom agents and workflows in the project
|
* Discovers custom agents and workflows in the project
|
||||||
|
|
@ -2,9 +2,9 @@ const os = require('node:os');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const { BaseIdeSetup } = require('./_base-ide');
|
const prompts = require('../prompts');
|
||||||
const prompts = require('../../../lib/prompts');
|
|
||||||
const csv = require('csv-parse/sync');
|
const csv = require('csv-parse/sync');
|
||||||
|
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Config-driven IDE setup handler
|
* Config-driven IDE setup handler
|
||||||
|
|
@ -15,43 +15,45 @@ const csv = require('csv-parse/sync');
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Config-driven from platform-codes.yaml
|
* - Config-driven from platform-codes.yaml
|
||||||
* - Template-based content generation
|
* - Verbatim skill installation from skill-manifest.csv
|
||||||
* - Multi-target installation support (e.g., GitHub Copilot)
|
* - Legacy directory cleanup and IDE-specific marker removal
|
||||||
* - Artifact type filtering (agents, workflows, tasks, tools)
|
|
||||||
*/
|
*/
|
||||||
class ConfigDrivenIdeSetup extends BaseIdeSetup {
|
class ConfigDrivenIdeSetup {
|
||||||
constructor(platformCode, platformConfig) {
|
constructor(platformCode, platformConfig) {
|
||||||
super(platformCode, platformConfig.name, platformConfig.preferred);
|
this.name = platformCode;
|
||||||
|
this.displayName = platformConfig.name || platformCode;
|
||||||
|
this.preferred = platformConfig.preferred || false;
|
||||||
this.platformConfig = platformConfig;
|
this.platformConfig = platformConfig;
|
||||||
this.installerConfig = platformConfig.installer || null;
|
this.installerConfig = platformConfig.installer || null;
|
||||||
|
this.bmadFolderName = BMAD_FOLDER_NAME;
|
||||||
|
|
||||||
// Set configDir from target_dir so base-class detect() works
|
// Set configDir from target_dir so detect() works
|
||||||
if (this.installerConfig?.target_dir) {
|
this.configDir = this.installerConfig?.target_dir || null;
|
||||||
this.configDir = this.installerConfig.target_dir;
|
}
|
||||||
}
|
|
||||||
|
setBmadFolderName(bmadFolderName) {
|
||||||
|
this.bmadFolderName = bmadFolderName;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect whether this IDE already has configuration in the project.
|
* Detect whether this IDE already has configuration in the project.
|
||||||
* For skill_format platforms, checks for bmad-prefixed entries in target_dir
|
* Checks for bmad-prefixed entries in target_dir.
|
||||||
* (matching old codex.js behavior) instead of just checking directory existence.
|
|
||||||
* @param {string} projectDir - Project directory
|
* @param {string} projectDir - Project directory
|
||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
async detect(projectDir) {
|
async detect(projectDir) {
|
||||||
if (this.installerConfig?.skill_format && this.configDir) {
|
if (!this.configDir) return false;
|
||||||
const dir = path.join(projectDir || process.cwd(), this.configDir);
|
|
||||||
if (await fs.pathExists(dir)) {
|
const dir = path.join(projectDir || process.cwd(), this.configDir);
|
||||||
try {
|
if (await fs.pathExists(dir)) {
|
||||||
const entries = await fs.readdir(dir);
|
try {
|
||||||
return entries.some((e) => typeof e === 'string' && e.startsWith('bmad'));
|
const entries = await fs.readdir(dir);
|
||||||
} catch {
|
return entries.some((e) => typeof e === 'string' && e.startsWith('bmad'));
|
||||||
return false;
|
} catch {
|
||||||
}
|
return false;
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
return super.detect(projectDir);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -90,12 +92,6 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
|
||||||
return { success: false, reason: 'no-config' };
|
return { success: false, reason: 'no-config' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle multi-target installations (e.g., GitHub Copilot)
|
|
||||||
if (this.installerConfig.targets) {
|
|
||||||
return this.installToMultipleTargets(projectDir, bmadDir, this.installerConfig.targets, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle single-target installations
|
|
||||||
if (this.installerConfig.target_dir) {
|
if (this.installerConfig.target_dir) {
|
||||||
return this.installToTarget(projectDir, bmadDir, this.installerConfig, options);
|
return this.installToTarget(projectDir, bmadDir, this.installerConfig, options);
|
||||||
}
|
}
|
||||||
|
|
@ -113,13 +109,8 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
|
||||||
*/
|
*/
|
||||||
async installToTarget(projectDir, bmadDir, config, options) {
|
async installToTarget(projectDir, bmadDir, config, options) {
|
||||||
const { target_dir } = config;
|
const { target_dir } = config;
|
||||||
|
|
||||||
if (!config.skill_format) {
|
|
||||||
return { success: false, reason: 'missing-skill-format', error: 'Installer config missing skill_format — cannot install skills' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetPath = path.join(projectDir, target_dir);
|
const targetPath = path.join(projectDir, target_dir);
|
||||||
await this.ensureDir(targetPath);
|
await fs.ensureDir(targetPath);
|
||||||
|
|
||||||
this.skillWriteTracker = new Set();
|
this.skillWriteTracker = new Set();
|
||||||
const results = { skills: 0 };
|
const results = { skills: 0 };
|
||||||
|
|
@ -132,351 +123,6 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
|
||||||
return { success: true, results };
|
return { success: true, results };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Install to multiple target directories
|
|
||||||
* @param {string} projectDir - Project directory
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @param {Array} targets - Array of target configurations
|
|
||||||
* @param {Object} options - Setup options
|
|
||||||
* @returns {Promise<Object>} Installation result
|
|
||||||
*/
|
|
||||||
async installToMultipleTargets(projectDir, bmadDir, targets, options) {
|
|
||||||
const allResults = { skills: 0 };
|
|
||||||
|
|
||||||
for (const target of targets) {
|
|
||||||
const result = await this.installToTarget(projectDir, bmadDir, target, options);
|
|
||||||
if (result.success) {
|
|
||||||
allResults.skills += result.results.skills || 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, results: allResults };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load template based on type and configuration
|
|
||||||
* @param {string} templateType - Template type (claude, windsurf, etc.)
|
|
||||||
* @param {string} artifactType - Artifact type (agent, workflow, task, tool)
|
|
||||||
* @param {Object} config - Installation configuration
|
|
||||||
* @param {string} fallbackTemplateType - Fallback template type if requested template not found
|
|
||||||
* @returns {Promise<{content: string, extension: string}>} Template content and extension
|
|
||||||
*/
|
|
||||||
async loadTemplate(templateType, artifactType, config = {}, fallbackTemplateType = null) {
|
|
||||||
const { header_template, body_template } = config;
|
|
||||||
|
|
||||||
// Check for separate header/body templates
|
|
||||||
if (header_template || body_template) {
|
|
||||||
const content = await this.loadSplitTemplates(templateType, artifactType, header_template, body_template);
|
|
||||||
// Allow config to override extension, default to .md
|
|
||||||
const ext = config.extension || '.md';
|
|
||||||
const normalizedExt = ext.startsWith('.') ? ext : `.${ext}`;
|
|
||||||
return { content, extension: normalizedExt };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load combined template - try multiple extensions
|
|
||||||
// If artifactType is empty, templateType already contains full name (e.g., 'gemini-workflow-yaml')
|
|
||||||
const templateBaseName = artifactType ? `${templateType}-${artifactType}` : templateType;
|
|
||||||
const templateDir = path.join(__dirname, 'templates', 'combined');
|
|
||||||
const extensions = ['.md', '.toml', '.yaml', '.yml'];
|
|
||||||
|
|
||||||
for (const ext of extensions) {
|
|
||||||
const templatePath = path.join(templateDir, templateBaseName + ext);
|
|
||||||
if (await fs.pathExists(templatePath)) {
|
|
||||||
const content = await fs.readFile(templatePath, 'utf8');
|
|
||||||
return { content, extension: ext };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to default template (if provided)
|
|
||||||
if (fallbackTemplateType) {
|
|
||||||
for (const ext of extensions) {
|
|
||||||
const fallbackPath = path.join(templateDir, `${fallbackTemplateType}${ext}`);
|
|
||||||
if (await fs.pathExists(fallbackPath)) {
|
|
||||||
const content = await fs.readFile(fallbackPath, 'utf8');
|
|
||||||
return { content, extension: ext };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ultimate fallback - minimal template
|
|
||||||
return { content: this.getDefaultTemplate(artifactType), extension: '.md' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load split templates (header + body)
|
|
||||||
* @param {string} templateType - Template type
|
|
||||||
* @param {string} artifactType - Artifact type
|
|
||||||
* @param {string} headerTpl - Header template name
|
|
||||||
* @param {string} bodyTpl - Body template name
|
|
||||||
* @returns {Promise<string>} Combined template content
|
|
||||||
*/
|
|
||||||
async loadSplitTemplates(templateType, artifactType, headerTpl, bodyTpl) {
|
|
||||||
let header = '';
|
|
||||||
let body = '';
|
|
||||||
|
|
||||||
// Load header template
|
|
||||||
if (headerTpl) {
|
|
||||||
const headerPath = path.join(__dirname, 'templates', 'split', headerTpl);
|
|
||||||
if (await fs.pathExists(headerPath)) {
|
|
||||||
header = await fs.readFile(headerPath, 'utf8');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use default header for template type
|
|
||||||
const defaultHeaderPath = path.join(__dirname, 'templates', 'split', templateType, 'header.md');
|
|
||||||
if (await fs.pathExists(defaultHeaderPath)) {
|
|
||||||
header = await fs.readFile(defaultHeaderPath, 'utf8');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load body template
|
|
||||||
if (bodyTpl) {
|
|
||||||
const bodyPath = path.join(__dirname, 'templates', 'split', bodyTpl);
|
|
||||||
if (await fs.pathExists(bodyPath)) {
|
|
||||||
body = await fs.readFile(bodyPath, 'utf8');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use default body for template type
|
|
||||||
const defaultBodyPath = path.join(__dirname, 'templates', 'split', templateType, 'body.md');
|
|
||||||
if (await fs.pathExists(defaultBodyPath)) {
|
|
||||||
body = await fs.readFile(defaultBodyPath, 'utf8');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine header and body
|
|
||||||
return `${header}\n${body}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get default minimal template
|
|
||||||
* @param {string} artifactType - Artifact type
|
|
||||||
* @returns {string} Default template
|
|
||||||
*/
|
|
||||||
getDefaultTemplate(artifactType) {
|
|
||||||
if (artifactType === 'agent') {
|
|
||||||
return `---
|
|
||||||
name: '{{name}}'
|
|
||||||
description: '{{description}}'
|
|
||||||
disable-model-invocation: true
|
|
||||||
---
|
|
||||||
|
|
||||||
You must fully embody this agent's persona and follow all activation instructions exactly as specified.
|
|
||||||
|
|
||||||
<agent-activation CRITICAL="TRUE">
|
|
||||||
1. LOAD the FULL agent file from {project-root}/{{bmadFolderName}}/{{path}}
|
|
||||||
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
|
|
||||||
3. FOLLOW every step in the <activation> section precisely
|
|
||||||
</agent-activation>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
return `---
|
|
||||||
name: '{{name}}'
|
|
||||||
description: '{{description}}'
|
|
||||||
---
|
|
||||||
|
|
||||||
# {{name}}
|
|
||||||
|
|
||||||
LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render template with artifact data
|
|
||||||
* @param {string} template - Template content
|
|
||||||
* @param {Object} artifact - Artifact data
|
|
||||||
* @returns {string} Rendered content
|
|
||||||
*/
|
|
||||||
renderTemplate(template, artifact) {
|
|
||||||
// Use the appropriate path property based on artifact type
|
|
||||||
let pathToUse = artifact.relativePath || '';
|
|
||||||
switch (artifact.type) {
|
|
||||||
case 'agent-launcher': {
|
|
||||||
pathToUse = artifact.agentPath || artifact.relativePath || '';
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'workflow-command': {
|
|
||||||
pathToUse = artifact.workflowPath || artifact.relativePath || '';
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'task':
|
|
||||||
case 'tool': {
|
|
||||||
pathToUse = artifact.path || artifact.relativePath || '';
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// No default
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace _bmad placeholder with actual folder name BEFORE inserting paths,
|
|
||||||
// so that paths containing '_bmad' are not corrupted by the blanket replacement.
|
|
||||||
let rendered = template.replaceAll('_bmad', this.bmadFolderName);
|
|
||||||
|
|
||||||
// Replace {{bmadFolderName}} placeholder if present
|
|
||||||
rendered = rendered.replaceAll('{{bmadFolderName}}', this.bmadFolderName);
|
|
||||||
|
|
||||||
rendered = rendered
|
|
||||||
.replaceAll('{{name}}', artifact.name || '')
|
|
||||||
.replaceAll('{{module}}', artifact.module || 'core')
|
|
||||||
.replaceAll('{{path}}', pathToUse)
|
|
||||||
.replaceAll('{{description}}', artifact.description || `${artifact.name} ${artifact.type || ''}`)
|
|
||||||
.replaceAll('{{workflow_path}}', pathToUse);
|
|
||||||
|
|
||||||
return rendered;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write artifact as a skill directory with SKILL.md inside.
|
|
||||||
* Writes artifact as a skill directory with SKILL.md inside.
|
|
||||||
* @param {string} targetPath - Base skills directory
|
|
||||||
* @param {Object} artifact - Artifact data
|
|
||||||
* @param {string} content - Rendered template content
|
|
||||||
*/
|
|
||||||
async writeSkillFile(targetPath, artifact, content) {
|
|
||||||
const { resolveSkillName } = require('./shared/path-utils');
|
|
||||||
|
|
||||||
// Get the skill name (prefers canonicalId, falls back to path-derived) and remove .md
|
|
||||||
const flatName = resolveSkillName(artifact);
|
|
||||||
const skillName = path.basename(flatName.replace(/\.md$/, ''));
|
|
||||||
|
|
||||||
if (!skillName) {
|
|
||||||
throw new Error(`Cannot derive skill name for artifact: ${artifact.relativePath || JSON.stringify(artifact)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create skill directory
|
|
||||||
const skillDir = path.join(targetPath, skillName);
|
|
||||||
await this.ensureDir(skillDir);
|
|
||||||
this.skillWriteTracker?.add(skillName);
|
|
||||||
|
|
||||||
// Transform content: rewrite frontmatter for skills format
|
|
||||||
const skillContent = this.transformToSkillFormat(content, skillName);
|
|
||||||
|
|
||||||
await this.writeFile(path.join(skillDir, 'SKILL.md'), skillContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform artifact content to Agent Skills format.
|
|
||||||
* Rewrites frontmatter to contain only unquoted name and description.
|
|
||||||
* @param {string} content - Original content with YAML frontmatter
|
|
||||||
* @param {string} skillName - Skill name (must match directory name)
|
|
||||||
* @returns {string} Transformed content
|
|
||||||
*/
|
|
||||||
transformToSkillFormat(content, skillName) {
|
|
||||||
// Normalize line endings
|
|
||||||
content = content.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
|
||||||
|
|
||||||
// Parse frontmatter
|
|
||||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
||||||
if (!fmMatch) {
|
|
||||||
// No frontmatter -- wrap with minimal frontmatter
|
|
||||||
const fm = yaml.stringify({ name: skillName, description: skillName }).trimEnd();
|
|
||||||
return `---\n${fm}\n---\n\n${content}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const frontmatter = fmMatch[1];
|
|
||||||
const body = fmMatch[2];
|
|
||||||
|
|
||||||
// Parse frontmatter with yaml library to extract description
|
|
||||||
let description;
|
|
||||||
try {
|
|
||||||
const parsed = yaml.parse(frontmatter);
|
|
||||||
const rawDesc = parsed?.description;
|
|
||||||
description = typeof rawDesc === 'string' && rawDesc ? rawDesc : `${skillName} skill`;
|
|
||||||
} catch {
|
|
||||||
description = `${skillName} skill`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build new frontmatter with only name and description, unquoted
|
|
||||||
const newFrontmatter = yaml.stringify({ name: skillName, description: String(description) }, { lineWidth: 0 }).trimEnd();
|
|
||||||
return `---\n${newFrontmatter}\n---\n${body}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install a custom agent launcher.
|
|
||||||
* For skill_format platforms, produces <skillDir>/SKILL.md.
|
|
||||||
* For flat platforms, produces a single file in target_dir.
|
|
||||||
* @param {string} projectDir - Project directory
|
|
||||||
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
|
|
||||||
* @param {string} agentPath - Path to compiled agent (relative to project root)
|
|
||||||
* @param {Object} metadata - Agent metadata
|
|
||||||
* @returns {Object|null} Info about created file/skill
|
|
||||||
*/
|
|
||||||
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
|
|
||||||
if (!this.installerConfig?.target_dir) return null;
|
|
||||||
|
|
||||||
const { customAgentDashName } = require('./shared/path-utils');
|
|
||||||
const targetPath = path.join(projectDir, this.installerConfig.target_dir);
|
|
||||||
await this.ensureDir(targetPath);
|
|
||||||
|
|
||||||
// Build artifact to reuse existing template rendering.
|
|
||||||
// The default-agent template already includes the _bmad/ prefix before {{path}},
|
|
||||||
// but agentPath is relative to project root (e.g. "_bmad/custom/agents/fred.md").
|
|
||||||
// Strip the bmadFolderName prefix so the template doesn't produce a double path.
|
|
||||||
const bmadPrefix = this.bmadFolderName + '/';
|
|
||||||
const normalizedPath = agentPath.startsWith(bmadPrefix) ? agentPath.slice(bmadPrefix.length) : agentPath;
|
|
||||||
|
|
||||||
const artifact = {
|
|
||||||
type: 'agent-launcher',
|
|
||||||
name: agentName,
|
|
||||||
description: metadata?.description || `${agentName} agent`,
|
|
||||||
agentPath: normalizedPath,
|
|
||||||
relativePath: normalizedPath,
|
|
||||||
module: 'custom',
|
|
||||||
};
|
|
||||||
|
|
||||||
const { content: template } = await this.loadTemplate(
|
|
||||||
this.installerConfig.template_type || 'default',
|
|
||||||
'agent',
|
|
||||||
this.installerConfig,
|
|
||||||
'default-agent',
|
|
||||||
);
|
|
||||||
const content = this.renderTemplate(template, artifact);
|
|
||||||
|
|
||||||
if (this.installerConfig.skill_format) {
|
|
||||||
const skillName = customAgentDashName(agentName).replace(/\.md$/, '');
|
|
||||||
const skillDir = path.join(targetPath, skillName);
|
|
||||||
await this.ensureDir(skillDir);
|
|
||||||
const skillContent = this.transformToSkillFormat(content, skillName);
|
|
||||||
const skillPath = path.join(skillDir, 'SKILL.md');
|
|
||||||
await this.writeFile(skillPath, skillContent);
|
|
||||||
return { path: path.relative(projectDir, skillPath), command: `$${skillName}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flat file output
|
|
||||||
const filename = customAgentDashName(agentName);
|
|
||||||
const filePath = path.join(targetPath, filename);
|
|
||||||
await this.writeFile(filePath, content);
|
|
||||||
return { path: path.relative(projectDir, filePath), command: agentName };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate filename for artifact
|
|
||||||
* @param {Object} artifact - Artifact data
|
|
||||||
* @param {string} artifactType - Artifact type (agent, workflow, task, tool)
|
|
||||||
* @param {string} extension - File extension to use (e.g., '.md', '.toml')
|
|
||||||
* @returns {string} Generated filename
|
|
||||||
*/
|
|
||||||
generateFilename(artifact, artifactType, extension = '.md') {
|
|
||||||
const { resolveSkillName } = require('./shared/path-utils');
|
|
||||||
|
|
||||||
// Reuse central logic to ensure consistent naming conventions
|
|
||||||
// Prefers canonicalId from manifest when available, falls back to path-derived name
|
|
||||||
const standardName = resolveSkillName(artifact);
|
|
||||||
|
|
||||||
// Clean up potential double extensions from source files (e.g. .yaml.md, .xml.md -> .md)
|
|
||||||
// This handles any extensions that might slip through toDashPath()
|
|
||||||
const baseName = standardName.replace(/\.(md|yaml|yml|json|xml|toml)\.md$/i, '.md');
|
|
||||||
|
|
||||||
// If using default markdown, preserve the bmad-agent- prefix for agents
|
|
||||||
if (extension === '.md') {
|
|
||||||
return baseName;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other extensions (e.g., .toml), replace .md extension
|
|
||||||
// Note: agent prefix is preserved even with non-markdown extensions
|
|
||||||
return baseName.replace(/\.md$/, extension);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
|
|
@ -598,22 +244,8 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
|
||||||
await this.cleanupRovoDevPrompts(projectDir, options);
|
await this.cleanupRovoDevPrompts(projectDir, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean all target directories
|
// Clean target directory
|
||||||
if (this.installerConfig?.targets) {
|
if (this.installerConfig?.target_dir) {
|
||||||
const parentDirs = new Set();
|
|
||||||
for (const target of this.installerConfig.targets) {
|
|
||||||
await this.cleanupTarget(projectDir, target.target_dir, options);
|
|
||||||
// Track parent directories for empty-dir cleanup
|
|
||||||
const parentDir = path.dirname(target.target_dir);
|
|
||||||
if (parentDir && parentDir !== '.') {
|
|
||||||
parentDirs.add(parentDir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// After all targets cleaned, remove empty parent directories (recursive up to projectDir)
|
|
||||||
for (const parentDir of parentDirs) {
|
|
||||||
await this.removeEmptyParents(projectDir, parentDir);
|
|
||||||
}
|
|
||||||
} else if (this.installerConfig?.target_dir) {
|
|
||||||
await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options);
|
await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -711,6 +343,7 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip BMAD-owned content from .github/copilot-instructions.md.
|
* Strip BMAD-owned content from .github/copilot-instructions.md.
|
||||||
* The old custom installer injected content between <!-- BMAD:START --> and <!-- BMAD:END --> markers.
|
* The old custom installer injected content between <!-- BMAD:START --> and <!-- BMAD:END --> markers.
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
|
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
|
||||||
const prompts = require('../../../lib/prompts');
|
const prompts = require('../prompts');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IDE Manager - handles IDE-specific setup
|
* IDE Manager - handles IDE-specific setup
|
||||||
|
|
@ -226,23 +226,6 @@ class IdeManager {
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of supported IDEs
|
|
||||||
* @returns {Array} List of supported IDE names
|
|
||||||
*/
|
|
||||||
getSupportedIdes() {
|
|
||||||
return [...this.handlers.keys()];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an IDE is supported
|
|
||||||
* @param {string} ideName - Name of the IDE
|
|
||||||
* @returns {boolean} True if IDE is supported
|
|
||||||
*/
|
|
||||||
isSupported(ideName) {
|
|
||||||
return this.handlers.has(ideName.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect installed IDEs
|
* Detect installed IDEs
|
||||||
* @param {string} projectDir - Project directory
|
* @param {string} projectDir - Project directory
|
||||||
|
|
@ -259,41 +242,6 @@ class IdeManager {
|
||||||
|
|
||||||
return detected;
|
return detected;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Install custom agent launchers for specified IDEs
|
|
||||||
* @param {Array} ides - List of IDE names to install for
|
|
||||||
* @param {string} projectDir - Project directory
|
|
||||||
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
|
|
||||||
* @param {string} agentPath - Path to compiled agent (relative to project root)
|
|
||||||
* @param {Object} metadata - Agent metadata
|
|
||||||
* @returns {Object} Results for each IDE
|
|
||||||
*/
|
|
||||||
async installCustomAgentLaunchers(ides, projectDir, agentName, agentPath, metadata) {
|
|
||||||
const results = {};
|
|
||||||
|
|
||||||
for (const ideName of ides) {
|
|
||||||
const handler = this.handlers.get(ideName.toLowerCase());
|
|
||||||
|
|
||||||
if (!handler) {
|
|
||||||
await prompts.log.warn(`IDE '${ideName}' is not yet supported for custom agent installation`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (typeof handler.installCustomAgentLauncher === 'function') {
|
|
||||||
const result = await handler.installCustomAgentLauncher(projectDir, agentName, agentPath, metadata);
|
|
||||||
if (result) {
|
|
||||||
results[ideName] = result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
await prompts.log.warn(`Failed to install ${ideName} launcher: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { IdeManager };
|
module.exports = { IdeManager };
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const path = require('node:path');
|
||||||
|
const yaml = require('yaml');
|
||||||
|
|
||||||
|
const PLATFORM_CODES_PATH = path.join(__dirname, 'platform-codes.yaml');
|
||||||
|
|
||||||
|
let _cachedPlatformCodes = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the platform codes configuration from YAML
|
||||||
|
* @returns {Object} Platform codes configuration
|
||||||
|
*/
|
||||||
|
async function loadPlatformCodes() {
|
||||||
|
if (_cachedPlatformCodes) {
|
||||||
|
return _cachedPlatformCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(PLATFORM_CODES_PATH))) {
|
||||||
|
throw new Error(`Platform codes configuration not found at: ${PLATFORM_CODES_PATH}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(PLATFORM_CODES_PATH, 'utf8');
|
||||||
|
_cachedPlatformCodes = yaml.parse(content);
|
||||||
|
return _cachedPlatformCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the cached platform codes (useful for testing)
|
||||||
|
*/
|
||||||
|
function clearCache() {
|
||||||
|
_cachedPlatformCodes = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadPlatformCodes,
|
||||||
|
clearCache,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
# BMAD Platform Codes Configuration
|
||||||
|
#
|
||||||
|
# Each platform entry has:
|
||||||
|
# name: Display name shown to users
|
||||||
|
# preferred: Whether shown as a recommended option on install
|
||||||
|
# suspended: (optional) Message explaining why install is blocked
|
||||||
|
# installer:
|
||||||
|
# target_dir: Directory where skill directories are installed
|
||||||
|
# legacy_targets: (optional) Old target dirs to clean up on reinstall
|
||||||
|
# ancestor_conflict_check: (optional) Refuse install when ancestor dir has BMAD files
|
||||||
|
|
||||||
|
platforms:
|
||||||
|
antigravity:
|
||||||
|
name: "Google Antigravity"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .agent/workflows
|
||||||
|
target_dir: .agent/skills
|
||||||
|
|
||||||
|
auggie:
|
||||||
|
name: "Auggie"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .augment/commands
|
||||||
|
target_dir: .augment/skills
|
||||||
|
|
||||||
|
claude-code:
|
||||||
|
name: "Claude Code"
|
||||||
|
preferred: true
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .claude/commands
|
||||||
|
target_dir: .claude/skills
|
||||||
|
ancestor_conflict_check: true
|
||||||
|
|
||||||
|
cline:
|
||||||
|
name: "Cline"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .clinerules/workflows
|
||||||
|
target_dir: .cline/skills
|
||||||
|
|
||||||
|
codex:
|
||||||
|
name: "Codex"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .codex/prompts
|
||||||
|
- ~/.codex/prompts
|
||||||
|
target_dir: .agents/skills
|
||||||
|
ancestor_conflict_check: true
|
||||||
|
|
||||||
|
codebuddy:
|
||||||
|
name: "CodeBuddy"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .codebuddy/commands
|
||||||
|
target_dir: .codebuddy/skills
|
||||||
|
|
||||||
|
crush:
|
||||||
|
name: "Crush"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .crush/commands
|
||||||
|
target_dir: .crush/skills
|
||||||
|
|
||||||
|
cursor:
|
||||||
|
name: "Cursor"
|
||||||
|
preferred: true
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .cursor/commands
|
||||||
|
target_dir: .cursor/skills
|
||||||
|
|
||||||
|
gemini:
|
||||||
|
name: "Gemini CLI"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .gemini/commands
|
||||||
|
target_dir: .gemini/skills
|
||||||
|
|
||||||
|
github-copilot:
|
||||||
|
name: "GitHub Copilot"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .github/agents
|
||||||
|
- .github/prompts
|
||||||
|
target_dir: .github/skills
|
||||||
|
|
||||||
|
iflow:
|
||||||
|
name: "iFlow"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .iflow/commands
|
||||||
|
target_dir: .iflow/skills
|
||||||
|
|
||||||
|
kilo:
|
||||||
|
name: "KiloCoder"
|
||||||
|
preferred: false
|
||||||
|
suspended: "Kilo Code does not yet support the Agent Skills standard. Support is paused until they implement it. See https://github.com/kilocode/kilo-code/issues for updates."
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .kilocode/workflows
|
||||||
|
target_dir: .kilocode/skills
|
||||||
|
|
||||||
|
kiro:
|
||||||
|
name: "Kiro"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .kiro/steering
|
||||||
|
target_dir: .kiro/skills
|
||||||
|
|
||||||
|
ona:
|
||||||
|
name: "Ona"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .ona/skills
|
||||||
|
|
||||||
|
opencode:
|
||||||
|
name: "OpenCode"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .opencode/agents
|
||||||
|
- .opencode/commands
|
||||||
|
- .opencode/agent
|
||||||
|
- .opencode/command
|
||||||
|
target_dir: .opencode/skills
|
||||||
|
ancestor_conflict_check: true
|
||||||
|
|
||||||
|
pi:
|
||||||
|
name: "Pi"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .pi/skills
|
||||||
|
|
||||||
|
qoder:
|
||||||
|
name: "Qoder"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
target_dir: .qoder/skills
|
||||||
|
|
||||||
|
qwen:
|
||||||
|
name: "QwenCoder"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .qwen/commands
|
||||||
|
target_dir: .qwen/skills
|
||||||
|
|
||||||
|
roo:
|
||||||
|
name: "Roo Code"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .roo/commands
|
||||||
|
target_dir: .roo/skills
|
||||||
|
|
||||||
|
rovo-dev:
|
||||||
|
name: "Rovo Dev"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .rovodev/workflows
|
||||||
|
target_dir: .rovodev/skills
|
||||||
|
|
||||||
|
trae:
|
||||||
|
name: "Trae"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .trae/rules
|
||||||
|
target_dir: .trae/skills
|
||||||
|
|
||||||
|
windsurf:
|
||||||
|
name: "Windsurf"
|
||||||
|
preferred: false
|
||||||
|
installer:
|
||||||
|
legacy_targets:
|
||||||
|
- .windsurf/workflows
|
||||||
|
target_dir: .windsurf/skills
|
||||||
|
|
@ -2,7 +2,7 @@ const path = require('node:path');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const { glob } = require('glob');
|
const { glob } = require('glob');
|
||||||
const { getSourcePath } = require('../../../../lib/project-root');
|
const { getSourcePath } = require('../../project-root');
|
||||||
|
|
||||||
async function loadModuleInjectionConfig(handler, moduleName) {
|
async function loadModuleInjectionConfig(handler, moduleName) {
|
||||||
const sourceModulesPath = getSourcePath('modules');
|
const sourceModulesPath = getSourcePath('modules');
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const prompts = require('../../lib/prompts');
|
const prompts = require('./prompts');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load and display installer messages from messages.yaml
|
* Load and display installer messages from messages.yaml
|
||||||
|
|
@ -18,7 +18,7 @@ class MessageLoader {
|
||||||
return this.messages;
|
return this.messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messagesPath = path.join(__dirname, '..', 'install-messages.yaml');
|
const messagesPath = path.join(__dirname, 'install-messages.yaml');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(messagesPath, 'utf8');
|
const content = fs.readFileSync(messagesPath, 'utf8');
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
const path = require('node:path');
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const yaml = require('yaml');
|
||||||
|
const { CustomHandler } = require('../custom-handler');
|
||||||
|
const { Manifest } = require('../core/manifest');
|
||||||
|
const prompts = require('../prompts');
|
||||||
|
|
||||||
|
class CustomModules {
|
||||||
|
constructor() {
|
||||||
|
this.paths = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
has(moduleCode) {
|
||||||
|
return this.paths.has(moduleCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(moduleCode) {
|
||||||
|
return this.paths.get(moduleCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(moduleId, sourcePath) {
|
||||||
|
this.paths.set(moduleId, sourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a custom module from its source path.
|
||||||
|
* @param {string} moduleName - Module identifier
|
||||||
|
* @param {string} bmadDir - Target bmad directory
|
||||||
|
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
||||||
|
* @param {Object} options - Install options
|
||||||
|
* @param {Object} options.moduleConfig - Pre-collected module configuration
|
||||||
|
* @returns {Object} Install result
|
||||||
|
*/
|
||||||
|
async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
|
||||||
|
const sourcePath = this.paths.get(moduleName);
|
||||||
|
if (!sourcePath) {
|
||||||
|
throw new Error(`No source path for custom module '${moduleName}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(sourcePath))) {
|
||||||
|
throw new Error(`Source for custom module '${moduleName}' not found at: ${sourcePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = path.join(bmadDir, moduleName);
|
||||||
|
|
||||||
|
// Read custom.yaml and merge into module config
|
||||||
|
let moduleConfig = options.moduleConfig ? { ...options.moduleConfig } : {};
|
||||||
|
const customConfigPath = path.join(sourcePath, 'custom.yaml');
|
||||||
|
if (await fs.pathExists(customConfigPath)) {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(customConfigPath, 'utf8');
|
||||||
|
const customConfig = yaml.parse(content);
|
||||||
|
if (customConfig) {
|
||||||
|
moduleConfig = { ...moduleConfig, ...customConfig };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await prompts.log.warn(`Failed to read custom.yaml for ${moduleName}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove existing installation
|
||||||
|
if (await fs.pathExists(targetPath)) {
|
||||||
|
await fs.remove(targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy files with filtering
|
||||||
|
await this._copyWithFiltering(sourcePath, targetPath, fileTrackingCallback);
|
||||||
|
|
||||||
|
// Add to manifest
|
||||||
|
const manifest = new Manifest();
|
||||||
|
const versionInfo = await manifest.getModuleVersionInfo(moduleName, bmadDir, sourcePath);
|
||||||
|
await manifest.addModule(bmadDir, moduleName, {
|
||||||
|
version: versionInfo.version,
|
||||||
|
source: versionInfo.source,
|
||||||
|
npmPackage: versionInfo.npmPackage,
|
||||||
|
repoUrl: versionInfo.repoUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, module: moduleName, path: targetPath, moduleConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy module files, filtering out install-time-only artifacts.
|
||||||
|
* @param {string} sourcePath - Source module directory
|
||||||
|
* @param {string} targetPath - Target module directory
|
||||||
|
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
||||||
|
*/
|
||||||
|
async _copyWithFiltering(sourcePath, targetPath, fileTrackingCallback = null) {
|
||||||
|
const files = await this._getFileList(sourcePath);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.startsWith('sub-modules/')) continue;
|
||||||
|
|
||||||
|
const isInSidecar = path
|
||||||
|
.dirname(file)
|
||||||
|
.split('/')
|
||||||
|
.some((dir) => dir.toLowerCase().endsWith('-sidecar'));
|
||||||
|
if (isInSidecar) continue;
|
||||||
|
|
||||||
|
if (file === 'module.yaml') continue;
|
||||||
|
if (file === 'config.yaml') continue;
|
||||||
|
|
||||||
|
const sourceFile = path.join(sourcePath, file);
|
||||||
|
const targetFile = path.join(targetPath, file);
|
||||||
|
|
||||||
|
// Skip web-only agents
|
||||||
|
if (file.startsWith('agents/') && file.endsWith('.md')) {
|
||||||
|
const content = await fs.readFile(sourceFile, 'utf8');
|
||||||
|
if (/<agent[^>]*\slocalskip="true"[^>]*>/.test(content)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.ensureDir(path.dirname(targetFile));
|
||||||
|
await fs.copy(sourceFile, targetFile, { overwrite: true });
|
||||||
|
|
||||||
|
if (fileTrackingCallback) {
|
||||||
|
fileTrackingCallback(targetFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively list all files in a directory.
|
||||||
|
* @param {string} dir - Directory to scan
|
||||||
|
* @param {string} baseDir - Base directory for relative paths
|
||||||
|
* @returns {string[]} Relative file paths
|
||||||
|
*/
|
||||||
|
async _getFileList(dir, baseDir = dir) {
|
||||||
|
const files = [];
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...(await this._getFileList(fullPath, baseDir)));
|
||||||
|
} else {
|
||||||
|
files.push(path.relative(baseDir, fullPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover custom module source paths from all available sources.
|
||||||
|
* @param {Object} config - Installation configuration
|
||||||
|
* @param {Object} paths - InstallPaths instance
|
||||||
|
* @returns {Map<string, string>} Map of module ID to source path
|
||||||
|
*/
|
||||||
|
async discoverPaths(config, paths) {
|
||||||
|
this.paths = new Map();
|
||||||
|
|
||||||
|
if (config._quickUpdate) {
|
||||||
|
if (config._customModuleSources) {
|
||||||
|
for (const [moduleId, customInfo] of config._customModuleSources) {
|
||||||
|
this.paths.set(moduleId, customInfo.sourcePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
// From UI: selectedFiles
|
||||||
|
if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) {
|
||||||
|
const customHandler = new CustomHandler();
|
||||||
|
for (const customFile of config.customContent.selectedFiles) {
|
||||||
|
const customInfo = await customHandler.getCustomInfo(customFile, paths.projectRoot);
|
||||||
|
if (customInfo && customInfo.id) {
|
||||||
|
this.paths.set(customInfo.id, customInfo.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From UI: sources
|
||||||
|
if (config.customContent && config.customContent.sources) {
|
||||||
|
for (const source of config.customContent.sources) {
|
||||||
|
this.paths.set(source.id, source.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From UI: cachedModules
|
||||||
|
if (config.customContent && config.customContent.cachedModules) {
|
||||||
|
const selectedCachedIds = config.customContent.selectedCachedModules || [];
|
||||||
|
const shouldIncludeAll = selectedCachedIds.length === 0 && config.customContent.selected;
|
||||||
|
|
||||||
|
for (const cachedModule of config.customContent.cachedModules) {
|
||||||
|
if (cachedModule.id && cachedModule.cachePath && (shouldIncludeAll || selectedCachedIds.includes(cachedModule.id))) {
|
||||||
|
this.paths.set(cachedModule.id, cachedModule.cachePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.paths;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { CustomModules };
|
||||||
|
|
@ -0,0 +1,323 @@
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { execSync } = require('node:child_process');
|
||||||
|
const yaml = require('yaml');
|
||||||
|
const prompts = require('../prompts');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages external official modules defined in external-official-modules.yaml
|
||||||
|
* These are modules hosted in external repositories that can be installed
|
||||||
|
*
|
||||||
|
* @class ExternalModuleManager
|
||||||
|
*/
|
||||||
|
class ExternalModuleManager {
|
||||||
|
constructor() {
|
||||||
|
this.externalModulesConfigPath = path.join(__dirname, '../external-official-modules.yaml');
|
||||||
|
this.cachedModules = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and parse the external-official-modules.yaml file
|
||||||
|
* @returns {Object} Parsed YAML content with modules object
|
||||||
|
*/
|
||||||
|
async loadExternalModulesConfig() {
|
||||||
|
if (this.cachedModules) {
|
||||||
|
return this.cachedModules;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(this.externalModulesConfigPath, 'utf8');
|
||||||
|
const config = yaml.parse(content);
|
||||||
|
this.cachedModules = config;
|
||||||
|
return config;
|
||||||
|
} catch (error) {
|
||||||
|
await prompts.log.warn(`Failed to load external modules config: ${error.message}`);
|
||||||
|
return { modules: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of available external modules
|
||||||
|
* @returns {Array<Object>} Array of module info objects
|
||||||
|
*/
|
||||||
|
async listAvailable() {
|
||||||
|
const config = await this.loadExternalModulesConfig();
|
||||||
|
const modules = [];
|
||||||
|
|
||||||
|
for (const [key, moduleConfig] of Object.entries(config.modules || {})) {
|
||||||
|
modules.push({
|
||||||
|
key,
|
||||||
|
url: moduleConfig.url,
|
||||||
|
moduleDefinition: moduleConfig['module-definition'],
|
||||||
|
code: moduleConfig.code,
|
||||||
|
name: moduleConfig.name,
|
||||||
|
header: moduleConfig.header,
|
||||||
|
subheader: moduleConfig.subheader,
|
||||||
|
description: moduleConfig.description || '',
|
||||||
|
defaultSelected: moduleConfig.defaultSelected === true,
|
||||||
|
type: moduleConfig.type || 'community', // bmad-org or community
|
||||||
|
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
|
||||||
|
isExternal: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return modules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get module info by code
|
||||||
|
* @param {string} code - The module code (e.g., 'cis')
|
||||||
|
* @returns {Object|null} Module info or null if not found
|
||||||
|
*/
|
||||||
|
async getModuleByCode(code) {
|
||||||
|
const modules = await this.listAvailable();
|
||||||
|
return modules.find((m) => m.code === code) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get module info by key
|
||||||
|
* @param {string} key - The module key (e.g., 'bmad-creative-intelligence-suite')
|
||||||
|
* @returns {Object|null} Module info or null if not found
|
||||||
|
*/
|
||||||
|
async getModuleByKey(key) {
|
||||||
|
const config = await this.loadExternalModulesConfig();
|
||||||
|
const moduleConfig = config.modules?.[key];
|
||||||
|
|
||||||
|
if (!moduleConfig) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
url: moduleConfig.url,
|
||||||
|
moduleDefinition: moduleConfig['module-definition'],
|
||||||
|
code: moduleConfig.code,
|
||||||
|
name: moduleConfig.name,
|
||||||
|
header: moduleConfig.header,
|
||||||
|
subheader: moduleConfig.subheader,
|
||||||
|
description: moduleConfig.description || '',
|
||||||
|
defaultSelected: moduleConfig.defaultSelected === true,
|
||||||
|
type: moduleConfig.type || 'community', // bmad-org or community
|
||||||
|
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
|
||||||
|
isExternal: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a module code exists in external modules
|
||||||
|
* @param {string} code - The module code to check
|
||||||
|
* @returns {boolean} True if the module exists
|
||||||
|
*/
|
||||||
|
async hasModule(code) {
|
||||||
|
const module = await this.getModuleByCode(code);
|
||||||
|
return module !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the URL for a module by code
|
||||||
|
* @param {string} code - The module code
|
||||||
|
* @returns {string|null} The URL or null if not found
|
||||||
|
*/
|
||||||
|
async getModuleUrl(code) {
|
||||||
|
const module = await this.getModuleByCode(code);
|
||||||
|
return module ? module.url : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the module definition path for a module by code
|
||||||
|
* @param {string} code - The module code
|
||||||
|
* @returns {string|null} The module definition path or null if not found
|
||||||
|
*/
|
||||||
|
async getModuleDefinition(code) {
|
||||||
|
const module = await this.getModuleByCode(code);
|
||||||
|
return module ? module.moduleDefinition : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cache directory for external modules
|
||||||
|
* @returns {string} Path to the external modules cache directory
|
||||||
|
*/
|
||||||
|
getExternalCacheDir() {
|
||||||
|
const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules');
|
||||||
|
return cacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone an external module repository to cache
|
||||||
|
* @param {string} moduleCode - Code of the external module
|
||||||
|
* @param {Object} options - Clone options
|
||||||
|
* @param {boolean} options.silent - Suppress spinner output
|
||||||
|
* @returns {string} Path to the cloned repository
|
||||||
|
*/
|
||||||
|
async cloneExternalModule(moduleCode, options = {}) {
|
||||||
|
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||||
|
|
||||||
|
if (!moduleInfo) {
|
||||||
|
throw new Error(`External module '${moduleCode}' not found in external-official-modules.yaml`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheDir = this.getExternalCacheDir();
|
||||||
|
const moduleCacheDir = path.join(cacheDir, moduleCode);
|
||||||
|
const silent = options.silent || false;
|
||||||
|
|
||||||
|
// Create cache directory if it doesn't exist
|
||||||
|
await fs.ensureDir(cacheDir);
|
||||||
|
|
||||||
|
// Helper to create a spinner or a no-op when silent
|
||||||
|
const createSpinner = async () => {
|
||||||
|
if (silent) {
|
||||||
|
return {
|
||||||
|
start() {},
|
||||||
|
stop() {},
|
||||||
|
error() {},
|
||||||
|
message() {},
|
||||||
|
cancel() {},
|
||||||
|
clear() {},
|
||||||
|
get isSpinning() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
get isCancelled() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await prompts.spinner();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track if we need to install dependencies
|
||||||
|
let needsDependencyInstall = false;
|
||||||
|
let wasNewClone = false;
|
||||||
|
|
||||||
|
// Check if already cloned
|
||||||
|
if (await fs.pathExists(moduleCacheDir)) {
|
||||||
|
// Try to update if it's a git repo
|
||||||
|
const fetchSpinner = await createSpinner();
|
||||||
|
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
|
||||||
|
try {
|
||||||
|
const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
||||||
|
// Fetch and reset to remote - works better with shallow clones than pull
|
||||||
|
execSync('git fetch origin --depth 1', {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
execSync('git reset --hard origin/HEAD', {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
||||||
|
|
||||||
|
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
|
||||||
|
// Force dependency install if we got new code
|
||||||
|
if (currentRef !== newRef) {
|
||||||
|
needsDependencyInstall = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.name}`);
|
||||||
|
// If update fails, remove and re-clone
|
||||||
|
await fs.remove(moduleCacheDir);
|
||||||
|
wasNewClone = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wasNewClone = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone if not exists or was removed
|
||||||
|
if (wasNewClone) {
|
||||||
|
const fetchSpinner = await createSpinner();
|
||||||
|
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
|
||||||
|
try {
|
||||||
|
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
fetchSpinner.error(`Failed to fetch ${moduleInfo.name}`);
|
||||||
|
throw new Error(`Failed to clone external module '${moduleCode}': ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install dependencies if package.json exists
|
||||||
|
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
|
||||||
|
const nodeModulesPath = path.join(moduleCacheDir, 'node_modules');
|
||||||
|
if (await fs.pathExists(packageJsonPath)) {
|
||||||
|
// Install if node_modules doesn't exist, or if package.json is newer (dependencies changed)
|
||||||
|
const nodeModulesMissing = !(await fs.pathExists(nodeModulesPath));
|
||||||
|
|
||||||
|
// Force install if we updated or cloned new
|
||||||
|
if (needsDependencyInstall || wasNewClone || nodeModulesMissing) {
|
||||||
|
const installSpinner = await createSpinner();
|
||||||
|
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
|
||||||
|
try {
|
||||||
|
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 120_000, // 2 minute timeout
|
||||||
|
});
|
||||||
|
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
|
||||||
|
if (!silent) await prompts.log.warn(` ${error.message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Check if package.json is newer than node_modules
|
||||||
|
let packageJsonNewer = false;
|
||||||
|
try {
|
||||||
|
const packageStats = await fs.stat(packageJsonPath);
|
||||||
|
const nodeModulesStats = await fs.stat(nodeModulesPath);
|
||||||
|
packageJsonNewer = packageStats.mtime > nodeModulesStats.mtime;
|
||||||
|
} catch {
|
||||||
|
// If stat fails, assume we need to install
|
||||||
|
packageJsonNewer = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packageJsonNewer) {
|
||||||
|
const installSpinner = await createSpinner();
|
||||||
|
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
|
||||||
|
try {
|
||||||
|
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 120_000, // 2 minute timeout
|
||||||
|
});
|
||||||
|
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
|
||||||
|
if (!silent) await prompts.log.warn(` ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return moduleCacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the source path for an external module
|
||||||
|
* @param {string} moduleCode - Code of the external module
|
||||||
|
* @param {Object} options - Options passed to cloneExternalModule
|
||||||
|
* @returns {string|null} Path to the module source or null if not found
|
||||||
|
*/
|
||||||
|
async findExternalModuleSource(moduleCode, options = {}) {
|
||||||
|
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||||
|
|
||||||
|
if (!moduleInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone the external module repo
|
||||||
|
const cloneDir = await this.cloneExternalModule(moduleCode, options);
|
||||||
|
|
||||||
|
// The module-definition specifies the path to module.yaml relative to repo root
|
||||||
|
// We need to return the directory containing module.yaml
|
||||||
|
const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'src/module.yaml'
|
||||||
|
const moduleDir = path.dirname(path.join(cloneDir, moduleDefinitionPath));
|
||||||
|
|
||||||
|
return moduleDir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ExternalModuleManager };
|
||||||
|
|
@ -1,30 +1,701 @@
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const { getProjectRoot, getModulePath } = require('../../../lib/project-root');
|
const prompts = require('../prompts');
|
||||||
const { CLIUtils } = require('../../../lib/cli-utils');
|
const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root');
|
||||||
const prompts = require('../../../lib/prompts');
|
const { CLIUtils } = require('../cli-utils');
|
||||||
|
const { ExternalModuleManager } = require('./external-manager');
|
||||||
|
|
||||||
class ConfigCollector {
|
class OfficialModules {
|
||||||
constructor() {
|
constructor(options = {}) {
|
||||||
|
this.externalModuleManager = new ExternalModuleManager();
|
||||||
|
// Config collection state (merged from ConfigCollector)
|
||||||
this.collectedConfig = {};
|
this.collectedConfig = {};
|
||||||
this.existingConfig = null;
|
this._existingConfig = null;
|
||||||
this.currentProjectDir = null;
|
this.currentProjectDir = null;
|
||||||
this._moduleManagerInstance = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a cached ModuleManager instance (lazy initialization)
|
* Module configurations collected during install.
|
||||||
* @returns {Object} ModuleManager instance
|
|
||||||
*/
|
*/
|
||||||
_getModuleManager() {
|
get moduleConfigs() {
|
||||||
if (!this._moduleManagerInstance) {
|
return this.collectedConfig;
|
||||||
const { ModuleManager } = require('../modules/manager');
|
|
||||||
this._moduleManagerInstance = new ModuleManager();
|
|
||||||
}
|
|
||||||
return this._moduleManagerInstance;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Existing module configurations read from a previous installation.
|
||||||
|
*/
|
||||||
|
get existingConfig() {
|
||||||
|
return this._existingConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a configured OfficialModules instance from install config.
|
||||||
|
* @param {Object} config - Clean install config (from Config.build)
|
||||||
|
* @param {Object} paths - InstallPaths instance
|
||||||
|
* @returns {OfficialModules}
|
||||||
|
*/
|
||||||
|
static async build(config, paths) {
|
||||||
|
const instance = new OfficialModules();
|
||||||
|
|
||||||
|
// Pre-collected by UI or quickUpdate — store and load existing for path-change detection
|
||||||
|
if (config.moduleConfigs) {
|
||||||
|
instance.collectedConfig = config.moduleConfigs;
|
||||||
|
await instance.loadExistingConfig(paths.projectRoot);
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headless collection (--yes flag from CLI without UI, tests)
|
||||||
|
if (config.hasCoreConfig()) {
|
||||||
|
instance.collectedConfig.core = config.coreConfig;
|
||||||
|
instance.allAnswers = {};
|
||||||
|
for (const [key, value] of Object.entries(config.coreConfig)) {
|
||||||
|
instance.allAnswers[`core_${key}`] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCollect = config.hasCoreConfig() ? config.modules.filter((m) => m !== 'core') : [...config.modules];
|
||||||
|
|
||||||
|
await instance.collectAllConfigurations(toCollect, paths.projectRoot, {
|
||||||
|
skipPrompts: config.skipPrompts,
|
||||||
|
});
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a file to the target location
|
||||||
|
* @param {string} sourcePath - Source file path
|
||||||
|
* @param {string} targetPath - Target file path
|
||||||
|
* @param {boolean} overwrite - Whether to overwrite existing files (default: true)
|
||||||
|
*/
|
||||||
|
async copyFile(sourcePath, targetPath, overwrite = true) {
|
||||||
|
await fs.copy(sourcePath, targetPath, { overwrite });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a directory recursively
|
||||||
|
* @param {string} sourceDir - Source directory path
|
||||||
|
* @param {string} targetDir - Target directory path
|
||||||
|
* @param {boolean} overwrite - Whether to overwrite existing files (default: true)
|
||||||
|
*/
|
||||||
|
async copyDirectory(sourceDir, targetDir, overwrite = true) {
|
||||||
|
await fs.ensureDir(targetDir);
|
||||||
|
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const sourcePath = path.join(sourceDir, entry.name);
|
||||||
|
const targetPath = path.join(targetDir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await this.copyDirectory(sourcePath, targetPath, overwrite);
|
||||||
|
} else {
|
||||||
|
await this.copyFile(sourcePath, targetPath, overwrite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all available built-in modules (core and bmm).
|
||||||
|
* All other modules come from external-official-modules.yaml
|
||||||
|
* @returns {Object} Object with modules array and customModules array
|
||||||
|
*/
|
||||||
|
async listAvailable() {
|
||||||
|
const modules = [];
|
||||||
|
const customModules = [];
|
||||||
|
|
||||||
|
// Add built-in core module (directly under src/core-skills)
|
||||||
|
const corePath = getSourcePath('core-skills');
|
||||||
|
if (await fs.pathExists(corePath)) {
|
||||||
|
const coreInfo = await this.getModuleInfo(corePath, 'core', 'src/core-skills');
|
||||||
|
if (coreInfo) {
|
||||||
|
modules.push(coreInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add built-in bmm module (directly under src/bmm-skills)
|
||||||
|
const bmmPath = getSourcePath('bmm-skills');
|
||||||
|
if (await fs.pathExists(bmmPath)) {
|
||||||
|
const bmmInfo = await this.getModuleInfo(bmmPath, 'bmm', 'src/bmm-skills');
|
||||||
|
if (bmmInfo) {
|
||||||
|
modules.push(bmmInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { modules, customModules };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get module information from a module path
|
||||||
|
* @param {string} modulePath - Path to the module directory
|
||||||
|
* @param {string} defaultName - Default name for the module
|
||||||
|
* @param {string} sourceDescription - Description of where the module was found
|
||||||
|
* @returns {Object|null} Module info or null if not a valid module
|
||||||
|
*/
|
||||||
|
async getModuleInfo(modulePath, defaultName, sourceDescription) {
|
||||||
|
// Check for module structure (module.yaml OR custom.yaml)
|
||||||
|
const moduleConfigPath = path.join(modulePath, 'module.yaml');
|
||||||
|
const rootCustomConfigPath = path.join(modulePath, 'custom.yaml');
|
||||||
|
let configPath = null;
|
||||||
|
|
||||||
|
if (await fs.pathExists(moduleConfigPath)) {
|
||||||
|
configPath = moduleConfigPath;
|
||||||
|
} else if (await fs.pathExists(rootCustomConfigPath)) {
|
||||||
|
configPath = rootCustomConfigPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if this doesn't look like a module
|
||||||
|
if (!configPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as custom if it's using custom.yaml OR if it's outside src/bmm or src/core
|
||||||
|
const isCustomSource =
|
||||||
|
sourceDescription !== 'src/bmm-skills' && sourceDescription !== 'src/core-skills' && sourceDescription !== 'src/modules';
|
||||||
|
const moduleInfo = {
|
||||||
|
id: defaultName,
|
||||||
|
path: modulePath,
|
||||||
|
name: defaultName
|
||||||
|
.split('-')
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' '),
|
||||||
|
description: 'BMAD Module',
|
||||||
|
version: '5.0.0',
|
||||||
|
source: sourceDescription,
|
||||||
|
isCustom: configPath === rootCustomConfigPath || isCustomSource,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read module config for metadata
|
||||||
|
try {
|
||||||
|
const configContent = await fs.readFile(configPath, 'utf8');
|
||||||
|
const config = yaml.parse(configContent);
|
||||||
|
|
||||||
|
// Use the code property as the id if available
|
||||||
|
if (config.code) {
|
||||||
|
moduleInfo.id = config.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
moduleInfo.name = config.name || moduleInfo.name;
|
||||||
|
moduleInfo.description = config.description || moduleInfo.description;
|
||||||
|
moduleInfo.version = config.version || moduleInfo.version;
|
||||||
|
moduleInfo.dependencies = config.dependencies || [];
|
||||||
|
moduleInfo.defaultSelected = config.default_selected === undefined ? false : config.default_selected;
|
||||||
|
} catch (error) {
|
||||||
|
await prompts.log.warn(`Failed to read config for ${defaultName}: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return moduleInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the source path for a module by searching all possible locations
|
||||||
|
* @param {string} moduleCode - Code of the module to find (from module.yaml)
|
||||||
|
* @returns {string|null} Path to the module source or null if not found
|
||||||
|
*/
|
||||||
|
async findModuleSource(moduleCode, options = {}) {
|
||||||
|
const projectRoot = getProjectRoot();
|
||||||
|
|
||||||
|
// Check for core module (directly under src/core-skills)
|
||||||
|
if (moduleCode === 'core') {
|
||||||
|
const corePath = getSourcePath('core-skills');
|
||||||
|
if (await fs.pathExists(corePath)) {
|
||||||
|
return corePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for built-in bmm module (directly under src/bmm-skills)
|
||||||
|
if (moduleCode === 'bmm') {
|
||||||
|
const bmmPath = getSourcePath('bmm-skills');
|
||||||
|
if (await fs.pathExists(bmmPath)) {
|
||||||
|
return bmmPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check external official modules
|
||||||
|
const externalSource = await this.externalModuleManager.findExternalModuleSource(moduleCode, options);
|
||||||
|
if (externalSource) {
|
||||||
|
return externalSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a module
|
||||||
|
* @param {string} moduleName - Code of the module to install (from module.yaml)
|
||||||
|
* @param {string} bmadDir - Target bmad directory
|
||||||
|
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
||||||
|
* @param {Object} options - Additional installation options
|
||||||
|
* @param {Array<string>} options.installedIDEs - Array of IDE codes that were installed
|
||||||
|
* @param {Object} options.moduleConfig - Module configuration from config collector
|
||||||
|
* @param {Object} options.logger - Logger instance for output
|
||||||
|
*/
|
||||||
|
async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
|
||||||
|
const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent });
|
||||||
|
const targetPath = path.join(bmadDir, moduleName);
|
||||||
|
|
||||||
|
if (!sourcePath) {
|
||||||
|
throw new Error(
|
||||||
|
`Source for module '${moduleName}' is not available. It will be retained but cannot be updated without its source files.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await fs.pathExists(targetPath)) {
|
||||||
|
await fs.remove(targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
|
||||||
|
|
||||||
|
if (!options.skipModuleInstaller) {
|
||||||
|
await this.createModuleDirectories(moduleName, bmadDir, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Manifest } = require('../core/manifest');
|
||||||
|
const manifestObj = new Manifest();
|
||||||
|
const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath);
|
||||||
|
|
||||||
|
await manifestObj.addModule(bmadDir, moduleName, {
|
||||||
|
version: versionInfo.version,
|
||||||
|
source: versionInfo.source,
|
||||||
|
npmPackage: versionInfo.npmPackage,
|
||||||
|
repoUrl: versionInfo.repoUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, module: moduleName, path: targetPath, versionInfo };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing module
|
||||||
|
* @param {string} moduleName - Name of the module to update
|
||||||
|
* @param {string} bmadDir - Target bmad directory
|
||||||
|
*/
|
||||||
|
async update(moduleName, bmadDir) {
|
||||||
|
const sourcePath = await this.findModuleSource(moduleName);
|
||||||
|
const targetPath = path.join(bmadDir, moduleName);
|
||||||
|
|
||||||
|
if (!sourcePath) {
|
||||||
|
throw new Error(`Module '${moduleName}' not found in any source location`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(targetPath))) {
|
||||||
|
throw new Error(`Module '${moduleName}' is not installed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.syncModule(sourcePath, targetPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
module: moduleName,
|
||||||
|
path: targetPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a module
|
||||||
|
* @param {string} moduleName - Name of the module to remove
|
||||||
|
* @param {string} bmadDir - Target bmad directory
|
||||||
|
*/
|
||||||
|
async remove(moduleName, bmadDir) {
|
||||||
|
const targetPath = path.join(bmadDir, moduleName);
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(targetPath))) {
|
||||||
|
throw new Error(`Module '${moduleName}' is not installed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.remove(targetPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
module: moduleName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a module is installed
|
||||||
|
* @param {string} moduleName - Name of the module
|
||||||
|
* @param {string} bmadDir - Target bmad directory
|
||||||
|
* @returns {boolean} True if module is installed
|
||||||
|
*/
|
||||||
|
async isInstalled(moduleName, bmadDir) {
|
||||||
|
const targetPath = path.join(bmadDir, moduleName);
|
||||||
|
return await fs.pathExists(targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get installed module info
|
||||||
|
* @param {string} moduleName - Name of the module
|
||||||
|
* @param {string} bmadDir - Target bmad directory
|
||||||
|
* @returns {Object|null} Module info or null if not installed
|
||||||
|
*/
|
||||||
|
async getInstalledInfo(moduleName, bmadDir) {
|
||||||
|
const targetPath = path.join(bmadDir, moduleName);
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(targetPath))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = path.join(targetPath, 'config.yaml');
|
||||||
|
const moduleInfo = {
|
||||||
|
id: moduleName,
|
||||||
|
path: targetPath,
|
||||||
|
installed: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (await fs.pathExists(configPath)) {
|
||||||
|
try {
|
||||||
|
const configContent = await fs.readFile(configPath, 'utf8');
|
||||||
|
const config = yaml.parse(configContent);
|
||||||
|
Object.assign(moduleInfo, config);
|
||||||
|
} catch (error) {
|
||||||
|
await prompts.log.warn(`Failed to read installed module config: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return moduleInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy module with filtering for localskip agents and conditional content
|
||||||
|
* @param {string} sourcePath - Source module path
|
||||||
|
* @param {string} targetPath - Target module path
|
||||||
|
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
||||||
|
* @param {Object} moduleConfig - Module configuration with conditional flags
|
||||||
|
*/
|
||||||
|
async copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback = null, moduleConfig = {}) {
|
||||||
|
// Get all files in source
|
||||||
|
const sourceFiles = await this.getFileList(sourcePath);
|
||||||
|
|
||||||
|
for (const file of sourceFiles) {
|
||||||
|
// Skip sub-modules directory - these are IDE-specific and handled separately
|
||||||
|
if (file.startsWith('sub-modules/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip sidecar directories - these contain agent-specific assets not needed at install time
|
||||||
|
const isInSidecarDirectory = path
|
||||||
|
.dirname(file)
|
||||||
|
.split('/')
|
||||||
|
.some((dir) => dir.toLowerCase().endsWith('-sidecar'));
|
||||||
|
|
||||||
|
if (isInSidecarDirectory) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip module.yaml at root - it's only needed at install time
|
||||||
|
if (file === 'module.yaml') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip module root config.yaml only - generated by config collector with actual values
|
||||||
|
// Workflow-level config.yaml (e.g. workflows/orchestrate-story/config.yaml) must be copied
|
||||||
|
// for custom modules that use workflow-specific configuration
|
||||||
|
if (file === 'config.yaml') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceFile = path.join(sourcePath, file);
|
||||||
|
const targetFile = path.join(targetPath, file);
|
||||||
|
|
||||||
|
// Check if this is an agent file
|
||||||
|
if (file.startsWith('agents/') && file.endsWith('.md')) {
|
||||||
|
// Read the file to check for localskip
|
||||||
|
const content = await fs.readFile(sourceFile, 'utf8');
|
||||||
|
|
||||||
|
// Check for localskip="true" in the agent tag
|
||||||
|
const agentMatch = content.match(/<agent[^>]*\slocalskip="true"[^>]*>/);
|
||||||
|
if (agentMatch) {
|
||||||
|
await prompts.log.message(` Skipping web-only agent: ${path.basename(file)}`);
|
||||||
|
continue; // Skip this agent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the file with placeholder replacement
|
||||||
|
await this.copyFile(sourceFile, targetFile);
|
||||||
|
|
||||||
|
// Track the file if callback provided
|
||||||
|
if (fileTrackingCallback) {
|
||||||
|
fileTrackingCallback(targetFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all .md agent files recursively in a directory
|
||||||
|
* @param {string} dir - Directory to search
|
||||||
|
* @returns {Array} List of .md agent file paths
|
||||||
|
*/
|
||||||
|
async findAgentMdFiles(dir) {
|
||||||
|
const agentFiles = [];
|
||||||
|
|
||||||
|
async function searchDirectory(searchDir) {
|
||||||
|
const entries = await fs.readdir(searchDir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(searchDir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||||
|
agentFiles.push(fullPath);
|
||||||
|
} else if (entry.isDirectory()) {
|
||||||
|
await searchDirectory(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await searchDirectory(dir);
|
||||||
|
return agentFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create directories declared in module.yaml's `directories` key
|
||||||
|
* This replaces the security-risky module installer pattern with declarative config
|
||||||
|
* During updates, if a directory path changed, moves the old directory to the new path
|
||||||
|
* @param {string} moduleName - Name of the module
|
||||||
|
* @param {string} bmadDir - Target bmad directory
|
||||||
|
* @param {Object} options - Installation options
|
||||||
|
* @param {Object} options.moduleConfig - Module configuration from config collector
|
||||||
|
* @param {Object} options.existingModuleConfig - Previous module config (for detecting path changes during updates)
|
||||||
|
* @param {Object} options.coreConfig - Core configuration
|
||||||
|
* @returns {Promise<{createdDirs: string[], movedDirs: string[], createdWdsFolders: string[]}>} Created directories info
|
||||||
|
*/
|
||||||
|
async createModuleDirectories(moduleName, bmadDir, options = {}) {
|
||||||
|
const moduleConfig = options.moduleConfig || {};
|
||||||
|
const existingModuleConfig = options.existingModuleConfig || {};
|
||||||
|
const projectRoot = path.dirname(bmadDir);
|
||||||
|
const emptyResult = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
|
||||||
|
|
||||||
|
// Special handling for core module - it's in src/core-skills not src/modules
|
||||||
|
let sourcePath;
|
||||||
|
if (moduleName === 'core') {
|
||||||
|
sourcePath = getSourcePath('core-skills');
|
||||||
|
} else {
|
||||||
|
sourcePath = await this.findModuleSource(moduleName, { silent: true });
|
||||||
|
if (!sourcePath) {
|
||||||
|
return emptyResult; // No source found, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read module.yaml to find the `directories` key
|
||||||
|
const moduleYamlPath = path.join(sourcePath, 'module.yaml');
|
||||||
|
if (!(await fs.pathExists(moduleYamlPath))) {
|
||||||
|
return emptyResult; // No module.yaml, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
let moduleYaml;
|
||||||
|
try {
|
||||||
|
const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
|
||||||
|
moduleYaml = yaml.parse(yamlContent);
|
||||||
|
} catch (error) {
|
||||||
|
await prompts.log.warn(`Invalid module.yaml for ${moduleName}: ${error.message}`);
|
||||||
|
return emptyResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!moduleYaml || !moduleYaml.directories) {
|
||||||
|
return emptyResult; // No directories declared, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
const directories = moduleYaml.directories;
|
||||||
|
const wdsFolders = moduleYaml.wds_folders || [];
|
||||||
|
const createdDirs = [];
|
||||||
|
const movedDirs = [];
|
||||||
|
const createdWdsFolders = [];
|
||||||
|
|
||||||
|
for (const dirRef of directories) {
|
||||||
|
// Parse variable reference like "{design_artifacts}"
|
||||||
|
const varMatch = dirRef.match(/^\{([^}]+)\}$/);
|
||||||
|
if (!varMatch) {
|
||||||
|
// Not a variable reference, skip
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configKey = varMatch[1];
|
||||||
|
const dirValue = moduleConfig[configKey];
|
||||||
|
if (!dirValue || typeof dirValue !== 'string') {
|
||||||
|
continue; // No value or not a string, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip {project-root}/ prefix if present
|
||||||
|
let dirPath = dirValue.replace(/^\{project-root\}\/?/, '');
|
||||||
|
|
||||||
|
// Handle remaining {project-root} anywhere in the path
|
||||||
|
dirPath = dirPath.replaceAll('{project-root}', '');
|
||||||
|
|
||||||
|
// Resolve to absolute path
|
||||||
|
const fullPath = path.join(projectRoot, dirPath);
|
||||||
|
|
||||||
|
// Validate path is within project root (prevent directory traversal)
|
||||||
|
const normalizedPath = path.normalize(fullPath);
|
||||||
|
const normalizedRoot = path.normalize(projectRoot);
|
||||||
|
if (!normalizedPath.startsWith(normalizedRoot + path.sep) && normalizedPath !== normalizedRoot) {
|
||||||
|
const color = await prompts.getColor();
|
||||||
|
await prompts.log.warn(color.yellow(`${configKey} path escapes project root, skipping: ${dirPath}`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if directory path changed from previous config (update/modify scenario)
|
||||||
|
const oldDirValue = existingModuleConfig[configKey];
|
||||||
|
let oldFullPath = null;
|
||||||
|
let oldDirPath = null;
|
||||||
|
if (oldDirValue && typeof oldDirValue === 'string') {
|
||||||
|
// F3: Normalize both values before comparing to avoid false negatives
|
||||||
|
// from trailing slashes, separator differences, or prefix format variations
|
||||||
|
let normalizedOld = oldDirValue.replace(/^\{project-root\}\/?/, '');
|
||||||
|
normalizedOld = path.normalize(normalizedOld.replaceAll('{project-root}', ''));
|
||||||
|
const normalizedNew = path.normalize(dirPath);
|
||||||
|
|
||||||
|
if (normalizedOld !== normalizedNew) {
|
||||||
|
oldDirPath = normalizedOld;
|
||||||
|
oldFullPath = path.join(projectRoot, oldDirPath);
|
||||||
|
const normalizedOldAbsolute = path.normalize(oldFullPath);
|
||||||
|
if (!normalizedOldAbsolute.startsWith(normalizedRoot + path.sep) && normalizedOldAbsolute !== normalizedRoot) {
|
||||||
|
oldFullPath = null; // Old path escapes project root, ignore it
|
||||||
|
}
|
||||||
|
|
||||||
|
// F13: Prevent parent/child move (e.g. docs/planning → docs/planning/v2)
|
||||||
|
if (oldFullPath) {
|
||||||
|
const normalizedNewAbsolute = path.normalize(fullPath);
|
||||||
|
if (
|
||||||
|
normalizedOldAbsolute.startsWith(normalizedNewAbsolute + path.sep) ||
|
||||||
|
normalizedNewAbsolute.startsWith(normalizedOldAbsolute + path.sep)
|
||||||
|
) {
|
||||||
|
const color = await prompts.getColor();
|
||||||
|
await prompts.log.warn(
|
||||||
|
color.yellow(
|
||||||
|
`${configKey}: cannot move between parent/child paths (${oldDirPath} / ${dirPath}), creating new directory instead`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
oldFullPath = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirName = configKey.replaceAll('_', ' ');
|
||||||
|
|
||||||
|
if (oldFullPath && (await fs.pathExists(oldFullPath)) && !(await fs.pathExists(fullPath))) {
|
||||||
|
// Path changed and old dir exists → move old to new location
|
||||||
|
// F1: Use fs.move() instead of fs.rename() for cross-device/volume support
|
||||||
|
// F2: Wrap in try/catch — fallback to creating new dir on failure
|
||||||
|
try {
|
||||||
|
await fs.ensureDir(path.dirname(fullPath));
|
||||||
|
await fs.move(oldFullPath, fullPath);
|
||||||
|
movedDirs.push(`${dirName}: ${oldDirPath} → ${dirPath}`);
|
||||||
|
} catch (moveError) {
|
||||||
|
const color = await prompts.getColor();
|
||||||
|
await prompts.log.warn(
|
||||||
|
color.yellow(
|
||||||
|
`Failed to move ${oldDirPath} → ${dirPath}: ${moveError.message}\n Creating new directory instead. Please move contents from the old directory manually.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await fs.ensureDir(fullPath);
|
||||||
|
createdDirs.push(`${dirName}: ${dirPath}`);
|
||||||
|
}
|
||||||
|
} else if (oldFullPath && (await fs.pathExists(oldFullPath)) && (await fs.pathExists(fullPath))) {
|
||||||
|
// F5: Both old and new directories exist — warn user about potential orphaned documents
|
||||||
|
const color = await prompts.getColor();
|
||||||
|
await prompts.log.warn(
|
||||||
|
color.yellow(
|
||||||
|
`${dirName}: path changed but both directories exist:\n Old: ${oldDirPath}\n New: ${dirPath}\n Old directory may contain orphaned documents — please review and merge manually.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (!(await fs.pathExists(fullPath))) {
|
||||||
|
// New directory doesn't exist yet → create it
|
||||||
|
createdDirs.push(`${dirName}: ${dirPath}`);
|
||||||
|
await fs.ensureDir(fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create WDS subfolders if this is the design_artifacts directory
|
||||||
|
if (configKey === 'design_artifacts' && wdsFolders.length > 0) {
|
||||||
|
for (const subfolder of wdsFolders) {
|
||||||
|
const subPath = path.join(fullPath, subfolder);
|
||||||
|
if (!(await fs.pathExists(subPath))) {
|
||||||
|
await fs.ensureDir(subPath);
|
||||||
|
createdWdsFolders.push(subfolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { createdDirs, movedDirs, createdWdsFolders };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private: Process module configuration
|
||||||
|
* @param {string} modulePath - Path to installed module
|
||||||
|
* @param {string} moduleName - Module name
|
||||||
|
*/
|
||||||
|
async processModuleConfig(modulePath, moduleName) {
|
||||||
|
const configPath = path.join(modulePath, 'config.yaml');
|
||||||
|
|
||||||
|
if (await fs.pathExists(configPath)) {
|
||||||
|
try {
|
||||||
|
let configContent = await fs.readFile(configPath, 'utf8');
|
||||||
|
|
||||||
|
// Replace path placeholders
|
||||||
|
configContent = configContent.replaceAll('{project-root}', `bmad/${moduleName}`);
|
||||||
|
configContent = configContent.replaceAll('{module}', moduleName);
|
||||||
|
|
||||||
|
await fs.writeFile(configPath, configContent, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
await prompts.log.warn(`Failed to process module config: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private: Sync module files (preserving user modifications)
|
||||||
|
* @param {string} sourcePath - Source module path
|
||||||
|
* @param {string} targetPath - Target module path
|
||||||
|
*/
|
||||||
|
async syncModule(sourcePath, targetPath) {
|
||||||
|
// Get list of all source files
|
||||||
|
const sourceFiles = await this.getFileList(sourcePath);
|
||||||
|
|
||||||
|
for (const file of sourceFiles) {
|
||||||
|
const sourceFile = path.join(sourcePath, file);
|
||||||
|
const targetFile = path.join(targetPath, file);
|
||||||
|
|
||||||
|
// Check if target file exists and has been modified
|
||||||
|
if (await fs.pathExists(targetFile)) {
|
||||||
|
const sourceStats = await fs.stat(sourceFile);
|
||||||
|
const targetStats = await fs.stat(targetFile);
|
||||||
|
|
||||||
|
// Skip if target is newer (user modified)
|
||||||
|
if (targetStats.mtime > sourceStats.mtime) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy file with placeholder replacement
|
||||||
|
await this.copyFile(sourceFile, targetFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private: Get list of all files in a directory
|
||||||
|
* @param {string} dir - Directory path
|
||||||
|
* @param {string} baseDir - Base directory for relative paths
|
||||||
|
* @returns {Array} List of relative file paths
|
||||||
|
*/
|
||||||
|
async getFileList(dir, baseDir = dir) {
|
||||||
|
const files = [];
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const subFiles = await this.getFileList(fullPath, baseDir);
|
||||||
|
files.push(...subFiles);
|
||||||
|
} else {
|
||||||
|
files.push(path.relative(baseDir, fullPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Config collection methods (merged from ConfigCollector) ───
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the bmad installation directory in a project
|
* Find the bmad installation directory in a project
|
||||||
* V6+ installations can use ANY folder name but ALWAYS have _config/manifest.yaml
|
* V6+ installations can use ANY folder name but ALWAYS have _config/manifest.yaml
|
||||||
|
|
@ -95,7 +766,7 @@ class ConfigCollector {
|
||||||
* @param {string} projectDir - Target project directory
|
* @param {string} projectDir - Target project directory
|
||||||
*/
|
*/
|
||||||
async loadExistingConfig(projectDir) {
|
async loadExistingConfig(projectDir) {
|
||||||
this.existingConfig = {};
|
this._existingConfig = {};
|
||||||
|
|
||||||
// Check if project directory exists first
|
// Check if project directory exists first
|
||||||
if (!(await fs.pathExists(projectDir))) {
|
if (!(await fs.pathExists(projectDir))) {
|
||||||
|
|
@ -129,7 +800,7 @@ class ConfigCollector {
|
||||||
const content = await fs.readFile(moduleConfigPath, 'utf8');
|
const content = await fs.readFile(moduleConfigPath, 'utf8');
|
||||||
const moduleConfig = yaml.parse(content);
|
const moduleConfig = yaml.parse(content);
|
||||||
if (moduleConfig) {
|
if (moduleConfig) {
|
||||||
this.existingConfig[entry.name] = moduleConfig;
|
this._existingConfig[entry.name] = moduleConfig;
|
||||||
foundAny = true;
|
foundAny = true;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -153,7 +824,7 @@ class ConfigCollector {
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
for (const moduleName of modules) {
|
for (const moduleName of modules) {
|
||||||
// Resolve module.yaml path - custom paths first, then standard location, then ModuleManager search
|
// Resolve module.yaml path - custom paths first, then standard location, then OfficialModules search
|
||||||
let moduleConfigPath = null;
|
let moduleConfigPath = null;
|
||||||
const customPath = this.customModulePaths?.get(moduleName);
|
const customPath = this.customModulePaths?.get(moduleName);
|
||||||
if (customPath) {
|
if (customPath) {
|
||||||
|
|
@ -163,7 +834,7 @@ class ConfigCollector {
|
||||||
if (await fs.pathExists(standardPath)) {
|
if (await fs.pathExists(standardPath)) {
|
||||||
moduleConfigPath = standardPath;
|
moduleConfigPath = standardPath;
|
||||||
} else {
|
} else {
|
||||||
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
|
const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true });
|
||||||
if (moduleSourcePath) {
|
if (moduleSourcePath) {
|
||||||
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
||||||
}
|
}
|
||||||
|
|
@ -349,7 +1020,7 @@ class ConfigCollector {
|
||||||
this.currentProjectDir = projectDir;
|
this.currentProjectDir = projectDir;
|
||||||
|
|
||||||
// Load existing config if not already loaded
|
// Load existing config if not already loaded
|
||||||
if (!this.existingConfig) {
|
if (!this._existingConfig) {
|
||||||
await this.loadExistingConfig(projectDir);
|
await this.loadExistingConfig(projectDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -364,7 +1035,7 @@ class ConfigCollector {
|
||||||
|
|
||||||
// If not found in src/modules, we need to find it by searching the project
|
// If not found in src/modules, we need to find it by searching the project
|
||||||
if (!(await fs.pathExists(moduleConfigPath))) {
|
if (!(await fs.pathExists(moduleConfigPath))) {
|
||||||
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
|
const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true });
|
||||||
|
|
||||||
if (moduleSourcePath) {
|
if (moduleSourcePath) {
|
||||||
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
||||||
|
|
@ -378,7 +1049,7 @@ class ConfigCollector {
|
||||||
configPath = moduleConfigPath;
|
configPath = moduleConfigPath;
|
||||||
} else {
|
} else {
|
||||||
// Check if this is a custom module with custom.yaml
|
// Check if this is a custom module with custom.yaml
|
||||||
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
|
const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true });
|
||||||
|
|
||||||
if (moduleSourcePath) {
|
if (moduleSourcePath) {
|
||||||
const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml');
|
const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml');
|
||||||
|
|
@ -391,11 +1062,11 @@ class ConfigCollector {
|
||||||
}
|
}
|
||||||
|
|
||||||
// No config schema for this module - use existing values
|
// No config schema for this module - use existing values
|
||||||
if (this.existingConfig && this.existingConfig[moduleName]) {
|
if (this._existingConfig && this._existingConfig[moduleName]) {
|
||||||
if (!this.collectedConfig[moduleName]) {
|
if (!this.collectedConfig[moduleName]) {
|
||||||
this.collectedConfig[moduleName] = {};
|
this.collectedConfig[moduleName] = {};
|
||||||
}
|
}
|
||||||
this.collectedConfig[moduleName] = { ...this.existingConfig[moduleName] };
|
this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] };
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -409,7 +1080,7 @@ class ConfigCollector {
|
||||||
|
|
||||||
// Compare schema with existing config to find new/missing fields
|
// Compare schema with existing config to find new/missing fields
|
||||||
const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt');
|
const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt');
|
||||||
const existingKeys = this.existingConfig && this.existingConfig[moduleName] ? Object.keys(this.existingConfig[moduleName]) : [];
|
const existingKeys = this._existingConfig && this._existingConfig[moduleName] ? Object.keys(this._existingConfig[moduleName]) : [];
|
||||||
|
|
||||||
// Check if this module has no configuration keys at all (like CIS)
|
// Check if this module has no configuration keys at all (like CIS)
|
||||||
// Filter out metadata fields and only count actual config objects
|
// Filter out metadata fields and only count actual config objects
|
||||||
|
|
@ -440,11 +1111,11 @@ class ConfigCollector {
|
||||||
|
|
||||||
// If in silent mode and no new keys (neither interactive nor static), use existing config and skip prompts
|
// If in silent mode and no new keys (neither interactive nor static), use existing config and skip prompts
|
||||||
if (silentMode && newKeys.length === 0 && newStaticKeys.length === 0) {
|
if (silentMode && newKeys.length === 0 && newStaticKeys.length === 0) {
|
||||||
if (this.existingConfig && this.existingConfig[moduleName]) {
|
if (this._existingConfig && this._existingConfig[moduleName]) {
|
||||||
if (!this.collectedConfig[moduleName]) {
|
if (!this.collectedConfig[moduleName]) {
|
||||||
this.collectedConfig[moduleName] = {};
|
this.collectedConfig[moduleName] = {};
|
||||||
}
|
}
|
||||||
this.collectedConfig[moduleName] = { ...this.existingConfig[moduleName] };
|
this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] };
|
||||||
|
|
||||||
// Special handling for user_name: ensure it has a value
|
// Special handling for user_name: ensure it has a value
|
||||||
if (
|
if (
|
||||||
|
|
@ -455,7 +1126,7 @@ class ConfigCollector {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also populate allAnswers for cross-referencing
|
// Also populate allAnswers for cross-referencing
|
||||||
for (const [key, value] of Object.entries(this.existingConfig[moduleName])) {
|
for (const [key, value] of Object.entries(this._existingConfig[moduleName])) {
|
||||||
// Ensure user_name is properly set in allAnswers too
|
// Ensure user_name is properly set in allAnswers too
|
||||||
let finalValue = value;
|
let finalValue = value;
|
||||||
if (moduleName === 'core' && key === 'user_name' && (!value || value === '[USER_NAME]')) {
|
if (moduleName === 'core' && key === 'user_name' && (!value || value === '[USER_NAME]')) {
|
||||||
|
|
@ -519,8 +1190,8 @@ class ConfigCollector {
|
||||||
|
|
||||||
// Process all answers (both static and prompted)
|
// Process all answers (both static and prompted)
|
||||||
// First, copy existing config to preserve values that aren't being updated
|
// First, copy existing config to preserve values that aren't being updated
|
||||||
if (this.existingConfig && this.existingConfig[moduleName]) {
|
if (this._existingConfig && this._existingConfig[moduleName]) {
|
||||||
this.collectedConfig[moduleName] = { ...this.existingConfig[moduleName] };
|
this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] };
|
||||||
} else {
|
} else {
|
||||||
this.collectedConfig[moduleName] = {};
|
this.collectedConfig[moduleName] = {};
|
||||||
}
|
}
|
||||||
|
|
@ -545,11 +1216,11 @@ class ConfigCollector {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy over existing values for fields that weren't prompted
|
// Copy over existing values for fields that weren't prompted
|
||||||
if (this.existingConfig && this.existingConfig[moduleName]) {
|
if (this._existingConfig && this._existingConfig[moduleName]) {
|
||||||
if (!this.collectedConfig[moduleName]) {
|
if (!this.collectedConfig[moduleName]) {
|
||||||
this.collectedConfig[moduleName] = {};
|
this.collectedConfig[moduleName] = {};
|
||||||
}
|
}
|
||||||
for (const [key, value] of Object.entries(this.existingConfig[moduleName])) {
|
for (const [key, value] of Object.entries(this._existingConfig[moduleName])) {
|
||||||
if (!this.collectedConfig[moduleName][key]) {
|
if (!this.collectedConfig[moduleName][key]) {
|
||||||
this.collectedConfig[moduleName][key] = value;
|
this.collectedConfig[moduleName][key] = value;
|
||||||
this.allAnswers[`${moduleName}_${key}`] = value;
|
this.allAnswers[`${moduleName}_${key}`] = value;
|
||||||
|
|
@ -652,7 +1323,7 @@ class ConfigCollector {
|
||||||
async collectModuleConfig(moduleName, projectDir, skipLoadExisting = false, skipCompletion = false) {
|
async collectModuleConfig(moduleName, projectDir, skipLoadExisting = false, skipCompletion = false) {
|
||||||
this.currentProjectDir = projectDir;
|
this.currentProjectDir = projectDir;
|
||||||
// Load existing config if needed and not already loaded
|
// Load existing config if needed and not already loaded
|
||||||
if (!skipLoadExisting && !this.existingConfig) {
|
if (!skipLoadExisting && !this._existingConfig) {
|
||||||
await this.loadExistingConfig(projectDir);
|
await this.loadExistingConfig(projectDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -674,7 +1345,7 @@ class ConfigCollector {
|
||||||
|
|
||||||
// If not found in src/modules or custom paths, search the project
|
// If not found in src/modules or custom paths, search the project
|
||||||
if (!(await fs.pathExists(moduleConfigPath))) {
|
if (!(await fs.pathExists(moduleConfigPath))) {
|
||||||
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
|
const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true });
|
||||||
|
|
||||||
if (moduleSourcePath) {
|
if (moduleSourcePath) {
|
||||||
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
||||||
|
|
@ -994,8 +1665,8 @@ class ConfigCollector {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer the current module's persisted value when re-prompting an existing install
|
// Prefer the current module's persisted value when re-prompting an existing install
|
||||||
if (!configValue && currentModule && this.existingConfig?.[currentModule]?.[configKey] !== undefined) {
|
if (!configValue && currentModule && this._existingConfig?.[currentModule]?.[configKey] !== undefined) {
|
||||||
configValue = this.existingConfig[currentModule][configKey];
|
configValue = this._existingConfig[currentModule][configKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check in already collected config
|
// Check in already collected config
|
||||||
|
|
@ -1009,10 +1680,10 @@ class ConfigCollector {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to other existing module config values
|
// Fall back to other existing module config values
|
||||||
if (!configValue && this.existingConfig) {
|
if (!configValue && this._existingConfig) {
|
||||||
for (const mod of Object.keys(this.existingConfig)) {
|
for (const mod of Object.keys(this._existingConfig)) {
|
||||||
if (mod !== '_meta' && this.existingConfig[mod] && this.existingConfig[mod][configKey]) {
|
if (mod !== '_meta' && this._existingConfig[mod] && this._existingConfig[mod][configKey]) {
|
||||||
configValue = this.existingConfig[mod][configKey];
|
configValue = this._existingConfig[mod][configKey];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1083,8 +1754,8 @@ class ConfigCollector {
|
||||||
|
|
||||||
// Check for existing value
|
// Check for existing value
|
||||||
let existingValue = null;
|
let existingValue = null;
|
||||||
if (this.existingConfig && this.existingConfig[moduleName]) {
|
if (this._existingConfig && this._existingConfig[moduleName]) {
|
||||||
existingValue = this.existingConfig[moduleName][key];
|
existingValue = this._existingConfig[moduleName][key];
|
||||||
existingValue = this.normalizeExistingValueForPrompt(existingValue, moduleName, item, moduleConfig);
|
existingValue = this.normalizeExistingValueForPrompt(existingValue, moduleName, item, moduleConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1369,4 +2040,4 @@ class ConfigCollector {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ConfigCollector };
|
module.exports = { OfficialModules };
|
||||||
|
|
@ -2,8 +2,8 @@ const path = require('node:path');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const { CLIUtils } = require('./cli-utils');
|
const { CLIUtils } = require('./cli-utils');
|
||||||
const { CustomHandler } = require('../installers/lib/custom/handler');
|
const { CustomHandler } = require('./custom-handler');
|
||||||
const { ExternalModuleManager } = require('../installers/lib/modules/external-manager');
|
const { ExternalModuleManager } = require('./modules/external-manager');
|
||||||
const prompts = require('./prompts');
|
const prompts = require('./prompts');
|
||||||
|
|
||||||
// Separator class for visual grouping in select/multiselect prompts
|
// Separator class for visual grouping in select/multiselect prompts
|
||||||
|
|
@ -32,7 +32,7 @@ class UI {
|
||||||
await CLIUtils.displayLogo();
|
await CLIUtils.displayLogo();
|
||||||
|
|
||||||
// Display version-specific start message from install-messages.yaml
|
// Display version-specific start message from install-messages.yaml
|
||||||
const { MessageLoader } = require('../installers/lib/message-loader');
|
const { MessageLoader } = require('./message-loader');
|
||||||
const messageLoader = new MessageLoader();
|
const messageLoader = new MessageLoader();
|
||||||
await messageLoader.displayStartMessage();
|
await messageLoader.displayStartMessage();
|
||||||
|
|
||||||
|
|
@ -51,125 +51,11 @@ class UI {
|
||||||
confirmedDirectory = await this.getConfirmedDirectory();
|
confirmedDirectory = await this.getConfirmedDirectory();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preflight: Check for legacy BMAD v4 footprints immediately after getting directory
|
const { Installer } = require('./core/installer');
|
||||||
const { Detector } = require('../installers/lib/core/detector');
|
|
||||||
const { Installer } = require('../installers/lib/core/installer');
|
|
||||||
const detector = new Detector();
|
|
||||||
const installer = new Installer();
|
const installer = new Installer();
|
||||||
const legacyV4 = await detector.detectLegacyV4(confirmedDirectory);
|
const { bmadDir } = await installer.findBmadDir(confirmedDirectory);
|
||||||
if (legacyV4.hasLegacyV4) {
|
|
||||||
await installer.handleLegacyV4Migration(confirmedDirectory, legacyV4);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for legacy folders and prompt for rename before showing any menus
|
// Check if there's an existing BMAD installation
|
||||||
let hasLegacyCfg = false;
|
|
||||||
let hasLegacyBmadFolder = false;
|
|
||||||
let bmadDir = null;
|
|
||||||
let legacyBmadPath = null;
|
|
||||||
|
|
||||||
// First check for legacy .bmad folder (instead of _bmad)
|
|
||||||
// Only check if directory exists
|
|
||||||
if (await fs.pathExists(confirmedDirectory)) {
|
|
||||||
const entries = await fs.readdir(confirmedDirectory, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isDirectory() && (entry.name === '.bmad' || entry.name === 'bmad')) {
|
|
||||||
hasLegacyBmadFolder = true;
|
|
||||||
legacyBmadPath = path.join(confirmedDirectory, entry.name);
|
|
||||||
bmadDir = legacyBmadPath;
|
|
||||||
|
|
||||||
// Check if it has _cfg folder
|
|
||||||
const cfgPath = path.join(legacyBmadPath, '_cfg');
|
|
||||||
if (await fs.pathExists(cfgPath)) {
|
|
||||||
hasLegacyCfg = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no .bmad or bmad found, check for current installations _bmad
|
|
||||||
if (!hasLegacyBmadFolder) {
|
|
||||||
const bmadResult = await installer.findBmadDir(confirmedDirectory);
|
|
||||||
bmadDir = bmadResult.bmadDir;
|
|
||||||
hasLegacyCfg = bmadResult.hasLegacyCfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle legacy .bmad or _cfg folder - these are very old (v4 or alpha)
|
|
||||||
// Show version warning instead of offering conversion
|
|
||||||
if (hasLegacyBmadFolder || hasLegacyCfg) {
|
|
||||||
await prompts.log.warn('LEGACY INSTALLATION DETECTED');
|
|
||||||
await prompts.note(
|
|
||||||
'Found a ".bmad"/"bmad" folder, or a legacy "_cfg" folder under the bmad folder -\n' +
|
|
||||||
'this is from an old BMAD version that is out of date for automatic upgrade,\n' +
|
|
||||||
'manual intervention required.\n\n' +
|
|
||||||
'You have a legacy version installed (v4 or alpha).\n' +
|
|
||||||
'Legacy installations may have compatibility issues.\n\n' +
|
|
||||||
'For the best experience, we strongly recommend:\n' +
|
|
||||||
' 1. Delete your current BMAD installation folder (.bmad or bmad)\n' +
|
|
||||||
' 2. Run a fresh installation\n\n' +
|
|
||||||
'If you do not want to start fresh, you can attempt to proceed beyond this\n' +
|
|
||||||
'point IF you have ensured the bmad folder is named _bmad, and under it there\n' +
|
|
||||||
'is a _config folder. If you have a folder under your bmad folder named _cfg,\n' +
|
|
||||||
'you would need to rename it _config, and then restart the installer.\n\n' +
|
|
||||||
'Benefits of a fresh install:\n' +
|
|
||||||
' \u2022 Cleaner configuration without legacy artifacts\n' +
|
|
||||||
' \u2022 All new features properly configured\n' +
|
|
||||||
' \u2022 Fewer potential conflicts\n\n' +
|
|
||||||
'If you have already produced output from an earlier alpha version, you can\n' +
|
|
||||||
'still retain those artifacts. After installation, ensure you configured during\n' +
|
|
||||||
'install the proper file locations for artifacts depending on the module you\n' +
|
|
||||||
'are using, or move the files to the proper locations.',
|
|
||||||
'Legacy Installation Detected',
|
|
||||||
);
|
|
||||||
|
|
||||||
const proceed = await prompts.select({
|
|
||||||
message: 'How would you like to proceed?',
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
name: 'Cancel and do a fresh install (recommended)',
|
|
||||||
value: 'cancel',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Proceed anyway (will attempt update, potentially may fail or have unstable behavior)',
|
|
||||||
value: 'proceed',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
default: 'cancel',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (proceed === 'cancel') {
|
|
||||||
await prompts.note('1. Delete the existing bmad folder in your project\n' + "2. Run 'bmad install' again", 'To do a fresh install');
|
|
||||||
process.exit(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = await prompts.spinner();
|
|
||||||
s.start('Updating folder structure...');
|
|
||||||
try {
|
|
||||||
// Handle .bmad folder
|
|
||||||
if (hasLegacyBmadFolder) {
|
|
||||||
const newBmadPath = path.join(confirmedDirectory, '_bmad');
|
|
||||||
await fs.move(legacyBmadPath, newBmadPath);
|
|
||||||
bmadDir = newBmadPath;
|
|
||||||
s.stop(`Renamed "${path.basename(legacyBmadPath)}" to "_bmad"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle _cfg folder (either from .bmad or standalone)
|
|
||||||
const cfgPath = path.join(bmadDir, '_cfg');
|
|
||||||
if (await fs.pathExists(cfgPath)) {
|
|
||||||
s.start('Renaming configuration folder...');
|
|
||||||
const newCfgPath = path.join(bmadDir, '_config');
|
|
||||||
await fs.move(cfgPath, newCfgPath);
|
|
||||||
s.stop('Renamed "_cfg" to "_config"');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
s.stop('Failed to update folder structure');
|
|
||||||
await prompts.log.error(`Error: ${error.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there's an existing BMAD installation (after any folder renames)
|
|
||||||
const hasExistingInstall = await fs.pathExists(bmadDir);
|
const hasExistingInstall = await fs.pathExists(bmadDir);
|
||||||
|
|
||||||
let customContentConfig = { hasCustomContent: false };
|
let customContentConfig = { hasCustomContent: false };
|
||||||
|
|
@ -184,18 +70,9 @@ class UI {
|
||||||
if (hasExistingInstall) {
|
if (hasExistingInstall) {
|
||||||
// Get version information
|
// Get version information
|
||||||
const { existingInstall, bmadDir } = await this.getExistingInstallation(confirmedDirectory);
|
const { existingInstall, bmadDir } = await this.getExistingInstallation(confirmedDirectory);
|
||||||
const packageJsonPath = path.join(__dirname, '../../../package.json');
|
const packageJsonPath = path.join(__dirname, '../../package.json');
|
||||||
const currentVersion = require(packageJsonPath).version;
|
const currentVersion = require(packageJsonPath).version;
|
||||||
const installedVersion = existingInstall.version || 'unknown';
|
const installedVersion = existingInstall.installed ? existingInstall.version || 'unknown' : 'unknown';
|
||||||
|
|
||||||
// Check if version is pre beta
|
|
||||||
const shouldProceed = await this.showLegacyVersionWarning(installedVersion, currentVersion, path.basename(bmadDir), options);
|
|
||||||
|
|
||||||
// If user chose to cancel, exit the installer
|
|
||||||
if (!shouldProceed) {
|
|
||||||
process.exit(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build menu choices dynamically
|
// Build menu choices dynamically
|
||||||
const choices = [];
|
const choices = [];
|
||||||
|
|
@ -402,7 +279,7 @@ class UI {
|
||||||
customModuleResult = await this.handleCustomModulesInModifyFlow(confirmedDirectory, selectedModules);
|
customModuleResult = await this.handleCustomModulesInModifyFlow(confirmedDirectory, selectedModules);
|
||||||
} else {
|
} else {
|
||||||
// Preserve existing custom modules if user doesn't want to modify them
|
// Preserve existing custom modules if user doesn't want to modify them
|
||||||
const { Installer } = require('../installers/lib/core/installer');
|
const { Installer } = require('./core/installer');
|
||||||
const installer = new Installer();
|
const installer = new Installer();
|
||||||
const { bmadDir } = await installer.findBmadDir(confirmedDirectory);
|
const { bmadDir } = await installer.findBmadDir(confirmedDirectory);
|
||||||
|
|
||||||
|
|
@ -423,22 +300,24 @@ class UI {
|
||||||
selectedModules.push(...customModuleResult.selectedCustomModules);
|
selectedModules.push(...customModuleResult.selectedCustomModules);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out core - it's always installed via installCore flag
|
// Ensure core is in the modules list
|
||||||
selectedModules = selectedModules.filter((m) => m !== 'core');
|
if (!selectedModules.includes('core')) {
|
||||||
|
selectedModules.unshift('core');
|
||||||
|
}
|
||||||
|
|
||||||
// Get tool selection
|
// Get tool selection
|
||||||
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||||
|
|
||||||
const coreConfig = await this.collectCoreConfig(confirmedDirectory, options);
|
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actionType: 'update',
|
actionType: 'update',
|
||||||
directory: confirmedDirectory,
|
directory: confirmedDirectory,
|
||||||
installCore: true,
|
|
||||||
modules: selectedModules,
|
modules: selectedModules,
|
||||||
ides: toolSelection.ides,
|
ides: toolSelection.ides,
|
||||||
skipIde: toolSelection.skipIde,
|
skipIde: toolSelection.skipIde,
|
||||||
coreConfig: coreConfig,
|
coreConfig: moduleConfigs.core || {},
|
||||||
|
moduleConfigs: moduleConfigs,
|
||||||
customContent: customModuleResult.customContentConfig,
|
customContent: customModuleResult.customContentConfig,
|
||||||
skipPrompts: options.yes || false,
|
skipPrompts: options.yes || false,
|
||||||
};
|
};
|
||||||
|
|
@ -543,18 +422,21 @@ class UI {
|
||||||
selectedModules.push(...customContentConfig.selectedModuleIds);
|
selectedModules.push(...customContentConfig.selectedModuleIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedModules = selectedModules.filter((m) => m !== 'core');
|
// Ensure core is in the modules list
|
||||||
|
if (!selectedModules.includes('core')) {
|
||||||
|
selectedModules.unshift('core');
|
||||||
|
}
|
||||||
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||||
const coreConfig = await this.collectCoreConfig(confirmedDirectory, options);
|
const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actionType: 'install',
|
actionType: 'install',
|
||||||
directory: confirmedDirectory,
|
directory: confirmedDirectory,
|
||||||
installCore: true,
|
|
||||||
modules: selectedModules,
|
modules: selectedModules,
|
||||||
ides: toolSelection.ides,
|
ides: toolSelection.ides,
|
||||||
skipIde: toolSelection.skipIde,
|
skipIde: toolSelection.skipIde,
|
||||||
coreConfig: coreConfig,
|
coreConfig: moduleConfigs.core || {},
|
||||||
|
moduleConfigs: moduleConfigs,
|
||||||
customContent: customContentConfig,
|
customContent: customContentConfig,
|
||||||
skipPrompts: options.yes || false,
|
skipPrompts: options.yes || false,
|
||||||
};
|
};
|
||||||
|
|
@ -570,18 +452,15 @@ class UI {
|
||||||
* @returns {Object} Tool configuration
|
* @returns {Object} Tool configuration
|
||||||
*/
|
*/
|
||||||
async promptToolSelection(projectDir, options = {}) {
|
async promptToolSelection(projectDir, options = {}) {
|
||||||
// Check for existing configured IDEs - use findBmadDir to detect custom folder names
|
const { ExistingInstall } = require('./core/existing-install');
|
||||||
const { Detector } = require('../installers/lib/core/detector');
|
const { Installer } = require('./core/installer');
|
||||||
const { Installer } = require('../installers/lib/core/installer');
|
|
||||||
const detector = new Detector();
|
|
||||||
const installer = new Installer();
|
const installer = new Installer();
|
||||||
const bmadResult = await installer.findBmadDir(projectDir || process.cwd());
|
const { bmadDir } = await installer.findBmadDir(projectDir || process.cwd());
|
||||||
const bmadDir = bmadResult.bmadDir;
|
const existingInstall = await ExistingInstall.detect(bmadDir);
|
||||||
const existingInstall = await detector.detect(bmadDir);
|
const configuredIdes = existingInstall.ides;
|
||||||
const configuredIdes = existingInstall.ides || [];
|
|
||||||
|
|
||||||
// Get IDE manager to fetch available IDEs dynamically
|
// Get IDE manager to fetch available IDEs dynamically
|
||||||
const { IdeManager } = require('../installers/lib/ide/manager');
|
const { IdeManager } = require('./ide/manager');
|
||||||
const ideManager = new IdeManager();
|
const ideManager = new IdeManager();
|
||||||
await ideManager.ensureInitialized(); // IMPORTANT: Must initialize before getting IDEs
|
await ideManager.ensureInitialized(); // IMPORTANT: Must initialize before getting IDEs
|
||||||
|
|
||||||
|
|
@ -811,29 +690,29 @@ class UI {
|
||||||
* @returns {Object} Object with existingInstall, installedModuleIds, and bmadDir
|
* @returns {Object} Object with existingInstall, installedModuleIds, and bmadDir
|
||||||
*/
|
*/
|
||||||
async getExistingInstallation(directory) {
|
async getExistingInstallation(directory) {
|
||||||
const { Detector } = require('../installers/lib/core/detector');
|
const { ExistingInstall } = require('./core/existing-install');
|
||||||
const { Installer } = require('../installers/lib/core/installer');
|
const { Installer } = require('./core/installer');
|
||||||
const detector = new Detector();
|
|
||||||
const installer = new Installer();
|
const installer = new Installer();
|
||||||
const bmadDirResult = await installer.findBmadDir(directory);
|
const { bmadDir } = await installer.findBmadDir(directory);
|
||||||
const bmadDir = bmadDirResult.bmadDir;
|
const existingInstall = await ExistingInstall.detect(bmadDir);
|
||||||
const existingInstall = await detector.detect(bmadDir);
|
const installedModuleIds = new Set(existingInstall.moduleIds);
|
||||||
const installedModuleIds = new Set(existingInstall.modules.map((mod) => mod.id));
|
|
||||||
|
|
||||||
return { existingInstall, installedModuleIds, bmadDir };
|
return { existingInstall, installedModuleIds, bmadDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect core configuration
|
* Collect all module configurations (core + selected modules).
|
||||||
|
* All interactive prompting happens here in the UI layer.
|
||||||
* @param {string} directory - Installation directory
|
* @param {string} directory - Installation directory
|
||||||
|
* @param {string[]} modules - Modules to configure (including 'core')
|
||||||
* @param {Object} options - Command-line options
|
* @param {Object} options - Command-line options
|
||||||
* @returns {Object} Core configuration
|
* @returns {Object} Collected module configurations keyed by module name
|
||||||
*/
|
*/
|
||||||
async collectCoreConfig(directory, options = {}) {
|
async collectModuleConfigs(directory, modules, options = {}) {
|
||||||
const { ConfigCollector } = require('../installers/lib/core/config-collector');
|
const { OfficialModules } = require('./modules/official-modules');
|
||||||
const configCollector = new ConfigCollector();
|
const configCollector = new OfficialModules();
|
||||||
|
|
||||||
// If options are provided, set them directly
|
// Seed core config from CLI options if provided
|
||||||
if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) {
|
if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) {
|
||||||
const coreConfig = {};
|
const coreConfig = {};
|
||||||
if (options.userName) {
|
if (options.userName) {
|
||||||
|
|
@ -855,8 +734,6 @@ class UI {
|
||||||
|
|
||||||
// Load existing config to merge with provided options
|
// Load existing config to merge with provided options
|
||||||
await configCollector.loadExistingConfig(directory);
|
await configCollector.loadExistingConfig(directory);
|
||||||
|
|
||||||
// Merge provided options with existing config (or defaults)
|
|
||||||
const existingConfig = configCollector.collectedConfig.core || {};
|
const existingConfig = configCollector.collectedConfig.core || {};
|
||||||
configCollector.collectedConfig.core = { ...existingConfig, ...coreConfig };
|
configCollector.collectedConfig.core = { ...existingConfig, ...coreConfig };
|
||||||
|
|
||||||
|
|
@ -872,7 +749,6 @@ class UI {
|
||||||
await configCollector.loadExistingConfig(directory);
|
await configCollector.loadExistingConfig(directory);
|
||||||
const existingConfig = configCollector.collectedConfig.core || {};
|
const existingConfig = configCollector.collectedConfig.core || {};
|
||||||
|
|
||||||
// If no existing config, use defaults
|
|
||||||
if (Object.keys(existingConfig).length === 0) {
|
if (Object.keys(existingConfig).length === 0) {
|
||||||
let safeUsername;
|
let safeUsername;
|
||||||
try {
|
try {
|
||||||
|
|
@ -889,16 +765,14 @@ class UI {
|
||||||
};
|
};
|
||||||
await prompts.log.info('Using default configuration (--yes flag)');
|
await prompts.log.info('Using default configuration (--yes flag)');
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Load existing configs first if they exist
|
|
||||||
await configCollector.loadExistingConfig(directory);
|
|
||||||
// Now collect with existing values as defaults (false = don't skip loading, true = skip completion message)
|
|
||||||
await configCollector.collectModuleConfig('core', directory, false, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const coreConfig = configCollector.collectedConfig.core;
|
// Collect all module configs — core is skipped if already seeded above
|
||||||
// Ensure we always have a core config object, even if empty
|
await configCollector.collectAllConfigurations(modules, directory, {
|
||||||
return coreConfig || {};
|
skipPrompts: options.yes || false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return configCollector.collectedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -935,9 +809,9 @@ class UI {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add official modules
|
// Add official modules
|
||||||
const { ModuleManager } = require('../installers/lib/modules/manager');
|
const { OfficialModules } = require('./modules/official-modules');
|
||||||
const moduleManager = new ModuleManager();
|
const officialModules = new OfficialModules();
|
||||||
const { modules: availableModules, customModules: customModulesFromCache } = await moduleManager.listAvailable();
|
const { modules: availableModules, customModules: customModulesFromCache } = await officialModules.listAvailable();
|
||||||
|
|
||||||
// First, add all items to appropriate sections
|
// First, add all items to appropriate sections
|
||||||
const allCustomModules = [];
|
const allCustomModules = [];
|
||||||
|
|
@ -992,9 +866,9 @@ class UI {
|
||||||
* @returns {Array} Selected module codes (excluding core)
|
* @returns {Array} Selected module codes (excluding core)
|
||||||
*/
|
*/
|
||||||
async selectAllModules(installedModuleIds = new Set()) {
|
async selectAllModules(installedModuleIds = new Set()) {
|
||||||
const { ModuleManager } = require('../installers/lib/modules/manager');
|
const { OfficialModules } = require('./modules/official-modules');
|
||||||
const moduleManager = new ModuleManager();
|
const officialModulesSource = new OfficialModules();
|
||||||
const { modules: localModules } = await moduleManager.listAvailable();
|
const { modules: localModules } = await officialModulesSource.listAvailable();
|
||||||
|
|
||||||
// Get external modules
|
// Get external modules
|
||||||
const externalManager = new ExternalModuleManager();
|
const externalManager = new ExternalModuleManager();
|
||||||
|
|
@ -1069,7 +943,7 @@ class UI {
|
||||||
maxItems: allOptions.length,
|
maxItems: allOptions.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = selected ? selected.filter((m) => m !== 'core') : [];
|
const result = selected ? [...selected] : [];
|
||||||
|
|
||||||
// Display selected modules as bulleted list
|
// Display selected modules as bulleted list
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
|
|
@ -1089,9 +963,9 @@ class UI {
|
||||||
* @returns {Array} Default module codes
|
* @returns {Array} Default module codes
|
||||||
*/
|
*/
|
||||||
async getDefaultModules(installedModuleIds = new Set()) {
|
async getDefaultModules(installedModuleIds = new Set()) {
|
||||||
const { ModuleManager } = require('../installers/lib/modules/manager');
|
const { OfficialModules } = require('./modules/official-modules');
|
||||||
const moduleManager = new ModuleManager();
|
const officialModules = new OfficialModules();
|
||||||
const { modules: localModules } = await moduleManager.listAvailable();
|
const { modules: localModules } = await officialModules.listAvailable();
|
||||||
|
|
||||||
const defaultModules = [];
|
const defaultModules = [];
|
||||||
|
|
||||||
|
|
@ -1149,7 +1023,7 @@ class UI {
|
||||||
const files = await fs.readdir(directory);
|
const files = await fs.readdir(directory);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
// Check for any bmad installation (any folder with _config/manifest.yaml)
|
// Check for any bmad installation (any folder with _config/manifest.yaml)
|
||||||
const { Installer } = require('../installers/lib/core/installer');
|
const { Installer } = require('./core/installer');
|
||||||
const installer = new Installer();
|
const installer = new Installer();
|
||||||
const bmadResult = await installer.findBmadDir(directory);
|
const bmadResult = await installer.findBmadDir(directory);
|
||||||
const hasBmadInstall =
|
const hasBmadInstall =
|
||||||
|
|
@ -1385,50 +1259,18 @@ class UI {
|
||||||
return path.resolve(expanded);
|
return path.resolve(expanded);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load existing configurations to use as defaults
|
|
||||||
* @param {string} directory - Installation directory
|
|
||||||
* @returns {Object} Existing configurations
|
|
||||||
*/
|
|
||||||
async loadExistingConfigurations(directory) {
|
|
||||||
const configs = {
|
|
||||||
hasCustomContent: false,
|
|
||||||
coreConfig: {},
|
|
||||||
ideConfig: { ides: [], skipIde: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Load core config
|
|
||||||
configs.coreConfig = await this.collectCoreConfig(directory);
|
|
||||||
|
|
||||||
// Load IDE configuration
|
|
||||||
const configuredIdes = await this.getConfiguredIdes(directory);
|
|
||||||
if (configuredIdes.length > 0) {
|
|
||||||
configs.ideConfig.ides = configuredIdes;
|
|
||||||
configs.ideConfig.skipIde = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return configs;
|
|
||||||
} catch {
|
|
||||||
// If loading fails, return empty configs
|
|
||||||
await prompts.log.warn('Could not load existing configurations');
|
|
||||||
return configs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get configured IDEs from existing installation
|
* Get configured IDEs from existing installation
|
||||||
* @param {string} directory - Installation directory
|
* @param {string} directory - Installation directory
|
||||||
* @returns {Array} List of configured IDEs
|
* @returns {Array} List of configured IDEs
|
||||||
*/
|
*/
|
||||||
async getConfiguredIdes(directory) {
|
async getConfiguredIdes(directory) {
|
||||||
const { Detector } = require('../installers/lib/core/detector');
|
const { ExistingInstall } = require('./core/existing-install');
|
||||||
const { Installer } = require('../installers/lib/core/installer');
|
const { Installer } = require('./core/installer');
|
||||||
const detector = new Detector();
|
|
||||||
const installer = new Installer();
|
const installer = new Installer();
|
||||||
const bmadResult = await installer.findBmadDir(directory);
|
const { bmadDir } = await installer.findBmadDir(directory);
|
||||||
const existingInstall = await detector.detect(bmadResult.bmadDir);
|
const existingInstall = await ExistingInstall.detect(bmadDir);
|
||||||
return existingInstall.ides || [];
|
return existingInstall.ides;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1573,7 +1415,7 @@ class UI {
|
||||||
const { existingInstall } = await this.getExistingInstallation(directory);
|
const { existingInstall } = await this.getExistingInstallation(directory);
|
||||||
|
|
||||||
// Check if there are any custom modules in cache
|
// Check if there are any custom modules in cache
|
||||||
const { Installer } = require('../installers/lib/core/installer');
|
const { Installer } = require('./core/installer');
|
||||||
const installer = new Installer();
|
const installer = new Installer();
|
||||||
const { bmadDir } = await installer.findBmadDir(directory);
|
const { bmadDir } = await installer.findBmadDir(directory);
|
||||||
|
|
||||||
|
|
@ -1707,82 +1549,6 @@ class UI {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if installed version is a legacy version that needs fresh install
|
|
||||||
* @param {string} installedVersion - The installed version
|
|
||||||
* @returns {boolean} True if legacy (v4 or any alpha)
|
|
||||||
*/
|
|
||||||
isLegacyVersion(installedVersion) {
|
|
||||||
if (!installedVersion || installedVersion === 'unknown') {
|
|
||||||
return true; // Treat unknown as legacy for safety
|
|
||||||
}
|
|
||||||
// Check if version string contains -alpha or -Alpha (any v6 alpha)
|
|
||||||
return /-alpha\./i.test(installedVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show warning for legacy version (v4 or alpha) and ask if user wants to proceed
|
|
||||||
* @param {string} installedVersion - The installed version
|
|
||||||
* @param {string} currentVersion - The current version
|
|
||||||
* @param {string} bmadFolderName - Name of the BMAD folder
|
|
||||||
* @returns {Promise<boolean>} True if user wants to proceed, false if they cancel
|
|
||||||
*/
|
|
||||||
async showLegacyVersionWarning(installedVersion, currentVersion, bmadFolderName, options = {}) {
|
|
||||||
if (!this.isLegacyVersion(installedVersion)) {
|
|
||||||
return true; // Not legacy, proceed
|
|
||||||
}
|
|
||||||
|
|
||||||
let warningContent;
|
|
||||||
if (installedVersion === 'unknown') {
|
|
||||||
warningContent = 'Unable to detect your installed BMAD version.\n' + 'This appears to be a legacy or unsupported installation.';
|
|
||||||
} else {
|
|
||||||
warningContent =
|
|
||||||
`You are updating from ${installedVersion} to ${currentVersion}.\n` + 'You have a legacy version installed (v4 or alpha).';
|
|
||||||
}
|
|
||||||
|
|
||||||
warningContent +=
|
|
||||||
'\n\nFor the best experience, we recommend:\n' +
|
|
||||||
' 1. Delete your current BMAD installation folder\n' +
|
|
||||||
` (the "${bmadFolderName}/" folder in your project)\n` +
|
|
||||||
' 2. Run a fresh installation\n\n' +
|
|
||||||
'Benefits of a fresh install:\n' +
|
|
||||||
' \u2022 Cleaner configuration without legacy artifacts\n' +
|
|
||||||
' \u2022 All new features properly configured\n' +
|
|
||||||
' \u2022 Fewer potential conflicts';
|
|
||||||
|
|
||||||
await prompts.log.warn('VERSION WARNING');
|
|
||||||
await prompts.note(warningContent, 'Version Warning');
|
|
||||||
|
|
||||||
if (options.yes) {
|
|
||||||
await prompts.log.warn('Non-interactive mode (--yes): auto-proceeding with legacy update');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const proceed = await prompts.select({
|
|
||||||
message: 'How would you like to proceed?',
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
name: 'Proceed with update anyway (may have issues)',
|
|
||||||
value: 'proceed',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cancel (recommended - do a fresh install instead)',
|
|
||||||
value: 'cancel',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
default: 'cancel',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (proceed === 'cancel') {
|
|
||||||
await prompts.note(
|
|
||||||
`1. Delete the "${bmadFolderName}/" folder in your project\n` + "2. Run 'bmad install' again",
|
|
||||||
'To do a fresh install',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return proceed === 'proceed';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display module versions with update availability
|
* Display module versions with update availability
|
||||||
* @param {Array} modules - Array of module info objects with version info
|
* @param {Array} modules - Array of module info objects with version info
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
# JavaScript Conventions
|
||||||
|
|
||||||
|
## Function ordering
|
||||||
|
|
||||||
|
Define functions top-to-bottom in call order: callers above callees. If `install()` calls `_initPaths()`, then `install` appears first and `_initPaths` appears after it.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue