#!/usr/bin/env python3 """ agents-sync — PreToolUse(Bash) hook. Before any Bash command runs, walks the project tree and enforces one filesystem invariant in every directory: AGENTS.md is the real file; CLAUDE.md is a symlink pointing at it. Per directory: - AGENTS.md real, no CLAUDE.md ........ create symlink CLAUDE.md -> AGENTS.md - CLAUDE.md real, no AGENTS.md ........ rename to AGENTS.md, symlink back - CLAUDE.md symlink -> AGENTS.md ...... already canonical, nothing to do - CLAUDE.md symlink elsewhere ......... re-point at AGENTS.md - AGENTS.md symlink -> real CLAUDE.md . reversed layout: swap to canonical - both real, identical content ........ replace CLAUDE.md with the symlink - both real, different content ........ DON'T touch; report the conflict The hook never blocks the tool call and never deletes content: every branch either performs a lossless fix or reports. Fixes/conflicts are surfaced via hookSpecificOutput.additionalContext; silence means the tree was already canonical. Any unexpected error fails open (exit 0). """ import sys, os, json, filecmp SKIP_DIRS = {"node_modules", "__pycache__", "venv", "vendor"} def same_file(a, b): try: return os.path.realpath(a) == os.path.realpath(b) except OSError: return False def fix_dir(d, root, fixes, conflicts): agents = os.path.join(d, "AGENTS.md") claude = os.path.join(d, "CLAUDE.md") a = os.path.lexists(agents) c = os.path.lexists(claude) if not a and not c: return rel = lambda p: os.path.relpath(p, root) a_link = a and os.path.islink(agents) c_link = c and os.path.islink(claude) if a and not c: if a_link and not os.path.exists(agents): conflicts.append("%s: broken symlink and no CLAUDE.md" % rel(agents)) return os.symlink("AGENTS.md", claude) fixes.append("%s: created symlink -> AGENTS.md" % rel(claude)) return if c and not a: if c_link: conflicts.append("%s: symlink to missing target (%s)" % (rel(claude), os.readlink(claude))) return os.rename(claude, agents) os.symlink("AGENTS.md", claude) fixes.append("%s: renamed to AGENTS.md, symlink left in place" % rel(claude)) return # Both exist. if c_link: if same_file(claude, agents): return # canonical old = os.readlink(claude) os.remove(claude) os.symlink("AGENTS.md", claude) fixes.append("%s: re-pointed symlink (%s -> AGENTS.md)" % (rel(claude), old)) return if a_link: # Reversed layout: AGENTS.md is the symlink, CLAUDE.md the real file. if same_file(agents, claude): os.remove(agents) os.rename(claude, agents) os.symlink("AGENTS.md", claude) fixes.append("%s: swapped — AGENTS.md is now the real file" % rel(agents)) else: conflicts.append("%s: symlink elsewhere while CLAUDE.md is a real file" % rel(agents)) return # Both are real files. try: identical = filecmp.cmp(agents, claude, shallow=False) except OSError: identical = False if identical: os.remove(claude) os.symlink("AGENTS.md", claude) fixes.append("%s: identical to AGENTS.md, replaced with symlink" % rel(claude)) else: conflicts.append("%s: AGENTS.md and CLAUDE.md are different real files — " "merge manually" % (rel(d) if rel(d) != "." else "")) def main(): try: payload = json.load(sys.stdin) except Exception: payload = {} root = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd() if not os.path.isdir(root): return fixes, conflicts = [], [] for dirpath, dirnames, _ in os.walk(root): dirnames[:] = [n for n in dirnames if n not in SKIP_DIRS and not n.startswith(".")] try: fix_dir(dirpath, root, fixes, conflicts) except OSError: pass # unwritable dir etc. — skip, never block the command if fixes or conflicts: parts = [] if fixes: parts.append("agents-sync fixed:\n " + "\n ".join(fixes)) if conflicts: parts.append("agents-sync needs manual resolution:\n " + "\n ".join(conflicts)) print(json.dumps({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "additionalContext": "\n".join(parts), } })) if __name__ == "__main__": try: main() except Exception: pass # fail open — this hook must never break Bash sys.exit(0)