e629d14585
Gitea becomes the source of truth. Once a push is confirmed, push.py
deletes tmp/issues/<id>.md and <id>.comments.md and prints the number and
URL the issue now lives at; the current state is obtained by pulling
again rather than by reconciling. --update follows the same rule, with no
exception: what is local is what has not left.
This reverses three statements AGENTS.md used to make, and rewriting them
is part of the change:
- "tmp/issues/ is the store, not a cache of Gitea" — it is both, split
by origin:. An origin: local file is the only copy of the work; an
origin: gitea file is a deletable working copy.
- "Pushing is additive: the file is never deleted" — it is deleted.
- "origin: local is a durable state" — complete, but not durable:
pushing ends it.
Slug stability, which the format promises for the life of an issue, can
no longer rest on a file push is about to delete. The slug goes up in the
body as a hidden marker, <!-- tea:id <slug> -->, on the first line:
map.to_payload strips every marker and prepends exactly one, map.from_api
strips every marker on the way down, so the local file never holds one
and a body cannot accumulate them however many round trips it makes. The
marker survives a rename in the web UI, a lost .remote.json, a fresh
clone and another machine — none of which a local index does.
Deletion is the last thing that happens to an issue and only after the
transport returned, the answer carried a positive integer number (and, on
--update, the number that was PATCHed — push.confirmed_number), and
.remote.json was written. A raised transport, a non-2xx, an empty or
mismatched body each leave the file on disk and stop the run.
.remote.json is no longer "only an index over the files": its entries now
deliberately outlive them, so it is the local number -> slug ledger and
rebuild_map merges into it instead of reconstructing it from files that
may be gone. It stays recoverable, from the markers in Gitea rather than
from the files. push.dep_state reads it too, so a blocker whose file an
earlier push dropped still gets its native dependency link.
Also fixes a pre-existing bug the new tests hit: issue.all_ids treated
<id>.comments.md as an issue called "<id>.comments", so a bare push.py in
a store holding pulled threads tried to file a comment thread as a unit
of work. A slug has no dot in it.
tests/test_drop_after_push.py covers the round trip (push -> gone -> pull
-> identical in slug, depends: and body), the marker's algebra, and every
failure path separately. test_push_dependencies.py is updated where it
encoded the old "never deleted" contract. 183 tests, no network.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
174 lines
7.9 KiB
Markdown
174 lines
7.9 KiB
Markdown
# 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 |
|
|
| `/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. Those are 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 JSON, then over the wire
|
|
▲
|
|
│ calls
|
|
tea-runner EXECUTION runs the scripts, reports a receipt — no opinions
|
|
```
|
|
|
|
Delete `skills/sync` and the domain layer keeps working — issues that live only
|
|
on your machine are first-class, not drafts waiting to be uploaded. That is the
|
|
point of the split: you can plan, write, validate, and track work 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-guard` hook (`python3` must be on `$PATH`)
|
|
- **`tea`** — Gitea's official CLI. Install with `brew install tea` (macOS) or from [gitea.com/gitea/tea/releases](https://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`.
|
|
|
|
1. Register this repo as a marketplace:
|
|
|
|
```
|
|
/plugin marketplace add https://git.noodles.cam/claude-skills/tea.git
|
|
```
|
|
|
|
Already have a local clone? Point at the directory instead:
|
|
|
|
```
|
|
/plugin marketplace add /path/to/tea
|
|
```
|
|
|
|
2. 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 `extraKnownMarketplaces` and the plugin to `enabledPlugins` in 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 `.claude/settings.local.json` and takes effect immediately — no restart needed.
|
|
|
|
```
|
|
/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.
|
|
|
|
Claude is **blocked** from:
|
|
- running `tea` without `--login` at 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 hook
|
|
tea-guard.sh the guard (Python 3, no deps)
|
|
skills/
|
|
auth/SKILL.md /tea:auth skill
|
|
issue/ /tea:issue — the domain layer, 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_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
|
|
use/ /tea:use — tea CLI reference (non-issue entities)
|
|
SKILL.md
|
|
references/tea/ command docs
|
|
```
|
|
|
|
## 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 a `gitea:` field.
|
|
- `origin: local` is 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** (`--update` too) 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 with `pull.py <n>` —
|
|
same slug, same `depends:`, 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.
|