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
+209
View File
@@ -0,0 +1,209 @@
package issue
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
)
// The store holds two kinds of file, and only one of them is a store.
//
// An issue whose origin is Local lives here and nowhere else — that file IS the
// issue, and losing it loses the work. Anything with a tracker origin is a
// cache: the tracker has it, this copy is a working copy, and it is deleted the
// moment a push confirms the tracker is up to date.
// Root resolves the issue store for the current project. An explicit out
// overrides it and is used exactly as typed: a relative out stays relative to
// the working directory, because that is what the operator asked for.
func Root(out string) string {
if out != "" {
return out
}
return project.StoreRoot("")
}
// ErrStoreMissing marks the "the store directory is not there" failure.
//
// Deliberately a different answer from "the store is empty". One is a path that
// does not exist, the other is a repository with no issues filed yet, and
// conflating the two is exactly what made a missed directory look like an empty
// backlog.
var ErrStoreMissing = errors.New("store missing")
// StoreExists reports whether root is a directory that can be read as a store.
func StoreExists(root string) bool {
if root == "" {
return false
}
fi, err := os.Stat(root)
return err == nil && fi.IsDir()
}
// RequireStore asserts the store is there before reading or writing it.
//
// An empty root means no project was found at all — a different failure from a
// project whose store has not been created yet, and the message says so.
func RequireStore(root string) error {
if root == "" {
return fmt.Errorf("%w: %s", ErrStoreMissing, project.NotFoundError(""))
}
if !StoreExists(root) {
return fmt.Errorf("%w: store %s does not exist", ErrStoreMissing, root)
}
return nil
}
// CreateStore creates the store, reporting whether it made the directory.
//
// Only the commands that legitimately bootstrap a store call this — `new` and
// `pull` — and both announce it. Nothing creates a store as a side effect of a
// write: a missing directory is something to report, not something to conjure.
// An unresolved root is never conjured either — without a marker there is no
// project to create a store IN, and guessing one is how a store once ended up
// inside the plugin.
func CreateStore(root string) (bool, error) {
if root == "" {
return false, fmt.Errorf("%w: %s", ErrStoreMissing, project.NotFoundError(""))
}
if StoreExists(root) {
return false, nil
}
if err := os.MkdirAll(root, 0o755); err != nil {
return false, err
}
return true, nil
}
// StoreError says why root cannot be read as a store, or nil when it holds
// issues.
//
// The three messages are distinct on purpose — no project at all, a project
// with no store, and a store with nothing in it are three different things to
// do next.
func StoreError(root string) error {
switch {
case root == "":
return project.NotFoundError("")
case !StoreExists(root):
return fmt.Errorf("store %s does not exist — nothing was created; pass --out to point elsewhere", root)
case len(AllIDs(root)) == 0:
return fmt.Errorf("store %s exists but is empty", root)
}
return nil
}
// PathOf is where the issue with this id lives.
func PathOf(root, id string) string { return filepath.Join(root, id+".md") }
// AllIDs lists every issue in the store, by slug.
//
// An issue file is named by its slug and a slug has no dot in it, so
// `<id>.comments.md` — the thread the sync layer parks beside an issue — is not
// one, and neither is anything else that grew a second extension. Without that
// rule `wire-sqlc.comments` reads as an issue called `wire-sqlc.comments`, and
// a bare push tries to file the comment thread as a unit of work.
func AllIDs(root string) []string {
if !StoreExists(root) {
return nil
}
entries, err := os.ReadDir(root)
if err != nil {
return nil
}
var out []string
for _, e := range entries {
name := e.Name()
if !strings.HasSuffix(name, ".md") {
continue
}
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "INDEX") ||
strings.HasPrefix(name, "tree-") {
continue
}
id := name[:len(name)-3]
if strings.Contains(id, ".") {
continue
}
out = append(out, id)
}
sort.Strings(out)
return out
}
// SlugFiles lists every file the store holds under one slug — the issue and its
// sidecars.
//
// `<id>.md` is the issue. Anything named `<id>.<something>` beside it is a
// companion another layer parked there (`<id>.comments.md` is the one that
// exists today). AllIDs already refuses to read those as issues because a slug
// has no dot in it; this is the same rule read the other way round.
//
// Which is how the domain can remove an issue completely without learning what
// any of those companions are: it does not need to know that a comment thread
// exists to know that a file named after this issue belongs to it and goes when
// it goes. The issue's own file comes first — it is the headline of any receipt
// printed from this list.
//
// A missing store is an empty list, not an error: nothing is there to remove.
func SlugFiles(root, id string) []string {
entries, err := os.ReadDir(root)
if err != nil {
return nil
}
prefix, own := id+".", id+".md"
var self, sidecars []string
for _, e := range entries {
name := e.Name()
if !strings.HasPrefix(name, prefix) || e.IsDir() {
continue
}
p := filepath.Join(root, name)
if name == own {
self = append(self, p)
} else {
sidecars = append(sidecars, p)
}
}
sort.Strings(sidecars)
return append(self, sidecars...)
}
// Load reads one issue. The file name wins over the id in the metadata block.
func Load(root, id string) (*Issue, error) {
raw, err := os.ReadFile(PathOf(root, id))
if err != nil {
return nil, err
}
return FromText(string(raw), id), nil
}
// LoadAll reads the whole store.
func LoadAll(root string) (map[string]*Issue, error) {
out := map[string]*Issue{}
for _, id := range AllIDs(root) {
i, err := Load(root, id)
if err != nil {
return nil, err
}
out[id] = i
}
return out, nil
}
// Save writes an issue to the store, which must already exist.
func Save(root string, i *Issue) (string, error) {
if err := RequireStore(root); err != nil {
return "", err
}
p := PathOf(root, i.ID)
if err := os.WriteFile(p, []byte(i.Text()), 0o644); err != nil {
return "", err
}
return p, nil
}