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:
+144
-125
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user