Compare commits

..

1 Commits

Author SHA1 Message Date
Hisyam 0c1a6a519f
Merge 84a3aa57de into f5030c7084 2026-04-10 15:03:42 +02:00
45 changed files with 1980 additions and 87 deletions

View File

@ -5,66 +5,66 @@ sidebar:
order: 1
---
Fáze analýzy (fáze 1) vám pomůže jasně si promyslet váš produkt, než se pustíte do jeho tvorby. Každý nástroj v této fázi je volitelný, ale úplné vynechání analýzy znamená, že váš PRD je postaven na předpokladech namísto vhledu.
Fáze analýzy (fáze 1) vám pomůže jasně promyslet váš produkt, než se zavážete k jeho budování. Každý nástroj v této fázi je volitelný, ale pokud analýzu úplně vynecháte, váš PRD bude postavený na domněnkách místo na poznatcích.
## Proč analýza před plánováním?
PRD odpovídá na otázku „Co bychom měli postavit a proč?“. Pokud jej nakrmíte vágním myšlením, získáte vágní PRD — a každý navazující dokument tuto vágnost zdědí. Architektura postavená na slabém PRD sází na špatnou techniku. Příběhy odvozené ze slabé architektury opomíjejí okrajové případy. Náklady se zvyšují.
PRD odpovídá na otázku „co bychom měli vybudovat a proč?“. Když do něj vložíte vágní úvahy, dostanete vágní PRD — a každý následující dokument tu vágnost zdědí. Architektura postavená na slabém PRD udělá špatné technické sázky. Stories odvozené ze slabé architektury minou hraniční případy. Náklady se vrší.
Existují analytické nástroje, které vám PRD zostří. Napadají problém z různých úhlů — kreativní průzkum, realita trhu, jasnost zákazníka, proveditelnost — takže v době, kdy sedíte s agentem PM, víte, co a pro koho stavíte.
Analytické nástroje existují proto, aby váš PRD byl ostrý. Útočí na problém z různých úhlů — kreativní průzkum, realita trhu, jasnost ohledně zákazníka, proveditelnost — takže když si sednete s PM agentem, víte, co stavíte a pro koho.
## Nástroje
### Brainstorming
**Co to je.** Zprostředkované tvůrčí sezení s využitím osvědčených technik generování nápadů. AI funguje jako kouč, který z vás tahá nápady prostřednictvím strukturovaných cvičení — negeneruje nápady za vás.
**Co to je.** Facilitované kreativní sezení využívající osvědčené techniky ideace. AI působí jako kouč, který z vás tahá nápady prostřednictvím strukturovaných cvičení — negeneruje nápady za vás.
**Proč je to tady.** Neotřelé nápady potřebují prostor pro rozvoj, než se zakotví v požadavcích. Brainstorming tento prostor vytváří. Je cenný zejména tehdy, když máte problémovou oblast, ale nemáte jasné řešení, nebo když chcete prozkoumat více směrů, než se k něčemu zavážete.
**Proč je tu.** Surové nápady potřebují prostor k rozvoji, než se uzamknou do požadavků. Brainstorming ten prostor vytváří. Je obzvlášť cenný, když máte problémovou doménu, ale žádné jasné řešení, nebo když chcete prozkoumat více směrů, než se zavážete.
**Kdy jej použít.** Máte nejasnou představu o tom, co chcete vytvořit, ale nemáte vykrystalizovaný koncept. Nebo máte koncept, ale chcete ho otestovat pod tlakem oproti alternativám.
**Kdy ho použít.** Máte mlhavou představu o tom, co chcete vybudovat, ale ještě jste ji nevykrystalizovali do konkrétního konceptu. Nebo máte koncept, ale chcete ho otestovat proti alternativám.
Viz [Brainstorming](./brainstorming.md), kde se dozvíte, jak relace fungují.
Viz [Brainstorming](./brainstorming.md) pro podrobnější pohled na průběh sezení.
### Výzkum (trhu, domény, technický)
### Výzkum (tržní, doménový, technický)
**Co to je.** Tři cílené pracovní postupy výzkumu, které zkoumají různé rozměry vašeho nápadu. Výzkum trhu zkoumá konkurenci, trendy a nálady uživatelů. Doménový výzkum vytváří odborné znalosti v daném oboru a terminologii. Technický výzkum hodnotí proveditelnost, možnosti architektury a přístupy k implementaci.
**Co to je.** Tři cílené výzkumné workflow zkoumající různé dimenze vašeho nápadu. Tržní výzkum prozkoumá konkurenci, trendy a nálady uživatelů. Doménový výzkum buduje odborné znalosti a terminologii. Technický výzkum hodnotí proveditelnost, architektonické možnosti a přístupy k implementaci.
**Proč je to tady.** Stavět na předpokladech je nejrychlejší způsob, jak vytvořit něco, co nikdo nepotřebuje. Výzkum zakládá váš koncept na realitě — co již existuje u konkurence, s čím uživatelé skutečně bojují, co je technicky proveditelné a jakým omezením specifickým pro dané odvětví budete čelit.
**Proč je tu.** Budovat na domněnkách je nejrychlejší cesta k vytvoření něčeho, co nikdo nepotřebuje. Výzkum uzemní váš koncept v realitě — jací konkurenti už existují, s čím uživatelé skutečně bojují, co je technicky proveditelné a jaká specifická odvětvová omezení vás čekají.
**Kdy ho použít.** Vstupujete do neznámé oblasti, tušíte, že konkurence existuje, ale nemáte ji zmapovanou, nebo váš koncept závisí na technických možnostech, které nemáte ověřené. Proveďte jeden, dva nebo všechny tři — každý z nich je samostatný.
**Kdy ho použít.** Vstupujete do neznámé domény, tušíte, že existují konkurenti, ale ještě jste je nezmapovali, nebo váš koncept závisí na technických schopnostech, které jste dosud neověřili. Spusťte jeden, dva nebo všechny tři — každý stojí samostatně.
### Product Brief
**Co to je.** Řízené zjišťovací sezení, jehož výsledkem je 12stránkové shrnutí vašeho konceptu produktu. AI funguje jako spolupracující obchodní analytik, který vám pomůže formulovat vizi, cílovou skupinu, nabídku hodnoty a rozsah.
**Co to je.** Řízená discovery session, která vytvoří 12stránkový executive summary vašeho produktového konceptu. AI působí jako kolaborativní Business Analyst a pomáhá vám formulovat vizi, cílovou skupinu, hodnotovou nabídku a rozsah.
**Proč tu je.** Produktový brief je jemnější cestou k plánování. Zachycuje vaši strategickou vizi ve strukturovaném formátu, který se přímo promítá do tvorby PRD. Nejlépe funguje, když jste již o svém konceptu přesvědčeni — znáte zákazníka, problém a zhruba víte, co chcete vytvořit. Brief tyto úvahy uspořádá a vyostří.
**Proč je tu.** Product brief je mírnější cesta do plánování. Zachytí vaši strategickou vizi ve strukturovaném formátu, který přímo vstupuje do tvorby PRD. Funguje nejlépe, když už jste si svým konceptem poměrně jistí — víte, kdo je zákazník, jaký je problém a přibližně co chcete vybudovat. Brief toto myšlení organizuje a zaostří.
**Kdy jej použít.** Váš koncept je relativně jasný a chcete jej efektivně zdokumentovat ještě před vytvořením PRD. Jste si jisti svým směřováním a nepotřebujete své předpoklady agresivně zpochybňovat.
**Kdy ho použít.** Váš koncept je poměrně jasný a chcete ho efektivně zdokumentovat před vytvořením PRD. Jste si jistí směrem a nepotřebujete, aby vaše předpoklady byly agresivně zpochybňovány.
### PRFAQ (Working Backwards)
**Co to je.** Metodika Working Backwards společnosti Amazon upravená jako interaktivní výzva. Napíšete tiskovou zprávu oznamující váš hotový produkt dříve, než existuje jediný řádek kódu, a pak odpovíte na nejtěžší otázky, které by vám zákazníci a zainteresované strany položili. Umělá inteligence funguje jako neúprosný, ale konstruktivní produktový kouč.
**Co to je.** Metodologie Working Backwards od Amazonu adaptovaná jako interaktivní výzva. Napíšete tiskovou zprávu oznamující váš hotový produkt dříve, než existuje jediný řádek kódu, a pak odpovíte na nejtěžší otázky, které by zákazníci a stakeholdeři položili. AI působí jako neúnavný, ale konstruktivní produktový kouč.
**Proč je to tady.** PRFAQ je přísná cesta k plánování. Vynucuje si jasnost v zájmu zákazníka tím, že vás nutí obhájit každé tvrzení. Pokud nedokážete napsat přesvědčivou tiskovou zprávu, produkt není připraven. Pokud odpovědi na časté dotazy zákazníků odhalí nedostatky, jsou to nedostatky, které byste objevili mnohem později — a nákladněji — při implementaci. Hozená rukavice odhalí slabé myšlení v rané fázi, kdy je nejlevnější ho opravit.
**Proč je tu.** PRFAQ je náročnější cesta do plánování. Vynucuje si jasnost zaměřenou na zákazníka tím, že vás nutí obhájit každé tvrzení. Pokud nedokážete napsat přesvědčivou tiskovou zprávu, produkt není připravený. Pokud odpovědi na FAQ odhalí mezery, jsou to mezery, které byste jinak objevili mnohem později — a mnohem dráž — během implementace. Tato výzva odhalí slabé myšlení brzy, když je oprava nejlevnější.
**Kdy ji použít.** Před vyčleněním zdrojů chcete, aby váš koncept prošel zátěžovým testem. Nejste si jisti, zda to uživatele bude skutečně zajímat. Chcete si ověřit, že dokážete formulovat jasnou a obhajitelnou nabídku hodnoty. Nebo si prostě chcete disciplínou Working Backwards zpřesnit své myšlení.
**Kdy ho použít.** Chcete svůj koncept podrobit zátěžovému testu, než vynaložíte zdroje. Nejste si jistí, zda to uživatele skutečně bude zajímat. Chcete ověřit, že dokážete formulovat jasnou, obhajitelnou hodnotovou nabídku. Nebo prostě chcete disciplínu Working Backwards k zaostření svého myšlení.
## Který nástroj bych měl použít?
| Situace | Doporučený nástroj |
| --------- | ---------------- |
| „Mám nejasný nápad, ale nevím, kde začít“ | Brainstorming |
| „Než se rozhodnu, potřebuji pochopit trh“ | Výzkum |
| „Vím, co chci vytvořit, jen to potřebuji zdokumentovat“ | Product Brief |
| „Chci se ujistit, že tento nápad skutečně stojí za vybudování“ | PRFAQ |
| „Chci prozkoumat, pak ověřit a pak zdokumentovat“ | Brainstorming → Výzkum → PRFAQ nebo Brief |
| --------- | ------------------ |
| „Mám vágní nápad, nevím kde začít“ | Brainstorming |
| „Potřebuji pochopit trh, než se rozhodnu“ | Výzkum |
| „Vím, co chci vybudovat, jen to potřebuji zdokumentovat“ | Product Brief |
| „Chci se ujistit, že tento nápad skutečně stojí za budování“ | PRFAQ |
| „Chci prozkoumat, pak ověřit, pak zdokumentovat“ | Brainstorming → Výzkum → PRFAQ nebo Brief |
Product Brief i PRFAQ jsou vstupem pro PRD — vyberte si jeden z nich podle toho, jak moc chcete být nároční. Brief je společným objevováním. PRFAQ je hozená rukavice. Obojí vás dovede ke stejnému cíli; PRFAQ testuje, zda si váš koncept zaslouží se tam dostat.
Product Brief a PRFAQ oba vytvářejí vstup pro PRD — vyberte si podle toho, jak velkou výzvu chcete. Brief je kolaborativní discovery. PRFAQ je náročný zátěžový test. Oba vás dovedou ke stejnému cíli; PRFAQ testuje, zda si váš koncept zaslouží tam dojít.
:::tip[Nejste si jisti?]
Spusťte `bmad-help` a popište svou situaci. Doporučí vám správný výchozí bod na základě toho, co jste již udělali a čeho se snažíte dosáhnout.
:::tip[Nejste si jistí?]
Spusťte `bmad-help` a popište svou situaci. Doporučí vám správný výchozí bod na základě toho, co jste už udělali a čeho chcete dosáhnout.
:::
## Co se stane po analýze?
## Co následuje po analýze?
Výstupy analýzy se přímo promítají do fáze 2 (plánování). Pracovní postup PRD přijímá jako vstupy produktové briefy, dokumenty PRFAQ, výsledky výzkumu a zprávy z brainstormingu — syntetizuje vše, co jste vytvořili, do strukturovaných požadavků. Čím více analýz provedete, tím ostřejší bude vaše PRD.
Výstupy analýzy přímo vstupují do fáze 2 (plánování). Workflow tvorby PRD přijímá product briefy, PRFAQ dokumenty, výzkumná zjištění a záznamy z brainstormingu jako vstupy — syntetizuje vše, co jste vytvořili, do strukturovaných požadavků. Čím důkladnější analýzu provedete, tím ostřejší bude váš PRD.

View File

@ -1,7 +1,6 @@
---
deferred_work_file: '{implementation_artifacts}/deferred-work.md'
spec_file: '' # set at runtime for both routes before leaving this step
story_key: '' # set at runtime to the current story's full sprint-status key (e.g. 3-2-digest-delivery) when the intent is an epic story and sprint-status resolution succeeds
---
# Step 1: Clarify and Route
@ -21,7 +20,7 @@ Before listing artifacts or prompting the user, check whether you already know t
1. Explicit argument
Did the user pass a specific file path, spec name, or clear instruction this message?
- If it points to a file that matches the spec template (has `status` frontmatter with a recognized value: draft, ready-for-dev, in-progress, in-review, or done) → set `spec_file`. Before exiting, run **Story-key resolution** (below). Then **EARLY EXIT** to the appropriate step (step-02 for draft, step-03 for ready/in-progress, step-04 for review). For `done`, ingest as context and proceed to INSTRUCTIONS — do not resume.
- If it points to a file that matches the spec template (has `status` frontmatter with a recognized value: draft, ready-for-dev, in-progress, in-review, or done) → set `spec_file` and **EARLY EXIT** to the appropriate step (step-02 for draft, step-03 for ready/in-progress, step-04 for review). For `done`, ingest as context and proceed to INSTRUCTIONS — do not resume.
- Anything else (intent files, external docs, plans, descriptions) → ingest it as starting intent and proceed to INSTRUCTIONS. Do not attempt to infer a workflow state from it.
2. Recent conversation
@ -30,19 +29,13 @@ Before listing artifacts or prompting the user, check whether you already know t
3. Otherwise — scan artifacts and ask
- Active specs (`draft`, `ready-for-dev`, `in-progress`, `in-review`) in `{implementation_artifacts}`? → List them and HALT. Ask user which to resume (or `[N]` for new).
- If `draft` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT**`./step-02-plan.md` (resume planning from the draft)
- If `ready-for-dev` or `in-progress` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT**`./step-03-implement.md`
- If `in-review` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT**`./step-04-review.md`
- If `draft` selected: Set `spec_file`. **EARLY EXIT**`./step-02-plan.md` (resume planning from the draft)
- If `ready-for-dev` or `in-progress` selected: Set `spec_file`. **EARLY EXIT**`./step-03-implement.md`
- If `in-review` selected: Set `spec_file`. **EARLY EXIT**`./step-04-review.md`
- Unformatted spec or intent file lacking `status` frontmatter? → Suggest treating its contents as the starting intent. Do NOT attempt to infer a state and resume it.
Never ask extra questions if you already understand what the user intends.
### Story-key resolution
This runs on ALL paths (early-exit and INSTRUCTIONS) whenever `spec_file` is set. Determine whether the spec is an epic story — use the spec's filename, frontmatter, and any loaded epics file to identify `{epic_num}` and `{story_num}`. If the spec is not an epic story, skip silently and leave `{story_key}` unset.
If the spec is an epic story and `{sprint_status}` exists: find the `development_status` key matching `{epic_num}-{story_num}` by exact numeric equality on the first two segments (so `1-1` never collides with `1-10`). Exactly one match → set `{story_key}` to that full key. Zero or multiple matches → leave `{story_key}` unset (warn on multiple).
## INSTRUCTIONS
1. Load context.
@ -52,7 +45,7 @@ If the spec is an epic story and `{sprint_status}` exists: find the `development
**A) Epic story path** — if the intent is clearly an epic story:
1. Identify the epic number `{epic_num}` and (if present) the story number `{story_num}`. If you can't identify an epic number, use path B.
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-<N>-context.md` (where `<N>` is the epic number). A file is **valid** when it exists, is non-empty, starts with `# Epic <N> Context:` (with the correct epic number), and no file in `{planning_artifacts}` is newer.
- **If valid:** load it as the primary planning context. Do not load raw planning docs (PRD, architecture, UX, etc.). Skip to step 5.
@ -66,8 +59,6 @@ If the spec is an epic story and `{sprint_status}` exists: find the `development
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.
6. **Resolve `{story_key}`.** If not already set by an earlier early-exit path, run **Story-key resolution** (above) now.
**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

View File

@ -24,8 +24,6 @@ Capture `baseline_commit` (current HEAD, or `NO_VCS` if version control is unava
Change `{spec_file}` status to `in-progress` in the frontmatter before starting implementation.
Follow `./sync-sprint-status.md` with `{target_status}` = `in-progress`.
If `{spec_file}` has a non-empty `context:` list in its frontmatter, load those files before implementation begins. When handing to a sub-agent, include them in the sub-agent prompt so it has access to the referenced context.
Hand `{spec_file}` to a sub-agent/task and let it implement. If no sub-agents are available, implement directly.

View File

@ -48,25 +48,16 @@ Format each stop as framing first, link on the next indented line:
When there is only one concern, omit the bold label — just list the stops directly.
### Mark Spec Done
### Commit and Present
Change `{spec_file}` status to `done` in the frontmatter.
Follow `./sync-sprint-status.md` with `{target_status}` = `review`.
### Commit and Open
1. If version control is available and the tree is dirty, create a local commit with a conventional message derived from the spec title.
2. Open the spec in the user's editor so they can click through the Suggested Review Order:
1. Change `{spec_file}` status to `done` in the frontmatter.
2. If version control is available and the tree is dirty, create a local commit with a conventional message derived from the spec title.
3. Open the spec in the user's editor so they can click through the Suggested Review Order:
- Resolve two absolute paths: (1) the repository root (`git rev-parse --show-toplevel` — returns the worktree root when in a worktree, project root otherwise; if this fails, fall back to the current working directory), (2) `{spec_file}`. Run `code -r "{absolute-root}" "{absolute-spec-file}"` — the root first so VS Code opens in the right context, then the spec file. Always double-quote paths to handle spaces and special characters.
- If `code` is not available (command fails), skip gracefully and tell the user the spec file path instead.
### Display Summary
Display summary of your work to the user, including the commit hash if one was created. Any file paths shown in conversation/terminal output must use CWD-relative format (no leading `/`) with `:line` notation (e.g., `src/path/file.ts:42`) for terminal clickability — the goal is to make paths clickable in terminal emulators. Include:
- A note that the spec is open in their editor (or the file path if it couldn't be opened). Mention that `{spec_file}` now contains a Suggested Review Order.
- **Navigation tip:** "Ctrl+click (Cmd+click on macOS) the links in the Suggested Review Order to jump to each stop."
- Offer to push and/or create a pull request.
4. Display summary of your work to the user, including the commit hash if one was created. Any file paths shown in conversation/terminal output must use CWD-relative format (no leading `/`) with `:line` notation (e.g., `src/path/file.ts:42`) for terminal clickability — the goal is to make paths clickable in terminal emulators. Include:
- A note that the spec is open in their editor (or the file path if it couldn't be opened). Mention that `{spec_file}` now contains a Suggested Review Order.
- **Navigation tip:** "Ctrl+click (Cmd+click on macOS) the links in the Suggested Review Order to jump to each stop."
- Offer to push and/or create a pull request.
Workflow complete.

View File

@ -13,8 +13,6 @@ deferred_work_file: '{implementation_artifacts}/deferred-work.md'
### Implement
Follow `./sync-sprint-status.md` with `{target_status}` = `in-progress`.
Implement the clarified intent directly.
### Review
@ -41,8 +39,6 @@ Write `{spec_file}` using `./spec-template.md`. Fill only these sections — del
2. **Title and Intent**`# {title}` heading and `## Intent` with **Problem** and **Approach** lines. Reuse the summary you already generated for the terminal.
3. **Suggested Review Order** — append after Intent. Build using the same convention as `./step-05-present.md` § "Generate Suggested Review Order" (spec-file-relative links, concern-based ordering, ultra-concise framing).
Follow `./sync-sprint-status.md` with `{target_status}` = `review`.
### Commit
If version control is available and the tree is dirty, create a local commit with a conventional message derived from the intent. If VCS is unavailable, skip.

View File

@ -1,19 +0,0 @@
# Sync Sprint Status
Shared sub-step for updating `sprint-status.yaml` during quick-dev. Called from any route (plan-code-review, one-shot, future routes) with a `{target_status}` parameter.
## Preconditions
Skip this entire file (return to caller) if ANY of:
- `{story_key}` is unset
- `{sprint_status}` does not exist on disk
## Instructions
1. Load the FULL `{sprint_status}` file.
2. Find the `development_status` entry matching `{story_key}`. If not found, warn the user once (`"{story_key} not found in sprint-status; skipping sprint sync"`) and return to caller.
3. **Idempotency check.** If `development_status[{story_key}]` is already at `{target_status}` or a later state (`review` is later than `in-progress`; `done` is later than both), return to caller — no write needed. Never regress a story's status.
4. Set `development_status[{story_key}]` to `{target_status}`.
5. **Epic lift (only when `{target_status}` = `in-progress`).** Derive the parent epic key as `epic-{N}` from the leading numeric segment of `{story_key}` (e.g., `3-2-digest-delivery``epic-3`). If that entry exists and is `backlog`, set it to `in-progress`. Leave it alone otherwise. Skip this sub-step entirely when `{target_status}` is not `in-progress`.
6. Refresh `last_updated` to the current date.
7. Save the file, preserving ALL comments and structure including STATUS DEFINITIONS and WORKFLOW NOTES.

View File

@ -65,7 +65,6 @@ Load and read full config from `{main_config}` and resolve:
- `project_name`, `planning_artifacts`, `implementation_artifacts`, `user_name`
- `communication_language`, `document_output_language`, `user_skill_level`
- `date` as system-generated current datetime
- `sprint_status` = `{implementation_artifacts}/sprint-status.yaml`
- `project_context` = `**/project-context.md` (load if exists)
- CLAUDE.md / memory files (load if exist)

View File

@ -1728,6 +1728,36 @@ async function runTests() {
// ============================================================
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');
@ -1924,6 +1954,25 @@ async function runTests() {
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('');
// ============================================================

View File

@ -1,6 +1,20 @@
const path = require('node:path');
const os = require('node:os');
const prompts = require('./prompts');
const CLIUtils = {
/**
* Get version from package.json
*/
getVersion() {
try {
const packageJson = require(path.join(__dirname, '..', '..', 'package.json'));
return packageJson.version || 'Unknown';
} catch {
return 'Unknown';
}
},
/**
* Display BMAD logo and version using @clack intro + box
*/
@ -38,6 +52,37 @@ const CLIUtils = {
});
},
/**
* Display section header
* @param {string} title - Section title
* @param {string} subtitle - Optional subtitle
*/
async displaySection(title, subtitle = null) {
await prompts.note(subtitle || '', title);
},
/**
* Display info box
* @param {string|Array} content - Content to display
* @param {Object} options - Box options
*/
async displayBox(content, options = {}) {
let text = content;
if (Array.isArray(content)) {
text = content.join('\n\n');
}
const color = await prompts.getColor();
const borderColor = options.borderColor || 'cyan';
const colorMap = { green: color.green, red: color.red, yellow: color.yellow, cyan: color.cyan, blue: color.blue };
const formatBorder = colorMap[borderColor] || color.cyan;
await prompts.box(text, options.title, {
rounded: options.borderStyle === 'round' || options.borderStyle === undefined,
formatBorder,
});
},
/**
* Display module configuration header
* @param {string} moduleName - Module name (fallback if no custom header)
@ -48,6 +93,98 @@ const CLIUtils = {
const title = header || `Configuring ${moduleName.toUpperCase()} Module`;
await prompts.note(subheader || '', title);
},
/**
* Display module with no custom configuration
* @param {string} moduleName - Module name (fallback if no custom header)
* @param {string} header - Custom header from module.yaml
* @param {string} subheader - Custom subheader from module.yaml
*/
async displayModuleNoConfig(moduleName, header = null, subheader = null) {
const title = header || `${moduleName.toUpperCase()} Module - No Custom Configuration`;
await prompts.note(subheader || '', title);
},
/**
* Display step indicator
* @param {number} current - Current step
* @param {number} total - Total steps
* @param {string} description - Step description
*/
async displayStep(current, total, description) {
const progress = `[${current}/${total}]`;
await prompts.log.step(`${progress} ${description}`);
},
/**
* Display completion message
* @param {string} message - Completion message
*/
async displayComplete(message) {
const color = await prompts.getColor();
await prompts.box(`\u2728 ${message}`, 'Complete', {
rounded: true,
formatBorder: color.green,
});
},
/**
* Display error message
* @param {string} message - Error message
*/
async displayError(message) {
const color = await prompts.getColor();
await prompts.box(`\u2717 ${message}`, 'Error', {
rounded: true,
formatBorder: color.red,
});
},
/**
* Format list for display
* @param {Array} items - Items to display
* @param {string} prefix - Item prefix
*/
formatList(items, prefix = '\u2022') {
return items.map((item) => ` ${prefix} ${item}`).join('\n');
},
/**
* Clear previous lines
* @param {number} lines - Number of lines to clear
*/
clearLines(lines) {
for (let i = 0; i < lines; i++) {
process.stdout.moveCursor(0, -1);
process.stdout.clearLine(1);
}
},
/**
* Display module completion message
* @param {string} moduleName - Name of the completed module
* @param {boolean} clearScreen - Whether to clear the screen first (deprecated, always false now)
*/
displayModuleComplete(moduleName, clearScreen = false) {
// No longer clear screen or show boxes - just a simple completion message
// This is deprecated but kept for backwards compatibility
},
/**
* Expand path with ~ expansion
* @param {string} inputPath - Path to expand
* @returns {string} Expanded path
*/
expandPath(inputPath) {
if (!inputPath) return inputPath;
// Expand ~ to home directory
if (inputPath.startsWith('~')) {
return path.join(os.homedir(), inputPath.slice(1));
}
return inputPath;
},
};
module.exports = { CLIUtils };

View File

@ -107,6 +107,117 @@ class Manifest {
return null;
}
/**
* Update existing manifest
* @param {string} bmadDir - Path to bmad directory
* @param {Object} updates - Fields to update
* @param {Array} installedFiles - Updated list of installed files
*/
async update(bmadDir, updates, installedFiles = null) {
const yaml = require('yaml');
const manifest = (await this._readRaw(bmadDir)) || {
installation: {},
modules: [],
ides: [],
};
// Handle module updates
if (updates.modules) {
// If modules is being updated, we need to preserve detailed module info
const existingDetailed = manifest.modules || [];
const incomingNames = updates.modules;
// Build updated modules array
const updatedModules = [];
for (const name of incomingNames) {
const existing = existingDetailed.find((m) => m.name === name);
if (existing) {
// Preserve existing details, update lastUpdated if this module is being updated
updatedModules.push({
...existing,
lastUpdated: new Date().toISOString(),
});
} else {
// New module - add with minimal details
updatedModules.push({
name,
version: null,
installDate: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
source: 'unknown',
});
}
}
manifest.modules = updatedModules;
}
// Merge other updates
if (updates.version) {
manifest.installation.version = updates.version;
}
if (updates.installDate) {
manifest.installation.installDate = updates.installDate;
}
manifest.installation.lastUpdated = new Date().toISOString();
if (updates.ides) {
manifest.ides = updates.ides;
}
// Handle per-module version updates
if (updates.moduleVersions) {
for (const [moduleName, versionInfo] of Object.entries(updates.moduleVersions)) {
const moduleIndex = manifest.modules.findIndex((m) => m.name === moduleName);
if (moduleIndex !== -1) {
manifest.modules[moduleIndex] = {
...manifest.modules[moduleIndex],
...versionInfo,
lastUpdated: new Date().toISOString(),
};
}
}
}
// Handle adding a new module with version info
if (updates.addModule) {
const { name, version, source, npmPackage, repoUrl, localPath } = updates.addModule;
const existing = manifest.modules.find((m) => m.name === name);
if (!existing) {
const entry = {
name,
version: version || null,
installDate: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
source: source || 'external',
npmPackage: npmPackage || null,
repoUrl: repoUrl || null,
};
if (localPath) entry.localPath = localPath;
manifest.modules.push(entry);
}
}
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
await fs.ensureDir(path.dirname(manifestPath));
// Clean the manifest data to remove any non-serializable values
const cleanManifestData = structuredClone(manifest);
const yamlContent = yaml.stringify(cleanManifestData, {
indent: 2,
lineWidth: 0,
sortKeys: false,
});
// Ensure POSIX-compliant final newline
const content = yamlContent.endsWith('\n') ? yamlContent : yamlContent + '\n';
await fs.writeFile(manifestPath, content, 'utf8');
// Return the flattened format for compatibility
return this._flattenManifest(manifest);
}
/**
* Read raw manifest data without flattening
* @param {string} bmadDir - Path to bmad directory
@ -199,6 +310,62 @@ class Manifest {
await this._writeRaw(bmadDir, manifest);
}
/**
* Remove a module from the manifest
* @param {string} bmadDir - Path to bmad directory
* @param {string} moduleName - Module name to remove
*/
async removeModule(bmadDir, moduleName) {
const manifest = await this._readRaw(bmadDir);
if (!manifest || !manifest.modules) {
return;
}
const index = manifest.modules.findIndex((m) => m.name === moduleName);
if (index !== -1) {
manifest.modules.splice(index, 1);
await this._writeRaw(bmadDir, manifest);
}
}
/**
* Update a single module's version info
* @param {string} bmadDir - Path to bmad directory
* @param {string} moduleName - Module name
* @param {Object} versionInfo - Version info to update
*/
async updateModuleVersion(bmadDir, moduleName, versionInfo) {
const manifest = await this._readRaw(bmadDir);
if (!manifest || !manifest.modules) {
return;
}
const index = manifest.modules.findIndex((m) => m.name === moduleName);
if (index !== -1) {
manifest.modules[index] = {
...manifest.modules[index],
...versionInfo,
lastUpdated: new Date().toISOString(),
};
await this._writeRaw(bmadDir, manifest);
}
}
/**
* Get version info for a specific module
* @param {string} bmadDir - Path to bmad directory
* @param {string} moduleName - Module name
* @returns {Object|null} Module version info or null
*/
async getModuleVersion(bmadDir, moduleName) {
const manifest = await this._readRaw(bmadDir);
if (!manifest || !manifest.modules) {
return null;
}
return manifest.modules.find((m) => m.name === moduleName) || null;
}
/**
* Get all modules with their version info
* @param {string} bmadDir - Path to bmad directory
@ -236,6 +403,27 @@ class Manifest {
await fs.writeFile(manifestPath, content, 'utf8');
}
/**
* Add an IDE configuration to the manifest
* @param {string} bmadDir - Path to bmad directory
* @param {string} ideName - IDE name to add
*/
async addIde(bmadDir, ideName) {
const manifest = await this.read(bmadDir);
if (!manifest) {
throw new Error('No manifest found');
}
if (!manifest.ides) {
manifest.ides = [];
}
if (!manifest.ides.includes(ideName)) {
manifest.ides.push(ideName);
await this.update(bmadDir, { ides: manifest.ides });
}
}
/**
* Calculate SHA256 hash of a file
* @param {string} filePath - Path to file
@ -250,6 +438,354 @@ class Manifest {
}
}
/**
* Parse installed files to extract metadata
* @param {Array} installedFiles - List of installed file paths
* @param {string} bmadDir - Path to bmad directory for relative paths
* @returns {Array} Array of file metadata objects
*/
async parseInstalledFiles(installedFiles, bmadDir) {
const fileMetadata = [];
for (const filePath of installedFiles) {
const fileExt = path.extname(filePath).toLowerCase();
// Make path relative to parent of bmad directory, starting with 'bmad/'
const relativePath = 'bmad' + filePath.replace(bmadDir, '').replaceAll('\\', '/');
// Calculate file hash
const hash = await this.calculateFileHash(filePath);
// Handle markdown files - extract XML metadata if present
if (fileExt === '.md') {
try {
if (await fs.pathExists(filePath)) {
const content = await fs.readFile(filePath, 'utf8');
const metadata = this.extractXmlNodeAttributes(content, filePath, relativePath);
if (metadata) {
// Has XML metadata
metadata.hash = hash;
fileMetadata.push(metadata);
} else {
// No XML metadata - still track the file
fileMetadata.push({
file: relativePath,
type: 'md',
name: path.basename(filePath, fileExt),
title: null,
hash: hash,
});
}
}
} catch (error) {
await prompts.log.warn(`Could not parse ${filePath}: ${error.message}`);
}
}
// Handle other file types (CSV, JSON, YAML, etc.)
else {
fileMetadata.push({
file: relativePath,
type: fileExt.slice(1), // Remove the dot
name: path.basename(filePath, fileExt),
title: null,
hash: hash,
});
}
}
return fileMetadata;
}
/**
* Extract XML node attributes from MD file content
* @param {string} content - File content
* @param {string} filePath - File path for context
* @param {string} relativePath - Relative path starting with 'bmad/'
* @returns {Object|null} Extracted metadata or null
*/
extractXmlNodeAttributes(content, filePath, relativePath) {
// Look for XML blocks in code fences
const xmlBlockMatch = content.match(/```xml\s*([\s\S]*?)```/);
if (!xmlBlockMatch) {
return null;
}
const xmlContent = xmlBlockMatch[1];
// Extract root XML node (agent, task, template, etc.)
const rootNodeMatch = xmlContent.match(/<(\w+)([^>]*)>/);
if (!rootNodeMatch) {
return null;
}
const nodeType = rootNodeMatch[1];
const attributes = rootNodeMatch[2];
// Extract name and title attributes (id not needed since we have path)
const nameMatch = attributes.match(/name="([^"]*)"/);
const titleMatch = attributes.match(/title="([^"]*)"/);
return {
file: relativePath,
type: nodeType,
name: nameMatch ? nameMatch[1] : null,
title: titleMatch ? titleMatch[1] : null,
};
}
/**
* Generate CSV manifest content
* @param {Object} data - Manifest data
* @param {Array} fileMetadata - File metadata array
* @param {Object} moduleConfigs - Module configuration data
* @returns {string} CSV content
*/
generateManifestCsv(data, fileMetadata, moduleConfigs = {}) {
const timestamp = new Date().toISOString();
let csv = [];
// Header section
csv.push(
'# BMAD Manifest',
`# Generated: ${timestamp}`,
'',
'## Installation Info',
'Property,Value',
`Version,${data.version}`,
`InstallDate,${data.installDate || timestamp}`,
`LastUpdated,${data.lastUpdated || timestamp}`,
);
if (data.language) {
csv.push(`Language,${data.language}`);
}
csv.push('');
// Modules section
if (data.modules && data.modules.length > 0) {
csv.push('## Modules', 'Name,Version,ShortTitle');
for (const moduleName of data.modules) {
const config = moduleConfigs[moduleName] || {};
csv.push([moduleName, config.version || '', config['short-title'] || ''].map((v) => this.escapeCsv(v)).join(','));
}
csv.push('');
}
// IDEs section
if (data.ides && data.ides.length > 0) {
csv.push('## IDEs', 'IDE');
for (const ide of data.ides) {
csv.push(this.escapeCsv(ide));
}
csv.push('');
}
// Files section - NO LONGER USED
// Files are now tracked in files-manifest.csv by ManifestGenerator
return csv.join('\n');
}
/**
* Parse CSV manifest content back to object
* @param {string} csvContent - CSV content to parse
* @returns {Object} Parsed manifest data
*/
parseManifestCsv(csvContent) {
const result = {
modules: [],
ides: [],
files: [],
};
const lines = csvContent.split('\n');
let section = '';
for (const line_ of lines) {
const line = line_.trim();
// Skip empty lines and comments
if (!line || line.startsWith('#')) {
// Check for section headers
if (line.startsWith('## ')) {
section = line.slice(3).toLowerCase();
}
continue;
}
// Parse based on current section
switch (section) {
case 'installation info': {
// Skip header row
if (line === 'Property,Value') continue;
const [property, ...valueParts] = line.split(',');
const value = this.unescapeCsv(valueParts.join(','));
switch (property) {
// Path no longer stored in manifest
case 'Version': {
result.version = value;
break;
}
case 'InstallDate': {
result.installDate = value;
break;
}
case 'LastUpdated': {
result.lastUpdated = value;
break;
}
case 'Language': {
result.language = value;
break;
}
}
break;
}
case 'modules': {
// Skip header row
if (line === 'Name,Version,ShortTitle') continue;
const parts = this.parseCsvLine(line);
if (parts[0]) {
result.modules.push(parts[0]);
}
break;
}
case 'ides': {
// Skip header row
if (line === 'IDE') continue;
result.ides.push(this.unescapeCsv(line));
break;
}
case 'files': {
// Skip header rows (support both old and new format)
if (line === 'Type,Path,Name,Title' || line === 'Type,Path,Name,Title,Hash') continue;
const parts = this.parseCsvLine(line);
if (parts.length >= 2) {
result.files.push({
type: parts[0] || '',
file: parts[1] || '',
name: parts[2] || null,
title: parts[3] || null,
hash: parts[4] || null, // Hash column (may not exist in old manifests)
});
}
break;
}
// No default
}
}
return result;
}
/**
* Parse a CSV line handling quotes and commas
* @param {string} line - CSV line to parse
* @returns {Array} Array of values
*/
parseCsvLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
// Escaped quote
current += '"';
i++;
} else {
// Toggle quote state
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
// Field separator
result.push(this.unescapeCsv(current));
current = '';
} else {
current += char;
}
}
// Add the last field
result.push(this.unescapeCsv(current));
return result;
}
/**
* Escape CSV special characters
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
escapeCsv(text) {
if (!text) return '';
const str = String(text);
// If contains comma, newline, or quote, wrap in quotes and escape quotes
if (str.includes(',') || str.includes('\n') || str.includes('"')) {
return '"' + str.replaceAll('"', '""') + '"';
}
return str;
}
/**
* Unescape CSV field
* @param {string} text - Text to unescape
* @returns {string} Unescaped text
*/
unescapeCsv(text) {
if (!text) return '';
// Remove surrounding quotes if present
if (text.startsWith('"') && text.endsWith('"')) {
text = text.slice(1, -1);
// Unescape doubled quotes
text = text.replaceAll('""', '"');
}
return text;
}
/**
* Load module configuration files
* @param {Array} modules - List of module names
* @returns {Object} Module configurations indexed by name
*/
async loadModuleConfigs(modules) {
const configs = {};
for (const moduleName of modules) {
// Handle core module differently - it's in src/core-skills not src/modules/core
const configPath =
moduleName === 'core'
? path.join(process.cwd(), 'src', 'core-skills', 'config.yaml')
: path.join(process.cwd(), 'src', 'modules', moduleName, 'config.yaml');
try {
if (await fs.pathExists(configPath)) {
const yaml = require('yaml');
const content = await fs.readFile(configPath, 'utf8');
configs[moduleName] = yaml.parse(content);
}
} catch (error) {
await prompts.log.warn(`Could not load config for module ${moduleName}: ${error.message}`);
}
}
return configs;
}
/**
* Get module version info from source
* @param {string} moduleName - Module name/code
@ -450,6 +986,47 @@ class Manifest {
return updates;
}
/**
* Compare two semantic versions
* @param {string} v1 - First version
* @param {string} v2 - Second version
* @returns {number} -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
*/
compareVersions(v1, v2) {
if (!v1 || !v2) return 0;
const normalize = (v) => {
// Remove leading 'v' if present
v = v.replace(/^v/, '');
// Handle prerelease tags
const parts = v.split('-');
const main = parts[0].split('.');
const prerelease = parts[1];
return { main, prerelease };
};
const n1 = normalize(v1);
const n2 = normalize(v2);
// Compare main version parts
for (let i = 0; i < 3; i++) {
const num1 = parseInt(n1.main[i] || '0', 10);
const num2 = parseInt(n2.main[i] || '0', 10);
if (num1 !== num2) {
return num1 < num2 ? -1 : 1;
}
}
// If main versions are equal, compare prerelease
if (n1.prerelease && n2.prerelease) {
return n1.prerelease < n2.prerelease ? -1 : n1.prerelease > n2.prerelease ? 1 : 0;
}
if (n1.prerelease) return -1; // Prerelease is older than stable
if (n2.prerelease) return 1; // Stable is newer than prerelease
return 0;
}
}
module.exports = { Manifest };

View File

@ -0,0 +1,180 @@
const path = require('node:path');
const fs = require('fs-extra');
const { toColonPath, toDashPath, customAgentColonName, customAgentDashName, BMAD_FOLDER_NAME } = require('./path-utils');
/**
* Generates launcher command files for each agent
*/
class AgentCommandGenerator {
constructor(bmadFolderName = BMAD_FOLDER_NAME) {
this.templatePath = path.join(__dirname, '../templates/agent-command-template.md');
this.bmadFolderName = bmadFolderName;
}
/**
* Collect agent artifacts for IDE installation
* @param {string} bmadDir - BMAD installation directory
* @param {Array} selectedModules - Modules to include
* @returns {Object} Artifacts array with metadata
*/
async collectAgentArtifacts(bmadDir, selectedModules = []) {
const { getAgentsFromBmad } = require('./bmad-artifacts');
// Get agents from INSTALLED bmad/ directory
const agents = await getAgentsFromBmad(bmadDir, selectedModules);
const artifacts = [];
for (const agent of agents) {
const launcherContent = await this.generateLauncherContent(agent);
// Use relativePath if available (for nested agents), otherwise just name with .md
const agentPathInModule = agent.relativePath || `${agent.name}.md`;
// Calculate the relative agent path (e.g., bmm/agents/pm.md)
let agentRelPath = agent.path || '';
// Normalize path separators for cross-platform compatibility
agentRelPath = agentRelPath.replaceAll('\\', '/');
// Remove _bmad/ prefix if present to get relative path from project root
// Handle both absolute paths (/path/to/_bmad/...) and relative paths (_bmad/...)
if (agentRelPath.includes('_bmad/')) {
const parts = agentRelPath.split(/_bmad\//);
if (parts.length > 1) {
agentRelPath = parts.slice(1).join('/');
}
}
artifacts.push({
type: 'agent-launcher',
name: agent.name,
description: agent.description || `${agent.name} agent`,
module: agent.module,
canonicalId: agent.canonicalId || '',
relativePath: path.join(agent.module, 'agents', agentPathInModule), // For command filename
agentPath: agentRelPath, // Relative path to actual agent file
content: launcherContent,
sourcePath: agent.path,
});
}
return {
artifacts,
counts: {
agents: agents.length,
},
};
}
/**
* Generate launcher content for an agent
* @param {Object} agent - Agent metadata
* @returns {string} Launcher file content
*/
async generateLauncherContent(agent) {
// Load the template
const template = await fs.readFile(this.templatePath, 'utf8');
// Replace template variables
// Use relativePath if available (for nested agents), otherwise just name with .md
const agentPathInModule = agent.relativePath || `${agent.name}.md`;
return template
.replaceAll('{{name}}', agent.name)
.replaceAll('{{module}}', agent.module)
.replaceAll('{{path}}', agentPathInModule)
.replaceAll('{{description}}', agent.description || `${agent.name} agent`)
.replaceAll('_bmad', this.bmadFolderName)
.replaceAll('_bmad', '_bmad');
}
/**
* Write agent launcher artifacts to IDE commands directory
* @param {string} baseCommandsDir - Base commands directory for the IDE
* @param {Array} artifacts - Agent launcher artifacts
* @returns {number} Count of launchers written
*/
async writeAgentLaunchers(baseCommandsDir, artifacts) {
let writtenCount = 0;
for (const artifact of artifacts) {
if (artifact.type === 'agent-launcher') {
const moduleAgentsDir = path.join(baseCommandsDir, artifact.module, 'agents');
await fs.ensureDir(moduleAgentsDir);
const launcherPath = path.join(moduleAgentsDir, `${artifact.name}.md`);
await fs.writeFile(launcherPath, artifact.content);
writtenCount++;
}
}
return writtenCount;
}
/**
* Write agent launcher artifacts using underscore format (Windows-compatible)
* Creates flat files like: bmad_bmm_pm.md
*
* @param {string} baseCommandsDir - Base commands directory for the IDE
* @param {Array} artifacts - Agent launcher artifacts
* @returns {number} Count of launchers written
*/
async writeColonArtifacts(baseCommandsDir, artifacts) {
let writtenCount = 0;
for (const artifact of artifacts) {
if (artifact.type === 'agent-launcher') {
// Convert relativePath to underscore format: bmm/agents/pm.md → bmad_bmm_pm.md
const flatName = toColonPath(artifact.relativePath);
const launcherPath = path.join(baseCommandsDir, flatName);
await fs.ensureDir(path.dirname(launcherPath));
await fs.writeFile(launcherPath, artifact.content);
writtenCount++;
}
}
return writtenCount;
}
/**
* Write agent launcher artifacts using dash format (NEW STANDARD)
* Creates flat files like: bmad-agent-bmm-pm.md
*
* The bmad-agent- prefix distinguishes agents from workflows/tasks/tools.
*
* @param {string} baseCommandsDir - Base commands directory for the IDE
* @param {Array} artifacts - Agent launcher artifacts
* @returns {number} Count of launchers written
*/
async writeDashArtifacts(baseCommandsDir, artifacts) {
let writtenCount = 0;
for (const artifact of artifacts) {
if (artifact.type === 'agent-launcher') {
// Convert relativePath to dash format: bmm/agents/pm.md → bmad-agent-bmm-pm.md
const flatName = toDashPath(artifact.relativePath);
const launcherPath = path.join(baseCommandsDir, flatName);
await fs.ensureDir(path.dirname(launcherPath));
await fs.writeFile(launcherPath, artifact.content);
writtenCount++;
}
}
return writtenCount;
}
/**
* Get the custom agent name in underscore format (Windows-compatible)
* @param {string} agentName - Custom agent name
* @returns {string} Underscore-formatted filename
*/
getCustomAgentColonName(agentName) {
return customAgentColonName(agentName);
}
/**
* Get the custom agent name in underscore format (Windows-compatible)
* @param {string} agentName - Custom agent name
* @returns {string} Underscore-formatted filename
*/
getCustomAgentDashName(agentName) {
return customAgentDashName(agentName);
}
}
module.exports = { AgentCommandGenerator };

View File

@ -0,0 +1,208 @@
const path = require('node:path');
const fs = require('fs-extra');
const { loadSkillManifest, getCanonicalId } = require('./skill-manifest');
/**
* Helpers for gathering BMAD agents/tasks from the installed tree.
* Shared by installers that need Claude-style exports.
*
* TODO: Dead code cleanup compiled XML agents are retired.
*
* All agents now use the SKILL.md directory format with bmad-skill-manifest.yaml
* (type: agent). The legacy pipeline below only discovers compiled .md files
* containing <agent> XML tags, which no longer exist. The following are dead:
*
* - getAgentsFromBmad() scans {module}/agents/ for .md files with <agent> tags
* - getAgentsFromDir() recursive helper for the above
* - AgentCommandGenerator (agent-command-generator.js) generates launcher .md files
* that tell the LLM to load a compiled agent .md file
* - agent-command-template.md (templates/) the launcher template with hardcoded
* {module}/agents/{{path}} reference
*
* Agent metadata for agent-manifest.csv is now handled entirely by
* ManifestGenerator.getAgentsFromDirRecursive() in manifest-generator.js,
* which walks the full module tree and finds type:agent directories.
*
* IDE installation of agents is handled by the native skill pipeline
* each agent's SKILL.md directory is installed directly to the IDE's
* skills path, so no launcher intermediary is needed.
*
* Cleanup: remove getAgentsFromBmad, getAgentsFromDir, their exports,
* AgentCommandGenerator, agent-command-template.md, and all call sites
* in IDE installers that invoke collectAgentArtifacts / writeAgentLaunchers /
* writeColonArtifacts / writeDashArtifacts.
* getTasksFromBmad and getTasksFromDir may still be live verify before removing.
*/
async function getAgentsFromBmad(bmadDir, selectedModules = []) {
const agents = [];
// Get core agents
if (await fs.pathExists(path.join(bmadDir, 'core', 'agents'))) {
const coreAgents = await getAgentsFromDir(path.join(bmadDir, 'core', 'agents'), 'core');
agents.push(...coreAgents);
}
// Get module agents
for (const moduleName of selectedModules) {
const agentsPath = path.join(bmadDir, moduleName, 'agents');
if (await fs.pathExists(agentsPath)) {
const moduleAgents = await getAgentsFromDir(agentsPath, moduleName);
agents.push(...moduleAgents);
}
}
// Get standalone agents from bmad/agents/ directory
const standaloneAgentsDir = path.join(bmadDir, 'agents');
if (await fs.pathExists(standaloneAgentsDir)) {
const agentDirs = await fs.readdir(standaloneAgentsDir, { withFileTypes: true });
for (const agentDir of agentDirs) {
if (!agentDir.isDirectory()) continue;
const agentDirPath = path.join(standaloneAgentsDir, agentDir.name);
const agentFiles = await fs.readdir(agentDirPath);
const skillManifest = await loadSkillManifest(agentDirPath);
for (const file of agentFiles) {
if (!file.endsWith('.md')) continue;
if (file.includes('.customize.')) continue;
const filePath = path.join(agentDirPath, file);
const content = await fs.readFile(filePath, 'utf8');
if (content.includes('localskip="true"')) continue;
agents.push({
path: filePath,
name: file.replace('.md', ''),
module: 'standalone', // Mark as standalone agent
canonicalId: getCanonicalId(skillManifest, file),
});
}
}
}
return agents;
}
async function getTasksFromBmad(bmadDir, selectedModules = []) {
const tasks = [];
if (await fs.pathExists(path.join(bmadDir, 'core', 'tasks'))) {
const coreTasks = await getTasksFromDir(path.join(bmadDir, 'core', 'tasks'), 'core');
tasks.push(...coreTasks);
}
for (const moduleName of selectedModules) {
const tasksPath = path.join(bmadDir, moduleName, 'tasks');
if (await fs.pathExists(tasksPath)) {
const moduleTasks = await getTasksFromDir(tasksPath, moduleName);
tasks.push(...moduleTasks);
}
}
return tasks;
}
async function getAgentsFromDir(dirPath, moduleName, relativePath = '') {
const agents = [];
if (!(await fs.pathExists(dirPath))) {
return agents;
}
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const skillManifest = await loadSkillManifest(dirPath);
for (const entry of entries) {
// Skip if entry.name is undefined or not a string
if (!entry.name || typeof entry.name !== 'string') {
continue;
}
const fullPath = path.join(dirPath, entry.name);
const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
// Recurse into subdirectories
const subDirAgents = await getAgentsFromDir(fullPath, moduleName, newRelativePath);
agents.push(...subDirAgents);
} else if (entry.name.endsWith('.md')) {
// Skip README files and other non-agent files
if (entry.name.toLowerCase() === 'readme.md' || entry.name.toLowerCase().startsWith('readme-')) {
continue;
}
if (entry.name.includes('.customize.')) {
continue;
}
const content = await fs.readFile(fullPath, 'utf8');
if (content.includes('localskip="true"')) {
continue;
}
// Only include files that have agent-specific content (compiled agents have <agent> tag)
if (!content.includes('<agent')) {
continue;
}
agents.push({
path: fullPath,
name: entry.name.replace('.md', ''),
module: moduleName,
relativePath: newRelativePath, // Keep the .md extension for the full path
canonicalId: getCanonicalId(skillManifest, entry.name),
});
}
}
return agents;
}
async function getTasksFromDir(dirPath, moduleName) {
const tasks = [];
if (!(await fs.pathExists(dirPath))) {
return tasks;
}
const files = await fs.readdir(dirPath);
const skillManifest = await loadSkillManifest(dirPath);
for (const file of files) {
// Include both .md and .xml task files
if (!file.endsWith('.md') && !file.endsWith('.xml')) {
continue;
}
const filePath = path.join(dirPath, file);
const content = await fs.readFile(filePath, 'utf8');
// Skip internal/engine files (not user-facing tasks)
if (content.includes('internal="true"')) {
continue;
}
// Remove extension to get task name
const ext = file.endsWith('.xml') ? '.xml' : '.md';
tasks.push({
path: filePath,
name: file.replace(ext, ''),
module: moduleName,
canonicalId: getCanonicalId(skillManifest, file),
});
}
return tasks;
}
module.exports = {
getAgentsFromBmad,
getTasksFromBmad,
getAgentsFromDir,
getTasksFromDir,
};

View File

@ -0,0 +1,136 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const { glob } = require('glob');
const { getSourcePath } = require('../../project-root');
async function loadModuleInjectionConfig(handler, moduleName) {
const sourceModulesPath = getSourcePath('modules');
const handlerBaseDir = path.join(sourceModulesPath, moduleName, 'sub-modules', handler);
const configPath = path.join(handlerBaseDir, 'injections.yaml');
if (!(await fs.pathExists(configPath))) {
return null;
}
const configContent = await fs.readFile(configPath, 'utf8');
const config = yaml.parse(configContent) || {};
return {
config,
handlerBaseDir,
configPath,
};
}
function shouldApplyInjection(injection, subagentChoices) {
if (!subagentChoices || subagentChoices.install === 'none') {
return false;
}
if (subagentChoices.install === 'all') {
return true;
}
if (subagentChoices.install === 'selective') {
const selected = subagentChoices.selected || [];
if (injection.requires === 'any' && selected.length > 0) {
return true;
}
if (injection.requires) {
const required = `${injection.requires}.md`;
return selected.includes(required);
}
if (injection.point) {
const selectedNames = selected.map((file) => file.replace('.md', ''));
return selectedNames.some((name) => injection.point.includes(name));
}
}
return false;
}
function filterAgentInstructions(content, selectedFiles) {
if (!selectedFiles || selectedFiles.length === 0) {
return '';
}
const selectedAgents = selectedFiles.map((file) => file.replace('.md', ''));
const lines = content.split('\n');
const filteredLines = [];
for (const line of lines) {
if (line.includes('<llm') || line.includes('</llm>')) {
filteredLines.push(line);
} else if (line.includes('subagent')) {
let shouldInclude = false;
for (const agent of selectedAgents) {
if (line.includes(agent)) {
shouldInclude = true;
break;
}
}
if (shouldInclude) {
filteredLines.push(line);
}
} else if (line.includes('When creating PRDs') || line.includes('ACTIVELY delegate')) {
filteredLines.push(line);
}
}
if (filteredLines.length > 2) {
return filteredLines.join('\n');
}
return '';
}
async function resolveSubagentFiles(handlerBaseDir, subagentConfig, subagentChoices) {
if (!subagentConfig || !subagentConfig.files) {
return [];
}
if (!subagentChoices || subagentChoices.install === 'none') {
return [];
}
let filesToCopy = subagentConfig.files;
if (subagentChoices.install === 'selective') {
filesToCopy = subagentChoices.selected || [];
}
const sourceDir = path.join(handlerBaseDir, subagentConfig.source || '');
const resolved = [];
for (const file of filesToCopy) {
// Use forward slashes for glob pattern (works on both Windows and Unix)
// Convert backslashes to forward slashes for glob compatibility
const normalizedSourceDir = sourceDir.replaceAll('\\', '/');
const pattern = `${normalizedSourceDir}/**/${file}`;
const matches = await glob(pattern);
if (matches.length > 0) {
const absolutePath = matches[0];
resolved.push({
file,
absolutePath,
relativePath: path.relative(sourceDir, absolutePath),
sourceDir,
});
}
}
return resolved;
}
module.exports = {
loadModuleInjectionConfig,
shouldApplyInjection,
filterAgentInstructions,
resolveSubagentFiles,
};

View File

@ -15,6 +15,8 @@
* - standalone/agents/fred.md bmad-agent-standalone-fred.md
*/
// Type segments - agents are included in naming, others are filtered out
const TYPE_SEGMENTS = ['workflows', 'tasks', 'tools'];
const AGENT_SEGMENT = 'agents';
// BMAD installation folder name - centralized constant for all installers
@ -192,6 +194,125 @@ function parseDashName(filename) {
};
}
// ============================================================================
// LEGACY FUNCTIONS (underscore format) - kept for backward compatibility
// ============================================================================
/**
* Convert hierarchical path to flat underscore-separated name (LEGACY)
* @deprecated Use toDashName instead
*/
function toUnderscoreName(module, type, name) {
const isAgent = type === AGENT_SEGMENT;
if (module === 'core') {
return isAgent ? `bmad_agent_${name}.md` : `bmad_${name}.md`;
}
if (module === 'standalone') {
return isAgent ? `bmad_agent_standalone_${name}.md` : `bmad_standalone_${name}.md`;
}
return isAgent ? `bmad_${module}_agent_${name}.md` : `bmad_${module}_${name}.md`;
}
/**
* Convert relative path to flat underscore-separated name (LEGACY)
* @deprecated Use toDashPath instead
*/
function toUnderscorePath(relativePath) {
// Strip common file extensions (same as toDashPath for consistency)
const withoutExt = relativePath.replace(/\.(md|yaml|yml|json|xml|toml)$/i, '');
const parts = withoutExt.split(/[/\\]/);
const module = parts[0];
const type = parts[1];
const name = parts.slice(2).join('_');
return toUnderscoreName(module, type, name);
}
/**
* Create custom agent underscore name (LEGACY)
* @deprecated Use customAgentDashName instead
*/
function customAgentUnderscoreName(agentName) {
return `bmad_custom_${agentName}.md`;
}
/**
* Check if a filename uses underscore format (LEGACY)
* @deprecated Use isDashFormat instead
*/
function isUnderscoreFormat(filename) {
return filename.startsWith('bmad_') && filename.includes('_');
}
/**
* Extract parts from an underscore-formatted filename (LEGACY)
* @deprecated Use parseDashName instead
*/
function parseUnderscoreName(filename) {
const withoutExt = filename.replace('.md', '');
const parts = withoutExt.split('_');
if (parts.length < 2 || parts[0] !== 'bmad') {
return null;
}
const agentIndex = parts.indexOf('agent');
if (agentIndex !== -1) {
if (agentIndex === 1) {
// bmad_agent_... - check for standalone
if (parts.length >= 4 && parts[2] === 'standalone') {
return {
prefix: parts[0],
module: 'standalone',
type: 'agents',
name: parts.slice(3).join('_'),
};
}
return {
prefix: parts[0],
module: 'core',
type: 'agents',
name: parts.slice(agentIndex + 1).join('_'),
};
} else {
return {
prefix: parts[0],
module: parts[1],
type: 'agents',
name: parts.slice(agentIndex + 1).join('_'),
};
}
}
if (parts.length === 2) {
return {
prefix: parts[0],
module: 'core',
type: 'workflows',
name: parts[1],
};
}
// Check for standalone non-agent: bmad_standalone_name
if (parts[1] === 'standalone') {
return {
prefix: parts[0],
module: 'standalone',
type: 'workflows',
name: parts.slice(2).join('_'),
};
}
return {
prefix: parts[0],
module: parts[1],
type: 'workflows',
name: parts.slice(2).join('_'),
};
}
/**
* Resolve the skill name for an artifact.
* Prefers canonicalId from a bmad-skill-manifest.yaml sidecar when available,
@ -207,13 +328,37 @@ function resolveSkillName(artifact) {
return toDashPath(artifact.relativePath);
}
// Backward compatibility aliases (colon format was same as underscore)
const toColonName = toUnderscoreName;
const toColonPath = toUnderscorePath;
const customAgentColonName = customAgentUnderscoreName;
const isColonFormat = isUnderscoreFormat;
const parseColonName = parseUnderscoreName;
module.exports = {
// New standard (dash-based)
toDashName,
toDashPath,
resolveSkillName,
customAgentDashName,
isDashFormat,
parseDashName,
// Legacy (underscore-based) - kept for backward compatibility
toUnderscoreName,
toUnderscorePath,
customAgentUnderscoreName,
isUnderscoreFormat,
parseUnderscoreName,
// Backward compatibility aliases
toColonName,
toColonPath,
customAgentColonName,
isColonFormat,
parseColonName,
TYPE_SEGMENTS,
AGENT_SEGMENT,
BMAD_FOLDER_NAME,
};

View File

@ -0,0 +1,14 @@
---
name: '{{name}}'
description: '{{description}}'
---
You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
<agent-activation CRITICAL="TRUE">
1. LOAD the FULL agent file from {project-root}/_bmad/{{module}}/agents/{{path}}
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
3. Execute ALL activation steps exactly as written in the agent file
4. Follow the agent's persona and menu system precisely
5. Stay in character throughout the session
</agent-activation>

View File

@ -0,0 +1,8 @@
---
name: '{{name}}'
description: '{{description}}'
---
Read the entire workflow file at: {project-root}/_bmad/{{workflow_path}}
Follow all instructions in the workflow file exactly as written.

View File

@ -0,0 +1 @@
default-agent.md

View File

@ -0,0 +1 @@
default-workflow.md

View File

@ -0,0 +1,15 @@
---
name: '{{name}}'
description: '{{description}}'
---
You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
<agent-activation CRITICAL="TRUE">
1. LOAD the FULL agent file from {project-root}/_bmad/{{path}}
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
3. FOLLOW every step in the <activation> section precisely
4. DISPLAY the welcome/greeting as instructed
5. PRESENT the numbered menu
6. WAIT for user input before proceeding
</agent-activation>

View File

@ -0,0 +1,10 @@
---
name: '{{name}}'
description: '{{description}}'
---
# {{name}}
Read the entire task file at: {project-root}/{{bmadFolderName}}/{{path}}
Follow all instructions in the task file exactly as written.

View File

@ -0,0 +1,10 @@
---
name: '{{name}}'
description: '{{description}}'
---
# {{name}}
Read the entire tool file at: {project-root}/{{bmadFolderName}}/{{path}}
Follow all instructions in the tool file exactly as written.

View File

@ -0,0 +1,6 @@
---
name: '{{name}}'
description: '{{description}}'
---
IT IS CRITICAL THAT YOU FOLLOW THIS COMMAND: LOAD the FULL {project-root}/{{bmadFolderName}}/{{path}}, READ its entire contents and follow its directions exactly!

View File

@ -0,0 +1,14 @@
description = "Activates the {{name}} agent from the BMad Method."
prompt = """
CRITICAL: You are now the BMad '{{name}}' agent.
PRE-FLIGHT CHECKLIST:
1. [ ] IMMEDIATE ACTION: Load and parse {project-root}/{{bmadFolderName}}/{{module}}/config.yaml - store ALL config values in memory for use throughout the session.
2. [ ] IMMEDIATE ACTION: Read and internalize the full agent definition at {project-root}/{{bmadFolderName}}/{{path}}.
3. [ ] CONFIRM: The user's name from config is {user_name}.
Only after all checks are complete, greet the user by name and display the menu.
Acknowledge this checklist is complete in your first response.
AGENT DEFINITION: {project-root}/{{bmadFolderName}}/{{path}}
"""

View File

@ -0,0 +1,11 @@
description = "Executes the {{name}} task from the BMAD Method."
prompt = """
Execute the BMAD '{{name}}' task.
TASK INSTRUCTIONS:
1. LOAD the task file from {project-root}/{{bmadFolderName}}/{{path}}
2. READ its entire contents
3. FOLLOW every instruction precisely as specified
TASK FILE: {project-root}/{{bmadFolderName}}/{{path}}
"""

View File

@ -0,0 +1,11 @@
description = "Executes the {{name}} tool from the BMAD Method."
prompt = """
Execute the BMAD '{{name}}' tool.
TOOL INSTRUCTIONS:
1. LOAD the tool file from {project-root}/{{bmadFolderName}}/{{path}}
2. READ its entire contents
3. FOLLOW every instruction precisely as specified
TOOL FILE: {project-root}/{{bmadFolderName}}/{{path}}
"""

View File

@ -0,0 +1,16 @@
description = '{{description}}'
prompt = """
Execute the BMAD '{{name}}' workflow.
CRITICAL: This is a structured YAML workflow. Follow these steps precisely:
1. LOAD the workflow definition from {project-root}/{{bmadFolderName}}/{{workflow_path}}
2. PARSE the YAML structure to understand:
- Workflow phases and steps
- Required inputs and outputs
- Dependencies between steps
3. EXECUTE each step in order
4. VALIDATE outputs before proceeding to next step
WORKFLOW FILE: {project-root}/{{bmadFolderName}}/{{workflow_path}}
"""

View File

@ -0,0 +1,14 @@
description = '{{description}}'
prompt = """
Execute the BMAD '{{name}}' workflow.
CRITICAL: You must load and follow the workflow definition exactly.
WORKFLOW INSTRUCTIONS:
1. LOAD the workflow file from {project-root}/{{bmadFolderName}}/{{workflow_path}}
2. READ its entire contents
3. FOLLOW every step precisely as specified
4. DO NOT skip or modify any steps
WORKFLOW FILE: {project-root}/{{bmadFolderName}}/{{workflow_path}}
"""

View File

@ -0,0 +1,16 @@
---
inclusion: manual
---
# {{name}}
You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
<agent-activation CRITICAL="TRUE">
1. LOAD the FULL agent file from #[[file:{{bmadFolderName}}/{{path}}]]
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
3. FOLLOW every step in the <activation> section precisely
4. DISPLAY the welcome/greeting as instructed
5. PRESENT the numbered menu
6. WAIT for user input before proceeding
</agent-activation>

View File

@ -0,0 +1,9 @@
---
inclusion: manual
---
# {{name}}
Read the entire task file at: #[[file:{{bmadFolderName}}/{{path}}]]
Follow all instructions in the task file exactly as written.

View File

@ -0,0 +1,9 @@
---
inclusion: manual
---
# {{name}}
Read the entire tool file at: #[[file:{{bmadFolderName}}/{{path}}]]
Follow all instructions in the tool file exactly as written.

View File

@ -0,0 +1,7 @@
---
inclusion: manual
---
# {{name}}
IT IS CRITICAL THAT YOU FOLLOW THIS COMMAND: LOAD the FULL #[[file:{{bmadFolderName}}/{{path}}]], READ its entire contents and follow its directions exactly!

View File

@ -0,0 +1,15 @@
---
mode: all
description: '{{description}}'
---
You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
<agent-activation CRITICAL="TRUE">
1. LOAD the FULL agent file from {project-root}/{{bmadFolderName}}/{{path}}
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
3. FOLLOW every step in the <activation> section precisely
4. DISPLAY the welcome/greeting as instructed
5. PRESENT the numbered menu
6. WAIT for user input before proceeding
</agent-activation>

View File

@ -0,0 +1,13 @@
---
description: '{{description}}'
---
Execute the BMAD '{{name}}' task.
TASK INSTRUCTIONS:
1. LOAD the task file from {project-root}/{{bmadFolderName}}/{{path}}
2. READ its entire contents
3. FOLLOW every instruction precisely as specified
TASK FILE: {project-root}/{{bmadFolderName}}/{{path}}

View File

@ -0,0 +1,13 @@
---
description: '{{description}}'
---
Execute the BMAD '{{name}}' tool.
TOOL INSTRUCTIONS:
1. LOAD the tool file from {project-root}/{{bmadFolderName}}/{{path}}
2. READ its entire contents
3. FOLLOW every instruction precisely as specified
TOOL FILE: {project-root}/{{bmadFolderName}}/{{path}}

View File

@ -0,0 +1,16 @@
---
description: '{{description}}'
---
Execute the BMAD '{{name}}' workflow.
CRITICAL: You must load and follow the workflow definition exactly.
WORKFLOW INSTRUCTIONS:
1. LOAD the workflow file from {project-root}/{{bmadFolderName}}/{{path}}
2. READ its entire contents
3. FOLLOW every step precisely as specified
4. DO NOT skip or modify any steps
WORKFLOW FILE: {project-root}/{{bmadFolderName}}/{{path}}

View File

@ -0,0 +1,16 @@
---
description: '{{description}}'
---
Execute the BMAD '{{name}}' workflow.
CRITICAL: You must load and follow the workflow definition exactly.
WORKFLOW INSTRUCTIONS:
1. LOAD the workflow file from {project-root}/{{bmadFolderName}}/{{path}}
2. READ its entire contents
3. FOLLOW every step precisely as specified
4. DO NOT skip or modify any steps
WORKFLOW FILE: {project-root}/{{bmadFolderName}}/{{path}}

View File

@ -0,0 +1,9 @@
# {{name}}
{{description}}
---
Read the entire workflow file at: {project-root}/_bmad/{{workflow_path}}
Follow all instructions in the workflow file exactly as written.

View File

@ -0,0 +1,9 @@
# {{name}}
{{description}}
## Instructions
Read the entire workflow file at: {project-root}/_bmad/{{workflow_path}}
Follow all instructions in the workflow file exactly as written.

View File

@ -0,0 +1,10 @@
---
description: '{{description}}'
auto_execution_mode: "iterate"
---
# {{name}}
Read the entire workflow file at {project-root}/_bmad/{{workflow_path}}
Follow all instructions in the workflow file exactly as written.

View File

@ -155,6 +155,33 @@ class CustomModuleManager {
};
}
/**
* @deprecated Use parseSource() instead. Kept for backward compatibility.
* Parse and validate a GitHub repository URL.
* @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] (strict, no trailing path)
const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
if (httpsMatch) {
return { owner: httpsMatch[1], repo: httpsMatch[2], isValid: true, error: null };
}
// SSH format: git@github.com:owner/repo[.git]
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)' };
}
// ─── Marketplace JSON ─────────────────────────────────────────────────────
/**

View File

@ -109,6 +109,46 @@ class ExternalModuleManager {
return modules.find((m) => m.code === code) || null;
}
/**
* Get module info by key
* @param {string} key - The module key (e.g., 'bmad-creative-intelligence-suite')
* @returns {Object|null} Module info or null if not found
*/
async getModuleByKey(key) {
const modules = await this.listAvailable();
return modules.find((m) => m.key === key) || null;
}
/**
* Check if a module code exists in external modules
* @param {string} code - The module code to check
* @returns {boolean} True if the module exists
*/
async hasModule(code) {
const module = await this.getModuleByCode(code);
return module !== null;
}
/**
* Get the URL for a module by code
* @param {string} code - The module code
* @returns {string|null} The URL or null if not found
*/
async getModuleUrl(code) {
const module = await this.getModuleByCode(code);
return module ? module.url : null;
}
/**
* Get the module definition path for a module by code
* @param {string} code - The module code
* @returns {string|null} The module definition path or null if not found
*/
async getModuleDefinition(code) {
const module = await this.getModuleByCode(code);
return module ? module.moduleDefinition : null;
}
/**
* Get the cache directory for external modules
* @returns {string} Path to the external modules cache directory

View File

@ -12,8 +12,6 @@ class OfficialModules {
// Config collection state (merged from ConfigCollector)
this.collectedConfig = {};
this._existingConfig = null;
// Tracked during interactive config collection so {directory_name}
// placeholder defaults can be resolved in buildQuestion().
this.currentProjectDir = null;
}
@ -502,6 +500,32 @@ class OfficialModules {
}
}
/**
* Find all .md agent files recursively in a directory
* @param {string} dir - Directory to search
* @returns {Array} List of .md agent file paths
*/
async findAgentMdFiles(dir) {
const agentFiles = [];
async function searchDirectory(searchDir) {
const entries = await fs.readdir(searchDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(searchDir, entry.name);
if (entry.isFile() && entry.name.endsWith('.md')) {
agentFiles.push(fullPath);
} else if (entry.isDirectory()) {
await searchDirectory(fullPath);
}
}
}
await searchDirectory(dir);
return agentFiles;
}
/**
* Create directories declared in module.yaml's `directories` key
* This replaces the security-risky module installer pattern with declarative config
@ -675,6 +699,29 @@ class OfficialModules {
return { createdDirs, movedDirs, createdWdsFolders };
}
/**
* Private: Process module configuration
* @param {string} modulePath - Path to installed module
* @param {string} moduleName - Module name
*/
async processModuleConfig(modulePath, moduleName) {
const configPath = path.join(modulePath, 'config.yaml');
if (await fs.pathExists(configPath)) {
try {
let configContent = await fs.readFile(configPath, 'utf8');
// Replace path placeholders
configContent = configContent.replaceAll('{project-root}', `bmad/${moduleName}`);
configContent = configContent.replaceAll('{module}', moduleName);
await fs.writeFile(configPath, configContent, 'utf8');
} catch (error) {
await prompts.log.warn(`Failed to process module config: ${error.message}`);
}
}
}
/**
* Private: Sync module files (preserving user modifications)
* @param {string} sourcePath - Source module path
@ -1044,6 +1091,7 @@ class OfficialModules {
*/
async collectModuleConfigQuick(moduleName, projectDir, silentMode = true) {
this.currentProjectDir = projectDir;
// Load existing config if not already loaded
if (!this._existingConfig) {
await this.loadExistingConfig(projectDir);

View File

@ -50,6 +50,17 @@ class RegistryClient {
const content = await this.fetch(url, timeout);
return yaml.parse(content);
}
/**
* Fetch a URL and parse the response as JSON.
* @param {string} url - URL to fetch
* @param {number} [timeout] - Timeout in ms
* @returns {Promise<Object>} Parsed JSON content
*/
async fetchJson(url, timeout) {
const content = await this.fetch(url, timeout);
return JSON.parse(content);
}
}
module.exports = { RegistryClient };

View File

@ -498,6 +498,26 @@ async function password(options) {
return result;
}
/**
* Group multiple prompts together
* @param {Object} prompts - Object of prompt functions
* @param {Object} [options] - Group options
* @returns {Promise<Object>} Object with all answers
*/
async function group(prompts, options = {}) {
const clack = await getClack();
const result = await clack.group(prompts, {
onCancel: () => {
clack.cancel('Operation cancelled');
process.exit(0);
},
...options,
});
return result;
}
/**
* Run tasks with spinner feedback
* @param {Array} tasks - Array of task objects [{title, task, enabled?}]
@ -558,6 +578,42 @@ async function box(content, title, options) {
clack.box(content, title, options);
}
/**
* Create a progress bar for visualizing task completion
* @param {Object} [options] - Progress options (max, style, etc.)
* @returns {Promise<Object>} Progress controller with start, advance, stop methods
*/
async function progress(options) {
const clack = await getClack();
return clack.progress(options);
}
/**
* Create a task log for displaying scrolling subprocess output
* @param {Object} options - TaskLog options (title, limit, retainLog)
* @returns {Promise<Object>} TaskLog controller with message, success, error methods
*/
async function taskLog(options) {
const clack = await getClack();
return clack.taskLog(options);
}
/**
* File system path prompt with autocomplete
* @param {Object} options - Path options
* @param {string} options.message - The prompt message
* @param {string} [options.initialValue] - Initial path value
* @param {boolean} [options.directory=false] - Only allow directories
* @param {Function} [options.validate] - Validation function
* @returns {Promise<string>} Selected path
*/
async function pathPrompt(options) {
const clack = await getClack();
const result = await clack.path(options);
await handleCancel(result);
return result;
}
/**
* Autocomplete single-select prompt with type-ahead filtering
* @param {Object} options - Autocomplete options
@ -575,6 +631,50 @@ async function autocomplete(options) {
return result;
}
/**
* Key-based instant selection prompt
* @param {Object} options - SelectKey options
* @param {string} options.message - The prompt message
* @param {Array} options.options - Array of choices [{value, label, hint?}]
* @returns {Promise<any>} Selected value
*/
async function selectKey(options) {
const clack = await getClack();
const result = await clack.selectKey(options);
await handleCancel(result);
return result;
}
/**
* Stream messages with dynamic content (for LLMs, generators, etc.)
*/
const stream = {
async info(generator) {
const clack = await getClack();
return clack.stream.info(generator);
},
async success(generator) {
const clack = await getClack();
return clack.stream.success(generator);
},
async step(generator) {
const clack = await getClack();
return clack.stream.step(generator);
},
async warn(generator) {
const clack = await getClack();
return clack.stream.warn(generator);
},
async error(generator) {
const clack = await getClack();
return clack.stream.error(generator);
},
async message(generator, options) {
const clack = await getClack();
return clack.stream.message(generator, options);
},
};
/**
* Get the color utility (picocolors instance from @clack/prompts)
* @returns {Promise<Object>} The color utility (picocolors)
@ -690,14 +790,20 @@ module.exports = {
note,
box,
spinner,
progress,
taskLog,
select,
multiselect,
autocompleteMultiselect,
autocomplete,
selectKey,
confirm,
text,
path: pathPrompt,
password,
group,
tasks,
log,
stream,
prompt,
};