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
+157
View File
@@ -0,0 +1,157 @@
package cmd
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"git.noodles.cam/claude-skills/marketplace/cli/internal/config"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
)
// writeConfig creates or updates .kettle/config.yaml, touching only the
// settings it was given.
//
// Init is idempotent, and that has to include the config: re-running it to add
// a repository must not silently drop the login somebody pinned last week.
func writeConfig(root, login, repo string, dryRun bool) (string, error) {
path := filepath.Join(root, project.Marker, "config.yaml")
rel := filepath.Join(project.Marker, "config.yaml")
cfg, existed, err := config.ReadProjectFile(path)
if err != nil {
return "", err
}
changed := !existed
if login != "" && cfg.Login != login {
cfg.Login, changed = login, true
}
if repo != "" && cfg.Repo != repo {
cfg.Repo, changed = repo, true
}
if !changed {
return "", nil
}
verb := "updated"
if !existed {
verb = "created"
}
detail := "no login or repository pinned yet — `kettle init --login … --repo …`"
if cfg.Login != "" || cfg.Repo != "" {
detail = fmt.Sprintf("login: %s, repo: %s", orNone(cfg.Login), orNone(cfg.Repo))
}
if dryRun {
return fmt.Sprintf("%s %s (%s)", verb, rel, detail), nil
}
if err := config.SaveProject(path, cfg); err != nil {
return "", err
}
return fmt.Sprintf("%s %s (%s)", verb, rel, detail), nil
}
func orNone(s string) string {
if s == "" {
return "none"
}
return s
}
func init() {
register(&Command{
Name: "init",
Group: GroupProject,
Short: "make this directory a project that tracks issues",
Long: `Creates ` + "`.kettle/`" + ` — the marker every other command resolves the store from,
and ` + "`.kettle/config.yaml`" + `, which says which tracker repository these issues
belong to and which login to reach it under.
The marker is deliberately something an operator makes, not something inferred
from the tree: ` + "`.git`" + ` is in every clone, so anything that inferred a root from
one would write issues into whatever it happened to be installed in.
--login pins a name, never a credential. The tokens live in one file per
machine, outside every working tree, managed with ` + "`kettle auth`" + `.
All of it is idempotent: it creates .kettle/issues and .kettle/payload, migrates
an older store in if it finds one (tmp/ or .tea/), writes the config without
disturbing settings it was not given, and adds .kettle/ to .gitignore. Each
migration is a move, not a copy — two stores is the state the marker exists to
prevent — and it refuses to pick a winner when both sides hold a file of the
same name.
Do NOT run this inside a linked worktree. A worktree is the same project on
another branch and reaches the store by a hop out to the main checkout; a marker
here would give one project two stores, and the directory holding the second one
disappears with the branch.`,
Examples: []Example{
{"kettle init", "initialize the current directory"},
{"kettle init --login noodles --repo claude-skills/marketplace", "and point it at a tracker"},
{"kettle init --at ~/code/x", "initialize somewhere else"},
{"kettle init --dry-run", "say what it would do, touch nothing"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
at := fs.String("at", "", "directory to initialize (default: the working directory)")
login := fs.String("login", "", "name of a login in the machine-wide file (see `kettle auth`)")
repo := fs.String("repo", "", "tracker repository, as owner/name")
dryRun := fs.Bool("dry-run", false, "report what would happen; change nothing")
return func(args []string) error {
root := *at
if root == "" {
wd, err := os.Getwd()
if err != nil {
return err
}
root = wd
}
root, err := filepath.Abs(root)
if err != nil {
return err
}
if fi, err := os.Stat(root); err != nil || !fi.IsDir() {
return Fail("%s is not a directory", root)
}
// A second marker inside an existing project gives it a second
// store, and the nearer one wins — which is a surprise worth
// naming before it happens, not after.
if existing := project.Root(root); existing != "" && existing != root {
fmt.Fprintf(os.Stderr,
"warning: %s already sits inside the project at %s — a second marker here gives it a second store, and the nearer one wins.\n",
root, existing)
}
if *repo != "" {
if owner, name, ok := strings.Cut(*repo, "/"); !ok || owner == "" || name == "" {
return Fail("--repo %q is not owner/name", *repo)
}
}
done, err := project.Init(root, *dryRun)
if err != nil {
return err
}
line, err := writeConfig(root, *login, *repo, *dryRun)
if err != nil {
return err
}
if line != "" {
done = append(done, line)
}
prefix := ""
if *dryRun {
prefix = "would: "
}
for _, l := range done {
fmt.Println(prefix + l)
}
return nil
}
},
})
}