Skip to content

Commit 744a669

Browse files
luiseimanclaude
andcommitted
chore: scripts/wire_hooks_all.py — programmatic hook wiring + deny union
Companion to sync_all.py. After files are added on disk, this script wires them into each project's settings.json by matching event/matcher entries against template/settings.json.tmpl, and union-merges the deny list. Safety guarantees: - Idempotent — already-wired hooks are detected by basename and skipped - Custom project hooks (incl. v3 generated/* behaviors) preserved - Allow list NOT touched (project-owned) - CLAUDE.md NOT touched (custom sections risk) - settings.json.bak.<ts> backup created on every modified project - Validates resulting JSON before writing Today's batch (12 projects): - 37 hooks wired (mostly tool-latency.sh + permission-denied.sh from v3.4.0; jira-nbch had block-destructive.sh on disk but never wired into PreToolUse) - 52 deny entries added (most projects missing the 4 v3.x additions: DROP TABLE, DROP DATABASE, git checkout -- *, git checkout .; SOMA2 was missing 8 — incl. Read(**/.env) — significant gap closed) All 12 settings.json validated post-write. Audit re-run shows stable scores (avg 9.12, no regressions, no improvements — checklist doesn't reward 'more hooks wired', only file presence). Use: python3 scripts/wire_hooks_all.py --dry-run python3 scripts/wire_hooks_all.py Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b294bc8 commit 744a669

2 files changed

Lines changed: 255 additions & 1 deletion

File tree

.claude/settings.json

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@
2424
"Read(**/.env)",
2525
"Read(**/*.key)",
2626
"Read(**/*.pem)",
27-
"Read(**/*credentials*)"
27+
"Read(**/*credentials*)",
28+
"Bash(DROP TABLE*)",
29+
"Bash(DROP DATABASE*)",
30+
"Bash(git checkout -- *)",
31+
"Bash(git checkout .)"
2832
]
2933
},
3034
"hooks": {
@@ -135,6 +139,16 @@
135139
"timeout": 5
136140
}
137141
]
142+
},
143+
{
144+
"matcher": "",
145+
"hooks": [
146+
{
147+
"type": "command",
148+
"command": ".claude/hooks/tool-latency.sh",
149+
"timeout": 5
150+
}
151+
]
138152
}
139153
],
140154
"PostCompact": [
@@ -160,6 +174,18 @@
160174
}
161175
]
162176
}
177+
],
178+
"PermissionDenied": [
179+
{
180+
"matcher": "",
181+
"hooks": [
182+
{
183+
"type": "command",
184+
"command": ".claude/hooks/permission-denied.sh",
185+
"timeout": 5
186+
}
187+
]
188+
}
163189
]
164190
}
165191
}

scripts/wire_hooks_all.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
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

Comments
 (0)