Compare commits

..

7 Commits

Author SHA1 Message Date
Alex Verkhovsky c00af57cd0
Merge 0452ba53cf into 59b07c33e2 2026-04-08 17:32:21 +00:00
Alex Verkhovsky 0452ba53cf refactor(quick-dev): restructure epic-story context loading
Reshape path A of step-01 into five explicit numbered steps and add an
inline-compilation fallback for runtimes that cannot spawn sub-agents
(Copilot, Codex, local Ollama, older Claude).

- Pull cache validity, compilation, verification, and continuity into
  separate numbered steps instead of nested paragraphs.
- Define "valid cached context" upfront: non-empty and starts with
  `# Epic <N> Context:`.
- Add inline-compilation fallback: runtimes without sub-agent support
  read compile-epic-context.md and follow it directly.
- Make previous-story continuity run regardless of which context source
  succeeded (cache hit, fresh compilation, or path-B raw fallback).
2026-04-08 10:30:42 -07:00
Alex Verkhovsky 7a321a56c0 refactor(quick-dev): tighten compile-epic-context prompt
- Restructure with Task/Steps opening and Exact Output Format section.
- Switch Stories template to bullet form for clarity.
- Add "no hallucination" and explicit "omit empty sections except Goal
  and Stories" rules.
- Use <N> instead of {N} in the filename for consistency with step-01.
2026-04-08 10:02:36 -07:00
Brian 59b07c33e2
feat(bmad-help): llms.txt support for general questions (#2230)
* feat(bmad-help): add _meta rows and llms.txt support for general questions

Register llms.txt URLs in module-help.csv via _meta rows so bmad-help
can fetch module documentation when users ask questions that don't map
to a specific skill.

* refactor(bmad-help): streamline llms.txt docs into existing skill sections
2026-04-08 09:53:27 -05:00
Alex Verkhovsky f9925eb180
feat(quick-dev): improve checkpoint 1 UX (#2217)
* feat(quick-dev): improve checkpoint 1 UX with clickable link, external editing note, and change detection

Display spec file path as clickable CWD-relative link alongside the
summary. Inform users they can open the spec in another session with
any tool before approving. On approval, re-read the spec from disk
and acknowledge any external edits before proceeding.

* fix(quick-dev): tighten checkpoint 1 [A] flow wording

- Remove stray 'and options' from the editing-note intro so the note's
  position relative to the [A]/[E] menu is unambiguous.
- Restructure the [A] bullet into explicit missing/exists branches so
  the missing-file HALT cannot fall through to status updates and
  recreate a deleted spec.

Addresses augmentcode review comments on PR #2217.

* docs(quick-dev): rewrite checkpoint 1 editing-note

- Drop boilerplate opener about the spec being a regular file.
- Enumerate concrete options: editor, in-session Q&A, or bmad-advanced-elicitation / bmad-party-mode / bmad-code-review skills.
- Flag that skills should ideally run in another session to avoid context bloat.
- Change "add this note" to "display this note" for precision.
2026-04-08 07:27:06 -07:00
Brian b744408783
feat(installer): community module browser and custom URL support (#2229)
* feat(installer): add community module browser and custom URL support

Three-tier module selection: official, community (category drill-down
with featured/search), and custom GitHub URL.

- Add RegistryClient shared fetch utility
- Add CommunityModuleManager with SHA-pinned cloning (refuses install
  if approved SHA cannot be reached; uses HEAD when no SHA set)
- Add CustomModuleManager for arbitrary GitHub repo installation
- Extend findModuleSource chain with community and custom fallthrough
- Extend manifest to detect community and custom source types
- Add Config.customModulesMeta for custom module metadata

* fix: resolve review findings for community/custom module support

- Remove redundant CommunityModuleManager instantiation in UI display
- Remove dead customModulesMeta field from Config (never populated)
- Add 35 unit tests for CustomModuleManager and CommunityModuleManager
  pure functions: URL validation, normalization, search, featured, categories

* fix: preserve installed community/custom modules in modify flow

When a user does "Modify Installation" and declines to browse community
modules, previously installed community/custom modules are now auto-kept.
If the user does browse, their selections are trusted (they can deselect).

Also fix stale docs: class doc for SHA pinning, JSDoc return type.

* fix: include community and custom modules in quick update

Quick update now checks community registry and custom cache so installed
community/custom modules are updated instead of skipped.

* fix: use defaults for new config fields during quick update

When quick update encounters new config fields (e.g., from a newly
supported community module), use schema defaults silently instead of
prompting the user. Quick update should be non-interactive.

* test: add unit tests for SHA pinning, category filtering, and URL edge cases

Cover SHA normalization (set vs null/trusted), listByCategory,
getModuleByCode, and URL validation edge cases (HTTP, trailing slash,
SSH without .git). Total: 243 tests.
2026-04-08 00:50:04 -05:00
Alex Verkhovsky 565167aafd fix(quick-dev): tighten epic context loading per PR review
- Validate cached epic-<N>-context.md is non-empty and starts with the
  expected header before loading; treat invalid cache as missing.
- Replace inline {N} placeholders with <N> so the skill validator does
  not flag them as unresolved workflow variables.
- Replace ambiguous "fall back to path B" with an explicit instruction
  to scan/load planning artifacts using path B's procedure, with a note
  not to re-evaluate path B's gating clause.

Addresses CodeRabbit and Augment review comments on PR #2218.
2026-04-07 13:47:43 -07:00
15 changed files with 1449 additions and 88 deletions

View File

@ -1,72 +1,62 @@
# Compile Epic Context # Compile Epic Context
You are a context-compilation agent. Your job is to read planning artifacts and produce a single, scoped context document for one epic. **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`).
## Inputs **Steps**
You will receive: 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.
- **Epic number** — which epic to compile context for ## Exact Output Format
- **Epics file path** — the file containing epic and story definitions
- **Planning artifacts directory** — where PRD, architecture, UX, and other planning docs live
- **Output path** — where to write the compiled context file
## Instructions Use these headings verbatim:
1. **Load the epics file** and extract the definition for the target epic: its title, goal, and story list.
2. **Scan the planning artifacts directory** for these standard BMAD files:
- PRD (`*prd*`) — product requirements and success criteria
- Architecture (`*architecture*`) — technical design decisions and constraints
- UX/Design (`*ux*`) — user experience and interaction design
- Product Brief (`*brief*`) — project vision and scope
3. **For each planning doc found**, load it and extract only the sections relevant to this epic. Relevance means: the section describes a constraint, requirement, pattern, or decision that a developer working on any story in this epic needs to know. Skip sections that are about other epics or unrelated features.
4. **Write the compiled context file** to the output path using the format below.
## Output format
```markdown ```markdown
# Epic {N} Context: {Epic Title} # Epic {N} Context: {Epic Title}
<!-- Compiled from planning artifacts. Edit freely — regenerate with compile-epic-context if planning docs change. --> <!-- Compiled from planning artifacts. Edit freely. Regenerate with compile-epic-context if planning docs change. -->
## Goal ## Goal
{One paragraph: what this epic achieves and why it matters.} {One clear paragraph: what this epic achieves and why it matters.}
## Stories ## Stories
{Numbered list of stories in this epic — ID and title only. No details.} - Story X.Y: Brief title only
- ...
## Requirements & Constraints ## Requirements & Constraints
{Relevant product requirements, success criteria, and non-functional requirements scoped to this epic. Describe by purpose, not by source document section.} {Relevant functional/non-functional requirements and success criteria for this epic (describe by purpose, not source).}
## Technical Decisions ## Technical Decisions
{Architecture decisions, tech stack constraints, API patterns, data models, and conventions relevant to this epic. Include ADR references where applicable.} {Key architecture decisions, constraints, patterns, data models, and conventions relevant to this epic.}
## UX & Interaction Patterns ## UX & Interaction Patterns
{Relevant UX patterns, interaction flows, and design constraints. Omit this section entirely if no UX doc exists or nothing is relevant.} {Relevant UX flows, interaction patterns, and design constraints (omit section entirely if nothing relevant).}
## Cross-Story Dependencies ## Cross-Story Dependencies
{Dependencies between stories within this epic, and any dependencies on other epics or external systems. Omit if none.} {Dependencies between stories in this epic or with other epics/systems (omit if none).}
``` ```
## Error handling
- If the epics file does not exist or the target epic number is not found in it, write nothing and report the problem to the calling agent.
- If no planning docs match the expected patterns, write the output file with only the Goal and Stories sections populated from the epics file. Note the absence of planning docs in the Goal section.
## Rules ## Rules
- **Scope aggressively.** Include only what a developer working on this epic needs. When in doubt, leave it out — the developer can always read the full planning doc. - **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. - **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 paste entire planning doc sections. Distill into what matters for implementation. - **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. - **No story-level details.** The story list is for orientation only. Individual story specs handle the details.
- **No code.** Nothing derivable from reading the codebase belongs here. - **Nothing derivable from the codebase.** Don't document what a developer can learn by reading the code.
- **Keep it compact.** Target 8001500 tokens. This file will be loaded into quick-dev's context alongside other material. - **Be concise and actionable.** Target 8001500 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.

View File

@ -44,11 +44,20 @@ Never ask extra questions if you already understand what the user intends.
- **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. - **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.
**A) Epic story path** — if the intent is an epic story: **A) Epic story path** — if the intent is an epic story:
1. Identify the epic number and story number.
2. Check if `{implementation_artifacts}/epic-{N}-context.md` exists (where N is the epic number). 1. Identify the epic number and (if present) the story number.
- **If it exists**: load it. This is the compiled planning context for the epic — do not load raw planning docs (PRD, architecture, UX, etc.).
- **If it does not exist**: 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`. When the sub-agent completes, verify the output file exists and is non-empty. If the file is missing, empty, or the sub-agent errored, fall back to path B. Otherwise load the compiled file. 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, and starts with `# Epic <N> Context:` (with the correct epic number).
3. **Previous story continuity.** 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. - **If valid:** load it as the primary planning context. Do not load raw planning docs (PRD, architecture, UX, etc.). Skip to step 5.
- **If missing, empty, or invalid:** continue to step 3.
3. **Compile epic context.** Produce `{implementation_artifacts}/epic-<N>-context.md` by following `./compile-epic-context.md`, in order of preference:
- **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`.
- **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.
4. **Verify or fall back to path B.** After compilation, verify the output file exists, is non-empty, and starts with `# Epic <N> Context:`. If valid, load it. If verification fails, fall back to path B's raw planning artifacts loading procedure below (scan `{planning_artifacts}` for PRD, architecture, UX, and epics files and load selectively) — apply this fallback even though the intent is an epic story; do not re-evaluate path B's gating clause.
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: **B) Freeform path** — if the intent is not an epic story:
- Planning artifacts are the output of BMAD phases 1-3. Typical files include: - Planning artifacts are the output of BMAD phases 1-3. Typical files include:

View File

@ -24,9 +24,21 @@ deferred_work_file: '{implementation_artifacts}/deferred-work.md'
### CHECKPOINT 1 ### CHECKPOINT 1
Present summary. If token count exceeded 1600 and user chose [K], include the token count and explain why it may be a problem. HALT and ask human: `[A] Approve` | `[E] Edit` Present summary. Display the spec file path as a CWD-relative path (no leading `/`) so it is clickable in the terminal. If token count exceeded 1600 and user chose [K], include the token count and explain why it may be a problem.
- **A**: Set status `ready-for-dev` in `{spec_file}`. Everything inside `<frozen-after-approval>` is now locked — only the human can change it. Display the finalized spec path to the user as a CWD-relative path (no leading `/`) so it is clickable in the terminal. → Step 3. After presenting the summary, display this note:
---
Before approving, you can open the spec file in an editor or ask me questions and tell me what to change. You can also use `bmad-advanced-elicitation`, `bmad-party-mode`, or `bmad-code-review` skills, ideally in another session to avoid context bloat.
---
HALT and ask human: `[A] Approve` | `[E] Edit`
- **A**: Re-read `{spec_file}` from disk.
- **If the file is missing:** HALT. Tell the user the spec file is gone and STOP — do not write anything to `{spec_file}`, do not set status, do not proceed to Step 3. Nothing below this point runs.
- **If the file exists:** Compare the content to what you wrote. If it has changed since you wrote it, acknowledge the external edits — show a brief summary of what changed — and proceed with the updated version. Then set status `ready-for-dev` in `{spec_file}`. Everything inside `<frozen-after-approval>` is now locked — only the human can change it. → Step 3.
- **E**: Apply changes, then return to CHECKPOINT 1. - **E**: Apply changes, then return to CHECKPOINT 1.

View File

@ -1,4 +1,5 @@
module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs
BMad Method,_meta,,,,,,,,,false,https://docs.bmad-method.org/llms.txt,
BMad Method,bmad-document-project,Document Project,DP,Analyze an existing project to produce useful documentation.,,anytime,,,false,project-knowledge,* BMad Method,bmad-document-project,Document Project,DP,Analyze an existing project to produce useful documentation.,,anytime,,,false,project-knowledge,*
BMad Method,bmad-generate-project-context,Generate Project Context,GPC,Scan existing codebase to generate a lean LLM-optimized project-context.md. Essential for brownfield projects.,,anytime,,,false,output_folder,project context BMad Method,bmad-generate-project-context,Generate Project Context,GPC,Scan existing codebase to generate a lean LLM-optimized project-context.md. Essential for brownfield projects.,,anytime,,,false,output_folder,project context
BMad Method,bmad-quick-dev,Quick Dev,QQ,Unified intent-in code-out workflow: clarify plan implement review and present.,,anytime,,,false,implementation_artifacts,spec and project implementation BMad Method,bmad-quick-dev,Quick Dev,QQ,Unified intent-in code-out workflow: clarify plan implement review and present.,,anytime,,,false,implementation_artifacts,spec and project implementation

Can't render this file because it has a wrong number of fields in line 2.

View File

@ -7,7 +7,7 @@ description: 'Analyzes current state and user query to answer BMad questions or
## Purpose ## Purpose
Help the user understand where they are in their BMad workflow and what to do next. Answer BMad questions when asked. Help the user understand where they are in their BMad workflow and what to do next, and also answer broader questions when asked that could be augmented with remote sources such as module documentation sources.
## Desired Outcomes ## Desired Outcomes
@ -18,6 +18,7 @@ When this skill completes, the user should:
3. **Know how to invoke it** — skill name, menu code, action context, and any args that shortcut the conversation 3. **Know how to invoke it** — skill name, menu code, action context, and any args that shortcut the conversation
4. **Get offered a quick start** — when a single skill is the clear next step, offer to run it for the user right now rather than just listing it 4. **Get offered a quick start** — when a single skill is the clear next step, offer to run it for the user right now rather than just listing it
5. **Feel oriented, not overwhelmed** — surface only what's relevant to their current position; don't dump the entire catalog 5. **Feel oriented, not overwhelmed** — surface only what's relevant to their current position; don't dump the entire catalog
6. **Get answers to general questions** — when the question doesn't map to a specific skill, use the module's registered documentation to give a grounded answer
## Data Sources ## Data Sources
@ -25,6 +26,7 @@ When this skill completes, the user should:
- **Config**: `config.yaml` and `user-config.yaml` files in `{project-root}/_bmad/` and its subfolders — resolve `output-location` variables, provide `communication_language` and `project_knowledge` - **Config**: `config.yaml` and `user-config.yaml` files in `{project-root}/_bmad/` and its subfolders — resolve `output-location` variables, provide `communication_language` and `project_knowledge`
- **Artifacts**: Files matching `outputs` patterns at resolved `output-location` paths reveal which steps are possibly completed; their content may also provide grounding context for recommendations - **Artifacts**: Files matching `outputs` patterns at resolved `output-location` paths reveal which steps are possibly completed; their content may also provide grounding context for recommendations
- **Project knowledge**: If `project_knowledge` resolves to an existing path, read it for grounding context. Never fabricate project-specific details. - **Project knowledge**: If `project_knowledge` resolves to an existing path, read it for grounding context. Never fabricate project-specific details.
- **Module docs**: Rows with `_meta` in the `skill` column carry a URL or path in `output-location` pointing to the module's documentation (e.g., llms.txt). Fetch and use these to answer general questions about that module.
## CSV Interpretation ## CSV Interpretation
@ -70,4 +72,4 @@ For each recommended item, present:
- Present all output in `{communication_language}` - Present all output in `{communication_language}`
- Recommend running each skill in a **fresh context window** - Recommend running each skill in a **fresh context window**
- Match the user's tone — conversational when they're casual, structured when they want specifics - Match the user's tone — conversational when they're casual, structured when they want specifics
- If the active module is ambiguous, ask rather than guess - If the active module is ambiguous, retrieve all meta rows remote sources to find relevant info also to help answer their question

View File

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

Can't render this file because it has a wrong number of fields in line 2.

View File

@ -1723,6 +1723,258 @@ async function runTests() {
console.log(''); console.log('');
// ============================================================
// Test Suite 33: Community & Custom Module Managers
// ============================================================
console.log(`${colors.yellow}Test Suite 33: Community & Custom Module Managers${colors.reset}\n`);
// --- CustomModuleManager.validateGitHubUrl ---
{
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
const mgr = new CustomModuleManager();
const https1 = mgr.validateGitHubUrl('https://github.com/owner/repo');
assert(https1.isValid === true, 'validateGitHubUrl accepts HTTPS URL');
assert(https1.owner === 'owner' && https1.repo === 'repo', 'validateGitHubUrl extracts owner/repo from HTTPS');
const https2 = mgr.validateGitHubUrl('https://github.com/owner/repo.git');
assert(https2.isValid === true, 'validateGitHubUrl accepts HTTPS URL with .git');
assert(https2.repo === 'repo', 'validateGitHubUrl strips .git suffix');
const ssh1 = mgr.validateGitHubUrl('git@github.com:owner/repo.git');
assert(ssh1.isValid === true, 'validateGitHubUrl accepts SSH URL');
assert(ssh1.owner === 'owner' && ssh1.repo === 'repo', 'validateGitHubUrl extracts owner/repo from SSH');
const bad1 = mgr.validateGitHubUrl('https://gitlab.com/owner/repo');
assert(bad1.isValid === false, 'validateGitHubUrl rejects non-GitHub URL');
const bad2 = mgr.validateGitHubUrl('');
assert(bad2.isValid === false, 'validateGitHubUrl rejects empty string');
const bad3 = mgr.validateGitHubUrl(null);
assert(bad3.isValid === false, 'validateGitHubUrl rejects null');
const bad4 = mgr.validateGitHubUrl('https://github.com/owner');
assert(bad4.isValid === false, 'validateGitHubUrl rejects URL without repo');
}
// --- CustomModuleManager._normalizeCustomModule ---
{
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
const mgr = new CustomModuleManager();
const plugin = { name: 'test-plugin', description: 'A test', version: '1.0.0', author: 'tester', source: './src' };
const data = { owner: 'Fallback Owner' };
const result = mgr._normalizeCustomModule(plugin, 'https://github.com/o/r', data);
assert(result.code === 'test-plugin', 'normalizeCustomModule sets code from plugin name');
assert(result.type === 'custom', 'normalizeCustomModule sets type to custom');
assert(result.trustTier === 'unverified', 'normalizeCustomModule sets trustTier to unverified');
assert(result.version === '1.0.0', 'normalizeCustomModule preserves version');
assert(result.author === 'tester', 'normalizeCustomModule uses plugin author over data.owner');
const pluginNoAuthor = { name: 'x', description: '', version: null };
const result2 = mgr._normalizeCustomModule(pluginNoAuthor, 'https://github.com/o/r', data);
assert(result2.author === 'Fallback Owner', 'normalizeCustomModule falls back to data.owner');
}
// --- CommunityModuleManager._normalizeCommunityModule ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
const mod = {
name: 'test-mod',
display_name: 'Test Module',
code: 'tm',
description: 'desc',
repository: 'https://github.com/o/r',
module_definition: 'src/module.yaml',
category: 'software-development',
subcategory: 'dev-tools',
trust_tier: 'bmad-certified',
version: '2.0.0',
approved_sha: 'abc123',
promoted: true,
promoted_rank: 1,
keywords: ['test', 'module'],
};
const result = mgr._normalizeCommunityModule(mod);
assert(result.code === 'tm', 'normalizeCommunityModule sets code');
assert(result.displayName === 'Test Module', 'normalizeCommunityModule sets displayName from display_name');
assert(result.type === 'community', 'normalizeCommunityModule sets type to community');
assert(result.category === 'software-development', 'normalizeCommunityModule preserves category');
assert(result.trustTier === 'bmad-certified', 'normalizeCommunityModule maps trust_tier');
assert(result.approvedSha === 'abc123', 'normalizeCommunityModule maps approved_sha');
assert(result.promoted === true, 'normalizeCommunityModule maps promoted');
assert(result.promotedRank === 1, 'normalizeCommunityModule maps promoted_rank');
assert(result.builtIn === false, 'normalizeCommunityModule sets builtIn false');
}
// --- CommunityModuleManager.searchByKeyword (with injected cache) ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
// Inject cached index to avoid network call
mgr._cachedIndex = {
modules: [
{ name: 'mod-a', display_name: 'Alpha', code: 'a', description: 'testing tools', category: 'dev', keywords: ['test'] },
{ name: 'mod-b', display_name: 'Beta', code: 'b', description: 'design suite', category: 'design', keywords: ['ux'] },
{ name: 'mod-c', display_name: 'Gamma', code: 'c', description: 'game engine', category: 'game', keywords: ['unity'] },
],
};
const r1 = await mgr.searchByKeyword('test');
assert(r1.length === 1 && r1[0].code === 'a', 'searchByKeyword matches keyword');
const r2 = await mgr.searchByKeyword('design');
assert(r2.length === 1 && r2[0].code === 'b', 'searchByKeyword matches description');
const r3 = await mgr.searchByKeyword('alpha');
assert(r3.length === 1 && r3[0].code === 'a', 'searchByKeyword matches display name');
const r4 = await mgr.searchByKeyword('xyz');
assert(r4.length === 0, 'searchByKeyword returns empty for no match');
const r5 = await mgr.searchByKeyword('UNITY');
assert(r5.length === 1 && r5[0].code === 'c', 'searchByKeyword is case-insensitive');
}
// --- CommunityModuleManager.listFeatured (with injected cache) ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
mgr._cachedIndex = {
modules: [
{ name: 'a', code: 'a', promoted: true, promoted_rank: 3 },
{ name: 'b', code: 'b', promoted: false },
{ name: 'c', code: 'c', promoted: true, promoted_rank: 1 },
],
};
const featured = await mgr.listFeatured();
assert(featured.length === 2, 'listFeatured returns only promoted modules');
assert(featured[0].code === 'c' && featured[1].code === 'a', 'listFeatured sorts by promoted_rank ascending');
}
// --- CommunityModuleManager.getCategoryList (with injected cache) ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
mgr._cachedIndex = {
modules: [
{ name: 'a', code: 'a', category: 'software-development' },
{ name: 'b', code: 'b', category: 'design-and-creative' },
{ name: 'c', code: 'c', category: 'software-development' },
],
};
mgr._cachedCategories = {
categories: {
'software-development': { name: 'Software Development' },
'design-and-creative': { name: 'Design & Creative' },
},
};
const cats = await mgr.getCategoryList();
assert(cats.length === 2, 'getCategoryList returns categories with modules');
const swDev = cats.find((c) => c.slug === 'software-development');
assert(swDev && swDev.moduleCount === 2, 'getCategoryList counts modules per category');
assert(cats[0].name === 'Design & Creative', 'getCategoryList sorts alphabetically');
}
// --- CommunityModuleManager SHA pinning normalization ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
// Module with SHA set
const withSha = mgr._normalizeCommunityModule({
name: 'pinned-mod',
code: 'pm',
approved_sha: 'abc123def456',
approved_tag: 'v1.0.0',
});
assert(withSha.approvedSha === 'abc123def456', 'SHA is preserved when set');
assert(withSha.approvedTag === 'v1.0.0', 'Tag is preserved as metadata');
// Module with null SHA (trusted contributor)
const noSha = mgr._normalizeCommunityModule({
name: 'trusted-mod',
code: 'tm',
approved_sha: null,
});
assert(noSha.approvedSha === null, 'Null SHA means no pinning (trusted contributor)');
}
// --- CommunityModuleManager.listByCategory (with injected cache) ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
mgr._cachedIndex = {
modules: [
{ name: 'a', code: 'a', category: 'design-and-creative' },
{ name: 'b', code: 'b', category: 'software-development' },
{ name: 'c', code: 'c', category: 'design-and-creative' },
{ name: 'd', code: 'd', category: 'game-development' },
],
};
const design = await mgr.listByCategory('design-and-creative');
assert(design.length === 2, 'listByCategory filters to matching category');
assert(
design.every((m) => m.category === 'design-and-creative'),
'listByCategory returns only matching modules',
);
const empty = await mgr.listByCategory('nonexistent');
assert(empty.length === 0, 'listByCategory returns empty for unknown category');
}
// --- CommunityModuleManager.getModuleByCode (with injected cache) ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
mgr._cachedIndex = {
modules: [
{ name: 'test-mod', code: 'tm', display_name: 'Test Module' },
{ name: 'other-mod', code: 'om', display_name: 'Other Module' },
],
};
const found = await mgr.getModuleByCode('tm');
assert(found !== null && found.code === 'tm', 'getModuleByCode finds existing module');
const notFound = await mgr.getModuleByCode('xyz');
assert(notFound === null, 'getModuleByCode returns null for unknown code');
}
// --- CustomModuleManager URL edge cases ---
{
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
const mgr = new CustomModuleManager();
// HTTP (not HTTPS) should work
const http = mgr.validateGitHubUrl('http://github.com/owner/repo');
assert(http.isValid === true, 'validateGitHubUrl accepts HTTP URL');
// Trailing slash should be rejected (strict matching)
const trailing = mgr.validateGitHubUrl('https://github.com/owner/repo/');
assert(trailing.isValid === false, 'validateGitHubUrl rejects trailing slash');
// SSH without .git should work
const sshNoDotGit = mgr.validateGitHubUrl('git@github.com:owner/repo');
assert(sshNoDotGit.isValid === true, 'validateGitHubUrl accepts SSH without .git');
assert(sshNoDotGit.repo === 'repo', 'validateGitHubUrl extracts repo from SSH without .git');
}
console.log('');
// ============================================================ // ============================================================
// Summary // Summary
// ============================================================ // ============================================================

View File

@ -969,6 +969,14 @@ class Installer {
outputs, outputs,
] = columns; ] = columns;
// Pass through _meta rows as-is (module metadata, not a skill)
if (phase === '_meta') {
const finalModule = (!module || module.trim() === '') && moduleName !== 'core' ? moduleName : module || '';
const metaRow = [finalModule, '_meta', '', '', '', '', '', 'false', '', '', '', '', '', '', outputLocation || '', ''];
allRows.push(metaRow.map((c) => this.escapeCSVField(c)).join(','));
continue;
}
// If module column is empty, set it to this module's name (except for core which stays empty for universal tools) // If module column is empty, set it to this module's name (except for core which stays empty for universal tools)
const finalModule = (!module || module.trim() === '') && moduleName !== 'core' ? moduleName : module || ''; const finalModule = (!module || module.trim() === '') && moduleName !== 'core' ? moduleName : module || '';
@ -1161,6 +1169,38 @@ class Installer {
} }
} }
// Add installed community modules to available modules
const { CommunityModuleManager } = require('../modules/community-manager');
const communityMgr = new CommunityModuleManager();
const communityModules = await communityMgr.listAll();
for (const communityModule of communityModules) {
if (installedModules.includes(communityModule.code) && !availableModules.some((m) => m.id === communityModule.code)) {
availableModules.push({
id: communityModule.code,
name: communityModule.displayName,
isExternal: true,
fromCommunity: true,
});
}
}
// Add installed custom modules to available modules
const { CustomModuleManager } = require('../modules/custom-module-manager');
const customMgr = new CustomModuleManager();
for (const moduleId of installedModules) {
if (!availableModules.some((m) => m.id === moduleId)) {
const customSource = await customMgr.findModuleSourceByCode(moduleId);
if (customSource) {
availableModules.push({
id: moduleId,
name: moduleId,
isExternal: true,
fromCustom: true,
});
}
}
}
const availableModuleIds = new Set(availableModules.map((m) => m.id)); const availableModuleIds = new Set(availableModules.map((m) => m.id));
// Only update modules that are BOTH installed AND available (we have source for) // Only update modules that are BOTH installed AND available (we have source for)

View File

@ -818,6 +818,34 @@ class Manifest {
}; };
} }
// Check if this is a community module
const { CommunityModuleManager } = require('../modules/community-manager');
const communityMgr = new CommunityModuleManager();
const communityInfo = await communityMgr.getModuleByCode(moduleName);
if (communityInfo) {
const communityVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
return {
version: communityVersion || communityInfo.version,
source: 'community',
npmPackage: communityInfo.npmPackage || null,
repoUrl: communityInfo.url || null,
};
}
// Check if this is a custom module (from user-provided URL)
const { CustomModuleManager } = require('../modules/custom-module-manager');
const customMgr = new CustomModuleManager();
const customSource = await customMgr.findModuleSourceByCode(moduleName);
if (customSource) {
const customVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
return {
version: customVersion,
source: 'custom',
npmPackage: null,
repoUrl: null,
};
}
// Unknown module // Unknown module
const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath); const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
return { return {

View File

@ -0,0 +1,377 @@
const fs = require('fs-extra');
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');
const MARKETPLACE_BASE = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main';
const COMMUNITY_INDEX_URL = `${MARKETPLACE_BASE}/registry/community-index.yaml`;
const CATEGORIES_URL = `${MARKETPLACE_BASE}/categories.yaml`;
/**
* Manages community modules from the BMad marketplace registry.
* Fetches community-index.yaml and categories.yaml from GitHub.
* Returns empty results when the registry is unreachable.
* Community modules are pinned to approved SHA when set; uses HEAD otherwise.
*/
class CommunityModuleManager {
constructor() {
this._client = new RegistryClient();
this._cachedIndex = null;
this._cachedCategories = null;
}
// ─── Data Loading ──────────────────────────────────────────────────────────
/**
* Load the community module index from the marketplace repo.
* Returns empty when the registry is unreachable.
* @returns {Object} Parsed YAML with modules array
*/
async loadCommunityIndex() {
if (this._cachedIndex) return this._cachedIndex;
try {
const config = await this._client.fetchYaml(COMMUNITY_INDEX_URL);
if (config?.modules?.length) {
this._cachedIndex = config;
return config;
}
} catch {
// Registry unreachable - no community modules available
}
return { modules: [] };
}
/**
* Load categories from the marketplace repo.
* Returns empty when the registry is unreachable.
* @returns {Object} Parsed categories.yaml content
*/
async loadCategories() {
if (this._cachedCategories) return this._cachedCategories;
try {
const config = await this._client.fetchYaml(CATEGORIES_URL);
if (config?.categories) {
this._cachedCategories = config;
return config;
}
} catch {
// Registry unreachable - no categories available
}
return { categories: {} };
}
// ─── Listing & Filtering ──────────────────────────────────────────────────
/**
* Get all community modules, normalized.
* @returns {Array<Object>} Normalized community modules
*/
async listAll() {
const index = await this.loadCommunityIndex();
return (index.modules || []).map((mod) => this._normalizeCommunityModule(mod));
}
/**
* Get community modules filtered to a category.
* @param {string} categorySlug - Category slug (e.g., 'design-and-creative')
* @returns {Array<Object>} Filtered modules
*/
async listByCategory(categorySlug) {
const all = await this.listAll();
return all.filter((mod) => mod.category === categorySlug);
}
/**
* Get promoted/featured community modules, sorted by rank.
* @returns {Array<Object>} Featured modules
*/
async listFeatured() {
const all = await this.listAll();
return all.filter((mod) => mod.promoted === true).sort((a, b) => (a.promotedRank || 999) - (b.promotedRank || 999));
}
/**
* Search community modules by keyword.
* Matches against name, display name, description, and keywords array.
* @param {string} query - Search query
* @returns {Array<Object>} Matching modules
*/
async searchByKeyword(query) {
const all = await this.listAll();
const q = query.toLowerCase();
return all.filter((mod) => {
const searchable = [mod.name, mod.displayName, mod.description, ...(mod.keywords || [])].join(' ').toLowerCase();
return searchable.includes(q);
});
}
/**
* Get categories with module counts for UI display.
* Only returns categories that have at least one community module.
* @returns {Array<Object>} Array of { slug, name, moduleCount }
*/
async getCategoryList() {
const all = await this.listAll();
const categoriesData = await this.loadCategories();
const categories = categoriesData.categories || {};
// Count modules per category
const counts = {};
for (const mod of all) {
counts[mod.category] = (counts[mod.category] || 0) + 1;
}
// Build list with display names from categories.yaml
const result = [];
for (const [slug, count] of Object.entries(counts)) {
const catInfo = categories[slug];
result.push({
slug,
name: catInfo?.name || slug,
moduleCount: count,
});
}
// Sort alphabetically by name
result.sort((a, b) => a.name.localeCompare(b.name));
return result;
}
// ─── Module Lookup ────────────────────────────────────────────────────────
/**
* Get a community module by its code.
* @param {string} code - Module code (e.g., 'wds')
* @returns {Object|null} Normalized module or null
*/
async getModuleByCode(code) {
const all = await this.listAll();
return all.find((m) => m.code === code) || null;
}
// ─── Clone with Tag Pinning ───────────────────────────────────────────────
/**
* Get the cache directory for community modules.
* @returns {string} Path to the community modules cache directory
*/
getCacheDir() {
return path.join(os.homedir(), '.bmad', 'cache', 'community-modules');
}
/**
* Clone a community module repository, pinned to its approved tag.
* @param {string} moduleCode - Module code
* @param {Object} [options] - Clone options
* @param {boolean} [options.silent] - Suppress spinner output
* @returns {string} Path to the cloned repository
*/
async cloneModule(moduleCode, options = {}) {
const moduleInfo = await this.getModuleByCode(moduleCode);
if (!moduleInfo) {
throw new Error(`Community module '${moduleCode}' not found in the registry`);
}
const cacheDir = this.getCacheDir();
const moduleCacheDir = path.join(cacheDir, moduleCode);
const silent = options.silent || false;
await fs.ensureDir(cacheDir);
const createSpinner = async () => {
if (silent) {
return { start() {}, stop() {}, error() {}, message() {} };
}
return await prompts.spinner();
};
const sha = moduleInfo.approvedSha;
let needsDependencyInstall = false;
let wasNewClone = false;
if (await fs.pathExists(moduleCacheDir)) {
// Already cloned - update to latest HEAD
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Checking ${moduleInfo.displayName}...`);
try {
const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
execSync('git fetch origin --depth 1', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
execSync('git reset --hard origin/HEAD', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
if (currentRef !== newRef) needsDependencyInstall = true;
fetchSpinner.stop(`Verified ${moduleInfo.displayName}`);
} catch {
fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.displayName}`);
await fs.remove(moduleCacheDir);
wasNewClone = true;
}
} else {
wasNewClone = true;
}
if (wasNewClone) {
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Fetching ${moduleInfo.displayName}...`);
try {
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
fetchSpinner.stop(`Fetched ${moduleInfo.displayName}`);
needsDependencyInstall = true;
} catch (error) {
fetchSpinner.error(`Failed to fetch ${moduleInfo.displayName}`);
throw new Error(`Failed to clone community module '${moduleCode}': ${error.message}`);
}
}
// If pinned to a specific SHA, check out that exact commit.
// Refuse to install if the approved SHA cannot be reached - security requirement.
if (sha) {
const headSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
if (headSha !== sha) {
try {
execSync(`git fetch --depth 1 origin ${sha}`, {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
execSync(`git checkout ${sha}`, {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
needsDependencyInstall = true;
} catch {
await fs.remove(moduleCacheDir);
throw new Error(
`Community module '${moduleCode}' could not be pinned to its approved commit (${sha}). ` +
`Installation refused for security. The module registry entry may need updating.`,
);
}
}
}
// Install dependencies if needed
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
if ((needsDependencyInstall || wasNewClone) && (await fs.pathExists(packageJsonPath))) {
const installSpinner = await createSpinner();
installSpinner.start(`Installing dependencies for ${moduleInfo.displayName}...`);
try {
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 120_000,
});
installSpinner.stop(`Installed dependencies for ${moduleInfo.displayName}`);
} catch (error) {
installSpinner.error(`Failed to install dependencies for ${moduleInfo.displayName}`);
if (!silent) await prompts.log.warn(` ${error.message}`);
}
}
return moduleCacheDir;
}
// ─── Source Finding ───────────────────────────────────────────────────────
/**
* Find the source path for a community module (clone + locate module.yaml).
* @param {string} moduleCode - Module code
* @param {Object} [options] - Options passed to cloneModule
* @returns {string|null} Path to the module source or null
*/
async findModuleSource(moduleCode, options = {}) {
const moduleInfo = await this.getModuleByCode(moduleCode);
if (!moduleInfo) return null;
const cloneDir = await this.cloneModule(moduleCode, options);
// Check configured module_definition path first
if (moduleInfo.moduleDefinition) {
const configuredPath = path.join(cloneDir, moduleInfo.moduleDefinition);
if (await fs.pathExists(configuredPath)) {
return path.dirname(configuredPath);
}
}
// Fallback: search skills/ and src/ directories
for (const dir of ['skills', 'src']) {
const rootCandidate = path.join(cloneDir, dir, 'module.yaml');
if (await fs.pathExists(rootCandidate)) {
return path.dirname(rootCandidate);
}
const dirPath = path.join(cloneDir, dir);
if (await fs.pathExists(dirPath)) {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const subCandidate = path.join(dirPath, entry.name, 'module.yaml');
if (await fs.pathExists(subCandidate)) {
return path.dirname(subCandidate);
}
}
}
}
}
// Check repo root
const rootCandidate = path.join(cloneDir, 'module.yaml');
if (await fs.pathExists(rootCandidate)) {
return path.dirname(rootCandidate);
}
return moduleInfo.moduleDefinition ? path.dirname(path.join(cloneDir, moduleInfo.moduleDefinition)) : null;
}
// ─── Normalization ────────────────────────────────────────────────────────
/**
* Normalize a community module entry to a consistent shape.
* @param {Object} mod - Raw module from community-index.yaml
* @returns {Object} Normalized module info
*/
_normalizeCommunityModule(mod) {
return {
key: mod.name,
code: mod.code,
name: mod.display_name || mod.name,
displayName: mod.display_name || mod.name,
description: mod.description || '',
url: mod.repository || mod.url,
moduleDefinition: mod.module_definition || mod['module-definition'],
npmPackage: mod.npm_package || mod.npmPackage || null,
author: mod.author || '',
license: mod.license || '',
type: 'community',
category: mod.category || '',
subcategory: mod.subcategory || '',
keywords: mod.keywords || [],
version: mod.version || null,
approvedTag: mod.approved_tag || null,
approvedSha: mod.approved_sha || null,
approvedDate: mod.approved_date || null,
reviewer: mod.reviewer || null,
trustTier: mod.trust_tier || 'unverified',
promoted: mod.promoted === true,
promotedRank: mod.promoted_rank || null,
defaultSelected: false,
builtIn: false,
isExternal: true,
};
}
}
module.exports = { CommunityModuleManager };

View File

@ -0,0 +1,308 @@
const fs = require('fs-extra');
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.
*/
class CustomModuleManager {
constructor() {
this._client = new RegistryClient();
}
// ─── URL Validation ───────────────────────────────────────────────────────
/**
* 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 }
*/
validateGitHubUrl(url) {
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]
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
const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
if (sshMatch) {
return { owner: sshMatch[1], repo: sshMatch[2], isValid: true, error: null };
}
return { owner: null, repo: null, isValid: false, error: 'Not a valid GitHub URL (expected https://github.com/owner/repo)' };
}
// ─── Discovery ────────────────────────────────────────────────────────────
/**
* Fetch .claude-plugin/marketplace.json from a GitHub repository.
* @param {string} repoUrl - GitHub repository URL
* @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
*/
async discoverModules(repoUrl) {
const data = await this.fetchMarketplaceJson(repoUrl);
const plugins = data?.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));
}
// ─── Clone ────────────────────────────────────────────────────────────────
/**
* Get the cache directory for custom modules.
* @returns {string} Path to the custom modules cache directory
*/
getCacheDir() {
return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules');
}
/**
* Clone a custom module repository to cache.
* @param {string} repoUrl - GitHub repository URL
* @param {Object} [options] - Clone options
* @param {boolean} [options.silent] - Suppress spinner output
* @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);
const cacheDir = this.getCacheDir();
const repoCacheDir = path.join(cacheDir, owner, repo);
const silent = options.silent || false;
await fs.ensureDir(path.join(cacheDir, owner));
const createSpinner = async () => {
if (silent) {
return { start() {}, stop() {}, error() {} };
}
return await prompts.spinner();
};
if (await fs.pathExists(repoCacheDir)) {
// Update existing clone
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Updating ${owner}/${repo}...`);
try {
execSync('git fetch origin --depth 1', {
cwd: repoCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
execSync('git reset --hard origin/HEAD', {
cwd: repoCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
fetchSpinner.stop(`Updated ${owner}/${repo}`);
} catch {
fetchSpinner.error(`Update failed, re-downloading ${owner}/${repo}`);
await fs.remove(repoCacheDir);
}
}
if (!(await fs.pathExists(repoCacheDir))) {
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Cloning ${owner}/${repo}...`);
try {
execSync(`git clone --depth 1 "${repoUrl}" "${repoCacheDir}"`, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
fetchSpinner.stop(`Cloned ${owner}/${repo}`);
} catch (error_) {
fetchSpinner.error(`Failed to clone ${owner}/${repo}`);
throw new Error(`Failed to clone ${repoUrl}: ${error_.message}`);
}
}
// Install dependencies if package.json exists
const packageJsonPath = path.join(repoCacheDir, 'package.json');
if (await fs.pathExists(packageJsonPath)) {
const installSpinner = await createSpinner();
installSpinner.start(`Installing dependencies for ${owner}/${repo}...`);
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}`);
} catch (error_) {
installSpinner.error(`Failed to install dependencies for ${owner}/${repo}`);
if (!silent) await prompts.log.warn(` ${error_.message}`);
}
}
return repoCacheDir;
}
// ─── Source Finding ───────────────────────────────────────────────────────
/**
* Find the module source path within a cloned custom repo.
* @param {string} repoUrl - GitHub repository URL (for cache location)
* @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);
if (!(await fs.pathExists(repoCacheDir))) return null;
// Try plugin source path first (e.g., "./src/pro-skills")
if (pluginSource) {
const sourcePath = path.join(repoCacheDir, pluginSource);
const moduleYaml = path.join(sourcePath, 'module.yaml');
if (await fs.pathExists(moduleYaml)) {
return sourcePath;
}
}
// Fallback: search skills/ and src/ directories
for (const dir of ['skills', 'src']) {
const rootCandidate = path.join(repoCacheDir, dir, 'module.yaml');
if (await fs.pathExists(rootCandidate)) {
return path.dirname(rootCandidate);
}
const dirPath = path.join(repoCacheDir, dir);
if (await fs.pathExists(dirPath)) {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const subCandidate = path.join(dirPath, entry.name, 'module.yaml');
if (await fs.pathExists(subCandidate)) {
return path.dirname(subCandidate);
}
}
}
}
}
// Check repo root
const rootCandidate = path.join(repoCacheDir, 'module.yaml');
if (await fs.pathExists(rootCandidate)) {
return repoCacheDir;
}
return null;
}
/**
* Find module source by module code, searching the custom cache.
* @param {string} moduleCode - Module code to search for
* @param {Object} [options] - Options
* @returns {string|null} Path to the module source or null
*/
async findModuleSourceByCode(moduleCode, options = {}) {
const cacheDir = this.getCacheDir();
if (!(await fs.pathExists(cacheDir))) return null;
// Search through all custom repo caches
try {
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);
// 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 || []) {
if (plugin.name === moduleCode) {
// Found the module - find its source
const sourcePath = plugin.source ? path.join(repoPath, plugin.source) : repoPath;
const moduleYaml = path.join(sourcePath, 'module.yaml');
if (await fs.pathExists(moduleYaml)) {
return sourcePath;
}
}
}
} catch {
// Skip malformed marketplace.json
}
}
}
}
} catch {
// Cache doesn't exist or is inaccessible
}
return null;
}
// ─── Normalization ────────────────────────────────────────────────────────
/**
* 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 {Object} data - Full marketplace.json data
* @returns {Object} Normalized module info
*/
_normalizeCustomModule(plugin, repoUrl, data) {
return {
code: plugin.name,
name: plugin.name,
displayName: plugin.name,
description: plugin.description || '',
version: plugin.version || null,
author: plugin.author || data.owner || '',
url: repoUrl,
source: plugin.source || null,
type: 'custom',
trustTier: 'unverified',
builtIn: false,
isExternal: true,
};
}
}
module.exports = { CustomModuleManager };

View File

@ -1,10 +1,10 @@
const fs = require('fs-extra'); const fs = require('fs-extra');
const os = require('node:os'); const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const https = require('node:https');
const { execSync } = require('node:child_process'); const { execSync } = require('node:child_process');
const yaml = require('yaml'); const yaml = require('yaml');
const prompts = require('../prompts'); const prompts = require('../prompts');
const { RegistryClient } = require('./registry-client');
const REGISTRY_RAW_URL = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml'; const REGISTRY_RAW_URL = 'https://raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml';
const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml'); const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
@ -17,35 +17,8 @@ const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
* @class ExternalModuleManager * @class ExternalModuleManager
*/ */
class ExternalModuleManager { class ExternalModuleManager {
constructor() {} constructor() {
this._client = new RegistryClient();
/**
* Fetch a URL and return the response body as a string.
* @param {string} url - URL to fetch
* @param {number} timeout - Timeout in ms (default 10s)
* @returns {Promise<string>} Response body
*/
_fetch(url, timeout = 10_000) {
return new Promise((resolve, reject) => {
const req = https
.get(url, { timeout }, (res) => {
// Follow one redirect (GitHub sometimes 301s)
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return this._fetch(res.headers.location, timeout).then(resolve, reject);
}
if (res.statusCode !== 200) {
return reject(new Error(`HTTP ${res.statusCode}`));
}
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => resolve(data));
})
.on('error', reject)
.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out'));
});
});
} }
/** /**
@ -60,7 +33,7 @@ class ExternalModuleManager {
// Try remote registry first // Try remote registry first
try { try {
const content = await this._fetch(REGISTRY_RAW_URL); const content = await this._client.fetch(REGISTRY_RAW_URL);
const config = yaml.parse(content); const config = yaml.parse(content);
if (config?.modules?.length) { if (config?.modules?.length) {
this.cachedModules = config; this.cachedModules = config;

View File

@ -202,6 +202,22 @@ class OfficialModules {
return externalSource; return externalSource;
} }
// Check community modules
const { CommunityModuleManager } = require('./community-manager');
const communityMgr = new CommunityModuleManager();
const communitySource = await communityMgr.findModuleSource(moduleCode, options);
if (communitySource) {
return communitySource;
}
// Check custom modules (from user-provided URLs, already cloned to cache)
const { CustomModuleManager } = require('./custom-module-manager');
const customMgr = new CustomModuleManager();
const customSource = await customMgr.findModuleSourceByCode(moduleCode, options);
if (customSource) {
return customSource;
}
return null; return null;
} }
@ -1131,7 +1147,13 @@ class OfficialModules {
// Collect all answers (static + prompted) // Collect all answers (static + prompted)
let allAnswers = { ...staticAnswers }; let allAnswers = { ...staticAnswers };
if (questions.length > 0) { if (questions.length > 0 && silentMode) {
// In silent mode (quick update), use defaults for new fields instead of prompting
for (const q of questions) {
allAnswers[q.name] = typeof q.default === 'function' ? q.default({}) : q.default;
}
await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module configured with defaults`);
} else if (questions.length > 0) {
// Only show header if we actually have questions // Only show header if we actually have questions
await CLIUtils.displayModuleConfigHeader(moduleName, moduleConfig.header, moduleConfig.subheader); await CLIUtils.displayModuleConfigHeader(moduleName, moduleConfig.header, moduleConfig.subheader);
await prompts.log.message(''); await prompts.log.message('');

View File

@ -0,0 +1,66 @@
const https = require('node:https');
const yaml = require('yaml');
/**
* Shared HTTP client for fetching registry data from GitHub.
* Used by ExternalModuleManager, CommunityModuleManager, and CustomModuleManager.
*/
class RegistryClient {
constructor(options = {}) {
this.timeout = options.timeout || 10_000;
}
/**
* Fetch a URL and return the response body as a string.
* Follows one redirect (GitHub sometimes 301s).
* @param {string} url - URL to fetch
* @param {number} [timeout] - Timeout in ms (overrides default)
* @returns {Promise<string>} Response body
*/
fetch(url, timeout) {
const timeoutMs = timeout || this.timeout;
return new Promise((resolve, reject) => {
const req = https
.get(url, { timeout: timeoutMs }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return this.fetch(res.headers.location, timeoutMs).then(resolve, reject);
}
if (res.statusCode !== 200) {
return reject(new Error(`HTTP ${res.statusCode}`));
}
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => resolve(data));
})
.on('error', reject)
.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out'));
});
});
}
/**
* Fetch a URL and parse the response as YAML.
* @param {string} url - URL to fetch
* @param {number} [timeout] - Timeout in ms
* @returns {Promise<Object>} Parsed YAML content
*/
async fetchYaml(url, timeout) {
const content = await this.fetch(url, timeout);
return yaml.parse(content);
}
/**
* Fetch a URL and parse the response as JSON.
* @param {string} url - URL to fetch
* @param {number} [timeout] - Timeout in ms
* @returns {Promise<Object>} Parsed JSON content
*/
async fetchJson(url, timeout) {
const content = await this.fetch(url, timeout);
return JSON.parse(content);
}
}
module.exports = { RegistryClient };

View File

@ -563,22 +563,58 @@ class UI {
} }
/** /**
* Select all modules (official + community) using grouped multiselect. * Select all modules across three tiers: official, community, and custom URL.
* Core is shown as locked but filtered from the result since it's always installed separately.
* @param {Set} installedModuleIds - Currently installed module IDs * @param {Set} installedModuleIds - Currently installed module IDs
* @returns {Array} Selected module codes (excluding core) * @returns {Array} Selected module codes (excluding core)
*/ */
async selectAllModules(installedModuleIds = new Set()) { async selectAllModules(installedModuleIds = new Set()) {
// Registry is the single source of truth for the module list // Phase 1: Official modules
const officialSelected = await this._selectOfficialModules(installedModuleIds);
// Determine which installed modules are NOT official (community or custom).
// These must be preserved even if the user declines to browse community/custom.
const officialCodes = new Set(officialSelected);
const externalManager = new ExternalModuleManager();
const registryModules = await externalManager.listAvailable();
const officialRegistryCodes = new Set(registryModules.map((m) => m.code));
const installedNonOfficial = [...installedModuleIds].filter((id) => !officialRegistryCodes.has(id));
// Phase 2: Community modules (category drill-down)
// Returns { codes, didBrowse } so we know if the user entered the flow
const communityResult = await this._browseCommunityModules(installedModuleIds);
// Phase 3: Custom URL modules
const customSelected = await this._addCustomUrlModules(installedModuleIds);
// Merge all selections
const allSelected = new Set([...officialSelected, ...communityResult.codes, ...customSelected]);
// Auto-include installed non-official modules that the user didn't get
// a chance to manage (they declined to browse). If they did browse,
// trust their selections - they could have deselected intentionally.
if (!communityResult.didBrowse) {
for (const code of installedNonOfficial) {
allSelected.add(code);
}
}
return [...allSelected];
}
/**
* Select official modules using autocompleteMultiselect.
* Extracted from the original selectAllModules - unchanged behavior.
* @param {Set} installedModuleIds - Currently installed module IDs
* @returns {Array} Selected official module codes
*/
async _selectOfficialModules(installedModuleIds = new Set()) {
const externalManager = new ExternalModuleManager(); const externalManager = new ExternalModuleManager();
const registryModules = await externalManager.listAvailable(); const registryModules = await externalManager.listAvailable();
// Build flat options list with group hints for autocompleteMultiselect
const allOptions = []; const allOptions = [];
const initialValues = []; const initialValues = [];
const lockedValues = ['core']; const lockedValues = ['core'];
// Helper to build module entry with proper sorting and selection
const buildModuleEntry = async (mod) => { const buildModuleEntry = async (mod) => {
const isInstalled = installedModuleIds.has(mod.code); const isInstalled = installedModuleIds.has(mod.code);
const version = await getMarketplaceVersion(mod.code); const version = await getMarketplaceVersion(mod.code);
@ -591,7 +627,6 @@ class UI {
}; };
}; };
// Registry order is display order; core is always locked
for (const mod of registryModules) { for (const mod of registryModules) {
const entry = await buildModuleEntry(mod); const entry = await buildModuleEntry(mod);
allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint }); allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint });
@ -601,7 +636,7 @@ class UI {
} }
const selected = await prompts.autocompleteMultiselect({ const selected = await prompts.autocompleteMultiselect({
message: 'Select modules to install:', message: 'Select official modules to install:',
options: allOptions, options: allOptions,
initialValues: initialValues.length > 0 ? initialValues : undefined, initialValues: initialValues.length > 0 ? initialValues : undefined,
lockedValues, lockedValues,
@ -611,18 +646,261 @@ class UI {
const result = selected ? [...selected] : []; const result = selected ? [...selected] : [];
// Display selected modules as bulleted list
if (result.length > 0) { if (result.length > 0) {
const moduleLines = result.map((moduleId) => { const moduleLines = result.map((moduleId) => {
const opt = allOptions.find((o) => o.value === moduleId); const opt = allOptions.find((o) => o.value === moduleId);
return ` \u2022 ${opt?.label || moduleId}`; return ` \u2022 ${opt?.label || moduleId}`;
}); });
await prompts.log.message('Selected modules:\n' + moduleLines.join('\n')); await prompts.log.message('Selected official modules:\n' + moduleLines.join('\n'));
} }
return result; return result;
} }
/**
* Browse and select community modules using category drill-down.
* Featured/promoted modules appear at the top.
* @param {Set} installedModuleIds - Currently installed module IDs
* @returns {Object} { codes: string[], didBrowse: boolean }
*/
async _browseCommunityModules(installedModuleIds = new Set()) {
const browseCommunity = await prompts.confirm({
message: 'Would you like to browse community modules?',
default: false,
});
if (!browseCommunity) return { codes: [], didBrowse: false };
const { CommunityModuleManager } = require('./modules/community-manager');
const communityMgr = new CommunityModuleManager();
const s = await prompts.spinner();
s.start('Loading community module catalog...');
let categories, featured, allCommunity;
try {
[categories, featured, allCommunity] = await Promise.all([
communityMgr.getCategoryList(),
communityMgr.listFeatured(),
communityMgr.listAll(),
]);
s.stop(`Community catalog loaded (${allCommunity.length} modules)`);
} catch (error) {
s.error('Failed to load community catalog');
await prompts.log.warn(` ${error.message}`);
return { codes: [], didBrowse: false };
}
if (allCommunity.length === 0) {
await prompts.log.info('No community modules are currently available.');
return { codes: [], didBrowse: false };
}
const selectedCodes = new Set();
let browsing = true;
while (browsing) {
const categoryChoices = [];
// Featured section at top
if (featured.length > 0) {
categoryChoices.push({
value: '__featured__',
label: `\u2605 Featured (${featured.length} module${featured.length === 1 ? '' : 's'})`,
});
}
// Categories with module counts
for (const cat of categories) {
categoryChoices.push({
value: cat.slug,
label: `${cat.name} (${cat.moduleCount} module${cat.moduleCount === 1 ? '' : 's'})`,
});
}
// Special actions at bottom
categoryChoices.push(
{ value: '__all__', label: '\u25CE View all community modules' },
{ value: '__search__', label: '\u25CE Search by keyword' },
{ value: '__done__', label: '\u2713 Done browsing' },
);
const selectedCount = selectedCodes.size;
const categoryChoice = await prompts.select({
message: `Browse community modules${selectedCount > 0 ? ` (${selectedCount} selected)` : ''}:`,
choices: categoryChoices,
});
if (categoryChoice === '__done__') {
browsing = false;
continue;
}
let modulesToShow;
switch (categoryChoice) {
case '__featured__': {
modulesToShow = featured;
break;
}
case '__all__': {
modulesToShow = allCommunity;
break;
}
case '__search__': {
const query = await prompts.text({
message: 'Search community modules:',
placeholder: 'e.g., design, testing, game',
});
if (!query || query.trim() === '') continue;
modulesToShow = await communityMgr.searchByKeyword(query.trim());
if (modulesToShow.length === 0) {
await prompts.log.warn('No matching modules found.');
continue;
}
break;
}
default: {
modulesToShow = await communityMgr.listByCategory(categoryChoice);
}
}
// Build options for autocompleteMultiselect
const trustBadge = (tier) => {
if (tier === 'bmad-certified') return '\u2713';
if (tier === 'community-reviewed') return '\u25CB';
return '\u26A0';
};
const options = modulesToShow.map((mod) => {
const versionStr = mod.version ? ` (v${mod.version})` : '';
const badge = trustBadge(mod.trustTier);
return {
label: `${mod.displayName}${versionStr} [${badge}]`,
value: mod.code,
hint: mod.description,
};
});
// Pre-check modules that are already selected or installed
const initialValues = modulesToShow.filter((m) => selectedCodes.has(m.code) || installedModuleIds.has(m.code)).map((m) => m.code);
const selected = await prompts.autocompleteMultiselect({
message: 'Select community modules:',
options,
initialValues: initialValues.length > 0 ? initialValues : undefined,
required: false,
maxItems: Math.min(options.length, 10),
});
// Update accumulated selections: sync with what user selected in this view
const shownCodes = new Set(modulesToShow.map((m) => m.code));
for (const code of shownCodes) {
if (selected && selected.includes(code)) {
selectedCodes.add(code);
} else {
selectedCodes.delete(code);
}
}
}
if (selectedCodes.size > 0) {
const moduleLines = [];
for (const code of selectedCodes) {
const mod = await communityMgr.getModuleByCode(code);
moduleLines.push(` \u2022 ${mod?.displayName || code}`);
}
await prompts.log.message('Selected community modules:\n' + moduleLines.join('\n'));
}
return { codes: [...selectedCodes], didBrowse: true };
}
/**
* Prompt user to install modules from custom GitHub URLs.
* @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?',
default: false,
});
if (!addCustom) return [];
const { CustomModuleManager } = require('./modules/custom-module-manager');
const customMgr = new CustomModuleManager();
const selectedModules = [];
let addMore = true;
while (addMore) {
const url = await prompts.text({
message: 'GitHub repository URL:',
placeholder: 'https://github.com/owner/repo',
validate: (input) => {
if (!input || input.trim() === '') return 'URL is required';
const result = customMgr.validateGitHubUrl(input.trim());
return result.isValid ? undefined : result.error;
},
});
const s = await prompts.spinner();
s.start('Fetching module info...');
try {
const plugins = await customMgr.discoverModules(url.trim());
s.stop('Module info loaded');
await prompts.log.warn(
'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.',
);
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 {
await customMgr.cloneRepo(url.trim());
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;
}
for (const plugin of plugins) {
selectedModules.push(plugin.code);
}
}
} catch (error) {
s.error('Failed to load module info');
await prompts.log.error(` ${error.message}`);
}
addMore = await prompts.confirm({
message: 'Add another custom module?',
default: false,
});
}
if (selectedModules.length > 0) {
await prompts.log.message('Selected custom modules:\n' + selectedModules.map((c) => ` \u2022 ${c}`).join('\n'));
}
return selectedModules;
}
/** /**
* 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
@ -946,6 +1224,7 @@ class UI {
// Group modules by source // Group modules by source
const builtIn = modules.filter((m) => m.source === 'built-in'); const builtIn = modules.filter((m) => m.source === 'built-in');
const external = modules.filter((m) => m.source === 'external'); const external = modules.filter((m) => m.source === 'external');
const community = modules.filter((m) => m.source === 'community');
const custom = modules.filter((m) => m.source === 'custom'); const custom = modules.filter((m) => m.source === 'custom');
const unknown = modules.filter((m) => m.source === 'unknown'); const unknown = modules.filter((m) => m.source === 'unknown');
@ -966,6 +1245,7 @@ class UI {
formatGroup(builtIn, 'Built-in Modules'); formatGroup(builtIn, 'Built-in Modules');
formatGroup(external, 'External Modules (Official)'); formatGroup(external, 'External Modules (Official)');
formatGroup(community, 'Community Modules');
formatGroup(custom, 'Custom Modules'); formatGroup(custom, 'Custom Modules');
formatGroup(unknown, 'Other Modules'); formatGroup(unknown, 'Other Modules');