Files
marketplace/cli/internal/cmd/command.go
T
naudachu 9480e48312 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>
2026-08-11 19:05:39 +05:00

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 }