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
}
+245
View File
@@ -0,0 +1,245 @@
// Package project answers one question: which directory is the project.
//
// Everything that is a fact about the project — the issue store, the request
// payload scratchpad, the tracker the issues belong to — is resolved from the
// answer, and the answer is found by one walk written once. The guard, the
// transport and the store used to each have their own copy of that walk in
// Python, and they disagreed: in a linked worktree `tea` worked while every
// script reported no login pinned.
//
// This package depends on nothing but the standard library, and nothing in it
// resolves from the binary's own location. Where an installation keeps its
// files is a fact about the installation; which 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.
package project
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Marker is the directory an operator creates to state "this is a project".
// It is never inferred. `.git` was tried and is in every clone, including this
// repository's own, so a plugin resolved its store inside itself.
const Marker = ".kettle"
// Everything under the marker, each resolved by the same walk so that which
// command wrote a file cannot change where it landed.
var (
storeParts = []string{Marker, "issues"}
payloadParts = []string{Marker, "payload"}
configParts = []string{Marker, "config.yaml"}
)
// Anchors are the directories a root search starts from, in order, first hit
// wins: the project the agent harness was opened on, then the working
// directory. A non-empty start overrides both and exists so resolution can be
// exercised against a scratch tree.
func Anchors(start string) []string {
if start != "" {
abs, err := filepath.Abs(start)
if err != nil {
return nil
}
return []string{abs}
}
var out []string
for _, d := range []string{os.Getenv("CLAUDE_PROJECT_DIR"), cwd()} {
if d == "" || !isDir(d) {
continue
}
abs, err := filepath.Abs(d)
if err != nil {
continue
}
if !contains(out, abs) {
out = append(out, abs)
}
}
return out
}
// Parents yields start and every ancestor of it, up to the filesystem root.
func Parents(start string) []string {
d, err := filepath.Abs(start)
if err != nil {
return nil
}
var out []string
for {
out = append(out, d)
parent := filepath.Dir(d)
if parent == d {
return out
}
d = parent
}
}
// GitDirOf is the private git directory `d/.git` points at, or "".
//
// Only a `.git` FILE is a pointer; in an ordinary clone `.git` is a directory
// and there is nothing to follow.
func GitDirOf(d string) string {
p := filepath.Join(d, ".git")
fi, err := os.Stat(p)
if err != nil || fi.IsDir() {
return ""
}
head, err := os.ReadFile(p)
if err != nil {
return ""
}
for _, line := range strings.Split(string(head), "\n") {
line = strings.TrimSpace(line)
target, ok := strings.CutPrefix(line, "gitdir:")
if !ok {
continue
}
target = strings.TrimSpace(target)
if target == "" {
return ""
}
if !filepath.IsAbs(target) {
target = filepath.Join(d, target)
}
abs, err := filepath.Abs(target)
if err != nil {
return ""
}
return abs
}
return ""
}
// MainWorktree is the main working tree of d's repository when d is a linked
// worktree, or "".
//
// `<worktree>/.git` -> `<main>/.git/worktrees/<name>`, whose `commondir` file
// holds a path to `<main>/.git`; the main working tree is its parent. The
// `.git` basename check keeps this to worktrees: a submodule's `.git` is a
// pointer too, but it points into `<super>/.git/modules/…`, and the tree it
// belongs to is already on the parent chain.
func MainWorktree(d string) string {
gitdir := GitDirOf(d)
if gitdir == "" || !isDir(gitdir) {
return ""
}
common := gitdir
if raw, err := os.ReadFile(filepath.Join(gitdir, "commondir")); err == nil {
if rel := strings.TrimSpace(string(raw)); rel != "" {
if abs, err := filepath.Abs(filepath.Join(gitdir, rel)); err == nil {
common = abs
}
}
}
if filepath.Base(common) != ".git" {
return ""
}
root := filepath.Dir(common)
abs, err := filepath.Abs(d)
if err != nil {
return ""
}
if root != "" && isDir(root) && root != abs {
return root
}
return ""
}
// Root is the nearest ancestor of an anchor (inclusive) holding the marker, or
// "" when there is no project.
//
// 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.
//
// A linked worktree is the same project on another branch, and the marker is
// gitignored, so it is only ever in the main checkout: the chain is searched
// first and always wins, then the main working tree of any worktree met on it.
func Root(start string) string {
for _, anchor := range Anchors(start) {
var hops []string
for _, d := range Parents(anchor) {
if isDir(filepath.Join(d, Marker)) {
return d
}
if main := MainWorktree(d); main != "" && !contains(hops, main) {
hops = append(hops, main)
}
}
// One level of indirection, never two: a main checkout is not itself a
// linked worktree, so this cannot chain and cannot cycle.
for _, hop := range hops {
for _, d := range Parents(hop) {
if isDir(filepath.Join(d, Marker)) {
return d
}
}
}
}
return ""
}
// StoreRoot is the absolute path of the issue store, or "" with no project.
func StoreRoot(start string) string { return under(start, storeParts) }
// PayloadRoot is the absolute path of the request-payload scratchpad, or "".
//
// A sibling of the store under the same marker, resolved by the same walk, so
// the scratchpad and the store can never end up in two different projects —
// and so a scratchpad can never sit INSIDE a store, where a call that touched
// no issue would still materialize the issue directory.
func PayloadRoot(start string) string { return under(start, payloadParts) }
// ConfigPath is the absolute path of the project's tracker config, or "".
func ConfigPath(start string) string { return under(start, configParts) }
func under(start string, parts []string) string {
root := Root(start)
if root == "" {
return ""
}
return filepath.Join(append([]string{root}, parts...)...)
}
// NotFoundError explains why no project could be resolved, naming every
// directory the search began from.
//
// The anchors, not the whole chain above them: an operator who sees the two
// places the search started knows immediately whether it started where they
// meant it to.
func NotFoundError(start string) error {
dirs := strings.Join(Anchors(start), " and ")
if dirs == "" {
dirs = "nowhere"
}
return fmt.Errorf("no %s/ found — searched up from %s. Run `kettle init` in the project you mean to track issues in", Marker, dirs)
}
func isDir(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
func cwd() string {
d, err := os.Getwd()
if err != nil {
return ""
}
return d
}
func contains(xs []string, x string) bool {
for _, v := range xs {
if v == x {
return true
}
}
return false
}
+160
View File
@@ -0,0 +1,160 @@
package project
import (
"os"
"path/filepath"
"testing"
)
// Every test here strips CLAUDE_PROJECT_DIR: it is the first anchor of the
// walk, so the harness's own value would point every fixture at whatever
// repository the suite happens to run in.
func fixture(t *testing.T) string {
t.Helper()
t.Setenv("CLAUDE_PROJECT_DIR", "")
dir := t.TempDir()
// macOS hands out /var/… , a symlink to /private/var, and the walk works
// in resolved paths.
real, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatal(err)
}
return real
}
func chdir(t *testing.T, dir string) {
t.Helper()
t.Chdir(dir)
}
func TestRootFindsTheNearestMarkerUpFromTheWorkingDirectory(t *testing.T) {
root := fixture(t)
deep := filepath.Join(root, "a", "b", "c")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, Marker), 0o755); err != nil {
t.Fatal(err)
}
chdir(t, deep)
if got := Root(""); got != root {
t.Errorf("Root() = %q, want %q", got, root)
}
if got := StoreRoot(""); got != filepath.Join(root, Marker, "issues") {
t.Errorf("StoreRoot() = %q", got)
}
// The scratchpad is a sibling of the store, never inside it: a call that
// touches no issue must not materialize the issue directory.
if got := PayloadRoot(""); got != filepath.Join(root, Marker, "payload") {
t.Errorf("PayloadRoot() = %q", got)
}
}
func TestNoMarkerIsAnAnswerNotAFallback(t *testing.T) {
dir := fixture(t)
chdir(t, dir)
if got := Root(""); got != "" {
t.Errorf("Root() = %q, want empty — a plausible-looking directory is the failure this replaces", got)
}
if got := StoreRoot(""); got != "" {
t.Errorf("StoreRoot() = %q, want empty", got)
}
if err := NotFoundError(""); err == nil {
t.Fatal("NotFoundError must explain itself")
}
}
func TestTheNearestMarkerWinsOverAnAncestorOne(t *testing.T) {
outer := fixture(t)
inner := filepath.Join(outer, "vendored")
for _, d := range []string{filepath.Join(outer, Marker), filepath.Join(inner, Marker)} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
chdir(t, inner)
if got := Root(""); got != inner {
t.Errorf("Root() = %q, want the nearest marker %q", got, inner)
}
}
// A linked worktree is a SIBLING of the main checkout, so the gitignored
// marker is never on its parent chain. The whole sync layer once died there
// while the tracker CLI in the same directory worked.
func TestALinkedWorktreeResolvesToTheMainCheckout(t *testing.T) {
base := fixture(t)
main := filepath.Join(base, "repo")
wt := filepath.Join(base, "repo-feat")
gitdir := filepath.Join(main, ".git", "worktrees", "feat")
for _, d := range []string{filepath.Join(main, Marker), gitdir, wt} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
// <worktree>/.git points at the private dir; commondir points back at
// <main>/.git, whose parent is the main working tree.
write(t, filepath.Join(wt, ".git"), "gitdir: "+gitdir+"\n")
write(t, filepath.Join(gitdir, "commondir"), "../..\n")
chdir(t, wt)
if got := Root(""); got != main {
t.Errorf("Root() = %q, want the main checkout %q", got, main)
}
}
// A submodule's .git is a pointer too, but it points into
// <super>/.git/modules/…, and the tree it belongs to is already on the parent
// chain. Following it would be a hop to nowhere.
func TestASubmoduleIsNotAWorktree(t *testing.T) {
base := fixture(t)
sub := filepath.Join(base, "super", "sub")
gitdir := filepath.Join(base, "super", ".git", "modules", "sub")
for _, d := range []string{sub, gitdir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
write(t, filepath.Join(sub, ".git"), "gitdir: "+gitdir+"\n")
if got := MainWorktree(sub); got != "" {
t.Errorf("MainWorktree() = %q, want empty", got)
}
}
func TestAnOrdinaryCloneHasNothingToFollow(t *testing.T) {
dir := fixture(t)
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if got := GitDirOf(dir); got != "" {
t.Errorf("GitDirOf() = %q — only a .git FILE is a pointer", got)
}
}
func TestClaudeProjectDirIsTheFirstAnchor(t *testing.T) {
base := fixture(t)
opened := filepath.Join(base, "opened")
elsewhere := filepath.Join(base, "elsewhere")
for _, d := range []string{filepath.Join(opened, Marker), filepath.Join(elsewhere, Marker)} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("CLAUDE_PROJECT_DIR", opened)
chdir(t, elsewhere)
if got := Root(""); got != opened {
t.Errorf("Root() = %q, want the opened project %q", got, opened)
}
}
func write(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}