Compare commits

..

5 Commits

Author SHA1 Message Date
Brian Madison 0df1d9853e docs: add custom and community module installation guide
New how-to page covering community module browsing, custom sources (any
Git host, local paths), discovery vs direct mode, local development
workflow, and the --custom-source CLI flag. Clarifies that
.claude-plugin/ is a cross-tool convention, not Claude-specific.

Also updates non-interactive installation docs with the new flag and
examples, bumps sidebar ordering, and fixes --custom-source to install
only core + custom modules when --modules is not specified.
2026-04-09 18:43:01 -05:00
Brian Madison 9ff131ac15 feat(installer): add --custom-source CLI flag for non-interactive installs
Allows installing custom modules from Git URLs or local paths directly
from the command line without interactive prompts:

  npx bmad-method install --custom-source /path/to/module
  npx bmad-method install --custom-source https://gitlab.com/org/repo
  npx bmad-method install --custom-source /path/one,https://host/org/repo

Works alongside --modules and --yes flags. All discovered modules from
each source are auto-selected.
2026-04-09 18:08:51 -05:00
Brian Madison 225e5ee77b fix(installer): restore validateGitHubUrl strictness and fix prettier
- Restore original GitHub-only regex in deprecated validateGitHubUrl
  wrapper so existing tests pass (rejects non-GitHub URLs, trailing
  slashes)
- Run prettier to fix formatting in custom-module-manager.js
2026-04-09 17:56:46 -05:00
Brian Madison ed51e6c538 fix(installer): harden readMarketplaceJsonFromDisk and hoist require
- Add try/catch to readMarketplaceJsonFromDisk so malformed JSON returns
  null instead of throwing an unhandled parse error
- Hoist CustomModuleManager require outside the per-module loop in
  _installOfficialModules
2026-04-09 17:53:54 -05:00
Brian Madison eec011ae9a feat(installer): universal source support for custom modules
Replace GitHub-only custom module installation with support for any Git
host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths.

- Add parseSource() universal input parser (local paths, SSH, HTTPS with
  deep path/subdir extraction for GitHub, GitLab, Gitea)
- Add resolveSource() coordinator: parse -> clone if URL -> detect
  discovery vs direct mode (marketplace.json present or not)
- Clone-first approach eliminates host-specific raw URL fetching
- 3-level cache structure (host/owner/repo) with .bmad-source.json
  metadata for URL reconstruction
- Local paths install directly without caching; localPath persisted in
  manifest for quick-update source lookup
- Direct mode scans target directory for SKILL.md when no marketplace.json
- Fix version display bug where walk-up found parent repo marketplace.json
  and reported wrong version for custom modules
2026-04-09 17:50:59 -05:00
16 changed files with 920 additions and 261 deletions

View File

@ -1,8 +1,8 @@
---
title: "How to Customize BMad"
title: 'How to Customize BMad'
description: Customize agents, workflows, and modules while preserving update compatibility
sidebar:
order: 7
order: 8
---
Use the `.customize.yaml` files to tailor agent behavior, personas, and menus while preserving your changes across updates.
@ -15,9 +15,10 @@ 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
:::note[Prerequisites]
- BMad installed in your project (see [How to Install BMad](./install-bmad.md))
- A text editor for YAML files
:::
:::
:::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.
@ -136,10 +137,10 @@ npx bmad-method install
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 |
| **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.

View File

@ -1,8 +1,8 @@
---
title: "Established Projects"
title: 'Established Projects'
description: How to use BMad Method on existing codebases
sidebar:
order: 6
order: 7
---
Use BMad Method effectively when working on existing projects and legacy codebases.
@ -10,10 +10,11 @@ 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.
:::note[Prerequisites]
- BMad Method installed (`npx bmad-method install`)
- An existing codebase you want to work on
- Access to an AI-powered IDE (Claude Code or Cursor)
:::
:::
## Step 1: Clean Up Completed Planning Artifacts
@ -36,6 +37,7 @@ bmad-generate-project-context
```
This scans your codebase to identify:
- Technology stack and versions
- Code organization patterns
- Naming conventions
@ -79,10 +81,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:
| 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. |
| **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

View File

@ -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
sidebar:
order: 4
order: 5
---
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.
| Channel | Use For |
| ------------------------- | ------------------------------------------- |
| `help-requests` forum | Questions |
| `#suggestions-feedback` | Ideas and feature requests |
| Channel | Use For |
| ----------------------- | -------------------------- |
| `help-requests` forum | Questions |
| `#suggestions-feedback` | Ideas and feature requests |
**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)
*You!*
*Stuck*
*in the queue—*
*waiting*
*for who?*
_You!_
_Stuck_
_in the queue—_
_waiting_
_for who?_
*The source*
*is there,*
*plain to see!*
_The source_
_is there,_
_plain to see!_
*Point*
*your machine.*
*Set it free.*
_Point_
_your machine._
_Set it free._
*It reads.*
*It speaks.*
*Ask away—*
_It reads._
_It speaks._
_Ask away—_
*Why wait*
*for tomorrow*
*when you have*
*today?*
_Why wait_
_for tomorrow_
_when you have_
_today?_
*—Claude*
_—Claude_

View File

@ -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
sidebar:
order: 1
@ -16,10 +16,11 @@ If you want to use a non interactive installer and provide all install options o
- Update the existing BMad Installation
:::note[Prerequisites]
- **Node.js** 20+ (required for the installer)
- **Git** (recommended)
- **AI tool** (Claude Code, Cursor, or similar)
:::
:::
## Steps
@ -31,6 +32,7 @@ npx bmad-method install
:::tip[Want the newest prerelease build?]
Use the `next` dist-tag:
```bash
npx bmad-method@next install
```
@ -40,9 +42,11 @@ This gets you newer changes earlier, with a higher chance of churn than the defa
:::tip[Bleeding edge]
To install the latest from the main branch (may be unstable):
```bash
npx github:bmad-code-org/BMAD-METHOD install
```
:::
### 2. Choose Installation Location
@ -99,11 +103,13 @@ your-project/
Run `bmad-help` to verify everything works and see what to do next.
**BMad-Help is your intelligent guide** that will:
- Confirm your installation is working
- Show what's available based on your installed modules
- Recommend your first step
You can also ask it questions:
```
bmad-help I just installed, what should I do first?
bmad-help What are my options for a SaaS project?

View File

@ -0,0 +1,180 @@
---
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.
:::

View File

@ -22,39 +22,40 @@ Requires [Node.js](https://nodejs.org) v20+ and `npx` (included with npm).
### Installation Options
| Flag | Description | Example |
|------|-------------|---------|
| `--directory <path>` | Installation directory | `--directory ~/projects/myapp` |
| `--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` |
| `--action <type>` | Action for existing installations: `install` (default), `update`, or `quick-update` | `--action quick-update` |
| Flag | Description | Example |
| --------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------- |
| `--directory <path>` | Installation directory | `--directory ~/projects/myapp` |
| `--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` |
| `--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
| Flag | Description | Default |
|------|-------------|---------|
| `--user-name <name>` | Name for agents to use | System username |
| `--communication-language <lang>` | Agent communication language | English |
| `--document-output-language <lang>` | Document output language | English |
| `--output-folder <path>` | Output folder path (see resolution rules below) | `_bmad-output` |
| Flag | Description | Default |
| ----------------------------------- | ----------------------------------------------- | --------------- |
| `--user-name <name>` | Name for agents to use | System username |
| `--communication-language <lang>` | Agent communication 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 Resolution
The value passed to `--output-folder` (or entered interactively) is resolved according to these rules:
| Input type | Example | Resolved as |
|------------|---------|-------------|
| Relative path (default) | `_bmad-output` | `<project-root>/_bmad-output` |
| Relative path with traversal | `../../shared-outputs` | Normalized absolute path — e.g. `/Users/me/shared-outputs` |
| Absolute path | `/Users/me/shared-outputs` | Used as-is — project root is **not** prepended |
| Input type | Example | Resolved as |
| ---------------------------- | -------------------------- | ---------------------------------------------------------- |
| Relative path (default) | `_bmad-output` | `<project-root>/_bmad-output` |
| Relative path with traversal | `../../shared-outputs` | Normalized absolute path — e.g. `/Users/me/shared-outputs` |
| Absolute path | `/Users/me/shared-outputs` | Used as-is — project root is **not** prepended |
The resolved path is what agents and workflows use at runtime when writing output files. Using an absolute path or a traversal-based relative path lets you direct all generated artifacts to a directory outside your project tree — useful for shared or monorepo setups.
### Other Options
| Flag | Description |
|------|-------------|
| `-y, --yes` | Accept all defaults and skip prompts |
| Flag | Description |
| ------------- | ------------------------------------------- |
| `-y, --yes` | Accept all defaults and skip prompts |
| `-d, --debug` | Enable debug output for manifest generation |
## Module IDs
@ -76,12 +77,13 @@ Run `npx bmad-method install` interactively once to see the full current list of
## Installation Modes
| Mode | Description | Example |
|------|-------------|---------|
| 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` |
| Defaults only | Accept all defaults with `-y` | `npx bmad-method install --yes` |
| Without tools | Skip tool/IDE configuration | `npx bmad-method install --modules bmm --tools none` |
| Mode | Description | Example |
| --------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| 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` |
| 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` |
## Examples
@ -119,6 +121,33 @@ npx bmad-method install \
--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
- A fully configured `_bmad/` directory in your project
@ -135,17 +164,19 @@ BMad validates all provided flags:
- **Action** — Must be one of: `install`, `update`, `quick-update`
Invalid values will either:
1. Show an error and exit (for critical options like directory)
2. Show a warning and skip (for optional items)
3. Fall back to interactive prompts (for missing required values)
:::tip[Best Practices]
- Use absolute paths for `--directory` to avoid ambiguity
- Use an absolute path for `--output-folder` when you want artifacts written outside the project tree (e.g. a shared monorepo outputs directory)
- Test flags locally before using in CI/CD pipelines
- Combine with `-y` for truly unattended installations
- Use `--debug` if you encounter issues during installation
:::
:::
## Troubleshooting

View File

@ -1,16 +1,17 @@
---
title: "Manage Project Context"
title: 'Manage Project Context'
description: Create and maintain project-context.md to guide AI agents
sidebar:
order: 8
order: 9
---
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]
- BMad Method installed
- Understanding of your project's technology stack and conventions
:::
:::
## When to Use This
@ -60,14 +61,17 @@ sections_completed: ['technology_stack', 'critical_rules']
## Critical Implementation Rules
**TypeScript:**
- Strict mode enabled, no `any` types
- Use `interface` for public APIs, `type` for unions
**Code Organization:**
- Components in `/src/components/` with co-located tests
- API calls use `apiClient` singleton — never fetch directly
**Testing:**
- Unit tests focus on business logic
- Integration tests use MSW for API mocking
```
@ -115,11 +119,12 @@ A `project-context.md` file that:
## Tips
:::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."
- **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.
- Works for Quick Flow and full BMad Method projects alike.
:::
:::
## Next Steps

View File

@ -1,8 +1,8 @@
---
title: "Quick Fixes"
title: 'Quick Fixes'
description: How to make quick fixes and ad-hoc changes
sidebar:
order: 5
order: 6
---
Use **Quick Dev** for bug fixes, refactorings, or small targeted changes that don't require the full BMad Method.
@ -15,9 +15,10 @@ Use **Quick Dev** for bug fixes, refactorings, or small targeted changes that do
- Dependency updates
:::note[Prerequisites]
- BMad Method installed (`npx bmad-method install`)
- An AI-powered IDE (Claude Code, Cursor, or similar)
:::
:::
## Steps

View File

@ -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
sidebar:
order: 9
order: 10
---
Use the `bmad-shard-doc` tool if you need to split large markdown files into smaller, organized files for better context management.

View File

@ -1,8 +1,8 @@
---
title: "How to Upgrade to v6"
title: 'How to Upgrade to v6'
description: Migrate from BMad v4 to v6
sidebar:
order: 3
order: 4
---
Use the BMad installer to upgrade from v4 to v6, which includes automatic detection of legacy installations and migration assistance.
@ -14,9 +14,10 @@ Use the BMad installer to upgrade from v4 to v6, which includes automatic detect
- You have existing planning artifacts to preserve
:::note[Prerequisites]
- Node.js 20+
- Existing BMad v4 installation
:::
:::
## Steps

View File

@ -22,6 +22,7 @@ module.exports = {
['--communication-language <lang>', 'Language for agent communication (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)'],
['--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'],
],
action: async (options) => {

View File

@ -569,6 +569,7 @@ class Installer {
*/
async _installOfficialModules(config, paths, officialModuleIds, addResult, isQuickUpdate, officialModules, ctx) {
const { message, installedModuleNames } = ctx;
const { CustomModuleManager } = require('../modules/custom-module-manager');
for (const moduleName of officialModuleIds) {
if (installedModuleNames.has(moduleName)) continue;
@ -591,11 +592,15 @@ class Installer {
},
);
// Get display name from source module.yaml; version from marketplace.json
// Get display name from source module.yaml; version from resolution cache or marketplace.json
const sourcePath = await officialModules.findModuleSource(moduleName, { silent: true });
const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null;
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 });
}
}
@ -1189,7 +1194,7 @@ class Installer {
const customMgr = new CustomModuleManager();
for (const moduleId of installedModules) {
if (!availableModules.some((m) => m.id === moduleId)) {
const customSource = await customMgr.findModuleSourceByCode(moduleId);
const customSource = await customMgr.findModuleSourceByCode(moduleId, { bmadDir });
if (customSource) {
availableModules.push({
id: moduleId,

View File

@ -412,7 +412,7 @@ class ManifestGenerator {
// Get existing install date if available
const existing = existingModulesMap.get(moduleName);
updatedModules.push({
const moduleEntry = {
name: moduleName,
version: versionInfo.version,
installDate: existing?.installDate || new Date().toISOString(),
@ -420,7 +420,9 @@ class ManifestGenerator {
source: versionInfo.source,
npmPackage: versionInfo.npmPackage,
repoUrl: versionInfo.repoUrl,
});
};
if (versionInfo.localPath) moduleEntry.localPath = versionInfo.localPath;
updatedModules.push(moduleEntry);
}
const manifest = {

View File

@ -181,10 +181,10 @@ class Manifest {
// Handle adding a new module with version info
if (updates.addModule) {
const { name, version, source, npmPackage, repoUrl } = updates.addModule;
const { name, version, source, npmPackage, repoUrl, localPath } = updates.addModule;
const existing = manifest.modules.find((m) => m.name === name);
if (!existing) {
manifest.modules.push({
const entry = {
name,
version: version || null,
installDate: new Date().toISOString(),
@ -192,7 +192,9 @@ class Manifest {
source: source || 'external',
npmPackage: npmPackage || null,
repoUrl: repoUrl || null,
});
};
if (localPath) entry.localPath = localPath;
manifest.modules.push(entry);
}
}
@ -280,7 +282,7 @@ class Manifest {
if (existingIndex === -1) {
// Module doesn't exist, add it
manifest.modules.push({
const entry = {
name: moduleName,
version: options.version || null,
installDate: new Date().toISOString(),
@ -288,7 +290,9 @@ class Manifest {
source: options.source || 'unknown',
npmPackage: options.npmPackage || null,
repoUrl: options.repoUrl || null,
});
};
if (options.localPath) entry.localPath = options.localPath;
manifest.modules.push(entry);
} else {
// Module exists, update its version info
const existing = manifest.modules[existingIndex];
@ -298,6 +302,7 @@ class Manifest {
source: options.source || existing.source,
npmPackage: options.npmPackage === undefined ? existing.npmPackage : options.npmPackage,
repoUrl: options.repoUrl === undefined ? existing.repoUrl : options.repoUrl,
localPath: options.localPath === undefined ? existing.localPath : options.localPath,
lastUpdated: new Date().toISOString(),
};
}
@ -832,11 +837,11 @@ class Manifest {
};
}
// Check if this is a custom module (from user-provided URL)
// Check if this is a custom module (from user-provided URL or local path)
const { CustomModuleManager } = require('../modules/custom-module-manager');
const customMgr = new CustomModuleManager();
const resolved = customMgr.getResolution(moduleName);
const customSource = await customMgr.findModuleSourceByCode(moduleName);
const customSource = await customMgr.findModuleSourceByCode(moduleName, { bmadDir });
if (customSource || resolved) {
const customVersion = resolved?.version || (await this._readMarketplaceVersion(moduleName, moduleSourcePath));
return {
@ -844,6 +849,7 @@ class Manifest {
source: 'custom',
npmPackage: null,
repoUrl: resolved?.repoUrl || null,
localPath: resolved?.localPath || null,
};
}

View File

@ -3,25 +3,161 @@ const os = require('node:os');
const path = require('node:path');
const { execSync } = require('node:child_process');
const prompts = require('../prompts');
const { RegistryClient } = require('./registry-client');
/**
* Manages custom modules installed from user-provided GitHub URLs.
* Validates URLs, fetches .claude-plugin/marketplace.json, clones repos.
* Manages custom modules installed from user-provided sources.
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths.
* Validates input, clones repos, reads .claude-plugin/marketplace.json, resolves plugins.
*/
class CustomModuleManager {
/** @type {Map<string, Object>} Shared across all instances: module code -> ResolvedModule */
static _resolutionCache = new Map();
constructor() {
this._client = new RegistryClient();
}
// ─── URL Validation ───────────────────────────────────────────────────────
// ─── 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',
};
}
/**
* 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.
* Supports HTTPS and SSH formats.
* @param {string} url - GitHub URL to validate
* @returns {Object} { owner, repo, isValid, error }
*/
@ -29,16 +165,15 @@ class CustomModuleManager {
if (!url || typeof url !== 'string') {
return { owner: null, repo: null, isValid: false, error: 'URL is required' };
}
const trimmed = url.trim();
// HTTPS format: https://github.com/owner/repo[.git]
// HTTPS format: https://github.com/owner/repo[.git] (strict, no trailing path)
const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
if (httpsMatch) {
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)?$/);
if (sshMatch) {
return { owner: sshMatch[1], repo: sshMatch[2], isValid: true, error: null };
@ -47,46 +182,75 @@ class CustomModuleManager {
return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' };
}
// ─── Discovery ────────────────────────────────────────────────────────────
// ─── Marketplace JSON ─────────────────────────────────────────────────────
/**
* Fetch .claude-plugin/marketplace.json from a GitHub repository.
* @param {string} repoUrl - GitHub repository URL
* @returns {Object} Parsed marketplace.json content
* 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 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`;
async readMarketplaceJsonFromDisk(dirPath) {
const marketplacePath = path.join(dirPath, '.claude-plugin', 'marketplace.json');
if (!(await fs.pathExists(marketplacePath))) return null;
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}`);
return JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
} catch {
return null;
}
}
// ─── Discovery ────────────────────────────────────────────────────────────
/**
* Discover modules from a GitHub repository's marketplace.json.
* @param {string} repoUrl - GitHub repository URL
* Discover modules from pre-read marketplace.json data.
* @param {Object} marketplaceData - Parsed marketplace.json content
* @param {string|null} sourceUrl - Source URL for tracking (null for local paths)
* @returns {Array<Object>} Normalized plugin list
*/
async discoverModules(repoUrl) {
const data = await this.fetchMarketplaceJson(repoUrl);
const plugins = data?.plugins;
async discoverModules(marketplaceData, sourceUrl) {
const plugins = marketplaceData?.plugins;
if (!Array.isArray(plugins) || plugins.length === 0) {
throw new Error('marketplace.json contains no plugins');
}
return plugins.map((plugin) => this._normalizeCustomModule(plugin, repoUrl, data));
return plugins.map((plugin) => this._normalizeCustomModule(plugin, sourceUrl, marketplaceData));
}
// ─── 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 ────────────────────────────────────────────────────────────────
@ -101,21 +265,24 @@ class CustomModuleManager {
/**
* Clone a custom module repository to cache.
* @param {string} repoUrl - GitHub repository URL
* Supports any Git host (GitHub, GitLab, Bitbucket, self-hosted, etc.).
* @param {string} sourceInput - Git URL (HTTPS or SSH)
* @param {Object} [options] - Clone options
* @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
*/
async cloneRepo(repoUrl, options = {}) {
const { owner, repo, isValid, error } = this.validateGitHubUrl(repoUrl);
if (!isValid) throw new Error(error);
async cloneRepo(sourceInput, options = {}) {
const parsed = this.parseSource(sourceInput);
if (!parsed.isValid) throw new Error(parsed.error);
if (parsed.type === 'local') throw new Error('cloneRepo does not accept local paths');
const cacheDir = this.getCacheDir();
const repoCacheDir = path.join(cacheDir, owner, repo);
const repoCacheDir = path.join(cacheDir, ...parsed.cacheKey.split('/'));
const silent = options.silent || false;
const displayName = parsed.displayName;
await fs.ensureDir(path.join(cacheDir, owner));
await fs.ensureDir(path.dirname(repoCacheDir));
const createSpinner = async () => {
if (silent) {
@ -127,7 +294,7 @@ class CustomModuleManager {
if (await fs.pathExists(repoCacheDir)) {
// Update existing clone
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Updating ${owner}/${repo}...`);
fetchSpinner.start(`Updating ${displayName}...`);
try {
execSync('git fetch origin --depth 1', {
cwd: repoCacheDir,
@ -138,42 +305,51 @@ class CustomModuleManager {
cwd: repoCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
fetchSpinner.stop(`Updated ${owner}/${repo}`);
fetchSpinner.stop(`Updated ${displayName}`);
} catch {
fetchSpinner.error(`Update failed, re-downloading ${owner}/${repo}`);
fetchSpinner.error(`Update failed, re-downloading ${displayName}`);
await fs.remove(repoCacheDir);
}
}
if (!(await fs.pathExists(repoCacheDir))) {
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Cloning ${owner}/${repo}...`);
fetchSpinner.start(`Cloning ${displayName}...`);
try {
execSync(`git clone --depth 1 "${repoUrl}" "${repoCacheDir}"`, {
execSync(`git clone --depth 1 "${parsed.cloneUrl}" "${repoCacheDir}"`, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
fetchSpinner.stop(`Cloned ${owner}/${repo}`);
fetchSpinner.stop(`Cloned ${displayName}`);
} catch (error_) {
fetchSpinner.error(`Failed to clone ${owner}/${repo}`);
throw new Error(`Failed to clone ${repoUrl}: ${error_.message}`);
fetchSpinner.error(`Failed to clone ${displayName}`);
throw new Error(`Failed to clone ${parsed.cloneUrl}: ${error_.message}`);
}
}
// Write source metadata for later URL reconstruction
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');
if (!options.skipInstall && (await fs.pathExists(packageJsonPath))) {
const installSpinner = await createSpinner();
installSpinner.start(`Installing dependencies for ${owner}/${repo}...`);
installSpinner.start(`Installing dependencies for ${displayName}...`);
try {
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
cwd: repoCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 120_000,
});
installSpinner.stop(`Installed dependencies for ${owner}/${repo}`);
installSpinner.stop(`Installed dependencies for ${displayName}`);
} catch (error_) {
installSpinner.error(`Failed to install dependencies for ${owner}/${repo}`);
installSpinner.error(`Failed to install dependencies for ${displayName}`);
if (!silent) await prompts.log.warn(` ${error_.message}`);
}
}
@ -186,19 +362,21 @@ class CustomModuleManager {
/**
* 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
* @param {string} repoPath - Absolute path to the cloned repository or local directory
* @param {Object} plugin - Raw plugin object from marketplace.json
* @param {string} [repoUrl] - Original GitHub URL for manifest tracking
* @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, repoUrl) {
async resolvePlugin(repoPath, plugin, sourceUrl, localPath) {
const { PluginResolver } = require('./plugin-resolver');
const resolver = new PluginResolver();
const resolved = await resolver.resolve(repoPath, plugin);
// Stamp repo URL onto each resolved module for manifest tracking
// Stamp source info onto each resolved module for manifest tracking
for (const mod of resolved) {
if (repoUrl) mod.repoUrl = repoUrl;
if (sourceUrl) mod.repoUrl = sourceUrl;
if (localPath) mod.localPath = localPath;
CustomModuleManager._resolutionCache.set(mod.code, mod);
}
@ -217,20 +395,27 @@ class CustomModuleManager {
// ─── Source Finding ───────────────────────────────────────────────────────
/**
* Find the module source path within a cloned custom repo.
* @param {string} repoUrl - GitHub repository URL (for cache location)
* Find the module source path within a cached or local source directory.
* @param {string} sourceInput - Git URL or local path (used to locate cached clone)
* @param {string} [pluginSource] - Plugin source path from marketplace.json
* @returns {string|null} Path to directory containing module.yaml
*/
async findModuleSource(repoUrl, pluginSource) {
const { owner, repo } = this.validateGitHubUrl(repoUrl);
const repoCacheDir = path.join(this.getCacheDir(), owner, repo);
async findModuleSource(sourceInput, pluginSource) {
const parsed = this.parseSource(sourceInput);
if (!parsed.isValid) return null;
if (!(await fs.pathExists(repoCacheDir))) return null;
let baseDir;
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")
if (pluginSource) {
const sourcePath = path.join(repoCacheDir, pluginSource);
const sourcePath = path.join(baseDir, pluginSource);
const moduleYaml = path.join(sourcePath, 'module.yaml');
if (await fs.pathExists(moduleYaml)) {
return sourcePath;
@ -239,11 +424,11 @@ class CustomModuleManager {
// Fallback: search skills/ and src/ directories
for (const dir of ['skills', 'src']) {
const rootCandidate = path.join(repoCacheDir, dir, 'module.yaml');
const rootCandidate = path.join(baseDir, dir, 'module.yaml');
if (await fs.pathExists(rootCandidate)) {
return path.dirname(rootCandidate);
}
const dirPath = path.join(repoCacheDir, dir);
const dirPath = path.join(baseDir, dir);
if (await fs.pathExists(dirPath)) {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
@ -257,10 +442,10 @@ class CustomModuleManager {
}
}
// Check repo root
const rootCandidate = path.join(repoCacheDir, 'module.yaml');
// Check base directory root
const rootCandidate = path.join(baseDir, 'module.yaml');
if (await fs.pathExists(rootCandidate)) {
return repoCacheDir;
return baseDir;
}
return null;
@ -268,6 +453,8 @@ class CustomModuleManager {
/**
* 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 {Object} [options] - Options
* @returns {string|null} Path to the module source or null
@ -289,68 +476,140 @@ class CustomModuleManager {
const cacheDir = this.getCacheDir();
if (!(await fs.pathExists(cacheDir))) return null;
// Search through all custom repo caches
// Search through all cached repo roots
try {
const { PluginResolver } = require('./plugin-resolver');
const resolver = new PluginResolver();
const owners = await fs.readdir(cacheDir, { withFileTypes: true });
for (const ownerEntry of owners) {
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);
const repoRoots = await this._findCacheRepoRoots(cacheDir);
// Check marketplace.json for matching module code
const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
if (await fs.pathExists(marketplacePath)) {
try {
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
for (const plugin of data.plugins || []) {
// Direct name match (legacy behavior)
if (plugin.name === moduleCode) {
const sourcePath = plugin.source ? path.join(repoPath, plugin.source) : repoPath;
const moduleYaml = path.join(sourcePath, 'module.yaml');
if (await fs.pathExists(moduleYaml)) {
return sourcePath;
}
}
for (const { repoPath, metadata } of repoRoots) {
// Check marketplace.json for matching module code
const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
if (!(await fs.pathExists(marketplacePath))) continue;
// Resolve plugin to check if any module.yaml code matches
if (plugin.skills && plugin.skills.length > 0) {
try {
const resolved = await resolver.resolve(repoPath, plugin);
for (const mod of resolved) {
if (mod.code === moduleCode) {
// Derive repo URL from cache path for manifest tracking
const repoUrl = `https://github.com/${ownerEntry.name}/${repoEntry.name}`;
mod.repoUrl = repoUrl;
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
}
}
try {
const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
for (const plugin of data.plugins || []) {
// Direct name match (legacy behavior)
if (plugin.name === moduleCode) {
const sourcePath = plugin.source ? path.join(repoPath, plugin.source) : repoPath;
const moduleYaml = path.join(sourcePath, 'module.yaml');
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 {
// Cache doesn't exist or is inaccessible
}
return null;
// Fallback: check manifest for localPath (local-source modules not in cache)
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 ────────────────────────────────────────────────────────
@ -358,11 +617,11 @@ class CustomModuleManager {
/**
* Normalize a plugin from marketplace.json to a consistent shape.
* @param {Object} plugin - Plugin object from marketplace.json
* @param {string} repoUrl - Source repository URL
* @param {string|null} sourceUrl - Source URL (null for local paths)
* @param {Object} data - Full marketplace.json data
* @returns {Object} Normalized module info
*/
_normalizeCustomModule(plugin, repoUrl, data) {
_normalizeCustomModule(plugin, sourceUrl, data) {
return {
code: plugin.name,
name: plugin.name,
@ -370,7 +629,7 @@ class CustomModuleManager {
description: plugin.description || '',
version: plugin.version || null,
author: plugin.author || data.owner || '',
url: repoUrl,
url: sourceUrl || null,
source: plugin.source || null,
skills: plugin.skills || [],
rawPlugin: plugin,

View File

@ -158,6 +158,9 @@ class UI {
.map((m) => m.trim())
.filter(Boolean);
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) {
selectedModules = await this.getDefaultModules(installedModuleIds);
await prompts.log.info(
@ -167,6 +170,14 @@ class UI {
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
if (!selectedModules.includes('core')) {
selectedModules.unshift('core');
@ -202,6 +213,9 @@ class UI {
.map((m) => m.trim())
.filter(Boolean);
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) {
// Use default modules when --yes flag is set
selectedModules = await this.getDefaultModules(installedModuleIds);
@ -210,6 +224,14 @@ class UI {
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
if (!selectedModules.includes('core')) {
selectedModules.unshift('core');
@ -818,13 +840,13 @@ class UI {
}
/**
* Prompt user to install modules from custom GitHub URLs.
* Prompt user to install modules from custom sources (Git URLs or local paths).
* @param {Set} installedModuleIds - Currently installed module IDs
* @returns {Array} Selected custom module code strings
*/
async _addCustomUrlModules(installedModuleIds = new Set()) {
const addCustom = await prompts.confirm({
message: 'Would you like to install from a custom GitHub URL?',
message: 'Would you like to install from a custom source (Git URL or local path)?',
default: false,
});
if (!addCustom) return [];
@ -835,76 +857,117 @@ class UI {
let addMore = true;
while (addMore) {
const url = await prompts.text({
message: 'GitHub repository URL:',
placeholder: 'https://github.com/owner/repo',
const sourceInput = await prompts.text({
message: 'Git URL or local path:',
placeholder: 'https://github.com/owner/repo or /path/to/module',
validate: (input) => {
if (!input || input.trim() === '') return 'URL is required';
const result = customMgr.validateGitHubUrl(input.trim());
if (!input || input.trim() === '') return 'Source is required';
const result = customMgr.parseSource(input.trim());
return result.isValid ? undefined : result.error;
},
});
const s = await prompts.spinner();
s.start('Fetching module info...');
s.start('Resolving source...');
let plugins;
let sourceResult;
try {
plugins = await customMgr.discoverModules(url.trim());
s.stop('Module info loaded');
sourceResult = await customMgr.resolveSource(sourceInput.trim(), { skipInstall: true, silent: true });
s.stop(sourceResult.parsed.type === 'local' ? 'Local source resolved' : 'Repository cloned');
} catch (error) {
s.error('Failed to load module info');
s.error('Failed to resolve source');
await prompts.log.error(` ${error.message}`);
addMore = await prompts.confirm({ message: 'Try another URL?', default: false });
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
continue;
}
await prompts.log.warn(
'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.',
);
// Clone the repo so we can resolve plugin structures (skip npm install until user confirms)
s.start('Cloning repository...');
let repoPath;
try {
repoPath = await customMgr.cloneRepo(url.trim(), { skipInstall: true });
s.stop('Repository cloned');
} catch (cloneError) {
s.error('Failed to clone repository');
await prompts.log.error(` ${cloneError.message}`);
addMore = await prompts.confirm({ message: 'Try another URL?', 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(
'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.',
);
}
// Resolve each plugin to determine installable modules
// Resolve plugins based on discovery mode vs direct mode
s.start('Analyzing plugin structure...');
const allResolved = [];
for (const plugin of plugins) {
const localPath = sourceResult.parsed.type === 'local' ? sourceResult.rootDir : null;
if (sourceResult.mode === 'discovery') {
// Discovery mode: marketplace.json found, list available plugins
let plugins;
try {
const resolved = await customMgr.resolvePlugin(repoPath, plugin.rawPlugin, url.trim());
if (resolved.length > 0) {
allResolved.push(...resolved);
} else {
// No skills array or empty - use plugin metadata as-is (legacy)
allResolved.push({
code: plugin.code,
name: plugin.displayName || plugin.name,
version: plugin.version,
description: plugin.description,
strategy: 0,
pluginName: plugin.name,
skillPaths: [],
});
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) {
try {
const resolved = await customMgr.resolvePlugin(effectiveRepoPath, plugin.rawPlugin, sourceResult.sourceUrl, localPath);
if (resolved.length > 0) {
allResolved.push(...resolved);
} else {
// No skills array or empty - use plugin metadata as-is (legacy)
allResolved.push({
code: plugin.code,
name: plugin.displayName || plugin.name,
version: plugin.version,
description: plugin.description,
strategy: 0,
pluginName: plugin.name,
skillPaths: [],
});
}
} catch (resolveError) {
await prompts.log.warn(` Could not resolve ${plugin.name}: ${resolveError.message}`);
}
}
} else {
// Direct mode: no marketplace.json, scan directory for skills and resolve
const directPlugin = {
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}`);
}
} catch (resolveError) {
await prompts.log.warn(` Could not resolve ${plugin.name}: ${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 repository.');
addMore = await prompts.confirm({ message: 'Try another URL?', default: false });
await prompts.log.warn('No installable modules found in this source.');
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
continue;
}
@ -945,7 +1008,7 @@ class UI {
}
addMore = await prompts.confirm({
message: 'Add another custom module URL?',
message: 'Add another custom source?',
default: false,
});
}
@ -957,6 +1020,102 @@ class UI {
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
* @param {Set} installedModuleIds - Already installed module IDs