9480e48312
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>
270 lines
7.2 KiB
Go
270 lines
7.2 KiB
Go
// 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 }
|