There is no CI: the instance has no act_runner and none is planned, so releases are cut by hand. That makes `make check` the only thing standing between a mistake and the tracker, and it is one command: gofmt, vet, the suite with the cache defeated, `go mod verify`, a vendored build, and `kettle gen skills --check`. The last one is the invariant worth having — the plugin's SKILL.md command reference is generated from the binary's registry, so a flag that changed cannot ship with documentation that recommends the old one. `cli/cmd/release` publishes to Gitea using the same SDK the binary already vendors, which is a pleasing thing to be able to say: nothing third-party handles the artifacts. It is a second binary rather than a `kettle` subcommand on purpose — `kettle`'s command tree is what generates the plugin's skills, so a verb there ships to every operator, and publishing a release is build infrastructure. It is idempotent end to end: an existing release for the tag is reused, an asset of the same name is replaced rather than doubled, and a retried run converges instead of duplicating. `make release` refuses three things, each with its own message: a dirty working tree, a TAG that is not what `git describe` reports, and a tag the remote does not have. A release built from uncommitted code is unreproducible and nobody finds out until they need to reproduce it. `kettle version` reports the stamp, the toolchain and the VCS revision. The default is `dev`, and a hand build says so and means it — a binary out of somebody's working tree is not a release and must not claim to be one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 KiB
AGENTS.md — the kettle CLI
kettle is a globally installed binary. It owns everything mechanical about
issues: what an issue is, where the store lives, who this machine is, and how
issues move to and from Gitea. It replaced a set of Python scripts that used to
ship inside the plugin.
The plugin keeps what only a plugin can carry — the rules an operator states and a binary cannot enforce. Everything else is here.
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
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.insertwas 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.
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/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
| 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 the plugin's docs 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/project |
root | the walk, and every path resolved from its answer |
The rules that hold the layers apart, and the seven 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 and code.gitea.io/sdk/gitea — eight modules once the SDK's
own are counted. 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.
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 skills --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
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.
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.
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/cmdbuilds the binary once inTestMainand 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 setsKETTLE_CONFIG_HOMEat 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 skills --out ../plugins/kettle/skills # rewrite the blocks
kettle gen skills --out ../plugins/kettle/skills --check # exit 1 if stale
Everything between <!-- kettle:gen --> and <!-- /kettle:gen --> in the
plugin's SKILL.md files comes from the command registry, so a renamed flag cannot
ship with documentation that recommends the old one. --check is what a
pre-commit hook or a CI step calls. The generator is
internal/cmd's, and the seam between command groups
and plugin skills is the plugin's.
Note the asymmetry, because it is easy to get backwards: a SKILL.md command block is generated and must never be hand-edited; every AGENTS.md in this tree is hand-written and must never be generated. One is a flag table, the other is a reason.
Status
Done and tested: every package in the table above, and the commands init, auth,
config, gen, version, new, check, ac, tree, index, evict, pull,
push, remote, comment, close, labels, sync-evict — plus cmd/release,
against a fake Gitea.
The plugin is rewired: it lives at plugins/kettle, ships no Python domain code
and no guard hook, and its command reference is generated from this registry.
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
maketarget is added or its meaning changes, or a cross-package contract like the round trip changes. - 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.