Files
marketplace/cli/internal/cmd/gen.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

323 lines
10 KiB
Go

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)
}