diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/compile-epic-context.md b/src/bmm-skills/4-implementation/bmad-quick-dev/compile-epic-context.md new file mode 100644 index 000000000..03034770b --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/compile-epic-context.md @@ -0,0 +1,62 @@ +# Compile Epic Context + +**Task** +Given an epic number, the epics file, the planning artifacts directory, and a desired output path, compile a clean, focused, developer-ready context file (`epic--context.md`). + +**Steps** + +1. Read the epics file and extract the target epic's title, goal, and list of stories. +2. Scan the planning artifacts directory for the standard files (PRD, architecture, UX/design, product brief). +3. Pull only the information relevant to this epic. +4. Write the compiled context to the exact output path using the format below. + +## Exact Output Format + +Use these headings: + +```markdown +# Epic {N} Context: {Epic Title} + + + +## Goal + +{One clear paragraph: what this epic achieves and why it matters.} + +## Stories + +- Story X.Y: Brief title only +- ... + +## Requirements & Constraints + +{Relevant functional/non-functional requirements and success criteria for this epic (describe by purpose, not source).} + +## Technical Decisions + +{Key architecture decisions, constraints, patterns, data models, and conventions relevant to this epic.} + +## UX & Interaction Patterns + +{Relevant UX flows, interaction patterns, and design constraints (omit section entirely if nothing relevant).} + +## Cross-Story Dependencies + +{Dependencies between stories in this epic or with other epics/systems (omit if none).} +``` + +## Rules + +- **Scope aggressively.** Include only what a developer working on any story in this epic actually needs. When in doubt, leave it out — the developer can always read the full planning doc. +- **Describe by purpose, not by source.** Write "API responses must include pagination metadata" not "Per PRD section 3.2.1, pagination is required." Planning doc internals will change; the constraint won't. +- **No full copies.** Never quote source documents, section numbers, or paste large blocks verbatim. Always distill. +- **No story-level details.** The story list is for orientation only. Individual story specs handle the details. +- **Nothing derivable from the codebase.** Don't document what a developer can learn by reading the code. +- **Be concise and actionable.** Target 800–1500 tokens total. This file loads into quick-dev's context alongside other material. +- **Never hallucinate content.** If source material doesn't say something, don't invent it. +- **Omit empty sections entirely**, except Goal and Stories, which are always required. + +## Error handling + +- **If the epics file is missing or the target epic is not found:** write nothing and report the problem to the calling agent. Goal and Stories cannot be populated without a usable epics file. +- **If planning artifacts are missing or empty:** still produce the file with Goal and Stories populated from the epics file, and note the gap in the Goal section. Never hallucinate content to fill missing sections. diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md b/src/bmm-skills/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md index 5e04d8545..aae1b3105 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md @@ -41,19 +41,32 @@ Never ask extra questions if you already understand what the user intends. 1. Load context. - List files in `{planning_artifacts}` and `{implementation_artifacts}`. - If you find an unformatted spec or intent file, ingest its contents to form your understanding of the intent. - - Planning artifacts are the output of BMAD phases 1-3. Typical files include: - - **PRD** (`*prd*`) — product requirements and success criteria - - **Architecture** (`*architecture*`) — technical design decisions and constraints - - **UX/Design** (`*ux*`) — user experience and interaction design - - **Epics** (`*epic*`) — feature breakdown into implementable stories - - **Product Brief** (`*brief*`) — project vision and scope - - Scan the listing for files matching these patterns. If any look relevant to the current intent, load them selectively — you don't need all of them, but you need the right constraints and requirements rather than guessing from code alone. - - **Previous story continuity.** Using the intent and loaded context (especially any epics file), infer whether the current work is a story from an epic. Do not rely on filename patterns or regex — reason about the intent, the artifact listing, and epics content together. If the intent is an epic story: - 1. Identify the epic and story number. - 2. Scan `{implementation_artifacts}` for specs from the same epic with `status: done` and a lower story number. - 3. Load the most recent one (highest story number below current). - 4. Extract its **Code Map**, **Design Notes**, **Spec Change Log**, and **task list** as continuity context for step-02 planning. - If no `done` spec is found but an `in-review` spec exists for the same epic with a lower story number, note it to the user and ask whether to load it. If the intent is not an epic story, or no previous spec exists, skip this silently. + - **Determine context strategy.** Using the intent and the artifact listing, infer whether the current work is a story from an epic. Do not rely on filename patterns or regex — reason about the intent, the listing, and any epics file content together. + + **A) Epic story path** — if the intent is clearly an epic story: + + 1. Identify the epic number and (if present) the story number. If you can't identify an epic number, use path B. + + 2. **Check for a valid cached epic context.** Look for `{implementation_artifacts}/epic--context.md` (where `` is the epic number). A file is **valid** when it exists, is non-empty, starts with `# Epic Context:` (with the correct epic number), and no file in `{planning_artifacts}` is newer. + - **If valid:** load it as the primary planning context. Do not load raw planning docs (PRD, architecture, UX, etc.). Skip to step 5. + - **If missing, empty, or invalid:** continue to step 3. + + 3. **Compile epic context.** Produce `{implementation_artifacts}/epic--context.md` by following `./compile-epic-context.md`, in order of preference: + - **Preferred — sub-agent:** spawn a sub-agent with `./compile-epic-context.md` as its prompt. Pass it the epic number, the epics file path, the `{planning_artifacts}` directory, and the output path `{implementation_artifacts}/epic--context.md`. + - **Fallback — inline** (for runtimes without sub-agent support, e.g. Copilot, Codex, local Ollama, older Claude): if your runtime cannot spawn sub-agents, or the spawn fails/times out, read `./compile-epic-context.md` yourself and follow its instructions to produce the same output file. + + 4. **Verify.** After compilation, verify the output file exists, is non-empty, and starts with `# Epic Context:`. If valid, load it. If verification fails, HALT and report the failure. + + 5. **Previous story continuity.** Regardless of which context source succeeded above, scan `{implementation_artifacts}` for specs from the same epic with `status: done` and a lower story number. Load the most recent one (highest story number below current). Extract its **Code Map**, **Design Notes**, **Spec Change Log**, and **task list** as continuity context for step-02 planning. If no `done` spec is found but an `in-review` spec exists for the same epic with a lower story number, note it to the user and ask whether to load it. + + **B) Freeform path** — if the intent is not an epic story: + - Planning artifacts are the output of BMAD phases 1-3. Typical files include: + - **PRD** (`*prd*`) — product requirements and success criteria + - **Architecture** (`*architecture*`) — technical design decisions and constraints + - **UX/Design** (`*ux*`) — user experience and interaction design + - **Epics** (`*epic*`) — feature breakdown into implementable stories + - **Product Brief** (`*brief*`) — project vision and scope + - Scan the listing for files matching these patterns. If any look relevant to the current intent, load them selectively — you don't need all of them, but you need the right constraints and requirements rather than guessing from code alone. 2. Clarify intent. Do not fantasize, do not leave open questions. If you must ask questions, ask them as a numbered list. When the human replies, verify that every single numbered question was answered. If any were ignored, HALT and re-ask only the missing questions before proceeding. Keep looping until intent is clear enough to implement. 3. Version control sanity check. Is the working tree clean? Does the current branch make sense for this intent — considering its name and recent history? If the tree is dirty or the branch is an obvious mismatch, HALT and ask the human before proceeding. If version control is unavailable, skip this check. 4. Multi-goal check (see SCOPE STANDARD). If the intent fails the single-goal criteria: diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/step-02-plan.md b/src/bmm-skills/4-implementation/bmad-quick-dev/step-02-plan.md index 2ab75284c..7385e634a 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/step-02-plan.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/step-02-plan.md @@ -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 `` 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 `` is now locked — only the human can change it. → Step 3. - **E**: Apply changes, then return to CHECKPOINT 1. diff --git a/src/bmm-skills/module-help.csv b/src/bmm-skills/module-help.csv index 816061e90..8b824795f 100644 --- a/src/bmm-skills/module-help.csv +++ b/src/bmm-skills/module-help.csv @@ -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 diff --git a/src/core-skills/bmad-help/SKILL.md b/src/core-skills/bmad-help/SKILL.md index cecb50fae..e829543cf 100644 --- a/src/core-skills/bmad-help/SKILL.md +++ b/src/core-skills/bmad-help/SKILL.md @@ -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 diff --git a/src/core-skills/module-help.csv b/src/core-skills/module-help.csv index 4a70c1bad..efa081372 100644 --- a/src/core-skills/module-help.csv +++ b/src/core-skills/module-help.csv @@ -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,, diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 82094165a..45c3ea19c 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -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 // ============================================================ diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index 60245ce1d..b71e8a05b 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -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) diff --git a/tools/installer/core/manifest.js b/tools/installer/core/manifest.js index f70482f43..d810ec1d3 100644 --- a/tools/installer/core/manifest.js +++ b/tools/installer/core/manifest.js @@ -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 { diff --git a/tools/installer/ide/_config-driven.js b/tools/installer/ide/_config-driven.js index 15791e112..9c7df4bc5 100644 --- a/tools/installer/ide/_config-driven.js +++ b/tools/installer/ide/_config-driven.js @@ -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); + } } } } diff --git a/tools/installer/modules/community-manager.js b/tools/installer/modules/community-manager.js new file mode 100644 index 000000000..0f88cffff --- /dev/null +++ b/tools/installer/modules/community-manager.js @@ -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} 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} 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} 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} 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} 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 }; diff --git a/tools/installer/modules/custom-module-manager.js b/tools/installer/modules/custom-module-manager.js new file mode 100644 index 000000000..18a631a29 --- /dev/null +++ b/tools/installer/modules/custom-module-manager.js @@ -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} 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 }; diff --git a/tools/installer/modules/external-manager.js b/tools/installer/modules/external-manager.js index fceb94e22..f9f9ff06e 100644 --- a/tools/installer/modules/external-manager.js +++ b/tools/installer/modules/external-manager.js @@ -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} 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 }; diff --git a/tools/installer/modules/official-modules.js b/tools/installer/modules/official-modules.js index 0effc86b8..6b9f76059 100644 --- a/tools/installer/modules/official-modules.js +++ b/tools/installer/modules/official-modules.js @@ -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(''); diff --git a/tools/installer/modules/registry-client.js b/tools/installer/modules/registry-client.js new file mode 100644 index 000000000..31965e00c --- /dev/null +++ b/tools/installer/modules/registry-client.js @@ -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} 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} 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} Parsed JSON content + */ + async fetchJson(url, timeout) { + const content = await this.fetch(url, timeout); + return JSON.parse(content); + } +} + +module.exports = { RegistryClient }; diff --git a/tools/installer/external-official-modules.yaml b/tools/installer/modules/registry-fallback.yaml similarity index 71% rename from tools/installer/external-official-modules.yaml rename to tools/installer/modules/registry-fallback.yaml index b62f3dc21..29b2cc07d 100644 --- a/tools/installer/external-official-modules.yaml +++ b/tools/installer/modules/registry-fallback.yaml @@ -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 diff --git a/tools/installer/ui.js b/tools/installer/ui.js index 9b8812f8a..de8783666 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -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');