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>
This commit is contained in:
naudachu
2026-08-12 16:17:24 +05:00
parent f18a633185
commit 8b1b11001a
445 changed files with 231172 additions and 1339 deletions
+144 -125
View File
@@ -9,21 +9,24 @@ import (
"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 belongs to the generator; the
// rest of the file belongs to whoever wrote it.
// 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
// 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`.** " +
const genBanner = "**Generated from the kettle command registry by `kettle gen scaffold`.** " +
"Everything between the two markers is replaced on the next run — " +
"hand-written prose belongs outside them."
"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
@@ -34,176 +37,199 @@ 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.
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.
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.
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.
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.
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 generated, which is what a pre-commit hook or a CI
step calls. It wins over --dry-run when both are given.`,
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 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"},
{"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 the skills live in; one <group>/SKILL.md under it")
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 := "skills"
target := "scaffold"
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 len(args) > 1 || target != "scaffold" {
return Fail("the only target is `scaffold` — try `kettle gen scaffold`")
}
if *out == "" {
return Fail("--out is required — the directory the SKILL.md files live under")
dir, err := scaffoldDir(*out)
if err != nil {
return err
}
return genSkills(*out, *dryRun, *check)
return genScaffold(dir, *dryRun, *check)
}
},
})
}
// errNoRegion is what a file that the generator may not touch reports.
var errNoRegion = errors.New("no " + genOpen + " … " + genClose + " region")
// 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 genSkills(dir string, dryRun, check bool) error {
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
}
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
}
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 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 {
if err := report(path, "missing", "would create", "created", dryRun, check, func() error {
return writeFile(path, f.Body)
}); err != nil {
return err
}
written++
fmt.Printf("%-13s %s\n", "created", path)
if !dryRun {
written++
}
case err != nil:
return err
case string(existing) == f.Body:
unchanged++
fmt.Printf("%-13s %s\n", "unchanged", path)
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 {
if err := report(path, "stale", "would update", "updated", dryRun, check, func() error {
return writeFile(path, f.Body)
}); err != nil {
return err
}
written++
fmt.Printf("%-13s %s\n", "updated", path)
if !dryRun {
written++
}
}
}
switch {
case check:
fmt.Printf("%d file(s) checked, %d out of date, %d without a region\n",
len(groups), outdated, kept)
fmt.Printf("%d file(s) checked, %d out of date\n", len(files), outdated)
if outdated > 0 {
fmt.Printf("run `kettle gen skills --out %s`\n", dir)
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, %d without a region — nothing was written\n",
outdated, unchanged, kept)
fmt.Printf("%d file(s) would change, %d unchanged — nothing was written\n", outdated, unchanged)
default:
fmt.Printf("%d file(s) written, %d unchanged, %d without a region\n", written, unchanged, kept)
fmt.Printf("%d file(s) written, %d unchanged\n", written, unchanged)
}
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)
// 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 out
return nil
}
func commandsIn(group string) []*Command {
var out []*Command
for _, c := range Commands() {
if c.Group == group {
out = append(out, c)
// 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
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) {
@@ -295,23 +321,16 @@ func spliceRegion(existing, block string) (string, error) {
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"
// 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 "---\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"
return out
}
func writeFile(path, content string) error {