merge-help-csv.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. #!/usr/bin/env python3
  2. # /// script
  3. # requires-python = ">=3.9"
  4. # dependencies = []
  5. # ///
  6. """Merge module help entries into shared _bmad/module-help.csv.
  7. Reads a source CSV with module help entries and merges them into a target CSV.
  8. Uses an anti-zombie pattern: all existing rows matching the source module code
  9. are removed before appending fresh rows.
  10. Legacy cleanup: when --legacy-dir and --module-code are provided, deletes old
  11. per-module module-help.csv files from {legacy-dir}/{module-code}/ and
  12. {legacy-dir}/core/. Only the current module and core are touched.
  13. Exit codes: 0=success, 1=validation error, 2=runtime error
  14. """
  15. import argparse
  16. import csv
  17. import json
  18. import sys
  19. from io import StringIO
  20. from pathlib import Path
  21. # CSV header for module-help.csv
  22. HEADER = [
  23. "module",
  24. "skill",
  25. "display-name",
  26. "menu-code",
  27. "description",
  28. "action",
  29. "args",
  30. "phase",
  31. "after",
  32. "before",
  33. "required",
  34. "output-location",
  35. "outputs",
  36. ]
  37. def parse_args():
  38. parser = argparse.ArgumentParser(
  39. description="Merge module help entries into shared _bmad/module-help.csv with anti-zombie pattern."
  40. )
  41. parser.add_argument(
  42. "--target",
  43. required=True,
  44. help="Path to the target _bmad/module-help.csv file",
  45. )
  46. parser.add_argument(
  47. "--source",
  48. required=True,
  49. help="Path to the source module-help.csv with entries to merge",
  50. )
  51. parser.add_argument(
  52. "--legacy-dir",
  53. help="Path to _bmad/ directory to check for legacy per-module CSV files.",
  54. )
  55. parser.add_argument(
  56. "--module-code",
  57. help="Module code (required with --legacy-dir for scoping cleanup).",
  58. )
  59. parser.add_argument(
  60. "--verbose",
  61. action="store_true",
  62. help="Print detailed progress to stderr",
  63. )
  64. return parser.parse_args()
  65. def read_csv_rows(path: str) -> tuple[list[str], list[list[str]]]:
  66. """Read CSV file returning (header, data_rows).
  67. Returns empty header and rows if file doesn't exist.
  68. """
  69. file_path = Path(path)
  70. if not file_path.exists():
  71. return [], []
  72. with open(file_path, "r", encoding="utf-8", newline="") as f:
  73. content = f.read()
  74. reader = csv.reader(StringIO(content))
  75. rows = list(reader)
  76. if not rows:
  77. return [], []
  78. return rows[0], rows[1:]
  79. def extract_module_codes(rows: list[list[str]]) -> set[str]:
  80. """Extract unique module codes from data rows."""
  81. codes = set()
  82. for row in rows:
  83. if row and row[0].strip():
  84. codes.add(row[0].strip())
  85. return codes
  86. def filter_rows(rows: list[list[str]], module_code: str) -> list[list[str]]:
  87. """Remove all rows matching the given module code."""
  88. return [row for row in rows if not row or row[0].strip() != module_code]
  89. def write_csv(path: str, header: list[str], rows: list[list[str]], verbose: bool = False) -> None:
  90. """Write header + rows to CSV file, creating parent dirs as needed."""
  91. file_path = Path(path)
  92. file_path.parent.mkdir(parents=True, exist_ok=True)
  93. if verbose:
  94. print(f"Writing {len(rows)} data rows to {path}", file=sys.stderr)
  95. with open(file_path, "w", encoding="utf-8", newline="") as f:
  96. writer = csv.writer(f)
  97. writer.writerow(header)
  98. for row in rows:
  99. writer.writerow(row)
  100. def cleanup_legacy_csvs(legacy_dir: str, module_code: str, verbose: bool = False) -> list:
  101. """Delete legacy per-module module-help.csv files for this module and core only.
  102. Returns list of deleted file paths.
  103. """
  104. deleted = []
  105. for subdir in (module_code, "core"):
  106. legacy_path = Path(legacy_dir) / subdir / "module-help.csv"
  107. if legacy_path.exists():
  108. if verbose:
  109. print(f"Deleting legacy CSV: {legacy_path}", file=sys.stderr)
  110. legacy_path.unlink()
  111. deleted.append(str(legacy_path))
  112. return deleted
  113. def reject_unresolved_paths(named_paths: list[tuple[str, str]]) -> None:
  114. """Exit with a clear error if any path argument still contains the literal
  115. ``{project-root}`` token. That token is meaningful only inside config
  116. values; filesystem path arguments must be resolved by the caller. Failing
  117. loudly here prevents silently creating a junk ``{project-root}/`` directory.
  118. """
  119. for name, value in named_paths:
  120. if value and "{project-root}" in value:
  121. print(
  122. json.dumps(
  123. {
  124. "status": "error",
  125. "error": (
  126. f"Unresolved '{{project-root}}' token in {name} path: {value!r}. "
  127. "Resolve '{project-root}' to the actual project root before running "
  128. "this script — it is a filesystem path, not a config value."
  129. ),
  130. },
  131. indent=2,
  132. )
  133. )
  134. sys.exit(1)
  135. def main():
  136. args = parse_args()
  137. reject_unresolved_paths([("--target", args.target), ("--legacy-dir", args.legacy_dir)])
  138. # Read source entries
  139. source_header, source_rows = read_csv_rows(args.source)
  140. if not source_rows:
  141. print(f"Error: No data rows found in source {args.source}", file=sys.stderr)
  142. sys.exit(1)
  143. # Determine module codes being merged
  144. source_codes = extract_module_codes(source_rows)
  145. if not source_codes:
  146. print("Error: Could not determine module code from source rows", file=sys.stderr)
  147. sys.exit(1)
  148. if args.verbose:
  149. print(f"Source module codes: {source_codes}", file=sys.stderr)
  150. print(f"Source rows: {len(source_rows)}", file=sys.stderr)
  151. # Read existing target (may not exist)
  152. target_header, target_rows = read_csv_rows(args.target)
  153. target_existed = Path(args.target).exists()
  154. if args.verbose:
  155. print(f"Target exists: {target_existed}", file=sys.stderr)
  156. if target_existed:
  157. print(f"Existing target rows: {len(target_rows)}", file=sys.stderr)
  158. # Use source header if target doesn't exist or has no header
  159. header = target_header if target_header else (source_header if source_header else HEADER)
  160. # Anti-zombie: remove all rows for each source module code
  161. filtered_rows = target_rows
  162. removed_count = 0
  163. for code in source_codes:
  164. before_count = len(filtered_rows)
  165. filtered_rows = filter_rows(filtered_rows, code)
  166. removed_count += before_count - len(filtered_rows)
  167. if args.verbose and removed_count > 0:
  168. print(f"Removed {removed_count} existing rows (anti-zombie)", file=sys.stderr)
  169. # Append source rows
  170. merged_rows = filtered_rows + source_rows
  171. # Write result
  172. write_csv(args.target, header, merged_rows, args.verbose)
  173. # Legacy cleanup: delete old per-module CSV files
  174. legacy_deleted = []
  175. if args.legacy_dir:
  176. if not args.module_code:
  177. print(
  178. "Error: --module-code is required when --legacy-dir is provided",
  179. file=sys.stderr,
  180. )
  181. sys.exit(1)
  182. legacy_deleted = cleanup_legacy_csvs(args.legacy_dir, args.module_code, args.verbose)
  183. # Output result summary as JSON
  184. result = {
  185. "status": "success",
  186. "target_path": str(Path(args.target).resolve()),
  187. "target_existed": target_existed,
  188. "module_codes": sorted(source_codes),
  189. "rows_removed": removed_count,
  190. "rows_added": len(source_rows),
  191. "total_rows": len(merged_rows),
  192. "legacy_csvs_deleted": legacy_deleted,
  193. }
  194. print(json.dumps(result, indent=2))
  195. if __name__ == "__main__":
  196. main()