Files
naudachu 83f73c5cea refactor: turn the repo into a two-plugin marketplace
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>
2026-08-11 00:25:28 +05:00

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)