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
+110
View File
@@ -0,0 +1,110 @@
package mapping
import (
"regexp"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/issue"
)
// The id marker: the slug, kept tracker-side.
//
// Push deletes the local file once the tracker has confirmed the write, so the
// slug — the issue's ONLY identity in the domain — cannot live only on this
// machine any more. It rides up in the body as an HTML comment:
//
// <!-- kettle:id wire-sqlc-appclick -->
//
// Why the body and not a local number -> slug ledger: the ledger is a local
// file, and "the local copy is not the record" is the whole point of deleting
// one. A marker in the body survives a rename in the web UI, a lost ledger, a
// fresh clone, and a second machine — none of which the ledger does. Why an
// HTML comment: Gitea renders markdown, so it is invisible to a human reader,
// and it comes back verbatim on every API read.
//
// WHERE: the first line of the tracker-side body, followed by one blank line.
// First because it is the one position that does not depend on what sections
// the issue happens to have, and because a human who does look at the raw
// markdown finds it before the prose rather than buried in it.
//
// WHAT THE LOCAL FILE SEES: nothing. FromPayload strips every marker before the
// body reaches the store, so `.kettle/issues/<id>.md` holds exactly what the
// author wrote — checkbox line numbers, `kettle check`, and diffs are all
// unaffected, and the slug is already the file's name, so a copy of it in the
// body would be duplicated state.
//
// WHY IT CANNOT ACCUMULATE: the two operations are strip-all and
// strip-all-then-prepend-one. WithIDMarker never appends to what is there, and
// StripIDMarker removes EVERY marker line, not the first. So a body that
// somehow gained two (a hand-edit in the web UI, a copy-paste) is cleaned on
// the next pull and goes back up with exactly one. There is no code path that
// adds a marker to a body that has not just been stripped.
//
// WHY TWO SPELLINGS ARE READ AND ONE IS WRITTEN: this tool was called `tea`
// and wrote `<!-- tea:id … -->`. Issues pushed under that name are sitting in
// the tracker right now, and their local files are gone — the marker is the
// only copy of their slug there is. A rename that stopped reading the old
// spelling would orphan every one of them: the pull would fall back to the
// title, allocate a fresh slug, and every `depends:` pointing at the old one
// would dangle. So the writer moved and the reader did not.
var markerRe = regexp.MustCompile(
`^[ \t]*<!--[ \t]*(?:kettle|tea):id[ \t]+(\S+)[ \t]*-->[ \t]*$`)
// IDMarker is the marker line for a slug. One place formats it, one regex
// reads it — and what that regex accepts is deliberately wider than this.
func IDMarker(id string) string { return "<!-- kettle:id " + id + " -->" }
// IDInBody is the slug a tracker-side body claims, or "" when it claims none.
//
// The FIRST valid marker wins; a second one is ignored here and removed by
// StripIDMarker on the way in. The captured text must be a slug by the domain's
// own rule — a marker holding anything else is not a slug and is treated as if
// it were not there, so a mangled comment falls back to the title instead of
// naming a file after garbage.
func IDInBody(body string) string {
for _, line := range strings.Split(body, "\n") {
if m := markerRe.FindStringSubmatch(strings.TrimSuffix(line, "\r")); m != nil {
if issue.IsSlug(m[1]) {
return m[1]
}
}
}
return ""
}
// StripIDMarker is body with every marker line removed, in either spelling.
// Idempotent.
//
// A body that carries no marker is returned byte for byte — the common case (an
// issue filed in the web UI) costs nothing and is not reformatted. When a marker
// is removed from the top, the blank line it was written with goes with it, so
// the round trip is exact: StripIDMarker(WithIDMarker(b, id)) == b.
func StripIDMarker(body string) string {
lines := strings.Split(body, "\n")
found := false
for _, line := range lines {
if markerRe.MatchString(strings.TrimSuffix(line, "\r")) {
found = true
break
}
}
if !found {
return body
}
kept := make([]string, 0, len(lines))
for _, line := range lines {
if !markerRe.MatchString(strings.TrimSuffix(line, "\r")) {
kept = append(kept, line)
}
}
return strings.TrimLeft(strings.Join(kept, "\n"), "\n")
}
// WithIDMarker is body with exactly one marker, as its first line.
//
// Strip-then-prepend, always — that is the guarantee that a body can never end
// up with two, however many it arrived with, and it is what quietly rewrites a
// `tea:id` marker into the current spelling the next time the issue is pushed.
func WithIDMarker(body, id string) string {
return IDMarker(id) + "\n\n" + StripIDMarker(body)
}