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,322 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// The region markers. What sits between them belongs to the generator; the
|
||||
// rest of the file belongs to whoever wrote it.
|
||||
const (
|
||||
genOpen = "<!-- kettle:gen -->"
|
||||
genClose = "<!-- /kettle:gen -->"
|
||||
)
|
||||
|
||||
// genBanner opens every generated region. The first thing anybody who finds
|
||||
// the block wants to do is edit it in place, so the block says who wrote it and
|
||||
// which command writes it again.
|
||||
const genBanner = "**Generated from the kettle command registry by `kettle gen skills`.** " +
|
||||
"Everything between the two markers is replaced on the next run — " +
|
||||
"hand-written prose belongs outside them."
|
||||
|
||||
// exampleAlign is the widest example command that still gets its `# what`
|
||||
// padded into a column. One long pipeline would otherwise push every other
|
||||
// comment off the right edge of the page.
|
||||
const exampleAlign = 56
|
||||
|
||||
func init() {
|
||||
register(&Command{
|
||||
Name: "gen",
|
||||
Group: GroupProject,
|
||||
Args: "skills",
|
||||
Short: "write the plugin's SKILL.md files from the command registry",
|
||||
Long: `A SKILL.md tells an agent how to invoke this binary. Hand-written, it drifts: a
|
||||
flag is renamed here and the documentation goes on recommending the old one,
|
||||
and the agent that reads it fails in a way nobody traces back to a stale
|
||||
sentence. Everything those files say about a command — its usage line, its
|
||||
flags with their defaults, its worked examples — is already in the registry
|
||||
this binary is built from, so it is written from there and cannot disagree.
|
||||
|
||||
THE GENERATOR OWNS A REGION, NOT A FILE. Each SKILL.md carries a pair of HTML
|
||||
comment markers — ` + "`kettle:gen`" + ` to open and ` + "`/kettle:gen`" + ` to close, both written in
|
||||
the ` + "`<!-- … -->`" + ` form and visible at the top and bottom of the block below.
|
||||
Everything between them is replaced on every run; every byte outside them comes
|
||||
back exactly as it was, which matters most for ` + "`description:`" + `, the prose that
|
||||
decides whether an agent loads the skill at all, and the one thing here that no
|
||||
generator can write.
|
||||
|
||||
A file with no markers is REPORTED AND LEFT ALONE, never overwritten: clobbering
|
||||
somebody's prose because they forgot a marker is the failure this design exists
|
||||
to prevent. A file that does not exist yet is created with a frontmatter stub
|
||||
around a generated block, for a human to fill in.
|
||||
|
||||
The output is deterministic to the byte — no timestamps, no map iteration — so
|
||||
regenerating something that has not changed produces no diff. --check is that
|
||||
property made useful: it writes nothing and exits 1 when any file on disk
|
||||
differs from what would be generated, which is what a pre-commit hook or a CI
|
||||
step calls. It wins over --dry-run when both are given.`,
|
||||
Examples: []Example{
|
||||
{"kettle gen skills --out ../plugins/tea/skills", "write the region in every group's SKILL.md"},
|
||||
{"kettle gen skills --out ../plugins/tea/skills --dry-run", "print what would change; write nothing"},
|
||||
{"kettle gen skills --out ../plugins/tea/skills --check", "exit 1 if the docs are out of date"},
|
||||
},
|
||||
Setup: func(fs *flag.FlagSet) func([]string) error {
|
||||
out := fs.String("out", "", "directory the skills live in; one <group>/SKILL.md under it")
|
||||
dryRun := fs.Bool("dry-run", false, "print what would change; write nothing")
|
||||
check := fs.Bool("check", false, "write nothing, exit 1 if anything is out of date")
|
||||
|
||||
return func(args []string) error {
|
||||
target := "skills"
|
||||
if len(args) > 0 {
|
||||
target = args[0]
|
||||
}
|
||||
if len(args) > 1 || target != "skills" {
|
||||
return Fail("the only target is `skills` — try `kettle gen skills --out <dir>`")
|
||||
}
|
||||
if *out == "" {
|
||||
return Fail("--out is required — the directory the SKILL.md files live under")
|
||||
}
|
||||
return genSkills(*out, *dryRun, *check)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// errNoRegion is what a file that the generator may not touch reports.
|
||||
var errNoRegion = errors.New("no " + genOpen + " … " + genClose + " region")
|
||||
|
||||
func genSkills(dir string, dryRun, check bool) error {
|
||||
// --check is a read-only question about the working tree, so it overrules
|
||||
// --dry-run rather than combining with it.
|
||||
if check {
|
||||
dryRun = true
|
||||
}
|
||||
|
||||
groups := docGroups()
|
||||
var written, unchanged, outdated, kept int
|
||||
for _, group := range groups {
|
||||
path := filepath.Join(dir, group, "SKILL.md")
|
||||
block, err := renderGroup(commandsIn(group))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := os.ReadFile(path)
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
outdated++
|
||||
if check {
|
||||
fmt.Printf("%-13s %s\n", "missing", path)
|
||||
continue
|
||||
}
|
||||
if dryRun {
|
||||
fmt.Printf("%-13s %s\n", "would create", path)
|
||||
continue
|
||||
}
|
||||
if err := writeFile(path, stubFile(group, block)); err != nil {
|
||||
return err
|
||||
}
|
||||
written++
|
||||
fmt.Printf("%-13s %s\n", "created", path)
|
||||
|
||||
case err != nil:
|
||||
return err
|
||||
|
||||
default:
|
||||
want, err := spliceRegion(string(existing), block)
|
||||
if err != nil {
|
||||
// Reported, never repaired: a missing marker is somebody's
|
||||
// prose sitting where the block used to be.
|
||||
kept++
|
||||
fmt.Fprintf(os.Stderr, "kettle gen: %s left alone — %v\n", path, err)
|
||||
continue
|
||||
}
|
||||
if want == string(existing) {
|
||||
unchanged++
|
||||
fmt.Printf("%-13s %s\n", "unchanged", path)
|
||||
continue
|
||||
}
|
||||
outdated++
|
||||
if check {
|
||||
fmt.Printf("%-13s %s\n", "stale", path)
|
||||
continue
|
||||
}
|
||||
if dryRun {
|
||||
fmt.Printf("%-13s %s\n", "would update", path)
|
||||
continue
|
||||
}
|
||||
if err := writeFile(path, want); err != nil {
|
||||
return err
|
||||
}
|
||||
written++
|
||||
fmt.Printf("%-13s %s\n", "updated", path)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case check:
|
||||
fmt.Printf("%d file(s) checked, %d out of date, %d without a region\n",
|
||||
len(groups), outdated, kept)
|
||||
if outdated > 0 {
|
||||
fmt.Printf("run `kettle gen skills --out %s`\n", dir)
|
||||
return SilentError{Code: 1}
|
||||
}
|
||||
case dryRun:
|
||||
fmt.Printf("%d file(s) would change, %d unchanged, %d without a region — nothing was written\n",
|
||||
outdated, unchanged, kept)
|
||||
default:
|
||||
fmt.Printf("%d file(s) written, %d unchanged, %d without a region\n", written, unchanged, kept)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// docGroups lists the groups that have commands, in the order Commands()
|
||||
// returns them — the same order twice, so two runs cannot differ.
|
||||
func docGroups() []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, c := range Commands() {
|
||||
if c.Group == "" {
|
||||
fmt.Fprintf(os.Stderr, "kettle gen: command %q has no group and is in no skill\n", c.Name)
|
||||
continue
|
||||
}
|
||||
if !seen[c.Group] {
|
||||
seen[c.Group] = true
|
||||
out = append(out, c.Group)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func commandsIn(group string) []*Command {
|
||||
var out []*Command
|
||||
for _, c := range Commands() {
|
||||
if c.Group == group {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderGroup is the generated block for one group, without the markers and
|
||||
// without a trailing newline.
|
||||
func renderGroup(cmds []*Command) (string, error) {
|
||||
var b strings.Builder
|
||||
b.WriteString(genBanner)
|
||||
b.WriteString("\n")
|
||||
|
||||
for _, c := range cmds {
|
||||
text := renderCommand(c)
|
||||
// A block holding either marker would cut itself in half on the next
|
||||
// run — the splice would end the region in the middle of the prose that
|
||||
// mentions it. Loud here rather than quietly truncated on disk.
|
||||
if strings.Contains(text, genOpen) || strings.Contains(text, genClose) {
|
||||
return "", Fail("command %q spells a region marker out in full; the generated block would then end inside itself — write it another way", c.Name)
|
||||
}
|
||||
b.WriteString(text)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n"), nil
|
||||
}
|
||||
|
||||
func renderCommand(c *Command) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "\n## `%s`\n\n%s\n", c.Usage(), c.Short)
|
||||
if long := strings.TrimSpace(c.Long); long != "" {
|
||||
b.WriteString("\n" + long + "\n")
|
||||
}
|
||||
if flags := c.Flags(); len(flags) > 0 {
|
||||
b.WriteString("\n| flag | default | what it does |\n| --- | --- | --- |\n")
|
||||
for _, f := range flags {
|
||||
fmt.Fprintf(&b, "| `--%s` | %s | %s |\n", f.Name, defaultCell(f.DefValue), cell(f.Usage))
|
||||
}
|
||||
}
|
||||
if len(c.Examples) > 0 {
|
||||
w := exampleWidth(c.Examples)
|
||||
b.WriteString("\n```bash\n")
|
||||
for _, e := range c.Examples {
|
||||
pad := w - utf8.RuneCountInString(e.Cmd)
|
||||
if pad < 0 {
|
||||
pad = 0
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%s # %s\n", e.Cmd, strings.Repeat(" ", pad), e.What)
|
||||
}
|
||||
b.WriteString("```\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// exampleWidth is the column the `# what` comments line up at. Runes, not
|
||||
// bytes: an example with Cyrillic in it would otherwise pull the column left by
|
||||
// however many multi-byte characters it holds.
|
||||
func exampleWidth(examples []Example) int {
|
||||
w := 0
|
||||
for _, e := range examples {
|
||||
if n := utf8.RuneCountInString(e.Cmd); n > w && n <= exampleAlign {
|
||||
w = n
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func defaultCell(v string) string {
|
||||
if v == "" {
|
||||
return "—"
|
||||
}
|
||||
return "`" + cell(v) + "`"
|
||||
}
|
||||
|
||||
// cell keeps a value from breaking out of its table row.
|
||||
func cell(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
return strings.ReplaceAll(s, "|", `\|`)
|
||||
}
|
||||
|
||||
func region(block string) string {
|
||||
return genOpen + "\n" + block + "\n" + genClose
|
||||
}
|
||||
|
||||
// spliceRegion swaps the block into existing, leaving every other byte alone.
|
||||
func spliceRegion(existing, block string) (string, error) {
|
||||
start := strings.Index(existing, genOpen)
|
||||
if start < 0 {
|
||||
return "", errNoRegion
|
||||
}
|
||||
rest := start + len(genOpen)
|
||||
end := strings.Index(existing[rest:], genClose)
|
||||
if end < 0 {
|
||||
return "", fmt.Errorf("%s is missing its %s", genOpen, genClose)
|
||||
}
|
||||
return existing[:start] + region(block) + existing[rest+end+len(genClose):], nil
|
||||
}
|
||||
|
||||
// stubFile is a new SKILL.md: the least frontmatter that is still a skill,
|
||||
// and the region.
|
||||
//
|
||||
// The description is left as a TODO on purpose. It is the sentence that decides
|
||||
// whether an agent loads this skill at all — prose a human tunes against real
|
||||
// failures to trigger, and the one thing here a generator has no way to write.
|
||||
func stubFile(group, block string) string {
|
||||
title := "# kettle " + group + "\n"
|
||||
if blurb := groupBlurb[group]; blurb != "" {
|
||||
title += "\n" + blurb + "\n"
|
||||
}
|
||||
return "---\n" +
|
||||
"name: " + group + "\n" +
|
||||
"description: TODO — write this by hand. It is the only thing that decides whether an agent loads this skill at all, so it is prose a human tunes; kettle gen never reads or writes it.\n" +
|
||||
"---\n\n" +
|
||||
title + "\n" +
|
||||
region(block) + "\n"
|
||||
}
|
||||
|
||||
func writeFile(path, content string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(content), 0o644)
|
||||
}
|
||||
Reference in New Issue
Block a user