feat: add agents-sync hook and AGENTS.md
Keep AGENTS.md as the single source of agent docs: agents-sync runs before every Bash command and makes each directory canonical — AGENTS.md real file, CLAUDE.md a symlink to it. Renames a lone CLAUDE.md, creates or re-points symlinks, replaces identical duplicates; differing files are only reported, never merged. Fails open so it can never block a command. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/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 "<root>"))
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user