83f73c5cea
tea and tdl were two repositories, each carrying its own
.claude-plugin/marketplace.json — two marketplaces to register for what
is one collection. Fold them into one.
The repo root is now the marketplace and nothing else: a single
.claude-plugin/marketplace.json whose entries point at ./plugins/tea and
./plugins/tdl. A plugin's root is its own directory under plugins/, so
${CLAUDE_PLUGIN_ROOT} still resolves inside it and every path a plugin
uses stays relative to itself — the hooks and the test roots needed no
adjustment beyond the move.
tea's files move with git mv, so its history and blame follow. tdl
arrives as a plain copy; its history stays in claude-skills/threedotslab.
test_payload_root asserted `tmp/` was ignored by REPO/.gitignore. The
rule is that tmp/ is ignored, not which file says so, and git reads every
.gitignore on the way up — so the test now walks up to the repo root the
same way git does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
142 lines
4.7 KiB
Bash
Executable File
142 lines
4.7 KiB
Bash
Executable File
#!/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)
|