8b1b11001a
The plugin and the binary shipped on two release cadences and nothing on an operator's machine ever checked that the one they installed described the other. The generated flag block existed precisely so a renamed flag could not ship with documentation recommending the old one — and then shipped one version behind the registry it came from, which is the same bug one hop downstream. So the prose moved into the binary. `internal/scaffold` embeds every document; `kettle init` and `kettle gen scaffold` write them into a project's own `.claude/`. The two cannot disagree because there is one artefact. The namespace survived the move. A project's skills are flat, so the prefix is spelled into the directory name (`kettle-issue`); a project's *commands* take their namespace from a subdirectory, so `commands/kettle/init.md` is still `/kettle:init`. Four of the six command files are thin pointers at a skill, and that is what kept ~1,600 lines of `/kettle:…` cross-references true without a rewrite. `init` and `auth` lost `disable-model-invocation: true` — being a command is that property — and `auth` now restricts `allowed-tools` so a model cannot reach `kettle auth add` at all. `gen scaffold` writes files whole rather than splicing a region. The old refusal protected somebody's hand-written prose around the block; that prose is embedded now, so there is none to protect, and preserving local edits would freeze a project's documentation at whatever version first initialized it. `--check` warns before an upgrade discards one. The plugin's `agents-sync.sh` — 141 lines of Python behind a filename that said `.sh` — became `internal/mirror` and `kettle mirror`. Same seven branches, same refusal to merge two real files that differ, now with a table test per branch and a check that a repair converges in one pass. `--hook` is the PreToolUse form and exits 0 on every path including a panic. It is opt-in per project, which is strictly narrower than the plugin hook that was on for everybody who installed it. `kettle init --interactive` walks a person through the login, the token (read with the echo off, so it lands in no history and no file), the repository, the `.claude/` tree and the mirror hook. It refuses a stdin that is not a terminal and names the flags instead: every question it asks has one, and it performs nothing itself, so an interactive run and a flag run are one code path. Two rules that used to be prose are now the binary's: init refuses a linked worktree and names the main checkout, and writing into an existing `.claude/settings.json` is refused with the snippet printed rather than reformatting a file the operator commits. The scaffold version stamp went to its own `.kettle/scaffold.yaml` rather than into `config.yaml`, because unknown keys there are a hard error and that file may be committed and read by whatever build each machine has. golang.org/x/term becomes a direct dependency; it was already in the tree indirectly, so no module was added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
272 lines
7.3 KiB
Go
272 lines
7.3 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 skills kettle
|
|
// writes into a project 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"
|
|
GroupAPI = "api"
|
|
)
|
|
|
|
var groupOrder = []string{GroupProject, GroupIssue, GroupSync, GroupAPI}
|
|
|
|
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",
|
|
GroupAPI: "everything else Gitea has, reached directly — not issues",
|
|
}
|
|
|
|
// 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 }
|