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") } }