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:
@@ -0,0 +1,224 @@
|
||||
package scaffold
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// frontmatter returns the YAML block at the top of a document, or "" if there
|
||||
// is none. Every file here is read by an agent harness that will not load a
|
||||
// document without one.
|
||||
func frontmatter(body string) string {
|
||||
if !strings.HasPrefix(body, "---\n") {
|
||||
return ""
|
||||
}
|
||||
rest := body[len("---\n"):]
|
||||
end := strings.Index(rest, "\n---\n")
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
return rest[:end+1]
|
||||
}
|
||||
|
||||
func field(fm, key string) string {
|
||||
for _, line := range strings.Split(fm, "\n") {
|
||||
if v, ok := strings.CutPrefix(line, key+":"); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// routed reports whether a document is one the harness loads by itself — a
|
||||
// skill, a command, a subagent. Everything under references/ is prose that a
|
||||
// skill names by path and reads in full, so it carries no frontmatter and needs
|
||||
// none.
|
||||
func routed(p string) bool {
|
||||
return !strings.Contains(p, "/references/")
|
||||
}
|
||||
|
||||
// A routed document with no frontmatter, or with an empty description, is a
|
||||
// document the harness either refuses to load or never routes to. Either way it
|
||||
// is dead weight in the binary, and neither failure shows up until somebody's
|
||||
// project is quietly missing a skill.
|
||||
func TestEveryDocumentIsLoadable(t *testing.T) {
|
||||
files := Files()
|
||||
if len(files) == 0 {
|
||||
t.Fatal("no assets embedded — check the //go:embed directive")
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
if !routed(f.Path) {
|
||||
if frontmatter(f.Body) != "" {
|
||||
t.Errorf("%s: a reference is read by path and needs no frontmatter", f.Path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
fm := frontmatter(f.Body)
|
||||
if fm == "" {
|
||||
t.Errorf("%s: no frontmatter", f.Path)
|
||||
continue
|
||||
}
|
||||
if field(fm, "description") == "" {
|
||||
t.Errorf("%s: no description — nothing will route to it", f.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A skill is addressed by its name, and the harness resolves that name from the
|
||||
// directory. The two disagreeing is a skill that cannot be loaded by the name it
|
||||
// calls itself.
|
||||
func TestSkillNamesMatchTheirDirectories(t *testing.T) {
|
||||
for _, f := range Files() {
|
||||
if !strings.HasPrefix(f.Path, "skills/") || path.Base(f.Path) != "SKILL.md" {
|
||||
continue
|
||||
}
|
||||
dir := path.Base(path.Dir(f.Path))
|
||||
if got := field(frontmatter(f.Body), "name"); got != dir {
|
||||
t.Errorf("%s: name is %q, directory is %q", f.Path, got, dir)
|
||||
}
|
||||
if !strings.HasPrefix(dir, "kettle-") {
|
||||
t.Errorf("%s: a project skill has no namespace of its own, so the prefix is the whole of it", f.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A command is invoked by an operator who typed it, so it needs no description
|
||||
// to be routed on — but it gets one anyway, because that is what the operator
|
||||
// reads in the command list. What it must NOT carry is a name: a project command
|
||||
// is named by its filename, and a `name:` here would be a second spelling of the
|
||||
// same identity, free to drift.
|
||||
func TestCommandsAreNamedByTheirFilenames(t *testing.T) {
|
||||
found := 0
|
||||
for _, f := range Files() {
|
||||
if !strings.HasPrefix(f.Path, "commands/") {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
if got := field(frontmatter(f.Body), "name"); got != "" {
|
||||
t.Errorf("%s: carries name: %q — the filename is the name", f.Path, got)
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
t.Error("no commands embedded")
|
||||
}
|
||||
}
|
||||
|
||||
// The generated region and the map that declares it are one fact written twice,
|
||||
// and this is the test that keeps them equal. A file that grew a region without
|
||||
// being declared would have it silently ignored; a file declared without one
|
||||
// would fail at splice time, in somebody's project rather than here.
|
||||
func TestDeclaredRegionsAreTheRealOnes(t *testing.T) {
|
||||
const open, close = "<!-- kettle:gen -->", "<!-- /kettle:gen -->"
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, f := range Files() {
|
||||
has := strings.Contains(f.Body, open)
|
||||
switch {
|
||||
case has && f.Group == "":
|
||||
t.Errorf("%s carries a generated region but is in no group", f.Path)
|
||||
case !has && f.Group != "":
|
||||
t.Errorf("%s is declared for group %q but has no region", f.Path, f.Group)
|
||||
}
|
||||
if has && !strings.Contains(f.Body, close) {
|
||||
t.Errorf("%s opens a region and never closes it", f.Path)
|
||||
}
|
||||
if f.Group != "" {
|
||||
seen[f.Path] = true
|
||||
}
|
||||
}
|
||||
for p := range generated {
|
||||
if !seen[p] {
|
||||
t.Errorf("generated names %s, which is not embedded", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One group, one file. Two files claiming the same group would both be written
|
||||
// from the same registry block, and only one of them would be the one anybody
|
||||
// read.
|
||||
func TestEachGroupHasExactlyOneFile(t *testing.T) {
|
||||
for _, g := range Groups() {
|
||||
var paths []string
|
||||
for p, group := range generated {
|
||||
if group == g {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
if len(paths) != 1 {
|
||||
t.Errorf("group %q is claimed by %v", g, paths)
|
||||
}
|
||||
if PathFor(g) == "" {
|
||||
t.Errorf("PathFor(%q) found nothing", g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two calls, one list. Every receipt, every --check diff and every golden test
|
||||
// downstream is built on this holding.
|
||||
func TestFilesAreDeterministic(t *testing.T) {
|
||||
a, b := Files(), Files()
|
||||
if len(a) != len(b) {
|
||||
t.Fatalf("two calls returned %d and %d files", len(a), len(b))
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
t.Fatalf("call %d differs at %d: %s vs %s", i, i, a[i].Path, b[i].Path)
|
||||
}
|
||||
if i > 0 && a[i-1].Path >= a[i].Path {
|
||||
t.Errorf("not sorted: %s before %s", a[i-1].Path, a[i].Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dirs is what a writer creates before it writes, so a parent that came after
|
||||
// its child would be a mkdir that fails on a cold directory.
|
||||
func TestDirsListsParentsBeforeChildren(t *testing.T) {
|
||||
dirs := Dirs()
|
||||
seen := map[string]bool{}
|
||||
for _, d := range dirs {
|
||||
if parent := path.Dir(d); parent != "." && !seen[parent] {
|
||||
t.Errorf("%s comes before its parent %s", d, parent)
|
||||
}
|
||||
seen[d] = true
|
||||
}
|
||||
|
||||
for _, f := range Files() {
|
||||
if d := path.Dir(f.Path); d != "." && !seen[d] {
|
||||
t.Errorf("%s lives in %s, which Dirs does not list", f.Path, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing authored here may name the plugin it replaced. A path into
|
||||
// `plugins/kettle` is a path that no longer exists, and an operator who follows
|
||||
// one is an operator reading a document that outlived its subject.
|
||||
//
|
||||
// Authored, so the generated region is cut out first: what is between the
|
||||
// markers came from the command registry and is that registry's to get right.
|
||||
// Asserting over it here would fail on a stale embedded block rather than on the
|
||||
// sentence somebody actually wrote.
|
||||
func TestNothingPointsAtTheOldPlugin(t *testing.T) {
|
||||
for _, f := range Files() {
|
||||
body := withoutRegion(f.Body)
|
||||
for _, dead := range []string{"plugins/kettle", "gen skills", "${CLAUDE_PLUGIN_ROOT}"} {
|
||||
if strings.Contains(body, dead) {
|
||||
t.Errorf("%s still mentions %q", f.Path, dead)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func withoutRegion(body string) string {
|
||||
const open, close = "<!-- kettle:gen -->", "<!-- /kettle:gen -->"
|
||||
start := strings.Index(body, open)
|
||||
if start < 0 {
|
||||
return body
|
||||
}
|
||||
end := strings.Index(body, close)
|
||||
if end < 0 {
|
||||
return body[:start]
|
||||
}
|
||||
return body[:start] + body[end+len(close):]
|
||||
}
|
||||
Reference in New Issue
Block a user