feat: publish releases with this repository's own SDK code

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>
This commit is contained in:
naudachu
2026-08-12 01:01:27 +05:00
parent ec0a1893b7
commit 01fb5a2703
27 changed files with 3385 additions and 266 deletions
+4
View File
@@ -3,6 +3,10 @@
.claude/ .claude/
.tea/ .tea/
tmp/ tmp/
# `make dist` and `make build` — release artifacts are rebuilt from a tag, never
# committed
cli/dist/
__pycache__/ __pycache__/
*.pyc *.pyc
+90
View File
@@ -0,0 +1,90 @@
# AGENTS.md — the repository root
One repository holding a Claude Code **plugin marketplace** and the **binary one
of its plugins drives**. Three things live here and nothing else does:
```
.claude-plugin/marketplace.json the catalog: one entry per plugin
cli/ the kettle binary — Go, no cobra, 8 packages
plugins/ one directory per plugin
```
Where to go from here, and each of these directories documents itself:
| directory | what it is |
|---|---|
| [`cli/`](cli/AGENTS.md) | one Go module, two binaries: `kettle`, which owns every mechanical thing about issues — the format, the store, the credentials, the tracker — and [`cmd/release`](cli/cmd/release/AGENTS.md), which publishes this repository's own releases. `make check` is the gate; there is no CI here |
| [`plugins/`](plugins/AGENTS.md) | what a plugin is here, and what the catalog entry has to match |
| [`plugins/kettle/`](plugins/kettle/AGENTS.md) | the plugin that wraps the binary: skills, the runner subagent, the hooks |
| [`plugins/tdl/`](plugins/tdl/AGENTS.md) | the Three Dots Labs Go rule set — no binary, no state, just rules and templates |
The split between `cli/` and `plugins/kettle/` is the one architectural fact
worth carrying: **a binary holds what can be enforced, a plugin holds what can
only be stated.** Anything mechanical belongs in Go where a test can hold it
down; anything that is a judgement an operator makes belongs in a SKILL.md.
## The AGENTS.md convention
**Every directory with a story documents itself, in that directory.** This file
is a map, not a manual — it says what lives where and sends you down. A reader
who opens `cli/internal/gitea/` gets the transport's rules from
`cli/internal/gitea/AGENTS.md` and does not have to load the whole repository's
design to change one request.
Three rules make that work:
1. **`AGENTS.md` is the real file; `CLAUDE.md` beside it is a symlink to it.**
`plugins/kettle/hooks/agents-sync.sh` enforces that before every Bash call and
repairs any directory that drifted — it renames, re-points and swaps, and it
never deletes content. Two real files with different content is the one case
it refuses to resolve and reports instead. `CLAUDE.md` is gitignored, because
it is generated.
2. **A directory's file describes that directory only.** What a parent or a child
owns gets a link, never a second copy — the copy is what goes stale. If a
sentence is true of the whole binary it belongs in `cli/AGENTS.md`; if it is
true of one package it belongs in that package's file.
3. **Every file ends with its own maintenance contract** — the *Keeping this file
true* section. It names the files the document covers and what kind of change
obliges an edit.
### What keeps them true
Nothing automatic, and that is a choice. Keeping these files honest is the job of
whoever changes the code they describe, which is what the *Keeping this file true*
section at the bottom of each one is for.
A hook that nagged after every write was written and then removed: it would have
fired for every user of the `kettle` plugin, on every edit in every repository
they touched, to enforce a documentation convention that is this repository's and
nobody else's. A plugin about issue tracking does not get to reach that far.
`plugins/kettle/hooks/agents-sync.sh` stays, because it repairs the filesystem
layout rather than asking anybody for anything: `AGENTS.md` a real file,
`CLAUDE.md` a symlink to it. It cannot fail a tool call — it exits 0 on every
path, including its own bugs, because documentation maintenance is not permitted
to break a build.
## Development
```bash
cd cli && make check # fmt, vet, test, go mod verify, build, docs — the gate
cd cli && make help # install, dist, release
```
**There is no CI on the instance this lives on**, so `make check` is the only thing
between a mistake and the tracker, and it is on whoever is committing to run it. Its
last step is the repository's one mechanical documentation invariant: the
command reference an agent reads inside the plugin is generated from the command
registry the binary is built from. Everything else in this tree — including every
`AGENTS.md` — is prose, and prose is held true by the hook above and by whoever
is editing.
## Keeping this file true
- **Scope:** the repository layout, the plugin/binary split, and the AGENTS.md
convention itself. Every deeper subject belongs to a deeper file.
- **Update it when** a top-level directory appears or goes, a plugin is added or
removed, the marketplace catalog changes shape, or either hook's behaviour
changes.
- **Do not** put a command reference, a package's rules, or a skill's procedure
here. Link to the file that owns it.
+228 -18
View File
@@ -1,9 +1,8 @@
# claude-skills — a Claude Code plugin marketplace # claude-skills — a Claude Code plugin marketplace
One repository, one marketplace, several plugins, and the `kettle` binary the One repository, one marketplace, two plugins, and the `kettle` binary the issue
issue plugin is built on. Register the marketplace once and install whichever plugin is built on. Register the marketplace once and install whichever pieces you
pieces you want; each plugin is independent and carries its own manifest and want; each plugin is independent and carries its own manifest and docs.
docs.
## Installation ## Installation
@@ -27,15 +26,19 @@ Then install what you need:
Use `/plugin` to enable, disable, or update them later. Use `/plugin` to enable, disable, or update them later.
**`kettle` also needs its binary**, which no plugin can install for you. Build it **`kettle` also needs its binary**, which no plugin can install for you. Build it
from this repository (`cli/go.mod` requires **Go 1.26**; `vendor/` is committed, from this repository `cli/go.mod` requires **Go 1.26**, and the first build
so the build needs no network): downloads eight modules, verified against `go.sum`:
```bash ```bash
cd cli && make install # ~/.local/bin/kettle, version stamped
cd cli && make install BINDIR=$(go env GOPATH)/bin
# or plain go, without the version stamp:
cd cli && go build -o ~/.local/bin/kettle ./cmd/kettle cd cli && go build -o ~/.local/bin/kettle ./cmd/kettle
go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest
``` ```
Put the target directory on your `PATH` and check with `kettle help`. A skill Put the target directory on your `PATH` and check with `kettle version`. A skill
that answers `command not found: kettle` is telling you exactly this. that answers `command not found: kettle` is telling you exactly this.
## What ships here ## What ships here
@@ -43,7 +46,142 @@ that answers `command not found: kettle` is telling you exactly this.
| Plugin | Commands | What it does | | Plugin | Commands | What it does |
|---|---|---| |---|---|---|
| [`kettle`](plugins/kettle) | `/kettle:init` `/kettle:auth` `/kettle:issue` `/kettle:sync` `/kettle:use` `/kettle:project` | Issues as local markdown, cleanly layered. Issues are units of work offline first and tracker rows second; the `kettle` binary does the work, and each skill's command reference is generated from the binary's own command registry so it cannot drift | | [`kettle`](plugins/kettle) | `/kettle:init` `/kettle:auth` `/kettle:issue` `/kettle:sync` `/kettle:use` `/kettle:project` | Issues as local markdown, cleanly layered. Issues are units of work offline first and tracker rows second; the `kettle` binary does the work, and each skill's command reference is generated from the binary's own command registry so it cannot drift |
| [`tdl`](plugins/tdl) | `/tdl:audit` | Three Dots Labs Go conventions as an enforceable rule set — audits a Go project against 63 CQRS/DDD/Clean-Architecture rules by severity, or scaffolds services, handlers, entities, repositories and Watermill adapters from templates that already follow them | | [`tdl`](plugins/tdl) | `/tdl:audit` | Three Dots Labs Go conventions as an enforceable rule set — audits a Go project against nine categories of CQRS/DDD/Clean-Architecture rules by severity, or scaffolds services, handlers, entities, repositories and Watermill adapters from templates that already follow them |
---
# The `kettle` CLI
A single Go binary that keeps a project's issues as flat markdown files and moves
them to and from Gitea when you say so. **Everything outside the `sync` group works
with the network off.** Two direct dependencies, vendored, no cobra, seven
internal packages with one direction of imports — see
[`cli/AGENTS.md`](cli/AGENTS.md).
## What it does
**project** — the project itself
| command | what it does |
|---|---|
| `kettle init` | makes this directory a project: creates the `.kettle/` marker every other command resolves the store from, migrates an older store in, gitignores it |
| `kettle auth` | manages the tokens this machine holds — one file, mode 0600, outside every working tree |
| `kettle config` | prints every path and setting a run resolved to, with overrides applied. Never prints a token |
| `kettle gen skills` | rewrites the plugin's SKILL.md command reference from the binary's own command registry |
| `kettle version` | what this binary was built as — `dev` from a working tree, the tag when a release build stamped it in |
**issue** — units of work, offline, no tracker involved
| command | what it does |
|---|---|
| `kettle new` | creates a local issue from its type template. `origin: local` is a complete state, not a draft |
| `kettle check` | validates issues against the canonical format. Exit 1 on errors, so a hook or a CI step can call it |
| `kettle ac` | lists and ticks an issue's checkboxes — one byte of the file changes, so the diff is the state that changed |
| `kettle tree` | draws the dependency graph read off `depends:` |
| `kettle index` | rebuilds `INDEX.md` from what is on disk, progress counted from the bodies |
| `kettle evict` | removes closed issues that also live in a tracker. A local one is never evicted, in any state |
**sync** — moving issues between the store and the tracker
| command | what it does |
|---|---|
| `kettle pull` | fetches issues by address (`42`, `#42`, `owner/repo#42`, a URL) or by filter, with their blockers, comments included |
| `kettle push` | sends local issues up, blockers first, and deletes the local copy once the tracker confirms it |
| `kettle remote` | lists what exists in the tracker, one line each. Writes nothing |
| `kettle comment` | posts or edits a comment, body from a file or inline, then refetches the whole thread |
| `kettle close` | closes or reopens issues in the tracker, and on disk with them. State only |
| `kettle labels` | creates the canonical `type/*` and `severity/*` labels in a repository, `exclusive` flag and all |
| `kettle sync-evict` | refreshes state from the tracker first, then evicts what is closed |
`kettle help` prints the tree; `kettle help <command>` prints one command in full —
flags, defaults and worked examples. Flags may be typed after positional arguments.
## Usage
### Once per project
```bash
kettle auth add --name noodles --url https://git.example.com < token.txt
kettle init --login noodles --repo owner/name
kettle config # what did that actually resolve to?
```
The token is read from standard input because an argument is in the shell history
the moment it is typed. What lands in the repository is the login's **name**; the
tokens stay in `~/.config/kettle/logins.yaml`.
### Working offline
```bash
kettle new --type task --title "Wire sqlc into the appclick repo layer" \
--label tech/sql --label comp/appclick
kettle new --type bug --title "Fix the index rebuild on an empty store" \
--depends wire-sqlc-appclick --milestone v0.2
kettle check # every issue in the store
kettle check --strict # warnings count as errors
kettle ac wire-sqlc-appclick # numbered checkboxes with their state
kettle ac wire-sqlc-appclick --check 3
kettle tree --depth 2 # every root, two levels down
kettle index
```
No login, no network and no tracker for any of that. The store is flat markdown with
one metadata field per line, so plain grep works without a parser:
```bash
grep -l 'labels:.*type/bug' .kettle/issues/*.md
grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md # who depends on it
```
### The tracker
```bash
kettle labels --dry-run # print the plan; not one writing request
kettle labels # create whatever is missing
kettle push # everything the tracker lacks, blockers first
kettle push --update wire-sqlc-appclick
kettle push --dry-run # validate and print the plan; no network
kettle remote --state all --label type/bug --limit 50
kettle pull 42 # the issue and everything blocking it, any state
kettle pull 42 --no-deps # just that one — one request
kettle pull owner/repo#42 # an issue in another repository
kettle pull --milestone v0.2 --limit 20
kettle comment wire-sqlc-appclick --file notes.md
kettle close 42 43
kettle close --reopen 42
kettle sync-evict --dry-run
```
### The rule worth knowing before you push
**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. Get it back with `kettle pull <n>`: it lands under the
same slug, with the same `depends:`, even on a machine that has never seen it,
because the slug travelled up in the body as a marker and was recorded in a local
number → slug ledger.
A never-pushed `origin: local` issue is the only copy of that work, and nothing
deletes it — in any state, not even when it is named on the command line.
## Configuration
| file | holds | notes |
|---|---|---|
| `<project>/.kettle/config.yaml` | `login:` (a name) and `repo:` | written by `kettle init`; safe inside a repository because a name is worth nothing on its own |
| `~/.config/kettle/logins.yaml` | the tokens | one file per machine, mode 0600, outside every working tree |
`KETTLE_LOGIN`, `KETTLE_REPO`, `KETTLE_URL`, `KETTLE_TOKEN` and
`KETTLE_CONFIG_HOME` each override the file they shadow — for CI, and for anyone who
would rather have no token on disk at all. An unknown key in either file is an
error, not a silent drop: an older binary would otherwise delete the setting it did
not recognize the next time it wrote the file.
---
## Layout ## Layout
@@ -51,8 +189,11 @@ that answers `command not found: kettle` is telling you exactly this.
.claude-plugin/ .claude-plugin/
marketplace.json the catalog — one entry per plugin, source is a marketplace.json the catalog — one entry per plugin, source is a
path into plugins/ path into plugins/
cli/ the kettle binary: cmd/kettle + internal/*, its cli/ one Go module, two binaries
own AGENTS.md, its own tests, vendored deps Makefile the gate and the release: make check | dist | release
cmd/kettle/ what people install
cmd/release/ what publishes this repository's own releases
internal/ seven packages, one direction of imports
plugins/ plugins/
kettle/ kettle/
.claude-plugin/plugin.json .claude-plugin/plugin.json
@@ -60,7 +201,7 @@ plugins/
README.md AGENTS.md README.md AGENTS.md
tdl/ tdl/
.claude-plugin/plugin.json .claude-plugin/plugin.json
skills/ skills/audit/{references,templates}
``` ```
A plugin's root is its directory under `plugins/`, so `${CLAUDE_PLUGIN_ROOT}` A plugin's root is its directory under `plugins/`, so `${CLAUDE_PLUGIN_ROOT}`
@@ -74,15 +215,84 @@ put an earlier version's issue store inside a versioned plugin cache.
## Development ## Development
**There is no CI.** The instance this lives on has no Actions runner and none is
planned, so nothing runs on a push, nothing checks a branch, and nothing will
tell you afterwards. `make check` is the whole gate, it takes about ten seconds,
and **it is on whoever is committing to run it**:
```bash ```bash
cd cli && go test ./... # the binary's suite cd cli && make check
cd cli && go build -o /tmp/kettle ./cmd/kettle
/tmp/kettle gen skills --out plugins/kettle/skills --check # docs vs binary
``` ```
The second one is the invariant that keeps the plugin honest: everything a Five steps in one command, non-zero the moment any of them fails:
| step | what it holds down |
|---|---|
| `gofmt -l cmd internal` | nothing in the tree is unformatted. It reports and never rewrites — a formatting change made silently by a build is a change nobody reviewed |
| `go vet ./...` | the compiler's own second opinion |
| `go test -count=1 ./...` | the whole suite, with the test cache defeated: a gate that can pass because it passed an hour ago on different code is not a gate |
| `go mod verify` + `go build -mod=vendor ./...` | every module matches its hash in `go.sum`, and the committed `vendor/` is complete and is what compiles. A `vendor/` that has drifted from `go.mod` breaks nobody until somebody builds with a cold module cache |
| `kettle gen skills --check` | the plugin's SKILL.md command reference still agrees with the binary it documents. Exit 1 the moment it does not, and `gen skills` without `--check` is the fix |
That last one is the invariant that keeps the plugin honest: everything a
SKILL.md says about a `kettle` command — its usage line, its flags, its SKILL.md says about a `kettle` command — its usage line, its flags, its
examples — is generated from the registry the binary is built from, between examples — is generated from the registry the binary is built from, between
`<!-- kettle:gen -->` markers. `--check` exits 1 when any of it is out of date; `<!-- kettle:gen -->` markers. Prose outside the markers is never touched.
run `gen skills` without it to regenerate. Prose outside the markers is never
touched. ### Where the design notes are
**Every directory with a story carries its own `AGENTS.md`**, starting at [the
repository root](AGENTS.md) and recursing into [`cli/`](cli/AGENTS.md), each of its
packages, and each plugin. A `CLAUDE.md` beside one is a generated symlink to it, and
two hooks in the `kettle` plugin keep the arrangement honest: one repairs the layout
before every Bash call, the other asks for the nearest `AGENTS.md` to be corrected
after a file is written. Read the one next to the code you are changing rather than
the whole tree.
`make help` lists the rest — `build`, `install [BINDIR=…]`, `dist`, `clean`.
`make install` puts a version-stamped binary in `~/.local/bin` (override with
`BINDIR=$(go env GOPATH)/bin`) and says so if that directory is not on your
`PATH`, which is the whole of what a skill means by `command not found: kettle`.
Design notes live next to the code they describe: **every directory with a story
carries its own `AGENTS.md`**, starting at [the repository root](AGENTS.md) and
recursing into [`cli/`](cli/AGENTS.md), each of its packages, and each plugin. A
`CLAUDE.md` beside one is a generated symlink to it. Two hooks in the `kettle`
plugin keep the arrangement honest — one repairs the layout before every Bash call,
the other asks for the nearest `AGENTS.md` to be corrected after a file is written.
## Cutting a release
Releases are cut **by hand, from a developer's machine**, and published by this
repository's own code. `cli/cmd/release` is a second binary in the same module
that talks to Gitea's API through the same SDK `kettle` uses: no third-party
action, and nothing between a tag and what people download that is not in this
tree. It is deliberately not a `kettle` subcommand — `kettle` is a tool for
issues, and its command tree is what generates the plugin's documentation.
```bash
cd cli
make check # nothing else is going to run this for you
git tag -a v0.2.0 -m v0.2.0 # on the commit you mean to ship
git push origin v0.2.0
export KETTLE_URL=https://git.noodles.cam KETTLE_TOKEN=# or run it from an
# initialized project and it reads the
# login pinned there
make release TAG=v0.2.0 NOTES=../notes-v0.2.0.md TITLE="kettle v0.2.0"
```
| step | what it verifies |
|---|---|
| `make check` | the five things above. A release is the worst place to find out |
| `git tag` / `git push` | the tag exists, and it exists on the server. A release naming a tag nobody else can fetch is a download page pointing at nothing |
| `make release` | **refuses a dirty working tree** — what shipped would not be what is committed, and nobody could rebuild it; **refuses a `TAG` that is not what `git describe` reports** — which is only true when the tag exists and `HEAD` is standing on it, so it also catches "I tagged, then committed one more thing"; and **refuses a tag that is not on the remote** |
| the `dist` it runs first | `kettle` cross-compiled for darwin and linux on amd64 and arm64, `CGO_ENABLED=0`, each stamped with the tag via `-ldflags`, plus a `SHA256SUMS` over all four |
| the publish | creates the release or reuses the one already there, uploads each artifact, replaces an asset of the same name rather than doubling it, and prints the release URL with every asset under it |
**Re-running it is safe, and that is the point.** An upload that died half way
through is fixed by running the same command again: the tag's release is reused,
the assets that made it are replaced by the freshly built ones, and corrected
notes actually land. You get one release and one copy of each asset either way.
`kettle version` on a downloaded binary reports the tag it was built from;
a binary somebody built out of a working tree says `dev` and means it.
+145 -239
View File
@@ -1,11 +1,15 @@
# AGENTS.md — the kettle CLI # AGENTS.md — the kettle CLI
`kettle` is a globally installed binary. It owns everything that used to be a `kettle` is a globally installed binary. It owns everything mechanical about
Python script under `plugins/tea/skills/*/scripts/`: what an issue is, where the issues: what an issue is, where the store lives, who this machine is, and how
store lives, who this machine is, and how issues move to and from Gitea. 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 The plugin keeps what only a plugin can carry — the rules an operator states and
a binary cannot enforce. Everything mechanical is here. 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 ## Why a binary
@@ -20,47 +24,17 @@ Three failures in the Python version were failures of *runtime*, not of logic:
convention checked by grep. convention checked by grep.
A compiled binary answers all three by construction. There is one walk A compiled binary answers all three by construction. There is one walk
(`internal/project`), it is imported rather than re-derived, and the layering ([`internal/project`](internal/project/AGENTS.md)), it is imported rather than
rule is a build graph a test walks. 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 ## Layers
Knowledge flows one way. The arrow means "imports". 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/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/cmd the command tree: flags, receipts, exit codes
│ │ │ │ │ │
@@ -86,185 +60,92 @@ internal/project ROOT which directory is the project, and every path
depends on nothing depends on nothing
``` ```
Both the transport and the bridge also import `code.gitea.io/sdk/gitea`, which | package | layer | what its own file opens with |
is where the payloads now live. The arrow that used to point at `internal/wire` |---|---|---|
for them points there instead. | [`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/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/project`](internal/project/AGENTS.md) | root | the walk, and every path resolved from its answer |
Read it bottom-up and each layer knows strictly less about trackers than the one The rules that hold the layers apart, and the seven tests that fail when one
above it. Four rules hold the line, seven tests between them, and each fails on breaks, are in [`internal/AGENTS.md`](internal/AGENTS.md). Read the diagram
a real mistake rather than on a naming convention: bottom-up: each layer knows strictly less about trackers than the one above it.
- `internal/issue` may import `internal/project` and the standard library, and ## Dependencies, and building
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.** `gopkg.in/yaml.v3` and `code.gitea.io/sdk/gitea` — eight modules once the SDK's
The payload shapes lived in `internal/wire`, a package that imported the own are counted. No cobra: commands are values in a registry, which is what lets
standard library and nothing else, so "the bridge cannot reach a transport" was the plugin's SKILL.md files be generated from the same struct that holds the code.
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
```bash ```bash
cd cli && go test ./... 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
``` ```
`internal/cmd` builds the binary once in `TestMain` and runs it as a subprocess **`make` is the build, and it is also the CI.** There is no act_runner on the
against a throwaway project in a temp directory — the same discipline the Python instance this lives on and none is planned, so nothing runs on a push: `make check`
suite arrived at, for the same reason. **The binary is never run in the — fmt, vet, test, `go mod verify`, build, `gen skills --check` is the only thing
directory it was built in**, because that is exactly the arrangement that hid standing between a mistake and the tracker, and it is on whoever is committing to run
the `__file__` bug: a tool is installed in one place and used on projects in it. `make help` lists the rest; `make dist` cross-compiles four platforms with a
another, and a test that collapses the two proves nothing about resolution. `SHA256SUMS`, and `make release TAG=v1.2.3` publishes them through
[`cmd/release`](cmd/release/AGENTS.md) after refusing a dirty tree, a `TAG` that is
not what `git describe` reports, and a tag the remote does not have.
Every fixture strips `CLAUDE_PROJECT_DIR`. It is the first anchor of the walk, A hand build says `dev` for `kettle version` and means it: a binary out of somebody's
so the harness's own value would point every fixture at this repository. working tree is not a release and must not claim to be one. The version is stamped at
Anything touching credentials sets `KETTLE_CONFIG_HOME` at a temp directory, so link time into `internal/cmd.Version`, derived from `git describe` rather than kept in
a test run can neither read nor overwrite the developer's own tokens. a file — a number somebody has to remember to bump is a number that will be wrong.
**Every fake tracker answers `/api/v1/version`**, because building a client asks **`go.mod` says `go 1.26`**, which the SDK requires, and **`vendor/` is
for it: the SDK will not hand one back until the instance has said what it is, committed** — 281 files, 2.3 MB, which is the price of knowing exactly what
and that answer is what the dependency gate is decided on later. The fakes say compiled.
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. 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`](internal/gitea/AGENTS.md) and
[`internal/mapping`](internal/mapping/AGENTS.md).
The SDK is imported as `sdk` everywhere, so one type has one spelling across the
tree.
## The round trip ## The round trip
`push` and `pull` are the two halves of one rule, and the rule is that **the `push` and `pull` are the two halves of one rule, and the rule is that **the
store holds what has not left this machine.** 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.
A successful push deletes `<id>.md` and every sidecar under that slug, on create The mechanics of each half live with the commands that implement them, in
and on `--update` alike, and prints the number and URL the issue now lives at. [`internal/cmd`](internal/cmd/AGENTS.md); the ledger that survives the deletion is
An `--update` can take one extra request with it: Gitea's edit endpoint carries [`internal/gitea`](internal/gitea/AGENTS.md)'s, and the marker that survives a lost
no labels, so when the answer's label set and the issue's disagree — a label ledger is [`internal/mapping`](internal/mapping/AGENTS.md)'s.
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 ## No guard hook
@@ -276,45 +157,70 @@ for the transport and the guard to disagree — the failure the hook existed to
catch is not expressible any more. catch is not expressible any more.
There is also no `--login` and no `--repo` on any sync command bar `labels`. 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 A cross-repository address is still an address: `kettle pull owner/repo#42`
facts about the project, stated once by `kettle init`. A cross-repository address re-points the client for that one call, which is bookkeeping and not a second
is still an address: `kettle pull owner/repo#42` re-points the client for that connection.
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 ## Tests
The one endpoint with a story. `depends:` becomes a native Gitea link, which is ```bash
what makes the tracker show the blocking panel and refuse to close a blocked make check # fmt, vet, test, go mod verify, build, docs — the whole gate
issue first, and it is read back the same way on a pull. go test ./... # just the tests
```
- **Reading** goes through the SDK (`ListIssueDependencies`). Three disciplines every test follows:
- **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 - **`internal/cmd` builds the binary once in `TestMain` and runs it as a
link inside one repository, and a `depends:` entry is allowed to live subprocess** against a throwaway project in a temp directory. The binary is
somewhere else. So one hand-rolled POST survives, through the same HTTP client never run in the directory it was built in, because that is exactly the
as everything else — same payload dump, same `*APIError`. arrangement that hid the `__file__` bug: a tool is installed in one place and
- **The version gates both.** The routes are absent from Gitea 1.19 and present used on projects in another, and a test that collapses the two proves nothing
in 1.20, checked against the release tags themselves, so an instance older about resolution.
than that is answered from the version it already gave us rather than from a - **Every fixture strips `CLAUDE_PROJECT_DIR`**, the first anchor of the walk, or
404 — which on an old server is also what "no such issue" looks like. the harness's own value would point every fixture at this repository. Anything
- **A tracker that answers with a status still means "no dependencies here"**, touching credentials sets `KETTLE_CONFIG_HOME` at a temp directory, so a run can
because an instance that HAS the endpoint can still have them turned off for a neither read nor overwrite the developer's own tokens.
repository, and a pull must bring the issue back either way. A dead connection - **Every fake tracker answers `/api/v1/version`**, because building an SDK client
is not that answer and never was. 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
```bash
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`](internal/cmd/AGENTS.md)'s, and the seam between command groups
and plugin skills is [the plugin's](../plugins/kettle/AGENTS.md).
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 ## Status
Done and tested: all seven packages, and the commands `init`, `auth`, `config`, Done and tested: every package in the table above, and the commands `init`, `auth`,
`new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`, `push`, `remote`, `config`, `gen`, `version`, `new`, `check`, `ac`, `tree`, `index`, `evict`, `pull`,
`comment`, `close`, `labels`, `sync-evict`. 99 tests. `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 and no guard The plugin is rewired: it lives at `plugins/kettle`, ships no Python domain code
hook, and its command reference is generated from this registry 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 ## Keeping this file true
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 - **Scope:** the binary as a whole — why it exists, what it depends on, how it is
directory there, so name one only when it is a subject somebody would load on its built and tested, and where each package's own document is. Files: `go.mod`,
own. `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.
- **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.
+188
View File
@@ -0,0 +1,188 @@
# The gate, and the release, for a repository with no CI.
#
# There is no act_runner on the instance this lives on and none is planned, so
# NOTHING RUNS ON A PUSH. `make check` is the only thing standing between a
# mistake and the tracker, and it is on whoever is committing to run it — the
# same five steps a workflow would have run, in one command, exiting non-zero
# the moment one of them fails.
#
# `make release` is the other half: a release is cut by hand, from a developer's
# machine, by this module's own code (cmd/release) talking to Gitea's API. It
# refuses to publish a dirty tree or a tag that is not the version it is about
# to stamp into the binaries, because a release built from uncommitted code is
# unreproducible and nobody finds out until they need to reproduce it.
SHELL := /bin/sh
# Sequential on purpose: `check` builds the binary and then asks it whether the
# plugin's documentation still matches, and -j would let the second start first.
.NOTPARALLEL:
MODULE := git.noodles.cam/claude-skills/marketplace/cli
SKILLS := ../plugins/kettle/skills
DIST := dist
BIN := $(DIST)/kettle
REMOTE ?= origin
# Where `make install` puts the binary. The plugin's skills expect `kettle` on
# PATH and say so when it is not; override for a Go-style layout:
# make install BINDIR=$(go env GOPATH)/bin
BINDIR ?= $(HOME)/.local/bin
# VERSION is what a binary reports for `kettle version`. It is derived from git
# rather than kept in a file: a number somebody has to remember to bump is a
# number that will be wrong. A tree that is not on a tag says so, and a dirty
# tree says that too — "v0.2.0-4-g1a2b3c4-dirty" is the honest answer, and it is
# exactly what you want to see in a bug report.
VERSION ?= $(shell git describe --tags --dirty --always 2>/dev/null || echo dev)
LDFLAGS := -X $(MODULE)/internal/cmd.Version=$(VERSION)
PLATFORMS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64
.PHONY: help check fmt vet test verify build gen-check dist release install clean
help:
@echo 'kettle — there is no CI here; these are what a person runs.'
@echo
@echo ' make check the gate: fmt, vet, test, modules, build, docs'
@echo ' make build dist/kettle, version stamped'
@echo ' make install [BINDIR=…] build straight onto your PATH'
@echo ' make dist cross-compile every platform + SHA256SUMS'
@echo ' make release TAG=v1.2.3 [NOTES=notes.md] [TITLE="…"]'
@echo ' dist, then publish it to Gitea'
@echo ' make clean remove dist/'
@echo
@echo " version $(VERSION)"
@echo " bindir $(BINDIR)"
# ---------------------------------------------------------------------------
# the gate
# ---------------------------------------------------------------------------
check: fmt vet test verify build gen-check
@echo 'check all clear — $(VERSION)'
# gofmt reports rather than rewrites: a formatting change made silently by a
# build is a change nobody reviewed. cmd and internal, never the module cache.
fmt:
@out=`gofmt -l cmd internal`; \
if [ -n "$$out" ]; then \
echo 'gofmt these files are not formatted:'; \
echo "$$out" | sed 's/^/ /'; \
echo ' run: gofmt -w cmd internal'; \
exit 1; \
fi; \
echo 'gofmt clean'
vet:
@echo 'vet go vet ./...'
@go vet ./...
# -count=1 defeats the test cache. A gate that can pass because it passed an
# hour ago on different code is not a gate.
test:
@echo 'test go test -count=1 ./...'
@go test -count=1 ./...
# Two checks, because they answer different questions. `go mod verify` says the
# module cache matches go.sum; the vendored build says the committed vendor/ is
# complete and is what actually compiles. A vendor/ that has drifted from go.mod
# fails nothing until somebody builds on a machine with a cold cache.
verify:
@echo 'modules go mod verify'
@go mod verify
@echo 'vendor go build -mod=vendor ./...'
@go build -mod=vendor ./...
build:
@mkdir -p $(DIST)
@go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN) ./cmd/kettle
@echo 'build $(BIN) — $(VERSION)'
# The documentation invariant: everything the plugin's SKILL.md files say about
# a kettle command is generated from the registry the binary is built from, and
# this exits 1 the moment the two disagree. Run `kettle gen skills --out …`
# without --check to fix it.
gen-check: build
@echo 'docs gen skills --check'
@$(BIN) gen skills --out $(SKILLS) --check
# ---------------------------------------------------------------------------
# building for other people
# ---------------------------------------------------------------------------
install:
@mkdir -p $(BINDIR)
@go build -trimpath -ldflags '$(LDFLAGS)' -o $(BINDIR)/kettle ./cmd/kettle
@echo 'installed $(BINDIR)/kettle — $(VERSION)'
@case ":$$PATH:" in \
*":$(BINDIR):"*) ;; \
*) echo "note: $(BINDIR) is not on your PATH, so the plugin's skills will not find it" ;; \
esac
# CGO_ENABLED=0 because these binaries are downloaded by people whose machines
# are not this one: a build that links against the host's libc is a build that
# runs on the host.
dist:
@rm -f $(DIST)/kettle_* $(DIST)/SHA256SUMS
@mkdir -p $(DIST)
@for p in $(PLATFORMS); do \
os=$${p%/*}; arch=$${p#*/}; \
out=$(DIST)/kettle_$(VERSION)_$${os}_$${arch}; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch \
go build -trimpath -ldflags '$(LDFLAGS)' -o $$out ./cmd/kettle || exit 1; \
echo "dist $$out"; \
done
@cd $(DIST) && \
if command -v sha256sum >/dev/null 2>&1; then \
sha256sum kettle_$(VERSION)_* > SHA256SUMS; \
else \
shasum -a 256 kettle_$(VERSION)_* > SHA256SUMS; \
fi
@echo 'dist $(DIST)/SHA256SUMS'
# ---------------------------------------------------------------------------
# cutting one
# ---------------------------------------------------------------------------
# Published by cmd/release, which is this module's own code against Gitea's own
# API — no third-party action, nothing between the tag and what people download
# that is not in this repository.
#
# The three refusals are the point of doing it here rather than by hand:
#
# dirty tree what shipped would not be what is committed, and nobody could
# rebuild it;
# wrong tag TAG must be the version `git describe` reports, which is only
# true when the tag exists and HEAD is standing on it — so this
# also catches "I forgot to tag" and "I tagged, then committed";
# unpushed tag a release naming a tag the server does not have is a download
# page pointing at a commit nobody else can fetch.
release:
@test -n '$(TAG)' || { echo 'usage: make release TAG=v1.2.3 [NOTES=notes.md] [TITLE="…"]'; exit 2; }
@if [ -n "`git status --porcelain`" ]; then \
echo 'refusing: the working tree is dirty — a release built from uncommitted code cannot be rebuilt'; \
git status --short | sed 's/^/ /'; \
exit 1; \
fi
@if [ '$(VERSION)' != '$(TAG)' ]; then \
echo 'refusing: TAG is $(TAG) but this commit describes as $(VERSION)'; \
echo ' the binaries would be stamped $(VERSION) and the release would claim $(TAG).'; \
echo ' tag this commit first: git tag -a $(TAG) -m $(TAG)'; \
exit 1; \
fi
@if ! git ls-remote --exit-code --tags $(REMOTE) 'refs/tags/$(TAG)' >/dev/null 2>&1; then \
echo 'refusing: $(TAG) is not on $(REMOTE) — push it first: git push $(REMOTE) $(TAG)'; \
echo ' (another remote? make release TAG=$(TAG) REMOTE=…)'; \
exit 1; \
fi
@$(MAKE) dist
@echo 'release publishing $(TAG) with cmd/release'
@go run ./cmd/release --tag '$(TAG)' \
$(if $(TITLE),--title '$(TITLE)') \
$(if $(NOTES),--notes-file '$(NOTES)') \
$(DIST)/kettle_$(VERSION)_* $(DIST)/SHA256SUMS
clean:
@rm -rf $(DIST)
@echo 'clean $(DIST) removed'
+43
View File
@@ -0,0 +1,43 @@
# AGENTS.md — cmd/kettle
The binary's entry point, and all of it:
```go
func main() { os.Exit(cmd.Main(os.Args[1:])) }
```
One file, `main.go`, holding a package comment and that line. The sibling
[`cmd/release`](../release/AGENTS.md) is the module's other binary — build
infrastructure, deliberately not a `kettle` verb.
## Why it is empty
Everything a `main` usually accumulates — flag parsing, dispatch, usage text,
error formatting, exit codes — is in [`internal/cmd`](../../internal/cmd/AGENTS.md),
where it is **testable**. A `main` package cannot be imported, so anything written
here can only be exercised by running the binary; the command tree is instead a
library with one caller, and its tests run it as a subprocess *and* call into it
directly where that is cheaper.
The exit status is the only thing this layer owns, and it owns it because
`os.Exit` skips deferred functions: it has to happen after everything else is
finished, at the outermost frame, and nowhere else in the tree may call it.
The version a build reports is **not** stamped here either. `-ldflags -X` names
`internal/cmd.Version`, because that is where the `version` command reads it and
where a test can build with the flag and read the answer back — a `-X` whose symbol
path is one character wrong is silently ignored, and the binary goes on saying `dev`.
## Adding a command
Nothing here changes. A new command is a `register(&Command{…})` in an `init()`
over in [`internal/cmd`](../../internal/cmd/AGENTS.md) — that is the whole point
of a registry.
## Keeping this file true
- **Scope:** `main.go`, and the reason it stays this short.
- **Update it when** this package grows a second file or a line that does anything
but delegate — which should be treated as a design change and argued for, not
documented after the fact.
- **Do not** describe commands, flags or exit codes here.
+113
View File
@@ -0,0 +1,113 @@
# AGENTS.md — cmd/release
**The release tool: publishes a Gitea release for this repository, from this
repository's own code.** Driven by `make release TAG=v1.2.3`, never by a user.
| file | what is in it |
|---|---|
| `main.go` | flags, argument validation, exit codes — everything that can fail before a socket is opened |
| `publish.go` | `spec`, `receipt`, its own small SDK `client`, and the converge/upload logic |
| `release_test.go` | the whole tool against a fake Gitea, including every refusal |
## Why it is not a `kettle` subcommand
`kettle`'s command tree is not just a menu: it is what `kettle gen skills`
generates the plugin's SKILL.md files from. A verb added there arrives in the
documentation an agent loads and in the reference an operator reads, and **"publish
a release" is not something either of them does.** Publishing is build
infrastructure — it runs once, on a tag, by the person cutting it — and the thing
users install should not carry it.
It is still this module's code, built on the same Gitea SDK, and that is the point:
the release is published by the repository it is a release *of*, with nothing to
trust that is not in this tree and no third-party tool between a tag and what people
download.
## Why it does not use internal/gitea
[`internal/gitea`](../../internal/gitea/AGENTS.md) is otherwise the one door for
every request. Two reasons this one goes around it, both facts about where it runs
rather than preferences:
- that transport files every request body under `.kettle/payload/`, a path resolved
from the project marker — and the marker is gitignored, so a fresh clone has none
and a build tool has no business creating one;
- **an asset upload's request body IS the binary.** Filing a 20 MB multipart body as
JSON in a scratchpad helps nobody.
What it does **not** reinvent is credentials or error vocabulary.
[`internal/config`](../../internal/config/AGENTS.md) resolves the instance, the token
and the repository exactly as `kettle` does — through `ResolveOutsideAProject`,
which falls back to the environment when there is no marker and reads the project
config when there is — and `gitea.Fail` turns an SDK `(response, error)` pair into
the same `*APIError` a `kettle push` would report, so "the tracker said no" has one
spelling in the tree.
## Idempotent end to end
A tag that already has a release **reuses** it, an asset whose name is already there
is **replaced**, and a run repeated because the first died half way through converges
on the same release with the same assets — not a second release with doubled
attachments.
Reuse alone would only make a re-run *not fail*; it would not make it **converge**. A
second run with corrected notes has to leave the release holding the corrected notes,
or the retry that fixed the mistake published the mistake again. Empty notes mean
"leave what is there", not "clear them": `--notes-file` is how notes are supplied,
and a run that supplied none is not asking for the release to be emptied.
A 404 from the release lookup is an **answer** — it is what "no release yet" looks
like — and anything else is reported, because "the instance refused us" and "there is
nothing there" must not both read as "create one".
The by-tag route is a lookup *through the tag*, and a draft need not have one, so a
404 there is followed by a scan of the release listing before anything is created.
Without it a retried `--draft` publish would file a second release for one tag —
which is the failure this whole section exists to prevent, arriving through the one
door that looks like the ordinary case.
## Order of operations
Everything that can be wrong in the arguments is reported **before a release exists
to be half-published**:
1. `--tag` is required;
2. every asset is stat'ed up front — a release that exists with half its assets on
it, published by a run that then failed on a typo, is the failure this prevents;
3. two files with one basename are refused, because an attachment is addressed by
name and the second would silently replace the first while the receipt claimed
both went up;
4. notes are read from disk;
5. only then does anything dial. The attachment listing is read once, before the
first upload, so the names that matter are the ones that were there when the run
started.
## Usage
Through the Makefile, which adds the three refusals that make a release
reproducible — dirty tree, `TAG` that is not what `git describe` reports, tag not
pushed to the remote:
```bash
make release TAG=v1.2.3 [NOTES=notes.md] [TITLE="…"]
```
Directly, when the Makefile is not what you want:
```bash
KETTLE_URL=KETTLE_TOKEN=KETTLE_REPO=owner/name \
go run ./cmd/release --tag v1.2.3 --notes-file notes.md dist/kettle_* dist/SHA256SUMS
```
`--draft` and `--prerelease` are there; `--target` names the commitish a tag is
created from when the tag does not exist yet.
## Keeping this file true
- **Scope:** `main.go`, `publish.go` and their test — the argument checks, the
convergence rules, and the two decisions above about what this tool does *not*
share with `kettle`.
- **Update it when** a flag is added, the idempotency rules change, it starts or
stops borrowing something from `internal/`, or the Makefile's refusals change.
- **Do not** move any of this into `kettle`'s command registry without answering the
first section — a verb here becomes documentation an agent loads.
+155
View File
@@ -0,0 +1,155 @@
// Command release publishes a Gitea release for this repository, from this
// repository's own code.
//
// DELIBERATELY NOT A `kettle` SUBCOMMAND, and not for tidiness. `kettle` is a
// tool for issues, and its command tree is not just a menu: it is what
// `kettle gen skills` generates the plugin's SKILL.md files from. A verb added
// there arrives in the documentation an agent loads and in the reference an
// operator reads, and "publish a release" is not something either of them does.
// Publishing is build infrastructure — it runs once, on a tag, by the person
// cutting it — and the thing users install should not carry it.
//
// It is still this module's code, built on the same Gitea SDK the binary uses,
// and that is the point: the release is published by the repository it is a
// release of, with nothing to trust that is not in this tree and no third-party
// tool between a tag and what people download.
//
// WHY IT DOES NOT USE internal/gitea, which is otherwise the one door for every
// request. Two reasons, both facts about where this runs rather than
// preferences:
//
// - that transport files every request body under `.kettle/payload/`, a path
// resolved from the project marker — and the marker is gitignored, so a
// fresh clone has none and a build tool has no business creating one;
// - an asset upload's request body IS the binary. Filing a 20 MB multipart
// body as JSON in a scratchpad helps nobody and would double the memory
// this uses for no reader's benefit.
//
// What it does not reinvent is credentials. internal/config resolves the
// instance, the token and the repository exactly as kettle does, environment
// first, and refuses a half-filled configuration by naming what is missing.
// The error vocabulary is internal/gitea's too — a failure here carries the
// status and quotes what the server said, in the same words a `kettle push`
// would use.
//
// IDEMPOTENT END TO END. A tag that already has a release reuses it, an asset
// whose name is already there replaces it, and a run that is repeated because
// the first one died half way through converges on the same release with the
// same assets instead of a second release and doubled attachments.
package main
import (
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
)
const usage = `usage: release --tag <tag> [flags] [<file>…]
Publish a Gitea release for the repository this configuration points at, and
upload each named file as an asset. Re-running it is safe: an existing release
for the tag is reused and an asset of the same name is replaced, never doubled.
Credentials resolve the way kettle's do — ` + config.EnvURL + `, ` + config.EnvToken + ` and
` + config.EnvRepo + `, or, when this is run from inside an initialized project, that
project's .kettle/config.yaml and the machine's login file.
flags:
`
func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) }
// run is main with its edges handed in, so a test can drive the whole tool.
func run(argv []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("release", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() {
fmt.Fprint(stderr, usage)
fs.PrintDefaults()
}
tag := fs.String("tag", "", "the tag to publish, e.g. v1.2.3 (required)")
title := fs.String("title", "", "release title (default: the tag)")
notesFile := fs.String("notes-file", "", "file holding the release notes; empty leaves an existing release's notes alone")
target := fs.String("target", "", "commitish a tag is created from when the tag does not exist yet (default: the default branch)")
draft := fs.Bool("draft", false, "publish as a draft")
prerelease := fs.Bool("prerelease", false, "mark as a prerelease")
if err := fs.Parse(argv); err != nil {
return 2 // flag has already said what it did not like
}
s := spec{
Tag: strings.TrimSpace(*tag),
Title: strings.TrimSpace(*title),
Target: strings.TrimSpace(*target),
Draft: *draft,
Prerelease: *prerelease,
Files: fs.Args(),
}
if s.Tag == "" {
fmt.Fprintln(stderr, "release: --tag is required — the tag this release is for")
fs.Usage()
return 2
}
if err := checkFiles(s.Files); err != nil {
fmt.Fprintf(stderr, "release: %v\n", err)
return 2
}
if *notesFile != "" {
raw, err := os.ReadFile(*notesFile)
if err != nil {
fmt.Fprintf(stderr, "release: reading the notes: %v\n", err)
return 2
}
s.Notes = string(raw)
}
// Nothing above this line dials, and that is the order it is written in:
// every mistake a person can make in the arguments is reported before a
// release exists to be half-published.
cfg, err := config.ResolveOutsideAProject("")
if err != nil {
fmt.Fprintf(stderr, "release: %v\n", err)
return 1
}
got, err := publish(cfg, s)
if err != nil {
fmt.Fprintf(stderr, "release: %v\n", err)
return 1
}
got.print(stdout)
return 0
}
// checkFiles refuses what would fail later, before anything is created.
//
// Every asset is stat'ed up front because the alternative is a release that
// exists with half its assets on it, published by a run that then failed on a
// typo. Two files with one basename are refused for the same reason from the
// other direction: an attachment is addressed by name, so the second would
// replace the first and the receipt would claim both went up.
func checkFiles(files []string) error {
seen := map[string]string{}
for _, path := range files {
fi, err := os.Stat(path)
if err != nil {
return fmt.Errorf("cannot upload %s: %w", path, err)
}
if fi.IsDir() {
return fmt.Errorf("cannot upload %s: it is a directory", path)
}
name := filepath.Base(path)
if first, ok := seen[name]; ok {
return fmt.Errorf("%s and %s are both %q — an asset is addressed by name, so the second would replace the first",
first, path, name)
}
seen[name] = path
}
return nil
}
+358
View File
@@ -0,0 +1,358 @@
package main
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/gitea"
)
const (
// userAgent names this tool in the instance's log. An admin looking at a
// burst of requests should be able to tell a release from a push.
userAgent = "kettle-release"
// requestTimeout bounds one call. Generous next to the transport's 30s
// because one of these calls is an upload: a 20 MB binary over a domestic
// connection is minutes, and a run that gives up half way through its
// assets is exactly the mess this tool exists to avoid.
requestTimeout = 10 * time.Minute
// pageLimit and maxPages bound the two listings this makes. A repository
// with a runaway number of releases must not turn one publish into an
// unbounded read.
pageLimit = 50
maxPages = 20
)
// spec is what was asked for: one release, and the files that belong on it.
type spec struct {
Tag string
Title string
Notes string
Target string
Draft bool
Prerelease bool
Files []string
}
// title defaults to the tag, because Gitea refuses a release without one and
// "v1.2.3" is what a person would have typed anyway.
func (s spec) title() string {
if s.Title != "" {
return s.Title
}
return s.Tag
}
// asset is one file that ended up on the release.
type asset struct {
Name string
URL string
// Replaced records that an attachment of this name was already there and
// was removed to make room. Two assets with one name is the failure mode a
// retried publish has, and it is silent: the download URL is by name.
Replaced bool
}
// receipt is what happened, in the words the run will print.
type receipt struct {
Repo string
Release *sdk.Release
// State is "created", "updated" or "reused" — which of the three a re-run
// hit is the whole question an operator has about idempotency.
State string
Assets []asset
}
// publish makes the tracker say what the spec says, and reports what it did.
func publish(cfg *config.Resolved, s spec) (*receipt, error) {
c, err := newClient(cfg)
if err != nil {
return nil, err
}
rel, state, err := c.releaseFor(s)
if err != nil {
return nil, err
}
assets, err := c.uploadAll(rel, s.Files)
if err != nil {
return nil, err
}
return &receipt{Repo: c.slug(), Release: rel, State: state, Assets: assets}, nil
}
// print writes the receipt: what happened to the release, every asset that
// ended up on it, and the URL a person opens.
func (r *receipt) print(w io.Writer) {
fmt.Fprintf(w, "%-9s release %s in %s\n", r.State, r.Release.TagName, r.Repo)
width := 0
for _, a := range r.Assets {
if n := utf8.RuneCountInString(a.Name); n > width {
width = n
}
}
uploaded, replaced := 0, 0
for _, a := range r.Assets {
verb := "uploaded"
uploaded++
if a.Replaced {
verb, replaced = "replaced", replaced+1
}
fmt.Fprintf(w, "%-9s %-*s %s\n", verb, width, a.Name, a.URL)
}
if url := r.Release.HTMLURL; url != "" {
fmt.Fprintf(w, "%-9s %s\n", "release", url)
}
fmt.Fprintf(w, "%d asset(s): %d uploaded, %d replaced — draft: %s, prerelease: %s\n",
len(r.Assets), uploaded-replaced, replaced,
yesNo(r.Release.IsDraft), yesNo(r.Release.IsPrerelease))
}
func yesNo(b bool) string {
if b {
return "yes"
}
return "no"
}
// client is one repository on one instance.
type client struct {
api *sdk.Client
owner, name string
}
func (c *client) slug() string { return c.owner + "/" + c.name }
// newClient refuses a half-filled configuration before it dials, the same way
// gitea.New does and for the same reason: building a client is itself a
// request — the SDK asks the instance for its version before it hands one back
// — and a missing token reported as a connection failure sends whoever is
// reading it to the wrong place.
func newClient(cfg *config.Resolved) (*client, error) {
if cfg == nil {
return nil, errors.New("no resolved configuration — call config.ResolveOutsideAProject first")
}
if err := cfg.Complete(); err != nil {
return nil, err
}
base := strings.TrimRight(cfg.URL, "/")
api, err := sdk.NewClient(base,
sdk.SetToken(cfg.Token),
sdk.SetHTTPClient(&http.Client{Timeout: requestTimeout}),
sdk.SetUserAgent(userAgent))
if err != nil {
if errors.Is(err, &sdk.ErrUnknownVersion{}) {
return nil, fmt.Errorf("%s did not answer with a version this can read (%w)"+
" — check that %s points at a Gitea instance", base, err, config.EnvURL)
}
return nil, fmt.Errorf("cannot reach the Gitea instance at %s: %w", base, err)
}
return &client{api: api, owner: cfg.Owner, name: cfg.Repo}, nil
}
// releaseFor is the release this tag should have, created or brought into line.
func (c *client) releaseFor(s spec) (*sdk.Release, string, error) {
found, err := c.find(s.Tag)
if err != nil {
return nil, "", err
}
if found == nil {
rel, err := c.create(s)
return rel, "created", err
}
rel, changed, err := c.converge(found, s)
if err != nil {
return nil, "", err
}
if changed {
return rel, "updated", nil
}
return rel, "reused", nil
}
// find is the release for this tag, or nil when the repository has none.
//
// A 404 is an answer here and not a failure — it is what "no release yet"
// looks like, which is the ordinary case the first time a tag is published.
// Anything else is reported, because "the instance refused us" and "there is
// nothing there" must not both read as "create one".
func (c *client) find(tag string) (*sdk.Release, error) {
got, resp, err := c.api.GetReleaseByTag(c.owner, c.name, tag)
if err == nil {
return got, nil
}
if failed := gitea.Fail(resp, err); !gitea.StatusIs(failed, http.StatusNotFound) {
return nil, fmt.Errorf("looking for a release on %s: %w", tag, failed)
}
// The by-tag route is a lookup through the tag, and a draft need not have
// one — so a draft this tool created on an earlier run can answer 404 to
// the question "is it already there?". Scanning the listing is what keeps a
// retried `--draft` publish from filing a second release for one tag.
return c.scan(tag)
}
// scan walks the release listing for this tag.
func (c *client) scan(tag string) (*sdk.Release, error) {
for page := 1; page <= maxPages; page++ {
batch, resp, err := c.api.ListReleases(c.owner, c.name, sdk.ListReleasesOptions{
ListOptions: sdk.ListOptions{Page: page, PageSize: pageLimit},
})
if err := gitea.Fail(resp, err); err != nil {
return nil, fmt.Errorf("listing releases: %w", err)
}
for _, rel := range batch {
if rel.TagName == tag {
return rel, nil
}
}
if len(batch) < pageLimit {
return nil, nil // a short page is the last one
}
}
return nil, nil
}
func (c *client) create(s spec) (*sdk.Release, error) {
got, resp, err := c.api.CreateRelease(c.owner, c.name, sdk.CreateReleaseOption{
TagName: s.Tag,
Target: s.Target,
Title: s.title(),
Note: s.Notes,
IsDraft: s.Draft,
IsPrerelease: s.Prerelease,
})
if err := gitea.Fail(resp, err); err != nil {
return nil, fmt.Errorf("creating the release for %s: %w", s.Tag, err)
}
if got == nil || got.ID == 0 {
return nil, fmt.Errorf("creating the release for %s: the tracker's answer carries no id", s.Tag)
}
return got, nil
}
// converge edits an existing release until it says what the spec says, and
// reports whether anything had to change.
//
// Reuse alone would be enough to make a re-run not fail; it would not make it
// CONVERGE. A second run with corrected notes has to leave the release holding
// the corrected notes, or the retry that fixed the mistake published the
// mistake again.
//
// Empty notes mean "leave what is there", not "clear them": `--notes-file` is
// how notes are supplied, and a run that did not supply any is not a run asking
// for the release to be emptied.
func (c *client) converge(rel *sdk.Release, s spec) (*sdk.Release, bool, error) {
note := s.Notes
if note == "" {
note = rel.Note
}
if rel.Title == s.title() && rel.Note == note &&
rel.IsDraft == s.Draft && rel.IsPrerelease == s.Prerelease {
return rel, false, nil
}
draft, prerelease := s.Draft, s.Prerelease
got, resp, err := c.api.EditRelease(c.owner, c.name, rel.ID, sdk.EditReleaseOption{
TagName: rel.TagName,
Title: s.title(),
Note: note,
IsDraft: &draft,
IsPrerelease: &prerelease,
})
if err := gitea.Fail(resp, err); err != nil {
return nil, false, fmt.Errorf("updating the release for %s: %w", rel.TagName, err)
}
if got == nil || got.ID == 0 {
return nil, false, fmt.Errorf("updating the release for %s: the tracker's answer carries no id", rel.TagName)
}
return got, true, nil
}
// uploadAll puts every named file on the release, replacing an attachment that
// already carries that name.
//
// The listing is read once, before the first upload, because the names that
// matter are the ones that were there when the run started: the files being
// uploaded are checked for duplicate basenames up front, so nothing this run
// adds can collide with something else this run adds.
func (c *client) uploadAll(rel *sdk.Release, files []string) ([]asset, error) {
if len(files) == 0 {
return nil, nil
}
existing, err := c.attachments(rel.ID)
if err != nil {
return nil, err
}
byName := map[string][]*sdk.Attachment{}
for _, a := range existing {
byName[a.Name] = append(byName[a.Name], a)
}
out := make([]asset, 0, len(files))
for _, path := range files {
name := filepath.Base(path)
replaced := false
// Removed before the upload rather than after it. Gitea will happily
// hold two attachments with one name, and the download URL names the
// file — so the state to avoid at all costs is the ambiguous one, not
// the momentarily absent one.
for _, old := range byName[name] {
if resp, err := c.api.DeleteReleaseAttachment(c.owner, c.name, rel.ID, old.ID); err != nil {
return out, fmt.Errorf("removing the old %s: %w", name, gitea.Fail(resp, err))
}
replaced = true
}
got, err := c.upload(rel.ID, path, name)
if err != nil {
return out, err
}
out = append(out, asset{Name: name, URL: got.DownloadURL, Replaced: replaced})
}
return out, nil
}
func (c *client) attachments(release int64) ([]*sdk.Attachment, error) {
var out []*sdk.Attachment
for page := 1; page <= maxPages; page++ {
batch, resp, err := c.api.ListReleaseAttachments(c.owner, c.name, release,
sdk.ListReleaseAttachmentsOptions{ListOptions: sdk.ListOptions{Page: page, PageSize: pageLimit}})
if err := gitea.Fail(resp, err); err != nil {
return nil, fmt.Errorf("listing the release's assets: %w", err)
}
out = append(out, batch...)
if len(batch) < pageLimit {
return out, nil
}
}
return out, nil
}
func (c *client) upload(release int64, path, name string) (*sdk.Attachment, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("uploading %s: %w", name, err)
}
defer f.Close()
got, resp, err := c.api.CreateReleaseAttachment(c.owner, c.name, release, f, name)
if err := gitea.Fail(resp, err); err != nil {
return nil, fmt.Errorf("uploading %s: %w", name, err)
}
if got == nil {
return nil, fmt.Errorf("uploading %s: the tracker's answer carries no attachment", name)
}
return got, nil
}
+679
View File
@@ -0,0 +1,679 @@
package main
// The publisher is tested against httptest, never against an instance: a test
// that needs a server somewhere is a test nobody runs, and this is the one tool
// in the tree whose mistakes are visible to everybody who downloads a binary.
//
// Every fixture points CLAUDE_PROJECT_DIR at an empty temp directory — no
// `.kettle/` marker anywhere on the way up, which is the state a fresh clone is
// in and the whole reason this tool resolves its configuration the way it does
// — and KETTLE_CONFIG_HOME at another, so a run can neither read nor overwrite
// the developer's own tokens.
//
// THE FAKE ANSWERS /api/v1/version, because building an SDK client is itself a
// request: the SDK asks the instance what it is before it hands a client back,
// and a fake that did not answer is a fake nothing can be built against.
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
sdk "code.gitea.io/sdk/gitea"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
)
// modernGitea is what the fake says it is: new enough for every route this
// tool asks for.
const modernGitea = "1.26.1"
// harmless points every fixture away from the machine it runs on.
func harmless(t *testing.T) {
t.Helper()
dir := t.TempDir()
t.Setenv("CLAUDE_PROJECT_DIR", dir)
t.Setenv(config.EnvHome, filepath.Join(dir, "config"))
// An exported KETTLE_URL in the developer's shell would otherwise decide
// what a test resolved to, and one of these tests is about resolving
// nothing at all.
for _, key := range []string{config.EnvURL, config.EnvToken, config.EnvRepo, config.EnvLogin} {
t.Setenv(key, "")
}
}
func configFor(url string) *config.Resolved {
return &config.Resolved{URL: url, Token: "s3cret", Owner: "acme", Repo: "widgets"}
}
// --------------------------------------------------------------------------
// the fake tracker
// --------------------------------------------------------------------------
type fake struct {
mu sync.Mutex
base string
version string
nextID int64
releases []*sdk.Release
assets map[int64][]*sdk.Attachment
content map[int64][]byte
requests []string
// hideDraftsFromTheTagRoute makes the by-tag lookup answer 404 for a draft,
// which is what an instance does when the tag itself is not in git yet.
hideDraftsFromTheTagRoute bool
}
func newFake(t *testing.T) *fake {
t.Helper()
f := &fake{
version: modernGitea,
assets: map[int64][]*sdk.Attachment{},
content: map[int64][]byte{},
}
srv := httptest.NewServer(f)
t.Cleanup(srv.Close)
f.base = srv.URL
return f
}
func (f *fake) url() string { return f.base }
func (f *fake) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
if r.URL.Path == "/api/v1/version" {
writeJSON(w, map[string]string{"version": f.version})
return
}
rest, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/acme/widgets/releases")
if !ok {
f.refuse(w, http.StatusNotFound, "the fake has no route for "+r.URL.Path)
return
}
var parts []string
if rest = strings.Trim(rest, "/"); rest != "" {
parts = strings.Split(rest, "/")
}
switch {
case len(parts) == 0 && r.Method == http.MethodGet:
f.list(w, r)
case len(parts) == 0 && r.Method == http.MethodPost:
f.create(w, r)
case len(parts) == 2 && parts[0] == "tags" && r.Method == http.MethodGet:
f.byTag(w, parts[1])
case len(parts) == 1 && r.Method == http.MethodPatch:
f.edit(w, r, parts[0])
case len(parts) == 2 && parts[1] == "assets" && r.Method == http.MethodGet:
f.listAssets(w, parts[0])
case len(parts) == 2 && parts[1] == "assets" && r.Method == http.MethodPost:
f.addAsset(w, r, parts[0])
case len(parts) == 3 && parts[1] == "assets" && r.Method == http.MethodDelete:
f.dropAsset(w, parts[0], parts[2])
default:
f.refuse(w, http.StatusNotFound, "the fake has no route for "+r.Method+" "+r.URL.Path)
}
}
func (f *fake) list(w http.ResponseWriter, r *http.Request) {
if page := r.URL.Query().Get("page"); page != "" && page != "1" {
writeJSON(w, []*sdk.Release{})
return
}
writeJSON(w, f.releases)
}
func (f *fake) create(w http.ResponseWriter, r *http.Request) {
var opt sdk.CreateReleaseOption
if err := json.NewDecoder(r.Body).Decode(&opt); err != nil {
f.refuse(w, http.StatusUnprocessableEntity, err.Error())
return
}
f.nextID++
rel := &sdk.Release{
ID: f.nextID,
TagName: opt.TagName,
Target: opt.Target,
Title: opt.Title,
Note: opt.Note,
IsDraft: opt.IsDraft,
IsPrerelease: opt.IsPrerelease,
HTMLURL: f.base + "/acme/widgets/releases/tag/" + opt.TagName,
}
f.releases = append(f.releases, rel)
w.WriteHeader(http.StatusCreated)
writeJSON(w, rel)
}
func (f *fake) byTag(w http.ResponseWriter, tag string) {
for _, rel := range f.releases {
if rel.TagName != tag {
continue
}
if rel.IsDraft && f.hideDraftsFromTheTagRoute {
break
}
writeJSON(w, rel)
return
}
f.refuse(w, http.StatusNotFound, "release with tag '"+tag+"' not found")
}
func (f *fake) edit(w http.ResponseWriter, r *http.Request, id string) {
rel := f.release(id)
if rel == nil {
f.refuse(w, http.StatusNotFound, "no release "+id)
return
}
var opt sdk.EditReleaseOption
if err := json.NewDecoder(r.Body).Decode(&opt); err != nil {
f.refuse(w, http.StatusUnprocessableEntity, err.Error())
return
}
// Gitea's own semantics: an empty string leaves the field alone.
if opt.Title != "" {
rel.Title = opt.Title
}
if opt.Note != "" {
rel.Note = opt.Note
}
if opt.IsDraft != nil {
rel.IsDraft = *opt.IsDraft
}
if opt.IsPrerelease != nil {
rel.IsPrerelease = *opt.IsPrerelease
}
writeJSON(w, rel)
}
func (f *fake) listAssets(w http.ResponseWriter, id string) {
rel := f.release(id)
if rel == nil {
f.refuse(w, http.StatusNotFound, "no release "+id)
return
}
got := f.assets[rel.ID]
if got == nil {
got = []*sdk.Attachment{}
}
writeJSON(w, got)
}
func (f *fake) addAsset(w http.ResponseWriter, r *http.Request, id string) {
rel := f.release(id)
if rel == nil {
f.refuse(w, http.StatusNotFound, "no release "+id)
return
}
file, header, err := r.FormFile("attachment")
if err != nil {
f.refuse(w, http.StatusUnprocessableEntity, "no attachment in the form: "+err.Error())
return
}
defer file.Close()
raw, err := io.ReadAll(file)
if err != nil {
f.refuse(w, http.StatusInternalServerError, err.Error())
return
}
f.nextID++
a := &sdk.Attachment{
ID: f.nextID,
Name: header.Filename,
Size: int64(len(raw)),
DownloadURL: f.base + "/acme/widgets/releases/download/" + rel.TagName + "/" + header.Filename,
}
f.assets[rel.ID] = append(f.assets[rel.ID], a)
f.content[a.ID] = raw
w.WriteHeader(http.StatusCreated)
writeJSON(w, a)
}
func (f *fake) dropAsset(w http.ResponseWriter, id, asset string) {
rel := f.release(id)
if rel == nil {
f.refuse(w, http.StatusNotFound, "no release "+id)
return
}
want, _ := strconv.ParseInt(asset, 10, 64)
kept := make([]*sdk.Attachment, 0, len(f.assets[rel.ID]))
for _, a := range f.assets[rel.ID] {
if a.ID != want {
kept = append(kept, a)
}
}
f.assets[rel.ID] = kept
w.WriteHeader(http.StatusNoContent)
}
func (f *fake) release(id string) *sdk.Release {
want, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return nil
}
for _, rel := range f.releases {
if rel.ID == want {
return rel
}
}
return nil
}
func (f *fake) refuse(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"message": message})
}
// assetNamed is what the tracker holds under this name, for the test that says
// a replacement leaves exactly one.
func (f *fake) assetNamed(name string) []*sdk.Attachment {
f.mu.Lock()
defer f.mu.Unlock()
var out []*sdk.Attachment
for _, batch := range f.assets {
for _, a := range batch {
if a.Name == name {
out = append(out, a)
}
}
}
return out
}
func (f *fake) bytesOf(a *sdk.Attachment) string {
f.mu.Lock()
defer f.mu.Unlock()
return string(f.content[a.ID])
}
func (f *fake) calls() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string{}, f.requests...)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func writeFile(t *testing.T, dir, name, content string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
// --------------------------------------------------------------------------
// the tests
// --------------------------------------------------------------------------
// The ordinary case: a tag nobody has published yet, and two files that belong
// on it.
func TestItCreatesTheReleaseAndUploadsEveryAsset(t *testing.T) {
harmless(t)
f := newFake(t)
dir := t.TempDir()
binary := writeFile(t, dir, "kettle_v1.2.3_linux_amd64", "a binary, honestly")
sums := writeFile(t, dir, "SHA256SUMS", "beef kettle_v1.2.3_linux_amd64\n")
got, err := publish(configFor(f.url()), spec{
Tag: "v1.2.3",
Notes: "what changed\n",
Files: []string{binary, sums},
})
if err != nil {
t.Fatalf("publish: %v", err)
}
if got.State != "created" {
t.Errorf("state is %q, want created", got.State)
}
if len(f.releases) != 1 {
t.Fatalf("the tracker holds %d release(s), want 1", len(f.releases))
}
rel := f.releases[0]
if rel.TagName != "v1.2.3" || rel.Note != "what changed\n" {
t.Errorf("the release is %+v", rel)
}
// Gitea refuses a release with no title, so the tag stands in for one.
if rel.Title != "v1.2.3" {
t.Errorf("title is %q, want the tag", rel.Title)
}
if len(got.Assets) != 2 {
t.Fatalf("got %d asset(s), want 2", len(got.Assets))
}
for name, want := range map[string]string{
"kettle_v1.2.3_linux_amd64": "a binary, honestly",
"SHA256SUMS": "beef kettle_v1.2.3_linux_amd64\n",
} {
held := f.assetNamed(name)
if len(held) != 1 {
t.Fatalf("the tracker holds %d attachment(s) called %s, want 1", len(held), name)
}
if body := f.bytesOf(held[0]); body != want {
t.Errorf("%s arrived as %q, want %q", name, body, want)
}
}
// The receipt is the whole user experience of a tool nobody watches run.
var out strings.Builder
got.print(&out)
for _, want := range []string{"created", "v1.2.3", "acme/widgets", rel.HTMLURL,
"kettle_v1.2.3_linux_amd64", "SHA256SUMS", "2 asset(s)"} {
if !strings.Contains(out.String(), want) {
t.Errorf("the receipt does not name %q:\n%s", want, out.String())
}
}
}
// A re-run is not a failure and not a second release. It is also not a no-op
// when something changed: a retry that fixed the notes has to leave the fixed
// notes behind.
func TestARerunConvergesInsteadOfPublishingTwice(t *testing.T) {
harmless(t)
f := newFake(t)
dir := t.TempDir()
binary := writeFile(t, dir, "kettle_v2.0.0_darwin_arm64", "one")
first, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "first go", Files: []string{binary}})
if err != nil {
t.Fatalf("the first publish: %v", err)
}
again, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "first go", Files: []string{binary}})
if err != nil {
t.Fatalf("the second publish: %v", err)
}
if len(f.releases) != 1 {
t.Fatalf("a re-run left %d releases for one tag", len(f.releases))
}
if again.State != "reused" {
t.Errorf("state is %q, want reused — nothing had changed", again.State)
}
if again.Release.ID != first.Release.ID {
t.Errorf("the re-run published a different release (%d, was %d)", again.Release.ID, first.Release.ID)
}
if held := f.assetNamed("kettle_v2.0.0_darwin_arm64"); len(held) != 1 {
t.Errorf("the tracker holds %d copies of the one asset", len(held))
}
// And the corrected notes actually land.
fixed, err := publish(configFor(f.url()), spec{Tag: "v2.0.0", Notes: "second go", Files: []string{binary}})
if err != nil {
t.Fatalf("the third publish: %v", err)
}
if fixed.State != "updated" {
t.Errorf("state is %q, want updated — the notes changed", fixed.State)
}
if f.releases[0].Note != "second go" {
t.Errorf("the notes are %q, want the corrected ones", f.releases[0].Note)
}
if len(f.releases) != 1 {
t.Errorf("converging forked the release: %d of them", len(f.releases))
}
}
// Two attachments with one name is the silent failure: the download URL names
// the file, so the second copy is not addressable and nobody notices which one
// people got.
func TestAnAssetOfTheSameNameIsReplacedRatherThanDoubled(t *testing.T) {
harmless(t)
f := newFake(t)
dir := t.TempDir()
path := writeFile(t, dir, "kettle_v3.0.0_linux_arm64", "the first build")
if _, err := publish(configFor(f.url()), spec{Tag: "v3.0.0", Files: []string{path}}); err != nil {
t.Fatalf("the first publish: %v", err)
}
// Same name, different bytes — a rebuild after a fix, which is exactly when
// somebody re-runs this.
writeFile(t, dir, "kettle_v3.0.0_linux_arm64", "the second build")
got, err := publish(configFor(f.url()), spec{Tag: "v3.0.0", Files: []string{path}})
if err != nil {
t.Fatalf("the second publish: %v", err)
}
held := f.assetNamed("kettle_v3.0.0_linux_arm64")
if len(held) != 1 {
t.Fatalf("the release carries %d attachments of that name, want 1", len(held))
}
if body := f.bytesOf(held[0]); body != "the second build" {
t.Errorf("the asset is %q — the replacement did not take", body)
}
if len(got.Assets) != 1 || !got.Assets[0].Replaced {
t.Errorf("the receipt does not report a replacement: %+v", got.Assets)
}
var out strings.Builder
got.print(&out)
if !strings.Contains(out.String(), "replaced") {
t.Errorf("the receipt does not say it replaced anything:\n%s", out.String())
}
}
// A draft has no git tag behind it, so the by-tag route can answer 404 for a
// release that is plainly there. A publish that believed it would file a second
// release every time it was retried.
func TestADraftIsFoundEvenWhenTheTagRouteHidesIt(t *testing.T) {
harmless(t)
f := newFake(t)
f.hideDraftsFromTheTagRoute = true
s := spec{Tag: "v4.0.0", Draft: true}
if _, err := publish(configFor(f.url()), s); err != nil {
t.Fatalf("the first publish: %v", err)
}
again, err := publish(configFor(f.url()), s)
if err != nil {
t.Fatalf("the second publish: %v", err)
}
if len(f.releases) != 1 {
t.Fatalf("a retried draft published %d releases for one tag", len(f.releases))
}
if again.State != "reused" {
t.Errorf("state is %q, want reused", again.State)
}
if !f.releases[0].IsDraft {
t.Error("the release stopped being a draft")
}
}
// A half-filled configuration is refused before anything is dialled, naming the
// variable or the command that supplies what is missing. "401 Unauthorized"
// names nothing anybody can act on.
func TestAHalfFilledConfigurationIsRefusedBeforeItDials(t *testing.T) {
harmless(t)
f := newFake(t)
for _, tc := range []struct {
what string
cfg *config.Resolved
want string
}{
{"no url", &config.Resolved{Token: "t", Owner: "a", Repo: "b"}, config.EnvURL},
{"no token", &config.Resolved{URL: f.url(), Owner: "a", Repo: "b"}, config.EnvToken},
{"no repo", &config.Resolved{URL: f.url(), Token: "t"}, config.EnvRepo},
{"nothing at all", &config.Resolved{}, config.EnvURL},
} {
_, err := publish(tc.cfg, spec{Tag: "v0.0.1"})
if err == nil {
t.Errorf("%s: accepted", tc.what)
continue
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: the refusal does not name the fix (%q): %v", tc.what, tc.want, err)
}
}
if calls := f.calls(); len(calls) != 0 {
t.Errorf("a request went out for a configuration that was refused: %v", calls)
}
}
// End to end through main's own argument handling, with the credentials in the
// environment and no project anywhere on the way up — which is the state a
// clone is in, and the reason this resolves configuration the way it does.
func TestRunPublishesFromTheEnvironmentWithNoProjectInSight(t *testing.T) {
harmless(t)
f := newFake(t)
dir := t.TempDir()
binary := writeFile(t, dir, "kettle_v5.0.0_darwin_amd64", "mach-o, trust me")
notes := writeFile(t, dir, "NOTES.md", "## v5.0.0\n\nIt does the thing.\n")
t.Setenv(config.EnvURL, f.url())
t.Setenv(config.EnvToken, "s3cret")
t.Setenv(config.EnvRepo, "acme/widgets")
var stdout, stderr strings.Builder
code := run([]string{"--tag", "v5.0.0", "--title", "kettle v5.0.0", "--notes-file", notes, binary},
&stdout, &stderr)
if code != 0 {
t.Fatalf("exit = %d, want 0\nstdout:\n%s\nstderr:\n%s", code, stdout.String(), stderr.String())
}
if len(f.releases) != 1 || f.releases[0].Title != "kettle v5.0.0" {
t.Fatalf("the tracker holds %+v", f.releases)
}
if !strings.Contains(f.releases[0].Note, "It does the thing.") {
t.Errorf("the notes file did not arrive: %q", f.releases[0].Note)
}
for _, want := range []string{"created", "uploaded", "kettle_v5.0.0_darwin_amd64", f.releases[0].HTMLURL} {
if !strings.Contains(stdout.String(), want) {
t.Errorf("the receipt does not name %q:\n%s", want, stdout.String())
}
}
// A token in a receipt is a token in a terminal scrollback and a pasted
// bug report.
if strings.Contains(stdout.String()+stderr.String(), "s3cret") {
t.Errorf("the run printed the token:\n%s%s", stdout.String(), stderr.String())
}
}
// Everything a person can get wrong in the arguments is reported before a
// release exists to be half-published.
func TestRunRefusesBadArgumentsWithoutTouchingTheTracker(t *testing.T) {
harmless(t)
f := newFake(t)
dir := t.TempDir()
here := writeFile(t, dir, "kettle_v6.0.0_linux_amd64", "x")
elsewhere := filepath.Join(t.TempDir(), "kettle_v6.0.0_linux_amd64")
if err := os.WriteFile(elsewhere, []byte("y"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv(config.EnvURL, f.url())
t.Setenv(config.EnvToken, "s3cret")
t.Setenv(config.EnvRepo, "acme/widgets")
for _, tc := range []struct {
what string
argv []string
want string
}{
{"no tag", []string{here}, "--tag is required"},
{"a file that is not there", []string{"--tag", "v6.0.0", filepath.Join(dir, "absent")}, "cannot upload"},
{"a directory", []string{"--tag", "v6.0.0", dir}, "it is a directory"},
{"two files with one name", []string{"--tag", "v6.0.0", here, elsewhere}, "would replace the first"},
{"notes that are not there", []string{"--tag", "v6.0.0", "--notes-file", filepath.Join(dir, "absent.md")}, "reading the notes"},
} {
var stdout, stderr strings.Builder
if code := run(tc.argv, &stdout, &stderr); code != 2 {
t.Errorf("%s: exit = %d, want 2\n%s%s", tc.what, code, stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), tc.want) {
t.Errorf("%s: stderr does not say %q:\n%s", tc.what, tc.want, stderr.String())
}
}
if calls := f.calls(); len(calls) != 0 {
t.Errorf("a refused run still talked to the tracker: %v", calls)
}
if len(f.releases) != 0 {
t.Errorf("a refused run created %d release(s)", len(f.releases))
}
}
// A failure carries the status and what the server said, in the transport's own
// error type, because "500" on its own has never helped anybody.
func TestAFailureNamesTheStatusAndWhatTheServerSaid(t *testing.T) {
harmless(t)
// A token that is not allowed to write releases is the failure somebody
// will actually meet: reads are fine, the create is refused.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/version":
writeJSON(w, map[string]string{"version": modernGitea})
case r.Method == http.MethodPost:
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, `{"message":"token does not have at least one of required scope(s): [write:repository]"}`)
case strings.Contains(r.URL.Path, "/releases/tags/"):
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, `{"message":"release with tag 'v7.0.0' not found"}`)
default:
writeJSON(w, []*sdk.Release{})
}
}))
t.Cleanup(srv.Close)
_, err := publish(configFor(srv.URL), spec{Tag: "v7.0.0"})
if err == nil {
t.Fatal("a 403 published a release")
}
for _, want := range []string{"403", "write:repository", "v7.0.0"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the failure does not mention %q:\n%v", want, err)
}
}
if strings.Contains(err.Error(), "s3cret") {
t.Errorf("the failure quotes the token:\n%v", err)
}
}
// A tool nobody watches run has to be readable when somebody finally does.
func TestTheReceiptIsAligned(t *testing.T) {
r := &receipt{
Repo: "acme/widgets",
State: "created",
Release: &sdk.Release{TagName: "v1.0.0", HTMLURL: "https://git.example.com/acme/widgets/releases/tag/v1.0.0"},
Assets: []asset{
{Name: "kettle_v1.0.0_darwin_arm64", URL: "https://git.example.com/a"},
{Name: "SHA256SUMS", URL: "https://git.example.com/b", Replaced: true},
},
}
var out strings.Builder
r.print(&out)
// One line for the release, one per asset, the URL, and the summary.
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
if len(lines) != 5 {
t.Fatalf("the receipt is %d line(s):\n%s", len(lines), out.String())
}
// The URLs line up, which is what makes a column of them scannable.
first := strings.Index(lines[1], "https://")
if second := strings.Index(lines[2], "https://"); first != second {
t.Errorf("the asset URLs do not line up (%d vs %d):\n%s", first, second, out.String())
}
if !strings.HasPrefix(lines[2], "replaced") {
t.Errorf("a replaced asset is not called one:\n%s", out.String())
}
// The URL a person opens is on its own line, not buried in a summary.
if !strings.HasPrefix(lines[3], "release ") || !strings.HasSuffix(lines[3], "/releases/tag/v1.0.0") {
t.Errorf("the release URL is not on its own line:\n%s", out.String())
}
if want := fmt.Sprintf("%d asset(s): 1 uploaded, 1 replaced", 2); !strings.Contains(lines[4], want) {
t.Errorf("the summary does not read %q:\n%s", want, out.String())
}
}
+87
View File
@@ -0,0 +1,87 @@
# AGENTS.md — internal/, and the boundaries between the packages in it
Seven packages, one direction of knowledge. The diagram is in
[`cli/AGENTS.md`](../AGENTS.md); **this file owns the rules that hold it and the
tests that fail when one is broken.** Each package's own document owns what is
inside it.
The rule in one sentence: **read the diagram bottom-up and each layer knows
strictly less about trackers than the one above it.** A tracker concept — an issue
number, a login, an HTTP call, a label colour — that shows up in
[`issue`](issue/AGENTS.md) is in the wrong place, and a domain concept — a
section, an acceptance criterion, a type taxonomy — that shows up in
[`gitea`](gitea/AGENTS.md) is in the wrong place too.
## Four rules, seven tests
Each test fails on a real mistake rather than on a naming convention.
| rule | enforced by |
|---|---|
| [`issue`](issue/AGENTS.md) may import [`project`](project/AGENTS.md) and the standard library, and **nothing else** | `TestDomainDependsOnNothing` walks `go list -deps` and fails on any import path with a dot in its first element — which is what keeps yaml *and* the SDK out of the domain; `TestDomainDoesNotReachTheNetworkOrTheShell` names `net/http`, `net`, `os/exec` and `encoding/json`, standard library the first test cannot catch |
| [`wire`](wire/AGENTS.md) imports **only** the standard library | `TestWireDependsOnNothing` and `TestWireReachesNeitherTheNetworkNorTheDisk`, the same two checks |
| [`gitea`](gitea/AGENTS.md) must not import [`issue`](issue/AGENTS.md) **or** [`mapping`](mapping/AGENTS.md) | `TestTransportDoesNotImportTheDomain` — the transport knows numbers, logins, HTTP and JSON, and none of what they mean |
| [`mapping`](mapping/AGENTS.md) reaches for nothing but the domain, `wire` and the SDK, and does no I/O | `TestTheBridgeTranslatesAndNothingElse` on its **direct** imports, with `os`, `net/http`, `internal/gitea`, `internal/config` and `internal/project` named; `TestTheBridgeHasNoClock` greps its sources for `time.Now` |
The domain's two tests were **untouched by the migration to the Gitea SDK, and
that is the point: the domain did not notice it happened.**
## The one rule that got weaker, and why the trade was taken
The payload shapes used to live in `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. Note
the deliberate asymmetry with the domain's test: this one checks **direct**
imports, because the domain reaches `os` *through* `project` and that is the
domain's business. `time` is allowed here 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.
## Why `wire` still exists
It existed 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.
## Adding a package here
Three questions, in order:
1. **What does it know that its neighbours must not?** A package that cannot
answer this is a file in an existing one.
2. **Which direction does it import?** Draw it into the diagram in
[`cli/AGENTS.md`](../AGENTS.md) before writing code; an arrow that has to point
both ways means the split is in the wrong place.
3. **What test fails when the boundary is crossed?** Write it with the package,
not after. Every rule above has one, and each of them exists because the
equivalent convention in the Python version was a grep somebody eventually
forgot to run.
Then give it an `AGENTS.md`, add it to the table in [`cli/AGENTS.md`](../AGENTS.md),
and add its rule to the table above.
## Keeping this file true
- **Scope:** the boundaries *between* the packages under `internal/` — the four
rules, the seven tests that hold them, and the history of the one that changed.
Files: every `layering_test.go`, plus `TestTransportDoesNotImportTheDomain` in
`gitea/client_test.go`.
- **Update it when** a layering test is added, renamed, removed or weakened; when
a package is added or removed; or when an import that was forbidden becomes
allowed — that last one always comes with a reason, and the reason is what this
file is for.
- **Do not** restate what a package does. The table links to the file that says so.
+187
View File
@@ -0,0 +1,187 @@
# AGENTS.md — internal/cmd
**The command tree: flags, receipts, exit codes.** The only package that may import
every layer below it, and the only one that prints.
`cmd/kettle` is four lines around `cmd.Main(os.Args[1:])` — everything a `main`
usually accumulates lives here instead, because a `main` package cannot be imported
and therefore cannot be tested.
## Commands are values
Each command is one `register(&Command{…})` in an `init()`, carrying the metadata a
human needs — `Short`, `Long`, `Examples`, `Args`, `Group` — **in the same struct
that carries the code**. That is what lets the plugin's SKILL.md files be generated
from this list: a command whose flags changed cannot ship with documentation that
says otherwise.
```go
func init() {
register(&Command{
Name: "tree",
Group: GroupIssue,
Args: "[<id>…]",
Short: "draw the dependency graph of the local store",
Long: ``,
Examples: []Example{{"kettle tree", "every root (nothing depends on it)"}},
Setup: func(fs *flag.FlagSet) func([]string) error {
out := storeFlag(fs)
depth := fs.Int("depth", 6, "maximum depth")
return func(args []string) error { }
},
})
}
```
**`Setup` registers flags and returns the runner**, closing over them. Splitting it
that way is what lets `Command.Flags()` walk a command's flags without running
anything — which is how the doc generator reads them.
**The tree is flat.** `kettle new`, not `kettle issue new`: an agent pays for every
token of every invocation, and the grouping that matters for reading is carried in
`Group` and only shows up in the docs. Three groups, in presentation order:
`project`, `issue`, `sync`.
| file | what is in it |
|---|---|
| `command.go` | `Command`, the registry, `Main`, help rendering, `SilentError`, `Fail`, and `permute` |
| `flags.go` | `storeFlag`/`storeRoot`, `wasSet`, the repeatable `stringList` |
| `sync.go` | `syncStart`/`syncStartExisting`, `commentsSidecarPath` — the shared opening of every tracker command |
| `gen.go` | `kettle gen skills`: the generated region in the plugin's SKILL.md files |
| `init.go` `auth.go` `config.go` `version.go` | group `project`. `version.go` also holds `Version`, the string a release build stamps in with `-ldflags -X` |
| `new.go` `check.go` `ac.go` `tree.go` `index.go` `evict.go` | group `issue` — no network in any of them |
| `pull.go` `push.go` `remote.go` `comment.go` `close.go` `labels.go` `evict_sync.go` | group `sync` |
| `cli_test.go` | builds the binary in `TestMain`, runs it as a subprocess |
| `sync_pull_test.go` `sync_write_test.go` | the tracker halves, against fake servers |
| `gen_test.go` | the generator: determinism, the region splice, the missing-marker refusal |
## Three conventions every command follows
**Flags may come after positionals.** The standard `flag` package stops parsing at
the first non-flag argument, so `kettle ac <id> --check 3` would hand `--check` to
the command as a positional and tick nothing. `permute` moves flags forward, using
the `FlagSet` to know whether a flag swallows the next argument; `--` ends the
permutation. Every other CLI an operator uses interleaves the two, and a tool that
silently ignores a flag because of where it was typed is worse than one that rejects
it.
**Exit codes are three.** `0` fine, `2` for a usage problem (unknown command,
unparseable flags), `1` for an ordinary failure — printed as `kettle <name>: <err>`
by `Main`, which is why no command prefixes its own errors. `SilentError{Code: 1}`
is for a command that has already said everything it has to say: `check` and
`gen --check` use it, because findings went to stdout and a second copy on stderr
would be noise.
**The store is resolved before a socket is opened.** `syncStart` does that in one
place: a command that dialled first would report a network problem for a project
that was never initialized, and an operator would go looking at the wrong thing.
`syncStartExisting` adds `RequireStore` for the commands that read the store rather
than create it — `push`, `comment`, `close`, `sync-evict` — because a missing store
is a mistake to report, not a directory to conjure.
**There is no `--login` and no `--repo`** on any sync command bar `labels`. Which
login a project runs under is a fact about the project, stated once by
`kettle init`. That the two could disagree is what the Python version needed a
`PreToolUse` hook to police.
`--out` is the one flag almost every command has, and an explicit one is used
**exactly as typed**: a relative `--out` stays relative to the working directory,
because that is what the operator asked for.
## Usage
```bash
kettle help # the tree, grouped
kettle help push # one command in full: flags, defaults, examples
kettle init --login noodles --repo owner/name
kettle new --type task --title "Wire sqlc into the appclick repo layer"
kettle ac wire-sqlc-appclick --check 3
kettle check --strict # exit 1 on any error; --strict counts warnings too
kettle pull 42 # the issue and everything blocking it, any state
kettle push --update wire-sqlc-appclick
kettle sync-evict --dry-run
```
Every command's own `Long` text is the reference — it is what
`kettle help <name>` prints and what the generator writes into the plugin. **Do not
restate a flag table here**; it would be a third copy of something already in two
places, one of them mechanically checked.
## push and pull, the two halves of one rule
The rule is that **the store holds what has not left this machine.** Both halves are
worth reading in full before either file is touched.
`push` (`push.go`) deletes `<id>.md` and every sidecar under that slug — on create
and on `--update` alike, one rule with no exception, because a `PATCH` is a push and
two rules would put back exactly the question this removes ("is my copy the fresh
one?"). The deletion is the **last** thing that happens, and only after all three of:
1. the call came back without an error and with a 2xx,
2. the answer carries a plausible number — on `--update`, the very number that was
`PATCH`ed,
3. the number → slug ledger has been written.
Network down, non-2xx, an answer that does not confirm the write: the file stays and
the run stops. Get the ordering wrong and a slug is lost at exactly the moment the
local copy stops being the record, which is why the ledger is written *before*
anything is deleted. A never-pushed `origin: local` issue is never touched by any of
it.
Dependencies go first, in topological order, so a blocker has its number before the
issue that names it. An `--update` can take one extra request with it, because
Gitea's edit endpoint carries no labels — when the answer's label set and the
issue's disagree the whole set goes up in a `PUT`, and a warning on stderr says
which names moved.
`pull` (`pull.go`) is how a pushed issue comes back. Three sources answer "what is
this issue called here", in this order: the ledger (the only one that knows what is
on disk *right now*, so it wins), the `<!-- kettle:id … -->` marker in the
tracker-side body, then the slugified title. A marker is taken at its word only when
the slug is free; a name already in use is a collision, not an identity, and is
uniquified.
Two ways to name what to pull, and they are **not the same operation**: a key is an
*address* and fetches an issue in any state, while a filter is a *query* and leaves
closed issues out. `--limit` is on the **write**, not the selection — it counts what
lands in the store, which is why a filtered pull can enumerate far more than it
keeps and says so. Blockers come down too, recursively to `--depth`, and are outside
the limit: a blocker is followed because a stored issue named it. A pull **overwrites
the body** — it is a fetch, not a merge — with checkbox state the one exception.
## The generator
`gen.go` writes the plugin's SKILL.md command reference from this registry.
**It owns a region, not a file.** Everything between `<!-- kettle:gen -->` and
`<!-- /kettle:gen -->` is replaced on every run; every byte outside comes back
exactly as it was, which matters most for `description:`, the prose that decides
whether an agent loads the skill at all and the one thing here no generator can
write. A file with **no** markers is reported and left alone, never overwritten —
clobbering somebody's prose because they forgot a marker is the failure this design
exists to prevent.
The output is deterministic to the byte — no timestamps, no map iteration — so
regenerating something unchanged produces no diff. `--check` is that property made
useful: it writes nothing and exits 1 when anything on disk differs, which is what a
pre-commit hook or a CI step calls, and it wins over `--dry-run`.
One file per **group**, so adding a group here adds a skill directory over there;
name one only when it is a subject somebody would load on its own. A command with no
`Group` is in no skill and the run says so. A `Long` or `Example` that spells a
region marker out in full is a hard error — the generated block would end inside
itself.
## Keeping this file true
- **Scope:** the shape of the command tree — the registry, the shared helpers, the
three conventions, the round trip, the generator. The file table names every
source file in this directory.
- **Update it when** a command file is added or removed, a group is added, a shared
helper in `flags.go`/`sync.go` changes, an exit code gains a meaning, or the
push/pull ordering guarantees change.
- **Do not** copy a flag list or a command's `Long` text here. `kettle help <name>`
and the generated SKILL.md blocks are the two places that exist for it, and a
third would be the one that drifts.
+38
View File
@@ -491,6 +491,44 @@ func TestAMissingLoginIsExplained(t *testing.T) {
} }
} }
// The version is "dev" until a build stamps it, and the STAMPING is what is
// tested here rather than the printing.
//
// A `-X` whose symbol path is one character wrong is not an error: the linker
// ignores it and the binary goes on reporting "dev" for the rest of its life,
// which is discovered by an operator holding a release that will not say what
// it is. So this builds with the flag the Makefile uses and reads the answer
// back out of the binary.
func TestVersionSaysDevUntilABuildStampsIt(t *testing.T) {
dir := t.TempDir()
r := mustRun(t, dir, "version")
if !strings.Contains(r.stdout, "dev") || !strings.Contains(r.stdout, "built") {
t.Errorf("a build from source must say what it is:\n%s", r.out())
}
// A version needs no project: it is a fact about the binary, and the
// question is asked most often by somebody whose project is not resolving.
if short := mustRun(t, dir, "version", "--short"); strings.TrimSpace(short.stdout) != "dev" {
t.Errorf("--short printed %q, want dev", short.stdout)
}
const stamp = "v9.9.9-from-the-test"
stamped := filepath.Join(t.TempDir(), "kettle")
build := exec.Command("go", "build",
"-ldflags", "-X git.noodles.cam/claude-skills/marketplace/cli/internal/cmd.Version="+stamp,
"-o", stamped, "../../cmd/kettle")
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("building a stamped binary: %v\n%s", err, out)
}
out, err := exec.Command(stamped, "version", "--short").Output()
if err != nil {
t.Fatalf("running the stamped binary: %v", err)
}
if got := strings.TrimSpace(string(out)); got != stamp {
t.Errorf("the stamped binary reports %q, want %q — the -X symbol path is wrong", got, stamp)
}
}
func closeIssue(t *testing.T, path string) { func closeIssue(t *testing.T, path string) {
t.Helper() t.Helper()
setField(t, path, "state", "closed") setField(t, path, "state", "closed")
+93
View File
@@ -0,0 +1,93 @@
package cmd
import (
"flag"
"fmt"
"runtime"
"runtime/debug"
)
// Version is what this binary was built as. It is stamped at link time and
// defaults to something honest.
//
// "dev" is the truth for a build from a working tree: a binary somebody built
// out of a checkout is not a release and must not claim to be one. A release
// build says otherwise by naming this variable:
//
// go build -ldflags "-X git.noodles.cam/claude-skills/marketplace/cli/internal/cmd.Version=v1.2.3" ./cmd/kettle
//
// which is what `make build`, `make dist` and `make release` do. The path is
// exercised by a test that builds with the flag and reads the answer back,
// because a -X whose symbol path is one character wrong is silently ignored and
// the binary goes on reporting "dev".
var Version = "dev"
func init() {
register(&Command{
Name: "version",
Group: GroupProject,
Short: "print the version this binary was built as",
Long: `A binary that cannot say what it is, is a support problem: an operator with an
old copy on PATH and a skill written against a newer one has no way to see the
mismatch, and neither does anybody reading their transcript.
The version is stamped at link time. A build from source says "dev" and means
it — that is not a placeholder to be edited, it is the answer for a binary that
came out of somebody's working tree rather than off a tag.
The commit is reported when the build recorded one, which ` + "`go build`" + ` does from
git and a build from an unpacked tarball cannot. A tree with uncommitted
changes in it says so beside the commit.`,
Examples: []Example{
{"kettle version", "the version, the toolchain and the commit"},
{"kettle version --short", "just the version, for a script"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
short := fs.Bool("short", false, "print the version alone, with nothing around it")
return func(args []string) error {
if len(args) > 0 {
return Fail("version takes no arguments")
}
if *short {
fmt.Println(Version)
return nil
}
fmt.Printf("kettle %s\n", Version)
fmt.Printf("built %s %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)
if rev := revision(); rev != "" {
fmt.Printf("commit %s\n", rev)
}
return nil
}
},
})
}
// revision is the commit this binary was built from, or "" when the build
// recorded none.
//
// `go build` stamps it out of git; a build from an unpacked tarball has no
// repository to ask, and there is nothing to report rather than something to
// invent. A dirty tree is named as one: the commit is then a lower bound on
// what is in the binary and not a description of it.
func revision() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return ""
}
var rev string
var dirty bool
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
rev = s.Value
case "vcs.modified":
dirty = s.Value == "true"
}
}
if rev != "" && dirty {
rev += " (with uncommitted changes)"
}
return rev
}
+101
View File
@@ -0,0 +1,101 @@
# AGENTS.md — internal/config
**Two files: what this project is, and who this machine is.** The only package in
the tree that imports yaml.
| file | what is in it |
|---|---|
| `config.go` | `Project` and `Logins` (the two files), `Resolve`/`ResolveOutsideAProject`/`Require`, `Resolved` with `Complete` and `Redacted`, the `KETTLE_*` overrides, and the 0600 write |
## The split is the whole design
```
<project>/.kettle/config.yaml login: noodles a NAME, never a token
repo: owner/name
~/.config/kettle/logins.yaml logins: [{name, url, user, token}] mode 0600
```
**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. So the project pins a login by **name**, and the name is worth nothing
on its own, which is what makes it safe to keep in a repository.
Which tokens this computer holds is a fact about the computer, the way which issues
a tree holds is a fact about the tree. `SaveLogins` writes 0600 into a 0700
directory; nothing else on the machine has any business reading it. `$KETTLE_CONFIG_HOME`
relocates it — the test suite sets it, so a run can neither read nor overwrite the
developer's own tokens — and `$XDG_CONFIG_HOME` is honoured too.
**Nothing prints a token.** `Redacted` is what a receipt gets; `kettle config` shows
`(set)`.
## Resolution, and why it fails early
`Resolve` merges three sources — the project config, the machine's login file, and
the environment — into `Resolved`, which is everything the transport needs.
**Every failure names the file it read and the command that fixes it.**
"401 Unauthorized" is what happens when this function is allowed to return a
half-filled struct, and a 401 names nothing an operator can act on.
The same discipline splits the two "missing" answers: a missing `config.yaml` is
`ErrNoConfig`, not an empty config, because "this project has not been told which
tracker it belongs to" and "it belongs to no tracker" are different answers and only
one is fixed by running `init`. A missing login file, by contrast, **is** an empty
list — a machine with no logins yet is an ordinary machine.
`Complete` is that assertion on its own, as a method, because the two questions are
different: `kettle config` wants to **show** a half-filled configuration and
everything that dials wants to **refuse** one. `Require` is `Resolve` plus
`Complete`; [`gitea.New`](../gitea/AGENTS.md) and `cmd/release` call `Complete`
themselves, so a client can never be built from a struct that is missing a field.
`ResolveOutsideAProject` is for the one caller that legitimately stands nowhere near
a project: [`cmd/release`](../../cmd/release/AGENTS.md), run from a fresh clone. The
marker is gitignored, so a clone has none and a build tool must not create one — and
with no marker there is nothing to merge, so the **environment is** the
configuration. A marker that is there is read as always, so the same command run
from a maintainer's own checkout picks up the login pinned in it. Every other caller
wants `Resolve`, where "no project" is the answer rather than a state to work
around: a push that quietly ran against whatever was in the environment would be a
push into somebody else's repository.
`ReadProjectFile` exists for exactly one caller: `kettle init`, which is creating
the marker `LoadProject` walks for, and on a dry run may not have created it at all.
## Overrides
| variable | shadows |
|---|---|
| `KETTLE_LOGIN` | `login:` in the project config |
| `KETTLE_REPO` | `repo:` in the project config |
| `KETTLE_URL` | the login's `url` |
| `KETTLE_TOKEN` | the login's `token` |
| `KETTLE_CONFIG_HOME` | the directory holding `logins.yaml` |
Each wins over the file it shadows. They exist for CI, for a one-off run against
another instance, and for anyone who would rather not have a token on disk at all.
## Unknown keys 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 — which is a data-loss
bug that only shows up on the machine running the older build.
## What does not belong here
A request, a store path, an issue. This package reads and writes two files and
answers "who am I and where am I pointed"; [`gitea`](../gitea/AGENTS.md) takes the
answer and dials, and the paths themselves come from
[`project`](../project/AGENTS.md).
## Keeping this file true
- **Scope:** `config.go` — the two files, their fields, the overrides, the file modes.
- **Update it when** a field is added to either file (both tables above are the
contract), an override is added or renamed, the location or mode of the login file
changes, or the unknown-key policy changes.
- **Do not** move a credential into the project file, and if that ever changes, the
argument above is what has to be answered first.
+44 -2
View File
@@ -243,7 +243,34 @@ func Resolve(start string) (*Resolved, error) {
} else if !errors.Is(err, ErrNoConfig) { } else if !errors.Is(err, ErrNoConfig) {
return nil, err return nil, err
} }
return merge(p)
}
// ResolveOutsideAProject is Resolve for a caller that legitimately has no
// project to stand in.
//
// `cmd/release` is the one, and it is not an exception being carved out: the
// marker is gitignored, so a fresh clone has none, and a tool that publishes a
// tag must not create one on its way past. With no marker there is nothing to
// merge and the ENVIRONMENT IS the configuration — KETTLE_URL, KETTLE_TOKEN and
// KETTLE_REPO, which is exactly what somebody exports before cutting a release.
//
// A marker that IS there is read as always, overrides and all, so the same
// command run from a maintainer's own checkout picks up the login pinned in it
// and needs no token in the shell.
//
// Every other caller wants Resolve: for `kettle`, "no project" is the answer,
// not a state to work around. A push that quietly ran against whatever was in
// the environment would be a push into somebody else's repository.
func ResolveOutsideAProject(start string) (*Resolved, error) {
if ProjectPath(start) == "" {
return merge(Project{})
}
return Resolve(start)
}
// merge applies the login file and the environment to a project's settings.
func merge(p Project) (*Resolved, error) {
out := &Resolved{Login: p.Login} out := &Resolved{Login: p.Login}
if v := os.Getenv(EnvLogin); v != "" { if v := os.Getenv(EnvLogin); v != "" {
out.Login = v out.Login = v
@@ -295,6 +322,21 @@ func Require(start string) (*Resolved, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := r.Complete(); err != nil {
return nil, err
}
return r, nil
}
// Complete reports what a resolved configuration is still missing, naming the
// one command or the one variable that supplies each.
//
// A half-filled struct allowed through is a 401 three calls later, and "401
// Unauthorized" names nothing an operator can act on. It is a method rather
// than part of Resolve because the two questions are different: `kettle config`
// wants to SHOW a half-filled configuration, and everything that dials wants to
// refuse one.
func (r *Resolved) Complete() error {
var missing []string var missing []string
if r.URL == "" { if r.URL == "" {
missing = append(missing, "a URL (pin a login with `kettle init --login`, or set "+EnvURL+")") missing = append(missing, "a URL (pin a login with `kettle init --login`, or set "+EnvURL+")")
@@ -306,9 +348,9 @@ func Require(start string) (*Resolved, error) {
missing = append(missing, "a repository (`kettle init --repo owner/name`, or set "+EnvRepo+")") missing = append(missing, "a repository (`kettle init --repo owner/name`, or set "+EnvRepo+")")
} }
if len(missing) > 0 { if len(missing) > 0 {
return nil, fmt.Errorf("this project has no %s", strings.Join(missing, ", and no ")) return fmt.Errorf("this project has no %s", strings.Join(missing, ", and no "))
} }
return r, nil return nil
} }
// strictUnmarshal refuses keys the struct does not know. // strictUnmarshal refuses keys the struct does not know.
+179
View File
@@ -0,0 +1,179 @@
# AGENTS.md — internal/gitea (TRANSPORT)
**Everything that talks to a tracker, and nothing else.** Numbers, logins, HTTP
verbs, pagination, JSON.
It does not know what an issue *is* — no sections, no acceptance criteria, no type
taxonomy — and the import graph says so in **both** directions: this package may
not reach into [`issue`](../issue/AGENTS.md), and `issue` may not reach in here.
[`mapping`](../mapping/AGENTS.md) is not imported either: it sits *above* this
package, not beside it. `TestTransportDoesNotImportTheDomain` is the check.
| file | what is in it |
|---|---|
| `client.go` | `New`, `For`, the payload-dumping `RoundTripper`, pagination, `APIError` and `Fail`, `ListIssues` and its budget |
| `issues.go` | `GetIssue`, `CreateIssue`, `EditIssue`, `SetLabels`, comments, milestones, dependencies |
| `labels.go` | `ListLabels`, `CreateLabel`, `EditLabel` |
| `remotemap.go` | `RemoteMap` — the number → slug ledger, and why nothing prunes it |
| `client_test.go` | pagination, error bodies, the scratchpad, the page budget, the version gate |
| `remotemap_test.go` | load, merge, save |
## What this package is, now that the SDK exists
The one place that holds the **credentials, the scratchpad and the repository this
project points at**, so no command has to. Every method is a thin wrapper, and the
wrapping is for the three things the SDK does not do:
- **every request body is filed under `.kettle/payload/`** by a `RoundTripper`, so a
retry or a post-mortem has the bytes that went out;
- **every failure comes back as `*APIError`** carrying the status *and* what the
server said, because "500" on its own has never helped anybody. Gitea answers 422
for a label that already exists, for a milestone id belonging to another
repository, and for a body missing a field — the three are told apart only by the
message, so the body travels with the code, always;
- **a listing stops when the caller has what it asked for**, which a client that
fetches whole pages into a slice cannot do.
The payload shapes are the SDK's, aliased `sdk` everywhere. The issue **keys** are
still [`wire`](../wire/AGENTS.md)'s — the SDK addresses an issue as
`(owner, repo, int64)` and never parses `owner/repo#42` out of anything.
`Fail` builds an `*APIError` out of an SDK `(response, error)` pair and is exported
for [`cmd/release`](../../cmd/release/AGENTS.md), the one caller outside this package
that builds its own client — so "the tracker said no" has one spelling in the tree.
## Building a client dials
`New` refuses a half-filled configuration **before** anything else, because building
the client dials: the SDK asks the instance for its version before it hands one
back. A missing token reported as a connection failure sends the operator to the
wrong place. Every field it checks has exactly one command that supplies it.
That handshake is also what pays for the dependency gate below, and it is why every
fake tracker in the test suite answers `/api/v1/version`.
`For(repo)` returns a copy pointed at another repository — **bookkeeping, not a
second connection**, since the SDK takes the owner and name per call. Credentials,
the negotiated version and the scratchpad are shared, which is what makes
`kettle pull owner/repo#42` cost nothing extra.
## The scratchpad
`.kettle/payload/` is a **sibling of the store, 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 the `RoundTripper`, 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, leaves no
directory at all: 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.
## Listings, and the two boundaries
`ListIssues` makes one request per page, and a payload already carries the issue
body — a whole milestone costs one call per page, not one per issue.
`IssueFilter.Keep` decides whether a payload counts against `Limit`. **What Keep
means is the caller's business; this package only counts.** Two boundaries hold
whatever it decides:
- **stop at the limit** — the page after the one that completed the budget is never
requested;
- **stop at the page budget** — a filtered read scans at most `PageSlack` (4) times
the pages `Limit` would need if every payload counted. A predicate that rejects
everything must not turn a bounded read into a walk of the whole tracker. Hitting
the budget unfilled sets `IssueListing.Warning` rather than answering short in
silence — **returned rather than printed**, because the transport does not own the
operator's terminal.
`ResolveMilestone` fails **loudly**, and that is the whole point of resolving before
filtering: Gitea silently ignores a `milestones=` filter it cannot resolve and
answers with the entire backlog, so a typo would read as "your milestone has 300
issues in it". It resolves against the whole listing rather than the SDK's
`GetMilestoneByName`, which matches case-insensitively and would fold two different
milestones into one. `FindMilestone` is its quiet counterpart for a push, where a
milestone the tracker does not have means "filed without one".
`ListMilestones` returns both states, always: a milestone is closed the moment its
work is done, and a listing that hid those would fail to resolve exactly the filter
somebody types when they want to see what was in it. `ListLabels` is read from the
repository and never from a cache — a cache answers "what did we create last time",
and the question is "what does this repository have right now".
## The ledger
`.remote.json`, **inside the issue store, beside the issues it indexes** — it is
bookkeeping about issues and belongs where they are, not in the scratchpad.
**Its entries outlive the files they name, and that is deliberate.** A push deletes
an issue's file the moment the tracker confirms the write, and the entry left behind
is what makes the next pull of that number land on the same slug — so every
`depends:` that pointed at it still resolves. Nothing prunes them, not push and not
eviction, because "no file" no longer means "no such issue". A stale entry costs one
line of JSON and is corrected the next time that number is pulled.
It is a **cache, not a record**. The order of authority:
```
the tracker the issue, and the marker naming its slug
.remote.json a local number -> slug ledger, a cache of that marker
the store whatever happens to be checked out right now
```
Which is why `LoadRemoteMap` never fails — a missing, unreadable or malformed file
is an empty ledger, since refusing to run would block the very pull that would
rebuild it — and why a rebuild is a **merge and never a replacement**: the store is
a subset of what the ledger knows, so starting from the files alone would throw away
every entry it cannot see. Load, add what the files say, save.
`Save` is the one write here allowed to create the store, and only because of when
it happens: the ledger is written the instant the tracker confirms a push and
**before** the local file is deleted, so failing it over a missing directory would
lose the slug at exactly the moment the local copy stops being the record.
## 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.
- **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 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 URL names the blocked issue and
the body the blocker, which is the direction `Dependencies` reads back.
- **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 older instance 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** — the Python version swallowed both, and "the server said no"
and "there was no server" are different answers.
A link that already exists answers 409, so callers pre-filter with `DependencyKeys`
and treat a failure here as a note rather than an abort: one missing cross-link must
not undo a push that has already created issues.
## Two Gitea quirks worth knowing before touching anything
- **`EditIssue` carries no labels.** Gitea's edit endpoint takes none and neither
does the SDK's option struct, so an issue whose labels changed needs `SetLabels`
after it — `push` makes that call and says which names moved.
- **A create can silently drop labels handed to it.** `SetLabels` re-applies them
rather than trusting the echo.
## Keeping this file true
- **Scope:** every `.go` file here — the client, the endpoints it wraps, the
scratchpad, the ledger, and the quirks that shape them.
- **Update it when** a method is added or removed, a request stops going through the
SDK or starts to, the page budget or the version gate changes, the ledger's format
or location changes, or a new Gitea quirk is worked around — a workaround with no
written reason is a workaround somebody deletes.
- **Do not** explain what a field *means* to an issue. That is
[`mapping`](../mapping/AGENTS.md)'s and [`issue`](../issue/AGENTS.md)'s.
+8
View File
@@ -197,6 +197,14 @@ func StatusIs(err error, status int) bool {
return errors.As(err, &apiErr) && apiErr.Status == status return errors.As(err, &apiErr) && apiErr.Status == status
} }
// Fail is fail, exported for the one caller outside this package that needs it.
//
// `cmd/release` builds its own SDK client — see its package doc for why a build
// tool must not use this one — but a failure it reports has to name a status
// and quote what the server said in the same words a push does. One function,
// so the two spellings of "the tracker said no" cannot drift apart.
func Fail(resp *sdk.Response, err error) error { return fail(resp, err) }
// fail turns one SDK call's (response, error) pair into this package's error. // fail turns one SDK call's (response, error) pair into this package's error.
// //
// BOTH HALVES OR NEITHER. The SDK reads the response body to build its error // BOTH HALVES OR NEITHER. The SDK reads the response body to build its error
+173
View File
@@ -0,0 +1,173 @@
# AGENTS.md — internal/issue (DOMAIN)
**What an issue IS.** The canonical markdown format, the label taxonomy,
validation, checkboxes, the dependency graph, the store, and eviction.
It knows **nothing** about any tracker: no Gitea, no logins, no HTTP, no issue
numbers. Delete the transport entirely and this layer keeps working — issues that
live only on this machine are first-class, not drafts on their way somewhere.
Imports [`project`](../project/AGENTS.md) and the standard library, and nothing
else; two tests hold that, see [`internal/AGENTS.md`](../AGENTS.md).
| file | what is in it |
|---|---|
| `issue.go` | the `Issue` type, `FromText`/`Text`, `Slugify`, `IsSlug`, `UniqueID`, `DomainKeys` — and the package comment with the annotated file format |
| `meta.go` | `ParseMeta`/`RenderMeta`: the metadata block, one field per line |
| `taxonomy.go` | `Types`, `Severities`, the section headers, `RequiredSections`, `ExpectedSections`, `CanonicalLabels`, `SectionBody` |
| `template.go` | `Template`: the prefilled body per type |
| `validate.go` | `Validate`: errors mean malformed, warnings mean it deviates from its template |
| `checkbox.go` | `Checkboxes`, `SetCheckbox`, `CheckboxProgress` — pure functions over a string |
| `graph.go` | `Graph`, `Dependents`, `TopoOrder`, `FindCycles` over `depends:` |
| `depsection.go` | `BodyDepRefs`: references written in `## Depends on` / `## Issues` prose |
| `store.go` | `Root`, `AllIDs`, `SlugFiles`, `Load`/`LoadAll`/`Save`, `RequireStore`, `CreateStore`, `StoreError` |
| `index.go` | `BuildIndex`: INDEX.md, a view of the directory |
| `evict.go` | `Classify`, `Evict`, `Remove`, and the report types |
| `layering_test.go` | the two tests that keep a tracker out of this package |
## Identity
A slug derived from the title, and **the file name is the id**:
```
.kettle/issues/wire-sqlc-appclick.md
```
```
---
id: wire-sqlc-appclick
state: open
labels: [type/task, tech/sql]
assignees: [naudachu]
milestone: v0.2
depends: [migrate-schema]
origin: gitea
gitea: owner/repo#42
synced: 2026-08-07T18:40:00Z
---
# Wire sqlc into the appclick repo layer
## Summary
```
Keys down to `origin` are owned here. **Everything below is foreign**: written by
the sync layer, carried through load and save verbatim in `Issue.Extra`, never
read. That passthrough is what lets one file represent both a local issue and a
synced one without the domain learning a second vocabulary.
Every metadata field is one line and lists are inline, so plain grep works without
a parser:
```bash
grep -l 'labels:.*type/bug' .kettle/issues/*.md
grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md # who depends on it
```
`FromText` takes an id that **overrides** the one in the block, which is how the
store makes the file name authoritative.
## Origin is the safety argument
`origin: local` means **this file IS the issue** — there is no other copy, and
deleting it deletes the work. It is a complete state, not a pending one. Anything
with a tracker origin can be fetched again, which is what makes it safe to remove.
Every dangerous operation in this package turns on that one field:
- `Classify` splits the store into evictable, protected and still-open. It is
**pure** — it reads loaded issues and decides, touching no disk — and a protected
issue comes back as protected **even when it was named explicitly**: naming a
file does not make deleting it safe.
- `Evict` classifies, removes, and rebuilds the index. One implementation, called
both by the offline command and by the sync layer — which does nothing to this
decision except hand over issues whose `state:` it has just refreshed.
- `Remove` is deliberately dumb: it takes an id, not a decision. Whether an issue
may go is settled by `Classify` before this is reached, so the dangerous half of
the operation has no branches in it at all.
The store is a **working set, not an archive**: a closed issue with a tracker origin
is evicted, and eviction is not a one-off migration — a pull by number fetches an
issue in any state, so a closed issue pulled after an eviction lands on disk again.
## The store, and the three ways it can be missing
`AllIDs` reads `<slug>.md` and nothing else. **A slug has no dot in it**, so
`wire-sqlc.comments.md` is not an issue; without that rule a bare push tries to
file a comment thread as a unit of work. `SlugFiles` is the same rule read the
other way round — everything named `<id>.<something>` belongs to that issue and
goes when it goes, which is how the domain removes an issue completely without
learning what a comment thread is.
Three failures, three messages, because they are three different things to do next:
| answer | means |
|---|---|
| `project.NotFoundError` | no project at all — run `kettle init` |
| `store … does not exist` | a project whose store was never created |
| `store … exists but is empty` | a store with nothing filed in it yet |
`ErrStoreMissing` marks the first two. Conflating "empty" with "not there" is
exactly what once made a missed directory look like an empty backlog. **Nothing
creates a store as a side effect of a write** — only `new` and `pull` call
`CreateStore`, and both announce it.
## Sections, and what a checkbox is
Section headers are fixed English literals in a fixed order; **only body prose is
Russian**. `RequiredSections` (`## Summary`, `## Spec`) must be present in every
type; `ExpectedSections` are the per-type ones and their absence is a warning.
`DepSections``## Depends on` and `## Issues` — both name what an issue depends
on, so both are edge sources pointing the same way. In a `type/feature` that reads
container → child: "the container is closed when its children are closed" *is* a
dependency, while "a child belongs to a feature" is membership, and membership has
no place in a dependency graph. Which is why a child never names its container back.
**`depends:` is the authoritative edge list; body prose is never walked by
`Graph`.** `BodyDepRefs` exists so a command can *report* what the prose claims,
and never so the graph can be built from it.
A checkbox is the one part of a body that is **state** and not prose. `SetCheckbox`
is surgical: exactly one byte of the input changes, and everything else — trailing
whitespace, the item's own wording, an existing `[X]`'s capital — comes back byte
for byte. Ticking a box must not produce a diff wider than the state that changed.
Fenced code blocks are skipped whole: `- [ ]` inside a fence is an example of the
markup, not a box anybody may tick.
`CheckboxProgress` is computed on the fly. Progress is not a metadata field — a
second copy of that state would be wrong by the next edit.
## Usage
```go
root := issue.Root(out) // out overrides; "" resolves the project
if err := issue.RequireStore(root); err != nil { return err }
issues, err := issue.LoadAll(root)
order := issue.TopoOrder(ids, issue.Graph(issues)) // dependencies first
errs, warns := issue.Validate(issues[id], knownIDs)
```
`TopoOrder` breaks cycles deterministically rather than raising: a cycle is a data
problem for the caller to report (`FindCycles` finds them), not a reason to refuse
to order the rest.
## What does not belong here
An issue number, a login, an HTTP call, a label colour, a hex code, a JSON tag, a
yaml tag. If one appears in this package it is in the wrong place — colours are
[`mapping`](../mapping/AGENTS.md)'s, because a hex code is how a tracker paints a
chip and not what an issue is.
## Keeping this file true
- **Scope:** every `.go` file in this directory — the format, the taxonomy, the
store, the graph, checkboxes, eviction.
- **Update it when** a metadata field is added to `DomainKeys`, a type or severity
is added to the taxonomy, a required or expected section changes, a file appears
or goes in the table above, or any rule about what may be deleted changes. The
format's operator-facing statement of intent lives in the plugin
(`plugins/kettle/skills/issue/references/format.md`) — when the taxonomy moves,
both change.
- **Do not** document how any of this reaches a tracker.
+125
View File
@@ -0,0 +1,125 @@
# AGENTS.md — internal/mapping (BRIDGE)
**md ↔ Gitea's payloads. The whole translation, and only the translation.**
Pure functions: no network, no filesystem, no flags, no clock. Give it a payload
and it hands back a domain issue; give it an issue and it hands back a request
body. That purity is the point — it can be reasoned about and tested without a
Gitea anywhere, and it is the one package to open when the two representations
disagree.
Imports [`issue`](../issue/AGENTS.md), [`wire`](../wire/AGENTS.md) and the SDK.
Nothing imports it but [`cmd`](../cmd/AGENTS.md) — not the domain, not the
transport. Both sides speak the SDK's shapes, which is what lets the two meet
without either reaching into the other.
| file | what is in it |
|---|---|
| `mapping.go` | the sync-owned metadata keys (`gitea`, `url`, `synced`, `remote-updated`, `comments`, `branch`), `Origin`, `ApplyRemote` |
| `frompayload.go` | `FromPayload` and the accessors around it: `LabelNames`, `AssigneeLogins`, `MergeCheckboxState` |
| `torequest.go` | the create/edit request bodies, `LabelIDsFor` |
| `labels.go` | `LabelColor`, `LabelSpecs`, `CanonicalLabelSpecs`, `DefaultColor` |
| `marker.go` | `IDMarker`, `IDInBody`, and the strip on the way in |
| `layering_test.go` | the two tests that keep this package pure |
## What crosses the boundary, and what does not
```
domain Gitea note
----------------------------------------------------------------------
id (slug) body marker <!-- kettle:id … -->, first line of the
tracker-side body; stripped out of the
local copy — see marker.go
title title verbatim, both ways
body body verbatim up, verbatim down except the
marker and checkbox state
state state open/closed, the same vocabulary
labels labels[] names both ways; ids only on write
assignees assignees[] logins
milestone milestone.title resolved to an id on write
depends — slugs; #N is translated at this edge
— number, html_url lands in Extra as gitea:/url:
— ref Extra as branch:; push fills it from git
```
Only the **login** of a Gitea user crosses: it is what `assignees:` holds, and a
display name is not an identity anything can be pushed against. Label and
assignee lists are appended into a nil slice, so an issue with no labels is the
same value as one loaded from a file — two spellings of "none" is a comparison bug
waiting to happen.
`depends:` is the authoritative graph and is always slugs. The body's
`## Depends on` prose is passed through **unchanged in both directions**: a pull
seeds `depends:` from the `#N` it finds there, and a push never rewrites what the
author wrote. Deliberate — a translator that edits prose churns the body on every
round trip.
## The id marker
The **one** thing this package adds to a body, and it adds it because the slug has
to survive a push: push deletes the local file, so the tracker has to be the thing
that remembers what the issue was called here.
- one place formats it (`IDMarker`), one regex reads it — and the regex accepts
more than the formatter writes, including the older `<!-- tea:id … -->`, because
issues pushed before the rename are still in the tracker;
- the **first** valid marker wins; a second is ignored and removed on the way in;
- the captured text must be a slug by the domain's own rule, so a mangled comment
falls back to the title instead of naming a file after garbage;
- it is stripped before anything else looks at the body, so checkboxes, `#N`
references and what lands on disk all see the body the author wrote.
## The checkbox merge
`MergeCheckboxState` is the one exception to "a pull overwrites the body", and
deliberately the narrowest one that works. **A tick is monotone** — an item only
travels `[ ]``[x]` — so the two sides are joined by a **set union**: no base
version, no drift tracking, no conflict to resolve. An item comes out ticked when
either side has it ticked; everything else in the body is still the remote's word.
Matching is on `Checkbox.Text`, which the domain parser has already stripped and
rejoined with single spaces, so rewrapping a long item does not cost it its tick.
It is otherwise literal: reword an item and it is a different item.
The same text more than once is read as the rule says, as a set — one ticked local
item ticks every remote item with that text. Pairing duplicates up by order is the
reading that can still drop a tick, and dropping a tick is the bug this exists to
fix.
## Labels, and the two write paths
Colours live here, not in the domain: a hex code is how a tracker paints a chip and
not what an issue is. `CanonicalLabelSpecs` is derived from the domain's own list
rather than restated, so adding a type over in the taxonomy creates it on the next
bootstrap with no line changing here but the colour. `DefaultColor` paints
everything outside the canonical set, because `tech/*` and `comp/*` are
project-specific and guessing a colour for one invents a meaning it does not have.
`LabelIDsFor` is exported so that a create and a repair cannot derive the answer
differently: **Gitea's edit endpoint carries no labels**, so an issue that already
exists gets its label set through a `PUT`, and a `PUT` that disagreed with what a
create would have sent would make a pushed issue and a re-pushed one two different
things. `nil` means "resolved no ids"; an **empty, non-nil** list means "resolved
some and matched none", which is a statement to the tracker — `[]` clears every
label on the issue.
## Purity, and the one weakening
`layering_test.go` checks **direct** imports and fails on `os`, `net/http`,
`os/exec`, `internal/gitea`, `internal/config` and `internal/project`; a second
test greps the sources for `time.Now`.
It does not walk the dependency closure, and it cannot: the SDK's types come with
the SDK's client attached, so the graph contains an HTTP client whatever this
package does with it. The full reasoning — and why `time` is allowed where it once
was not — is in [`internal/AGENTS.md`](../AGENTS.md).
## Keeping this file true
- **Scope:** every `.go` file here — the field table, the marker, the merge, the
colours.
- **Update it when** a field starts or stops crossing the boundary (the table is
the contract), a sync-owned metadata key is added, the marker spelling changes or
an older one stops being read, or the purity test is loosened.
- **Do not** put a request here that anything else could make. This package returns
values; [`gitea`](../gitea/AGENTS.md) sends them.
+107
View File
@@ -0,0 +1,107 @@
# AGENTS.md — internal/project (ROOT)
**One question: which directory is the project.** Everything that is a fact about
a project — the issue store, the request-payload scratchpad, the tracker config —
is resolved from the answer, and the answer is found by one walk written once.
This package **depends on nothing** but the standard library, and it is the only
one in the tree with no other package below it.
| file | what is in it |
|---|---|
| `project.go` | `Marker`, `Anchors`, `Parents`, `GitDirOf`, `MainWorktree`, `Root`, and the paths resolved from it — `StoreRoot`, `PayloadRoot`, `ConfigPath` — plus `NotFoundError` |
| `init.go` | `Init`: creates the marker, migrates an older layout in, gitignores `.kettle/`. `ClashError` is its refusal |
| `project_test.go` | the walk, including the worktree hop and the "no marker anywhere" answer |
## The walk
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`.
A marker, not a fixed number of `..` hops: how deep a caller sits below the root
is an implementation detail of the layout, and the layout is not a promise. Walking
up means every command sees one store from anywhere inside the project — including
from inside the store itself — while a `cd` into a *different* project correctly
answers with that project's store.
The worktree hop is one level of indirection, never two: a main checkout is not
itself a linked worktree, so it cannot chain and cannot cycle. Only a `.git` *file*
is a pointer; in an ordinary clone `.git` is a directory and there is nothing to
follow. A submodule's `.git` is a pointer too, but it points into
`<super>/.git/modules/…`, and `MainWorktree` refuses it on the `.git` basename
check — the tree it belongs to is already on the parent chain.
## Two rules that are not negotiable
**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. This is the whole reason the package exists
— the Python version resolved its store from `__file__` and wrote issues into a
versioned plugin cache.
**The marker is created by `kettle init`, never inferred.** `.git` was tried and is
in every clone, including this repository's own, which is how a plugin came to
resolve its store inside itself. No marker anywhere is an *answer*, not a fallback:
`NotFoundError` names the anchors the search began from — not the whole chain,
because an operator who sees the two places it started knows immediately whether it
started where they meant it to.
## Init, and the migration
`Init` is idempotent and every step announces itself, so `--dry-run` is the same
code path with the writes turned off:
- creates `.kettle/issues/` and `.kettle/payload/`;
- migrates an older store in, oldest layout first — `tmp/issues`, then
`.tea/issues`, and the same pair for `payload` — so a tree that skipped a
generation still lands in one place;
- adds `.kettle/` to `.gitignore`, unless some line already ignores it.
**Each migration is a move, never a copy.** Two stores is the state the marker
exists to prevent, and a store left behind at an old path is a store somebody will
edit by accident months later. When both sides hold a file of the same name it
stops with a `ClashError` naming up to five of them and changes nothing: two
versions of one issue, and which survives is not a decision a migration makes
quietly. The old `.tea` marker is removed only when the migration emptied it —
anything else parked in there is somebody's.
`.kettle/` is gitignored because an `origin: local` issue is the only copy of that
work and what goes into a shared history is the operator's call. Committing the
store is a legitimate choice; drop the line if the team makes it.
## Usage
```go
root := project.Root("") // "" when there is no project
store := project.StoreRoot("") // <root>/.kettle/issues
if store == "" {
return project.NotFoundError("") // names the directories it searched
}
```
A non-empty `start` overrides both anchors and exists so resolution can be
exercised against a scratch tree — which is what the test suite does, and why
every fixture also strips `CLAUDE_PROJECT_DIR`.
## What does not belong here
Anything that reads or writes an issue, a config file or a socket. This package
hands out **paths** and one answer about directories; the store is
[`issue`](../issue/AGENTS.md)'s, the config is
[`config`](../config/AGENTS.md)'s, and the scratchpad is filled by
[`gitea`](../gitea/AGENTS.md).
## Keeping this file true
- **Scope:** `project.go`, `init.go`, `project_test.go` — the walk, the marker, the
paths derived from it, and the migration.
- **Update it when** an anchor is added or reordered, the marker name changes, a
new path is resolved under the marker (the file table and the walk section both
name them), a legacy layout is added to or dropped from the migration list, or
the worktree rule changes.
- **Do not** document what any resolved path is *used for*; that belongs to the
package that uses it.
+69
View File
@@ -0,0 +1,69 @@
# AGENTS.md — internal/wire (ADDRESSES)
**How this project addresses one repository and one issue, and nothing else.**
Two types, `Repo` and `Key`, and the parsing that reads them.
**Imports the standard library and nothing else** — no HTTP, no filesystem, no
configuration, no SDK, and above all not [`issue`](../issue/AGENTS.md). An
identifier that reached for any of those would drag every user of it into that
layer. Two tests hold it, see [`internal/AGENTS.md`](../AGENTS.md).
| file | what is in it |
|---|---|
| `key.go` | `Repo`, `Key`, `ParseRepo`, `ParseKey`, `Key.In`, and the `String` methods |
| `key_test.go` | every spelling above, and what a malformed one answers |
| `layering_test.go` | the two tests that keep this package at the bottom |
## Four spellings, one address
```go
wire.ParseKey("42") // Key{Number: 42} — repo zero: "this project's"
wire.ParseKey("#42") // the same, copied out of a body
wire.ParseKey("owner/repo#42") // qualified, out of the ledger
wire.ParseKey("https://git.example.com/owner/repo/issues/42")
```
All four because all four are what somebody has in hand — a number from a receipt,
a `#42` copied out of an issue body, a qualified key out of the ledger, a URL pasted
from a browser. Refusing three of them buys nothing.
**`Key.Repo` is zero when the caller named a number and nothing else**, which is the
common case on a command line: `42` means "42 in this project's repository", and
which repository that is, is the client's business. `Key.In(repo)` fills it in.
`Repo.Zero()` requires both halves — half a name addresses nothing.
## Why a key is not a bare number
`Key` has to survive being written to a file and read back: it is what the
number → slug ledger is keyed by and what the `gitea:` metadata field holds. **A
number is ambiguous the moment a dependency lives in another repository, and
dependencies are allowed to.** So a key is a repository and a number, always, and
`String()` spells it `owner/repo#42`.
## Why this package still exists after the SDK
The JSON shapes used to live here too, because the transport and the bridge both
had to name a Gitea issue and neither may import the other. They are
`code.gitea.io/sdk/gitea`'s now.
**What the SDK has no answer for is addressing.** It takes an owner, a name and an
`int64`, and never parses. So the parsing stays, and so does the pair of types it
produces — the values that go into the ledger, into the `gitea:` field, and into
every receipt. The longer version of that history is in
[`internal/AGENTS.md`](../AGENTS.md).
## What does not belong here
Anything that *does* something with an address: fetching, storing, resolving a
repository from configuration. This package parses and prints. Callers are
[`gitea`](../gitea/AGENTS.md), [`mapping`](../mapping/AGENTS.md) and
[`cmd`](../cmd/AGENTS.md).
## Keeping this file true
- **Scope:** `key.go` and its tests — the two types and the spellings they accept.
- **Update it when** a spelling is added or dropped, a type gains a field, or the
zero-value meaning of `Key.Repo` changes.
- **Do not** add a third type here without an argument for why it is an *address*.
Anything that is a payload belongs to the SDK; anything that is a fact about work
belongs to the domain.
+53
View File
@@ -0,0 +1,53 @@
# AGENTS.md — plugins/
One directory per plugin, and each is self-contained: **a plugin's root is its
directory here**, so `${CLAUDE_PLUGIN_ROOT}` resolves inside it and every path a
plugin uses stays relative to itself.
| plugin | what it is |
|---|---|
| [`kettle/`](kettle/AGENTS.md) | issues as local markdown, driven by the `kettle` binary in [`cli/`](../cli/AGENTS.md). Skills, a runner subagent, two hooks |
| [`tdl/`](tdl/AGENTS.md) | Three Dots Labs Go conventions as an enforceable rule set. One skill, no binary, no state |
## Adding one
Two edits, and nothing else in the repository needs to know:
1. a directory here holding `.claude-plugin/plugin.json`, plus whatever of
`skills/`, `agents/`, `hooks/`, `commands/` it needs;
2. one entry in [`.claude-plugin/marketplace.json`](../.claude-plugin/marketplace.json)
whose `source` is the path into this directory.
**The catalog entry's `name` and the directory name must match, and both must match
`plugin.json`.** They are three spellings of one identity: the catalog resolves a
`source` path, the manifest names the plugin, and the directory is what
`${CLAUDE_PLUGIN_ROOT}` becomes. A rename that misses one of the three installs a
plugin that cannot find its own files — which is exactly what happened when `tea`
became `kettle`.
The catalog's `description` is what somebody reads in `/plugin`; the manifest's is
what the installed plugin reports. Keep them saying the same thing.
## What a plugin may and may not do
- **A skill is prose an agent loads on demand.** Its frontmatter `description:` is
the only thing that decides whether it gets loaded at all, which makes it the one
line worth tuning against real failures — and the one line no generator may write.
- **Heavy reference material goes in `references/`**, not in the SKILL.md. The skill
says when to read it.
- **A hook must fail open.** Both of `kettle`'s exit 0 on every path, including
their own bugs: a plugin that breaks a user's Bash call because its documentation
helper crashed is worse than no plugin.
- **State belongs to the project, never to the plugin directory.** A plugin lives in
a versioned cache that changes path on every update; anything written inside it is
lost or invisible from the next project. This is not hypothetical — it is the bug
that motivated rewriting `kettle`'s Python scripts as a binary, and the whole
reason a project carries a `.kettle/` marker.
## Keeping this file true
- **Scope:** what a plugin directory is, how the catalog points at it, and the rules
common to all of them. The table names every plugin here.
- **Update it when** a plugin is added or removed or renamed, or when the catalog
gains a field a plugin author has to fill in.
- **Do not** describe an individual plugin's skills or design; each has its own file.
+24 -4
View File
@@ -31,8 +31,9 @@ go install git.noodles.cam/claude-skills/marketplace/cli/cmd/kettle@latest
``` ```
`cli/go.mod` says **go 1.26** — the Gitea SDK requires it, so that is the minimum `cli/go.mod` says **go 1.26** — the Gitea SDK requires it, so that is the minimum
for anybody building this. `vendor/` is committed, so a build from a clone needs for anybody building this. There is no `vendor/`: a build resolves its modules from
no network. the module cache or the network, and `go.sum` is what makes that safe. Why that
trade was taken is in [`cli/AGENTS.md`](../../cli/AGENTS.md).
An operator who sees `command not found: kettle` installs it and re-runs; there An operator who sees `command not found: kettle` installs it and re-runs; there
is nothing to configure in this plugin either way. `kettle config` is the command is nothing to configure in this plugin either way. `kettle config` is the command
@@ -89,7 +90,7 @@ agents/
kettle-runner.md subagent (Haiku): runs kettle commands, reports a kettle-runner.md subagent (Haiku): runs kettle commands, reports a
receipt. Batches only, and no opinions about content receipt. Batches only, and no opinions about content
hooks/ hooks/
hooks.json registers the PreToolUse hooks hooks.json registers the one hook: PreToolUse(Bash)
agents-sync.sh keeps every directory canonical: AGENTS.md a real agents-sync.sh keeps every directory canonical: AGENTS.md a real
file, CLAUDE.md a symlink to it file, CLAUDE.md a symlink to it
skills/ skills/
@@ -138,7 +139,12 @@ and it stays hand-written.
| `.claude/settings.local.json``env.GITEA_LOGIN`, the login pin | `<project>/.kettle/config.yaml` (a login **name**) plus `~/.config/kettle/logins.yaml` (the tokens, 0600, outside every working tree) | | `.claude/settings.local.json``env.GITEA_LOGIN`, the login pin | `<project>/.kettle/config.yaml` (a login **name**) plus `~/.config/kettle/logins.yaml` (the tokens, 0600, outside every working tree) |
| the tea plugin's own store marker | `.kettle/`; `kettle init` migrates an older layout in, and each migration is a move | | the tea plugin's own store marker | `.kettle/`; `kettle init` migrates an older layout in, and each migration is a move |
`hooks/agents-sync.sh` is unrelated to any of that and stays. `hooks/agents-sync.sh` is unrelated to any of that and stays. It maintains the
`AGENTS.md` convention by repairing the filesystem layout — a real file, with
`CLAUDE.md` a symlink to it — and the convention itself is documented once,
in [the repository root's AGENTS.md](../../AGENTS.md#the-agentsmd-convention). It
cannot fail a tool call: it exits 0 on every path, including its own bugs —
documentation maintenance is not permitted to break a build.
All of it is in git history. `git log --diff-filter=D` finds it if a decision All of it is in git history. `git log --diff-filter=D` finds it if a decision
needs to be re-read rather than re-derived. needs to be re-read rather than re-derived.
@@ -154,3 +160,17 @@ cd cli && go test ./...
The second line is the plugin's only mechanical invariant: the documentation an The second line is the plugin's only mechanical invariant: the documentation an
agent reads agrees with the binary it is documenting. agent reads agrees with the binary it is documenting.
## Keeping this file true
- **Scope:** everything under `plugins/kettle/` — the skills, the runner subagent,
the two hooks, and the group ⇄ skill seam. `skills/` and `agents/` carry no
AGENTS.md of their own on purpose: a SKILL.md already states its own procedure,
and a second document beside it would be the copy that goes stale.
- **Update it when** a skill is added or removed (the layout and the generated-region
table both name every one), a hook is added or its event changes, a rule in *the
rules that must survive* changes, or the binary takes over something this plugin
used to state.
- **Do not** restate the binary's design. [`cli/AGENTS.md`](../../cli/AGENTS.md) and
the eight package files under it own that; if the two ever disagree, the binary is
right and the prose is stale.
+4 -3
View File
@@ -27,7 +27,7 @@ the tool.
- **Claude Code** — CLI, desktop app, or IDE extension. - **Claude Code** — CLI, desktop app, or IDE extension.
- **The `kettle` binary, on your `PATH`.** It is not installed for you, and - **The `kettle` binary, on your `PATH`.** It is not installed for you, and
nothing here works without it — see below. nothing here works without it — see below.
- **Python 3** — the `agents-sync` hook is a Python script; `python3` must be on - **Python 3** — the two `agents-*` hooks are Python scripts; `python3` must be on
`$PATH`. Nothing else here needs it. `$PATH`. Nothing else here needs it.
- **`tea`** (optional) — Gitea's own CLI, only for `/kettle:use`. `brew install - **`tea`** (optional) — Gitea's own CLI, only for `/kettle:use`. `brew install
tea`, or from [gitea.com/gitea/tea/releases](https://gitea.com/gitea/tea/releases). tea`, or from [gitea.com/gitea/tea/releases](https://gitea.com/gitea/tea/releases).
@@ -36,7 +36,8 @@ the tool.
### Installing the binary ### Installing the binary
Building it needs **Go 1.26** — `cli/go.mod` says so because the Gitea SDK Building it needs **Go 1.26** — `cli/go.mod` says so because the Gitea SDK
requires it. `vendor/` is committed, so the build itself needs no network. requires it. There is no `vendor/` directory: the first build downloads eight
modules, verified against `go.sum`.
```bash ```bash
# from a clone of this repository # from a clone of this repository
@@ -159,7 +160,7 @@ dependency come back as a question, not as a guess.
agents/ agents/
kettle-runner.md subagent (Haiku) that runs the commands kettle-runner.md subagent (Haiku) that runs the commands
hooks/ hooks/
hooks.json registers the PreToolUse hooks hooks.json registers the one hook
agents-sync.sh keeps AGENTS.md real and CLAUDE.md a symlink to it agents-sync.sh keeps AGENTS.md real and CLAUDE.md a symlink to it
skills/ skills/
init/ /kettle:init — make a directory a project init/ /kettle:init — make a directory a project
+25
View File
@@ -158,4 +158,29 @@ kettle init --login noodles --repo claude-skills/marketplace # and point it at
kettle init --at ~/code/x # initialize somewhere else kettle init --at ~/code/x # initialize somewhere else
kettle init --dry-run # say what it would do, touch nothing kettle init --dry-run # say what it would do, touch nothing
``` ```
## `kettle version`
print the version this binary was built as
A binary that cannot say what it is, is a support problem: an operator with an
old copy on PATH and a skill written against a newer one has no way to see the
mismatch, and neither does anybody reading their transcript.
The version is stamped at link time. A build from source says "dev" and means
it — that is not a placeholder to be edited, it is the answer for a binary that
came out of somebody's working tree rather than off a tag.
The commit is reported when the build recorded one, which `go build` does from
git and a build from an unpacked tarball cannot. A tree with uncommitted
changes in it says so beside the commit.
| flag | default | what it does |
| --- | --- | --- |
| `--short` | `false` | print the version alone, with nothing around it |
```bash
kettle version # the version, the toolchain and the commit
kettle version --short # just the version, for a script
```
<!-- /kettle:gen --> <!-- /kettle:gen -->
+65
View File
@@ -0,0 +1,65 @@
# AGENTS.md — the tdl plugin
**Three Dots Labs Go conventions as an enforceable rule set.** One skill,
`/tdl:audit`, which does two things with the same body of rules: audits a Go project
against them, or scaffolds code that already follows them.
No binary, no hooks, no state, no network. Everything here is markdown an agent
reads.
```
.claude-plugin/plugin.json the manifest
skills/audit/
SKILL.md the procedure: parse args, load rules, audit or scaffold
references/rules-*.md nine rule files, loaded in full before any operation
templates/*.md nine scaffolds, one per generated artefact
```
## The rules
Nine files, by subject: `architecture`, `domain`, `cqrs`, `repository`, `errors`,
`ports`, `naming`, `codestyle`, `watermill`. They encode the conventions of the
`wild-workouts-go-ddd-example` reference implementation and the four canonical Three
Dots Labs articles — DDD Lite in Go, Introducing Clean Architecture, Basic CQRS in
Go, Repository Pattern in Go.
**The skill loads all nine before it does anything**, in parallel, and the reason is
that the rules cross-reference each other: a naming finding is only correct in the
light of the layer the file sits in, and an audit that read one file would report
violations of a rule it had not read the exception to.
Findings are reported **by severity**, and the severity is part of the rule, not a
judgement made at report time. That is what makes the output usable: a run that
flags everything equally is a run somebody stops reading.
## The templates
Nine, one per artefact the scaffolder can produce: `service`, `command`, `query`,
`entity`, `repo`, `unified_server`, `watermill_router`, `event_handler`,
`event_publisher`.
They exist so that "generate a handler" and "audit a handler" cannot disagree —
**a template that would fail the audit is a bug in the template.** When a rule
changes, the templates it touches change in the same edit; that is the plugin's only
real invariant and nothing mechanical checks it.
## Usage
```
/tdl:audit # audit the working directory
/tdl:audit ./internal/orders # audit one path
/tdl:audit scaffold command PlaceOrder # generate from a template
/tdl:audit scaffold service Billing
```
The scaffold types are listed in the skill's argument parsing, and an argument that
matches no pattern gets usage help rather than a guess.
## Keeping this file true
- **Scope:** this plugin's layout, the two things the skill does, and the
template ⇄ rule invariant. Files: `SKILL.md`, `references/`, `templates/`.
- **Update it when** a rule file or a template is added or removed (both lists above
name every one), a scaffold type is added, or the skill grows a third mode.
- **Do not** copy rule content here. The references are the rules; this file says
what they are for.