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>
323 lines
10 KiB
Go
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)
|
|
}
|