Files
marketplace/cli/AGENTS.md
T
naudachu e628ad6fd9 refactor!: rewire the plugin onto the kettle binary, and rename it
BREAKING: the plugin is `kettle`, not `tea`, and its commands are `/kettle:*`.
It also now needs a binary on PATH that it did not need before; the README and
every skill say how to get one and what a missing one looks like.

The plugin was 3800 lines of Python doing what a compiled binary does better,
and the name pointed at a tool that no longer takes part: `tea` is Gitea's CLI,
and since the transport moved into the binary nothing here shells out to it for
issues at all. A plugin named after it was going to keep suggesting otherwise.

Deleted: 19 scripts, the 14-file unittest suite, and the tea-guard hook. The
guard blocked any `tea` invocation that would run under a login the model picked
instead of the operator; the binary holds its own credentials and reads the
pinned login out of the project's own config, so that failure is no longer
expressible and there is nothing left to police. agents-sync stays — it is about
AGENTS.md symlinks and has nothing to do with any of this.

What the plugin keeps is what only a plugin can carry: the rules an operator
states and a binary cannot enforce. `init` still refuses to run inside a linked
worktree and still may not be model-invoked, because which directory is the
project is a statement a person makes. The issue format reference stays here and
stays the source of truth. The runner subagent is still for batches and still
may not decide what an issue says.

The command reference in the issue, sync and project skills is GENERATED from
the binary's own command registry, between markers, so a flag that changed
cannot ship with a skill that recommends the old one. `kettle gen skills
--check` exits non-zero when they drift. The generator owns the region and
nothing outside it: the frontmatter description, which is what decides whether a
skill loads at all, stays hand-written.

`use` survives and is the one place `tea` is still named — for releases,
webhooks and actions, which kettle does not cover. Its instruction to write
`--login "$GITEA_LOGIN"` and let the hook substitute the pin was true until this
commit and is now rewritten: `tea` keeps its own configuration, kettle keeps
its own, and configuring one configures nothing in the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 00:04:44 +05:00

17 KiB

AGENTS.md — the kettle CLI

kettle is a globally installed binary. It owns everything that used to be a Python script under plugins/tea/skills/*/scripts/: what an issue is, where the store lives, who this machine is, and how issues move to and from Gitea.

The plugin keeps what only a plugin can carry — the rules an operator states and a binary cannot enforce. Everything mechanical is here.

Why a binary

Three failures in the Python version were failures of runtime, not of logic:

  • the store resolved from __file__, so it landed inside a versioned plugin cache and issues written from one project were invisible from the next;
  • the walk that answers "which directory is the project" was written three times — store, login pin, guard hook — and in a linked worktree the three disagreed;
  • sys.path.insert was the import mechanism, so the layering rule was a convention checked by grep.

A compiled binary answers all three by construction. There is one walk (internal/project), it is imported rather than re-derived, and the layering rule is a build graph a test walks.

Two dependencies

gopkg.in/yaml.v3 and code.gitea.io/sdk/gitea, both vendored, and that is the whole list — eight modules once the SDK's own are counted, 2.3 MB of vendor/. The CLI still has no cobra: commands are values in a registry, which is what lets the plugin's SKILL.md files be generated from the same struct that holds the code.

vendor/ is committed, so a build needs no network. go.mod says go 1.26, which the SDK requires; that is now the minimum for anybody building this binary.

The transport was hand-rolled net/http against the REST API and is now the official SDK. What that bought:

  • the payload shapes are one vocabulary, maintained by the people who maintain the server. They used to be ours, in internal/wire, and every field Gitea learned was a field somebody here had to notice.
  • the server's version, for free. The SDK negotiates it when a client is built, which is what lets the transport answer "does this instance have issue dependencies?" from the version instead of guessing from a status code — see dependenciesSince.

What it cost is written down where it happened: the shapes come with an HTTP client attached (see the layering note below), a run makes one extra request before it does anything (the version handshake), and Gitea's issue edit endpoint carries no labels, so an issue whose labels changed needs a second call — push makes it and says so.

The SDK is imported as sdk everywhere, so one type has one spelling across the tree. internal/gitea is the package named gitea and the SDK is sdk inside it; the same alias holds in internal/mapping and internal/cmd.

Layers

Knowledge flows one way. The arrow means "imports".

cmd/kettle              thin main; exit status only

internal/cmd            the command tree: flags, receipts, exit codes
   │      │      │
   │      │      └────► internal/config   who this machine is, what this
   │      │                               project points at; yaml lives here
   │      │                               and only here
   │      └───────────► internal/gitea    TRANSPORT: one door for every
   │                          │           request, pagination, payload dumps,
   │                          │           the number -> slug ledger
   │                          ▼
   ├────────────────────► internal/wire   ADDRESSES: Repo and Key, and the
   │                          ▲           parsing that reads them. Imports
   │                          │           nothing.
   └──► internal/mapping ─────┘           BRIDGE: md <-> the SDK's payloads,
              │                           no I/O; label colours live here
              ▼
internal/issue    DOMAIN   what an issue is: format, taxonomy, validation,
      │                    checkboxes, dependency graph, the store, eviction
      │                    offline — no tracker, no network, no JSON
      ▼
internal/project  ROOT     which directory is the project, and every path
                           resolved from it: store, payload, config
                           depends on nothing

Both the transport and the bridge also import code.gitea.io/sdk/gitea, which is where the payloads now live. The arrow that used to point at internal/wire for them points there instead.

Read it bottom-up and each layer knows strictly less about trackers than the one above it. Four rules hold the line, seven tests between them, and each fails on a real mistake rather than on a naming convention:

  • internal/issue may import internal/project and the standard library, and nothing else. One test walks go list -deps and fails on any path with a dot in its first element — which is also what keeps yaml AND the SDK out of the domain — and another names net/http, net, os/exec and encoding/json, standard library the first test would not catch. Untouched by the migration to the SDK, and that is the point: the domain did not notice it happened.
  • internal/wire imports only the standard library, checked the same two ways.
  • internal/gitea must not import internal/issue or internal/mapping: the transport knows numbers, logins, HTTP and JSON, and none of what they mean.
  • internal/mapping reaches for nothing but the domain and the SDK — checked on its DIRECT imports, with os, net/http and internal/gitea named — and a second test greps its sources for time.Now.

That fourth rule was stronger before the SDK, and the trade is deliberate. The payload shapes lived in internal/wire, a package that imported the standard library and nothing else, so "the bridge cannot reach a transport" was a fact about the import graph: there was nothing in its dependency closure that could open a socket. code.gitea.io/sdk/gitea is a client and a set of types in one package, so importing the types imports the client, and a test that walked the closure would now be asserting something false. What is still true, and what the test now says, is that mapping performs no I/O — no os, no net/http, no transport, no configuration, no clock. time is allowed where it was not, because the SDK hands over a time.Time and somebody has to format it back into the string an issue file holds; the clock itself is still the caller's, and the grep for time.Now is what says so.

wire used to exist because Go needs the JSON shapes to be one type — the transport and the bridge were written in parallel and each invented its own Issue, Label, Milestone and Comment. The SDK settles that argument for the shapes. What survives is addressing, which the SDK has no answer for at all: it takes an owner, a name and an int64, and never parses. 42, #42, owner/repo#42 and an issue URL are four spellings of one address, all four are what somebody has in hand, and wire.Key is what the ledger is keyed by and what the gitea: metadata field holds. So wire keeps Repo, Key, their parsing and their tests, and lost the payloads.

If a tracker concept — an issue number, a login, an HTTP call, a label colour — shows up in internal/issue, it is in the wrong place.

The walk

internal/project answers one question and everything else reads the answer.

Anchors, first hit wins: $CLAUDE_PROJECT_DIR, then the working directory. Each is searched up its parent chain for a .kettle/ marker, and then — only if that found nothing — up the parent chain of the main working tree of any linked worktree met on the way, reached by reading gitdir: out of a .git file and following commondir.

Nothing here resolves from the executable's own location. Where an installation keeps its files is a fact about the installation; whose issues a tree has is a fact about the tree, and a binary installed in one place and pointed at another must answer from the one it was pointed at.

The marker is created by kettle init, never inferred. .git was tried and is in every clone. No marker anywhere is an answer, not a fallback: the command reports which directories it searched and stops.

Configuration, and where secrets are not

Two files, and the split is the whole design.

<project>/.kettle/config.yaml — written by kettle init, says which tracker repository the issues belong to and which login to reach it under. It pins a login by name. The name is worth nothing on its own, which is what makes it safe to keep in a file inside a working tree.

~/.config/kettle/logins.yaml ($KETTLE_CONFIG_HOME or $XDG_CONFIG_HOME override it) — one file per machine, mode 0600, holding the tokens. Managed with kettle auth, which reads the token from standard input by default because an argument is in the shell history the moment it is typed. Nothing prints a token back: kettle config shows (set).

A token in a file inside a working tree ends up in a commit. Not always, not immediately, and not by anyone careless — but a project config is exactly the file somebody eventually decides to share, and a secret that has ever been pushed has to be rotated.

KETTLE_LOGIN, KETTLE_REPO, KETTLE_URL and KETTLE_TOKEN each override the file they shadow, for CI and for anyone who would rather have no token on disk.

Unknown keys in either file are an error, not a silent drop: an older binary reading a newer config would otherwise delete the setting it did not recognize the next time it wrote the file.

init gitignores .kettle/ wholesale. An origin: local issue is the only copy of that work, and what goes into a shared history is the operator's call, not this binary's — drop the line if the team decides otherwise.

The store

<project root>/.kettle/issues, one flat markdown file per issue, named by its slug, one metadata field per line so plain grep works without a parser.

It holds two kinds of file and only one of them is a store. An origin: local issue lives here and nowhere else — that file is the issue. Anything with a tracker origin is a working copy, deleted the moment a push confirms the tracker is up to date. Eviction makes the same trade one step earlier and asks the file instead of the tracker, which is why it lives in the domain.

.kettle/payload/ is a sibling, never a child: request bodies are debris of the transport, and a scratchpad inside a store makes ls .kettle/issues lie about what exists. It is written by an http.RoundTripper installed on the SDK's client, so every request with a body is filed and not only the ones a call site remembered to name — a name only decides what the file is called. A run that sends nothing, which includes every read-only command, still leaves no directory: the first write creates it. The dump is the same JSON the wire carried, re-indented and with <, > and & left alone, because the SDK marshals with encoding/json's escaping and a dump nobody can read is a dump nobody reads.

kettle init migrates older layouts in, oldest first — tmp/issues and then .tea/issues — and each is a move. A store left behind at an old path is a store somebody will edit by accident months later. It refuses to pick a winner when both sides hold a file of the same name.

Tests

cd cli && go test ./...

internal/cmd builds the binary once in TestMain and runs it as a subprocess against a throwaway project in a temp directory — the same discipline the Python suite arrived at, for the same reason. The binary is never run in the directory it was built in, because that is exactly the arrangement that hid the __file__ bug: a tool is installed in one place and used on projects in another, and a test that collapses the two proves nothing about resolution.

Every fixture strips CLAUDE_PROJECT_DIR. It is the first anchor of the walk, so the harness's own value would point every fixture at this repository. Anything touching credentials sets KETTLE_CONFIG_HOME at a temp directory, so a test run can neither read nor overwrite the developer's own tokens.

Every fake tracker answers /api/v1/version, because building a client asks for it: the SDK will not hand one back until the instance has said what it is, and that answer is what the dependency gate is decided on later. The fakes say 1.26.1. One says 1.19.4, and that is a test — an instance too old for the issue dependency endpoints is answered from its version, with no request made.

The round trip

push and pull are the two halves of one rule, and the rule is that the store holds what has not left this machine.

A successful push deletes <id>.md and every sidecar under that slug, on create and on --update alike, and prints the number and URL the issue now lives at. An --update can take one extra request with it: Gitea's edit endpoint carries no labels, so when the answer's label set and the issue's disagree — a label added or removed locally, or one a create silently dropped — the whole set goes up in a PUT, and a warning on stderr says which names moved. The deletion happens only after a confirmed tracker response and only after the number -> slug ledger has been written — network down, non-2xx, or an answer that does not carry the right number, and the file stays where it is while the run stops. A never-pushed origin: local issue is never touched by any of it.

The slug survives that round trip two ways over, and a test proves both: it goes up in the body as <!-- kettle:id … -->, and it is indexed by number in .kettle/issues/.remote.json. A rename in the web UI, a lost ledger, a fresh clone, another machine — the file comes back under the same name and every depends: pointing at it still resolves. The marker is written in that spelling and read in both it and the older <!-- tea:id … -->, because issues pushed before the rename are still in the tracker.

Pull by number fetches an issue in any state: a number is an address, not a query, and 42, #42, owner/repo#42 and a URL all name one. Only filter mode leaves closed issues out. A pull returns the unit of work rather than one row of it — blockers come down with it unless --no-deps says otherwise — and it overwrites the body, because it is a fetch and not a merge. The one exception is checkbox state, which is the local half of the work and is merged rather than clobbered.

No guard hook

The Python version needed a PreToolUse hook to block any tea command that would run under a login the model picked instead of the operator. That whole apparatus is gone. The binary holds its own credentials and reads the login out of the project's own configuration, so there is no argument to police and no way for the transport and the guard to disagree — the failure the hook existed to catch is not expressible any more.

There is also no --login and no --repo on any sync command bar labels. Which login a project runs under, and which repository its issues belong to, are facts about the project, stated once by kettle init. A cross-repository address is still an address: kettle pull owner/repo#42 re-points the client for that one call — Client.For(repo), which is bookkeeping and not a second connection, because the SDK takes the owner and the name per call. The credentials, the negotiated version and the scratchpad come along.

Issue dependencies

The one endpoint with a story. depends: becomes a native Gitea link, which is what makes the tracker show the blocking panel and refuse to close a blocked issue first, and it is read back the same way on a pull.

  • Reading goes through the SDK (ListIssueDependencies).
  • Writing does not. Gitea's own IssueMeta is {index, owner, repo} and has been since the endpoint existed; the SDK's is {index}, which can only ever link inside one repository, and a depends: entry is allowed to live somewhere else. So one hand-rolled POST survives, through the same HTTP client as everything else — same payload dump, same *APIError.
  • The version gates both. The routes are absent from Gitea 1.19 and present in 1.20, checked against the release tags themselves, so an instance older than that is answered from the version it already gave us rather than from a 404 — which on an old server is also what "no such issue" looks like.
  • A tracker that answers with a status still means "no dependencies here", because an instance that HAS the endpoint can still have them turned off for a repository, and a pull must bring the issue back either way. A dead connection is not that answer and never was.

Status

Done and tested: all seven packages, and the commands init, auth, config, new, check, ac, tree, index, evict, pull, push, remote, comment, close, labels, sync-evict. 99 tests.

The plugin is rewired: it lives at plugins/kettle, ships no Python and no guard hook, and its command reference is generated from this registry — kettle gen skills --out plugins/kettle/skills, with --check as the invariant. The generator writes one file per GROUP (project, issue, sync); the plugin also carries init, auth and use, which hold procedure rather than flags and point at the generated project block. Adding a group here adds a skill directory there, so name one only when it is a subject somebody would load on its own.