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:
@@ -0,0 +1,269 @@
|
||||
// Package cmd is the kettle command tree.
|
||||
//
|
||||
// Commands are values, not init() side effects on a framework: each one carries
|
||||
// the metadata a human needs (what it does, what it takes, worked examples) in
|
||||
// the same struct that carries the code. That is deliberate — the plugin's
|
||||
// SKILL.md files are generated from this list, so a command whose flags changed
|
||||
// cannot ship with documentation that says otherwise.
|
||||
//
|
||||
// The tree is flat. `kettle new`, not `kettle issue new`: an agent pays for
|
||||
// every token of every invocation, and the grouping that matters for reading is
|
||||
// carried in Group and only shows up in the docs.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Groups, in the order they are presented. They name the layer a command
|
||||
// belongs to, which is the one thing a reader has to keep straight: the domain
|
||||
// works offline and the tracker does not exist to it.
|
||||
const (
|
||||
GroupProject = "project"
|
||||
GroupIssue = "issue"
|
||||
GroupSync = "sync"
|
||||
)
|
||||
|
||||
var groupOrder = []string{GroupProject, GroupIssue, GroupSync}
|
||||
|
||||
var groupBlurb = map[string]string{
|
||||
GroupProject: "the project itself",
|
||||
GroupIssue: "issues as units of work — offline, no tracker involved",
|
||||
GroupSync: "moving issues between the store and the tracker",
|
||||
}
|
||||
|
||||
// Example is one worked invocation. Both halves are shown in help and in the
|
||||
// generated skill docs.
|
||||
type Example struct {
|
||||
Cmd string
|
||||
What string
|
||||
}
|
||||
|
||||
// Command is one verb.
|
||||
type Command struct {
|
||||
// Name is what the user types.
|
||||
Name string
|
||||
// Group is the layer it belongs to; documentation only.
|
||||
Group string
|
||||
// Args is the positional-argument spec, e.g. "<id> [<id>…]".
|
||||
Args string
|
||||
// Short is one line, shown in the command list.
|
||||
Short string
|
||||
// Long is the full explanation, shown by `kettle help <name>`.
|
||||
Long string
|
||||
// Examples are worked invocations.
|
||||
Examples []Example
|
||||
// Setup registers this command's flags on fs and returns the function that
|
||||
// runs it, closing over them. Splitting it this way lets the doc generator
|
||||
// walk the flags without running anything.
|
||||
Setup func(fs *flag.FlagSet) func(args []string) error
|
||||
}
|
||||
|
||||
var registry []*Command
|
||||
|
||||
func register(c *Command) { registry = append(registry, c) }
|
||||
|
||||
// Commands lists every command, sorted by group and then by name.
|
||||
func Commands() []*Command {
|
||||
out := append([]*Command{}, registry...)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
gi, gj := groupIndex(out[i].Group), groupIndex(out[j].Group)
|
||||
if gi != gj {
|
||||
return gi < gj
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Lookup finds a command by name.
|
||||
func Lookup(name string) *Command {
|
||||
for _, c := range registry {
|
||||
if c.Name == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flags returns this command's flags without running it — what the doc
|
||||
// generator walks.
|
||||
func (c *Command) Flags() []*flag.Flag {
|
||||
fs := flag.NewFlagSet(c.Name, flag.ContinueOnError)
|
||||
fs.SetOutput(discard{})
|
||||
c.Setup(fs)
|
||||
var out []*flag.Flag
|
||||
fs.VisitAll(func(f *flag.Flag) { out = append(out, f) })
|
||||
return out
|
||||
}
|
||||
|
||||
// Usage is the one-line synopsis.
|
||||
func (c *Command) Usage() string {
|
||||
s := "kettle " + c.Name
|
||||
if c.Args != "" {
|
||||
s += " " + c.Args
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SilentError carries an exit status for a command that has already said
|
||||
// everything it has to say. `check` uses it: findings went to stdout and a
|
||||
// second copy on stderr would be noise.
|
||||
type SilentError struct{ Code int }
|
||||
|
||||
func (e SilentError) Error() string { return "" }
|
||||
|
||||
// Fail is the error every command returns for an ordinary failure. Main
|
||||
// prefixes it with the command name.
|
||||
func Fail(format string, a ...any) error { return fmt.Errorf(format, a...) }
|
||||
|
||||
// Main runs argv (without the program name) and returns the exit status.
|
||||
func Main(argv []string) int {
|
||||
if len(argv) == 0 {
|
||||
printUsage(os.Stdout)
|
||||
return 0
|
||||
}
|
||||
|
||||
name := argv[0]
|
||||
switch name {
|
||||
case "help", "-h", "--help":
|
||||
if len(argv) > 1 {
|
||||
c := Lookup(argv[1])
|
||||
if c == nil {
|
||||
fmt.Fprintf(os.Stderr, "kettle: no command %q\n", argv[1])
|
||||
return 2
|
||||
}
|
||||
printCommand(os.Stdout, c)
|
||||
return 0
|
||||
}
|
||||
printUsage(os.Stdout)
|
||||
return 0
|
||||
}
|
||||
|
||||
c := Lookup(name)
|
||||
if c == nil {
|
||||
fmt.Fprintf(os.Stderr, "kettle: no command %q — try `kettle help`\n", name)
|
||||
return 2
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
fs.Usage = func() { printCommand(os.Stderr, c) }
|
||||
run := c.Setup(fs)
|
||||
if err := fs.Parse(permute(fs, argv[1:])); err != nil {
|
||||
if err == flag.ErrHelp {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
switch err := run(fs.Args()).(type) {
|
||||
case nil:
|
||||
return 0
|
||||
case SilentError:
|
||||
return err.Code
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "kettle %s: %v\n", name, err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage(w *os.File) {
|
||||
fmt.Fprint(w, "kettle — issues as local markdown, and the tracker they sync with\n\n")
|
||||
fmt.Fprint(w, "usage: kettle <command> [flags] [args]\n")
|
||||
|
||||
current := ""
|
||||
for _, c := range Commands() {
|
||||
if c.Group != current {
|
||||
current = c.Group
|
||||
fmt.Fprintf(w, "\n%s — %s\n", current, groupBlurb[current])
|
||||
}
|
||||
fmt.Fprintf(w, " %-11s %s\n", c.Name, c.Short)
|
||||
}
|
||||
fmt.Fprint(w, "\n`kettle help <command>` for one command in full.\n")
|
||||
}
|
||||
|
||||
func printCommand(w *os.File, c *Command) {
|
||||
fmt.Fprintf(w, "%s\n\n%s\n", c.Usage(), c.Short)
|
||||
if c.Long != "" {
|
||||
fmt.Fprintf(w, "\n%s\n", strings.TrimSpace(c.Long))
|
||||
}
|
||||
if flags := c.Flags(); len(flags) > 0 {
|
||||
fmt.Fprint(w, "\nflags:\n")
|
||||
for _, f := range flags {
|
||||
name := "--" + f.Name
|
||||
if f.DefValue != "" && f.DefValue != "false" {
|
||||
name += "=" + f.DefValue
|
||||
}
|
||||
fmt.Fprintf(w, " %-22s %s\n", name, f.Usage)
|
||||
}
|
||||
}
|
||||
if len(c.Examples) > 0 {
|
||||
fmt.Fprint(w, "\nexamples:\n")
|
||||
for _, e := range c.Examples {
|
||||
fmt.Fprintf(w, " %s\n %s\n", e.Cmd, e.What)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// permute moves flags ahead of positional arguments.
|
||||
//
|
||||
// The standard flag package stops parsing at the first non-flag argument, so
|
||||
// `kettle ac <id> --check 3` would hand --check to the command as a positional
|
||||
// and tick nothing. Every other CLI an operator uses interleaves the two, and
|
||||
// a tool that silently ignores a flag because of where it was typed is worse
|
||||
// than one that rejects it.
|
||||
//
|
||||
// A flag that takes a value swallows the next argument, which is why this needs
|
||||
// the FlagSet: only the set knows whether --check wants one. `--` ends the
|
||||
// permutation, and everything after it is positional whatever it looks like.
|
||||
func permute(fs *flag.FlagSet, args []string) []string {
|
||||
var flags, positional []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if a == "--" {
|
||||
positional = append(positional, args[i+1:]...)
|
||||
break
|
||||
}
|
||||
if len(a) < 2 || a[0] != '-' {
|
||||
positional = append(positional, a)
|
||||
continue
|
||||
}
|
||||
flags = append(flags, a)
|
||||
if strings.Contains(a, "=") {
|
||||
continue
|
||||
}
|
||||
f := fs.Lookup(strings.TrimLeft(a, "-"))
|
||||
// An unknown flag consumes nothing; Parse will reject it by name in a
|
||||
// moment, which is a better message than one about its value.
|
||||
if f == nil || isBoolFlag(f.Value) {
|
||||
continue
|
||||
}
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
flags = append(flags, args[i])
|
||||
}
|
||||
}
|
||||
return append(flags, positional...)
|
||||
}
|
||||
|
||||
func isBoolFlag(v flag.Value) bool {
|
||||
b, ok := v.(interface{ IsBoolFlag() bool })
|
||||
return ok && b.IsBoolFlag()
|
||||
}
|
||||
|
||||
func groupIndex(g string) int {
|
||||
for i, name := range groupOrder {
|
||||
if name == g {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(groupOrder)
|
||||
}
|
||||
|
||||
type discard struct{}
|
||||
|
||||
func (discard) Write(p []byte) (int, error) { return len(p), nil }
|
||||
Reference in New Issue
Block a user