Files
marketplace/cli/AGENTS.md
T
naudachu 8b1b11001a feat: drop the kettle plugin; the binary writes its own skills
The plugin and the binary shipped on two release cadences and nothing on an
operator's machine ever checked that the one they installed described the other.
The generated flag block existed precisely so a renamed flag could not ship with
documentation recommending the old one — and then shipped one version behind the
registry it came from, which is the same bug one hop downstream.

So the prose moved into the binary. `internal/scaffold` embeds every document;
`kettle init` and `kettle gen scaffold` write them into a project's own
`.claude/`. The two cannot disagree because there is one artefact.

The namespace survived the move. A project's skills are flat, so the prefix is
spelled into the directory name (`kettle-issue`); a project's *commands* take
their namespace from a subdirectory, so `commands/kettle/init.md` is still
`/kettle:init`. Four of the six command files are thin pointers at a skill, and
that is what kept ~1,600 lines of `/kettle:…` cross-references true without a
rewrite. `init` and `auth` lost `disable-model-invocation: true` — being a
command is that property — and `auth` now restricts `allowed-tools` so a model
cannot reach `kettle auth add` at all.

`gen scaffold` writes files whole rather than splicing a region. The old
refusal protected somebody's hand-written prose around the block; that prose is
embedded now, so there is none to protect, and preserving local edits would
freeze a project's documentation at whatever version first initialized it.
`--check` warns before an upgrade discards one.

The plugin's `agents-sync.sh` — 141 lines of Python behind a filename that said
`.sh` — became `internal/mirror` and `kettle mirror`. Same seven branches, same
refusal to merge two real files that differ, now with a table test per branch
and a check that a repair converges in one pass. `--hook` is the PreToolUse
form and exits 0 on every path including a panic. It is opt-in per project,
which is strictly narrower than the plugin hook that was on for everybody who
installed it.

`kettle init --interactive` walks a person through the login, the token (read
with the echo off, so it lands in no history and no file), the repository, the
`.claude/` tree and the mirror hook. It refuses a stdin that is not a terminal
and names the flags instead: every question it asks has one, and it performs
nothing itself, so an interactive run and a flag run are one code path.

Two rules that used to be prose are now the binary's: init refuses a linked
worktree and names the main checkout, and writing into an existing
`.claude/settings.json` is refused with the snippet printed rather than
reformatting a file the operator commits.

The scaffold version stamp went to its own `.kettle/scaffold.yaml` rather than
into `config.yaml`, because unknown keys there are a hard error and that file
may be committed and read by whatever build each machine has.

golang.org/x/term becomes a direct dependency; it was already in the tree
indirectly, so no module was added.

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

17 KiB

AGENTS.md — the kettle CLI

kettle is a globally installed binary, and it is the whole product. There is no plugin any more: it owns this project's connection to its tracker — the credentials, the transport, the payload scratchpad — and it owns the documents an operator and a model read, which it writes into .claude/ and which are embedded in it.

Issues are its main subject and no longer its only one: what an issue is, where the store lives, who this machine is, how issues move to and from Gitea, and, through kettle api, every other Gitea entity that has no command of its own.

That is a widening, three times over, and it is worth being straight about each. "Issues and nothing else" was the line until cmd/release crossed it (publishing this repository's own releases), then kettle api (because the alternative was requiring tea — a second CLI with a second set of logins nothing here could see, documented in 400 lines of somebody else's flag reference nothing here could check), and then kettle mirror (141 lines of Python that used to be a plugin hook and had nowhere to live once the plugin went).

One rule made all three: a binary holds what can be enforced. Every one of them was mechanical, and every one of them was somewhere a test could not reach. What has not widened is the domain — internal/issue still knows nothing about trackers, api is transport plus a command, and mirror and scaffold import nothing at all.

This file is the binary's map. Each package documents its own rules in its own directory; nothing below is repeated there and nothing there is repeated here.

Why a binary, and then why no plugin

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.

A fourth failure survived that migration and outlived it by a year. The plugin shipped the skills; nothing on an operator's machine ever checked that the plugin they had installed described the binary they had installed. The generated block existed precisely so a renamed flag could not ship with documentation recommending the old one — and then shipped one version behind the registry it was generated from, which is the same bug one hop downstream. So the documents moved inside the binary (internal/scaffold) and kettle gen scaffold writes them into a project. Prose that travels inside the binary cannot be a version behind it.

What that cost is worth naming rather than glossing: the marketplace was discovery, and a README URL is not a shop window. The /kettle:… prefix came free from the plugin and had to be rebuilt out of a commands/kettle/ subdirectory. Both were paid knowingly — a document describing the wrong binary is worse than one nobody found.

Layers

Knowledge flows one way. The arrow means "imports"; follow a name to that package's own AGENTS.md.

cmd/kettle              thin main; exit status only
cmd/release             build infrastructure, not a kettle verb — see its own file

internal/cmd            the command tree: flags, receipts, exit codes
   │  │   │   │  │
   │  │   │   │  ├─────► internal/scaffold  the documents written into .claude/,
   │  │   │   │  │                          embedded. Imports nothing.
   │  │   │   │  └─────► internal/mirror    AGENTS.md ⇄ CLAUDE.md, seven
   │  │   │   │                             branches. Imports nothing.
   │  │   │   └────────► 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, scaffold
                           depends on nothing

Three packages at the bottom depend on nothing — project, wire, and now mirror and scaffold beside them. The last two are there for a reason pointing outward rather than inward: mirror walks any directory on any machine, and scaffold's documents must exist wherever the binary does. One import of internal/config in the first, or one os.ReadFile in the second, and each stops being the thing it is for.

package layer what its own file opens with
cmd/kettle entry point os.Exit(cmd.Main(os.Args[1:])), and why there is nothing else in it
cmd/release build tool why publishing a release is not a kettle verb, and why it goes around the transport
internal/cmd commands the registry every command is a value in, and the generator that writes a project's documents from it
internal/config configuration two files, and why the tokens are not in the one inside the repository
internal/gitea transport the SDK, the payload scratchpad, the ledger, the dependency endpoint
internal/mapping bridge md ↔ payload, the id marker, label colours, the checkbox merge
internal/wire addresses 42, #42, owner/repo#42, a URL — four spellings of one thing
internal/issue domain the format, the taxonomy, the store, eviction — all of it offline
internal/scaffold documents the commands, skills and subagent written into .claude/, and why they are not a plugin
internal/mirror filesystem one invariant per directory, seven branches, one refusal
internal/project root the walk, and every path resolved from its answer

The rules that hold the layers apart, and the eleven tests that fail when one breaks, are in internal/AGENTS.md. Read the diagram bottom-up: each layer knows strictly less about trackers than the one above it.

Dependencies, and building

gopkg.in/yaml.v3, code.gitea.io/sdk/gitea and golang.org/x/term — eight modules once the SDK's own are counted, and the third of those was already among them before anything here imported it. It became direct for one prompt: term.ReadPassword, so an operator typing a token at kettle init --interactive leaves it in no history, no file and no scrollback.

No cobra: commands are values in a registry, which is what lets a project's skills be generated from the same struct that holds the code.

make install                 # build straight onto your PATH, version stamped
make install BINDIR=$(go env GOPATH)/bin

go build -o ~/.local/bin/kettle ./cmd/kettle    # the same thing, unstamped
go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest

make is the build, and it is also the CI. There is no act_runner on the instance this lives on and none is planned, so nothing runs on a push: make check — fmt, vet, test, go mod verify, build, gen scaffold --check — is the only thing standing between a mistake and the tracker, and it is on whoever is committing to run it. make help lists the rest; make dist cross-compiles four platforms with a SHA256SUMS, and make release TAG=v1.2.3 publishes them through cmd/release after refusing a dirty tree, a TAG that is not what git describe reports, and a tag the remote does not have.

A hand build says dev for kettle version and means it: a binary out of somebody's working tree is not a release and must not claim to be one. The version is stamped at link time into internal/cmd.Version, derived from git describe rather than kept in a file — a number somebody has to remember to bump is a number that will be wrong.

go.mod says go 1.26, which the SDK requires, and vendor/ is committed — 281 files, 2.3 MB, which is the price of knowing exactly what compiled.

Be precise about what that buys, because it is easy to overclaim: vendoring pins the contents of every dependency in this repository's own history, so a dependency that is retagged, yanked or unreachable cannot change what this binary is built from. It does not by itself give a network-free build. go 1.26 in go.mod means GOTOOLCHAIN=auto fetches a toolchain over the network on any machine whose local Go is older, which is most of them. If an air-gapped build is ever a requirement, pin the toolchain too — vendoring alone will not get you there.

make check runs both halves: go mod verify says the module cache matches go.sum, and go build -mod=vendor ./... says the committed tree is complete and is what actually compiles. A vendor/ that has drifted from go.mod fails nothing until somebody builds with a cold cache, which is exactly when nobody wants to find out.

The transport is the official SDK rather than hand-rolled net/http. What that bought: the payload shapes are one vocabulary maintained by the people who maintain the server, and the instance's version arrives for free, which is what lets the transport answer "does this instance have issue dependencies?" from the version instead of guessing from a status code. What it cost is written down where it happened — see internal/gitea and internal/mapping.

The SDK is imported as sdk everywhere, so one type has one spelling across the tree.

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 the local file — on --update too, one rule with no exception — and only after the tracker confirms the write and the number → slug ledger is written. A pull is how the copy comes back, under the same slug, on a machine that has never seen it.

The mechanics of each half live with the commands that implement them, in internal/cmd; the ledger that survives the deletion is internal/gitea's, and the marker that survives a lost ledger is internal/mapping's.

No guard hook (and the one hook that stayed)

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. kettle api did not put it back: it takes an endpoint and a body, never a login, and a full URL on another host is refused rather than sent with this project's token attached.

There is also no --login and no --repo on any sync command bar labels. A cross-repository address is still an address: kettle pull owner/repo#42 re-points the client for that one call, which is bookkeeping and not a second connection.

The one PreToolUse hook that exists now is kettle mirror --hook, and it polices nothing: it repairs an AGENTS.md/CLAUDE.md pair and reports. It is opt-in per projectkettle init --mirror-hook, or a question in --interactive — which is strictly narrower than what it replaced, a hook that was on for everybody who installed the plugin, in every repository they touched, enforcing a convention that is this repository's. It cannot fail a tool call: it exits 0 on every path including its own bugs.

Tests

make check      # fmt, vet, test, go mod verify, build, docs — the whole gate
go test ./...   # just the tests

Three disciplines every test follows:

  • internal/cmd builds the binary once in TestMain and runs it as a subprocess against a throwaway project in a temp directory. 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, the first anchor of the walk, or 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 run can neither read nor overwrite the developer's own tokens.
  • Every fake tracker answers /api/v1/version, because building an SDK client asks for it. The fakes say 1.26.1; one says 1.19.4, and that one is a test — an instance too old for the dependency endpoints is answered from its version with no request made.

Documentation that is generated

kettle gen scaffold                                        # into <project>/.claude
dist/kettle gen scaffold --out internal/scaffold/assets    # into the embedded copy
make check                                                 # exit 1 if that copy is stale

Everything between <!-- kettle:gen --> and <!-- /kettle:gen --> comes from the command registry, so a renamed flag cannot ship with documentation that recommends the old one — and since the prose around it is embedded too, the whole document ships with the binary rather than beside it. The generator is internal/cmd's and the documents are internal/scaffold's.

The loop is circular on purpose. assets/ holds the block already spliced in, because that is what a project gets byte for byte; so a registry change makes the render differ from the embedded copy, --check exits 1, regenerating fixes it, and the binary has to be rebuilt to pick the new bytes up.

Note the asymmetry, because it is easy to get backwards: everything under internal/scaffold/assets is written out and must never be edited in a project; every AGENTS.md in this tree is hand-written and must never be generated. One is a document with a flag table in it, the other is a reason.

Status

Done and tested: every package in the table above, and the commands init, auth, config, gen, mirror, version, new, check, ac, tree, index, evict, pull, push, remote, comment, close, labels, sync-evict, api — plus cmd/release, against a fake Gitea.

The plugin is gone. Its skills are embedded here, its hook is kettle mirror, and kettle init writes the whole .claude/ tree. The marketplace catalogue at the repository root now holds one entry, tdl, which has no binary and never needed one.

Keeping this file true

  • Scope: the binary as a whole — why it exists, what it depends on, how it is built and tested, and where each package's own document is. Files: go.mod, go.sum, Makefile, and the shape of the tree.
  • Update it when a package is added or removed (the diagram and the table both name every one), a dependency changes, a make target is added or its meaning changes, a cross-package contract like the round trip changes, or the argument for embedding the documents rather than shipping them stops holding.
  • Do not describe a package's internals here. That is what the files it links to are for, and a summary that drifts is worse than a link.