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>
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)
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/agents-sync.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/tea-guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+280
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tea-guard — PreToolUse(Bash) hook for the `tea` plugin.
|
||||
|
||||
Enforces, deterministically, the one rule prose cannot: every `tea` command
|
||||
that touches Gitea runs under the login the OPERATOR pinned — never one Claude
|
||||
chose. It does this by *resolving and rewriting* the command rather than just
|
||||
checking it:
|
||||
|
||||
Claude must write: tea ... --login "$GITEA_LOGIN" ...
|
||||
The guard rewrites: tea ... --login <operator-pinned-login> ...
|
||||
|
||||
The pin is read from .claude/settings.local.json (env.GITEA_LOGIN) at call
|
||||
time — from the FILE, not the environment — so a freshly pinned login works in
|
||||
the same session with no restart. WHERE that file is looked for is not decided
|
||||
here: skills/auth/scripts/pin.py holds the search order, and the sync
|
||||
scripts resolve the pin through the same module. One order, one copy of it. The
|
||||
guard and the scripts disagreeing about a directory is a bug by construction,
|
||||
and was one: in a git worktree `tea` worked and every script said "no login
|
||||
pinned".
|
||||
|
||||
Rules:
|
||||
- not a `tea` command ............................. allow (passthrough)
|
||||
- tea logins list/ls, tea --version/--help ........ allow (no identity used)
|
||||
- no --login / -l ................................. BLOCK
|
||||
- --login <literal> or --login "$OTHER_VAR" ....... BLOCK (Claude may not pick)
|
||||
- --login "$GITEA_LOGIN", pin found ............... REWRITE to the pin, allow
|
||||
- --login "$GITEA_LOGIN", no pin .................. BLOCK (run /tea:auth)
|
||||
|
||||
"A `tea` command" means the shell would RUN `tea`, not that the string contains
|
||||
the word. The guard used to ask the second question — a substring search over
|
||||
the whole command line — and in a repository whose subject *is* the CLI that is
|
||||
a different question with the same answer far too often: an issue title, a
|
||||
commit message, `grep -rn " tea " docs/` and `echo tea` were all blocked, with
|
||||
a message telling the operator to add `--login` to `git commit`. Worse, the
|
||||
advice was unfollowable: the only way past the guard was to reword the prose.
|
||||
|
||||
So the command is tokenized (heredoc bodies dropped, line continuations
|
||||
folded, backticks and newlines treated as boundaries) and only words in
|
||||
*command position* count — the first word, and the first word after `;`, `&&`,
|
||||
`||`, `|`, `&`, `(`, `)`, `{`, `}`, past any VAR=value assignments and prefix
|
||||
words like `env`/`sudo`/`xargs`. Quoting is what saves the prose: a title or a
|
||||
`-m` message is one token, and one token is never a command. Compound commands
|
||||
stay guarded segment by segment, substitutions included, and every `tea` in the
|
||||
line is checked — not just the first.
|
||||
|
||||
If the line cannot be tokenized at all (unbalanced quotes), the old substring
|
||||
test decides. That direction fails closed: it over-matches, and over-matching
|
||||
blocks.
|
||||
|
||||
Output protocol: exit 0 + JSON {hookSpecificOutput:{updatedInput,...}} to
|
||||
rewrite; exit 2 + stderr to block.
|
||||
"""
|
||||
import sys, os, re, json, shlex
|
||||
|
||||
# The identity layer, reached by the plugin's own layout — the one thing a hook
|
||||
# may assume about where it lives. Import failure is not fatal on its own: a
|
||||
# command that is not `tea` still passes through untouched (see main), and only
|
||||
# a command that needs a login is blocked.
|
||||
sys.path.append(os.path.abspath(os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
os.pardir, "skills", "auth", "scripts")))
|
||||
try:
|
||||
import pin
|
||||
except Exception:
|
||||
pin = None
|
||||
|
||||
PLACEHOLDERS = {"$GITEA_LOGIN", "${GITEA_LOGIN}"}
|
||||
|
||||
# Operators after which the next word is a command again.
|
||||
SEPARATORS = {";", ";;", "&", "&&", "|", "|&", "||", "(", ")", "{", "}"}
|
||||
# Words that stand in front of a command without being one.
|
||||
TRANSPARENT = {"env", "command", "exec", "nohup", "time", "sudo", "xargs",
|
||||
"if", "then", "else", "elif", "while", "until", "do", "!"}
|
||||
|
||||
ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
||||
REDIRECT = re.compile(r"^\d*[<>]+&?\d*-?$")
|
||||
HEREDOC = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
|
||||
# A login flag and its value, in the ORIGINAL text — this is what gets
|
||||
# rewritten, so it works on the raw string rather than on tokens.
|
||||
LOGIN_FLAG = re.compile(r"(--login|(?<![\w-])-l)(\s+|=)(\S+)")
|
||||
# The pre-tokenizer test, kept for the one case tokenizing cannot serve.
|
||||
LOOKS_LIKE_TEA = re.compile(r"(^|[;&|(]|\s)tea(\s|$)")
|
||||
|
||||
NO_LOGIN = ('every `tea` command must include --login "$GITEA_LOGIN" '
|
||||
'(the guard substitutes the operator-pinned login). '
|
||||
'Run /tea:auth if no login is pinned.')
|
||||
|
||||
|
||||
def named_login(raw):
|
||||
return ('do not name the login yourself (got `%s`). Write exactly '
|
||||
'--login "$GITEA_LOGIN"; the guard replaces it with the login '
|
||||
'the operator pinned via /tea:auth. This prevents acting under '
|
||||
'the wrong identity.' % raw)
|
||||
|
||||
|
||||
def unquote(value):
|
||||
for q in ('"', "'"):
|
||||
if len(value) >= 2 and value[0] == q and value[-1] == q:
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
def strip_heredocs(cmd):
|
||||
"""Drop heredoc bodies. They are data the shell feeds to a command, not
|
||||
commands — and a commit message quoting a raw `tea api` call is exactly the
|
||||
thing that used to be unwritable."""
|
||||
lines, kept, i = cmd.split("\n"), [], 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
kept.append(line)
|
||||
i += 1
|
||||
for m in HEREDOC.finditer(line):
|
||||
delim, dash = m.group(2), m.group(0).startswith("<<-")
|
||||
while i < len(lines):
|
||||
probe = lines[i].strip() if dash else lines[i].rstrip()
|
||||
i += 1
|
||||
if probe == delim:
|
||||
break
|
||||
return "\n".join(kept)
|
||||
|
||||
|
||||
def shell_words(cmd):
|
||||
"""Tokens, with operators as tokens of their own and quotes honored.
|
||||
|
||||
Backticks and newlines become separators before tokenizing: shlex knows
|
||||
neither, and both start a command. Inside quotes that substitution is
|
||||
harmless — the token still spans the quotes, and a token is never a
|
||||
command."""
|
||||
text = strip_heredocs(cmd)
|
||||
text = re.sub(r"\\\n", " ", text)
|
||||
text = text.replace("`", " ; ").replace("\n", " ; ")
|
||||
lex = shlex.shlex(text, posix=True, punctuation_chars=True)
|
||||
lex.whitespace_split = True
|
||||
return list(lex)
|
||||
|
||||
|
||||
def tea_invocations(words):
|
||||
"""The argument list of every `tea` the shell would actually run."""
|
||||
found, current, expect, skip = [], None, True, False
|
||||
for w in words:
|
||||
if skip:
|
||||
skip = False
|
||||
continue
|
||||
if REDIRECT.match(w):
|
||||
skip = True # the target of a redirection is not a command
|
||||
continue
|
||||
if w in SEPARATORS:
|
||||
current, expect = None, True
|
||||
continue
|
||||
if expect:
|
||||
if ASSIGNMENT.match(w) or w in TRANSPARENT:
|
||||
continue
|
||||
expect = False
|
||||
if w.rsplit("/", 1)[-1] == "tea":
|
||||
current = []
|
||||
found.append(current)
|
||||
continue
|
||||
if current is not None:
|
||||
current.append(w)
|
||||
return found
|
||||
|
||||
|
||||
def is_meta(args):
|
||||
"""Login enumeration and `--version`/`--help`: no identity is used, and
|
||||
/tea:auth needs `tea logins list` while no pin exists yet."""
|
||||
if not args:
|
||||
return False
|
||||
if args[0] in ("--version", "-v", "--help", "-h", "help"):
|
||||
return True
|
||||
return args[0] in ("logins", "login") and len(args) > 1 \
|
||||
and args[1] in ("list", "ls")
|
||||
|
||||
|
||||
def login_value(args):
|
||||
"""The login as written, or None if the flag is absent."""
|
||||
for i, a in enumerate(args):
|
||||
if a in ("--login", "-l"):
|
||||
return args[i + 1] if i + 1 < len(args) else ""
|
||||
if a.startswith("--login=") or a.startswith("-l="):
|
||||
return a.split("=", 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def substitute(cmd, login):
|
||||
"""Every placeholder login in the line, replaced by the pin. Every one:
|
||||
a command may run `tea` twice, and half a rewrite leaves the second call
|
||||
with an unset variable and no login at all."""
|
||||
def repl(m):
|
||||
if unquote(m.group(3)) in PLACEHOLDERS:
|
||||
return m.group(1) + m.group(2) + shlex.quote(login)
|
||||
return m.group(0)
|
||||
return LOGIN_FLAG.sub(repl, cmd)
|
||||
|
||||
|
||||
def block(msg):
|
||||
sys.stderr.write("tea-guard: BLOCKED — " + msg + "\n")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def allow_passthrough():
|
||||
# exit 0 with no stdout → tool runs unchanged
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def rewrite(tool_input, new_cmd, note):
|
||||
updated = dict(tool_input)
|
||||
updated["command"] = new_cmd
|
||||
print(json.dumps({
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"updatedInput": updated,
|
||||
"additionalContext": note,
|
||||
}
|
||||
}))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except Exception:
|
||||
# Can't parse the hook payload — fail open for non-tea safety, but we
|
||||
# can't even read the command, so don't block arbitrary Bash.
|
||||
allow_passthrough()
|
||||
|
||||
tool_input = payload.get("tool_input") or {}
|
||||
cmd = tool_input.get("command") or ""
|
||||
|
||||
try:
|
||||
runs = tea_invocations(shell_words(cmd))
|
||||
except ValueError:
|
||||
# Unbalanced quotes: what the shell would run is not knowable here.
|
||||
# Fall back to the substring test — it over-matches, and over-matching
|
||||
# blocks rather than lets an unpinned call through.
|
||||
runs = None
|
||||
|
||||
if runs is None:
|
||||
if not LOOKS_LIKE_TEA.search(cmd):
|
||||
allow_passthrough()
|
||||
m = LOGIN_FLAG.search(cmd)
|
||||
if not m:
|
||||
block(NO_LOGIN)
|
||||
if unquote(m.group(3)) not in PLACEHOLDERS:
|
||||
block(named_login(m.group(3)))
|
||||
else:
|
||||
# The word appears but nothing runs it → not our concern. This is the
|
||||
# branch that lets prose about the CLI be written at all.
|
||||
if not runs:
|
||||
allow_passthrough()
|
||||
for args in runs:
|
||||
if is_meta(args):
|
||||
continue
|
||||
raw = login_value(args)
|
||||
if raw is None:
|
||||
block(NO_LOGIN)
|
||||
if unquote(raw) not in PLACEHOLDERS:
|
||||
block(named_login(raw))
|
||||
if all(is_meta(args) for args in runs):
|
||||
allow_passthrough()
|
||||
|
||||
if pin is None:
|
||||
block('cannot import skills/auth/scripts/pin.py, so the pinned login '
|
||||
'cannot be resolved. The plugin tree is incomplete; reinstall it.')
|
||||
|
||||
# The hint is the directory the Bash command will run in; the rest of the
|
||||
# order (CLAUDE_PROJECT_DIR first, cwd last, and the worktree branch of the
|
||||
# search) is pin.py's, and is the same order the scripts get.
|
||||
login, src = pin.find_pin(payload.get("cwd"))
|
||||
if not login:
|
||||
block('no login is pinned. Run /tea:auth to choose one (writes '
|
||||
'.claude/settings.local.json env.GITEA_LOGIN). The guard reads '
|
||||
'the file at call time, so it takes effect with no restart.')
|
||||
|
||||
rewrite(tool_input, substitute(cmd, login),
|
||||
'tea-guard: resolved --login -> %s (pinned in %s)' % (login, src))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user