Compare commits
15 Commits
a456da718e
...
647bf62d99
| Author | SHA1 | Date |
|---|---|---|
|
|
647bf62d99 | |
|
|
59b07c33e2 | |
|
|
f9925eb180 | |
|
|
b744408783 | |
|
|
5e038a8ce4 | |
|
|
9bcafdef51 | |
|
|
92498ebb52 | |
|
|
fdfe23fc22 | |
|
|
f32d1d4e8d | |
|
|
f9e7d65cf9 | |
|
|
bfdeef0453 | |
|
|
3ac8736756 | |
|
|
417fc44a98 | |
|
|
4e96a50515 | |
|
|
ad77c8e1c6 |
|
|
@ -24,9 +24,21 @@ deferred_work_file: '{implementation_artifacts}/deferred-work.md'
|
|||
|
||||
### 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.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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-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
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 2.
|
|
|
@ -1,51 +1,70 @@
|
|||
num,category,method_name,description,output_pattern
|
||||
1,collaboration,Stakeholder Round Table,Convene multiple personas to contribute diverse perspectives - essential for requirements gathering and finding balanced solutions across competing interests,perspectives → synthesis → alignment
|
||||
2,collaboration,Expert Panel Review,Assemble domain experts for deep specialized analysis - ideal when technical depth and peer review quality are needed,expert views → consensus → recommendations
|
||||
3,collaboration,Debate Club Showdown,Two personas argue opposing positions while a moderator scores points - great for exploring controversial decisions and finding middle ground,thesis → antithesis → synthesis
|
||||
4,collaboration,User Persona Focus Group,Gather your product's user personas to react to proposals and share frustrations - essential for validating features and discovering unmet needs,reactions → concerns → priorities
|
||||
5,collaboration,Time Traveler Council,Past-you and future-you advise present-you on decisions - powerful for gaining perspective on long-term consequences vs short-term pressures,past wisdom → present choice → future impact
|
||||
6,collaboration,Cross-Functional War Room,Product manager + engineer + designer tackle a problem together - reveals trade-offs between feasibility desirability and viability,constraints → trade-offs → balanced solution
|
||||
7,collaboration,Mentor and Apprentice,Senior expert teaches junior while junior asks naive questions - surfaces hidden assumptions through teaching,explanation → questions → deeper understanding
|
||||
8,collaboration,Good Cop Bad Cop,Supportive persona and critical persona alternate - finds both strengths to build on and weaknesses to address,encouragement → criticism → balanced view
|
||||
9,collaboration,Improv Yes-And,Multiple personas build on each other's ideas without blocking - generates unexpected creative directions through collaborative building,idea → build → build → surprising result
|
||||
10,collaboration,Customer Support Theater,Angry customer and support rep roleplay to find pain points - reveals real user frustrations and service gaps,complaint → investigation → resolution → prevention
|
||||
11,advanced,Tree of Thoughts,Explore multiple reasoning paths simultaneously then evaluate and select the best - perfect for complex problems with multiple valid approaches,paths → evaluation → selection
|
||||
12,advanced,Graph of Thoughts,Model reasoning as an interconnected network of ideas to reveal hidden relationships - ideal for systems thinking and discovering emergent patterns,nodes → connections → patterns
|
||||
13,advanced,Thread of Thought,Maintain coherent reasoning across long contexts by weaving a continuous narrative thread - essential for RAG systems and maintaining consistency,context → thread → synthesis
|
||||
14,advanced,Self-Consistency Validation,Generate multiple independent approaches then compare for consistency - crucial for high-stakes decisions where verification matters,approaches → comparison → consensus
|
||||
15,advanced,Meta-Prompting Analysis,Step back to analyze the approach structure and methodology itself - valuable for optimizing prompts and improving problem-solving,current → analysis → optimization
|
||||
16,advanced,Reasoning via Planning,Build a reasoning tree guided by world models and goal states - excellent for strategic planning and sequential decision-making,model → planning → strategy
|
||||
17,competitive,Red Team vs Blue Team,Adversarial attack-defend analysis to find vulnerabilities - critical for security testing and building robust solutions,defense → attack → hardening
|
||||
18,competitive,Shark Tank Pitch,Entrepreneur pitches to skeptical investors who poke holes - stress-tests business viability and forces clarity on value proposition,pitch → challenges → refinement
|
||||
19,competitive,Code Review Gauntlet,Senior devs with different philosophies review the same code - surfaces style debates and finds consensus on best practices,reviews → debates → standards
|
||||
20,technical,Architecture Decision Records,Multiple architect personas propose and debate architectural choices with explicit trade-offs - ensures decisions are well-reasoned and documented,options → trade-offs → decision → rationale
|
||||
21,technical,Rubber Duck Debugging Evolved,Explain your code to progressively more technical ducks until you find the bug - forces clarity at multiple abstraction levels,simple → detailed → technical → aha
|
||||
22,technical,Algorithm Olympics,Multiple approaches compete on the same problem with benchmarks - finds optimal solution through direct comparison,implementations → benchmarks → winner
|
||||
23,technical,Security Audit Personas,Hacker + defender + auditor examine system from different threat models - comprehensive security review from multiple angles,vulnerabilities → defenses → compliance
|
||||
24,technical,Performance Profiler Panel,Database expert + frontend specialist + DevOps engineer diagnose slowness - finds bottlenecks across the full stack,symptoms → analysis → optimizations
|
||||
25,creative,SCAMPER Method,Apply seven creativity lenses (Substitute/Combine/Adapt/Modify/Put/Eliminate/Reverse) - systematic ideation for product innovation,S→C→A→M→P→E→R
|
||||
26,creative,Reverse Engineering,Work backwards from desired outcome to find implementation path - powerful for goal achievement and understanding endpoints,end state → steps backward → path forward
|
||||
27,creative,What If Scenarios,Explore alternative realities to understand possibilities and implications - valuable for contingency planning and exploration,scenarios → implications → insights
|
||||
28,creative,Random Input Stimulus,Inject unrelated concepts to spark unexpected connections - breaks creative blocks through forced lateral thinking,random word → associations → novel ideas
|
||||
29,creative,Exquisite Corpse Brainstorm,Each persona adds to the idea seeing only the previous contribution - generates surprising combinations through constrained collaboration,contribution → handoff → contribution → surprise
|
||||
30,creative,Genre Mashup,Combine two unrelated domains to find fresh approaches - innovation through unexpected cross-pollination,domain A + domain B → hybrid insights
|
||||
31,research,Literature Review Personas,Optimist researcher + skeptic researcher + synthesizer review sources - balanced assessment of evidence quality,sources → critiques → synthesis
|
||||
32,research,Thesis Defense Simulation,Student defends hypothesis against committee with different concerns - stress-tests research methodology and conclusions,thesis → challenges → defense → refinements
|
||||
33,research,Comparative Analysis Matrix,Multiple analysts evaluate options against weighted criteria - structured decision-making with explicit scoring,options → criteria → scores → recommendation
|
||||
34,risk,Pre-mortem Analysis,Imagine future failure then work backwards to prevent it - powerful technique for risk mitigation before major launches,failure scenario → causes → prevention
|
||||
35,risk,Failure Mode Analysis,Systematically explore how each component could fail - critical for reliability engineering and safety-critical systems,components → failures → prevention
|
||||
36,risk,Challenge from Critical Perspective,Play devil's advocate to stress-test ideas and find weaknesses - essential for overcoming groupthink,assumptions → challenges → strengthening
|
||||
37,risk,Identify Potential Risks,Brainstorm what could go wrong across all categories - fundamental for project planning and deployment preparation,categories → risks → mitigations
|
||||
38,risk,Chaos Monkey Scenarios,Deliberately break things to test resilience and recovery - ensures systems handle failures gracefully,break → observe → harden
|
||||
39,core,First Principles Analysis,Strip away assumptions to rebuild from fundamental truths - breakthrough technique for innovation and solving impossible problems,assumptions → truths → new approach
|
||||
40,core,5 Whys Deep Dive,Repeatedly ask why to drill down to root causes - simple but powerful for understanding failures,why chain → root cause → solution
|
||||
41,core,Socratic Questioning,Use targeted questions to reveal hidden assumptions and guide discovery - excellent for teaching and self-discovery,questions → revelations → understanding
|
||||
42,core,Critique and Refine,Systematic review to identify strengths and weaknesses then improve - standard quality check for drafts,strengths/weaknesses → improvements → refined
|
||||
43,core,Explain Reasoning,Walk through step-by-step thinking to show how conclusions were reached - crucial for transparency,steps → logic → conclusion
|
||||
44,core,Expand or Contract for Audience,Dynamically adjust detail level and technical depth for target audience - matches content to reader capabilities,audience → adjustments → refined content
|
||||
45,learning,Feynman Technique,Explain complex concepts simply as if teaching a child - the ultimate test of true understanding,complex → simple → gaps → mastery
|
||||
46,learning,Active Recall Testing,Test understanding without references to verify true knowledge - essential for identifying gaps,test → gaps → reinforcement
|
||||
47,philosophical,Occam's Razor Application,Find the simplest sufficient explanation by eliminating unnecessary complexity - essential for debugging,options → simplification → selection
|
||||
48,philosophical,Trolley Problem Variations,Explore ethical trade-offs through moral dilemmas - valuable for understanding values and difficult decisions,dilemma → analysis → decision
|
||||
49,retrospective,Hindsight Reflection,Imagine looking back from the future to gain perspective - powerful for project reviews,future view → insights → application
|
||||
50,retrospective,Lessons Learned Extraction,Systematically identify key takeaways and actionable improvements - essential for continuous improvement,experience → lessons → actions
|
||||
1,advanced,Tree of Thoughts,Explore multiple reasoning paths simultaneously then evaluate and select the best - perfect for complex problems with multiple valid approaches,paths → evaluation → selection
|
||||
2,advanced,Graph of Thoughts,Model reasoning as an interconnected network of ideas to reveal hidden relationships - ideal for systems thinking and discovering emergent patterns,nodes → connections → patterns
|
||||
3,advanced,Thread of Thought,Maintain coherent reasoning across long contexts by weaving a continuous narrative thread - essential for RAG systems and maintaining consistency,context → thread → synthesis
|
||||
4,advanced,Self-Consistency Validation,Generate multiple independent approaches then compare for consistency - crucial for high-stakes decisions where verification matters,approaches → comparison → consensus
|
||||
5,advanced,Meta-Prompting Analysis,Step back to analyze the approach structure and methodology itself - valuable for optimizing prompts and improving problem-solving,current → analysis → optimization
|
||||
6,advanced,Reasoning via Planning,Build a reasoning tree guided by world models and goal states - excellent for strategic planning and sequential decision-making,model → planning → strategy
|
||||
7,advanced,Chain-of-Thought Scaffolding,Force explicit intermediate reasoning steps before any conclusion — prevents intuitive leaps that skip flawed logic,premise → step → step → conclusion
|
||||
8,advanced,Few-Shot Exemplar Priming,Provide 2-3 worked examples of the desired reasoning pattern before the real task — aligns output format and depth through demonstration,examples → pattern recognition → application
|
||||
9,collaboration,Stakeholder Round Table,Convene multiple personas to contribute diverse perspectives - essential for requirements gathering and finding balanced solutions across competing interests,perspectives → synthesis → alignment
|
||||
10,collaboration,Expert Panel Review,Assemble domain experts for deep specialized analysis - ideal when technical depth and peer review quality are needed,expert views → consensus → recommendations
|
||||
11,collaboration,Debate Club Showdown,Two personas argue opposing positions while a moderator scores points - great for exploring controversial decisions and finding middle ground,thesis → antithesis → synthesis
|
||||
12,collaboration,User Persona Focus Group,Gather your product's user personas to react to proposals and share frustrations - essential for validating features and discovering unmet needs,reactions → concerns → priorities
|
||||
13,collaboration,Time Traveler Council,Past-you and future-you advise present-you on decisions - powerful for gaining perspective on long-term consequences vs short-term pressures,past wisdom → present choice → future impact
|
||||
14,collaboration,Cross-Functional War Room,Product manager + engineer + designer tackle a problem together - reveals trade-offs between feasibility desirability and viability,constraints → trade-offs → balanced solution
|
||||
15,collaboration,Mentor and Apprentice,Senior expert teaches junior while junior asks naive questions - surfaces hidden assumptions through teaching,explanation → questions → deeper understanding
|
||||
16,collaboration,Good Cop Bad Cop,Supportive persona and critical persona alternate - finds both strengths to build on and weaknesses to address,encouragement → criticism → balanced view
|
||||
17,collaboration,Improv Yes-And,Multiple personas build on each other's ideas without blocking - generates unexpected creative directions through collaborative building,idea → build → build → surprising result
|
||||
18,collaboration,Customer Support Theater,Angry customer and support rep roleplay to find pain points - reveals real user frustrations and service gaps,complaint → investigation → resolution → prevention
|
||||
19,collaboration,Six Thinking Hats,Rotate through six modes (facts - feelings - caution - optimism - creativity - process) to ensure a group covers every angle without crosstalk,white → red → black → yellow → green → blue
|
||||
20,collaboration,Delphi Method,Experts give independent estimates - see anonymized results - then revise — converges on calibrated group judgment while avoiding anchoring bias,independent estimates → reveal → revise → converge
|
||||
21,competitive,Red Team vs Blue Team,Adversarial attack-defend analysis to find vulnerabilities - critical for security testing and building robust solutions,defense → attack → hardening
|
||||
22,competitive,Shark Tank Pitch,Entrepreneur pitches to skeptical investors who poke holes - stress-tests business viability and forces clarity on value proposition,pitch → challenges → refinement
|
||||
23,competitive,Code Review Gauntlet,Senior devs with different philosophies review the same code - surfaces style debates and finds consensus on best practices,reviews → debates → standards
|
||||
24,core,First Principles Analysis,Strip away assumptions to rebuild from fundamental truths - breakthrough technique for innovation and solving impossible problems,assumptions → truths → new approach
|
||||
25,core,5 Whys Deep Dive,Repeatedly ask why to drill down to root causes - simple but powerful for understanding failures,why chain → root cause → solution
|
||||
26,core,Socratic Questioning,Use targeted questions to reveal hidden assumptions and guide discovery - excellent for teaching and self-discovery,questions → revelations → understanding
|
||||
27,core,Critique and Refine,Systematic review to identify strengths and weaknesses then improve - standard quality check for drafts,strengths/weaknesses → improvements → refined
|
||||
28,core,Explain Reasoning,Walk through step-by-step thinking to show how conclusions were reached - crucial for transparency,steps → logic → conclusion
|
||||
29,core,Expand or Contract for Audience,Dynamically adjust detail level and technical depth for target audience - matches content to reader capabilities,audience → adjustments → refined content
|
||||
30,core,Second-Order Thinking,Think beyond immediate consequences to anticipate cascading effects and long-term implications - essential for strategic decisions where first-order solutions create hidden downstream problems,action → consequences → second-order effects → informed choice
|
||||
31,core,Inversion Analysis,Flip the problem by asking what would guarantee failure instead of how to succeed - reveals hidden obstacles and blind spots by approaching challenges from the opposite direction,goal → invert → failure paths → avoidance → solution
|
||||
32,core,Problem Decomposition,Break a complex problem into independent sub-problems - solve each - then reassemble — essential when a task is too large or tangled to tackle whole,whole → parts → solutions → reassembly
|
||||
33,core,Analogy Mapping,Find a well-understood parallel domain and transfer its structure to the current problem — unlocks insight by borrowing proven mental models,source domain → mapping → target insight
|
||||
34,core,Steelmanning,Construct the strongest possible version of an opposing argument before responding — builds credibility and catches blind spots that strawmanning misses,opposing view → strongest form → honest rebuttal
|
||||
35,creative,SCAMPER Method,Apply seven creativity lenses (Substitute/Combine/Adapt/Modify/Put/Eliminate/Reverse) - systematic ideation for product innovation,S→C→A→M→P→E→R
|
||||
36,creative,Reverse Engineering,Work backwards from desired outcome to find implementation path - powerful for goal achievement and understanding endpoints,end state → steps backward → path forward
|
||||
37,creative,What If Scenarios,Explore alternative realities to understand possibilities and implications - valuable for contingency planning and exploration,scenarios → implications → insights
|
||||
38,creative,Random Input Stimulus,Inject unrelated concepts to spark unexpected connections - breaks creative blocks through forced lateral thinking,random word → associations → novel ideas
|
||||
39,creative,Exquisite Corpse Brainstorm,Each persona adds to the idea seeing only the previous contribution - generates surprising combinations through constrained collaboration,contribution → handoff → contribution → surprise
|
||||
40,creative,Genre Mashup,Combine two unrelated domains to find fresh approaches - innovation through unexpected cross-pollination,domain A + domain B → hybrid insights
|
||||
41,creative,Constraint Injection,Deliberately add an artificial limitation (budget - time - technology) to force novel solutions — creativity thrives under pressure,add constraint → forced creativity → remove constraint → evaluate
|
||||
42,creative,Morphological Analysis,List independent parameters of a problem - enumerate options for each - then systematically combine — ensures you don't miss non-obvious configurations,parameters → options grid → combinations → evaluation
|
||||
43,framing,Abstraction Laddering,"Move up (""why?"") for strategic clarity or down (""how?"") for tactical detail — ensures you're solving at the right altitude",concrete ↔ abstract → right level
|
||||
44,framing,Reframe the Question,Challenge whether the stated problem is the real problem — often the question itself is wrong and a better framing unlocks an easy answer,stated problem → reframe → true problem → solution
|
||||
45,framing,Stakeholder Lens Rotation,Serially adopt each stakeholder's world-view to see the same situation differently — reveals whose needs are being overlooked,perspective A → B → C → gaps found
|
||||
46,learning,Feynman Technique,Explain complex concepts simply as if teaching a child - the ultimate test of true understanding,complex → simple → gaps → mastery
|
||||
47,learning,Active Recall Testing,Test understanding without references to verify true knowledge - essential for identifying gaps,test → gaps → reinforcement
|
||||
48,learning,Deliberate Practice Loop,Identify a specific sub-skill - drill it with immediate feedback - adjust - repeat — targeted improvement beats general repetition,isolate → drill → feedback → adjust → repeat
|
||||
49,philosophical,Occam's Razor Application,Find the simplest sufficient explanation by eliminating unnecessary complexity - essential for debugging,options → simplification → selection
|
||||
50,philosophical,Trolley Problem Variations,Explore ethical trade-offs through moral dilemmas - valuable for understanding values and difficult decisions,dilemma → analysis → decision
|
||||
51,research,Literature Review Personas,Optimist researcher + skeptic researcher + synthesizer review sources - balanced assessment of evidence quality,sources → critiques → synthesis
|
||||
52,research,Thesis Defense Simulation,Student defends hypothesis against committee with different concerns - stress-tests research methodology and conclusions,thesis → challenges → defense → refinements
|
||||
53,research,Comparative Analysis Matrix,Multiple analysts evaluate options against weighted criteria - structured decision-making with explicit scoring,options → criteria → scores → recommendation
|
||||
54,research,Source Triangulation,Require at least three independent source types (quantitative - qualitative - expert) before accepting a claim — guards against single-source bias,claim → source A → source B → source C → confidence rating
|
||||
55,retrospective,Hindsight Reflection,Imagine looking back from the future to gain perspective - powerful for project reviews,future view → insights → application
|
||||
56,retrospective,Lessons Learned Extraction,Systematically identify key takeaways and actionable improvements - essential for continuous improvement,experience → lessons → actions
|
||||
57,risk,Pre-mortem Analysis,Imagine future failure then work backwards to prevent it - powerful technique for risk mitigation before major launches,failure scenario → causes → prevention
|
||||
58,risk,Failure Mode Analysis,Systematically explore how each component could fail - critical for reliability engineering and safety-critical systems,components → failures → prevention
|
||||
59,risk,Challenge from Critical Perspective,Play devil's advocate to stress-test ideas and find weaknesses - essential for overcoming groupthink,assumptions → challenges → strengthening
|
||||
60,risk,Identify Potential Risks,Brainstorm what could go wrong across all categories - fundamental for project planning and deployment preparation,categories → risks → mitigations
|
||||
61,risk,Chaos Monkey Scenarios,Deliberately break things to test resilience and recovery - ensures systems handle failures gracefully,break → observe → harden
|
||||
62,risk,Assumption Audit,Explicitly list every assumption underlying a plan - rate each by confidence and impact - then stress-test the weakest — prevents building on shaky foundations,list → rate → stress-test → shore up
|
||||
63,risk,Cascading Failure Simulation,Trace how one component's failure propagates through dependencies — reveals hidden coupling and single points of failure,trigger failure → trace propagation → find amplifiers → decouple
|
||||
64,technical,Architecture Decision Records,Multiple architect personas propose and debate architectural choices with explicit trade-offs - ensures decisions are well-reasoned and documented,options → trade-offs → decision → rationale
|
||||
65,technical,Rubber Duck Debugging Evolved,Explain your code to progressively more technical ducks until you find the bug - forces clarity at multiple abstraction levels,simple → detailed → technical → aha
|
||||
66,technical,Algorithm Olympics,Multiple approaches compete on the same problem with benchmarks - finds optimal solution through direct comparison,implementations → benchmarks → winner
|
||||
67,technical,Security Audit Personas,Hacker + defender + auditor examine system from different threat models - comprehensive security review from multiple angles,vulnerabilities → defenses → compliance
|
||||
68,technical,Performance Profiler Panel,Database expert + frontend specialist + DevOps engineer diagnose slowness - finds bottlenecks across the full stack,symptoms → analysis → optimizations
|
||||
69,technical,Boundary & Edge Case Sweep,Systematically test extremes - zeros - nulls - maximums - and type mismatches — catches the failures that happy-path thinking always misses,inputs → boundaries → edge cases → failures found
|
||||
|
|
|
|||
|
|
|
@ -7,7 +7,7 @@ description: 'Analyzes current state and user query to answer BMad questions or
|
|||
|
||||
## 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
|
||||
|
||||
|
|
@ -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
|
||||
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
|
||||
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
|
||||
|
||||
|
|
@ -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`
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
|
|
@ -70,4 +72,4 @@ For each recommended item, present:
|
|||
- Present all output in `{communication_language}`
|
||||
- Recommend running each skill in a **fresh context window**
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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-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,,
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 2.
|
|
|
@ -1723,6 +1723,258 @@ async function runTests() {
|
|||
|
||||
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
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -969,6 +969,14 @@ class Installer {
|
|||
outputs,
|
||||
] = 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)
|
||||
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));
|
||||
|
||||
// Only update modules that are BOTH installed AND available (we have source for)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -225,13 +225,20 @@ class ConfigDrivenIdeSetup {
|
|||
// Migrate legacy target directories (e.g. .opencode/agent → .opencode/agents)
|
||||
// Legacy dirs are abandoned entirely, so use prefix matching (null removalSet)
|
||||
if (this.installerConfig?.legacy_targets) {
|
||||
if (!options.silent) await prompts.log.message(' Migrating legacy directories...');
|
||||
for (const legacyDir of this.installerConfig.legacy_targets) {
|
||||
if (this.isGlobalPath(legacyDir)) {
|
||||
await this.warnGlobalLegacy(legacyDir, options);
|
||||
} else {
|
||||
await this.cleanupTarget(projectDir, legacyDir, options, null);
|
||||
await this.removeEmptyParents(projectDir, legacyDir);
|
||||
const legacyDirsExist = await Promise.all(
|
||||
this.installerConfig.legacy_targets.map((d) =>
|
||||
this.isGlobalPath(d) ? fs.pathExists(d.replace(/^~/, os.homedir())) : fs.pathExists(path.join(projectDir, d)),
|
||||
),
|
||||
);
|
||||
if (legacyDirsExist.some(Boolean)) {
|
||||
if (!options.silent) await prompts.log.message(' Migrating legacy directories...');
|
||||
for (const legacyDir of this.installerConfig.legacy_targets) {
|
||||
if (this.isGlobalPath(legacyDir)) {
|
||||
await this.warnGlobalLegacy(legacyDir, options);
|
||||
} else {
|
||||
await this.cleanupTarget(projectDir, legacyDir, options, null);
|
||||
await this.removeEmptyParents(projectDir, legacyDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -4,64 +4,98 @@ const path = require('node:path');
|
|||
const { execSync } = require('node:child_process');
|
||||
const yaml = require('yaml');
|
||||
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 FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
|
||||
|
||||
/**
|
||||
* Manages external official modules defined in external-official-modules.yaml
|
||||
* These are modules hosted in external repositories that can be installed
|
||||
* Manages official modules from the remote BMad marketplace registry.
|
||||
* Fetches registry/official.yaml from GitHub; falls back to the bundled
|
||||
* external-official-modules.yaml when the network is unavailable.
|
||||
*
|
||||
* @class ExternalModuleManager
|
||||
*/
|
||||
class ExternalModuleManager {
|
||||
constructor() {
|
||||
this.externalModulesConfigPath = path.join(__dirname, '../external-official-modules.yaml');
|
||||
this.cachedModules = null;
|
||||
this._client = new RegistryClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse the external-official-modules.yaml file
|
||||
* @returns {Object} Parsed YAML content with modules object
|
||||
* Load the official modules registry from GitHub, falling back to the
|
||||
* bundled YAML file if the fetch fails.
|
||||
* @returns {Object} Parsed YAML content with modules array
|
||||
*/
|
||||
async loadExternalModulesConfig() {
|
||||
if (this.cachedModules) {
|
||||
return this.cachedModules;
|
||||
}
|
||||
|
||||
// Try remote registry first
|
||||
try {
|
||||
const content = await fs.readFile(this.externalModulesConfigPath, 'utf8');
|
||||
const content = await this._client.fetch(REGISTRY_RAW_URL);
|
||||
const config = yaml.parse(content);
|
||||
if (config?.modules?.length) {
|
||||
this.cachedModules = config;
|
||||
return config;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to local fallback
|
||||
}
|
||||
|
||||
// Fallback to bundled file
|
||||
try {
|
||||
const content = await fs.readFile(FALLBACK_CONFIG_PATH, 'utf8');
|
||||
const config = yaml.parse(content);
|
||||
this.cachedModules = config;
|
||||
await prompts.log.warn('Could not reach BMad registry; using bundled module list.');
|
||||
return config;
|
||||
} catch (error) {
|
||||
await prompts.log.warn(`Failed to load external modules config: ${error.message}`);
|
||||
return { modules: {} };
|
||||
await prompts.log.warn(`Failed to load modules config: ${error.message}`);
|
||||
return { modules: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available external modules
|
||||
* Normalize a module entry from either the remote registry format
|
||||
* (snake_case, array) or the legacy bundled format (kebab-case, object map).
|
||||
* @param {Object} mod - Raw module config from YAML
|
||||
* @param {string} [key] - Key name (only for legacy map format)
|
||||
* @returns {Object} Normalized module info
|
||||
*/
|
||||
_normalizeModule(mod, key) {
|
||||
return {
|
||||
key: key || mod.name,
|
||||
url: mod.repository || mod.url,
|
||||
moduleDefinition: mod.module_definition || mod['module-definition'],
|
||||
code: mod.code,
|
||||
name: mod.display_name || mod.name,
|
||||
description: mod.description || '',
|
||||
defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
|
||||
type: mod.type || 'bmad-org',
|
||||
npmPackage: mod.npm_package || mod.npmPackage || null,
|
||||
builtIn: mod.built_in === true,
|
||||
isExternal: mod.built_in !== true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available modules from the registry
|
||||
* @returns {Array<Object>} Array of module info objects
|
||||
*/
|
||||
async listAvailable() {
|
||||
const config = await this.loadExternalModulesConfig();
|
||||
const modules = [];
|
||||
|
||||
for (const [key, moduleConfig] of Object.entries(config.modules || {})) {
|
||||
modules.push({
|
||||
key,
|
||||
url: moduleConfig.url,
|
||||
moduleDefinition: moduleConfig['module-definition'],
|
||||
code: moduleConfig.code,
|
||||
name: moduleConfig.name,
|
||||
header: moduleConfig.header,
|
||||
subheader: moduleConfig.subheader,
|
||||
description: moduleConfig.description || '',
|
||||
defaultSelected: moduleConfig.defaultSelected === true,
|
||||
type: moduleConfig.type || 'community', // bmad-org or community
|
||||
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
|
||||
isExternal: true,
|
||||
});
|
||||
// Remote format: modules is an array
|
||||
if (Array.isArray(config.modules)) {
|
||||
return config.modules.map((mod) => this._normalizeModule(mod));
|
||||
}
|
||||
|
||||
// Legacy bundled format: modules is an object map
|
||||
const modules = [];
|
||||
for (const [key, mod] of Object.entries(config.modules || {})) {
|
||||
modules.push(this._normalizeModule(mod, key));
|
||||
}
|
||||
return modules;
|
||||
}
|
||||
|
||||
|
|
@ -81,27 +115,8 @@ class ExternalModuleManager {
|
|||
* @returns {Object|null} Module info or null if not found
|
||||
*/
|
||||
async getModuleByKey(key) {
|
||||
const config = await this.loadExternalModulesConfig();
|
||||
const moduleConfig = config.modules?.[key];
|
||||
|
||||
if (!moduleConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
url: moduleConfig.url,
|
||||
moduleDefinition: moduleConfig['module-definition'],
|
||||
code: moduleConfig.code,
|
||||
name: moduleConfig.name,
|
||||
header: moduleConfig.header,
|
||||
subheader: moduleConfig.subheader,
|
||||
description: moduleConfig.description || '',
|
||||
defaultSelected: moduleConfig.defaultSelected === true,
|
||||
type: moduleConfig.type || 'community', // bmad-org or community
|
||||
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
|
||||
isExternal: true,
|
||||
};
|
||||
const modules = await this.listAvailable();
|
||||
return modules.find((m) => m.key === key) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -154,7 +169,7 @@ class ExternalModuleManager {
|
|||
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||
|
||||
if (!moduleInfo) {
|
||||
throw new Error(`External module '${moduleCode}' not found in external-official-modules.yaml`);
|
||||
throw new Error(`External module '${moduleCode}' not found in the BMad registry`);
|
||||
}
|
||||
|
||||
const cacheDir = this.getExternalCacheDir();
|
||||
|
|
@ -304,7 +319,7 @@ class ExternalModuleManager {
|
|||
async findExternalModuleSource(moduleCode, options = {}) {
|
||||
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||
|
||||
if (!moduleInfo) {
|
||||
if (!moduleInfo || moduleInfo.builtIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -349,6 +364,7 @@ class ExternalModuleManager {
|
|||
// Nothing found: return configured path (preserves old behavior for error messaging)
|
||||
return path.dirname(configuredPath);
|
||||
}
|
||||
cachedModules = null;
|
||||
}
|
||||
|
||||
module.exports = { ExternalModuleManager };
|
||||
|
|
|
|||
|
|
@ -202,6 +202,22 @@ class OfficialModules {
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -1131,7 +1147,13 @@ class OfficialModules {
|
|||
// Collect all answers (static + prompted)
|
||||
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
|
||||
await CLIUtils.displayModuleConfigHeader(moduleName, moduleConfig.header, moduleConfig.subheader);
|
||||
await prompts.log.message('');
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
# This file allows these modules under bmad-code-org to also be installed with the bmad method installer, while
|
||||
# allowing us to keep the source of these projects in separate repos.
|
||||
# Fallback module registry — used only when the BMad Marketplace repo
|
||||
# (bmad-code-org/bmad-plugins-marketplace) is unreachable.
|
||||
# The remote registry/official.yaml is the source of truth.
|
||||
|
||||
modules:
|
||||
bmad-builder:
|
||||
|
|
@ -41,13 +42,3 @@ modules:
|
|||
defaultSelected: false
|
||||
type: bmad-org
|
||||
npmPackage: bmad-method-test-architecture-enterprise
|
||||
|
||||
whiteport-design-studio:
|
||||
url: https://github.com/bmad-code-org/bmad-method-wds-expansion
|
||||
module-definition: src/module.yaml
|
||||
code: wds
|
||||
name: "Whiteport Design Studio (For UX Professionals)"
|
||||
description: "Whiteport Design Studio (For UX Professionals)"
|
||||
defaultSelected: false
|
||||
type: community
|
||||
npmPackage: bmad-method-wds-expansion
|
||||
|
|
@ -563,86 +563,80 @@ class UI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Select all modules (official + community) using grouped multiselect.
|
||||
* Core is shown as locked but filtered from the result since it's always installed separately.
|
||||
* Select all modules across three tiers: official, community, and custom URL.
|
||||
* @param {Set} installedModuleIds - Currently installed module IDs
|
||||
* @returns {Array} Selected module codes (excluding core)
|
||||
*/
|
||||
async selectAllModules(installedModuleIds = new Set()) {
|
||||
const { OfficialModules } = require('./modules/official-modules');
|
||||
const officialModulesSource = new OfficialModules();
|
||||
const { modules: localModules } = await officialModulesSource.listAvailable();
|
||||
// Phase 1: Official modules
|
||||
const officialSelected = await this._selectOfficialModules(installedModuleIds);
|
||||
|
||||
// Get external modules
|
||||
// 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 externalModules = await externalManager.listAvailable();
|
||||
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 registryModules = await externalManager.listAvailable();
|
||||
|
||||
// Build flat options list with group hints for autocompleteMultiselect
|
||||
const allOptions = [];
|
||||
const initialValues = [];
|
||||
const lockedValues = ['core'];
|
||||
|
||||
// Core module is always installed — show it locked at the top
|
||||
const coreVersion = await getMarketplaceVersion('core');
|
||||
const coreLabel = coreVersion ? `BMad Core Module (v${coreVersion})` : 'BMad Core Module';
|
||||
allOptions.push({ label: coreLabel, value: 'core', hint: 'Core configuration and shared resources' });
|
||||
initialValues.push('core');
|
||||
|
||||
// Helper to build module entry with proper sorting and selection
|
||||
const buildModuleEntry = async (mod, value, group) => {
|
||||
const isInstalled = installedModuleIds.has(value);
|
||||
const version = await getMarketplaceVersion(value);
|
||||
const buildModuleEntry = async (mod) => {
|
||||
const isInstalled = installedModuleIds.has(mod.code);
|
||||
const version = await getMarketplaceVersion(mod.code);
|
||||
const label = version ? `${mod.name} (v${version})` : mod.name;
|
||||
return {
|
||||
label,
|
||||
value,
|
||||
hint: mod.description || group,
|
||||
// Pre-select only if already installed (not on fresh install)
|
||||
value: mod.code,
|
||||
hint: mod.description,
|
||||
selected: isInstalled,
|
||||
};
|
||||
};
|
||||
|
||||
// Local modules (BMM, BMB, etc.)
|
||||
const localEntries = [];
|
||||
for (const mod of localModules) {
|
||||
if (mod.id !== 'core') {
|
||||
const entry = await buildModuleEntry(mod, mod.id, 'Local');
|
||||
localEntries.push(entry);
|
||||
if (entry.selected) {
|
||||
initialValues.push(mod.id);
|
||||
}
|
||||
for (const mod of registryModules) {
|
||||
const entry = await buildModuleEntry(mod);
|
||||
allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint });
|
||||
if (entry.selected) {
|
||||
initialValues.push(mod.code);
|
||||
}
|
||||
}
|
||||
allOptions.push(...localEntries.map(({ label, value, hint }) => ({ label, value, hint })));
|
||||
|
||||
// Group 2: BMad Official Modules (type: bmad-org)
|
||||
const officialModules = [];
|
||||
for (const mod of externalModules) {
|
||||
if (mod.type === 'bmad-org') {
|
||||
const entry = await buildModuleEntry(mod, mod.code, 'Official');
|
||||
officialModules.push(entry);
|
||||
if (entry.selected) {
|
||||
initialValues.push(mod.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
allOptions.push(...officialModules.map(({ label, value, hint }) => ({ label, value, hint })));
|
||||
|
||||
// Group 3: Community Modules (type: community)
|
||||
const communityModules = [];
|
||||
for (const mod of externalModules) {
|
||||
if (mod.type === 'community') {
|
||||
const entry = await buildModuleEntry(mod, mod.code, 'Community');
|
||||
communityModules.push(entry);
|
||||
if (entry.selected) {
|
||||
initialValues.push(mod.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
allOptions.push(...communityModules.map(({ label, value, hint }) => ({ label, value, hint })));
|
||||
|
||||
const selected = await prompts.autocompleteMultiselect({
|
||||
message: 'Select modules to install:',
|
||||
message: 'Select official modules to install:',
|
||||
options: allOptions,
|
||||
initialValues: initialValues.length > 0 ? initialValues : undefined,
|
||||
lockedValues,
|
||||
|
|
@ -652,34 +646,275 @@ class UI {
|
|||
|
||||
const result = selected ? [...selected] : [];
|
||||
|
||||
// Display selected modules as bulleted list
|
||||
if (result.length > 0) {
|
||||
const moduleLines = result.map((moduleId) => {
|
||||
const opt = allOptions.find((o) => o.value === 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {Set} installedModuleIds - Already installed module IDs
|
||||
* @returns {Array} Default module codes
|
||||
*/
|
||||
async getDefaultModules(installedModuleIds = new Set()) {
|
||||
const { OfficialModules } = require('./modules/official-modules');
|
||||
const officialModules = new OfficialModules();
|
||||
const { modules: localModules } = await officialModules.listAvailable();
|
||||
const externalManager = new ExternalModuleManager();
|
||||
const registryModules = await externalManager.listAvailable();
|
||||
|
||||
const defaultModules = [];
|
||||
|
||||
// Add default-selected local modules (typically BMM)
|
||||
for (const mod of localModules) {
|
||||
if (mod.defaultSelected === true || installedModuleIds.has(mod.id)) {
|
||||
defaultModules.push(mod.id);
|
||||
for (const mod of registryModules) {
|
||||
if (mod.defaultSelected || installedModuleIds.has(mod.code)) {
|
||||
defaultModules.push(mod.code);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -989,6 +1224,7 @@ class UI {
|
|||
// Group modules by source
|
||||
const builtIn = modules.filter((m) => m.source === 'built-in');
|
||||
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 unknown = modules.filter((m) => m.source === 'unknown');
|
||||
|
||||
|
|
@ -1009,6 +1245,7 @@ class UI {
|
|||
|
||||
formatGroup(builtIn, 'Built-in Modules');
|
||||
formatGroup(external, 'External Modules (Official)');
|
||||
formatGroup(community, 'Community Modules');
|
||||
formatGroup(custom, 'Custom Modules');
|
||||
formatGroup(unknown, 'Other Modules');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue