Add Bmad-Method files and some brand-design artifacts!

This commit is contained in:
2026-07-06 18:00:53 +01:00
parent 87bff9c251
commit 2b4a6a413c
1806 changed files with 417144 additions and 0 deletions
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# ///
"""Scaffold a BMad module setup skill from template.
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.
"""
import argparse
import json
import shutil
import sys
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser(
description="Scaffold a BMad module setup skill from template"
)
parser.add_argument(
"--target-dir",
required=True,
help="Directory to create the setup skill in (the user's skills folder)",
)
parser.add_argument(
"--module-code",
required=True,
help="Module code (2-4 letter abbreviation, e.g. 'cis')",
)
parser.add_argument(
"--module-name",
required=True,
help="Module display name (e.g. 'Creative Intelligence Suite')",
)
parser.add_argument(
"--module-yaml",
required=True,
help="Path to the generated module.yaml content file",
)
parser.add_argument(
"--module-csv",
required=True,
help="Path to the generated module-help.csv content file",
)
parser.add_argument(
"--verbose", action="store_true", help="Print progress to stderr"
)
args = parser.parse_args()
template_dir = Path(__file__).resolve().parent.parent / "assets" / "setup-skill-template"
setup_skill_name = f"{args.module_code}-setup"
target = Path(args.target_dir) / setup_skill_name
if not template_dir.is_dir():
print(
json.dumps({"status": "error", "message": f"Template not found: {template_dir}"}),
file=sys.stdout,
)
return 2
for source_path in [args.module_yaml, args.module_csv]:
if not Path(source_path).is_file():
print(
json.dumps({"status": "error", "message": f"Source file not found: {source_path}"}),
file=sys.stdout,
)
return 2
target_dir = Path(args.target_dir)
if not target_dir.is_dir():
print(
json.dumps({"status": "error", "message": f"Target directory not found: {target_dir}"}),
file=sys.stdout,
)
return 2
# Remove existing setup skill if present (anti-zombie)
if target.exists():
if args.verbose:
print(f"Removing existing {setup_skill_name}/", file=sys.stderr)
shutil.rmtree(target)
# Copy template
if args.verbose:
print(f"Copying template to {target}", file=sys.stderr)
shutil.copytree(template_dir, target)
# Update SKILL.md frontmatter placeholders
skill_md = target / "SKILL.md"
content = skill_md.read_text(encoding="utf-8")
content = content.replace("{setup-skill-name}", setup_skill_name)
content = content.replace("{module-name}", args.module_name)
content = content.replace("{module-code}", args.module_code)
skill_md.write_text(content, encoding="utf-8")
# Write generated module.yaml
yaml_content = Path(args.module_yaml).read_text(encoding="utf-8")
(target / "assets" / "module.yaml").write_text(yaml_content, encoding="utf-8")
# Write generated module-help.csv
csv_content = Path(args.module_csv).read_text(encoding="utf-8")
(target / "assets" / "module-help.csv").write_text(csv_content, encoding="utf-8")
# Collect file list
files_created = sorted(
str(p.relative_to(target)) for p in target.rglob("*") if p.is_file()
)
result = {
"status": "success",
"setup_skill": setup_skill_name,
"location": str(target),
"files_created": files_created,
"files_count": len(files_created),
}
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
# /// 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.
"""
import argparse
import json
import sys
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser(
description="Scaffold standalone module infrastructure into an existing skill"
)
parser.add_argument(
"--skill-dir",
required=True,
help="Path to the existing skill directory (must contain SKILL.md)",
)
parser.add_argument(
"--module-code",
required=True,
help="Module code (2-4 letter abbreviation, e.g. 'exc')",
)
parser.add_argument(
"--module-name",
required=True,
help="Module display name (e.g. 'Excalidraw Tools')",
)
parser.add_argument(
"--marketplace-dir",
default=None,
help="Directory to create .claude-plugin/ in (defaults to skill-dir parent)",
)
parser.add_argument(
"--verbose", action="store_true", help="Print progress to stderr"
)
args = parser.parse_args()
template_dir = (
Path(__file__).resolve().parent.parent
/ "assets"
/ "standalone-module-template"
)
skill_dir = Path(args.skill_dir).resolve()
marketplace_dir = (
Path(args.marketplace_dir).resolve() if args.marketplace_dir else skill_dir.parent
)
# --- Validation ---
if not template_dir.is_dir():
print(
json.dumps({"status": "error", "message": f"Template not found: {template_dir}"}),
file=sys.stdout,
)
return 2
if not skill_dir.is_dir():
print(
json.dumps({"status": "error", "message": f"Skill directory not found: {skill_dir}"}),
file=sys.stdout,
)
return 2
if not (skill_dir / "SKILL.md").is_file():
print(
json.dumps({"status": "error", "message": f"No SKILL.md found in {skill_dir}"}),
file=sys.stdout,
)
return 2
if not (skill_dir / "assets" / "module.yaml").is_file():
print(
json.dumps({
"status": "error",
"message": f"assets/module.yaml not found in {skill_dir} — the LLM must write it before running this script",
}),
file=sys.stdout,
)
return 2
# --- Copy template files ---
files_created: list[str] = []
files_skipped: list[str] = []
warnings: list[str] = []
# 1. Copy module-setup.md to assets/ (alongside module.yaml and module-help.csv)
assets_dir = skill_dir / "assets"
assets_dir.mkdir(exist_ok=True)
src_setup = template_dir / "module-setup.md"
dst_setup = assets_dir / "module-setup.md"
if args.verbose:
print(f"Copying module-setup.md to {dst_setup}", file=sys.stderr)
dst_setup.write_bytes(src_setup.read_bytes())
files_created.append("assets/module-setup.md")
# 2. Copy merge scripts to scripts/
scripts_dir = skill_dir / "scripts"
scripts_dir.mkdir(exist_ok=True)
for script_name in ("merge-config.py", "merge-help-csv.py"):
src = template_dir / script_name
dst = scripts_dir / script_name
if dst.exists():
msg = f"scripts/{script_name} already exists — skipped to avoid overwriting"
files_skipped.append(f"scripts/{script_name}")
warnings.append(msg)
if args.verbose:
print(f"SKIP: {msg}", file=sys.stderr)
else:
if args.verbose:
print(f"Copying {script_name} to {dst}", file=sys.stderr)
dst.write_bytes(src.read_bytes())
dst.chmod(0o755)
files_created.append(f"scripts/{script_name}")
# 3. Generate marketplace.json
plugin_dir = marketplace_dir / ".claude-plugin"
plugin_dir.mkdir(parents=True, exist_ok=True)
marketplace_json = plugin_dir / "marketplace.json"
# Read module.yaml for description and version
module_yaml_path = skill_dir / "assets" / "module.yaml"
module_description = ""
module_version = "1.0.0"
try:
yaml_text = module_yaml_path.read_text(encoding="utf-8")
for line in yaml_text.splitlines():
stripped = line.strip()
if stripped.startswith("description:"):
module_description = stripped.split(":", 1)[1].strip().strip('"').strip("'")
elif stripped.startswith("module_version:"):
module_version = stripped.split(":", 1)[1].strip().strip('"').strip("'")
except Exception:
pass
skill_dir_name = skill_dir.name
marketplace_data = {
"name": args.module_code,
"owner": {"name": ""},
"license": "",
"homepage": "",
"repository": "",
"keywords": ["bmad"],
"plugins": [
{
"name": args.module_code,
"source": "./",
"description": module_description,
"version": module_version,
"author": {"name": ""},
"skills": [f"./{skill_dir_name}"],
}
],
}
if args.verbose:
print(f"Writing marketplace.json to {marketplace_json}", file=sys.stderr)
marketplace_json.write_text(
json.dumps(marketplace_data, indent=2) + "\n", encoding="utf-8"
)
files_created.append(".claude-plugin/marketplace.json")
# --- Result ---
result = {
"status": "success",
"skill_dir": str(skill_dir),
"module_code": args.module_code,
"files_created": files_created,
"files_skipped": files_skipped,
"warnings": warnings,
"marketplace_json": str(marketplace_json),
}
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# ///
"""Tests for scaffold-setup-skill.py"""
import json
import subprocess
import sys
import tempfile
from pathlib import Path
SCRIPT = Path(__file__).resolve().parent.parent / "scaffold-setup-skill.py"
TEMPLATE_DIR = Path(__file__).resolve().parent.parent.parent / "assets" / "setup-skill-template"
def run_scaffold(tmp: Path, **kwargs) -> tuple[int, dict]:
"""Run the scaffold script and return (exit_code, parsed_json)."""
target_dir = kwargs.get("target_dir", str(tmp / "output"))
Path(target_dir).mkdir(parents=True, exist_ok=True)
module_code = kwargs.get("module_code", "tst")
module_name = kwargs.get("module_name", "Test Module")
yaml_path = tmp / "module.yaml"
csv_path = tmp / "module-help.csv"
yaml_path.write_text(kwargs.get("yaml_content", f'code: {module_code}\nname: "{module_name}"\n'))
csv_path.write_text(
kwargs.get(
"csv_content",
"module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs\n"
f'{module_name},{module_code}-example,Example,EX,An example skill,do-thing,,anytime,,,false,output_folder,artifact\n',
)
)
cmd = [
sys.executable,
str(SCRIPT),
"--target-dir", target_dir,
"--module-code", module_code,
"--module-name", module_name,
"--module-yaml", str(yaml_path),
"--module-csv", str(csv_path),
]
result = subprocess.run(cmd, capture_output=True, text=True)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
data = {"raw_stdout": result.stdout, "raw_stderr": result.stderr}
return result.returncode, data
def test_basic_scaffold():
"""Test that scaffolding creates the expected structure."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
target_dir = tmp / "output"
target_dir.mkdir()
code, data = run_scaffold(tmp, target_dir=str(target_dir))
assert code == 0, f"Script failed: {data}"
assert data["status"] == "success"
assert data["setup_skill"] == "tst-setup"
setup_dir = target_dir / "tst-setup"
assert setup_dir.is_dir()
assert (setup_dir / "SKILL.md").is_file()
assert (setup_dir / "scripts" / "merge-config.py").is_file()
assert (setup_dir / "scripts" / "merge-help-csv.py").is_file()
assert (setup_dir / "scripts" / "cleanup-legacy.py").is_file()
assert (setup_dir / "assets" / "module.yaml").is_file()
assert (setup_dir / "assets" / "module-help.csv").is_file()
def test_skill_md_frontmatter_substitution():
"""Test that SKILL.md placeholders are replaced."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
target_dir = tmp / "output"
target_dir.mkdir()
code, data = run_scaffold(
tmp,
target_dir=str(target_dir),
module_code="xyz",
module_name="XYZ Studio",
)
assert code == 0
skill_md = (target_dir / "xyz-setup" / "SKILL.md").read_text()
assert "xyz-setup" in skill_md
assert "XYZ Studio" in skill_md
assert "{setup-skill-name}" not in skill_md
assert "{module-name}" not in skill_md
assert "{module-code}" not in skill_md
def test_template_frontmatter_uses_quoted_name_placeholder():
"""Test that the template frontmatter is valid before substitution."""
template_skill_md = (TEMPLATE_DIR / "SKILL.md").read_text()
assert 'name: "{setup-skill-name}"' in template_skill_md
def test_generated_files_written():
"""Test that module.yaml and module-help.csv contain generated content."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
target_dir = tmp / "output"
target_dir.mkdir()
custom_yaml = 'code: abc\nname: "ABC Module"\ndescription: "Custom desc"\n'
custom_csv = "module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs\nABC Module,bmad-abc-thing,Do Thing,DT,Does the thing,run,,anytime,,,false,output_folder,report\n"
code, data = run_scaffold(
tmp,
target_dir=str(target_dir),
module_code="abc",
module_name="ABC Module",
yaml_content=custom_yaml,
csv_content=custom_csv,
)
assert code == 0
yaml_content = (target_dir / "abc-setup" / "assets" / "module.yaml").read_text()
assert "ABC Module" in yaml_content
assert "Custom desc" in yaml_content
csv_content = (target_dir / "abc-setup" / "assets" / "module-help.csv").read_text()
assert "bmad-abc-thing" in csv_content
assert "DT" in csv_content
def test_anti_zombie_replaces_existing():
"""Test that an existing setup skill is replaced cleanly."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
target_dir = tmp / "output"
target_dir.mkdir()
# First scaffold
run_scaffold(tmp, target_dir=str(target_dir))
stale_file = target_dir / "tst-setup" / "stale-marker.txt"
stale_file.write_text("should be removed")
# Second scaffold should remove stale file
code, data = run_scaffold(tmp, target_dir=str(target_dir))
assert code == 0
assert not stale_file.exists()
def test_missing_target_dir():
"""Test error when target directory doesn't exist."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
nonexistent = tmp / "nonexistent"
# Write valid source files
yaml_path = tmp / "module.yaml"
csv_path = tmp / "module-help.csv"
yaml_path.write_text('code: tst\nname: "Test"\n')
csv_path.write_text("header\n")
cmd = [
sys.executable,
str(SCRIPT),
"--target-dir", str(nonexistent),
"--module-code", "tst",
"--module-name", "Test",
"--module-yaml", str(yaml_path),
"--module-csv", str(csv_path),
]
result = subprocess.run(cmd, capture_output=True, text=True)
assert result.returncode == 2
data = json.loads(result.stdout)
assert data["status"] == "error"
def test_missing_source_file():
"""Test error when module.yaml source doesn't exist."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
target_dir = tmp / "output"
target_dir.mkdir()
# Remove the yaml after creation to simulate missing file
yaml_path = tmp / "module.yaml"
csv_path = tmp / "module-help.csv"
csv_path.write_text("header\n")
# Don't create yaml_path
cmd = [
sys.executable,
str(SCRIPT),
"--target-dir", str(target_dir),
"--module-code", "tst",
"--module-name", "Test",
"--module-yaml", str(yaml_path),
"--module-csv", str(csv_path),
]
result = subprocess.run(cmd, capture_output=True, text=True)
assert result.returncode == 2
data = json.loads(result.stdout)
assert data["status"] == "error"
if __name__ == "__main__":
tests = [
test_basic_scaffold,
test_skill_md_frontmatter_substitution,
test_template_frontmatter_uses_quoted_name_placeholder,
test_generated_files_written,
test_anti_zombie_replaces_existing,
test_missing_target_dir,
test_missing_source_file,
]
passed = 0
failed = 0
for test in tests:
try:
test()
print(f" PASS: {test.__name__}")
passed += 1
except AssertionError as e:
print(f" FAIL: {test.__name__}: {e}")
failed += 1
except Exception as e:
print(f" ERROR: {test.__name__}: {e}")
failed += 1
print(f"\n{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
@@ -0,0 +1,266 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# ///
"""Tests for scaffold-standalone-module.py"""
import json
import subprocess
import sys
import tempfile
from pathlib import Path
SCRIPT = Path(__file__).resolve().parent.parent / "scaffold-standalone-module.py"
def make_skill_dir(tmp: Path, name: str = "my-skill") -> Path:
"""Create a minimal skill directory with SKILL.md and assets/module.yaml."""
skill_dir = tmp / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text("---\nname: my-skill\ndescription: A test skill\n---\n# My Skill\n")
assets = skill_dir / "assets"
assets.mkdir(exist_ok=True)
(assets / "module.yaml").write_text(
'code: tst\nname: "Test Module"\ndescription: "A test module"\nmodule_version: 1.0.0\n'
)
(assets / "module-help.csv").write_text(
"module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs\n"
"Test Module,my-skill,Do Thing,DT,Does the thing,run,,anytime,,,false,output_folder,artifact\n"
)
return skill_dir
def run_scaffold(skill_dir: Path, **kwargs) -> tuple[int, dict]:
"""Run the standalone scaffold script and return (exit_code, parsed_json)."""
cmd = [
sys.executable,
str(SCRIPT),
"--skill-dir", str(skill_dir),
"--module-code", kwargs.get("module_code", "tst"),
"--module-name", kwargs.get("module_name", "Test Module"),
]
if "marketplace_dir" in kwargs:
cmd.extend(["--marketplace-dir", str(kwargs["marketplace_dir"])])
if kwargs.get("verbose"):
cmd.append("--verbose")
result = subprocess.run(cmd, capture_output=True, text=True)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
data = {"raw_stdout": result.stdout, "raw_stderr": result.stderr}
return result.returncode, data
def test_basic_scaffold():
"""Test that scaffolding copies all expected template files."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
skill_dir = make_skill_dir(tmp)
code, data = run_scaffold(skill_dir)
assert code == 0, f"Script failed: {data}"
assert data["status"] == "success"
assert data["module_code"] == "tst"
# module-setup.md placed alongside module.yaml in assets/
assert (skill_dir / "assets" / "module-setup.md").is_file()
# merge scripts placed in scripts/
assert (skill_dir / "scripts" / "merge-config.py").is_file()
assert (skill_dir / "scripts" / "merge-help-csv.py").is_file()
# marketplace.json at parent level
assert (tmp / ".claude-plugin" / "marketplace.json").is_file()
def test_marketplace_json_content():
"""Test that marketplace.json contains correct module metadata."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
skill_dir = make_skill_dir(tmp, name="bmad-exc-tools")
code, data = run_scaffold(
skill_dir, module_code="exc", module_name="Excalidraw Tools"
)
assert code == 0
marketplace = json.loads(
(tmp / ".claude-plugin" / "marketplace.json").read_text()
)
assert marketplace["name"] == "bmad-exc"
plugin = marketplace["plugins"][0]
assert plugin["name"] == "bmad-exc"
assert plugin["skills"] == ["./bmad-exc-tools"]
assert plugin["description"] == "A test module"
assert plugin["version"] == "1.0.0"
def test_does_not_overwrite_existing_scripts():
"""Test that existing scripts are skipped with a warning."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
skill_dir = make_skill_dir(tmp)
# Pre-create a merge-config.py with custom content
scripts_dir = skill_dir / "scripts"
scripts_dir.mkdir(exist_ok=True)
existing_script = scripts_dir / "merge-config.py"
existing_script.write_text("# my custom script\n")
code, data = run_scaffold(skill_dir)
assert code == 0
# Should be skipped
assert "scripts/merge-config.py" in data["files_skipped"]
assert len(data["warnings"]) >= 1
assert any("merge-config.py" in w for w in data["warnings"])
# Content should be preserved
assert existing_script.read_text() == "# my custom script\n"
# merge-help-csv.py should still be created
assert "scripts/merge-help-csv.py" in data["files_created"]
def test_creates_missing_subdirectories():
"""Test that scripts/ directory is created if it doesn't exist."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
skill_dir = make_skill_dir(tmp)
# Verify scripts/ doesn't exist yet
assert not (skill_dir / "scripts").exists()
code, data = run_scaffold(skill_dir)
assert code == 0
assert (skill_dir / "scripts").is_dir()
assert (skill_dir / "scripts" / "merge-config.py").is_file()
def test_preserves_existing_skill_files():
"""Test that existing skill files are not modified or deleted."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
skill_dir = make_skill_dir(tmp)
# Add extra files
(skill_dir / "build-process.md").write_text("# Build\n")
refs_dir = skill_dir / "references"
refs_dir.mkdir()
(refs_dir / "my-ref.md").write_text("# Reference\n")
original_skill_md = (skill_dir / "SKILL.md").read_text()
code, data = run_scaffold(skill_dir)
assert code == 0
# Original files untouched
assert (skill_dir / "SKILL.md").read_text() == original_skill_md
assert (skill_dir / "build-process.md").read_text() == "# Build\n"
assert (refs_dir / "my-ref.md").read_text() == "# Reference\n"
def test_missing_skill_dir():
"""Test error when skill directory doesn't exist."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
nonexistent = tmp / "nonexistent-skill"
cmd = [
sys.executable, str(SCRIPT),
"--skill-dir", str(nonexistent),
"--module-code", "tst",
"--module-name", "Test",
]
result = subprocess.run(cmd, capture_output=True, text=True)
assert result.returncode == 2
data = json.loads(result.stdout)
assert data["status"] == "error"
def test_missing_skill_md():
"""Test error when skill directory has no SKILL.md."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
skill_dir = tmp / "empty-skill"
skill_dir.mkdir()
(skill_dir / "assets").mkdir()
(skill_dir / "assets" / "module.yaml").write_text("code: tst\n")
cmd = [
sys.executable, str(SCRIPT),
"--skill-dir", str(skill_dir),
"--module-code", "tst",
"--module-name", "Test",
]
result = subprocess.run(cmd, capture_output=True, text=True)
assert result.returncode == 2
data = json.loads(result.stdout)
assert data["status"] == "error"
assert "SKILL.md" in data["message"]
def test_missing_module_yaml():
"""Test error when assets/module.yaml hasn't been written yet."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
skill_dir = tmp / "skill-no-yaml"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("---\nname: test\n---\n")
cmd = [
sys.executable, str(SCRIPT),
"--skill-dir", str(skill_dir),
"--module-code", "tst",
"--module-name", "Test",
]
result = subprocess.run(cmd, capture_output=True, text=True)
assert result.returncode == 2
data = json.loads(result.stdout)
assert data["status"] == "error"
assert "module.yaml" in data["message"]
def test_custom_marketplace_dir():
"""Test that --marketplace-dir places marketplace.json in a custom location."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
skill_dir = make_skill_dir(tmp)
custom_dir = tmp / "custom-root"
custom_dir.mkdir()
code, data = run_scaffold(skill_dir, marketplace_dir=custom_dir)
assert code == 0
# Should be at custom location, not default parent
assert (custom_dir / ".claude-plugin" / "marketplace.json").is_file()
assert not (tmp / ".claude-plugin" / "marketplace.json").exists()
assert data["marketplace_json"] == str((custom_dir / ".claude-plugin" / "marketplace.json").resolve())
if __name__ == "__main__":
tests = [
test_basic_scaffold,
test_marketplace_json_content,
test_does_not_overwrite_existing_scripts,
test_creates_missing_subdirectories,
test_preserves_existing_skill_files,
test_missing_skill_dir,
test_missing_skill_md,
test_missing_module_yaml,
test_custom_marketplace_dir,
]
passed = 0
failed = 0
for test in tests:
try:
test()
print(f" PASS: {test.__name__}")
passed += 1
except AssertionError as e:
print(f" FAIL: {test.__name__}: {e}")
failed += 1
except Exception as e:
print(f" ERROR: {test.__name__}: {e}")
failed += 1
print(f"\n{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
@@ -0,0 +1,465 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# ///
"""Tests for validate-module.py"""
import json
import subprocess
import sys
import tempfile
from pathlib import Path
SCRIPT = Path(__file__).resolve().parent.parent / "validate-module.py"
CSV_HEADER = "module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs\n"
LEGACY_CSV_HEADER = "module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs\n"
def create_module(tmp: Path, skills: list[str] | None = None, csv_rows: str = "",
yaml_content: str = "", setup_name: str = "tst-setup") -> Path:
"""Create a minimal module structure for testing."""
module_dir = tmp / "module"
module_dir.mkdir()
# Setup skill
setup = module_dir / setup_name
setup.mkdir()
(setup / "SKILL.md").write_text("---\nname: " + setup_name + "\n---\n# Setup\n")
(setup / "assets").mkdir()
(setup / "assets" / "module.yaml").write_text(
yaml_content or 'code: tst\nname: "Test Module"\ndescription: "A test module"\n'
)
(setup / "assets" / "module-help.csv").write_text(CSV_HEADER + csv_rows)
# Other skills
for skill in (skills or []):
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 run_validate(module_dir: Path) -> tuple[int, dict]:
"""Run the validation script and return (exit_code, parsed_json)."""
result = subprocess.run(
[sys.executable, str(SCRIPT), str(module_dir)],
capture_output=True, text=True,
)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
data = {"raw_stdout": result.stdout, "raw_stderr": result.stderr}
return result.returncode, data
def test_valid_module():
"""A well-formed module should pass."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
csv_rows = 'Test Module,tst-foo,Do Foo,DF,Does the foo thing,run,,anytime,,,false,output_folder,report\n'
module_dir = create_module(tmp, skills=["tst-foo"], csv_rows=csv_rows)
code, data = run_validate(module_dir)
assert code == 0, f"Expected pass: {data}"
assert data["status"] == "pass"
assert data["summary"]["total_findings"] == 0
def test_missing_setup_skill():
"""Module with no setup skill should fail critically."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
module_dir = tmp / "module"
module_dir.mkdir()
skill = module_dir / "tst-foo"
skill.mkdir()
(skill / "SKILL.md").write_text("---\nname: tst-foo\n---\n")
code, data = run_validate(module_dir)
assert code == 1
assert any(f["category"] == "structure" for f in data["findings"])
def test_missing_csv_entry():
"""Skill without a CSV entry should be flagged."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
module_dir = create_module(tmp, skills=["tst-foo", "tst-bar"],
csv_rows='Test Module,tst-foo,Do Foo,DF,Does foo,run,,anytime,,,false,output_folder,report\n')
code, data = run_validate(module_dir)
assert code == 1
missing = [f for f in data["findings"] if f["category"] == "missing-entry"]
assert len(missing) == 1
assert "tst-bar" in missing[0]["message"]
def test_orphan_csv_entry():
"""CSV entry for nonexistent skill should be flagged."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
csv_rows = 'Test Module,tst-ghost,Ghost,GH,Does not exist,run,,anytime,,,false,output_folder,report\n'
module_dir = create_module(tmp, skills=[], csv_rows=csv_rows)
code, data = run_validate(module_dir)
orphans = [f for f in data["findings"] if f["category"] == "orphan-entry"]
assert len(orphans) == 1
assert "tst-ghost" in orphans[0]["message"]
def test_duplicate_menu_codes():
"""Duplicate menu codes should be flagged."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
csv_rows = (
'Test Module,tst-foo,Do Foo,DF,Does foo,run,,anytime,,,false,output_folder,report\n'
'Test Module,tst-foo,Also Foo,DF,Also does foo,other,,anytime,,,false,output_folder,report\n'
)
module_dir = create_module(tmp, skills=["tst-foo"], csv_rows=csv_rows)
code, data = run_validate(module_dir)
dupes = [f for f in data["findings"] if f["category"] == "duplicate-menu-code"]
assert len(dupes) == 1
assert "DF" in dupes[0]["message"]
def test_invalid_before_after_ref():
"""Before/after references to nonexistent capabilities should be flagged."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
csv_rows = 'Test Module,tst-foo,Do Foo,DF,Does foo,run,,anytime,tst-ghost:phantom,,false,output_folder,report\n'
module_dir = create_module(tmp, skills=["tst-foo"], csv_rows=csv_rows)
code, data = run_validate(module_dir)
refs = [f for f in data["findings"] if f["category"] == "invalid-ref"]
assert len(refs) == 1
assert "tst-ghost:phantom" in refs[0]["message"]
def test_missing_yaml_fields():
"""module.yaml with missing required fields should be flagged."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
csv_rows = 'Test Module,tst-foo,Do Foo,DF,Does foo,run,,anytime,,,false,output_folder,report\n'
module_dir = create_module(tmp, skills=["tst-foo"], csv_rows=csv_rows,
yaml_content='code: tst\n')
code, data = run_validate(module_dir)
yaml_findings = [f for f in data["findings"] if f["category"] == "yaml"]
assert len(yaml_findings) >= 1 # at least name or description missing
def test_empty_csv():
"""CSV with header but no rows should be flagged."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
module_dir = create_module(tmp, skills=["tst-foo"], csv_rows="")
code, data = run_validate(module_dir)
assert code == 1
empty = [f for f in data["findings"] if f["category"] == "csv-empty"]
assert len(empty) == 1
def test_canonical_header_accepted():
"""The canonical preceded-by/followed-by header must NOT produce a header finding."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
csv_rows = 'Test Module,tst-foo,Do Foo,DF,Does foo,run,,anytime,,,false,output_folder,report\n'
module_dir = create_module(tmp, skills=["tst-foo"], csv_rows=csv_rows)
code, data = run_validate(module_dir)
assert code == 0, f"expected a clean pass: {data}"
assert data["status"] == "pass"
header_findings = [f for f in data["findings"] if f["category"] == "csv-header"]
assert header_findings == [], f"unexpected header findings: {header_findings}"
def test_legacy_after_before_header_flagged():
"""A module-help.csv using the old after/before column names must be flagged as
a header mismatch — canonical is preceded-by/followed-by (matches the templates
and bmad-help). Regression for the CSV_HEADER drift in validate-module.py."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
module_dir = tmp / "module"
module_dir.mkdir()
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(
'code: tst\nname: "Test Module"\ndescription: "A test module"\n'
)
(setup / "assets" / "module-help.csv").write_text(
LEGACY_CSV_HEADER
+ 'Test Module,tst-foo,Do Foo,DF,Does foo,run,,anytime,,,false,output_folder,report\n'
)
(module_dir / "tst-foo").mkdir()
(module_dir / "tst-foo" / "SKILL.md").write_text("---\nname: tst-foo\n---\n# tst-foo\n")
code, data = run_validate(module_dir)
assert code == 1, f"expected fail (high-severity header finding): {data}"
assert data["status"] == "fail"
header_findings = [f for f in data["findings"] if f["category"] == "csv-header"]
assert len(header_findings) == 1, f"expected a csv-header finding: {data['findings']}"
msg = header_findings[0]["message"]
# missing the new names, has the legacy ones
assert "preceded-by" in msg and "followed-by" in msg
assert "after" in msg and "before" in msg
def test_short_row_does_not_crash():
"""A CSV row with fewer fields than the header must not crash the validator and
must be reported as a column-count mismatch. DictReader fills the missing
columns with None by default, so the validator's `.strip()` calls would raise
AttributeError on a short row — restval="" keeps them safe. Regression test."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
# Only 5 of the 13 columns present (the remaining 8 are missing entirely).
csv_rows = 'Test Module,tst-foo,Do Foo,DF,Does foo\n'
module_dir = create_module(tmp, skills=["tst-foo"], csv_rows=csv_rows)
code, data = run_validate(module_dir)
# Valid JSON with findings means the script completed instead of crashing
# with an uncaught traceback (which run_validate would surface as raw_*).
assert "findings" in data, f"validator crashed instead of reporting: {data}"
# A short row is a medium-severity finding: reported, but non-fatal.
assert code == 0 and data["status"] == "pass", f"expected non-fatal pass: {data}"
col_findings = [f for f in data["findings"] if f["category"] == "csv-columns"]
assert len(col_findings) == 1, f"expected a csv-columns finding: {data['findings']}"
assert "5 columns" in col_findings[0]["message"]
def create_standalone_module(tmp: Path, skill_name: str = "my-skill",
csv_rows: str = "", yaml_content: str = "",
include_setup_md: bool = True,
include_merge_scripts: bool = True,
merge_script_style: str = "dash") -> Path:
"""Create a minimal standalone module structure for testing.
``merge_script_style`` selects the merge-script naming form: "dash" for the
scaffolder default (merge-config.py) or "underscore" for the importable form
(merge_config.py). Both are valid.
"""
module_dir = tmp / "module"
module_dir.mkdir()
skill = module_dir / skill_name
skill.mkdir()
(skill / "SKILL.md").write_text(f"---\nname: {skill_name}\n---\n# {skill_name}\n")
assets = skill / "assets"
assets.mkdir()
(assets / "module.yaml").write_text(
yaml_content or 'code: tst\nname: "Test Module"\ndescription: "A standalone test module"\n'
)
if not csv_rows:
csv_rows = f'Test Module,{skill_name},Do Thing,DT,Does the thing,run,,anytime,,,false,output_folder,artifact\n'
(assets / "module-help.csv").write_text(CSV_HEADER + csv_rows)
if include_setup_md:
(assets / "module-setup.md").write_text("# Module Setup\nStandalone registration.\n")
if include_merge_scripts:
scripts = skill / "scripts"
scripts.mkdir()
if merge_script_style == "underscore":
(scripts / "merge_config.py").write_text("# merge_config\n")
(scripts / "merge_help_csv.py").write_text("# merge_help_csv\n")
else:
(scripts / "merge-config.py").write_text("# merge-config\n")
(scripts / "merge-help-csv.py").write_text("# merge-help-csv\n")
return module_dir
def test_valid_standalone_module():
"""A well-formed standalone module should pass with standalone=true in info."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
module_dir = create_standalone_module(tmp)
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["summary"]["total_findings"] == 0
def test_standalone_missing_module_setup_md():
"""Standalone module without assets/module-setup.md should fail."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
module_dir = create_standalone_module(tmp, include_setup_md=False)
code, data = run_validate(module_dir)
assert code == 1
structure_findings = [f for f in data["findings"] if f["category"] == "structure"]
assert any("module-setup.md" in f["message"] for f in structure_findings)
def test_standalone_missing_merge_scripts():
"""Standalone module without merge scripts should fail."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
module_dir = create_standalone_module(tmp, include_merge_scripts=False)
code, data = run_validate(module_dir)
assert code == 1
structure_findings = [f for f in data["findings"] if f["category"] == "structure"]
assert any("merge-config.py" in f["message"] for f in structure_findings)
def test_standalone_csv_validation():
"""Standalone module CSV should be validated the same as multi-skill."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
# Duplicate menu codes
csv_rows = (
'Test Module,my-skill,Do Thing,DT,Does thing,run,,anytime,,,false,output_folder,artifact\n'
'Test Module,my-skill,Also Thing,DT,Also does thing,other,,anytime,,,false,output_folder,report\n'
)
module_dir = create_standalone_module(tmp, csv_rows=csv_rows)
code, data = run_validate(module_dir)
dupes = [f for f in data["findings"] if f["category"] == "duplicate-menu-code"]
assert len(dupes) == 1
assert "DT" in dupes[0]["message"]
def test_standalone_underscore_merge_scripts():
"""Importable underscore-named merge scripts (merge_config.py) should pass."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
module_dir = create_standalone_module(tmp, merge_script_style="underscore")
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["summary"]["total_findings"] == 0
def test_standalone_cross_module_before_after_ref():
"""Bare (colon-less) preceded-by/followed-by refs are cross-module positional, not flagged."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
csv_rows = ('Test Module,my-skill,Do Thing,DT,Does thing,,,anytime,'
'bmad-sprint-planning,bmad-retrospective,false,output_folder,artifact\n')
module_dir = create_standalone_module(tmp, csv_rows=csv_rows)
code, data = run_validate(module_dir)
assert code == 0, f"Expected pass: {data}"
refs = [f for f in data["findings"] if f["category"] == "invalid-ref"]
assert refs == [], f"Cross-module bare refs should not be flagged: {refs}"
def test_standalone_given_skill_dir_directly():
"""Passing the standalone skill directory itself (not its parent) should work."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
module_dir = create_standalone_module(tmp, skill_name="my-skill")
skill_dir = module_dir / "my-skill"
code, data = run_validate(skill_dir)
assert code == 0, f"Expected pass: {data}"
assert data["status"] == "pass"
assert data["info"].get("standalone") is True
assert data["info"].get("skill_dir") == "my-skill"
def test_standalone_skill_dir_orphan_not_masked_by_sibling():
"""Validating a skill dir directly must still flag a CSV skill that only
exists as an unrelated sibling directory (not part of this standalone module)."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
csv_rows = (
'Test Module,my-skill,Do Thing,DT,Does thing,run,,anytime,,,false,output_folder,artifact\n'
'Test Module,other-skill,Other,OT,Other thing,run,,anytime,,,false,output_folder,report\n'
)
module_dir = create_standalone_module(tmp, skill_name="my-skill", csv_rows=csv_rows)
# A sibling skill dir next to the standalone skill (a different module).
sibling = module_dir / "other-skill"
sibling.mkdir()
(sibling / "SKILL.md").write_text("---\nname: other-skill\n---\n# other-skill\n")
skill_dir = module_dir / "my-skill"
code, data = run_validate(skill_dir)
assert code == 1, f"Orphan entry should fail validation: {data}"
orphans = [f for f in data["findings"] if f["category"] == "orphan-entry"]
assert any("other-skill" in f["message"] for f in orphans), \
f"Sibling skill must not mask the orphan: {data['findings']}"
def test_multi_skill_not_detected_as_standalone():
"""A folder with two skills and no setup skill should fail (not detected as standalone)."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
module_dir = tmp / "module"
module_dir.mkdir()
for name in ("skill-a", "skill-b"):
skill = module_dir / name
skill.mkdir()
(skill / "SKILL.md").write_text(f"---\nname: {name}\n---\n")
(skill / "assets").mkdir()
(skill / "assets" / "module.yaml").write_text(f'code: tst\nname: "Test"\ndescription: "Test"\n')
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"])
def test_nonexistent_directory():
"""Nonexistent path should return error."""
result = subprocess.run(
[sys.executable, str(SCRIPT), "/nonexistent/path"],
capture_output=True, text=True,
)
assert result.returncode == 2
data = json.loads(result.stdout)
assert data["status"] == "error"
if __name__ == "__main__":
tests = [
test_valid_module,
test_missing_setup_skill,
test_missing_csv_entry,
test_orphan_csv_entry,
test_duplicate_menu_codes,
test_invalid_before_after_ref,
test_missing_yaml_fields,
test_empty_csv,
test_canonical_header_accepted,
test_legacy_after_before_header_flagged,
test_short_row_does_not_crash,
test_valid_standalone_module,
test_standalone_missing_module_setup_md,
test_standalone_missing_merge_scripts,
test_standalone_csv_validation,
test_standalone_underscore_merge_scripts,
test_standalone_cross_module_before_after_ref,
test_standalone_given_skill_dir_directly,
test_standalone_skill_dir_orphan_not_masked_by_sibling,
test_multi_skill_not_detected_as_standalone,
test_nonexistent_directory,
]
passed = 0
failed = 0
for test in tests:
try:
test()
print(f" PASS: {test.__name__}")
passed += 1
except AssertionError as e:
print(f" FAIL: {test.__name__}: {e}")
failed += 1
except Exception as e:
print(f" ERROR: {test.__name__}: {e}")
failed += 1
print(f"\n{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
@@ -0,0 +1,348 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# ///
"""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)
Performs deterministic structural checks:
- Required files exist (setup skill or standalone structure)
- All skill folders have at least one capability entry in the CSV
- No orphan CSV entries pointing to nonexistent skills
- Menu codes are unique
- preceded-by/followed-by references point to real capability entries
- Required module.yaml fields are present
- CSV column count is consistent
"""
import argparse
import csv
import json
import sys
from io import StringIO
from pathlib import Path
REQUIRED_YAML_FIELDS = {"code", "name", "description"}
CSV_HEADER = [
"module", "skill", "display-name", "menu-code", "description",
"action", "args", "phase", "preceded-by", "followed-by", "required",
"output-location", "outputs",
]
def find_setup_skill(module_dir: Path) -> Path | None:
"""Find the setup skill folder (*-setup)."""
for d in module_dir.iterdir():
if d.is_dir() and d.name.endswith("-setup"):
return d
return None
def find_skill_folders(module_dir: Path, exclude_name: str = "") -> list[str]:
"""Find all skill folders (directories with SKILL.md), optionally excluding one."""
skills = []
for d in module_dir.iterdir():
if d.is_dir() and d.name != exclude_name and (d / "SKILL.md").is_file():
skills.append(d.name)
return sorted(skills)
def detect_standalone_module(module_dir: Path) -> Path | None:
"""Detect a standalone module: a single skill folder with assets/module.yaml.
Works whether ``module_dir`` is the parent folder that contains the skill, or
the standalone skill directory itself (the path a user is most likely to hand
over for a single-skill module).
"""
# Given the skill directory directly.
if (module_dir / "SKILL.md").is_file() and (module_dir / "assets" / "module.yaml").is_file():
return module_dir
# Given the parent folder containing exactly one skill.
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():
return candidate
return None
def parse_yaml_minimal(text: str) -> dict[str, str]:
"""Parse top-level YAML key-value pairs (no nested structures)."""
result = {}
for line in text.splitlines():
line = line.strip()
if ":" in line and not line.startswith("#") and not line.startswith("-"):
key, _, value = line.partition(":")
key = key.strip()
value = value.strip().strip('"').strip("'")
if value and not value.startswith(">"):
result[key] = value
return result
def parse_csv_rows(csv_text: str) -> tuple[list[str], list[dict[str, str]], list[int]]:
"""Parse CSV text into (header, row dicts, raw column count per data row).
``restval=""`` fills missing trailing fields in a short row with empty strings
instead of ``None``, so downstream ``.strip()`` calls stay safe on malformed
rows. DictReader pads short rows to the header width, so ``len(row)`` cannot
reveal a field shortfall; the raw per-row column counts from ``csv.reader``
(blank lines skipped, to stay aligned with DictReader) are returned separately
for the column-count consistency check.
"""
reader = csv.DictReader(StringIO(csv_text), restval="")
header = reader.fieldnames or []
rows = list(reader)
raw_rows = list(csv.reader(StringIO(csv_text)))
col_counts = [len(r) for r in raw_rows[1:] if r != []]
return header, rows, col_counts
def validate(module_dir: Path, verbose: bool = False) -> dict:
"""Run all structural validations. Returns JSON-serializable result."""
findings: list[dict] = []
info: dict = {}
def finding(severity: str, category: str, message: str, detail: str = ""):
findings.append({
"severity": severity,
"category": category,
"message": message,
"detail": detail,
})
# 1. Find setup skill or detect standalone module
setup_dir = find_setup_skill(module_dir)
standalone_dir = None
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}
# 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",
}
# Merge scripts: accept either the dash form the scaffolder emits
# (merge-config.py) or the importable underscore form (merge_config.py).
# Both are valid — a module may rename them to be importable from
# module-setup.md without that being a structural defect.
required_any = {
"scripts/merge-config.py (or merge_config.py)": [
skill_dir / "scripts" / "merge-config.py",
skill_dir / "scripts" / "merge_config.py",
],
"scripts/merge-help-csv.py (or merge_help_csv.py)": [
skill_dir / "scripts" / "merge-help-csv.py",
skill_dir / "scripts" / "merge_help_csv.py",
],
}
ok = True
for label, path in required_files.items():
if not path.is_file():
finding("critical", "structure", f"Missing required file: {label}")
ok = False
for label, candidates in required_any.items():
if not any(p.is_file() for p in candidates):
finding("critical", "structure", f"Missing required file: {label}")
ok = False
if not ok:
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",
}
for label, path in required_files.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()):
return {"status": "fail", "findings": findings, "info": info}
yaml_dir = setup_dir
csv_dir = setup_dir
# 3. Validate module.yaml
yaml_text = (yaml_dir / "assets" / "module.yaml").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", "")
for field in REQUIRED_YAML_FIELDS:
if not yaml_data.get(field):
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")
header, rows, col_counts = parse_csv_rows(csv_text)
# Check header
if header != CSV_HEADER:
missing = set(CSV_HEADER) - set(header)
extra = set(header) - set(CSV_HEADER)
detail_parts = []
if missing:
detail_parts.append(f"missing: {', '.join(sorted(missing))}")
if extra:
detail_parts.append(f"extra: {', '.join(sorted(extra))}")
finding("high", "csv-header", f"CSV header mismatch: {'; '.join(detail_parts)}")
if not rows:
finding("high", "csv-empty", "module-help.csv has no capability entries")
return {"status": "fail", "findings": findings, "info": info}
info["csv_entries"] = len(rows)
# 5. Check column count consistency (using raw field counts: DictReader pads
# short rows to the header width, so len(row) alone can't detect a shortfall)
expected_cols = len(CSV_HEADER)
for i, (row, n_cols) in enumerate(zip(rows, col_counts)):
if n_cols != expected_cols:
finding("medium", "csv-columns", f"Row {i + 2} has {n_cols} columns, expected {expected_cols}",
f"skill={row.get('skill', '?')}")
# 6. Collect skills from CSV and filesystem
csv_skills = {row.get("skill", "") for row in rows}
if standalone_dir:
# The only valid skill is the standalone skill itself, whether we were
# handed the module's parent folder or the skill directory directly.
skill_folders = [standalone_dir.name]
else:
skill_folders = find_skill_folders(module_dir, setup_dir.name)
info["skill_folders"] = skill_folders
info["csv_skills"] = sorted(csv_skills)
# 7. Skills without CSV entries
for skill in skill_folders:
if skill not in csv_skills:
finding("high", "missing-entry", f"Skill '{skill}' has no capability entries in the CSV")
# 8. Orphan CSV entries
setup_name = setup_dir.name if setup_dir else ""
for skill in csv_skills:
if skill in skill_folders or skill == setup_name:
continue
# For a standalone module, skill_folders already enumerates every valid
# skill, so any other CSV skill is an orphan — never look at the parent
# folder (which may hold unrelated sibling skills when validating a skill
# dir directly). For a multi-skill module, re-check the filesystem: the
# setup skill lives alongside the others and is excluded from skill_folders.
if standalone_dir or not (module_dir / skill / "SKILL.md").is_file():
finding("high", "orphan-entry", f"CSV references skill '{skill}' which does not exist in the module folder")
# 9. Unique menu codes
menu_codes: dict[str, list[str]] = {}
for row in rows:
code = row.get("menu-code", "").strip()
if code:
menu_codes.setdefault(code, []).append(row.get("display-name", "?"))
for code, names in menu_codes.items():
if len(names) > 1:
finding("high", "duplicate-menu-code", f"Menu code '{code}' used by multiple entries: {', '.join(names)}")
# 10. preceded-by/followed-by reference validation
# Build set of valid capability references (skill:action)
valid_refs = set()
for row in rows:
skill = row.get("skill", "").strip()
action = row.get("action", "").strip()
if skill and action:
valid_refs.add(f"{skill}:{action}")
for row in rows:
display = row.get("display-name", "?")
for field in ("preceded-by", "followed-by"):
value = row.get(field, "").strip()
if not value:
continue
# Can be comma-separated
for ref in value.split(","):
ref = ref.strip()
if not ref:
continue
# A colon-less ref is a cross-module positional reference (a bare
# sibling-module skill name, e.g. "bmad-sprint-planning"). Other
# installed modules aren't visible here, so it can't be resolved
# and isn't a defect — only validate intra-module skill:action refs.
if ":" not in ref:
continue
if ref not in valid_refs:
finding("medium", "invalid-ref",
f"'{display}' {field} references '{ref}' which is not a valid capability",
"Expected format: skill-name:action-name")
# 11. Required fields in each row
for row in rows:
display = row.get("display-name", "?")
for field in ("skill", "display-name", "menu-code", "description"):
if not row.get(field, "").strip():
finding("high", "missing-field", f"Entry '{display}' is missing required field: {field}")
# Summary
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for f in findings:
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
status = "pass" if severity_counts["critical"] == 0 and severity_counts["high"] == 0 else "fail"
return {
"status": status,
"info": info,
"findings": findings,
"summary": {
"total_findings": len(findings),
"by_severity": severity_counts,
},
}
def main() -> int:
parser = argparse.ArgumentParser(
description="Validate a BMad module's setup skill 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)",
)
parser.add_argument("--verbose", action="store_true", help="Print progress to stderr")
args = parser.parse_args()
module_path = Path(args.module_dir)
if not module_path.is_dir():
print(json.dumps({"status": "error", "message": f"Not a directory: {module_path}"}))
return 2
result = validate(module_path, verbose=args.verbose)
print(json.dumps(result, indent=2))
return 0 if result["status"] == "pass" else 1
if __name__ == "__main__":
sys.exit(main())