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:
naudachu
2026-08-11 00:25:28 +05:00
parent 23f78beafb
commit 83f73c5cea
69 changed files with 4187 additions and 168 deletions
+75
View File
@@ -0,0 +1,75 @@
---
name: auth
description: Pin the Gitea login used by the tea CLI in this project. Run when the tea-guard hook reports no login is pinned, or when the user types /tea:auth. Enumerates available logins, makes the OPERATOR pick one, and persists it to .claude/settings.local.json. The pin takes effect immediately — no restart.
---
# /tea:auth — pin the project Gitea login
Goal: have the **operator** select exactly one `tea` login for this project and
persist it to `.claude/settings.local.json` under `env.GITEA_LOGIN`. The
`tea-guard` hook reads this file at call time and rewrites every
`--login "$GITEA_LOGIN"` to the pinned value, so the choice takes effect
**immediately, with no session restart**.
## The one hard rule: the operator chooses, never you
Picking the wrong identity is the exact failure this command exists to prevent.
So:
- **ALWAYS** present the choice with `AskUserQuestion` and let the operator
pick — even if memory, context, the repo URL, or a previous session suggests
a "likely" login. Do **not** auto-select from memory or infer it. A wrong
guess writes under the wrong account.
- The only exception: exactly **one** login exists on the machine — then
propose it and still confirm before writing.
## Steps
1. Enumerate logins (allowed by the guard even with no pin):
`tea logins list -o json`
2. **No logins:** stop and ask the operator to run `tea logins add` themselves
— it is interactive (prompts for URL/token). Do not run it for them.
3. **One login:** propose it; confirm before writing.
4. **Several logins:** `AskUserQuestion` with each login's `name`, `user`, and
`url` so the operator's choice is unambiguous. Never decide for them.
5. Merge the chosen name into the **project root's**
`.claude/settings.local.json` under `env` (do not clobber other keys):
```json
{ "env": { "GITEA_LOGIN": "<chosen-name>" } }
```
**In a git worktree, write it to the main checkout, never to the worktree.**
A worktree is deleted when the branch is done, taking a pin written into it
with it, and one repository with two pins is one repository with two
identities. Both the guard and the scripts already reach the main checkout's
pin from inside any worktree — so there is nothing to pin a second time.
`git rev-parse --path-format=absolute --git-common-dir` names the `.git` to
write beside.
6. Done — it is live. The guard resolves the pin from the file on the next
`tea` call; no restart needed. Tell the operator which login is now pinned,
and which file it went in.
## Where the pin is looked for
One search order, written once in `scripts/pin.py` and imported by both the
`tea-guard` hook and the sync transport — they cannot disagree about a
directory, and a test asserts neither keeps a copy of the walk.
`$CLAUDE_PROJECT_DIR`, then the caller's hint (the hook passes the Bash call's
`cwd`), then the current directory. Each is searched up its parent chain; only
if that finds nothing does the search cross into the main working tree of a
linked worktree, via `gitdir:` in the `.git` file. The plugin's own directory
is never a source — a plugin pointed at somebody else's project must take the
identity from that project, not from where it happens to be installed.
If a script reports "no login pinned", that is the honest answer: nothing was
found anywhere on that order. Pin one — at the project root.
## Identity-safety rules
- NEVER run commands that mutate logins or global login state:
`tea logins add/edit/delete/default`, `tea logout`. Read-only
`tea logins list` is the only allowed login command.
- If a `tea` call fails with a permission/scope error, report it. Do NOT try to
fix it by switching to, or editing, a different login.
- If you ever see `no gitea login detected, falling back to login '...'`, treat
it as a hard failure: stop, do not act on the result, surface it.
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
pin.py — where the operator's Gitea login pin is, and how it is found.
**The search order lives here and nowhere else.** The `tea-guard` hook imports
this module; so does the transport every sync script runs on. Two
copies of the order is exactly how a git worktree came to have a working hook
and a dead transport in the same directory: `tea` resolved the login, the
scripts said "no login pinned", and the error told the operator to pin what was
already pinned.
Not a command — a lookup. Stdlib only, no subprocess, no network: a PreToolUse
hook runs before every Bash call and must not fork a process to answer this.
The pin is a file the OPERATOR owns and `/tea:auth` writes:
<project root>/.claude/settings.local.json -> env.GITEA_LOGIN
## Search order
Start directories, in order, first hit wins:
1. $CLAUDE_PROJECT_DIR the project Claude Code was started on, when set
2. an explicit hint the hook passes the Bash tool's cwd; scripts pass
nothing and go straight to 3
3. the current directory
Each start directory is searched the same way:
a. up the parent chain, from the directory itself to the filesystem root
b. then, for each LINKED WORKTREE seen on that chain, up the parent chain
of that repository's main working tree
(b) is the whole point of this module. A worktree is a *sibling* of the main
checkout, not a descendant, so `.claude/settings.local.json` — untracked, and
therefore only ever in the main checkout — is not on the parent chain of (a).
Git knows the two trees are one repository: a worktree's `.git` is a FILE
holding `gitdir: <path>`, and `<path>/commondir` points back at the shared
`.git`. `git rev-parse --git-common-dir` answers the same question by forking;
this reads the files.
## Why the search does not start at __file__
Deliberate asymmetry with `issue.store_root` and `_gitea.PAYLOAD_ROOT`, which
*are* anchored on their own module's location. Two different questions:
where does this installation keep its files a fact about the plugin
whose login does this project run under a fact about the project
A plugin installed outside any repository and pointed at somebody else's tree
must answer the second one from the tree it was pointed at. Anchoring the pin
on `__file__` would make the plugin's own directory an identity source, which
is how a checkout ends up acting under a login nobody chose for it. So the
search runs from the working directory upward — and reaches a worktree's main
checkout by asking git, not by walking somewhere else.
Finding nothing is a real answer: `(None, None)` means there is no pin, and the
caller says so. This module never guesses a login.
"""
import json
import os
SETTINGS_PARTS = (".claude", "settings.local.json")
ENV_KEY = "GITEA_LOGIN"
PROJECT_DIR_ENV = "CLAUDE_PROJECT_DIR"
def settings_path(root):
"""The pin file for a project root. The one place this path is spelled."""
return os.path.join(root, *SETTINGS_PARTS)
def read_pin(path):
"""The login in a settings file, or None.
Unreadable, not JSON, no `env`, empty string — all the same answer. A
broken file is not a login and is not worth a traceback in a hook."""
try:
with open(path) as f:
value = (json.load(f).get("env") or {}).get(ENV_KEY)
except Exception:
return None
if isinstance(value, str) and value.strip():
return value.strip()
return None
def parents(start):
"""`start` and every ancestor of it, up to the filesystem root."""
d = os.path.abspath(start)
while True:
yield d
parent = os.path.dirname(d)
if parent == d:
return
d = parent
def gitdir_of(d):
"""The private git directory `d/.git` points at, or None.
Only a `.git` FILE is a pointer; in an ordinary clone `.git` is a
directory and there is nothing to follow."""
p = os.path.join(d, ".git")
if not os.path.isfile(p):
return None
try:
with open(p) as f:
head = f.read(4096)
except OSError:
return None
for line in head.splitlines():
line = line.strip()
if line.startswith("gitdir:"):
target = line[len("gitdir:"):].strip()
if not target:
return None
if not os.path.isabs(target):
target = os.path.join(d, target)
return os.path.abspath(target)
return None
def main_worktree(d):
"""If `d` is a linked worktree, the main working tree of its repository.
`<worktree>/.git` -> `<main>/.git/worktrees/<name>`, whose `commondir`
file holds a path to `<main>/.git`; the main working tree is its parent.
The `.git` basename check keeps this to worktrees: a submodule's `.git`
is a pointer too, but it points into `<super>/.git/modules/…`, and the
tree it belongs to is already on the parent chain."""
gitdir = gitdir_of(d)
if not gitdir or not os.path.isdir(gitdir):
return None
common = gitdir
marker = os.path.join(gitdir, "commondir")
if os.path.isfile(marker):
try:
with open(marker) as f:
rel = f.read().strip()
except OSError:
rel = ""
if rel:
common = os.path.abspath(os.path.join(gitdir, rel))
if os.path.basename(common) != ".git":
return None
root = os.path.dirname(common)
if root and os.path.isdir(root) and root != os.path.abspath(d):
return root
return None
def search(start):
"""(login, path) for one start directory: the parent chain, then the main
checkout of any worktree met on it. (None, None) when there is no pin.
The chain comes first and always wins, so the worktree branch can only
ever find a pin that walking up would not have found at all."""
hops = []
for d in parents(start):
login = read_pin(settings_path(d))
if login:
return login, settings_path(d)
root = main_worktree(d)
if root and root not in hops:
hops.append(root)
for root in hops:
# One level of indirection, never two: a main checkout is not itself a
# linked worktree, so this loop cannot chain and cannot cycle.
for d in parents(root):
login = read_pin(settings_path(d))
if login:
return login, settings_path(d)
return None, None
def start_dirs(hint=None):
"""The ordered, deduplicated start directories.
`hint` is the caller's own idea of where the work is happening — the hook
passes the `cwd` from its payload, which is the directory the Bash command
will actually run in. A script has no payload and passes nothing."""
try:
cwd = os.getcwd()
except OSError: # cwd deleted out from under us
cwd = None
out = []
for d in (os.environ.get(PROJECT_DIR_ENV), hint, cwd):
if not d:
continue
d = os.path.abspath(d)
if d not in out:
out.append(d)
return out
def find_pin(hint=None):
"""(login, path) for the first start directory that has a pin, else
(None, None). The entry point; everything above is its parts."""
for start in start_dirs(hint):
login, path = search(start)
if login:
return login, path
return None, None