feat: add the kettle CLI, replacing the plugin's Python scripts

The plugin resolved its issue store from `__file__`, which put it inside a
versioned plugin cache: issues written from one project were invisible from the
next, and `origin: local` files — the only copy of that work by definition —
were stranded a version bump at a time. The walk that answers "which directory
is the project" was written three times over, and in a linked worktree the three
disagreed. Both are runtime failures rather than logic ones, so the fix is a
compiled binary: one walk, imported rather than re-derived, and a layering rule
the build graph enforces instead of a grep.

Seven packages, knowledge flowing one way. `project` answers which directory is
the project and depends on nothing. `issue` is the domain — format, taxonomy,
validation, checkboxes, dependency graph, the store, eviction — offline, with no
tracker in it. `wire` holds the protocol shapes. `gitea` is the transport,
`mapping` the bridge, `config` the credentials, `cmd` the command tree. Four
tests hold the boundaries, each failing on a real mistake rather than a naming
convention.

The marker moves to `.kettle/` and the login pin moves out of the harness's
settings file into `.kettle/config.yaml`, which pins a login by NAME; the tokens
live in one file per machine, mode 0600, outside every working tree. That
retires the PreToolUse guard hook entirely — the binary holds its own
credentials, so a command running under a login nobody chose is not expressible
rather than caught.

`kettle init` migrates an older `tmp/issues` or `.tea/issues` store in, as a
move: a store left behind at an old path is one somebody edits by accident
months later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-11 19:05:39 +05:00
parent fb5445915f
commit 9480e48312
83 changed files with 23894 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
package mapping
import (
"strings"
"testing"
"git.noodles.cam/claude-skills/marketplace/cli/internal/wire"
)
func TestIDInBodyReadsBothSpellings(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{"the spelling this tool writes", "<!-- kettle:id wire-sqlc -->\n\n## Summary\nx", "wire-sqlc"},
// The whole reason the reader is wider than the writer.
{"the spelling already in the tracker", "<!-- tea:id wire-sqlc -->\n\n## Summary\nx", "wire-sqlc"},
{"indented and loosely spaced", " <!-- tea:id wire-sqlc --> \n", "wire-sqlc"},
{"no marker at all", "## Summary\nx", ""},
// A mangled comment falls back to the title rather than naming a file
// after garbage.
{"not a slug", "<!-- kettle:id Wire_SQLC -->\n", ""},
{"not on a line of its own", "text <!-- kettle:id wire-sqlc -->\n", ""},
{"the first valid marker wins", "<!-- tea:id first-one -->\n<!-- kettle:id second-one -->\n", "first-one"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := IDInBody(c.body); got != c.want {
t.Errorf("IDInBody = %q, want %q", got, c.want)
}
})
}
}
// An issue pushed under the old name is sitting in the tracker with its local
// file long since deleted — the marker is the only copy of its slug there is.
// It has to keep resolving, and it has to come back up in the new spelling.
func TestAnIssuePushedByTheOldNameStillResolves(t *testing.T) {
const inTracker = "<!-- tea:id wire-sqlc-appclick -->\n\n## Summary\nПроводка sqlc.\n"
id := IDInBody(inTracker)
if id != "wire-sqlc-appclick" {
t.Fatalf("IDInBody = %q — every issue pushed under the old name would be orphaned", id)
}
iss, _ := FromPayload(&wire.Issue{Number: 42, Title: "Wire sqlc", Body: inTracker},
id, tea, PayloadOptions{})
if strings.Contains(iss.Body, "tea:id") {
t.Errorf("the old marker reached the local copy: %q", iss.Body)
}
if iss.Body != "## Summary\nПроводка sqlc." {
t.Errorf("body = %q", iss.Body)
}
// And the next push rewrites it into the current spelling, without ever
// having two.
up := *ToRequest(iss, RequestOptions{}).Body
if !strings.HasPrefix(up, "<!-- kettle:id wire-sqlc-appclick -->\n\n") {
t.Errorf("the marker was not rewritten: %q", up)
}
if strings.Contains(up, "tea:id") {
t.Errorf("both spellings went up: %q", up)
}
if n := strings.Count(up, ":id "); n != 1 {
t.Errorf("%d markers in the body, want 1", n)
}
}
func TestMarkersCannotAccumulate(t *testing.T) {
body := "## Summary\nx"
// Whatever it arrived with — one, the other, several — it goes up with one.
messy := "<!-- tea:id old-one -->\n\n<!-- kettle:id other-one -->\n\n" + body
got := WithIDMarker(messy, "real-one")
if want := IDMarker("real-one") + "\n\n" + body; got != want {
t.Errorf("got:\n%q\nwant:\n%q", got, want)
}
if got := WithIDMarker(got, "real-one"); strings.Count(got, "<!--") != 1 {
t.Errorf("a second pass added one: %q", got)
}
}
func TestStripIsTheExactInverseOfWith(t *testing.T) {
bodies := []string{
"## Summary\nx",
"## Summary\nx\n\n## Acceptance criteria\n- [ ] один\n",
"",
}
for _, b := range bodies {
if got := StripIDMarker(WithIDMarker(b, "an-id")); got != b {
t.Errorf("StripIDMarker(WithIDMarker(%q)) = %q", b, got)
}
}
}
// The common case — an issue filed in the web UI — costs nothing and is not
// reformatted.
func TestStripLeavesAnUnmarkedBodyByteForByte(t *testing.T) {
body := "\n\n## Summary\nx\n\n\n"
if got := StripIDMarker(body); got != body {
t.Errorf("StripIDMarker rewrote a body with no marker in it: %q", got)
}
}