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>
This commit is contained in:
+110
-57
@@ -1,31 +1,33 @@
|
||||
# AGENTS.md — the kettle CLI
|
||||
|
||||
`kettle` is a globally installed binary. **It owns this project's connection to
|
||||
its tracker** — the credentials, the transport, the payload scratchpad — and
|
||||
issues are its main subject but no longer its only one: what an issue is, where
|
||||
`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.
|
||||
It replaced a set of Python scripts that used to ship inside the plugin.
|
||||
|
||||
That last clause is a deliberate widening and it is worth being straight about.
|
||||
"Issues and nothing else" was the line until two things crossed it: `cmd/release`,
|
||||
which publishes this repository's own releases, and `kettle api`, which exists
|
||||
because the alternative was requiring `tea` — a second CLI with a second set of
|
||||
logins that nothing here could see, documented in 400 lines of somebody else's
|
||||
flag reference that nothing here could check. One door for every request is worth
|
||||
more than a slogan: the token is held in one place, every body lands in one
|
||||
scratchpad, and no skill has to explain which tool is authenticated as whom.
|
||||
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, and `api` is transport plus a command, touching neither it nor
|
||||
`internal/mapping`.
|
||||
|
||||
The plugin keeps what only a plugin can carry — the rules an operator states and
|
||||
a binary cannot enforce. Everything else is here.
|
||||
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
|
||||
## Why a binary, and then why no plugin
|
||||
|
||||
Three failures in the Python version were failures of *runtime*, not of logic:
|
||||
|
||||
@@ -41,6 +43,22 @@ A compiled binary answers all three by construction. There is one walk
|
||||
([`internal/project`](internal/project/AGENTS.md)), 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`](internal/scaffold/AGENTS.md))
|
||||
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
|
||||
@@ -51,50 +69,68 @@ 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/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
|
||||
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`](cmd/kettle/AGENTS.md) | entry point | `os.Exit(cmd.Main(os.Args[1:]))`, and why there is nothing else in it |
|
||||
| [`cmd/release`](cmd/release/AGENTS.md) | build tool | why publishing a release is not a `kettle` verb, and why it goes around the transport |
|
||||
| [`internal/cmd`](internal/cmd/AGENTS.md) | commands | the registry every command is a value in, and the generator that writes the plugin's docs from it |
|
||||
| [`internal/cmd`](internal/cmd/AGENTS.md) | commands | the registry every command is a value in, and the generator that writes a project's documents from it |
|
||||
| [`internal/config`](internal/config/AGENTS.md) | configuration | two files, and why the tokens are not in the one inside the repository |
|
||||
| [`internal/gitea`](internal/gitea/AGENTS.md) | transport | the SDK, the payload scratchpad, the ledger, the dependency endpoint |
|
||||
| [`internal/mapping`](internal/mapping/AGENTS.md) | bridge | md ↔ payload, the id marker, label colours, the checkbox merge |
|
||||
| [`internal/wire`](internal/wire/AGENTS.md) | addresses | `42`, `#42`, `owner/repo#42`, a URL — four spellings of one thing |
|
||||
| [`internal/issue`](internal/issue/AGENTS.md) | domain | the format, the taxonomy, the store, eviction — all of it offline |
|
||||
| [`internal/scaffold`](internal/scaffold/AGENTS.md) | documents | the commands, skills and subagent written into `.claude/`, and why they are not a plugin |
|
||||
| [`internal/mirror`](internal/mirror/AGENTS.md) | filesystem | one invariant per directory, seven branches, one refusal |
|
||||
| [`internal/project`](internal/project/AGENTS.md) | 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
|
||||
The rules that hold the layers apart, and the eleven tests that fail when one
|
||||
breaks, are in [`internal/AGENTS.md`](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.
|
||||
`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.
|
||||
|
||||
```bash
|
||||
make install # build straight onto your PATH, version stamped
|
||||
@@ -106,7 +142,7 @@ 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
|
||||
— 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
|
||||
@@ -161,7 +197,7 @@ The mechanics of each half live with the commands that implement them, in
|
||||
[`internal/gitea`](internal/gitea/AGENTS.md)'s, and the marker that survives a lost
|
||||
ledger is [`internal/mapping`](internal/mapping/AGENTS.md)'s.
|
||||
|
||||
## No guard hook
|
||||
## 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
|
||||
@@ -177,6 +213,14 @@ 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 project** — `kettle 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
|
||||
|
||||
```bash
|
||||
@@ -204,31 +248,39 @@ Three disciplines every test follows:
|
||||
## Documentation that is generated
|
||||
|
||||
```bash
|
||||
kettle gen skills --out ../plugins/kettle/skills # rewrite the blocks
|
||||
kettle gen skills --out ../plugins/kettle/skills --check # exit 1 if stale
|
||||
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 -->` 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`](internal/cmd/AGENTS.md)'s, and the seam between command groups
|
||||
and plugin skills is [the plugin's](../plugins/kettle/AGENTS.md).
|
||||
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`](internal/cmd/AGENTS.md)'s and the documents are
|
||||
[`internal/scaffold`](internal/scaffold/AGENTS.md)'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.
|
||||
**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`, `version`, `new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`,
|
||||
`push`, `remote`, `comment`, `close`, `labels`, `sync-evict`, `api` — plus
|
||||
`cmd/release`, against a fake Gitea.
|
||||
`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 rewired: it lives at `plugins/kettle`, ships no Python domain code
|
||||
and no guard hook, and its command reference is generated from this registry.
|
||||
**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`](../plugins/tdl/AGENTS.md), which
|
||||
has no binary and never needed one.
|
||||
|
||||
## Keeping this file true
|
||||
|
||||
@@ -237,6 +289,7 @@ and no guard hook, and its command reference is generated from this registry.
|
||||
`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, or a cross-package contract like the round trip changes.
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user