e628ad6fd9
BREAKING: the plugin is `kettle`, not `tea`, and its commands are `/kettle:*`. It also now needs a binary on PATH that it did not need before; the README and every skill say how to get one and what a missing one looks like. The plugin was 3800 lines of Python doing what a compiled binary does better, and the name pointed at a tool that no longer takes part: `tea` is Gitea's CLI, and since the transport moved into the binary nothing here shells out to it for issues at all. A plugin named after it was going to keep suggesting otherwise. Deleted: 19 scripts, the 14-file unittest suite, and the tea-guard hook. The guard blocked any `tea` invocation that would run under a login the model picked instead of the operator; the binary holds its own credentials and reads the pinned login out of the project's own config, so that failure is no longer expressible and there is nothing left to police. agents-sync stays — it is about AGENTS.md symlinks and has nothing to do with any of this. What the plugin keeps is what only a plugin can carry: the rules an operator states and a binary cannot enforce. `init` still refuses to run inside a linked worktree and still may not be model-invoked, because which directory is the project is a statement a person makes. The issue format reference stays here and stays the source of truth. The runner subagent is still for batches and still may not decide what an issue says. The command reference in the issue, sync and project skills is GENERATED from the binary's own command registry, between markers, so a flag that changed cannot ship with a skill that recommends the old one. `kettle gen skills --check` exits non-zero when they drift. The generator owns the region and nothing outside it: the frontmatter description, which is what decides whether a skill loads at all, stays hand-written. `use` survives and is the one place `tea` is still named — for releases, webhooks and actions, which kettle does not cover. Its instruction to write `--login "$GITEA_LOGIN"` and let the hook substitute the pin was true until this commit and is now rewritten: `tea` keeps its own configuration, kettle keeps its own, and configuring one configures nothing in the other. 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/kettle/skills", "write the region in every group's SKILL.md"},
|
|
{"kettle gen skills --out ../plugins/kettle/skills --dry-run", "print what would change; write nothing"},
|
|
{"kettle gen skills --out ../plugins/kettle/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)
|
|
}
|