8b1b11001a
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>
206 lines
6.9 KiB
Go
206 lines
6.9 KiB
Go
package cmd_test
|
|
|
|
// `kettle mirror` is the one command in the tree that has nothing to do with
|
|
// issues, and the one that must never fail a caller. Both are tested here.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func writeDoc(t *testing.T, dir, name, body string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func linkTarget(t *testing.T, path string) string {
|
|
t.Helper()
|
|
target, err := os.Readlink(path)
|
|
if err != nil {
|
|
t.Fatalf("%s is not a symlink: %v", path, err)
|
|
}
|
|
return target
|
|
}
|
|
|
|
// The tree it walks is the working directory, not a project: this must work in
|
|
// a directory that has never seen `kettle init`, because the convention it
|
|
// maintains has nothing to do with issues.
|
|
func TestMirrorNeedsNoProject(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeDoc(t, dir, "AGENTS.md", "root\n")
|
|
writeDoc(t, filepath.Join(dir, "cli"), "AGENTS.md", "cli\n")
|
|
|
|
r := mustRun(t, dir, "mirror")
|
|
if strings.Contains(r.out(), ".kettle") {
|
|
t.Errorf("mirror asked for a project:\n%s", r.out())
|
|
}
|
|
for _, sub := range []string{".", "cli"} {
|
|
if got := linkTarget(t, filepath.Join(dir, sub, "CLAUDE.md")); got != "AGENTS.md" {
|
|
t.Errorf("%s/CLAUDE.md points at %q, want a relative AGENTS.md", sub, got)
|
|
}
|
|
}
|
|
|
|
// Idempotent, and it says so rather than printing nothing at all.
|
|
again := mustRun(t, dir, "mirror")
|
|
if !strings.Contains(again.stdout, "nothing to do") {
|
|
t.Errorf("a canonical tree did not report itself clean:\n%s", again.out())
|
|
}
|
|
}
|
|
|
|
func TestMirrorTakesADirectoryArgument(t *testing.T) {
|
|
dir := t.TempDir()
|
|
elsewhere := filepath.Join(dir, "elsewhere")
|
|
writeDoc(t, elsewhere, "AGENTS.md", "there\n")
|
|
|
|
mustRun(t, dir, "mirror", elsewhere)
|
|
if _, err := os.Lstat(filepath.Join(elsewhere, "CLAUDE.md")); err != nil {
|
|
t.Errorf("the named directory was not repaired: %v", err)
|
|
}
|
|
|
|
if r := run(t, dir, "mirror", filepath.Join(dir, "nowhere")); r.code == 0 {
|
|
t.Errorf("a directory that is not there must be refused:\n%s", r.out())
|
|
}
|
|
}
|
|
|
|
func TestMirrorCheckWritesNothingAndFailsLoudly(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeDoc(t, dir, "AGENTS.md", "root\n")
|
|
|
|
r := run(t, dir, "mirror", "--check")
|
|
if r.code != 1 {
|
|
t.Fatalf("--check exited %d on a tree with work to do, want 1:\n%s", r.code, r.out())
|
|
}
|
|
if !strings.Contains(r.stdout, "would: ") {
|
|
t.Errorf("--check did not say what it would do:\n%s", r.out())
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(dir, "CLAUDE.md")); err == nil {
|
|
t.Error("--check wrote to the tree it was asked about")
|
|
}
|
|
|
|
mustRun(t, dir, "mirror")
|
|
if r := run(t, dir, "mirror", "--check"); r.code != 0 {
|
|
t.Errorf("--check exited %d on a canonical tree:\n%s", r.code, r.out())
|
|
}
|
|
}
|
|
|
|
// The refusal, end to end. One of these two files is somebody's writing and no
|
|
// rule in this binary knows which, so both survive and the operator is told.
|
|
func TestMirrorRefusesTwoDifferentRealFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeDoc(t, dir, "AGENTS.md", "mine\n")
|
|
writeDoc(t, dir, "CLAUDE.md", "theirs\n")
|
|
|
|
r := mustRun(t, dir, "mirror")
|
|
if !strings.Contains(r.stdout, "conflict:") || !strings.Contains(r.stdout, "merge manually") {
|
|
t.Errorf("the conflict was not reported:\n%s", r.out())
|
|
}
|
|
if got := readFile(t, filepath.Join(dir, "AGENTS.md")); got != "mine\n" {
|
|
t.Errorf("AGENTS.md was changed: %q", got)
|
|
}
|
|
if got := readFile(t, filepath.Join(dir, "CLAUDE.md")); got != "theirs\n" {
|
|
t.Errorf("CLAUDE.md was changed: %q", got)
|
|
}
|
|
// A repair run reports the conflict and still exits 0 — the six branches it
|
|
// could fix, it fixed. --check is the one that turns it into a failure.
|
|
if r := run(t, dir, "mirror", "--check"); r.code != 1 {
|
|
t.Errorf("--check exited %d on a conflict, want 1", r.code)
|
|
}
|
|
}
|
|
|
|
// The hook form's whole contract: it cannot fail a Bash call. Every one of these
|
|
// is a payload or a state that could plausibly arrive, and every one exits 0.
|
|
func TestMirrorHookAlwaysExitsZero(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
stdin string
|
|
setup func(t *testing.T, dir string)
|
|
}{
|
|
{name: "no payload at all", stdin: ""},
|
|
{name: "a payload that is not JSON", stdin: "not json at all"},
|
|
{name: "an empty object", stdin: "{}"},
|
|
{name: "a payload with keys it does not know", stdin: `{"cwd":".","tool_name":"Bash","future":{"x":1}}`},
|
|
{
|
|
name: "a tree it cannot resolve",
|
|
stdin: `{"cwd":"/nowhere/at/all"}`,
|
|
setup: func(t *testing.T, dir string) { writeDoc(t, dir, "AGENTS.md", "root\n") },
|
|
},
|
|
{
|
|
name: "a conflict it must not resolve",
|
|
stdin: `{}`,
|
|
setup: func(t *testing.T, dir string) {
|
|
writeDoc(t, dir, "AGENTS.md", "mine\n")
|
|
writeDoc(t, dir, "CLAUDE.md", "theirs\n")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if tc.setup != nil {
|
|
tc.setup(t, dir)
|
|
}
|
|
r := runWith(t, dir, nil, tc.stdin, "mirror", "--hook")
|
|
if r.code != 0 {
|
|
t.Errorf("exited %d — a documentation helper may not break a Bash call:\n%s", r.code, r.out())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// What it says when it did something, and what it says when it did not.
|
|
func TestMirrorHookReportsOnlyWhenThereIsSomethingToSay(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeDoc(t, dir, "AGENTS.md", "root\n")
|
|
|
|
r := runWith(t, dir, nil, "{}", "mirror", "--hook")
|
|
var out struct {
|
|
HookSpecificOutput struct {
|
|
HookEventName string `json:"hookEventName"`
|
|
AdditionalContext string `json:"additionalContext"`
|
|
} `json:"hookSpecificOutput"`
|
|
}
|
|
if err := json.Unmarshal([]byte(r.stdout), &out); err != nil {
|
|
t.Fatalf("the hook did not emit JSON: %v\n%s", err, r.out())
|
|
}
|
|
if out.HookSpecificOutput.HookEventName != "PreToolUse" {
|
|
t.Errorf("hookEventName is %q", out.HookSpecificOutput.HookEventName)
|
|
}
|
|
if !strings.Contains(out.HookSpecificOutput.AdditionalContext, "CLAUDE.md") {
|
|
t.Errorf("the report says nothing about what it did:\n%s", out.HookSpecificOutput.AdditionalContext)
|
|
}
|
|
|
|
// Silence means the tree was already canonical. A hook that spoke on every
|
|
// Bash call would be noise in every transcript.
|
|
quiet := runWith(t, dir, nil, "{}", "mirror", "--hook")
|
|
if strings.TrimSpace(quiet.stdout) != "" {
|
|
t.Errorf("a canonical tree still produced output:\n%s", quiet.stdout)
|
|
}
|
|
}
|
|
|
|
// CLAUDE_PROJECT_DIR is the harness's own answer to "which tree", and it wins
|
|
// over the payload's cwd — a Bash call made from a subdirectory must still
|
|
// repair the whole project rather than the corner it was made in.
|
|
func TestMirrorHookPrefersTheProjectDirectory(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sub := filepath.Join(dir, "deep", "inside")
|
|
writeDoc(t, dir, "AGENTS.md", "root\n")
|
|
writeDoc(t, sub, "AGENTS.md", "inside\n")
|
|
|
|
r := runWith(t, sub, []string{"CLAUDE_PROJECT_DIR=" + dir}, `{"cwd":"`+sub+`"}`, "mirror", "--hook")
|
|
if r.code != 0 {
|
|
t.Fatalf("exited %d:\n%s", r.code, r.out())
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(dir, "CLAUDE.md")); err != nil {
|
|
t.Errorf("the project root was not repaired: %v", err)
|
|
}
|
|
}
|