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>
234 lines
8.5 KiB
Go
234 lines
8.5 KiB
Go
package cmd_test
|
|
|
|
// What `kettle init` writes beyond the marker: the .claude/ tree, the record of
|
|
// which build wrote it, and the optional mirror hook. Plus the two refusals that
|
|
// used to be prose in a skill and are now the binary's.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestInitWritesTheClaudeTreeByDefault(t *testing.T) {
|
|
dir := t.TempDir()
|
|
r := mustRun(t, dir, "init")
|
|
|
|
for _, rel := range scaffoldFiles {
|
|
if _, err := os.Stat(filepath.Join(dir, ".claude", filepath.FromSlash(rel))); err != nil {
|
|
t.Errorf("%s was not written: %v\n%s", rel, err, r.out())
|
|
}
|
|
}
|
|
|
|
// And the record of what wrote it, which is the only way an operator can
|
|
// tell a current tree from one four releases old.
|
|
stamp := readFile(t, filepath.Join(dir, ".kettle", "scaffold.yaml"))
|
|
for _, want := range []string{"version:", "out: .claude"} {
|
|
if !strings.Contains(stamp, want) {
|
|
t.Errorf("the scaffold record is missing %q:\n%s", want, stamp)
|
|
}
|
|
}
|
|
if cfg := mustRun(t, dir, "config"); !strings.Contains(cfg.stdout, "scaffold .claude") {
|
|
t.Errorf("`kettle config` does not report the tree:\n%s", cfg.stdout)
|
|
}
|
|
|
|
// Idempotent all the way through: a second init rewrites nothing.
|
|
second := mustRun(t, dir, "init")
|
|
if strings.Contains(second.stdout, "updated") {
|
|
t.Errorf("a second init rewrote a document:\n%s", second.out())
|
|
}
|
|
}
|
|
|
|
func TestInitCanBeToldToWriteNoTreeAtAll(t *testing.T) {
|
|
dir := t.TempDir()
|
|
mustRun(t, dir, "init", "--no-scaffold")
|
|
|
|
if _, err := os.Stat(filepath.Join(dir, ".claude")); err == nil {
|
|
t.Error("--no-scaffold still wrote the tree")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, ".kettle", "scaffold.yaml")); err == nil {
|
|
t.Error("--no-scaffold recorded a tree it did not write")
|
|
}
|
|
// Nothing else is affected: this is still a project.
|
|
if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues")); err != nil {
|
|
t.Errorf("--no-scaffold skipped the store as well: %v", err)
|
|
}
|
|
// And `kettle config` says so rather than saying nothing.
|
|
if cfg := mustRun(t, dir, "config"); !strings.Contains(cfg.stdout, "scaffold (none written") {
|
|
t.Errorf("`kettle config` is silent about the missing tree:\n%s", cfg.stdout)
|
|
}
|
|
}
|
|
|
|
func TestInitScaffoldOutIsUsedAsTyped(t *testing.T) {
|
|
dir := t.TempDir()
|
|
elsewhere := filepath.Join(dir, "somewhere", "else")
|
|
|
|
mustRun(t, dir, "init", "--scaffold-out", elsewhere)
|
|
if _, err := os.Stat(filepath.Join(elsewhere, "skills", "kettle-issue", "SKILL.md")); err != nil {
|
|
t.Errorf("the tree did not go where it was told: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, ".claude")); err == nil {
|
|
t.Error("it went to the default as well")
|
|
}
|
|
}
|
|
|
|
func TestInitDryRunWritesNoTreeAndNoRecord(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
r := mustRun(t, dir, "init", "--dry-run", "--mirror-hook")
|
|
if !strings.Contains(r.stdout, "would create") {
|
|
t.Errorf("the dry run said nothing about the tree:\n%s", r.out())
|
|
}
|
|
for _, p := range []string{".claude", ".kettle"} {
|
|
if _, err := os.Stat(filepath.Join(dir, p)); err == nil {
|
|
t.Errorf("a dry run created %s", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The mirror hook: offered, never assumed. A project that keeps no AGENTS.md
|
|
// files wants nothing to do with it.
|
|
func TestInitRegistersTheMirrorHookOnlyWhenAsked(t *testing.T) {
|
|
plain := t.TempDir()
|
|
mustRun(t, plain, "init")
|
|
if _, err := os.Stat(filepath.Join(plain, ".claude", "settings.json")); err == nil {
|
|
t.Error("init registered a hook nobody asked for")
|
|
}
|
|
|
|
asked := t.TempDir()
|
|
mustRun(t, asked, "init", "--mirror-hook")
|
|
body := readFile(t, filepath.Join(asked, ".claude", "settings.json"))
|
|
|
|
var settings struct {
|
|
Hooks struct {
|
|
PreToolUse []struct {
|
|
Matcher string `json:"matcher"`
|
|
Hooks []struct {
|
|
Type string `json:"type"`
|
|
Command string `json:"command"`
|
|
} `json:"hooks"`
|
|
} `json:"PreToolUse"`
|
|
} `json:"hooks"`
|
|
}
|
|
if err := json.Unmarshal([]byte(body), &settings); err != nil {
|
|
t.Fatalf("the settings file is not valid JSON: %v\n%s", err, body)
|
|
}
|
|
if len(settings.Hooks.PreToolUse) != 1 || settings.Hooks.PreToolUse[0].Matcher != "Bash" {
|
|
t.Fatalf("the hook is not registered on PreToolUse(Bash):\n%s", body)
|
|
}
|
|
command := settings.Hooks.PreToolUse[0].Hooks[0].Command
|
|
if !strings.Contains(command, "kettle mirror --hook") {
|
|
t.Errorf("the registered command is %q", command)
|
|
}
|
|
// The guard outlives the binary: an operator who uninstalls kettle must not
|
|
// get "command not found" on every Bash call from a hook they set up months
|
|
// ago and have stopped thinking about.
|
|
if !strings.Contains(command, "command -v kettle") {
|
|
t.Errorf("the registered command has no guard against kettle being gone: %q", command)
|
|
}
|
|
}
|
|
|
|
// settings.json is a file the operator owns and commits, and Go cannot preserve
|
|
// its key order. So an existing one is never rewritten without being asked
|
|
// twice: the run reports what it did not do, prints the snippet, and succeeds.
|
|
func TestInitRefusesToRewriteAnExistingSettingsFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
settings := filepath.Join(dir, ".claude", "settings.json")
|
|
if err := os.MkdirAll(filepath.Dir(settings), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const theirs = `{ "permissions": { "allow": ["Bash(ls:*)"] } }`
|
|
if err := os.WriteFile(settings, []byte(theirs), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
r := mustRun(t, dir, "init", "--mirror-hook")
|
|
if got := readFile(t, settings); got != theirs {
|
|
t.Errorf("the operator's settings file was rewritten:\n%s", got)
|
|
}
|
|
if !strings.Contains(r.stderr, "kettle mirror") {
|
|
t.Errorf("the refusal did not print the snippet to paste:\n%s", r.out())
|
|
}
|
|
if !strings.Contains(r.stderr, "--force-settings") {
|
|
t.Errorf("the refusal did not name the way past it:\n%s", r.out())
|
|
}
|
|
// Everything before the hook still happened; this is a decision, not a
|
|
// failure, and reporting it as one would send somebody looking for damage.
|
|
if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues")); err != nil {
|
|
t.Error("the refusal rolled back the rest of the run")
|
|
}
|
|
|
|
// Asked twice, it merges — and keeps what was already in the file.
|
|
mustRun(t, dir, "init", "--mirror-hook", "--force-settings")
|
|
merged := readFile(t, settings)
|
|
if !strings.Contains(merged, "kettle mirror") {
|
|
t.Errorf("--force-settings did not add the hook:\n%s", merged)
|
|
}
|
|
if !strings.Contains(merged, "Bash(ls:*)") {
|
|
t.Errorf("--force-settings dropped what was already there:\n%s", merged)
|
|
}
|
|
|
|
// And a third run adds nothing: the hook is already there.
|
|
mustRun(t, dir, "init", "--mirror-hook")
|
|
if again := readFile(t, settings); again != merged {
|
|
t.Errorf("a re-run duplicated the hook:\n%s", again)
|
|
}
|
|
}
|
|
|
|
// The rule that used to be a paragraph in a skill an operator had to read. The
|
|
// wizard is the front door now, and a front door cannot assume anybody read
|
|
// anything.
|
|
func TestInitRefusesALinkedWorktree(t *testing.T) {
|
|
main := t.TempDir()
|
|
worktree := filepath.Join(main, "wt")
|
|
gitdir := filepath.Join(main, ".git", "worktrees", "wt")
|
|
if err := os.MkdirAll(gitdir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(worktree, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(gitdir, "commondir"), []byte("../..\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(worktree, ".git"), []byte("gitdir: "+gitdir+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
r := run(t, worktree, "init")
|
|
if r.code == 0 {
|
|
t.Fatalf("init in a linked worktree must be refused:\n%s", r.out())
|
|
}
|
|
if !strings.Contains(r.stderr, main) {
|
|
t.Errorf("the refusal does not name the main checkout to use instead:\n%s", r.out())
|
|
}
|
|
if _, err := os.Stat(filepath.Join(worktree, ".kettle")); err == nil {
|
|
t.Error("the refused run created a second marker anyway")
|
|
}
|
|
}
|
|
|
|
// --interactive is for a person. Under a test harness, a script or an agent,
|
|
// standard input is a pipe and the answer is an error that names the flags
|
|
// rather than a prompt nobody will ever answer.
|
|
func TestInteractiveRefusesWhatIsNotATerminal(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
r := runWith(t, dir, nil, "\n\n\n", "init", "--interactive")
|
|
if r.code == 0 {
|
|
t.Fatalf("--interactive succeeded without a terminal:\n%s", r.out())
|
|
}
|
|
if !strings.Contains(r.stderr, "terminal") {
|
|
t.Errorf("the refusal does not say what is missing:\n%s", r.out())
|
|
}
|
|
for _, flag := range []string{"--login", "--repo", "--mirror-hook"} {
|
|
if !strings.Contains(r.stderr, flag) {
|
|
t.Errorf("the refusal does not name %s, which is the way through:\n%s", flag, r.out())
|
|
}
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, ".kettle")); err == nil {
|
|
t.Error("the refused run initialized the directory anyway")
|
|
}
|
|
}
|