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 = "" genClose = "" ) // 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 /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 `") } 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) }