Compare commits
1 Commits
32e56513df
...
647bf62d99
| Author | SHA1 | Date |
|---|---|---|
|
|
647bf62d99 |
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'How to Customize BMad'
|
title: "How to Customize BMad"
|
||||||
description: Customize agents, workflows, and modules while preserving update compatibility
|
description: Customize agents, workflows, and modules while preserving update compatibility
|
||||||
sidebar:
|
sidebar:
|
||||||
order: 8
|
order: 7
|
||||||
---
|
---
|
||||||
|
|
||||||
Use the `.customize.yaml` files to tailor agent behavior, personas, and menus while preserving your changes across updates.
|
Use the `.customize.yaml` files to tailor agent behavior, personas, and menus while preserving your changes across updates.
|
||||||
|
|
@ -15,10 +15,9 @@ Use the `.customize.yaml` files to tailor agent behavior, personas, and menus wh
|
||||||
- You want agents to perform specific actions every time they start up
|
- You want agents to perform specific actions every time they start up
|
||||||
|
|
||||||
:::note[Prerequisites]
|
:::note[Prerequisites]
|
||||||
|
|
||||||
- BMad installed in your project (see [How to Install BMad](./install-bmad.md))
|
- BMad installed in your project (see [How to Install BMad](./install-bmad.md))
|
||||||
- A text editor for YAML files
|
- A text editor for YAML files
|
||||||
:::
|
:::
|
||||||
|
|
||||||
:::caution[Keep Your Customizations Safe]
|
:::caution[Keep Your Customizations Safe]
|
||||||
Always use the `.customize.yaml` files described here rather than editing agent files directly. The installer overwrites agent files during updates, but preserves your `.customize.yaml` changes.
|
Always use the `.customize.yaml` files described here rather than editing agent files directly. The installer overwrites agent files during updates, but preserves your `.customize.yaml` changes.
|
||||||
|
|
@ -137,10 +136,10 @@ npx bmad-method install
|
||||||
|
|
||||||
The installer detects the existing installation and offers these options:
|
The installer detects the existing installation and offers these options:
|
||||||
|
|
||||||
| Option | What It Does |
|
| Option | What It Does |
|
||||||
| ---------------------------- | -------------------------------------------------------------------- |
|
| ---------------------------- | ------------------------------------------------------------------- |
|
||||||
| **Quick Update** | Updates all modules to the latest version and applies customizations |
|
| **Quick Update** | Updates all modules to the latest version and applies customizations |
|
||||||
| **Modify BMad Installation** | Full installation flow for adding or removing modules |
|
| **Modify BMad Installation** | Full installation flow for adding or removing modules |
|
||||||
|
|
||||||
For customization-only changes, **Quick Update** is the fastest option.
|
For customization-only changes, **Quick Update** is the fastest option.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'Established Projects'
|
title: "Established Projects"
|
||||||
description: How to use BMad Method on existing codebases
|
description: How to use BMad Method on existing codebases
|
||||||
sidebar:
|
sidebar:
|
||||||
order: 7
|
order: 6
|
||||||
---
|
---
|
||||||
|
|
||||||
Use BMad Method effectively when working on existing projects and legacy codebases.
|
Use BMad Method effectively when working on existing projects and legacy codebases.
|
||||||
|
|
@ -10,11 +10,10 @@ Use BMad Method effectively when working on existing projects and legacy codebas
|
||||||
This guide covers the essential workflow for onboarding to existing projects with BMad Method.
|
This guide covers the essential workflow for onboarding to existing projects with BMad Method.
|
||||||
|
|
||||||
:::note[Prerequisites]
|
:::note[Prerequisites]
|
||||||
|
|
||||||
- BMad Method installed (`npx bmad-method install`)
|
- BMad Method installed (`npx bmad-method install`)
|
||||||
- An existing codebase you want to work on
|
- An existing codebase you want to work on
|
||||||
- Access to an AI-powered IDE (Claude Code or Cursor)
|
- Access to an AI-powered IDE (Claude Code or Cursor)
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Step 1: Clean Up Completed Planning Artifacts
|
## Step 1: Clean Up Completed Planning Artifacts
|
||||||
|
|
||||||
|
|
@ -37,7 +36,6 @@ bmad-generate-project-context
|
||||||
```
|
```
|
||||||
|
|
||||||
This scans your codebase to identify:
|
This scans your codebase to identify:
|
||||||
|
|
||||||
- Technology stack and versions
|
- Technology stack and versions
|
||||||
- Code organization patterns
|
- Code organization patterns
|
||||||
- Naming conventions
|
- Naming conventions
|
||||||
|
|
@ -81,10 +79,10 @@ BMad-Help also **automatically runs at the end of every workflow**, providing cl
|
||||||
|
|
||||||
You have two primary options depending on the scope of changes:
|
You have two primary options depending on the scope of changes:
|
||||||
|
|
||||||
| Scope | Recommended Approach |
|
| Scope | Recommended Approach |
|
||||||
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **Small updates or additions** | Run `bmad-quick-dev` to clarify intent, plan, implement, and review in a single workflow. The full four-phase BMad Method is likely overkill. |
|
| **Small updates or additions** | Run `bmad-quick-dev` to clarify intent, plan, implement, and review in a single workflow. The full four-phase BMad Method is likely overkill. |
|
||||||
| **Major changes or additions** | Start with the BMad Method, applying as much or as little rigor as needed. |
|
| **Major changes or additions** | Start with the BMad Method, applying as much or as little rigor as needed. |
|
||||||
|
|
||||||
### During PRD Creation
|
### During PRD Creation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'How to Get Answers About BMad'
|
title: "How to Get Answers About BMad"
|
||||||
description: Use an LLM to quickly answer your own BMad questions
|
description: Use an LLM to quickly answer your own BMad questions
|
||||||
sidebar:
|
sidebar:
|
||||||
order: 5
|
order: 4
|
||||||
---
|
---
|
||||||
|
|
||||||
Use BMad's built-in help, source docs, or the community to get answers — from quickest to most thorough.
|
Use BMad's built-in help, source docs, or the community to get answers — from quickest to most thorough.
|
||||||
|
|
@ -46,35 +46,35 @@ If your AI can't read local files (ChatGPT, Claude.ai, etc.), fetch [llms-full.t
|
||||||
|
|
||||||
If neither BMad-Help nor the source answered your question, you now have a much better question to ask.
|
If neither BMad-Help nor the source answered your question, you now have a much better question to ask.
|
||||||
|
|
||||||
| Channel | Use For |
|
| Channel | Use For |
|
||||||
| ----------------------- | -------------------------- |
|
| ------------------------- | ------------------------------------------- |
|
||||||
| `help-requests` forum | Questions |
|
| `help-requests` forum | Questions |
|
||||||
| `#suggestions-feedback` | Ideas and feature requests |
|
| `#suggestions-feedback` | Ideas and feature requests |
|
||||||
|
|
||||||
**Discord:** [discord.gg/gk8jAdXWmj](https://discord.gg/gk8jAdXWmj)
|
**Discord:** [discord.gg/gk8jAdXWmj](https://discord.gg/gk8jAdXWmj)
|
||||||
|
|
||||||
**GitHub Issues:** [github.com/bmad-code-org/BMAD-METHOD/issues](https://github.com/bmad-code-org/BMAD-METHOD/issues)
|
**GitHub Issues:** [github.com/bmad-code-org/BMAD-METHOD/issues](https://github.com/bmad-code-org/BMAD-METHOD/issues)
|
||||||
_You!_
|
*You!*
|
||||||
_Stuck_
|
*Stuck*
|
||||||
_in the queue—_
|
*in the queue—*
|
||||||
_waiting_
|
*waiting*
|
||||||
_for who?_
|
*for who?*
|
||||||
|
|
||||||
_The source_
|
*The source*
|
||||||
_is there,_
|
*is there,*
|
||||||
_plain to see!_
|
*plain to see!*
|
||||||
|
|
||||||
_Point_
|
*Point*
|
||||||
_your machine._
|
*your machine.*
|
||||||
_Set it free._
|
*Set it free.*
|
||||||
|
|
||||||
_It reads._
|
*It reads.*
|
||||||
_It speaks._
|
*It speaks.*
|
||||||
_Ask away—_
|
*Ask away—*
|
||||||
|
|
||||||
_Why wait_
|
*Why wait*
|
||||||
_for tomorrow_
|
*for tomorrow*
|
||||||
_when you have_
|
*when you have*
|
||||||
_today?_
|
*today?*
|
||||||
|
|
||||||
_—Claude_
|
*—Claude*
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
title: 'How to Install BMad'
|
title: "How to Install BMad"
|
||||||
description: Step-by-step guide to installing BMad in your project
|
description: Step-by-step guide to installing BMad in your project
|
||||||
sidebar:
|
sidebar:
|
||||||
order: 1
|
order: 1
|
||||||
|
|
@ -16,11 +16,10 @@ If you want to use a non interactive installer and provide all install options o
|
||||||
- Update the existing BMad Installation
|
- Update the existing BMad Installation
|
||||||
|
|
||||||
:::note[Prerequisites]
|
:::note[Prerequisites]
|
||||||
|
|
||||||
- **Node.js** 20+ (required for the installer)
|
- **Node.js** 20+ (required for the installer)
|
||||||
- **Git** (recommended)
|
- **Git** (recommended)
|
||||||
- **AI tool** (Claude Code, Cursor, or similar)
|
- **AI tool** (Claude Code, Cursor, or similar)
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
|
|
@ -32,7 +31,6 @@ npx bmad-method install
|
||||||
|
|
||||||
:::tip[Want the newest prerelease build?]
|
:::tip[Want the newest prerelease build?]
|
||||||
Use the `next` dist-tag:
|
Use the `next` dist-tag:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx bmad-method@next install
|
npx bmad-method@next install
|
||||||
```
|
```
|
||||||
|
|
@ -42,11 +40,9 @@ This gets you newer changes earlier, with a higher chance of churn than the defa
|
||||||
|
|
||||||
:::tip[Bleeding edge]
|
:::tip[Bleeding edge]
|
||||||
To install the latest from the main branch (may be unstable):
|
To install the latest from the main branch (may be unstable):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx github:bmad-code-org/BMAD-METHOD install
|
npx github:bmad-code-org/BMAD-METHOD install
|
||||||
```
|
```
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
### 2. Choose Installation Location
|
### 2. Choose Installation Location
|
||||||
|
|
@ -103,13 +99,11 @@ your-project/
|
||||||
Run `bmad-help` to verify everything works and see what to do next.
|
Run `bmad-help` to verify everything works and see what to do next.
|
||||||
|
|
||||||
**BMad-Help is your intelligent guide** that will:
|
**BMad-Help is your intelligent guide** that will:
|
||||||
|
|
||||||
- Confirm your installation is working
|
- Confirm your installation is working
|
||||||
- Show what's available based on your installed modules
|
- Show what's available based on your installed modules
|
||||||
- Recommend your first step
|
- Recommend your first step
|
||||||
|
|
||||||
You can also ask it questions:
|
You can also ask it questions:
|
||||||
|
|
||||||
```
|
```
|
||||||
bmad-help I just installed, what should I do first?
|
bmad-help I just installed, what should I do first?
|
||||||
bmad-help What are my options for a SaaS project?
|
bmad-help What are my options for a SaaS project?
|
||||||
|
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
---
|
|
||||||
title: 'Install Custom and Community Modules'
|
|
||||||
description: Install third-party modules from the community registry, Git repositories, or local paths
|
|
||||||
sidebar:
|
|
||||||
order: 3
|
|
||||||
---
|
|
||||||
|
|
||||||
Use the BMad installer to add modules from the community registry, third-party Git repositories, or local file paths.
|
|
||||||
|
|
||||||
## When to Use This
|
|
||||||
|
|
||||||
- Installing a community-contributed module from the BMad registry
|
|
||||||
- Installing a module from a third-party Git repository (GitHub, GitLab, Bitbucket, self-hosted)
|
|
||||||
- Testing a module you are developing locally with BMad Builder
|
|
||||||
- Installing modules from a private or self-hosted Git server
|
|
||||||
|
|
||||||
:::note[Prerequisites]
|
|
||||||
Requires [Node.js](https://nodejs.org) v20+ and `npx` (included with npm). Custom and community modules can be selected during a fresh install or added to an existing installation.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Community Modules
|
|
||||||
|
|
||||||
Community modules are curated in the [BMad plugins marketplace](https://github.com/bmad-code-org/bmad-plugins-marketplace). They are organized by category and are pinned to an approved commit for safety.
|
|
||||||
|
|
||||||
### 1. Run the Installer
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx bmad-method install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Browse the Community Catalog
|
|
||||||
|
|
||||||
After selecting official modules, the installer asks:
|
|
||||||
|
|
||||||
```
|
|
||||||
Would you like to browse community modules?
|
|
||||||
```
|
|
||||||
|
|
||||||
Select **Yes** to enter the catalog browser. You can:
|
|
||||||
|
|
||||||
- Browse by category
|
|
||||||
- View featured modules
|
|
||||||
- View all available modules
|
|
||||||
- Search by keyword
|
|
||||||
|
|
||||||
### 3. Select Modules
|
|
||||||
|
|
||||||
Pick modules from any category. The installer shows descriptions, versions, and trust tiers. Already-installed modules are pre-checked for update.
|
|
||||||
|
|
||||||
### 4. Continue with Installation
|
|
||||||
|
|
||||||
After selecting community modules, the installer proceeds to custom sources, then tool/IDE configuration and the rest of the install flow.
|
|
||||||
|
|
||||||
## Custom Sources (Git URLs and Local Paths)
|
|
||||||
|
|
||||||
Custom modules can come from any Git repository or a local directory on your machine. The installer resolves the source, analyzes the module structure, and installs it alongside your other modules.
|
|
||||||
|
|
||||||
### Interactive Installation
|
|
||||||
|
|
||||||
During installation, after the community module step, the installer asks:
|
|
||||||
|
|
||||||
```
|
|
||||||
Would you like to install from a custom source (Git URL or local path)?
|
|
||||||
```
|
|
||||||
|
|
||||||
Select **Yes**, then provide a source:
|
|
||||||
|
|
||||||
| Input Type | Example |
|
|
||||||
| --------------------- | ------------------------------------------------- |
|
|
||||||
| HTTPS URL (any host) | `https://github.com/org/repo` |
|
|
||||||
| HTTPS URL with subdir | `https://github.com/org/repo/tree/main/my-module` |
|
|
||||||
| SSH URL | `git@github.com:org/repo.git` |
|
|
||||||
| Local path | `/Users/me/projects/my-module` |
|
|
||||||
| Local path with tilde | `~/projects/my-module` |
|
|
||||||
|
|
||||||
The installer clones the repository (for URLs) or reads directly from disk (for local paths), then presents the discovered modules for selection.
|
|
||||||
|
|
||||||
### Non-Interactive Installation
|
|
||||||
|
|
||||||
Use the `--custom-source` flag to install custom modules from the command line:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx bmad-method install \
|
|
||||||
--directory . \
|
|
||||||
--custom-source /path/to/my-module \
|
|
||||||
--tools claude-code \
|
|
||||||
--yes
|
|
||||||
```
|
|
||||||
|
|
||||||
When `--custom-source` is provided without `--modules`, only core and the custom modules are installed. To include official modules as well, add `--modules`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx bmad-method install \
|
|
||||||
--directory . \
|
|
||||||
--modules bmm \
|
|
||||||
--custom-source https://gitlab.com/myorg/my-module \
|
|
||||||
--tools claude-code \
|
|
||||||
--yes
|
|
||||||
```
|
|
||||||
|
|
||||||
Multiple sources can be comma-separated:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
--custom-source /path/one,https://github.com/org/repo,/path/two
|
|
||||||
```
|
|
||||||
|
|
||||||
## How Module Discovery Works
|
|
||||||
|
|
||||||
The installer uses two modes to find installable modules in a source:
|
|
||||||
|
|
||||||
| Mode | Trigger | Behavior |
|
|
||||||
| --------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
||||||
| Discovery | Source contains `.claude-plugin/marketplace.json` | Lists all plugins from the manifest; you pick which to install |
|
|
||||||
| Direct | No marketplace.json found | Scans the directory for skills (subdirectories with `SKILL.md`), resolves as a single module |
|
|
||||||
|
|
||||||
Discovery mode is typical for published modules. Direct mode is convenient when pointing at a skills directory during local development.
|
|
||||||
|
|
||||||
:::note[About `.claude-plugin/`]
|
|
||||||
The `.claude-plugin/marketplace.json` path is a standard convention adopted across multiple AI tool installers for plugin discoverability. It does not require Claude, does not use Claude APIs, and has no effect on which AI tool you use. Any module with this file can be discovered by any installer that follows the convention.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Local Development Workflow
|
|
||||||
|
|
||||||
If you are building a module with [BMad Builder](https://github.com/bmad-code-org/bmad-builder), you can install it directly from your working directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx bmad-method install \
|
|
||||||
--directory ~/my-project \
|
|
||||||
--custom-source ~/my-module-repo/skills \
|
|
||||||
--tools claude-code \
|
|
||||||
--yes
|
|
||||||
```
|
|
||||||
|
|
||||||
Local sources are referenced by path, not copied to a cache. When you update your module source and reinstall, the installer picks up the latest changes.
|
|
||||||
|
|
||||||
:::caution[Source Removal]
|
|
||||||
If you delete the local source directory after installation, the installed module files in `_bmad/` are preserved. The module will be skipped during updates until the source path is restored.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## What You Get
|
|
||||||
|
|
||||||
After installation, custom modules appear in `_bmad/` alongside official modules:
|
|
||||||
|
|
||||||
```
|
|
||||||
your-project/
|
|
||||||
├── _bmad/
|
|
||||||
│ ├── core/ # Built-in core module
|
|
||||||
│ ├── bmm/ # Official module (if selected)
|
|
||||||
│ ├── my-module/ # Your custom module
|
|
||||||
│ │ ├── my-skill/
|
|
||||||
│ │ │ └── SKILL.md
|
|
||||||
│ │ └── module-help.csv
|
|
||||||
│ └── _config/
|
|
||||||
│ └── manifest.yaml # Tracks all modules, versions, and sources
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
The manifest records the source of each custom module (`repoUrl` for Git sources, `localPath` for local sources) so that quick updates can locate the source again.
|
|
||||||
|
|
||||||
## Updating Custom Modules
|
|
||||||
|
|
||||||
Custom modules participate in the normal update flow:
|
|
||||||
|
|
||||||
- **Quick update** (`--action quick-update`): Refreshes all modules from their original sources. Git-based modules are re-fetched; local modules are re-read from their source path.
|
|
||||||
- **Full update**: Re-runs module selection so you can add or remove custom modules.
|
|
||||||
|
|
||||||
## Creating Your Own Modules
|
|
||||||
|
|
||||||
Use [BMad Builder](https://github.com/bmad-code-org/bmad-builder) to create modules that others can install:
|
|
||||||
|
|
||||||
1. Run `bmad-module-builder` to scaffold your module structure
|
|
||||||
2. Add skills, agents, and workflows with the various bmad builder tools
|
|
||||||
3. Publish to a Git repository or share the folder collection
|
|
||||||
4. Others install with `--custom-source <your-repo-url>`
|
|
||||||
|
|
||||||
For modules to support discovery mode, include a `.claude-plugin/marketplace.json` in your repository root (this is a cross-tool convention, not Claude-specific). See the [BMad Builder documentation](https://github.com/bmad-code-org/bmad-builder) for the marketplace.json format.
|
|
||||||
|
|
||||||
:::tip[Testing Locally First]
|
|
||||||
During development, install your module with a local path to iterate quickly before publishing to a Git repository.
|
|
||||||
:::
|
|
||||||
|
|
@ -22,40 +22,39 @@ Requires [Node.js](https://nodejs.org) v20+ and `npx` (included with npm).
|
||||||
|
|
||||||
### Installation Options
|
### Installation Options
|
||||||
|
|
||||||
| Flag | Description | Example |
|
| Flag | Description | Example |
|
||||||
| --------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------- |
|
|------|-------------|---------|
|
||||||
| `--directory <path>` | Installation directory | `--directory ~/projects/myapp` |
|
| `--directory <path>` | Installation directory | `--directory ~/projects/myapp` |
|
||||||
| `--modules <modules>` | Comma-separated module IDs | `--modules bmm,bmb` |
|
| `--modules <modules>` | Comma-separated module IDs | `--modules bmm,bmb` |
|
||||||
| `--tools <tools>` | Comma-separated tool/IDE IDs (use `none` to skip) | `--tools claude-code,cursor` or `--tools none` |
|
| `--tools <tools>` | Comma-separated tool/IDE IDs (use `none` to skip) | `--tools claude-code,cursor` or `--tools none` |
|
||||||
| `--action <type>` | Action for existing installations: `install` (default), `update`, or `quick-update` | `--action quick-update` |
|
| `--action <type>` | Action for existing installations: `install` (default), `update`, or `quick-update` | `--action quick-update` |
|
||||||
| `--custom-source <sources>` | Comma-separated Git URLs or local paths for custom modules | `--custom-source /path/to/module` |
|
|
||||||
|
|
||||||
### Core Configuration
|
### Core Configuration
|
||||||
|
|
||||||
| Flag | Description | Default |
|
| Flag | Description | Default |
|
||||||
| ----------------------------------- | ----------------------------------------------- | --------------- |
|
|------|-------------|---------|
|
||||||
| `--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 (see resolution rules below) | `_bmad-output` |
|
| `--output-folder <path>` | Output folder path (see resolution rules below) | `_bmad-output` |
|
||||||
|
|
||||||
#### Output Folder Path Resolution
|
#### Output Folder Path Resolution
|
||||||
|
|
||||||
The value passed to `--output-folder` (or entered interactively) is resolved according to these rules:
|
The value passed to `--output-folder` (or entered interactively) is resolved according to these rules:
|
||||||
|
|
||||||
| Input type | Example | Resolved as |
|
| Input type | Example | Resolved as |
|
||||||
| ---------------------------- | -------------------------- | ---------------------------------------------------------- |
|
|------------|---------|-------------|
|
||||||
| Relative path (default) | `_bmad-output` | `<project-root>/_bmad-output` |
|
| Relative path (default) | `_bmad-output` | `<project-root>/_bmad-output` |
|
||||||
| Relative path with traversal | `../../shared-outputs` | Normalized absolute path — e.g. `/Users/me/shared-outputs` |
|
| 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 |
|
| 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.
|
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
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
| ------------- | ------------------------------------------- |
|
|------|-------------|
|
||||||
| `-y, --yes` | Accept all defaults and skip prompts |
|
| `-y, --yes` | Accept all defaults and skip prompts |
|
||||||
| `-d, --debug` | Enable debug output for manifest generation |
|
| `-d, --debug` | Enable debug output for manifest generation |
|
||||||
|
|
||||||
## Module IDs
|
## Module IDs
|
||||||
|
|
@ -77,13 +76,12 @@ Run `npx bmad-method install` interactively once to see the full current list of
|
||||||
|
|
||||||
## Installation Modes
|
## Installation Modes
|
||||||
|
|
||||||
| Mode | Description | Example |
|
| Mode | Description | Example |
|
||||||
| --------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
|------|-------------|---------|
|
||||||
| Fully non-interactive | Provide all flags to skip all prompts | `npx bmad-method install --directory . --modules bmm --tools claude-code --yes` |
|
| Fully non-interactive | Provide all flags to skip all prompts | `npx bmad-method install --directory . --modules bmm --tools claude-code --yes` |
|
||||||
| Semi-interactive | Provide some flags; BMad prompts for the rest | `npx bmad-method install --directory . --modules bmm` |
|
| Semi-interactive | Provide some flags; BMad prompts for the rest | `npx bmad-method install --directory . --modules bmm` |
|
||||||
| Defaults only | Accept all defaults with `-y` | `npx bmad-method install --yes` |
|
| Defaults only | Accept all defaults with `-y` | `npx bmad-method install --yes` |
|
||||||
| Custom source only | Install core + custom module(s) | `npx bmad-method install --directory . --custom-source /path/to/module --tools claude-code --yes` |
|
| Without tools | Skip tool/IDE configuration | `npx bmad-method install --modules bmm --tools none` |
|
||||||
| Without tools | Skip tool/IDE configuration | `npx bmad-method install --modules bmm --tools none` |
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
|
|
@ -121,33 +119,6 @@ npx bmad-method install \
|
||||||
--action quick-update
|
--action quick-update
|
||||||
```
|
```
|
||||||
|
|
||||||
### Install from Custom Source
|
|
||||||
|
|
||||||
Install a module from a local path or any Git host:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx bmad-method install \
|
|
||||||
--directory . \
|
|
||||||
--custom-source /path/to/my-module \
|
|
||||||
--tools claude-code \
|
|
||||||
--yes
|
|
||||||
```
|
|
||||||
|
|
||||||
Combine with official modules:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx bmad-method install \
|
|
||||||
--directory . \
|
|
||||||
--modules bmm \
|
|
||||||
--custom-source https://gitlab.com/myorg/my-module \
|
|
||||||
--tools claude-code \
|
|
||||||
--yes
|
|
||||||
```
|
|
||||||
|
|
||||||
:::note[Custom source behavior]
|
|
||||||
When `--custom-source` is used without `--modules`, only core and the custom modules are installed. Add `--modules` to include official modules as well. See [Install Custom and Community Modules](./install-custom-modules.md) for details.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## What You Get
|
## What You Get
|
||||||
|
|
||||||
- A fully configured `_bmad/` directory in your project
|
- A fully configured `_bmad/` directory in your project
|
||||||
|
|
@ -164,19 +135,17 @@ BMad validates all provided flags:
|
||||||
- **Action** — Must be one of: `install`, `update`, `quick-update`
|
- **Action** — Must be one of: `install`, `update`, `quick-update`
|
||||||
|
|
||||||
Invalid values will either:
|
Invalid values will either:
|
||||||
|
|
||||||
1. Show an error and exit (for critical options like directory)
|
1. Show an error and exit (for critical options like directory)
|
||||||
2. Show a warning and skip (for optional items)
|
2. Show a warning and skip (for optional items)
|
||||||
3. Fall back to interactive prompts (for missing required values)
|
3. Fall back to interactive prompts (for missing required values)
|
||||||
|
|
||||||
:::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)
|
- 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
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
---
|
---
|
||||||
title: 'Manage Project Context'
|
title: "Manage Project Context"
|
||||||
description: Create and maintain project-context.md to guide AI agents
|
description: Create and maintain project-context.md to guide AI agents
|
||||||
sidebar:
|
sidebar:
|
||||||
order: 9
|
order: 8
|
||||||
---
|
---
|
||||||
|
|
||||||
Use the `project-context.md` file to ensure AI agents follow your project's technical preferences and implementation rules throughout all workflows. To make sure this is always available, you can also add the line `Important project context and conventions are located in [path to project context]/project-context.md` to your tools context or always rules file (such as `AGENTS.md`)
|
Use the `project-context.md` file to ensure AI agents follow your project's technical preferences and implementation rules throughout all workflows. To make sure this is always available, you can also add the line `Important project context and conventions are located in [path to project context]/project-context.md` to your tools context or always rules file (such as `AGENTS.md`)
|
||||||
|
|
||||||
:::note[Prerequisites]
|
:::note[Prerequisites]
|
||||||
|
|
||||||
- BMad Method installed
|
- BMad Method installed
|
||||||
- Understanding of your project's technology stack and conventions
|
- Understanding of your project's technology stack and conventions
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## When to Use This
|
## When to Use This
|
||||||
|
|
||||||
|
|
@ -61,17 +60,14 @@ sections_completed: ['technology_stack', 'critical_rules']
|
||||||
## Critical Implementation Rules
|
## Critical Implementation Rules
|
||||||
|
|
||||||
**TypeScript:**
|
**TypeScript:**
|
||||||
|
|
||||||
- Strict mode enabled, no `any` types
|
- Strict mode enabled, no `any` types
|
||||||
- Use `interface` for public APIs, `type` for unions
|
- Use `interface` for public APIs, `type` for unions
|
||||||
|
|
||||||
**Code Organization:**
|
**Code Organization:**
|
||||||
|
|
||||||
- Components in `/src/components/` with co-located tests
|
- Components in `/src/components/` with co-located tests
|
||||||
- API calls use `apiClient` singleton — never fetch directly
|
- API calls use `apiClient` singleton — never fetch directly
|
||||||
|
|
||||||
**Testing:**
|
**Testing:**
|
||||||
|
|
||||||
- Unit tests focus on business logic
|
- Unit tests focus on business logic
|
||||||
- Integration tests use MSW for API mocking
|
- Integration tests use MSW for API mocking
|
||||||
```
|
```
|
||||||
|
|
@ -119,12 +115,11 @@ A `project-context.md` file that:
|
||||||
## Tips
|
## Tips
|
||||||
|
|
||||||
:::tip[Best Practices]
|
:::tip[Best Practices]
|
||||||
|
|
||||||
- **Focus on the unobvious** — Document patterns agents might miss (e.g., "Use JSDoc on every public class"), not universal practices like "use meaningful variable names."
|
- **Focus on the unobvious** — Document patterns agents might miss (e.g., "Use JSDoc on every public class"), not universal practices like "use meaningful variable names."
|
||||||
- **Keep it lean** — This file is loaded by every implementation workflow. Long files waste context. Exclude content that only applies to narrow scope or specific stories.
|
- **Keep it lean** — This file is loaded by every implementation workflow. Long files waste context. Exclude content that only applies to narrow scope or specific stories.
|
||||||
- **Update as needed** — Edit manually when patterns change, or re-generate after significant architecture changes.
|
- **Update as needed** — Edit manually when patterns change, or re-generate after significant architecture changes.
|
||||||
- Works for Quick Flow and full BMad Method projects alike.
|
- Works for Quick Flow and full BMad Method projects alike.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'Quick Fixes'
|
title: "Quick Fixes"
|
||||||
description: How to make quick fixes and ad-hoc changes
|
description: How to make quick fixes and ad-hoc changes
|
||||||
sidebar:
|
sidebar:
|
||||||
order: 6
|
order: 5
|
||||||
---
|
---
|
||||||
|
|
||||||
Use **Quick Dev** for bug fixes, refactorings, or small targeted changes that don't require the full BMad Method.
|
Use **Quick Dev** for bug fixes, refactorings, or small targeted changes that don't require the full BMad Method.
|
||||||
|
|
@ -15,10 +15,9 @@ Use **Quick Dev** for bug fixes, refactorings, or small targeted changes that do
|
||||||
- Dependency updates
|
- Dependency updates
|
||||||
|
|
||||||
:::note[Prerequisites]
|
:::note[Prerequisites]
|
||||||
|
|
||||||
- BMad Method installed (`npx bmad-method install`)
|
- BMad Method installed (`npx bmad-method install`)
|
||||||
- An AI-powered IDE (Claude Code, Cursor, or similar)
|
- An AI-powered IDE (Claude Code, Cursor, or similar)
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'Document Sharding Guide'
|
title: "Document Sharding Guide"
|
||||||
description: Split large markdown files into smaller organized files for better context management
|
description: Split large markdown files into smaller organized files for better context management
|
||||||
sidebar:
|
sidebar:
|
||||||
order: 10
|
order: 9
|
||||||
---
|
---
|
||||||
|
|
||||||
Use the `bmad-shard-doc` tool if you need to split large markdown files into smaller, organized files for better context management.
|
Use the `bmad-shard-doc` tool if you need to split large markdown files into smaller, organized files for better context management.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
title: 'How to Upgrade to v6'
|
title: "How to Upgrade to v6"
|
||||||
description: Migrate from BMad v4 to v6
|
description: Migrate from BMad v4 to v6
|
||||||
sidebar:
|
sidebar:
|
||||||
order: 4
|
order: 3
|
||||||
---
|
---
|
||||||
|
|
||||||
Use the BMad installer to upgrade from v4 to v6, which includes automatic detection of legacy installations and migration assistance.
|
Use the BMad installer to upgrade from v4 to v6, which includes automatic detection of legacy installations and migration assistance.
|
||||||
|
|
@ -14,10 +14,9 @@ Use the BMad installer to upgrade from v4 to v6, which includes automatic detect
|
||||||
- You have existing planning artifacts to preserve
|
- You have existing planning artifacts to preserve
|
||||||
|
|
||||||
:::note[Prerequisites]
|
:::note[Prerequisites]
|
||||||
|
|
||||||
- Node.js 20+
|
- Node.js 20+
|
||||||
- Existing BMad v4 installation
|
- Existing BMad v4 installation
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
# Compile Epic Context
|
|
||||||
|
|
||||||
**Task**
|
|
||||||
Given an epic number, the epics file, the planning artifacts directory, and a desired output path, compile a clean, focused, developer-ready context file (`epic-<N>-context.md`).
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. Read the epics file and extract the target epic's title, goal, and list of stories.
|
|
||||||
2. Scan the planning artifacts directory for the standard files (PRD, architecture, UX/design, product brief).
|
|
||||||
3. Pull only the information relevant to this epic.
|
|
||||||
4. Write the compiled context to the exact output path using the format below.
|
|
||||||
|
|
||||||
## Exact Output Format
|
|
||||||
|
|
||||||
Use these headings:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Epic {N} Context: {Epic Title}
|
|
||||||
|
|
||||||
<!-- Compiled from planning artifacts. Edit freely. Regenerate with compile-epic-context if planning docs change. -->
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
{One clear paragraph: what this epic achieves and why it matters.}
|
|
||||||
|
|
||||||
## Stories
|
|
||||||
|
|
||||||
- Story X.Y: Brief title only
|
|
||||||
- ...
|
|
||||||
|
|
||||||
## Requirements & Constraints
|
|
||||||
|
|
||||||
{Relevant functional/non-functional requirements and success criteria for this epic (describe by purpose, not source).}
|
|
||||||
|
|
||||||
## Technical Decisions
|
|
||||||
|
|
||||||
{Key architecture decisions, constraints, patterns, data models, and conventions relevant to this epic.}
|
|
||||||
|
|
||||||
## UX & Interaction Patterns
|
|
||||||
|
|
||||||
{Relevant UX flows, interaction patterns, and design constraints (omit section entirely if nothing relevant).}
|
|
||||||
|
|
||||||
## Cross-Story Dependencies
|
|
||||||
|
|
||||||
{Dependencies between stories in this epic or with other epics/systems (omit if none).}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- **Scope aggressively.** Include only what a developer working on any story in this epic actually needs. When in doubt, leave it out — the developer can always read the full planning doc.
|
|
||||||
- **Describe by purpose, not by source.** Write "API responses must include pagination metadata" not "Per PRD section 3.2.1, pagination is required." Planning doc internals will change; the constraint won't.
|
|
||||||
- **No full copies.** Never quote source documents, section numbers, or paste large blocks verbatim. Always distill.
|
|
||||||
- **No story-level details.** The story list is for orientation only. Individual story specs handle the details.
|
|
||||||
- **Nothing derivable from the codebase.** Don't document what a developer can learn by reading the code.
|
|
||||||
- **Be concise and actionable.** Target 800–1500 tokens total. This file loads into quick-dev's context alongside other material.
|
|
||||||
- **Never hallucinate content.** If source material doesn't say something, don't invent it.
|
|
||||||
- **Omit empty sections entirely**, except Goal and Stories, which are always required.
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
- **If the epics file is missing or the target epic is not found:** write nothing and report the problem to the calling agent. Goal and Stories cannot be populated without a usable epics file.
|
|
||||||
- **If planning artifacts are missing or empty:** still produce the file with Goal and Stories populated from the epics file, and note the gap in the Goal section. Never hallucinate content to fill missing sections.
|
|
||||||
|
|
@ -41,32 +41,19 @@ Never ask extra questions if you already understand what the user intends.
|
||||||
1. Load context.
|
1. Load context.
|
||||||
- List files in `{planning_artifacts}` and `{implementation_artifacts}`.
|
- List files in `{planning_artifacts}` and `{implementation_artifacts}`.
|
||||||
- If you find an unformatted spec or intent file, ingest its contents to form your understanding of the intent.
|
- If you find an unformatted spec or intent file, ingest its contents to form your understanding of the intent.
|
||||||
- **Determine context strategy.** Using the intent and the artifact listing, infer whether the current work is a story from an epic. Do not rely on filename patterns or regex — reason about the intent, the listing, and any epics file content together.
|
- Planning artifacts are the output of BMAD phases 1-3. Typical files include:
|
||||||
|
- **PRD** (`*prd*`) — product requirements and success criteria
|
||||||
**A) Epic story path** — if the intent is clearly an epic story:
|
- **Architecture** (`*architecture*`) — technical design decisions and constraints
|
||||||
|
- **UX/Design** (`*ux*`) — user experience and interaction design
|
||||||
1. Identify the epic number and (if present) the story number. If you can't identify an epic number, use path B.
|
- **Epics** (`*epic*`) — feature breakdown into implementable stories
|
||||||
|
- **Product Brief** (`*brief*`) — project vision and scope
|
||||||
2. **Check for a valid cached epic context.** Look for `{implementation_artifacts}/epic-<N>-context.md` (where `<N>` is the epic number). A file is **valid** when it exists, is non-empty, starts with `# Epic <N> Context:` (with the correct epic number), and no file in `{planning_artifacts}` is newer.
|
- Scan the listing for files matching these patterns. If any look relevant to the current intent, load them selectively — you don't need all of them, but you need the right constraints and requirements rather than guessing from code alone.
|
||||||
- **If valid:** load it as the primary planning context. Do not load raw planning docs (PRD, architecture, UX, etc.). Skip to step 5.
|
- **Previous story continuity.** Using the intent and loaded context (especially any epics file), infer whether the current work is a story from an epic. Do not rely on filename patterns or regex — reason about the intent, the artifact listing, and epics content together. If the intent is an epic story:
|
||||||
- **If missing, empty, or invalid:** continue to step 3.
|
1. Identify the epic and story number.
|
||||||
|
2. Scan `{implementation_artifacts}` for specs from the same epic with `status: done` and a lower story number.
|
||||||
3. **Compile epic context.** Produce `{implementation_artifacts}/epic-<N>-context.md` by following `./compile-epic-context.md`, in order of preference:
|
3. Load the most recent one (highest story number below current).
|
||||||
- **Preferred — sub-agent:** spawn a sub-agent with `./compile-epic-context.md` as its prompt. Pass it the epic number, the epics file path, the `{planning_artifacts}` directory, and the output path `{implementation_artifacts}/epic-<N>-context.md`.
|
4. Extract its **Code Map**, **Design Notes**, **Spec Change Log**, and **task list** as continuity context for step-02 planning.
|
||||||
- **Fallback — inline** (for runtimes without sub-agent support, e.g. Copilot, Codex, local Ollama, older Claude): if your runtime cannot spawn sub-agents, or the spawn fails/times out, read `./compile-epic-context.md` yourself and follow its instructions to produce the same output file.
|
If no `done` spec is found but an `in-review` spec exists for the same epic with a lower story number, note it to the user and ask whether to load it. If the intent is not an epic story, or no previous spec exists, skip this silently.
|
||||||
|
|
||||||
4. **Verify.** After compilation, verify the output file exists, is non-empty, and starts with `# Epic <N> Context:`. If valid, load it. If verification fails, HALT and report the failure.
|
|
||||||
|
|
||||||
5. **Previous story continuity.** Regardless of which context source succeeded above, scan `{implementation_artifacts}` for specs from the same epic with `status: done` and a lower story number. Load the most recent one (highest story number below current). Extract its **Code Map**, **Design Notes**, **Spec Change Log**, and **task list** as continuity context for step-02 planning. If no `done` spec is found but an `in-review` spec exists for the same epic with a lower story number, note it to the user and ask whether to load it.
|
|
||||||
|
|
||||||
**B) Freeform path** — if the intent is not an epic story:
|
|
||||||
- Planning artifacts are the output of BMAD phases 1-3. Typical files include:
|
|
||||||
- **PRD** (`*prd*`) — product requirements and success criteria
|
|
||||||
- **Architecture** (`*architecture*`) — technical design decisions and constraints
|
|
||||||
- **UX/Design** (`*ux*`) — user experience and interaction design
|
|
||||||
- **Epics** (`*epic*`) — feature breakdown into implementable stories
|
|
||||||
- **Product Brief** (`*brief*`) — project vision and scope
|
|
||||||
- Scan the listing for files matching these patterns. If any look relevant to the current intent, load them selectively — you don't need all of them, but you need the right constraints and requirements rather than guessing from code alone.
|
|
||||||
2. Clarify intent. Do not fantasize, do not leave open questions. If you must ask questions, ask them as a numbered list. When the human replies, verify that every single numbered question was answered. If any were ignored, HALT and re-ask only the missing questions before proceeding. Keep looping until intent is clear enough to implement.
|
2. Clarify intent. Do not fantasize, do not leave open questions. If you must ask questions, ask them as a numbered list. When the human replies, verify that every single numbered question was answered. If any were ignored, HALT and re-ask only the missing questions before proceeding. Keep looping until intent is clear enough to implement.
|
||||||
3. Version control sanity check. Is the working tree clean? Does the current branch make sense for this intent — considering its name and recent history? If the tree is dirty or the branch is an obvious mismatch, HALT and ask the human before proceeding. If version control is unavailable, skip this check.
|
3. Version control sanity check. Is the working tree clean? Does the current branch make sense for this intent — considering its name and recent history? If the tree is dirty or the branch is an obvious mismatch, HALT and ask the human before proceeding. If version control is unavailable, skip this check.
|
||||||
4. Multi-goal check (see SCOPE STANDARD). If the intent fails the single-goal criteria:
|
4. Multi-goal check (see SCOPE STANDARD). If the intent fails the single-goal criteria:
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ module.exports = {
|
||||||
['--communication-language <lang>', 'Language for agent communication (default: English)'],
|
['--communication-language <lang>', 'Language for agent communication (default: English)'],
|
||||||
['--document-output-language <lang>', 'Language for document output (default: English)'],
|
['--document-output-language <lang>', 'Language for document output (default: English)'],
|
||||||
['--output-folder <path>', 'Output folder path relative to project root (default: _bmad-output)'],
|
['--output-folder <path>', 'Output folder path relative to project root (default: _bmad-output)'],
|
||||||
['--custom-source <sources>', 'Comma-separated Git URLs or local paths to install custom modules from'],
|
|
||||||
['-y, --yes', 'Accept all defaults and skip prompts where possible'],
|
['-y, --yes', 'Accept all defaults and skip prompts where possible'],
|
||||||
],
|
],
|
||||||
action: async (options) => {
|
action: async (options) => {
|
||||||
|
|
|
||||||
|
|
@ -569,7 +569,6 @@ class Installer {
|
||||||
*/
|
*/
|
||||||
async _installOfficialModules(config, paths, officialModuleIds, addResult, isQuickUpdate, officialModules, ctx) {
|
async _installOfficialModules(config, paths, officialModuleIds, addResult, isQuickUpdate, officialModules, ctx) {
|
||||||
const { message, installedModuleNames } = ctx;
|
const { message, installedModuleNames } = ctx;
|
||||||
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
|
||||||
|
|
||||||
for (const moduleName of officialModuleIds) {
|
for (const moduleName of officialModuleIds) {
|
||||||
if (installedModuleNames.has(moduleName)) continue;
|
if (installedModuleNames.has(moduleName)) continue;
|
||||||
|
|
@ -592,15 +591,11 @@ class Installer {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get display name from source module.yaml; version from resolution cache or marketplace.json
|
// Get display name from source module.yaml; version from marketplace.json
|
||||||
const sourcePath = await officialModules.findModuleSource(moduleName, { silent: true });
|
const sourcePath = await officialModules.findModuleSource(moduleName, { silent: true });
|
||||||
const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null;
|
const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null;
|
||||||
const displayName = moduleInfo?.name || moduleName;
|
const displayName = moduleInfo?.name || moduleName;
|
||||||
|
const version = sourcePath ? await this._getMarketplaceVersion(sourcePath) : '';
|
||||||
// Prefer version from resolution cache (accurate for custom/local modules),
|
|
||||||
// fall back to marketplace.json walk-up for official modules
|
|
||||||
const cachedResolution = CustomModuleManager._resolutionCache.get(moduleName);
|
|
||||||
const version = cachedResolution?.version || (sourcePath ? await this._getMarketplaceVersion(sourcePath) : '');
|
|
||||||
addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version });
|
addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1194,7 +1189,7 @@ class Installer {
|
||||||
const customMgr = new CustomModuleManager();
|
const customMgr = new CustomModuleManager();
|
||||||
for (const moduleId of installedModules) {
|
for (const moduleId of installedModules) {
|
||||||
if (!availableModules.some((m) => m.id === moduleId)) {
|
if (!availableModules.some((m) => m.id === moduleId)) {
|
||||||
const customSource = await customMgr.findModuleSourceByCode(moduleId, { bmadDir });
|
const customSource = await customMgr.findModuleSourceByCode(moduleId);
|
||||||
if (customSource) {
|
if (customSource) {
|
||||||
availableModules.push({
|
availableModules.push({
|
||||||
id: moduleId,
|
id: moduleId,
|
||||||
|
|
|
||||||
|
|
@ -412,7 +412,7 @@ class ManifestGenerator {
|
||||||
// Get existing install date if available
|
// Get existing install date if available
|
||||||
const existing = existingModulesMap.get(moduleName);
|
const existing = existingModulesMap.get(moduleName);
|
||||||
|
|
||||||
const moduleEntry = {
|
updatedModules.push({
|
||||||
name: moduleName,
|
name: moduleName,
|
||||||
version: versionInfo.version,
|
version: versionInfo.version,
|
||||||
installDate: existing?.installDate || new Date().toISOString(),
|
installDate: existing?.installDate || new Date().toISOString(),
|
||||||
|
|
@ -420,9 +420,7 @@ class ManifestGenerator {
|
||||||
source: versionInfo.source,
|
source: versionInfo.source,
|
||||||
npmPackage: versionInfo.npmPackage,
|
npmPackage: versionInfo.npmPackage,
|
||||||
repoUrl: versionInfo.repoUrl,
|
repoUrl: versionInfo.repoUrl,
|
||||||
};
|
});
|
||||||
if (versionInfo.localPath) moduleEntry.localPath = versionInfo.localPath;
|
|
||||||
updatedModules.push(moduleEntry);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
|
|
|
||||||
|
|
@ -181,10 +181,10 @@ class Manifest {
|
||||||
|
|
||||||
// Handle adding a new module with version info
|
// Handle adding a new module with version info
|
||||||
if (updates.addModule) {
|
if (updates.addModule) {
|
||||||
const { name, version, source, npmPackage, repoUrl, localPath } = updates.addModule;
|
const { name, version, source, npmPackage, repoUrl } = updates.addModule;
|
||||||
const existing = manifest.modules.find((m) => m.name === name);
|
const existing = manifest.modules.find((m) => m.name === name);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const entry = {
|
manifest.modules.push({
|
||||||
name,
|
name,
|
||||||
version: version || null,
|
version: version || null,
|
||||||
installDate: new Date().toISOString(),
|
installDate: new Date().toISOString(),
|
||||||
|
|
@ -192,9 +192,7 @@ class Manifest {
|
||||||
source: source || 'external',
|
source: source || 'external',
|
||||||
npmPackage: npmPackage || null,
|
npmPackage: npmPackage || null,
|
||||||
repoUrl: repoUrl || null,
|
repoUrl: repoUrl || null,
|
||||||
};
|
});
|
||||||
if (localPath) entry.localPath = localPath;
|
|
||||||
manifest.modules.push(entry);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -282,7 +280,7 @@ class Manifest {
|
||||||
|
|
||||||
if (existingIndex === -1) {
|
if (existingIndex === -1) {
|
||||||
// Module doesn't exist, add it
|
// Module doesn't exist, add it
|
||||||
const entry = {
|
manifest.modules.push({
|
||||||
name: moduleName,
|
name: moduleName,
|
||||||
version: options.version || null,
|
version: options.version || null,
|
||||||
installDate: new Date().toISOString(),
|
installDate: new Date().toISOString(),
|
||||||
|
|
@ -290,9 +288,7 @@ class Manifest {
|
||||||
source: options.source || 'unknown',
|
source: options.source || 'unknown',
|
||||||
npmPackage: options.npmPackage || null,
|
npmPackage: options.npmPackage || null,
|
||||||
repoUrl: options.repoUrl || null,
|
repoUrl: options.repoUrl || null,
|
||||||
};
|
});
|
||||||
if (options.localPath) entry.localPath = options.localPath;
|
|
||||||
manifest.modules.push(entry);
|
|
||||||
} else {
|
} else {
|
||||||
// Module exists, update its version info
|
// Module exists, update its version info
|
||||||
const existing = manifest.modules[existingIndex];
|
const existing = manifest.modules[existingIndex];
|
||||||
|
|
@ -302,7 +298,6 @@ class Manifest {
|
||||||
source: options.source || existing.source,
|
source: options.source || existing.source,
|
||||||
npmPackage: options.npmPackage === undefined ? existing.npmPackage : options.npmPackage,
|
npmPackage: options.npmPackage === undefined ? existing.npmPackage : options.npmPackage,
|
||||||
repoUrl: options.repoUrl === undefined ? existing.repoUrl : options.repoUrl,
|
repoUrl: options.repoUrl === undefined ? existing.repoUrl : options.repoUrl,
|
||||||
localPath: options.localPath === undefined ? existing.localPath : options.localPath,
|
|
||||||
lastUpdated: new Date().toISOString(),
|
lastUpdated: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -837,19 +832,17 @@ class Manifest {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a custom module (from user-provided URL or local path)
|
// Check if this is a custom module (from user-provided URL)
|
||||||
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
const { CustomModuleManager } = require('../modules/custom-module-manager');
|
||||||
const customMgr = new CustomModuleManager();
|
const customMgr = new CustomModuleManager();
|
||||||
const resolved = customMgr.getResolution(moduleName);
|
const customSource = await customMgr.findModuleSourceByCode(moduleName);
|
||||||
const customSource = await customMgr.findModuleSourceByCode(moduleName, { bmadDir });
|
if (customSource) {
|
||||||
if (customSource || resolved) {
|
const customVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
||||||
const customVersion = resolved?.version || (await this._readMarketplaceVersion(moduleName, moduleSourcePath));
|
|
||||||
return {
|
return {
|
||||||
version: customVersion,
|
version: customVersion,
|
||||||
source: 'custom',
|
source: 'custom',
|
||||||
npmPackage: null,
|
npmPackage: null,
|
||||||
repoUrl: resolved?.repoUrl || null,
|
repoUrl: null,
|
||||||
localPath: resolved?.localPath || null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,161 +3,22 @@ const os = require('node:os');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { execSync } = require('node:child_process');
|
const { execSync } = require('node:child_process');
|
||||||
const prompts = require('../prompts');
|
const prompts = require('../prompts');
|
||||||
|
const { RegistryClient } = require('./registry-client');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages custom modules installed from user-provided sources.
|
* Manages custom modules installed from user-provided GitHub URLs.
|
||||||
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths.
|
* Validates URLs, fetches .claude-plugin/marketplace.json, clones repos.
|
||||||
* Validates input, clones repos, reads .claude-plugin/marketplace.json, resolves plugins.
|
|
||||||
*/
|
*/
|
||||||
class CustomModuleManager {
|
class CustomModuleManager {
|
||||||
/** @type {Map<string, Object>} Shared across all instances: module code -> ResolvedModule */
|
constructor() {
|
||||||
static _resolutionCache = new Map();
|
this._client = new RegistryClient();
|
||||||
|
|
||||||
// ─── Source Parsing ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a user-provided source input into a structured descriptor.
|
|
||||||
* Accepts local file paths, HTTPS Git URLs, and SSH Git URLs.
|
|
||||||
* For HTTPS URLs with deep paths (e.g., /tree/main/subdir), extracts the subdir.
|
|
||||||
*
|
|
||||||
* @param {string} input - URL or local file path
|
|
||||||
* @returns {Object} Parsed source descriptor:
|
|
||||||
* { type: 'url'|'local', cloneUrl, subdir, localPath, cacheKey, displayName, isValid, error }
|
|
||||||
*/
|
|
||||||
parseSource(input) {
|
|
||||||
if (!input || typeof input !== 'string') {
|
|
||||||
return {
|
|
||||||
type: null,
|
|
||||||
cloneUrl: null,
|
|
||||||
subdir: null,
|
|
||||||
localPath: null,
|
|
||||||
cacheKey: null,
|
|
||||||
displayName: null,
|
|
||||||
isValid: false,
|
|
||||||
error: 'Source is required',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = input.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return {
|
|
||||||
type: null,
|
|
||||||
cloneUrl: null,
|
|
||||||
subdir: null,
|
|
||||||
localPath: null,
|
|
||||||
cacheKey: null,
|
|
||||||
displayName: null,
|
|
||||||
isValid: false,
|
|
||||||
error: 'Source is required',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local path detection: starts with /, ./, ../, or ~
|
|
||||||
if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../') || trimmed.startsWith('~')) {
|
|
||||||
return this._parseLocalPath(trimmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSH URL: git@host:owner/repo.git
|
|
||||||
const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
||||||
if (sshMatch) {
|
|
||||||
const [, host, owner, repo] = sshMatch;
|
|
||||||
return {
|
|
||||||
type: 'url',
|
|
||||||
cloneUrl: trimmed,
|
|
||||||
subdir: null,
|
|
||||||
localPath: null,
|
|
||||||
cacheKey: `${host}/${owner}/${repo}`,
|
|
||||||
displayName: `${owner}/${repo}`,
|
|
||||||
isValid: true,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTPS URL: https://host/owner/repo[/tree/branch/subdir][.git]
|
|
||||||
const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?(\/.*)?$/);
|
|
||||||
if (httpsMatch) {
|
|
||||||
const [, host, owner, repo, remainder] = httpsMatch;
|
|
||||||
const cloneUrl = `https://${host}/${owner}/${repo}`;
|
|
||||||
let subdir = null;
|
|
||||||
|
|
||||||
if (remainder) {
|
|
||||||
// Extract subdir from deep path patterns used by various Git hosts
|
|
||||||
const deepPathPatterns = [
|
|
||||||
/^\/(?:-\/)?tree\/[^/]+\/(.+)$/, // GitHub /tree/branch/path, GitLab /-/tree/branch/path
|
|
||||||
/^\/(?:-\/)?blob\/[^/]+\/(.+)$/, // /blob/branch/path (treat same as tree)
|
|
||||||
/^\/src\/[^/]+\/(.+)$/, // Gitea/Forgejo /src/branch/path
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pattern of deepPathPatterns) {
|
|
||||||
const match = remainder.match(pattern);
|
|
||||||
if (match) {
|
|
||||||
subdir = match[1].replace(/\/$/, ''); // strip trailing slash
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'url',
|
|
||||||
cloneUrl,
|
|
||||||
subdir,
|
|
||||||
localPath: null,
|
|
||||||
cacheKey: `${host}/${owner}/${repo}`,
|
|
||||||
displayName: `${owner}/${repo}`,
|
|
||||||
isValid: true,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: null,
|
|
||||||
cloneUrl: null,
|
|
||||||
subdir: null,
|
|
||||||
localPath: null,
|
|
||||||
cacheKey: null,
|
|
||||||
displayName: null,
|
|
||||||
isValid: false,
|
|
||||||
error: 'Not a valid Git URL or local path',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ─── URL Validation ───────────────────────────────────────────────────────
|
||||||
* Parse a local filesystem path.
|
|
||||||
* @param {string} rawPath - Path string (may contain ~ for home)
|
|
||||||
* @returns {Object} Parsed source descriptor
|
|
||||||
*/
|
|
||||||
_parseLocalPath(rawPath) {
|
|
||||||
const expanded = rawPath.startsWith('~') ? path.join(os.homedir(), rawPath.slice(1)) : rawPath;
|
|
||||||
const resolved = path.resolve(expanded);
|
|
||||||
|
|
||||||
if (!fs.pathExistsSync(resolved)) {
|
|
||||||
return {
|
|
||||||
type: 'local',
|
|
||||||
cloneUrl: null,
|
|
||||||
subdir: null,
|
|
||||||
localPath: resolved,
|
|
||||||
cacheKey: null,
|
|
||||||
displayName: path.basename(resolved),
|
|
||||||
isValid: false,
|
|
||||||
error: `Path does not exist: ${resolved}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'local',
|
|
||||||
cloneUrl: null,
|
|
||||||
subdir: null,
|
|
||||||
localPath: resolved,
|
|
||||||
cacheKey: null,
|
|
||||||
displayName: path.basename(resolved),
|
|
||||||
isValid: true,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated Use parseSource() instead. Kept for backward compatibility.
|
|
||||||
* Parse and validate a GitHub repository URL.
|
* Parse and validate a GitHub repository URL.
|
||||||
|
* Supports HTTPS and SSH formats.
|
||||||
* @param {string} url - GitHub URL to validate
|
* @param {string} url - GitHub URL to validate
|
||||||
* @returns {Object} { owner, repo, isValid, error }
|
* @returns {Object} { owner, repo, isValid, error }
|
||||||
*/
|
*/
|
||||||
|
|
@ -165,15 +26,16 @@ class CustomModuleManager {
|
||||||
if (!url || typeof url !== 'string') {
|
if (!url || typeof url !== 'string') {
|
||||||
return { owner: null, repo: null, isValid: false, error: 'URL is required' };
|
return { owner: null, repo: null, isValid: false, error: 'URL is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmed = url.trim();
|
const trimmed = url.trim();
|
||||||
|
|
||||||
// HTTPS format: https://github.com/owner/repo[.git] (strict, no trailing path)
|
// HTTPS format: https://github.com/owner/repo[.git]
|
||||||
const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
||||||
if (httpsMatch) {
|
if (httpsMatch) {
|
||||||
return { owner: httpsMatch[1], repo: httpsMatch[2], isValid: true, error: null };
|
return { owner: httpsMatch[1], repo: httpsMatch[2], isValid: true, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSH format: git@github.com:owner/repo[.git]
|
// SSH format: git@github.com:owner/repo.git
|
||||||
const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
||||||
if (sshMatch) {
|
if (sshMatch) {
|
||||||
return { owner: sshMatch[1], repo: sshMatch[2], isValid: true, error: null };
|
return { owner: sshMatch[1], repo: sshMatch[2], isValid: true, error: null };
|
||||||
|
|
@ -182,75 +44,46 @@ class CustomModuleManager {
|
||||||
return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' };
|
return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Marketplace JSON ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read .claude-plugin/marketplace.json from a local directory.
|
|
||||||
* @param {string} dirPath - Directory to read from
|
|
||||||
* @returns {Object|null} Parsed marketplace.json or null if not found
|
|
||||||
*/
|
|
||||||
async readMarketplaceJsonFromDisk(dirPath) {
|
|
||||||
const marketplacePath = path.join(dirPath, '.claude-plugin', 'marketplace.json');
|
|
||||||
if (!(await fs.pathExists(marketplacePath))) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Discovery ────────────────────────────────────────────────────────────
|
// ─── Discovery ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discover modules from pre-read marketplace.json data.
|
* Fetch .claude-plugin/marketplace.json from a GitHub repository.
|
||||||
* @param {Object} marketplaceData - Parsed marketplace.json content
|
* @param {string} repoUrl - GitHub repository URL
|
||||||
* @param {string|null} sourceUrl - Source URL for tracking (null for local paths)
|
* @returns {Object} Parsed marketplace.json content
|
||||||
|
*/
|
||||||
|
async fetchMarketplaceJson(repoUrl) {
|
||||||
|
const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl);
|
||||||
|
if (!isValid) throw new Error(error);
|
||||||
|
|
||||||
|
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/.claude-plugin/marketplace.json`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this._client.fetchJson(rawUrl);
|
||||||
|
} catch (error_) {
|
||||||
|
if (error_.message.includes('404')) {
|
||||||
|
throw new Error(`No .claude-plugin/marketplace.json found in ${owner}/${repo}. This repository may not be a BMad module.`);
|
||||||
|
}
|
||||||
|
if (error_.message.includes('403')) {
|
||||||
|
throw new Error(`Repository ${owner}/${repo} is not accessible. Make sure it is public.`);
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch marketplace.json from ${owner}/${repo}: ${error_.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover modules from a GitHub repository's marketplace.json.
|
||||||
|
* @param {string} repoUrl - GitHub repository URL
|
||||||
* @returns {Array<Object>} Normalized plugin list
|
* @returns {Array<Object>} Normalized plugin list
|
||||||
*/
|
*/
|
||||||
async discoverModules(marketplaceData, sourceUrl) {
|
async discoverModules(repoUrl) {
|
||||||
const plugins = marketplaceData?.plugins;
|
const data = await this.fetchMarketplaceJson(repoUrl);
|
||||||
|
const plugins = data?.plugins;
|
||||||
|
|
||||||
if (!Array.isArray(plugins) || plugins.length === 0) {
|
if (!Array.isArray(plugins) || plugins.length === 0) {
|
||||||
throw new Error('marketplace.json contains no plugins');
|
throw new Error('marketplace.json contains no plugins');
|
||||||
}
|
}
|
||||||
|
|
||||||
return plugins.map((plugin) => this._normalizeCustomModule(plugin, sourceUrl, marketplaceData));
|
return plugins.map((plugin) => this._normalizeCustomModule(plugin, repoUrl, data));
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Source Resolution ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* High-level coordinator: parse input, clone if URL, determine discovery vs direct mode.
|
|
||||||
* @param {string} input - URL or local path
|
|
||||||
* @param {Object} [options] - Options passed to cloneRepo
|
|
||||||
* @returns {Object} { parsed, rootDir, repoPath, sourceUrl, marketplace, mode: 'discovery'|'direct' }
|
|
||||||
*/
|
|
||||||
async resolveSource(input, options = {}) {
|
|
||||||
const parsed = this.parseSource(input);
|
|
||||||
if (!parsed.isValid) throw new Error(parsed.error);
|
|
||||||
|
|
||||||
let rootDir;
|
|
||||||
let repoPath;
|
|
||||||
let sourceUrl;
|
|
||||||
|
|
||||||
if (parsed.type === 'local') {
|
|
||||||
rootDir = parsed.localPath;
|
|
||||||
repoPath = null;
|
|
||||||
sourceUrl = null;
|
|
||||||
} else {
|
|
||||||
repoPath = await this.cloneRepo(input, options);
|
|
||||||
sourceUrl = parsed.cloneUrl;
|
|
||||||
rootDir = parsed.subdir ? path.join(repoPath, parsed.subdir) : repoPath;
|
|
||||||
|
|
||||||
if (parsed.subdir && !(await fs.pathExists(rootDir))) {
|
|
||||||
throw new Error(`Subdirectory '${parsed.subdir}' not found in cloned repository`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const marketplace = await this.readMarketplaceJsonFromDisk(rootDir);
|
|
||||||
const mode = marketplace ? 'discovery' : 'direct';
|
|
||||||
|
|
||||||
return { parsed, rootDir, repoPath, sourceUrl, marketplace, mode };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Clone ────────────────────────────────────────────────────────────────
|
// ─── Clone ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -265,24 +98,20 @@ class CustomModuleManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone a custom module repository to cache.
|
* Clone a custom module repository to cache.
|
||||||
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted, etc.).
|
* @param {string} repoUrl - GitHub repository URL
|
||||||
* @param {string} sourceInput - Git URL (HTTPS or SSH)
|
|
||||||
* @param {Object} [options] - Clone options
|
* @param {Object} [options] - Clone options
|
||||||
* @param {boolean} [options.silent] - Suppress spinner output
|
* @param {boolean} [options.silent] - Suppress spinner output
|
||||||
* @param {boolean} [options.skipInstall] - Skip npm install (for browsing before user confirms)
|
|
||||||
* @returns {string} Path to the cloned repository
|
* @returns {string} Path to the cloned repository
|
||||||
*/
|
*/
|
||||||
async cloneRepo(sourceInput, options = {}) {
|
async cloneRepo(repoUrl, options = {}) {
|
||||||
const parsed = this.parseSource(sourceInput);
|
const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl);
|
||||||
if (!parsed.isValid) throw new Error(parsed.error);
|
if (!isValid) throw new Error(error);
|
||||||
if (parsed.type === 'local') throw new Error('cloneRepo does not accept local paths');
|
|
||||||
|
|
||||||
const cacheDir = this.getCacheDir();
|
const cacheDir = this.getCacheDir();
|
||||||
const repoCacheDir = path.join(cacheDir, ...parsed.cacheKey.split('/'));
|
const repoCacheDir = path.join(cacheDir, owner, repo);
|
||||||
const silent = options.silent || false;
|
const silent = options.silent || false;
|
||||||
const displayName = parsed.displayName;
|
|
||||||
|
|
||||||
await fs.ensureDir(path.dirname(repoCacheDir));
|
await fs.ensureDir(path.join(cacheDir, owner));
|
||||||
|
|
||||||
const createSpinner = async () => {
|
const createSpinner = async () => {
|
||||||
if (silent) {
|
if (silent) {
|
||||||
|
|
@ -294,7 +123,7 @@ class CustomModuleManager {
|
||||||
if (await fs.pathExists(repoCacheDir)) {
|
if (await fs.pathExists(repoCacheDir)) {
|
||||||
// Update existing clone
|
// Update existing clone
|
||||||
const fetchSpinner = await createSpinner();
|
const fetchSpinner = await createSpinner();
|
||||||
fetchSpinner.start(`Updating ${displayName}...`);
|
fetchSpinner.start(`Updating ${owner}/${repo}...`);
|
||||||
try {
|
try {
|
||||||
execSync('git fetch origin --depth 1', {
|
execSync('git fetch origin --depth 1', {
|
||||||
cwd: repoCacheDir,
|
cwd: repoCacheDir,
|
||||||
|
|
@ -305,51 +134,42 @@ class CustomModuleManager {
|
||||||
cwd: repoCacheDir,
|
cwd: repoCacheDir,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
fetchSpinner.stop(`Updated ${displayName}`);
|
fetchSpinner.stop(`Updated ${owner}/${repo}`);
|
||||||
} catch {
|
} catch {
|
||||||
fetchSpinner.error(`Update failed, re-downloading ${displayName}`);
|
fetchSpinner.error(`Update failed, re-downloading ${owner}/${repo}`);
|
||||||
await fs.remove(repoCacheDir);
|
await fs.remove(repoCacheDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await fs.pathExists(repoCacheDir))) {
|
if (!(await fs.pathExists(repoCacheDir))) {
|
||||||
const fetchSpinner = await createSpinner();
|
const fetchSpinner = await createSpinner();
|
||||||
fetchSpinner.start(`Cloning ${displayName}...`);
|
fetchSpinner.start(`Cloning ${owner}/${repo}...`);
|
||||||
try {
|
try {
|
||||||
execSync(`git clone --depth 1 "${parsed.cloneUrl}" "${repoCacheDir}"`, {
|
execSync(`git clone --depth 1 "${repoUrl}" "${repoCacheDir}"`, {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
});
|
});
|
||||||
fetchSpinner.stop(`Cloned ${displayName}`);
|
fetchSpinner.stop(`Cloned ${owner}/${repo}`);
|
||||||
} catch (error_) {
|
} catch (error_) {
|
||||||
fetchSpinner.error(`Failed to clone ${displayName}`);
|
fetchSpinner.error(`Failed to clone ${owner}/${repo}`);
|
||||||
throw new Error(`Failed to clone ${parsed.cloneUrl}: ${error_.message}`);
|
throw new Error(`Failed to clone ${repoUrl}: ${error_.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write source metadata for later URL reconstruction
|
// Install dependencies if package.json exists
|
||||||
const metadataPath = path.join(repoCacheDir, '.bmad-source.json');
|
|
||||||
await fs.writeJson(metadataPath, {
|
|
||||||
cloneUrl: parsed.cloneUrl,
|
|
||||||
cacheKey: parsed.cacheKey,
|
|
||||||
displayName: parsed.displayName,
|
|
||||||
clonedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Install dependencies if package.json exists (skip during browsing/analysis)
|
|
||||||
const packageJsonPath = path.join(repoCacheDir, 'package.json');
|
const packageJsonPath = path.join(repoCacheDir, 'package.json');
|
||||||
if (!options.skipInstall && (await fs.pathExists(packageJsonPath))) {
|
if (await fs.pathExists(packageJsonPath)) {
|
||||||
const installSpinner = await createSpinner();
|
const installSpinner = await createSpinner();
|
||||||
installSpinner.start(`Installing dependencies for ${displayName}...`);
|
installSpinner.start(`Installing dependencies for ${owner}/${repo}...`);
|
||||||
try {
|
try {
|
||||||
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
||||||
cwd: repoCacheDir,
|
cwd: repoCacheDir,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
});
|
});
|
||||||
installSpinner.stop(`Installed dependencies for ${displayName}`);
|
installSpinner.stop(`Installed dependencies for ${owner}/${repo}`);
|
||||||
} catch (error_) {
|
} catch (error_) {
|
||||||
installSpinner.error(`Failed to install dependencies for ${displayName}`);
|
installSpinner.error(`Failed to install dependencies for ${owner}/${repo}`);
|
||||||
if (!silent) await prompts.log.warn(` ${error_.message}`);
|
if (!silent) await prompts.log.warn(` ${error_.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -357,65 +177,23 @@ class CustomModuleManager {
|
||||||
return repoCacheDir;
|
return repoCacheDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Plugin Resolution ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a plugin to determine installation strategy and module registration files.
|
|
||||||
* Results are cached in _resolutionCache keyed by module code.
|
|
||||||
* @param {string} repoPath - Absolute path to the cloned repository or local directory
|
|
||||||
* @param {Object} plugin - Raw plugin object from marketplace.json
|
|
||||||
* @param {string} [sourceUrl] - Original URL for manifest tracking (null for local)
|
|
||||||
* @param {string} [localPath] - Local source path for manifest tracking (null for URLs)
|
|
||||||
* @returns {Promise<Array<Object>>} Array of ResolvedModule objects
|
|
||||||
*/
|
|
||||||
async resolvePlugin(repoPath, plugin, sourceUrl, localPath) {
|
|
||||||
const { PluginResolver } = require('./plugin-resolver');
|
|
||||||
const resolver = new PluginResolver();
|
|
||||||
const resolved = await resolver.resolve(repoPath, plugin);
|
|
||||||
|
|
||||||
// Stamp source info onto each resolved module for manifest tracking
|
|
||||||
for (const mod of resolved) {
|
|
||||||
if (sourceUrl) mod.repoUrl = sourceUrl;
|
|
||||||
if (localPath) mod.localPath = localPath;
|
|
||||||
CustomModuleManager._resolutionCache.set(mod.code, mod);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a cached resolution result by module code.
|
|
||||||
* @param {string} moduleCode - Module code to look up
|
|
||||||
* @returns {Object|null} ResolvedModule or null if not cached
|
|
||||||
*/
|
|
||||||
getResolution(moduleCode) {
|
|
||||||
return CustomModuleManager._resolutionCache.get(moduleCode) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Source Finding ───────────────────────────────────────────────────────
|
// ─── Source Finding ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the module source path within a cached or local source directory.
|
* Find the module source path within a cloned custom repo.
|
||||||
* @param {string} sourceInput - Git URL or local path (used to locate cached clone)
|
* @param {string} repoUrl - GitHub repository URL (for cache location)
|
||||||
* @param {string} [pluginSource] - Plugin source path from marketplace.json
|
* @param {string} [pluginSource] - Plugin source path from marketplace.json
|
||||||
* @returns {string|null} Path to directory containing module.yaml
|
* @returns {string|null} Path to directory containing module.yaml
|
||||||
*/
|
*/
|
||||||
async findModuleSource(sourceInput, pluginSource) {
|
async findModuleSource(repoUrl, pluginSource) {
|
||||||
const parsed = this.parseSource(sourceInput);
|
const { owner, repo } = this.validateGitHubUrl(repoUrl);
|
||||||
if (!parsed.isValid) return null;
|
const repoCacheDir = path.join(this.getCacheDir(), owner, repo);
|
||||||
|
|
||||||
let baseDir;
|
if (!(await fs.pathExists(repoCacheDir))) return null;
|
||||||
if (parsed.type === 'local') {
|
|
||||||
baseDir = parsed.localPath;
|
|
||||||
} else {
|
|
||||||
baseDir = path.join(this.getCacheDir(), ...parsed.cacheKey.split('/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(baseDir))) return null;
|
|
||||||
|
|
||||||
// Try plugin source path first (e.g., "./src/pro-skills")
|
// Try plugin source path first (e.g., "./src/pro-skills")
|
||||||
if (pluginSource) {
|
if (pluginSource) {
|
||||||
const sourcePath = path.join(baseDir, pluginSource);
|
const sourcePath = path.join(repoCacheDir, pluginSource);
|
||||||
const moduleYaml = path.join(sourcePath, 'module.yaml');
|
const moduleYaml = path.join(sourcePath, 'module.yaml');
|
||||||
if (await fs.pathExists(moduleYaml)) {
|
if (await fs.pathExists(moduleYaml)) {
|
||||||
return sourcePath;
|
return sourcePath;
|
||||||
|
|
@ -424,11 +202,11 @@ class CustomModuleManager {
|
||||||
|
|
||||||
// Fallback: search skills/ and src/ directories
|
// Fallback: search skills/ and src/ directories
|
||||||
for (const dir of ['skills', 'src']) {
|
for (const dir of ['skills', 'src']) {
|
||||||
const rootCandidate = path.join(baseDir, dir, 'module.yaml');
|
const rootCandidate = path.join(repoCacheDir, dir, 'module.yaml');
|
||||||
if (await fs.pathExists(rootCandidate)) {
|
if (await fs.pathExists(rootCandidate)) {
|
||||||
return path.dirname(rootCandidate);
|
return path.dirname(rootCandidate);
|
||||||
}
|
}
|
||||||
const dirPath = path.join(baseDir, dir);
|
const dirPath = path.join(repoCacheDir, dir);
|
||||||
if (await fs.pathExists(dirPath)) {
|
if (await fs.pathExists(dirPath)) {
|
||||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
|
@ -442,10 +220,10 @@ class CustomModuleManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check base directory root
|
// Check repo root
|
||||||
const rootCandidate = path.join(baseDir, 'module.yaml');
|
const rootCandidate = path.join(repoCacheDir, 'module.yaml');
|
||||||
if (await fs.pathExists(rootCandidate)) {
|
if (await fs.pathExists(rootCandidate)) {
|
||||||
return baseDir;
|
return repoCacheDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -453,163 +231,51 @@ class CustomModuleManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find module source by module code, searching the custom cache.
|
* Find module source by module code, searching the custom cache.
|
||||||
* Handles both new 3-level cache structure (host/owner/repo) and
|
|
||||||
* legacy 2-level structure (owner/repo).
|
|
||||||
* @param {string} moduleCode - Module code to search for
|
* @param {string} moduleCode - Module code to search for
|
||||||
* @param {Object} [options] - Options
|
* @param {Object} [options] - Options
|
||||||
* @returns {string|null} Path to the module source or null
|
* @returns {string|null} Path to the module source or null
|
||||||
*/
|
*/
|
||||||
async findModuleSourceByCode(moduleCode, options = {}) {
|
async findModuleSourceByCode(moduleCode, options = {}) {
|
||||||
// Check resolution cache first (populated by resolvePlugin)
|
|
||||||
const resolved = CustomModuleManager._resolutionCache.get(moduleCode);
|
|
||||||
if (resolved) {
|
|
||||||
// For strategies 1-2: the common parent or setup skill's parent has the module files
|
|
||||||
if (resolved.moduleYamlPath) {
|
|
||||||
return path.dirname(resolved.moduleYamlPath);
|
|
||||||
}
|
|
||||||
// For strategy 5 (synthesized): return the first skill's parent as a reference path
|
|
||||||
if (resolved.skillPaths && resolved.skillPaths.length > 0) {
|
|
||||||
return path.dirname(resolved.skillPaths[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheDir = this.getCacheDir();
|
const cacheDir = this.getCacheDir();
|
||||||
if (!(await fs.pathExists(cacheDir))) return null;
|
if (!(await fs.pathExists(cacheDir))) return null;
|
||||||
|
|
||||||
// Search through all cached repo roots
|
// Search through all custom repo caches
|
||||||
try {
|
try {
|
||||||
const { PluginResolver } = require('./plugin-resolver');
|
const owners = await fs.readdir(cacheDir, { withFileTypes: true });
|
||||||
const resolver = new PluginResolver();
|
for (const ownerEntry of owners) {
|
||||||
const repoRoots = await this._findCacheRepoRoots(cacheDir);
|
if (!ownerEntry.isDirectory()) continue;
|
||||||
|
const ownerPath = path.join(cacheDir, ownerEntry.name);
|
||||||
|
const repos = await fs.readdir(ownerPath, { withFileTypes: true });
|
||||||
|
for (const repoEntry of repos) {
|
||||||
|
if (!repoEntry.isDirectory()) continue;
|
||||||
|
const repoPath = path.join(ownerPath, repoEntry.name);
|
||||||
|
|
||||||
for (const { repoPath, metadata } of repoRoots) {
|
// Check marketplace.json for matching module code
|
||||||
// Check marketplace.json for matching module code
|
const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
|
||||||
const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
|
if (await fs.pathExists(marketplacePath)) {
|
||||||
if (!(await fs.pathExists(marketplacePath))) continue;
|
try {
|
||||||
|
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
||||||
try {
|
for (const plugin of data.plugins || []) {
|
||||||
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
|
if (plugin.name === moduleCode) {
|
||||||
for (const plugin of data.plugins || []) {
|
// Found the module - find its source
|
||||||
// Direct name match (legacy behavior)
|
const sourcePath = plugin.source ? path.join(repoPath, plugin.source) : repoPath;
|
||||||
if (plugin.name === moduleCode) {
|
const moduleYaml = path.join(sourcePath, 'module.yaml');
|
||||||
const sourcePath = plugin.source ? path.join(repoPath, plugin.source) : repoPath;
|
if (await fs.pathExists(moduleYaml)) {
|
||||||
const moduleYaml = path.join(sourcePath, 'module.yaml');
|
return sourcePath;
|
||||||
if (await fs.pathExists(moduleYaml)) {
|
|
||||||
return sourcePath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve plugin to check if any module.yaml code matches
|
|
||||||
if (plugin.skills && plugin.skills.length > 0) {
|
|
||||||
try {
|
|
||||||
const resolvedMods = await resolver.resolve(repoPath, plugin);
|
|
||||||
for (const mod of resolvedMods) {
|
|
||||||
if (mod.code === moduleCode) {
|
|
||||||
// Use metadata for URL reconstruction instead of deriving from path
|
|
||||||
mod.repoUrl = metadata?.cloneUrl || null;
|
|
||||||
CustomModuleManager._resolutionCache.set(mod.code, mod);
|
|
||||||
if (mod.moduleYamlPath) {
|
|
||||||
return path.dirname(mod.moduleYamlPath);
|
|
||||||
}
|
|
||||||
if (mod.skillPaths && mod.skillPaths.length > 0) {
|
|
||||||
return path.dirname(mod.skillPaths[0]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Skip unresolvable plugins
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed marketplace.json
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Skip malformed marketplace.json
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Cache doesn't exist or is inaccessible
|
// Cache doesn't exist or is inaccessible
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: check manifest for localPath (local-source modules not in cache)
|
return null;
|
||||||
return this._findLocalSourceFromManifest(moduleCode, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check the installation manifest for a localPath entry for this module.
|
|
||||||
* Used as fallback when the module was installed from a local source (no cache entry).
|
|
||||||
* Returns the path only if it still exists on disk; never removes installed files.
|
|
||||||
* @param {string} moduleCode - Module code to search for
|
|
||||||
* @param {Object} [options] - Options (must include bmadDir or will search common locations)
|
|
||||||
* @returns {string|null} Path to the local module source or null
|
|
||||||
*/
|
|
||||||
async _findLocalSourceFromManifest(moduleCode, options = {}) {
|
|
||||||
try {
|
|
||||||
const { Manifest } = require('../core/manifest');
|
|
||||||
const manifestObj = new Manifest();
|
|
||||||
|
|
||||||
// Try to find bmadDir from options or common locations
|
|
||||||
const bmadDir = options.bmadDir;
|
|
||||||
if (!bmadDir) return null;
|
|
||||||
|
|
||||||
const manifestData = await manifestObj.read(bmadDir);
|
|
||||||
if (!manifestData?.modulesDetailed) return null;
|
|
||||||
|
|
||||||
const moduleEntry = manifestData.modulesDetailed.find((m) => m.name === moduleCode);
|
|
||||||
if (!moduleEntry?.localPath) return null;
|
|
||||||
|
|
||||||
// Only return the path if it still exists (source not removed)
|
|
||||||
if (await fs.pathExists(moduleEntry.localPath)) {
|
|
||||||
return moduleEntry.localPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively find repo root directories within the cache.
|
|
||||||
* A repo root is identified by containing .bmad-source.json (new) or .claude-plugin/ (legacy).
|
|
||||||
* Handles both 3-level (host/owner/repo) and legacy 2-level (owner/repo) cache layouts.
|
|
||||||
* @param {string} dir - Directory to search
|
|
||||||
* @param {number} [depth=0] - Current recursion depth
|
|
||||||
* @param {number} [maxDepth=4] - Maximum recursion depth
|
|
||||||
* @returns {Promise<Array<{repoPath: string, metadata: Object|null}>>}
|
|
||||||
*/
|
|
||||||
async _findCacheRepoRoots(dir, depth = 0, maxDepth = 4) {
|
|
||||||
const results = [];
|
|
||||||
|
|
||||||
// Check if this directory is a repo root
|
|
||||||
const metadataPath = path.join(dir, '.bmad-source.json');
|
|
||||||
const claudePluginDir = path.join(dir, '.claude-plugin');
|
|
||||||
|
|
||||||
if (await fs.pathExists(metadataPath)) {
|
|
||||||
try {
|
|
||||||
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
|
|
||||||
results.push({ repoPath: dir, metadata });
|
|
||||||
} catch {
|
|
||||||
results.push({ repoPath: dir, metadata: null });
|
|
||||||
}
|
|
||||||
return results; // Don't recurse into repo contents
|
|
||||||
}
|
|
||||||
if (await fs.pathExists(claudePluginDir)) {
|
|
||||||
results.push({ repoPath: dir, metadata: null });
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recurse into subdirectories
|
|
||||||
if (depth >= maxDepth) return results;
|
|
||||||
try {
|
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
||||||
const subResults = await this._findCacheRepoRoots(path.join(dir, entry.name), depth + 1, maxDepth);
|
|
||||||
results.push(...subResults);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Directory not readable
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Normalization ────────────────────────────────────────────────────────
|
// ─── Normalization ────────────────────────────────────────────────────────
|
||||||
|
|
@ -617,11 +283,11 @@ class CustomModuleManager {
|
||||||
/**
|
/**
|
||||||
* Normalize a plugin from marketplace.json to a consistent shape.
|
* Normalize a plugin from marketplace.json to a consistent shape.
|
||||||
* @param {Object} plugin - Plugin object from marketplace.json
|
* @param {Object} plugin - Plugin object from marketplace.json
|
||||||
* @param {string|null} sourceUrl - Source URL (null for local paths)
|
* @param {string} repoUrl - Source repository URL
|
||||||
* @param {Object} data - Full marketplace.json data
|
* @param {Object} data - Full marketplace.json data
|
||||||
* @returns {Object} Normalized module info
|
* @returns {Object} Normalized module info
|
||||||
*/
|
*/
|
||||||
_normalizeCustomModule(plugin, sourceUrl, data) {
|
_normalizeCustomModule(plugin, repoUrl, data) {
|
||||||
return {
|
return {
|
||||||
code: plugin.name,
|
code: plugin.name,
|
||||||
name: plugin.name,
|
name: plugin.name,
|
||||||
|
|
@ -629,10 +295,8 @@ class CustomModuleManager {
|
||||||
description: plugin.description || '',
|
description: plugin.description || '',
|
||||||
version: plugin.version || null,
|
version: plugin.version || null,
|
||||||
author: plugin.author || data.owner || '',
|
author: plugin.author || data.owner || '',
|
||||||
url: sourceUrl || null,
|
url: repoUrl,
|
||||||
source: plugin.source || null,
|
source: plugin.source || null,
|
||||||
skills: plugin.skills || [],
|
|
||||||
rawPlugin: plugin,
|
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
trustTier: 'unverified',
|
trustTier: 'unverified',
|
||||||
builtIn: false,
|
builtIn: false,
|
||||||
|
|
|
||||||
|
|
@ -135,22 +135,6 @@ class OfficialModules {
|
||||||
const moduleConfigPath = path.join(modulePath, 'module.yaml');
|
const moduleConfigPath = path.join(modulePath, 'module.yaml');
|
||||||
|
|
||||||
if (!(await fs.pathExists(moduleConfigPath))) {
|
if (!(await fs.pathExists(moduleConfigPath))) {
|
||||||
// Check resolution cache for strategy 5 modules (no module.yaml on disk)
|
|
||||||
const { CustomModuleManager } = require('./custom-module-manager');
|
|
||||||
const customMgr = new CustomModuleManager();
|
|
||||||
const resolved = customMgr.getResolution(defaultName);
|
|
||||||
if (resolved && resolved.synthesizedModuleYaml) {
|
|
||||||
return {
|
|
||||||
id: resolved.code,
|
|
||||||
path: modulePath,
|
|
||||||
name: resolved.name,
|
|
||||||
description: resolved.description,
|
|
||||||
version: resolved.version || '1.0.0',
|
|
||||||
source: sourceDescription,
|
|
||||||
dependencies: [],
|
|
||||||
defaultSelected: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,14 +232,6 @@ class OfficialModules {
|
||||||
* @param {Object} options.logger - Logger instance for output
|
* @param {Object} options.logger - Logger instance for output
|
||||||
*/
|
*/
|
||||||
async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
|
async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
|
||||||
// Check if this module has a plugin resolution (custom marketplace install)
|
|
||||||
const { CustomModuleManager } = require('./custom-module-manager');
|
|
||||||
const customMgr = new CustomModuleManager();
|
|
||||||
const resolved = customMgr.getResolution(moduleName);
|
|
||||||
if (resolved) {
|
|
||||||
return this.installFromResolution(resolved, bmadDir, fileTrackingCallback, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent });
|
const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent });
|
||||||
const targetPath = path.join(bmadDir, moduleName);
|
const targetPath = path.join(bmadDir, moduleName);
|
||||||
|
|
||||||
|
|
@ -289,62 +265,6 @@ class OfficialModules {
|
||||||
return { success: true, module: moduleName, path: targetPath, versionInfo };
|
return { success: true, module: moduleName, path: targetPath, versionInfo };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Install a module from a PluginResolver resolution result.
|
|
||||||
* Copies specific skill directories and places module-help.csv at the target root.
|
|
||||||
* @param {Object} resolved - ResolvedModule from PluginResolver
|
|
||||||
* @param {string} bmadDir - Target bmad directory
|
|
||||||
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
|
||||||
* @param {Object} options - Installation options
|
|
||||||
*/
|
|
||||||
async installFromResolution(resolved, bmadDir, fileTrackingCallback = null, options = {}) {
|
|
||||||
const targetPath = path.join(bmadDir, resolved.code);
|
|
||||||
|
|
||||||
if (await fs.pathExists(targetPath)) {
|
|
||||||
await fs.remove(targetPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.ensureDir(targetPath);
|
|
||||||
|
|
||||||
// Copy each skill directory, flattened by leaf name
|
|
||||||
for (const skillPath of resolved.skillPaths) {
|
|
||||||
const skillDirName = path.basename(skillPath);
|
|
||||||
const skillTarget = path.join(targetPath, skillDirName);
|
|
||||||
await this.copyModuleWithFiltering(skillPath, skillTarget, fileTrackingCallback, options.moduleConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Place module-help.csv at the module root
|
|
||||||
if (resolved.moduleHelpCsvPath) {
|
|
||||||
// Strategies 1-4: copy the existing file
|
|
||||||
const helpTarget = path.join(targetPath, 'module-help.csv');
|
|
||||||
await fs.copy(resolved.moduleHelpCsvPath, helpTarget, { overwrite: true });
|
|
||||||
if (fileTrackingCallback) fileTrackingCallback(helpTarget);
|
|
||||||
} else if (resolved.synthesizedHelpCsv) {
|
|
||||||
// Strategy 5: write synthesized content
|
|
||||||
const helpTarget = path.join(targetPath, 'module-help.csv');
|
|
||||||
await fs.writeFile(helpTarget, resolved.synthesizedHelpCsv, 'utf8');
|
|
||||||
if (fileTrackingCallback) fileTrackingCallback(helpTarget);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create directories declared in module.yaml (strategies 1-4 may have these)
|
|
||||||
if (!options.skipModuleInstaller) {
|
|
||||||
await this.createModuleDirectories(resolved.code, bmadDir, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update manifest
|
|
||||||
const { Manifest } = require('../core/manifest');
|
|
||||||
const manifestObj = new Manifest();
|
|
||||||
|
|
||||||
await manifestObj.addModule(bmadDir, resolved.code, {
|
|
||||||
version: resolved.version || null,
|
|
||||||
source: 'custom',
|
|
||||||
npmPackage: null,
|
|
||||||
repoUrl: resolved.repoUrl || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, module: resolved.code, path: targetPath, versionInfo: { version: resolved.version || '' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing module
|
* Update an existing module
|
||||||
* @param {string} moduleName - Name of the module to update
|
* @param {string} moduleName - Name of the module to update
|
||||||
|
|
|
||||||
|
|
@ -1,398 +0,0 @@
|
||||||
const fs = require('fs-extra');
|
|
||||||
const path = require('node:path');
|
|
||||||
const yaml = require('yaml');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves how to install a plugin from marketplace.json by analyzing
|
|
||||||
* where module.yaml and module-help.csv live relative to the listed skills.
|
|
||||||
*
|
|
||||||
* Five strategies, tried in order:
|
|
||||||
* 1. Root module files at the common parent of all skills
|
|
||||||
* 2. A -setup skill with assets/module.yaml + assets/module-help.csv
|
|
||||||
* 3. Single standalone skill with both files in its assets/
|
|
||||||
* 4. Multiple standalone skills, each with both files in assets/
|
|
||||||
* 5. Fallback: synthesize from marketplace.json + SKILL.md frontmatter
|
|
||||||
*/
|
|
||||||
class PluginResolver {
|
|
||||||
/**
|
|
||||||
* Resolve a plugin to one or more installable module definitions.
|
|
||||||
* @param {string} repoPath - Absolute path to the cloned repository root
|
|
||||||
* @param {Object} plugin - Plugin object from marketplace.json
|
|
||||||
* @param {string} plugin.name - Plugin identifier
|
|
||||||
* @param {string} [plugin.source] - Relative path from repo root
|
|
||||||
* @param {string} [plugin.version] - Semantic version
|
|
||||||
* @param {string} [plugin.description] - Plugin description
|
|
||||||
* @param {string[]} [plugin.skills] - Relative paths to skill directories
|
|
||||||
* @returns {Promise<ResolvedModule[]>} Array of resolved module definitions
|
|
||||||
*/
|
|
||||||
async resolve(repoPath, plugin) {
|
|
||||||
const skillRelPaths = plugin.skills || [];
|
|
||||||
|
|
||||||
// No skills array: legacy behavior - caller should use existing findModuleSource
|
|
||||||
if (skillRelPaths.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve skill paths to absolute, constrain to repo root, filter non-existent
|
|
||||||
const repoRoot = path.resolve(repoPath);
|
|
||||||
const skillPaths = [];
|
|
||||||
for (const rel of skillRelPaths) {
|
|
||||||
const normalized = rel.replace(/^\.\//, '');
|
|
||||||
const abs = path.resolve(repoPath, normalized);
|
|
||||||
// Guard against path traversal (.. segments, absolute paths in marketplace.json)
|
|
||||||
if (!abs.startsWith(repoRoot + path.sep) && abs !== repoRoot) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (await fs.pathExists(abs)) {
|
|
||||||
skillPaths.push(abs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skillPaths.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try each strategy in order
|
|
||||||
const result =
|
|
||||||
(await this._tryRootModuleFiles(repoPath, plugin, skillPaths)) ||
|
|
||||||
(await this._trySetupSkill(repoPath, plugin, skillPaths)) ||
|
|
||||||
(await this._trySingleStandalone(repoPath, plugin, skillPaths)) ||
|
|
||||||
(await this._tryMultipleStandalone(repoPath, plugin, skillPaths)) ||
|
|
||||||
(await this._synthesizeFallback(repoPath, plugin, skillPaths));
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Strategy 1: Root Module Files ──────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if module.yaml + module-help.csv exist at the common parent of all skills.
|
|
||||||
*/
|
|
||||||
async _tryRootModuleFiles(repoPath, plugin, skillPaths) {
|
|
||||||
const commonParent = this._computeCommonParent(skillPaths);
|
|
||||||
const moduleYamlPath = path.join(commonParent, 'module.yaml');
|
|
||||||
const moduleHelpPath = path.join(commonParent, 'module-help.csv');
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(moduleYamlPath)) || !(await fs.pathExists(moduleHelpPath))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const moduleData = await this._readModuleYaml(moduleYamlPath);
|
|
||||||
if (!moduleData) return null;
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
code: moduleData.code || plugin.name,
|
|
||||||
name: moduleData.name || plugin.name,
|
|
||||||
version: plugin.version || moduleData.module_version || null,
|
|
||||||
description: moduleData.description || plugin.description || '',
|
|
||||||
strategy: 1,
|
|
||||||
pluginName: plugin.name,
|
|
||||||
moduleYamlPath,
|
|
||||||
moduleHelpCsvPath: moduleHelpPath,
|
|
||||||
skillPaths,
|
|
||||||
synthesizedModuleYaml: null,
|
|
||||||
synthesizedHelpCsv: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Strategy 2: Setup Skill ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for a skill ending in -setup with assets/module.yaml + assets/module-help.csv.
|
|
||||||
*/
|
|
||||||
async _trySetupSkill(repoPath, plugin, skillPaths) {
|
|
||||||
for (const skillPath of skillPaths) {
|
|
||||||
const dirName = path.basename(skillPath);
|
|
||||||
if (!dirName.endsWith('-setup')) continue;
|
|
||||||
|
|
||||||
const moduleYamlPath = path.join(skillPath, 'assets', 'module.yaml');
|
|
||||||
const moduleHelpPath = path.join(skillPath, 'assets', 'module-help.csv');
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(moduleYamlPath)) || !(await fs.pathExists(moduleHelpPath))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const moduleData = await this._readModuleYaml(moduleYamlPath);
|
|
||||||
if (!moduleData) continue;
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
code: moduleData.code || plugin.name,
|
|
||||||
name: moduleData.name || plugin.name,
|
|
||||||
version: plugin.version || moduleData.module_version || null,
|
|
||||||
description: moduleData.description || plugin.description || '',
|
|
||||||
strategy: 2,
|
|
||||||
pluginName: plugin.name,
|
|
||||||
moduleYamlPath,
|
|
||||||
moduleHelpCsvPath: moduleHelpPath,
|
|
||||||
skillPaths,
|
|
||||||
synthesizedModuleYaml: null,
|
|
||||||
synthesizedHelpCsv: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Strategy 3: Single Standalone Skill ────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One skill listed, with assets/module.yaml + assets/module-help.csv.
|
|
||||||
*/
|
|
||||||
async _trySingleStandalone(repoPath, plugin, skillPaths) {
|
|
||||||
if (skillPaths.length !== 1) return null;
|
|
||||||
|
|
||||||
const skillPath = skillPaths[0];
|
|
||||||
const moduleYamlPath = path.join(skillPath, 'assets', 'module.yaml');
|
|
||||||
const moduleHelpPath = path.join(skillPath, 'assets', 'module-help.csv');
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(moduleYamlPath)) || !(await fs.pathExists(moduleHelpPath))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const moduleData = await this._readModuleYaml(moduleYamlPath);
|
|
||||||
if (!moduleData) return null;
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
code: moduleData.code || plugin.name,
|
|
||||||
name: moduleData.name || plugin.name,
|
|
||||||
version: plugin.version || moduleData.module_version || null,
|
|
||||||
description: moduleData.description || plugin.description || '',
|
|
||||||
strategy: 3,
|
|
||||||
pluginName: plugin.name,
|
|
||||||
moduleYamlPath,
|
|
||||||
moduleHelpCsvPath: moduleHelpPath,
|
|
||||||
skillPaths,
|
|
||||||
synthesizedModuleYaml: null,
|
|
||||||
synthesizedHelpCsv: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Strategy 4: Multiple Standalone Skills ─────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Multiple skills, each with assets/module.yaml + assets/module-help.csv.
|
|
||||||
* Each becomes its own installable module.
|
|
||||||
*/
|
|
||||||
async _tryMultipleStandalone(repoPath, plugin, skillPaths) {
|
|
||||||
if (skillPaths.length < 2) return null;
|
|
||||||
|
|
||||||
const resolved = [];
|
|
||||||
|
|
||||||
for (const skillPath of skillPaths) {
|
|
||||||
const moduleYamlPath = path.join(skillPath, 'assets', 'module.yaml');
|
|
||||||
const moduleHelpPath = path.join(skillPath, 'assets', 'module-help.csv');
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(moduleYamlPath)) || !(await fs.pathExists(moduleHelpPath))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const moduleData = await this._readModuleYaml(moduleYamlPath);
|
|
||||||
if (!moduleData) continue;
|
|
||||||
|
|
||||||
resolved.push({
|
|
||||||
code: moduleData.code || path.basename(skillPath),
|
|
||||||
name: moduleData.name || path.basename(skillPath),
|
|
||||||
version: plugin.version || moduleData.module_version || null,
|
|
||||||
description: moduleData.description || '',
|
|
||||||
strategy: 4,
|
|
||||||
pluginName: plugin.name,
|
|
||||||
moduleYamlPath,
|
|
||||||
moduleHelpCsvPath: moduleHelpPath,
|
|
||||||
skillPaths: [skillPath],
|
|
||||||
synthesizedModuleYaml: null,
|
|
||||||
synthesizedHelpCsv: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only use strategy 4 if ALL skills have module files
|
|
||||||
if (resolved.length === skillPaths.length) {
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Partial match: fall through to strategy 5
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Strategy 5: Fallback (Synthesized) ─────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* No module files found anywhere. Synthesize from marketplace.json metadata
|
|
||||||
* and SKILL.md frontmatter.
|
|
||||||
*/
|
|
||||||
async _synthesizeFallback(repoPath, plugin, skillPaths) {
|
|
||||||
const skillInfos = [];
|
|
||||||
|
|
||||||
for (const skillPath of skillPaths) {
|
|
||||||
const frontmatter = await this._parseSkillFrontmatter(skillPath);
|
|
||||||
skillInfos.push({
|
|
||||||
dirName: path.basename(skillPath),
|
|
||||||
name: frontmatter.name || path.basename(skillPath),
|
|
||||||
description: frontmatter.description || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const moduleName = this._formatDisplayName(plugin.name);
|
|
||||||
const code = plugin.name;
|
|
||||||
|
|
||||||
const synthesizedYaml = {
|
|
||||||
code,
|
|
||||||
name: moduleName,
|
|
||||||
description: plugin.description || '',
|
|
||||||
module_version: plugin.version || '1.0.0',
|
|
||||||
default_selected: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const synthesizedCsv = this._buildSynthesizedHelpCsv(moduleName, skillInfos);
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
code,
|
|
||||||
name: moduleName,
|
|
||||||
version: plugin.version || null,
|
|
||||||
description: plugin.description || '',
|
|
||||||
strategy: 5,
|
|
||||||
pluginName: plugin.name,
|
|
||||||
moduleYamlPath: null,
|
|
||||||
moduleHelpCsvPath: null,
|
|
||||||
skillPaths,
|
|
||||||
synthesizedModuleYaml: synthesizedYaml,
|
|
||||||
synthesizedHelpCsv: synthesizedCsv,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute the deepest common ancestor directory of an array of absolute paths.
|
|
||||||
* @param {string[]} absPaths - Absolute directory paths
|
|
||||||
* @returns {string} Common parent directory
|
|
||||||
*/
|
|
||||||
_computeCommonParent(absPaths) {
|
|
||||||
if (absPaths.length === 0) return '/';
|
|
||||||
if (absPaths.length === 1) return path.dirname(absPaths[0]);
|
|
||||||
|
|
||||||
const segments = absPaths.map((p) => p.split(path.sep));
|
|
||||||
const minLen = Math.min(...segments.map((s) => s.length));
|
|
||||||
const common = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < minLen; i++) {
|
|
||||||
const segment = segments[0][i];
|
|
||||||
if (segments.every((s) => s[i] === segment)) {
|
|
||||||
common.push(segment);
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return common.join(path.sep) || '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read and parse a module.yaml file.
|
|
||||||
* @param {string} yamlPath - Absolute path to module.yaml
|
|
||||||
* @returns {Object|null} Parsed content or null on failure
|
|
||||||
*/
|
|
||||||
async _readModuleYaml(yamlPath) {
|
|
||||||
try {
|
|
||||||
const content = await fs.readFile(yamlPath, 'utf8');
|
|
||||||
return yaml.parse(content);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract name and description from a SKILL.md YAML frontmatter block.
|
|
||||||
* @param {string} skillDirPath - Absolute path to the skill directory
|
|
||||||
* @returns {Object} { name, description } or empty strings
|
|
||||||
*/
|
|
||||||
async _parseSkillFrontmatter(skillDirPath) {
|
|
||||||
const skillMdPath = path.join(skillDirPath, 'SKILL.md');
|
|
||||||
try {
|
|
||||||
const content = await fs.readFile(skillMdPath, 'utf8');
|
|
||||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
||||||
if (!match) return { name: '', description: '' };
|
|
||||||
|
|
||||||
const parsed = yaml.parse(match[1]);
|
|
||||||
return {
|
|
||||||
name: parsed.name || '',
|
|
||||||
description: parsed.description || '',
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return { name: '', description: '' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a synthesized module-help.csv from plugin metadata and skill frontmatter.
|
|
||||||
* Uses the standard 13-column format.
|
|
||||||
* @param {string} moduleName - Display name for the module column
|
|
||||||
* @param {Array<{dirName: string, name: string, description: string}>} skillInfos
|
|
||||||
* @returns {string} CSV content
|
|
||||||
*/
|
|
||||||
_buildSynthesizedHelpCsv(moduleName, skillInfos) {
|
|
||||||
const header = 'module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs';
|
|
||||||
const rows = [header];
|
|
||||||
|
|
||||||
for (const info of skillInfos) {
|
|
||||||
const displayName = this._formatDisplayName(info.name || info.dirName);
|
|
||||||
const menuCode = this._generateMenuCode(info.name || info.dirName);
|
|
||||||
const description = this._escapeCSVField(info.description);
|
|
||||||
|
|
||||||
rows.push(`${moduleName},${info.dirName},${displayName},${menuCode},${description},activate,,anytime,,,false,,`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows.join('\n') + '\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a kebab-case or snake_case name into a display name.
|
|
||||||
* Strips common prefixes like "bmad-" or "bmad-agent-".
|
|
||||||
* @param {string} name - Raw name
|
|
||||||
* @returns {string} Formatted display name
|
|
||||||
*/
|
|
||||||
_formatDisplayName(name) {
|
|
||||||
let cleaned = name.replace(/^bmad-agent-/, '').replace(/^bmad-/, '');
|
|
||||||
return cleaned
|
|
||||||
.split(/[-_]/)
|
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
||||||
.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a short menu code from a skill name.
|
|
||||||
* Takes first letter of each significant word, uppercased, max 3 chars.
|
|
||||||
* @param {string} name - Skill name (kebab-case)
|
|
||||||
* @returns {string} Menu code (e.g., "CC" for "code-coach")
|
|
||||||
*/
|
|
||||||
_generateMenuCode(name) {
|
|
||||||
const cleaned = name.replace(/^bmad-agent-/, '').replace(/^bmad-/, '');
|
|
||||||
const words = cleaned.split(/[-_]/).filter((w) => w.length > 0);
|
|
||||||
return words
|
|
||||||
.map((w) => w.charAt(0).toUpperCase())
|
|
||||||
.join('')
|
|
||||||
.slice(0, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape a value for CSV output (wrap in quotes if it contains commas, quotes, or newlines).
|
|
||||||
* @param {string} value
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
_escapeCSVField(value) {
|
|
||||||
if (!value) return '';
|
|
||||||
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
|
||||||
return `"${value.replaceAll('"', '""')}"`;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { PluginResolver };
|
|
||||||
|
|
@ -158,9 +158,6 @@ class UI {
|
||||||
.map((m) => m.trim())
|
.map((m) => m.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`);
|
await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`);
|
||||||
} else if (options.customSource) {
|
|
||||||
// Custom source without --modules: start with empty list (core added below)
|
|
||||||
selectedModules = [];
|
|
||||||
} else if (options.yes) {
|
} else if (options.yes) {
|
||||||
selectedModules = await this.getDefaultModules(installedModuleIds);
|
selectedModules = await this.getDefaultModules(installedModuleIds);
|
||||||
await prompts.log.info(
|
await prompts.log.info(
|
||||||
|
|
@ -170,14 +167,6 @@ class UI {
|
||||||
selectedModules = await this.selectAllModules(installedModuleIds);
|
selectedModules = await this.selectAllModules(installedModuleIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve custom sources from --custom-source flag
|
|
||||||
if (options.customSource) {
|
|
||||||
const customCodes = await this._resolveCustomSourcesCli(options.customSource);
|
|
||||||
for (const code of customCodes) {
|
|
||||||
if (!selectedModules.includes(code)) selectedModules.push(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure core is in the modules list
|
// Ensure core is in the modules list
|
||||||
if (!selectedModules.includes('core')) {
|
if (!selectedModules.includes('core')) {
|
||||||
selectedModules.unshift('core');
|
selectedModules.unshift('core');
|
||||||
|
|
@ -213,9 +202,6 @@ class UI {
|
||||||
.map((m) => m.trim())
|
.map((m) => m.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`);
|
await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`);
|
||||||
} else if (options.customSource) {
|
|
||||||
// Custom source without --modules: start with empty list (core added below)
|
|
||||||
selectedModules = [];
|
|
||||||
} else if (options.yes) {
|
} else if (options.yes) {
|
||||||
// Use default modules when --yes flag is set
|
// Use default modules when --yes flag is set
|
||||||
selectedModules = await this.getDefaultModules(installedModuleIds);
|
selectedModules = await this.getDefaultModules(installedModuleIds);
|
||||||
|
|
@ -224,14 +210,6 @@ class UI {
|
||||||
selectedModules = await this.selectAllModules(installedModuleIds);
|
selectedModules = await this.selectAllModules(installedModuleIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve custom sources from --custom-source flag
|
|
||||||
if (options.customSource) {
|
|
||||||
const customCodes = await this._resolveCustomSourcesCli(options.customSource);
|
|
||||||
for (const code of customCodes) {
|
|
||||||
if (!selectedModules.includes(code)) selectedModules.push(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure core is in the modules list
|
// Ensure core is in the modules list
|
||||||
if (!selectedModules.includes('core')) {
|
if (!selectedModules.includes('core')) {
|
||||||
selectedModules.unshift('core');
|
selectedModules.unshift('core');
|
||||||
|
|
@ -840,13 +818,13 @@ class UI {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prompt user to install modules from custom sources (Git URLs or local paths).
|
* Prompt user to install modules from custom GitHub URLs.
|
||||||
* @param {Set} installedModuleIds - Currently installed module IDs
|
* @param {Set} installedModuleIds - Currently installed module IDs
|
||||||
* @returns {Array} Selected custom module code strings
|
* @returns {Array} Selected custom module code strings
|
||||||
*/
|
*/
|
||||||
async _addCustomUrlModules(installedModuleIds = new Set()) {
|
async _addCustomUrlModules(installedModuleIds = new Set()) {
|
||||||
const addCustom = await prompts.confirm({
|
const addCustom = await prompts.confirm({
|
||||||
message: 'Would you like to install from a custom source (Git URL or local path)?',
|
message: 'Would you like to install from a custom GitHub URL?',
|
||||||
default: false,
|
default: false,
|
||||||
});
|
});
|
||||||
if (!addCustom) return [];
|
if (!addCustom) return [];
|
||||||
|
|
@ -857,158 +835,61 @@ class UI {
|
||||||
|
|
||||||
let addMore = true;
|
let addMore = true;
|
||||||
while (addMore) {
|
while (addMore) {
|
||||||
const sourceInput = await prompts.text({
|
const url = await prompts.text({
|
||||||
message: 'Git URL or local path:',
|
message: 'GitHub repository URL:',
|
||||||
placeholder: 'https://github.com/owner/repo or /path/to/module',
|
placeholder: 'https://github.com/owner/repo',
|
||||||
validate: (input) => {
|
validate: (input) => {
|
||||||
if (!input || input.trim() === '') return 'Source is required';
|
if (!input || input.trim() === '') return 'URL is required';
|
||||||
const result = customMgr.parseSource(input.trim());
|
const result = customMgr.validateGitHubUrl(input.trim());
|
||||||
return result.isValid ? undefined : result.error;
|
return result.isValid ? undefined : result.error;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const s = await prompts.spinner();
|
const s = await prompts.spinner();
|
||||||
s.start('Resolving source...');
|
s.start('Fetching module info...');
|
||||||
|
|
||||||
let sourceResult;
|
|
||||||
try {
|
try {
|
||||||
sourceResult = await customMgr.resolveSource(sourceInput.trim(), { skipInstall: true, silent: true });
|
const plugins = await customMgr.discoverModules(url.trim());
|
||||||
s.stop(sourceResult.parsed.type === 'local' ? 'Local source resolved' : 'Repository cloned');
|
s.stop('Module info loaded');
|
||||||
} catch (error) {
|
|
||||||
s.error('Failed to resolve source');
|
|
||||||
await prompts.log.error(` ${error.message}`);
|
|
||||||
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceResult.parsed.type === 'local') {
|
|
||||||
await prompts.log.info('LOCAL MODULE: Pointing directly at local source (changes take effect on reinstall).');
|
|
||||||
} else {
|
|
||||||
await prompts.log.warn(
|
await prompts.log.warn(
|
||||||
'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.',
|
'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.',
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve plugins based on discovery mode vs direct mode
|
|
||||||
s.start('Analyzing plugin structure...');
|
|
||||||
const allResolved = [];
|
|
||||||
const localPath = sourceResult.parsed.type === 'local' ? sourceResult.rootDir : null;
|
|
||||||
|
|
||||||
if (sourceResult.mode === 'discovery') {
|
|
||||||
// Discovery mode: marketplace.json found, list available plugins
|
|
||||||
let plugins;
|
|
||||||
try {
|
|
||||||
plugins = await customMgr.discoverModules(sourceResult.marketplace, sourceResult.sourceUrl);
|
|
||||||
} catch (discoverError) {
|
|
||||||
s.error('Failed to discover modules');
|
|
||||||
await prompts.log.error(` ${discoverError.message}`);
|
|
||||||
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const effectiveRepoPath = sourceResult.repoPath || sourceResult.rootDir;
|
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
|
const versionStr = plugin.version ? ` v${plugin.version}` : '';
|
||||||
|
await prompts.log.info(` ${plugin.name}${versionStr}\n ${plugin.description}\n Author: ${plugin.author}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmInstall = await prompts.confirm({
|
||||||
|
message: `Install ${plugins.length} plugin${plugins.length === 1 ? '' : 's'} from ${url.trim()}?`,
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmInstall) {
|
||||||
|
// Pre-clone the repo so it's cached for the install pipeline
|
||||||
|
s.start('Cloning repository...');
|
||||||
try {
|
try {
|
||||||
const resolved = await customMgr.resolvePlugin(effectiveRepoPath, plugin.rawPlugin, sourceResult.sourceUrl, localPath);
|
await customMgr.cloneRepo(url.trim());
|
||||||
if (resolved.length > 0) {
|
s.stop('Repository cloned');
|
||||||
allResolved.push(...resolved);
|
} catch (cloneError) {
|
||||||
} else {
|
s.error('Failed to clone repository');
|
||||||
// No skills array or empty - use plugin metadata as-is (legacy)
|
await prompts.log.error(` ${cloneError.message}`);
|
||||||
allResolved.push({
|
addMore = await prompts.confirm({ message: 'Try another URL?', default: false });
|
||||||
code: plugin.code,
|
continue;
|
||||||
name: plugin.displayName || plugin.name,
|
}
|
||||||
version: plugin.version,
|
|
||||||
description: plugin.description,
|
for (const plugin of plugins) {
|
||||||
strategy: 0,
|
selectedModules.push(plugin.code);
|
||||||
pluginName: plugin.name,
|
|
||||||
skillPaths: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (resolveError) {
|
|
||||||
await prompts.log.warn(` Could not resolve ${plugin.name}: ${resolveError.message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} catch (error) {
|
||||||
// Direct mode: no marketplace.json, scan directory for skills and resolve
|
s.error('Failed to load module info');
|
||||||
const directPlugin = {
|
await prompts.log.error(` ${error.message}`);
|
||||||
name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
|
|
||||||
source: '.',
|
|
||||||
skills: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Scan for SKILL.md directories to populate skills array
|
|
||||||
try {
|
|
||||||
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
|
|
||||||
if (await fs.pathExists(skillMd)) {
|
|
||||||
directPlugin.skills.push(entry.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (scanError) {
|
|
||||||
s.error('Failed to scan directory');
|
|
||||||
await prompts.log.error(` ${scanError.message}`);
|
|
||||||
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (directPlugin.skills.length > 0) {
|
|
||||||
try {
|
|
||||||
const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
|
|
||||||
allResolved.push(...resolved);
|
|
||||||
} catch (resolveError) {
|
|
||||||
await prompts.log.warn(` Could not resolve: ${resolveError.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.stop(`Found ${allResolved.length} installable module${allResolved.length === 1 ? '' : 's'}`);
|
|
||||||
|
|
||||||
if (allResolved.length === 0) {
|
|
||||||
await prompts.log.warn('No installable modules found in this source.');
|
|
||||||
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build multiselect choices
|
|
||||||
// Already-installed modules are pre-checked (update). New modules are unchecked (opt-in).
|
|
||||||
// Unchecking an installed module means "skip update" - removal is handled elsewhere.
|
|
||||||
const choices = allResolved.map((mod) => {
|
|
||||||
const versionStr = mod.version ? ` v${mod.version}` : '';
|
|
||||||
const skillCount = mod.skillPaths ? mod.skillPaths.length : 0;
|
|
||||||
const skillStr = skillCount > 0 ? ` (${skillCount} skill${skillCount === 1 ? '' : 's'})` : '';
|
|
||||||
const alreadyInstalled = installedModuleIds.has(mod.code);
|
|
||||||
const hint = alreadyInstalled ? 'update' : undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: `${mod.name}${versionStr}${skillStr}`,
|
|
||||||
value: mod.code,
|
|
||||||
hint,
|
|
||||||
checked: alreadyInstalled,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show descriptions before the multiselect
|
|
||||||
for (const mod of allResolved) {
|
|
||||||
const versionStr = mod.version ? ` v${mod.version}` : '';
|
|
||||||
await prompts.log.info(` ${mod.name}${versionStr}\n ${mod.description}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const selected = await prompts.multiselect({
|
|
||||||
message: 'Select modules to install:',
|
|
||||||
choices,
|
|
||||||
required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (selected && selected.length > 0) {
|
|
||||||
for (const code of selected) {
|
|
||||||
selectedModules.push(code);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addMore = await prompts.confirm({
|
addMore = await prompts.confirm({
|
||||||
message: 'Add another custom source?',
|
message: 'Add another custom module?',
|
||||||
default: false,
|
default: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1020,102 +901,6 @@ class UI {
|
||||||
return selectedModules;
|
return selectedModules;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve custom sources from --custom-source CLI flag (non-interactive).
|
|
||||||
* Auto-selects all discovered modules from each source.
|
|
||||||
* @param {string} sourcesArg - Comma-separated Git URLs or local paths
|
|
||||||
* @returns {Array} Module codes from all resolved sources
|
|
||||||
*/
|
|
||||||
async _resolveCustomSourcesCli(sourcesArg) {
|
|
||||||
const { CustomModuleManager } = require('./modules/custom-module-manager');
|
|
||||||
const customMgr = new CustomModuleManager();
|
|
||||||
const allCodes = [];
|
|
||||||
|
|
||||||
const sources = sourcesArg
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
for (const source of sources) {
|
|
||||||
const s = await prompts.spinner();
|
|
||||||
s.start(`Resolving ${source}...`);
|
|
||||||
|
|
||||||
let sourceResult;
|
|
||||||
try {
|
|
||||||
sourceResult = await customMgr.resolveSource(source, { skipInstall: true, silent: true });
|
|
||||||
s.stop(sourceResult.parsed.type === 'local' ? 'Local source resolved' : 'Repository cloned');
|
|
||||||
} catch (error) {
|
|
||||||
s.error(`Failed to resolve ${source}`);
|
|
||||||
await prompts.log.error(` ${error.message}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const s2 = await prompts.spinner();
|
|
||||||
s2.start('Analyzing plugin structure...');
|
|
||||||
const allResolved = [];
|
|
||||||
const localPath = sourceResult.parsed.type === 'local' ? sourceResult.rootDir : null;
|
|
||||||
|
|
||||||
if (sourceResult.mode === 'discovery') {
|
|
||||||
try {
|
|
||||||
const plugins = await customMgr.discoverModules(sourceResult.marketplace, sourceResult.sourceUrl);
|
|
||||||
const effectiveRepoPath = sourceResult.repoPath || sourceResult.rootDir;
|
|
||||||
for (const plugin of plugins) {
|
|
||||||
try {
|
|
||||||
const resolved = await customMgr.resolvePlugin(effectiveRepoPath, plugin.rawPlugin, sourceResult.sourceUrl, localPath);
|
|
||||||
if (resolved.length > 0) {
|
|
||||||
allResolved.push(...resolved);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip unresolvable plugins
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (discoverError) {
|
|
||||||
s2.error('Failed to discover modules');
|
|
||||||
await prompts.log.error(` ${discoverError.message}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Direct mode: scan for SKILL.md directories
|
|
||||||
const directPlugin = {
|
|
||||||
name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
|
|
||||||
source: '.',
|
|
||||||
skills: [],
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
|
|
||||||
if (await fs.pathExists(skillMd)) {
|
|
||||||
directPlugin.skills.push(entry.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip unreadable directories
|
|
||||||
}
|
|
||||||
|
|
||||||
if (directPlugin.skills.length > 0) {
|
|
||||||
try {
|
|
||||||
const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
|
|
||||||
allResolved.push(...resolved);
|
|
||||||
} catch {
|
|
||||||
// Skip unresolvable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s2.stop(`Found ${allResolved.length} module${allResolved.length === 1 ? '' : 's'}`);
|
|
||||||
|
|
||||||
for (const mod of allResolved) {
|
|
||||||
allCodes.push(mod.code);
|
|
||||||
const versionStr = mod.version ? ` v${mod.version}` : '';
|
|
||||||
await prompts.log.info(` Custom module: ${mod.name}${versionStr}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return allCodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get default modules for non-interactive mode
|
* Get default modules for non-interactive mode
|
||||||
* @param {Set} installedModuleIds - Already installed module IDs
|
* @param {Set} installedModuleIds - Already installed module IDs
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue