tea — Claude Code plugin for the Gitea CLI
A Claude Code plugin that gives Claude a reference for the tea CLI and enforces a hard rule: every tea command runs under the login the operator chose, never one Claude picked.
What it ships
| Piece | What it does |
|---|---|
/tea:auth skill |
Prompts you to pick a Gitea login and pins it to the project |
/tea:issue skill |
Issues as units of work — create, read, grep, validate, walk the dependency graph. Entirely offline |
/tea:sync skill |
Moves issues between the local store and Gitea — pull, push, comment, close, evict |
/tea:use skill |
Tea CLI reference for everything that is not an issue — loads command docs on demand |
tea-runner agent |
Subagent on Haiku that runs the scripts and reports back a receipt — the mechanical half, off your main context |
tea-guard hook |
PreToolUse hook that blocks or rewrites every tea invocation |
The layering
An issue is a unit of work first and a Gitea row second. That is two layers, and knowledge flows one way:
skills/issue DOMAIN what an issue is: format, validation, dependency graph
▲ offline — no tracker, no network, stdlib only
│ imports
skills/sync BRIDGE md <-> Gitea issue JSON, then over the wire
▲
│ calls
tea-runner EXECUTION runs the scripts, reports a receipt — no opinions
Delete skills/sync and the issue domain keeps working. Work that lives only
on your machine is first-class, not a draft waiting to be uploaded. That is the
point of the split: you can plan, write, and validate without a tracker, and
publish only what you choose to.
Prerequisites
- Claude Code — CLI, desktop app, or IDE extension
- Python 3 — required by the
tea-guardhook (python3must be on$PATH) tea— Gitea's official CLI. Install withbrew install tea(macOS) or from gitea.com/gitea/tea/releases- At least one login configured:
tea logins add(interactive — run it in a terminal, not via Claude)
Installation
This is a Claude Code plugin — install it through the plugin marketplace, not by hand-editing settings.json.
-
Register this repo as a marketplace:
/plugin marketplace add https://git.noodles.cam/claude-skills/tea.gitAlready have a local clone? Point at the directory instead:
/plugin marketplace add /path/to/tea -
Install the plugin:
/plugin install tea@tea
The skills (/tea:auth, /tea:issue, /tea:sync, /tea:use) and the tea-guard hook load immediately. Use /plugin to enable, disable, or update it later.
The marketplace registration is written to
extraKnownMarketplacesand the plugin toenabledPluginsin your settings automatically — you don't edit those by hand. There is no top-level"plugins"settings key; if you've added one from older instructions, remove it.
First use
Run /tea:auth once per project. Claude will list your available Gitea logins and ask you to pick one. The choice is written to the project root's .claude/settings.local.json and takes effect immediately — no restart needed.
Once per project, not once per checkout: a git worktree shares its main checkout's pin. Both the hook and the scripts find it from inside a worktree, so don't run /tea:auth there — it would leave a second pin in a directory that disappears with the branch.
/tea:auth
After that, just ask Claude to do something with issues or Gitea — it loads the
right skill automatically. /tea:auth is only needed for the tracker side;
/tea:issue works without any login at all.
How the login guard works
Every tea invocation Claude writes must carry the literal placeholder --login "$GITEA_LOGIN". The tea-guard hook intercepts the Bash call before it runs, looks up the pinned login from .claude/settings.local.json, and rewrites the command to use it. The hook and the scripts look it up the same way — one search order, in skills/auth/scripts/pin.py.
Claude is blocked from:
- running
teawithout--loginat all - naming a login itself (e.g.
--login myaccount) - using any variable other than
$GITEA_LOGIN
This prevents silent fallback to the machine's default login (often a personal account) when working in a project that belongs to a different identity.
tea logins list and tea --version / --help are exempt — they don't touch Gitea data.
The tea-runner agent
The skills carry meaning; the scripts carry work. tea-runner is a subagent on
Haiku that does the second half in its own context and hands back a receipt —
what ran, what it touched, what failed, verbatim.
Delegate a batch: pull a milestone and rebuild the index, push the three
issues you just wrote, bootstrap the label set, post a comment from a file you
prepared. Spawning it for a single pull.py 42 costs more than running the
command yourself; the saving is in the loop, the retry, and reading somebody
else's stderr.
It cannot decide anything. No Edit, no Write, no --force, no closing or
retitling, no raw tea, no pushing beyond the set it was handed. A missing
type, a failed validation, an unpushed dependency come back as a question, not
as a guess. The tea-guard hook applies to it exactly as it does to the main
session — the pinned login is enforced on every call it makes.
Project layout
.claude-plugin/
plugin.json plugin manifest
marketplace.json marketplace catalog (makes `/plugin install` work)
agents/
tea-runner.md subagent (Haiku) that executes the scripts
hooks/
hooks.json registers the PreToolUse hooks
tea-guard.sh the guard (Python 3, no deps)
agents-sync.sh keeps AGENTS.md real and CLAUDE.md a symlink to it
skills/
auth/ /tea:auth — the identity layer
SKILL.md
scripts/pin.py where the login pin is and how it is found —
imported by _gitea.py AND by tea-guard.sh
issue/ /tea:issue — the issue domain, offline
SKILL.md
references/format.md canonical issue format (identity, types, templates)
scripts/ Python 3, stdlib only, no network:
issue.py domain module: slug identity, parse/render,
validation, taxonomy, dependency graph,
body checkboxes
issue_new.py create a local issue from its type template
issue_check.py validate against the format
issue_ac.py list the body's checkboxes; tick one
issue_tree.py draw the dependency graph
issue_evict.py drop closed issues the tracker also has
issue_index.py rebuild tmp/issues/INDEX.md
sync/ /tea:sync — the bridge to Gitea
SKILL.md
scripts/
map.py md <-> Gitea JSON, pure functions, no I/O
_gitea.py transport: login pin, tea api, pagination, filters
pull.py Gitea -> tmp/issues/
push.py tmp/issues/ -> Gitea, then drops the local file
remote.py discovery listing to stdout
comment.py post or edit a comment
close.py the state field, both ways
evict.py refresh state: from Gitea, then evict
labels.py put the canonical label set into a repository
use/ /tea:use — tea CLI reference (non-issue entities)
SKILL.md
references/tea/ command docs
AGENTS.md carries the same layout with the reasoning behind it; if the two
ever disagree, AGENTS.md is the one being worked from.
Local issue store
Issues live in tmp/issues/ (gitignore it) as flat markdown with one metadata
field per line — so grep -l 'labels:.*type/bug' tmp/issues/*.md works without
a parser.
An origin: local file is the issue — the store, and the only copy.
Anything with origin: gitea is a working copy of something the tracker
already has, and it is deleted as soon as a push confirms the tracker is up to
date:
- Identity is a slug (
wire-sqlc-appclick.md), never a tracker number. Numbers live in agitea:field. origin: localis a complete state. An issue that never leaves your machine is valid and finished — but it is not permanent: pushing ends it.- A successful push deletes the local file (
--updatetoo) and prints the number and URL it now lives at. Only after a confirmed response: a failed call leaves the file exactly where it was. Get it back withpull.py <n>— same slug, samedepends:, even after a rename in Gitea. - Pulling overwrites the body: a fetch, not a merge. It is also how a pushed issue comes back.
- Nothing tracks drift, and there is no second copy to drift. A file that is still here has not been pushed.