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
+206
View File
@@ -0,0 +1,206 @@
package project
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Initializing is a statement, and the only one that matters here: *this*
// directory is the project whose issues live in it. It is answered once, by a
// person, and everything downstream reads the answer instead of guessing.
//
// The marker is deliberately something an operator makes, not something
// inferred from the tree: `.git` is in every clone including this repository's
// own, so a plugin that inferred its root from one wrote issues into itself.
// Layouts this has been through, migrated in on init in the order listed —
// oldest first, so a tree that skipped a generation still lands in one place.
//
// Each 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.
var legacy = map[string][]string{
"issues": {
filepath.Join("tmp", "issues"),
filepath.Join(".tea", "issues"),
},
"payload": {
filepath.Join("tmp", "payload"),
filepath.Join(".tea", "payload"),
},
}
// ClashError reports that a migration found the same name on both sides.
//
// Two versions of one issue, and which one survives is not a decision a
// migration gets to make quietly.
type ClashError struct {
Src, Dst string
Names []string
}
func (e *ClashError) Error() string {
names := e.Names
suffix := ""
if len(names) > 5 {
suffix = fmt.Sprintf(" (+%d more)", len(names)-5)
names = names[:5]
}
return fmt.Sprintf("%s and %s both hold %s%s — move or delete one side first; nothing was changed",
e.Src, e.Dst, strings.Join(names, ", "), suffix)
}
// Init makes root a project. Everything it does is idempotent:
//
// - creates .tea/issues/ and .tea/payload/
// - moves an existing tmp/issues/ and tmp/payload/ in, if it finds them
// - adds .tea/ to .gitignore
//
// The move is the migration off the old layout and it is a move, not a copy:
// two stores is the state the marker exists to prevent, and a store left behind
// at the old path is a store somebody will edit by accident.
//
// .tea/ is gitignored because an `origin: local` issue is the only copy of that
// work and the operator, not this command, decides what goes in a shared
// history. Committing the store is a legitimate choice — drop the line if you
// make it.
//
// Returns one line per thing done, for the receipt.
func Init(root string, dryRun bool) ([]string, error) {
var done []string
marker := filepath.Join(root, Marker)
fresh := !isDir(marker)
for _, name := range []string{"issues", "payload"} {
d := filepath.Join(marker, name)
if isDir(d) {
continue
}
if !dryRun {
if err := os.MkdirAll(d, 0o755); err != nil {
return done, err
}
}
done = append(done, "created "+filepath.Join(Marker, name))
}
for _, name := range []string{"issues", "payload"} {
for _, old := range legacy[name] {
src := filepath.Join(root, old)
moved, err := migrate(src, filepath.Join(marker, name), dryRun)
if err != nil {
return done, err
}
switch {
case moved == nil:
// nothing there to migrate
case len(moved) == 0:
done = append(done, old+" was empty — nothing to move")
default:
done = append(done, fmt.Sprintf("moved %d file(s) from %s to %s",
len(moved), old, filepath.Join(Marker, name)))
}
}
}
// The old marker goes only when the migration emptied it — anything else
// parked in there is somebody's, and this is not the command that decides
// what.
if !dryRun {
os.Remove(filepath.Join(root, ".tea"))
}
added, err := addToGitignore(filepath.Join(root, ".gitignore"), Marker+"/", dryRun)
if err != nil {
return done, err
}
if added {
done = append(done, "added "+Marker+"/ to .gitignore")
}
switch {
case len(done) == 0:
done = append(done, "already initialized — nothing to do")
case fresh:
done = append(done, fmt.Sprintf("%s now tracks issues in %s/issues", root, Marker))
}
return done, nil
}
// migrate moves the CONTENTS of src into dst — contents, not the directory, so
// an already-created destination is not a reason to refuse. Returns the names
// moved, or nil when there was nothing to migrate.
func migrate(src, dst string, dryRun bool) ([]string, error) {
if !isDir(src) {
return nil, nil
}
entries, err := os.ReadDir(src)
if err != nil {
return nil, err
}
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name())
}
sort.Strings(names)
if len(names) == 0 {
return []string{}, nil
}
var clashes []string
for _, n := range names {
if _, err := os.Lstat(filepath.Join(dst, n)); err == nil {
clashes = append(clashes, n)
}
}
if len(clashes) > 0 {
return nil, &ClashError{Src: src, Dst: dst, Names: clashes}
}
if dryRun {
return names, nil
}
if err := os.MkdirAll(dst, 0o755); err != nil {
return nil, err
}
for _, n := range names {
if err := os.Rename(filepath.Join(src, n), filepath.Join(dst, n)); err != nil {
return nil, err
}
}
os.Remove(src) // only succeeds when we emptied it, which is the intent
return names, nil
}
// addToGitignore appends entry unless some line already ignores it.
func addToGitignore(path, entry string, dryRun bool) (bool, error) {
var lines []string
if raw, err := os.ReadFile(path); err == nil {
lines = strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n")
} else if !os.IsNotExist(err) {
return false, err
}
want := strings.TrimSuffix(entry, "/")
for _, line := range lines {
if strings.TrimSuffix(strings.TrimSpace(line), "/") == want {
return false, nil
}
}
if dryRun {
return true, nil
}
trailer := "\n"
if len(lines) == 0 || lines[len(lines)-1] == "" {
trailer = ""
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return false, err
}
defer f.Close()
if _, err := f.WriteString(trailer + entry + "\n"); err != nil {
return false, err
}
return true, nil
}