scaffold-standalone-module.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. #!/usr/bin/env python3
  2. # /// script
  3. # requires-python = ">=3.10"
  4. # ///
  5. """Scaffold standalone module infrastructure into an existing skill.
  6. Copies template files (module-setup.md, merge scripts) into the skill directory
  7. and generates a .claude-plugin/marketplace.json for distribution. The LLM writes
  8. module.yaml and module-help.csv directly to the skill's assets/ folder before
  9. running this script.
  10. """
  11. import argparse
  12. import json
  13. import sys
  14. from pathlib import Path
  15. def main() -> int:
  16. parser = argparse.ArgumentParser(
  17. description="Scaffold standalone module infrastructure into an existing skill"
  18. )
  19. parser.add_argument(
  20. "--skill-dir",
  21. required=True,
  22. help="Path to the existing skill directory (must contain SKILL.md)",
  23. )
  24. parser.add_argument(
  25. "--module-code",
  26. required=True,
  27. help="Module code (2-4 letter abbreviation, e.g. 'exc')",
  28. )
  29. parser.add_argument(
  30. "--module-name",
  31. required=True,
  32. help="Module display name (e.g. 'Excalidraw Tools')",
  33. )
  34. parser.add_argument(
  35. "--marketplace-dir",
  36. default=None,
  37. help="Directory to create .claude-plugin/ in (defaults to skill-dir parent)",
  38. )
  39. parser.add_argument(
  40. "--verbose", action="store_true", help="Print progress to stderr"
  41. )
  42. args = parser.parse_args()
  43. template_dir = (
  44. Path(__file__).resolve().parent.parent
  45. / "assets"
  46. / "standalone-module-template"
  47. )
  48. skill_dir = Path(args.skill_dir).resolve()
  49. marketplace_dir = (
  50. Path(args.marketplace_dir).resolve() if args.marketplace_dir else skill_dir.parent
  51. )
  52. # --- Validation ---
  53. if not template_dir.is_dir():
  54. print(
  55. json.dumps({"status": "error", "message": f"Template not found: {template_dir}"}),
  56. file=sys.stdout,
  57. )
  58. return 2
  59. if not skill_dir.is_dir():
  60. print(
  61. json.dumps({"status": "error", "message": f"Skill directory not found: {skill_dir}"}),
  62. file=sys.stdout,
  63. )
  64. return 2
  65. if not (skill_dir / "SKILL.md").is_file():
  66. print(
  67. json.dumps({"status": "error", "message": f"No SKILL.md found in {skill_dir}"}),
  68. file=sys.stdout,
  69. )
  70. return 2
  71. if not (skill_dir / "assets" / "module.yaml").is_file():
  72. print(
  73. json.dumps({
  74. "status": "error",
  75. "message": f"assets/module.yaml not found in {skill_dir} — the LLM must write it before running this script",
  76. }),
  77. file=sys.stdout,
  78. )
  79. return 2
  80. # --- Copy template files ---
  81. files_created: list[str] = []
  82. files_skipped: list[str] = []
  83. warnings: list[str] = []
  84. # 1. Copy module-setup.md to assets/ (alongside module.yaml and module-help.csv)
  85. assets_dir = skill_dir / "assets"
  86. assets_dir.mkdir(exist_ok=True)
  87. src_setup = template_dir / "module-setup.md"
  88. dst_setup = assets_dir / "module-setup.md"
  89. if args.verbose:
  90. print(f"Copying module-setup.md to {dst_setup}", file=sys.stderr)
  91. dst_setup.write_bytes(src_setup.read_bytes())
  92. files_created.append("assets/module-setup.md")
  93. # 2. Copy merge scripts to scripts/
  94. scripts_dir = skill_dir / "scripts"
  95. scripts_dir.mkdir(exist_ok=True)
  96. for script_name in ("merge-config.py", "merge-help-csv.py"):
  97. src = template_dir / script_name
  98. dst = scripts_dir / script_name
  99. if dst.exists():
  100. msg = f"scripts/{script_name} already exists — skipped to avoid overwriting"
  101. files_skipped.append(f"scripts/{script_name}")
  102. warnings.append(msg)
  103. if args.verbose:
  104. print(f"SKIP: {msg}", file=sys.stderr)
  105. else:
  106. if args.verbose:
  107. print(f"Copying {script_name} to {dst}", file=sys.stderr)
  108. dst.write_bytes(src.read_bytes())
  109. dst.chmod(0o755)
  110. files_created.append(f"scripts/{script_name}")
  111. # 3. Generate marketplace.json
  112. plugin_dir = marketplace_dir / ".claude-plugin"
  113. plugin_dir.mkdir(parents=True, exist_ok=True)
  114. marketplace_json = plugin_dir / "marketplace.json"
  115. # Read module.yaml for description and version
  116. module_yaml_path = skill_dir / "assets" / "module.yaml"
  117. module_description = ""
  118. module_version = "1.0.0"
  119. try:
  120. yaml_text = module_yaml_path.read_text(encoding="utf-8")
  121. for line in yaml_text.splitlines():
  122. stripped = line.strip()
  123. if stripped.startswith("description:"):
  124. module_description = stripped.split(":", 1)[1].strip().strip('"').strip("'")
  125. elif stripped.startswith("module_version:"):
  126. module_version = stripped.split(":", 1)[1].strip().strip('"').strip("'")
  127. except Exception:
  128. pass
  129. skill_dir_name = skill_dir.name
  130. marketplace_data = {
  131. "name": args.module_code,
  132. "owner": {"name": ""},
  133. "license": "",
  134. "homepage": "",
  135. "repository": "",
  136. "keywords": ["bmad"],
  137. "plugins": [
  138. {
  139. "name": args.module_code,
  140. "source": "./",
  141. "description": module_description,
  142. "version": module_version,
  143. "author": {"name": ""},
  144. "skills": [f"./{skill_dir_name}"],
  145. }
  146. ],
  147. }
  148. if args.verbose:
  149. print(f"Writing marketplace.json to {marketplace_json}", file=sys.stderr)
  150. marketplace_json.write_text(
  151. json.dumps(marketplace_data, indent=2) + "\n", encoding="utf-8"
  152. )
  153. files_created.append(".claude-plugin/marketplace.json")
  154. # --- Result ---
  155. result = {
  156. "status": "success",
  157. "skill_dir": str(skill_dir),
  158. "module_code": args.module_code,
  159. "files_created": files_created,
  160. "files_skipped": files_skipped,
  161. "warnings": warnings,
  162. "marketplace_json": str(marketplace_json),
  163. }
  164. print(json.dumps(result, indent=2))
  165. return 0
  166. if __name__ == "__main__":
  167. sys.exit(main())