diff --git a/skills/bmad-module-builder/references/create-module.md b/skills/bmad-module-builder/references/create-module.md index 0905caf..37404b4 100644 --- a/skills/bmad-module-builder/references/create-module.md +++ b/skills/bmad-module-builder/references/create-module.md @@ -28,24 +28,13 @@ For each skill, understand: Skills without a `customize.toml` are fine — older skills or ones that predate customization support. Their metadata comes from the SKILL.md body (title heading, description frontmatter) as a fallback. -**Single skill detection:** If the folder contains exactly one skill (one directory with a SKILL.md), or the user provided a direct path to a single skill, note this as a **standalone module candidate**. +**Single skill detection:** Note whether the folder contains exactly one skill (one directory with a SKILL.md), or the user provided a direct path to a single skill. This affects only the optional direct-download bundling step later, not the default scaffolding. -### 1.5. Confirm Approach +### 1.5. Confirm Scope -**If single skill detected:** Present the standalone option: +Confirm with the user what you found: "I found {N} skill(s): {list}. I'll write `module.yaml`, `module-help.csv`, and `.claude-plugin/marketplace.json` at the module root. The BMad installer reads these directly. Sound good?" -> "I found one skill: **{skill-name}**. For single-skill modules, I recommend the **standalone self-registering** approach — instead of generating a separate setup skill, the registration logic is built directly into this skill via a setup reference file. When users pass `setup` or `configure` as an argument, the skill handles its own module registration. -> -> This means: -> - No separate `-setup` skill to maintain -> - Simpler distribution (single skill folder + marketplace.json) -> - Users install by adding the skill and running it with `setup` -> -> Shall I proceed with the standalone approach, or would you prefer a separate setup skill?" - -**If multiple skills detected:** Confirm with the user: "I found {N} skills: {list}. I'll generate a dedicated `-setup` skill to handle module registration for all of them. Sound good?" - -If the user overrides the recommendation (e.g., wants a setup skill for a single skill, or standalone for multiple), respect their choice. +This is the **default layout for every module**, single-skill or multi-skill. Direct-download bundling (a setup skill or self-registering single skill that duplicates the manifests inside a skill so users can install without running the BMad installer) is offered later as an optional add-on in step 7.5. Don't ask about bundling yet; offer it after the root scaffolding is confirmed. ### 2. Gather Module Identity @@ -148,9 +137,9 @@ Ask the user about requirements beyond configuration: - **UI or web app** — Does the module include a dashboard, visualization layer, or interactive web interface? If the setup skill needs to install or configure a web app, scaffold UI files, or set up a dev server, capture those requirements. - **Additional setup actions** — Beyond config collection: scaffolding project directories, generating starter files, configuring external services, setting up webhooks, etc. -If any of these apply, let the user know the scaffolded setup skill will need manual customization after creation to add these capabilities. Document what needs to be added so the user has a clear checklist. +External dependency checks belong inside the skills that need them, not in installer-time hooks. A skill should detect missing tools at runtime and guide the user, since direct-download installs skip the installer entirely. See [Skills Must Be Self-Runnable](../../../docs/explanation/skill-authoring-best-practices.md#skills-must-be-self-runnable). -**Standalone modules:** External dependency checks would need to be handled within the skill itself (in the module-setup.md reference or the main SKILL.md). Note any needed checks for the user to add manually. +If the user opts into setup-skill bundling later (step 7.5), the bundled setup skill can also include installer-time checks as a convenience. Note these for the user to add manually after scaffolding. ### 6. Generate and Confirm @@ -163,31 +152,82 @@ Present the complete module.yaml and module-help.csv content for the user to rev Iterate until the user confirms everything is correct. -### 7. Scaffold +### 7. Scaffold the Module Root (Always) + +Write the confirmed manifests directly to the **module root**: the skills folder the user pointed at (or, for a single-skill input, the parent of the skill folder). This is the canonical layout the BMad installer reads. Always do this, regardless of whether the user opts into bundling later. + +Use the Write tool to create three files: + +1. **`{module-root}/module.yaml`** — the confirmed module definition from step 3.5/4/6 +2. **`{module-root}/module-help.csv`** — the confirmed help CSV from step 3 +3. **`{module-root}/../.claude-plugin/marketplace.json`** — the distribution manifest (parent of the module root, not inside it) + +For the marketplace.json, use this template, filling in values from the module identity collected in step 2: + +```json +{ + "name": "{module-code}", + "owner": { "name": "" }, + "license": "", + "homepage": "", + "repository": "", + "keywords": ["bmad"], + "plugins": [ + { + "name": "{module-code}", + "source": "./", + "description": "{module-description}", + "version": "{module-version}", + "author": { "name": "" }, + "skills": [ + "./{module-folder-basename}/{skill-1}", + "./{module-folder-basename}/{skill-2}" + ] + } + ] +} +``` -#### Multi-skill modules (setup skill approach) +Adjust the `skills` paths to match the actual directory layout. If `marketplace.json` already exists, **merge into it rather than overwriting** (preserve existing owner/license/homepage/repository fields the user has already filled in; replace only this module's plugin entry). -Write the confirmed module.yaml and module-help.csv content to temporary files at `{bmad_builder_reports}/{module-code}-temp-module.yaml` and `{bmad_builder_reports}/{module-code}-temp-help.csv`. Run the scaffold script: +Show the user the three file paths and confirm before writing. + +### 7.5. Optional: Direct-Download Bundling + +After the root scaffolding is in place, ask the user: + +> "Your module is now installable via the BMad installer (`npx bmad-method install`). Do you also want users to be able to install it by direct download, without running the installer? If yes, I'll bundle the registration files inside a {bundling-target} so the user can trigger registration manually." + +Where `{bundling-target}` is: + +- **For multi-skill modules**: a dedicated `{code}-setup/` skill the user can run to register the module +- **For single-skill modules**: the existing skill itself (self-registering on first run or via `setup`/`configure`) + +If the user declines, skip to step 8. If they accept, run the appropriate scaffold below. Bundling is purely **additive**: the root manifests stay in place and remain canonical; the bundled copies exist for the manual-install path. + +#### Multi-skill bundling: setup skill + +Run the scaffold script. It reads the root manifests you already wrote and duplicates them into the setup skill's `assets/` folder, alongside the merge scripts: ```bash python3 ./scripts/scaffold-setup-skill.py \ - --target-dir "{skills-folder}" \ + --target-dir "{module-root}" \ --module-code "{code}" \ --module-name "{name}" \ - --module-yaml "{bmad_builder_reports}/{module-code}-temp-module.yaml" \ - --module-csv "{bmad_builder_reports}/{module-code}-temp-help.csv" + --module-yaml "{module-root}/module.yaml" \ + --module-csv "{module-root}/module-help.csv" ``` -This creates `{code}-setup/` in the user's skills folder containing: +This creates `{code}-setup/` in the module root containing: - `./SKILL.md` — Generic setup skill with module-specific frontmatter - `./scripts/` — merge-config.py, merge-help-csv.py, cleanup-legacy.py -- `./assets/module.yaml` — Generated module definition -- `./assets/module-help.csv` — Generated capability registry +- `./assets/module.yaml` — Duplicate of the root module.yaml +- `./assets/module-help.csv` — Duplicate of the root module-help.csv -#### Standalone modules (self-registering approach) +#### Single-skill bundling: self-registering skill -Write the confirmed module.yaml and module-help.csv directly to the skill's `assets/` folder (create the folder if needed). Then run the standalone scaffold script to copy the template infrastructure: +Copy the root `module.yaml` and `module-help.csv` into the skill's `assets/` folder (create the folder if needed), then run the standalone scaffold script to copy the registration reference and merge scripts: ```bash python3 ./scripts/scaffold-standalone-module.py \ @@ -198,10 +238,9 @@ python3 ./scripts/scaffold-standalone-module.py \ This adds to the existing skill: -- `./assets/module-setup.md` — Self-registration reference (alongside module.yaml and module-help.csv) +- `./assets/module-setup.md` — Self-registration reference (alongside the duplicated module.yaml and module-help.csv) - `./scripts/merge-config.py` — Config merge script - `./scripts/merge-help-csv.py` — Help CSV merge script -- `../.claude-plugin/marketplace.json` — Distribution manifest After scaffolding, read the skill's SKILL.md and integrate the registration check into its **On Activation** section. How you integrate depends on whether the skill has an existing first-run init flow: @@ -213,27 +252,26 @@ After scaffolding, read the skill's SKILL.md and integrate the registration chec In both cases, the `setup`/`configure` argument should always trigger `./assets/module-setup.md` regardless of whether the module is already registered (for reconfiguration). -Show the user the proposed changes and confirm before writing. +Show the user the proposed SKILL.md changes and confirm before writing. ### 8. Confirm and Next Steps -#### Multi-skill modules +Show what was created. Always: -Show what was created — the setup skill folder structure and key file contents. Let the user know: +- `module.yaml` and `module-help.csv` at the module root +- `.claude-plugin/marketplace.json` for distribution -- To install this module in any project, run the setup skill -- The setup skill handles config collection, writing, and help CSV registration -- The module is now a complete, distributable BMad module +If bundling was added, also list: -#### Standalone modules +- The `{code}-setup/` skill (multi-skill bundling), or +- The `module-setup.md` + merge scripts inside the skill (single-skill bundling) -Show what was added to the skill — the new files and the SKILL.md modification. Let the user know: +Let the user know: -- The skill is now a self-registering BMad module -- Users install by adding the skill and running it with `setup` or `configure` -- On first normal run, if config is missing, it will automatically trigger registration -- Review and fill in the `marketplace.json` fields (owner, license, homepage, repository) for distribution -- The module can be validated with the Validate Module (VM) capability +- To install this module in any project: `npx bmad-method install --custom-source `. The installer reads the root manifests directly. +- If bundling was added: users can also install by direct download. They run the setup skill (multi-skill) or run the skill with `setup`/`configure` (single-skill) to trigger registration manually. +- Review and fill in the `marketplace.json` fields (owner, license, homepage, repository) before publishing. +- The module can be validated with the Validate Module (VM) capability. ## Headless Mode @@ -254,24 +292,32 @@ When `--headless` is set, the skill requires either: - Version (defaults to 1.0.0) - Capability ordering (inferred from skill dependencies) -**Approach auto-detection:** If the path contains a single skill, use the standalone approach automatically. If it contains multiple skills, use the setup skill approach. +**Default behavior:** Always scaffold root manifests (`module.yaml`, `module-help.csv`, `.claude-plugin/marketplace.json`). Direct-download bundling is **off by default in headless mode** and must be opted into explicitly. + +**Bundling flags:** + +- `--bundle=setup` — also generate the `{code}-setup/` skill (multi-skill bundling); requires multiple skills in the input +- `--bundle=standalone` — also generate self-registering bundling inside the skill (single-skill bundling); requires single-skill input +- `--bundle=auto` — pick `setup` for multi-skill input or `standalone` for single-skill input +- `--bundle=none` (default) — root scaffolding only In headless mode: skip interactive questions, scaffold immediately, and return structured JSON: ```json { "status": "success|error", - "approach": "standalone|setup-skill", "module_code": "...", + "module_root": "/path/to/module-root/", + "marketplace_json": "/path/to/.claude-plugin/marketplace.json", + "bundle": "none|setup|standalone", "setup_skill": "{code}-setup", "skill_dir": "/path/to/skill/", - "location": "/path/to/...", "files_created": ["..."], "inferred": { "module_name": "...", "description": "..." }, "warnings": [] } ``` -For multi-skill modules: `setup_skill` and `location` point to the generated setup skill. For standalone modules: `skill_dir` points to the modified skill and `location` points to the marketplace.json parent. +`module_root` and `marketplace_json` are always populated. `setup_skill` is populated when `bundle == "setup"`. `skill_dir` is populated when `bundle == "standalone"`. The `inferred` object lists every value that was not explicitly provided, so the caller can spot wrong inferences. If critical information is missing and cannot be inferred, return `{ "status": "error", "message": "..." }`. diff --git a/skills/bmad-module-builder/scripts/scaffold-setup-skill.py b/skills/bmad-module-builder/scripts/scaffold-setup-skill.py index 34d132b..3016b8a 100644 --- a/skills/bmad-module-builder/scripts/scaffold-setup-skill.py +++ b/skills/bmad-module-builder/scripts/scaffold-setup-skill.py @@ -4,9 +4,17 @@ # /// """Scaffold a BMad module setup skill from template. +This is the **multi-skill direct-download bundle**: an optional add-on for +modules that need to be installable without the BMad installer. The canonical +manifests live at the module root (`/module.yaml` and +`/module-help.csv`); this script duplicates them into a setup skill so +users can run the skill manually to register the module. + Copies the setup-skill-template into the target directory as {code}-setup/, -then writes the generated module.yaml and module-help.csv into the assets folder -and updates the SKILL.md frontmatter with the module's identity. +then writes the supplied module.yaml and module-help.csv into the assets folder +and updates the SKILL.md frontmatter with the module's identity. The supplied +files should be the same content as the root manifests; typically pass the root +manifest paths directly via --module-yaml and --module-csv. """ import argparse diff --git a/skills/bmad-module-builder/scripts/scaffold-standalone-module.py b/skills/bmad-module-builder/scripts/scaffold-standalone-module.py index d997a76..9425fb8 100755 --- a/skills/bmad-module-builder/scripts/scaffold-standalone-module.py +++ b/skills/bmad-module-builder/scripts/scaffold-standalone-module.py @@ -2,12 +2,18 @@ # /// script # requires-python = ">=3.10" # /// -"""Scaffold standalone module infrastructure into an existing skill. - -Copies template files (module-setup.md, merge scripts) into the skill directory -and generates a .claude-plugin/marketplace.json for distribution. The LLM writes -module.yaml and module-help.csv directly to the skill's assets/ folder before -running this script. +"""Scaffold self-registering single-skill bundle into an existing skill. + +This is the **single-skill direct-download bundle**: an optional add-on for +single-skill modules that need to be installable without the BMad installer. +The canonical manifests live at the module root (the parent of the skill +folder); this script copies the registration reference and merge scripts into +the skill so it can self-register on first run or via `setup`/`configure`. + +Before running this script, the LLM must duplicate the root manifests into +`/assets/module.yaml` and `/assets/module-help.csv`. The script +then copies template files (module-setup.md, merge scripts) into the skill +directory and (re)generates a .claude-plugin/marketplace.json for distribution. """ import argparse diff --git a/skills/bmad-module-builder/scripts/tests/test-validate-module.py b/skills/bmad-module-builder/scripts/tests/test-validate-module.py index ac7e8e4..52e60fa 100644 --- a/skills/bmad-module-builder/scripts/tests/test-validate-module.py +++ b/skills/bmad-module-builder/scripts/tests/test-validate-module.py @@ -196,7 +196,7 @@ def create_standalone_module(tmp: Path, skill_name: str = "my-skill", def test_valid_standalone_module(): - """A well-formed standalone module should pass with standalone=true in info.""" + """A well-formed self-registering bundle should pass and report the canonical source.""" with tempfile.TemporaryDirectory() as tmp: tmp = Path(tmp) module_dir = create_standalone_module(tmp) @@ -204,7 +204,8 @@ def test_valid_standalone_module(): code, data = run_validate(module_dir) assert code == 0, f"Expected pass: {data}" assert data["status"] == "pass" - assert data["info"].get("standalone") is True + assert data["info"].get("canonical_source") == "self-registering-bundle" + assert data["info"].get("layouts", {}).get("self_registering_bundle") is True assert data["summary"]["total_findings"] == 0 @@ -250,7 +251,7 @@ def test_standalone_csv_validation(): def test_multi_skill_not_detected_as_standalone(): - """A folder with two skills and no setup skill should fail (not detected as standalone).""" + """Two skills with no root manifests, no setup skill, and no self-reg bundle should fail.""" with tempfile.TemporaryDirectory() as tmp: tmp = Path(tmp) module_dir = tmp / "module" @@ -265,8 +266,8 @@ def test_multi_skill_not_detected_as_standalone(): code, data = run_validate(module_dir) assert code == 1 - # Should fail because it's neither a setup-skill module nor a single-skill standalone - assert any("No setup skill found" in f["message"] for f in data["findings"]) + # Should fail because no recognized module layout is present + assert any("No module manifests found" in f["message"] for f in data["findings"]) def test_nonexistent_directory(): @@ -280,6 +281,97 @@ def test_nonexistent_directory(): assert data["status"] == "error" +def create_root_module(tmp: Path, skills: list[str] | None = None, + csv_rows: str = "", yaml_content: str = "") -> Path: + """Create a root-layout module: manifests at module root, skills as siblings.""" + module_dir = tmp / "module" + module_dir.mkdir() + + skills = skills or ["tst-foo"] + (module_dir / "module.yaml").write_text( + yaml_content or 'code: tst\nname: "Test Module"\ndescription: "A test module"\n' + ) + if not csv_rows: + csv_rows = f'Test Module,{skills[0]},Do Foo,DF,Does the foo,run,,anytime,,,false,output_folder,report\n' + (module_dir / "module-help.csv").write_text(CSV_HEADER + csv_rows) + + for skill in skills: + skill_dir = module_dir / skill + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text(f"---\nname: {skill}\n---\n# {skill}\n") + + return module_dir + + +def test_valid_root_module(): + """Root layout: module.yaml and module-help.csv at module root, no bundles.""" + with tempfile.TemporaryDirectory() as tmp: + code, data = run_validate(create_root_module(Path(tmp))) + assert code == 0, f"Expected pass: {data}" + assert data["status"] == "pass" + assert data["info"]["canonical_source"] == "root" + assert data["info"]["layouts"]["root"] is True + assert data["info"]["layouts"]["setup_skill_bundle"] is False + assert data["info"]["layouts"]["self_registering_bundle"] is False + + +def test_root_module_partial_manifests(): + """Root layout missing one of the two required files should fail.""" + with tempfile.TemporaryDirectory() as tmp: + module_dir = create_root_module(Path(tmp)) + (module_dir / "module-help.csv").unlink() + # No bundle either, so this falls through to "no manifests" + code, data = run_validate(module_dir) + assert code == 1 + assert any("No module manifests" in f["message"] for f in data["findings"]) + + +def test_root_plus_setup_bundle_passes(): + """Root manifests are canonical; setup-skill bundle present is fine if complete.""" + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + module_dir = create_root_module(tmp) + # Add a setup skill bundle alongside root + setup = module_dir / "tst-setup" + setup.mkdir() + (setup / "SKILL.md").write_text("---\nname: tst-setup\n---\n# Setup\n") + (setup / "assets").mkdir() + (setup / "assets" / "module.yaml").write_text( + (module_dir / "module.yaml").read_text() + ) + (setup / "assets" / "module-help.csv").write_text( + (module_dir / "module-help.csv").read_text() + ) + + code, data = run_validate(module_dir) + assert code == 0, f"Expected pass: {data}" + assert data["info"]["canonical_source"] == "root" + assert data["info"]["layouts"]["setup_skill_bundle"] is True + + +def test_root_plus_incomplete_setup_bundle_warns(): + """Root manifests are canonical, but an incomplete setup bundle should be flagged.""" + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + module_dir = create_root_module(tmp) + # Add a half-built setup skill bundle (missing SKILL.md) + setup = module_dir / "tst-setup" + setup.mkdir() + (setup / "assets").mkdir() + (setup / "assets" / "module.yaml").write_text( + (module_dir / "module.yaml").read_text() + ) + (setup / "assets" / "module-help.csv").write_text( + (module_dir / "module-help.csv").read_text() + ) + + code, data = run_validate(module_dir) + # Should flag bundle as incomplete (high severity → fail) + assert code == 1 + bundle_findings = [f for f in data["findings"] if f["category"] == "bundle"] + assert any("setup SKILL.md" in f["message"] for f in bundle_findings) + + if __name__ == "__main__": tests = [ test_valid_module, @@ -296,6 +388,10 @@ def test_nonexistent_directory(): test_standalone_csv_validation, test_multi_skill_not_detected_as_standalone, test_nonexistent_directory, + test_valid_root_module, + test_root_module_partial_manifests, + test_root_plus_setup_bundle_passes, + test_root_plus_incomplete_setup_bundle_warns, ] passed = 0 failed = 0 diff --git a/skills/bmad-module-builder/scripts/validate-module.py b/skills/bmad-module-builder/scripts/validate-module.py index ad0bbed..5287a65 100644 --- a/skills/bmad-module-builder/scripts/validate-module.py +++ b/skills/bmad-module-builder/scripts/validate-module.py @@ -4,18 +4,25 @@ # /// """Validate a BMad module's structure and help CSV integrity. -Supports two module types: -- Multi-skill modules with a dedicated setup skill (*-setup directory) -- Standalone single-skill modules with self-registration (assets/module-setup.md) +Supports three module layouts (in installer priority order): +- Root: module.yaml and module-help.csv at the module root (recommended default) +- Setup skill bundling: manifests inside a {code}-setup/assets/ folder (optional, for direct-download installs) +- Self-registering standalone bundling: manifests inside a single skill's assets/ folder with module-setup.md (optional, single-skill direct-download) + +A module needs at least one of these layouts. Root placement is the canonical +location; setup-skill and self-registering layouts are additive bundles for the +direct-download install path. When both root and a bundle are present, root wins +and the bundle is validated as additional infrastructure. Performs deterministic structural checks: -- Required files exist (setup skill or standalone structure) +- Manifests are present in at least one supported layout - All skill folders have at least one capability entry in the CSV - No orphan CSV entries pointing to nonexistent skills - Menu codes are unique - Before/after references point to real capability entries - Required module.yaml fields are present - CSV column count is consistent +- If bundling is present: bundle infrastructure files exist (merge scripts, module-setup.md, etc.) """ import argparse @@ -33,8 +40,15 @@ ] +def find_root_manifests(module_dir: Path) -> tuple[Path | None, Path | None]: + """Check for module.yaml and module-help.csv at the module root.""" + yaml = module_dir / "module.yaml" + csv = module_dir / "module-help.csv" + return (yaml if yaml.is_file() else None, csv if csv.is_file() else None) + + def find_setup_skill(module_dir: Path) -> Path | None: - """Find the setup skill folder (*-setup).""" + """Find the setup skill folder (*-setup) used for multi-skill direct-download bundling.""" for d in module_dir.iterdir(): if d.is_dir() and d.name.endswith("-setup"): return d @@ -50,15 +64,15 @@ def find_skill_folders(module_dir: Path, exclude_name: str = "") -> list[str]: return sorted(skills) -def detect_standalone_module(module_dir: Path) -> Path | None: - """Detect a standalone module: single skill folder with assets/module.yaml.""" +def detect_self_registering_bundle(module_dir: Path) -> Path | None: + """Detect a single-skill self-registering bundle: skill folder with assets/module-setup.md.""" skill_dirs = [ d for d in module_dir.iterdir() if d.is_dir() and (d / "SKILL.md").is_file() ] if len(skill_dirs) == 1: candidate = skill_dirs[0] - if (candidate / "assets" / "module.yaml").is_file(): + if (candidate / "assets" / "module-setup.md").is_file(): return candidate return None @@ -98,61 +112,97 @@ def finding(severity: str, category: str, message: str, detail: str = ""): "detail": detail, }) - # 1. Find setup skill or detect standalone module + # 1. Detect layouts in priority order: root > setup-skill bundle > self-registering bundle + root_yaml, root_csv = find_root_manifests(module_dir) setup_dir = find_setup_skill(module_dir) - standalone_dir = None + self_reg_dir = detect_self_registering_bundle(module_dir) - if not setup_dir: - standalone_dir = detect_standalone_module(module_dir) - if not standalone_dir: - finding("critical", "structure", - "No setup skill found (*-setup directory) and no standalone module detected") - return {"status": "fail", "findings": findings, "info": info} + has_root = bool(root_yaml and root_csv) + has_setup_bundle = bool(setup_dir) + has_self_reg_bundle = bool(self_reg_dir) - # Branch: standalone vs multi-skill - if standalone_dir: - info["standalone"] = True - info["skill_dir"] = standalone_dir.name - skill_dir = standalone_dir - - # 2s. Check required files for standalone module - required_files = { - "assets/module.yaml": skill_dir / "assets" / "module.yaml", - "assets/module-help.csv": skill_dir / "assets" / "module-help.csv", - "assets/module-setup.md": skill_dir / "assets" / "module-setup.md", - "scripts/merge-config.py": skill_dir / "scripts" / "merge-config.py", - "scripts/merge-help-csv.py": skill_dir / "scripts" / "merge-help-csv.py", - } - for label, path in required_files.items(): + info["layouts"] = { + "root": has_root, + "setup_skill_bundle": has_setup_bundle, + "self_registering_bundle": has_self_reg_bundle, + } + + if not (has_root or has_setup_bundle or has_self_reg_bundle): + finding("critical", "structure", + "No module manifests found. Expected module.yaml and module-help.csv at the module root, " + "or inside a {code}-setup/assets/ folder, or inside a single skill's assets/ with module-setup.md") + return {"status": "fail", "findings": findings, "info": info} + + # Partial root layout: one of the two manifests exists but not both + if has_root and not (root_yaml and root_csv): + if not root_yaml: + finding("critical", "structure", "module.yaml missing at module root (module-help.csv is present)") + if not root_csv: + finding("critical", "structure", "module-help.csv missing at module root (module.yaml is present)") + return {"status": "fail", "findings": findings, "info": info} + + # 2. Choose the canonical source for manifest reading (root wins, then bundles) + if has_root: + info["canonical_source"] = "root" + yaml_path = root_yaml + csv_path = root_csv + elif has_setup_bundle: + info["canonical_source"] = "setup-skill-bundle" + info["setup_skill"] = setup_dir.name + yaml_path = setup_dir / "assets" / "module.yaml" + csv_path = setup_dir / "assets" / "module-help.csv" + # Verify bundle files exist + for label, path in [ + ("setup SKILL.md", setup_dir / "SKILL.md"), + ("setup assets/module.yaml", yaml_path), + ("setup assets/module-help.csv", csv_path), + ]: if not path.is_file(): finding("critical", "structure", f"Missing required file: {label}") - - if not all(p.is_file() for p in required_files.values()): + if not (yaml_path.is_file() and csv_path.is_file()): return {"status": "fail", "findings": findings, "info": info} - - yaml_dir = skill_dir - csv_dir = skill_dir else: - info["setup_skill"] = setup_dir.name - - # 2. Check required files in setup skill - required_files = { - "SKILL.md": setup_dir / "SKILL.md", - "assets/module.yaml": setup_dir / "assets" / "module.yaml", - "assets/module-help.csv": setup_dir / "assets" / "module-help.csv", + info["canonical_source"] = "self-registering-bundle" + info["skill_dir"] = self_reg_dir.name + yaml_path = self_reg_dir / "assets" / "module.yaml" + csv_path = self_reg_dir / "assets" / "module-help.csv" + # Verify bundle files exist + required = { + "assets/module.yaml": yaml_path, + "assets/module-help.csv": csv_path, + "assets/module-setup.md": self_reg_dir / "assets" / "module-setup.md", + "scripts/merge-config.py": self_reg_dir / "scripts" / "merge-config.py", + "scripts/merge-help-csv.py": self_reg_dir / "scripts" / "merge-help-csv.py", } - for label, path in required_files.items(): + for label, path in required.items(): if not path.is_file(): finding("critical", "structure", f"Missing required file: {label}") - - if not all(p.is_file() for p in required_files.values()): + if not all(p.is_file() for p in required.values()): return {"status": "fail", "findings": findings, "info": info} - yaml_dir = setup_dir - csv_dir = setup_dir + # 2b. If root is canonical AND a bundle is also present, validate bundle infrastructure too + if has_root and has_setup_bundle: + for label, path in [ + ("setup SKILL.md", setup_dir / "SKILL.md"), + ("setup assets/module.yaml", setup_dir / "assets" / "module.yaml"), + ("setup assets/module-help.csv", setup_dir / "assets" / "module-help.csv"), + ]: + if not path.is_file(): + finding("high", "bundle", f"Setup-skill bundle is incomplete: missing {label}") + + if has_root and has_self_reg_bundle: + for label, path in [ + ("self-reg assets/module-setup.md", self_reg_dir / "assets" / "module-setup.md"), + ("self-reg assets/module.yaml", self_reg_dir / "assets" / "module.yaml"), + ("self-reg assets/module-help.csv", self_reg_dir / "assets" / "module-help.csv"), + ("self-reg scripts/merge-config.py", self_reg_dir / "scripts" / "merge-config.py"), + ("self-reg scripts/merge-help-csv.py", self_reg_dir / "scripts" / "merge-help-csv.py"), + ]: + if not path.is_file(): + finding("high", "bundle", f"Self-registering bundle is incomplete: missing {label}") # 3. Validate module.yaml - yaml_text = (yaml_dir / "assets" / "module.yaml").read_text(encoding="utf-8") + yaml_text = yaml_path.read_text(encoding="utf-8") yaml_data = parse_yaml_minimal(yaml_text) info["module_code"] = yaml_data.get("code", "") info["module_name"] = yaml_data.get("name", "") @@ -162,7 +212,7 @@ def finding(severity: str, category: str, message: str, detail: str = ""): finding("high", "yaml", f"module.yaml missing or empty required field: {field}") # 4. Parse and validate CSV - csv_text = (csv_dir / "assets" / "module-help.csv").read_text(encoding="utf-8") + csv_text = csv_path.read_text(encoding="utf-8") header, rows = parse_csv_rows(csv_text) # Check header @@ -270,11 +320,11 @@ def finding(severity: str, category: str, message: str, detail: str = ""): def main() -> int: parser = argparse.ArgumentParser( - description="Validate a BMad module's setup skill structure and help CSV integrity" + description="Validate a BMad module's structure and help CSV integrity" ) parser.add_argument( "module_dir", - help="Path to the module's skills folder (containing the setup skill and other skills)", + help="Path to the module root (folder containing module.yaml/module-help.csv and the skill folders)", ) parser.add_argument("--verbose", action="store_true", help="Print progress to stderr") args = parser.parse_args()