Files
marketplace/cli/internal/cmd/gen.go
T
naudachu 8b1b11001a feat: drop the kettle plugin; the binary writes its own skills
The plugin and the binary shipped on two release cadences and nothing on an
operator's machine ever checked that the one they installed described the other.
The generated flag block existed precisely so a renamed flag could not ship with
documentation recommending the old one — and then shipped one version behind the
registry it came from, which is the same bug one hop downstream.

So the prose moved into the binary. `internal/scaffold` embeds every document;
`kettle init` and `kettle gen scaffold` write them into a project's own
`.claude/`. The two cannot disagree because there is one artefact.

The namespace survived the move. A project's skills are flat, so the prefix is
spelled into the directory name (`kettle-issue`); a project's *commands* take
their namespace from a subdirectory, so `commands/kettle/init.md` is still
`/kettle:init`. Four of the six command files are thin pointers at a skill, and
that is what kept ~1,600 lines of `/kettle:…` cross-references true without a
rewrite. `init` and `auth` lost `disable-model-invocation: true` — being a
command is that property — and `auth` now restricts `allowed-tools` so a model
cannot reach `kettle auth add` at all.

`gen scaffold` writes files whole rather than splicing a region. The old
refusal protected somebody's hand-written prose around the block; that prose is
embedded now, so there is none to protect, and preserving local edits would
freeze a project's documentation at whatever version first initialized it.
`--check` warns before an upgrade discards one.

The plugin's `agents-sync.sh` — 141 lines of Python behind a filename that said
`.sh` — became `internal/mirror` and `kettle mirror`. Same seven branches, same
refusal to merge two real files that differ, now with a table test per branch
and a check that a repair converges in one pass. `--hook` is the PreToolUse
form and exits 0 on every path including a panic. It is opt-in per project,
which is strictly narrower than the plugin hook that was on for everybody who
installed it.

`kettle init --interactive` walks a person through the login, the token (read
with the echo off, so it lands in no history and no file), the repository, the
`.claude/` tree and the mirror hook. It refuses a stdin that is not a terminal
and names the flags instead: every question it asks has one, and it performs
nothing itself, so an interactive run and a flag run are one code path.

Two rules that used to be prose are now the binary's: init refuses a linked
worktree and names the main checkout, and writing into an existing
`.claude/settings.json` is refused with the snippet printed rather than
reformatting a file the operator commits.

The scaffold version stamp went to its own `.kettle/scaffold.yaml` rather than
into `config.yaml`, because unknown keys there are a hard error and that file
may be committed and read by whatever build each machine has.

golang.org/x/term becomes a direct dependency; it was already in the tree
indirectly, so no module was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:17:24 +05:00

342 lines
11 KiB
Go

package cmd
import (
"errors"
"flag"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"unicode/utf8"
"git.noodles.cam/claude-skills/marketplace/cli/internal/project"
"git.noodles.cam/claude-skills/marketplace/cli/internal/scaffold"
)
// The region markers. What sits between them comes from the registry; the rest
// of the document is the embedded prose around 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 scaffold`.** " +
"Everything between the two markers is replaced on the next run — " +
"the prose around it is embedded in the binary and replaced with it."
// 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: "scaffold",
Short: "write this project's .claude/ commands, skills and subagent",
Long: `A skill tells an agent how to invoke this binary, and a command is how an
operator invokes one by hand. Both are written from here, whole, because both
travel INSIDE the binary: the prose is embedded next to the code it describes
and the flag tables are rendered from the command registry the binary is built
from, so neither can be a version behind the other.
That is the whole reason these documents are not a plugin any more. A plugin
ships on its own cadence, and nothing on an operator's machine ever checked that
the one they installed described the binary they installed — so a renamed flag
could still arrive with documentation recommending the old one, which is exactly
the failure the generated block was invented to prevent, one hop further
downstream.
EVERY FILE IS WRITTEN WHOLE, and that is a deliberate reversal. The old
generator owned a region and left every byte outside it alone, because the prose
around the block was somebody's hand-written file. It is not any more: it is
embedded, so there is no hand-written half left to protect, and preserving local
edits would mean freezing a project's documentation at whatever version first
initialized it. The markers stay in the output so a reader can see which half
came from the registry.
WHAT THIS MEANS FOR A LOCAL EDIT: it does not survive. Run --check before an
upgrade if you have made one; the fix for a sentence that is wrong is a newer
kettle, not a patch that the next run silently discards.
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 written, 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 scaffold", "write .claude/ under this project"},
{"kettle gen scaffold --out ~/code/x/.claude", "write it somewhere else"},
{"kettle gen scaffold --dry-run", "print what would change; write nothing"},
{"kettle gen scaffold --check", "exit 1 if the documents are out of date"},
},
Setup: func(fs *flag.FlagSet) func([]string) error {
out := fs.String("out", "", "directory to write into (default: <project>/"+scaffold.Marker+")")
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 := "scaffold"
if len(args) > 0 {
target = args[0]
}
if len(args) > 1 || target != "scaffold" {
return Fail("the only target is `scaffold` — try `kettle gen scaffold`")
}
dir, err := scaffoldDir(*out)
if err != nil {
return err
}
return genScaffold(dir, *dryRun, *check)
}
},
})
}
// scaffoldDir resolves where the tree goes.
//
// An explicit --out is used exactly as typed, relative and all, because that is
// what the operator asked for. Without one the answer comes from the marker, the
// same walk every other command uses — and no marker is an answer rather than a
// fallback, because a `.claude/` written into a plausible-looking directory is
// the failure the marker exists to replace.
func scaffoldDir(out string) (string, error) {
if out != "" {
return out, nil
}
root := project.Root("")
if root == "" {
return "", project.NotFoundError("")
}
return filepath.Join(root, scaffold.Marker), nil
}
func genScaffold(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
}
files, err := renderAll()
if err != nil {
return err
}
var written, unchanged, outdated int
for _, f := range files {
path := filepath.Join(dir, filepath.FromSlash(f.Path))
existing, err := os.ReadFile(path)
switch {
case errors.Is(err, fs.ErrNotExist):
outdated++
if err := report(path, "missing", "would create", "created", dryRun, check, func() error {
return writeFile(path, f.Body)
}); err != nil {
return err
}
if !dryRun {
written++
}
case err != nil:
return err
case string(existing) == f.Body:
unchanged++
fmt.Printf("%-13s %s\n", "unchanged", path)
default:
outdated++
if err := report(path, "stale", "would update", "updated", dryRun, check, func() error {
return writeFile(path, f.Body)
}); err != nil {
return err
}
if !dryRun {
written++
}
}
}
switch {
case check:
fmt.Printf("%d file(s) checked, %d out of date\n", len(files), outdated)
if outdated > 0 {
fmt.Printf("run `kettle gen scaffold --out %s`\n", dir)
return SilentError{Code: 1}
}
case dryRun:
fmt.Printf("%d file(s) would change, %d unchanged — nothing was written\n", outdated, unchanged)
default:
fmt.Printf("%d file(s) written, %d unchanged\n", written, unchanged)
}
return nil
}
// report prints one line for one file and performs the write unless this run is
// only answering a question.
func report(path, checkWord, dryWord, doneWord string, dryRun, check bool, write func() error) error {
switch {
case check:
fmt.Printf("%-13s %s\n", checkWord, path)
case dryRun:
fmt.Printf("%-13s %s\n", dryWord, path)
default:
if err := write(); err != nil {
return err
}
fmt.Printf("%-13s %s\n", doneWord, path)
}
return nil
}
// renderAll is every embedded document with its generated region filled in.
//
// Nothing here touches the disk: the result is what the tree SHOULD be, and
// comparing it against what is there is a separate question asked by the caller.
// That split is what lets --check be exact rather than a heuristic about
// timestamps.
func renderAll() ([]scaffold.File, error) {
files := scaffold.Files()
out := make([]scaffold.File, 0, len(files))
for _, f := range files {
if f.Group == "" {
out = append(out, f)
continue
}
block, err := renderGroup(commandsIn(f.Group))
if err != nil {
return nil, err
}
body, err := spliceRegion(f.Body, block)
if err != nil {
// The embedded document is shipped inside this binary, so a missing
// marker is a build-time mistake in this repository and not
// something an operator can have caused.
return nil, Fail("%s: %v — this is a bug in the embedded document, not in your project", f.Path, err)
}
f.Body = body
out = append(out, f)
}
return out, nil
}
// errNoRegion is what a document that declares a region and has none reports.
var errNoRegion = errors.New("no " + genOpen + " … " + genClose + " region")
// 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
}
// commandsIn lists a group's commands in the order Commands() returns them —
// the same order twice, so two runs cannot differ.
func commandsIn(group string) []*Command {
var out []*Command
for _, c := range Commands() {
if c.Group == group {
out = append(out, c)
}
}
return out
}
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)
}