|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""wire_hooks_all.py — programmatic deep sync: wire missing hooks into each |
| 3 | +project's settings.json + union-merge deny list. |
| 4 | +
|
| 5 | +Companion to sync_all.py (which only adds files on disk). This script wires |
| 6 | +those files into settings.json so they actually fire. |
| 7 | +
|
| 8 | +Strategy: |
| 9 | +- For each event/matcher in template/settings.json.tmpl's hooks block, ensure |
| 10 | + the corresponding command is registered in the project's settings.json. |
| 11 | +- Hooks already wired are left alone (idempotent). |
| 12 | +- Custom hooks the project has are preserved. |
| 13 | +- Deny list: union of template deny + project deny (never removes). |
| 14 | +- Allow list: NOT touched (project-owned). |
| 15 | +- CLAUDE.md: NOT touched. |
| 16 | +
|
| 17 | +Safety: |
| 18 | +- Validates JSON before writing. |
| 19 | +- Backs up settings.json to settings.json.bak.<timestamp> on first change. |
| 20 | +- --dry-run shows planned wiring without writing. |
| 21 | +
|
| 22 | +Usage: |
| 23 | + python3 scripts/wire_hooks_all.py --dry-run |
| 24 | + python3 scripts/wire_hooks_all.py |
| 25 | +""" |
| 26 | +from __future__ import annotations |
| 27 | + |
| 28 | +import argparse |
| 29 | +import copy |
| 30 | +import json |
| 31 | +import sys |
| 32 | +from datetime import datetime |
| 33 | +from pathlib import Path |
| 34 | + |
| 35 | +try: |
| 36 | + import yaml |
| 37 | +except ImportError: |
| 38 | + print("ERROR: pyyaml required", file=sys.stderr) |
| 39 | + sys.exit(2) |
| 40 | + |
| 41 | +DOTFORGE = Path(__file__).resolve().parent.parent |
| 42 | +REGISTRY = DOTFORGE / "registry/projects.local.yml" |
| 43 | +TEMPLATE_SETTINGS = DOTFORGE / "template/settings.json.tmpl" |
| 44 | + |
| 45 | + |
| 46 | +def load_template_hooks() -> dict: |
| 47 | + s = json.loads(TEMPLATE_SETTINGS.read_text()) |
| 48 | + return s.get("hooks", {}) |
| 49 | + |
| 50 | + |
| 51 | +def load_template_deny() -> list[str]: |
| 52 | + s = json.loads(TEMPLATE_SETTINGS.read_text()) |
| 53 | + return list(s.get("permissions", {}).get("deny", [])) |
| 54 | + |
| 55 | + |
| 56 | +def hook_command_exists(event_entries: list, command: str) -> bool: |
| 57 | + for entry in event_entries or []: |
| 58 | + for h in entry.get("hooks", []): |
| 59 | + cmd = h.get("command", "") |
| 60 | + # Match on basename so we tolerate path variations (.claude/hooks/X.sh |
| 61 | + # vs $DOTFORGE_DIR/hooks/X.sh vs absolute path) |
| 62 | + if cmd.endswith(command.split("/")[-1]): |
| 63 | + return True |
| 64 | + return False |
| 65 | + |
| 66 | + |
| 67 | +def wire_event(project_event: list, template_event: list, target_command: str, matcher: str) -> bool: |
| 68 | + """Add target_command to the matching matcher group if absent. Returns True if added.""" |
| 69 | + # Look for an existing entry with this matcher |
| 70 | + target_basename = target_command.split("/")[-1] |
| 71 | + for entry in project_event: |
| 72 | + if entry.get("matcher", "") == matcher: |
| 73 | + for h in entry.get("hooks", []): |
| 74 | + if h.get("command", "").endswith(target_basename): |
| 75 | + return False # already wired |
| 76 | + # Add to this matcher group |
| 77 | + tmpl_hook = next( |
| 78 | + (h for tentry in template_event if tentry.get("matcher", "") == matcher |
| 79 | + for h in tentry.get("hooks", []) if h.get("command", "").endswith(target_basename)), |
| 80 | + None, |
| 81 | + ) |
| 82 | + if tmpl_hook: |
| 83 | + entry.setdefault("hooks", []).append(copy.deepcopy(tmpl_hook)) |
| 84 | + return True |
| 85 | + return False |
| 86 | + # No existing entry with this matcher — clone template's full entry for this matcher |
| 87 | + tmpl_entry = next( |
| 88 | + (e for e in template_event if e.get("matcher", "") == matcher), |
| 89 | + None, |
| 90 | + ) |
| 91 | + if tmpl_entry: |
| 92 | + new_entry = copy.deepcopy(tmpl_entry) |
| 93 | + # Strip out hooks not matching our target command, keep only the one we want |
| 94 | + new_entry["hooks"] = [ |
| 95 | + h for h in new_entry.get("hooks", []) |
| 96 | + if h.get("command", "").endswith(target_basename) |
| 97 | + ] |
| 98 | + if new_entry["hooks"]: |
| 99 | + project_event.append(new_entry) |
| 100 | + return True |
| 101 | + return False |
| 102 | + |
| 103 | + |
| 104 | +def sync_project(settings_path: Path, template_hooks: dict, template_deny: list[str], dry: bool) -> dict: |
| 105 | + """Returns dict of changes: hooks_added, deny_added, total_changes.""" |
| 106 | + if not settings_path.exists(): |
| 107 | + return {"error": "settings.json missing"} |
| 108 | + |
| 109 | + try: |
| 110 | + s = json.loads(settings_path.read_text()) |
| 111 | + except Exception as e: |
| 112 | + return {"error": f"invalid JSON: {e}"} |
| 113 | + |
| 114 | + original = copy.deepcopy(s) |
| 115 | + hooks_added = [] |
| 116 | + deny_added = [] |
| 117 | + |
| 118 | + # Wire each hook from template |
| 119 | + proj_hooks = s.setdefault("hooks", {}) |
| 120 | + for event_name, template_event in template_hooks.items(): |
| 121 | + proj_event = proj_hooks.setdefault(event_name, []) |
| 122 | + # Each template entry may have one or more hooks; wire each individually |
| 123 | + for tentry in template_event: |
| 124 | + matcher = tentry.get("matcher", "") |
| 125 | + for h in tentry.get("hooks", []): |
| 126 | + cmd = h.get("command", "") |
| 127 | + if wire_event(proj_event, template_event, cmd, matcher): |
| 128 | + hooks_added.append(f"{event_name}[{matcher or '*'}]: {cmd.split('/')[-1]}") |
| 129 | + |
| 130 | + # Union-merge deny list |
| 131 | + perms = s.setdefault("permissions", {}) |
| 132 | + proj_deny = perms.setdefault("deny", []) |
| 133 | + for entry in template_deny: |
| 134 | + if entry not in proj_deny: |
| 135 | + proj_deny.append(entry) |
| 136 | + deny_added.append(entry) |
| 137 | + |
| 138 | + if not hooks_added and not deny_added: |
| 139 | + return {"no_changes": True} |
| 140 | + |
| 141 | + # Validate JSON before writing |
| 142 | + try: |
| 143 | + json.dumps(s) # should always succeed but cheap to verify |
| 144 | + except Exception as e: |
| 145 | + return {"error": f"resulting JSON invalid: {e}"} |
| 146 | + |
| 147 | + if not dry: |
| 148 | + # Backup |
| 149 | + ts = datetime.now().strftime("%Y%m%d-%H%M%S") |
| 150 | + backup = settings_path.with_suffix(f".json.bak.{ts}") |
| 151 | + backup.write_text(json.dumps(original, indent=2) + "\n") |
| 152 | + # Write |
| 153 | + settings_path.write_text(json.dumps(s, indent=2) + "\n") |
| 154 | + |
| 155 | + return { |
| 156 | + "hooks_added": hooks_added, |
| 157 | + "deny_added": deny_added, |
| 158 | + "total_changes": len(hooks_added) + len(deny_added), |
| 159 | + } |
| 160 | + |
| 161 | + |
| 162 | +def main() -> int: |
| 163 | + ap = argparse.ArgumentParser() |
| 164 | + ap.add_argument("--dry-run", action="store_true") |
| 165 | + args = ap.parse_args() |
| 166 | + |
| 167 | + template_hooks = load_template_hooks() |
| 168 | + template_deny = load_template_deny() |
| 169 | + |
| 170 | + print(f"═══ WIRE HOOKS + MERGE DENY (programmatic deep sync) ═══") |
| 171 | + print(f"Template events: {list(template_hooks.keys())}") |
| 172 | + print(f"Template deny: {len(template_deny)} entries") |
| 173 | + print(f"Dry-run: {args.dry_run}\n") |
| 174 | + |
| 175 | + with open(REGISTRY) as f: |
| 176 | + data = yaml.safe_load(f) |
| 177 | + |
| 178 | + summary = [] |
| 179 | + for proj in data["projects"]: |
| 180 | + name = proj["name"] |
| 181 | + path = Path(proj["path"]) |
| 182 | + if str(path) == ".": |
| 183 | + path = DOTFORGE |
| 184 | + settings = path / ".claude" / "settings.json" |
| 185 | + result = sync_project(settings, template_hooks, template_deny, args.dry_run) |
| 186 | + |
| 187 | + if "error" in result: |
| 188 | + print(f"── {name:<20} ERROR: {result['error']}") |
| 189 | + summary.append((name, "error", 0, 0)) |
| 190 | + continue |
| 191 | + if result.get("no_changes"): |
| 192 | + print(f"── {name:<20} ✓ already wired") |
| 193 | + summary.append((name, "ok", 0, 0)) |
| 194 | + continue |
| 195 | + |
| 196 | + bits = [] |
| 197 | + if result["hooks_added"]: |
| 198 | + bits.append(f"+{len(result['hooks_added'])} hooks") |
| 199 | + if result["deny_added"]: |
| 200 | + bits.append(f"+{len(result['deny_added'])} deny") |
| 201 | + print(f"── {name:<20} {' | '.join(bits)}") |
| 202 | + for h in result["hooks_added"]: |
| 203 | + print(f" hook: {h}") |
| 204 | + for d in result["deny_added"]: |
| 205 | + print(f" deny: {d}") |
| 206 | + summary.append((name, "synced", len(result["hooks_added"]), len(result["deny_added"]))) |
| 207 | + |
| 208 | + print("\n═══ SUMMARY ═══") |
| 209 | + total_hooks = sum(s[2] for s in summary) |
| 210 | + total_deny = sum(s[3] for s in summary) |
| 211 | + synced = sum(1 for s in summary if s[1] == "synced") |
| 212 | + already = sum(1 for s in summary if s[1] == "ok") |
| 213 | + errors = sum(1 for s in summary if s[1] == "error") |
| 214 | + print(f"Synced: {synced}/{len(summary)}") |
| 215 | + print(f"Already wired: {already}") |
| 216 | + print(f"Errors: {errors}") |
| 217 | + print(f"Hooks wired: {total_hooks}") |
| 218 | + print(f"Deny entries: {total_deny}") |
| 219 | + if args.dry_run: |
| 220 | + print("\n(dry-run: no files written)") |
| 221 | + else: |
| 222 | + print("\nNote: settings.json.bak.<timestamp> created on each modified project.") |
| 223 | + print(" CLAUDE.md not touched. Allow list not touched. Custom hooks preserved.") |
| 224 | + return 0 if errors == 0 else 1 |
| 225 | + |
| 226 | + |
| 227 | +if __name__ == "__main__": |
| 228 | + sys.exit(main()) |
0 commit comments