package cmd import ( "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "strings" ) // mirrorHookCommand is what gets registered on PreToolUse(Bash). // // The `command -v` guard is not decoration. This line outlives the binary that // wrote it: an operator who uninstalls kettle, or moves it off PATH, would // otherwise get a "command not found" on every Bash call in this project, from a // hook they set up months ago and have long stopped thinking about. The guard // makes the failure mode silence. const mirrorHookCommand = `command -v kettle >/dev/null && kettle mirror --hook || true` // mirrorHookSnippet is what an operator is shown when the merge is not this // command's to make. const mirrorHookSnippet = `{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "` + mirrorHookCommand + `" } ] } ] } }` // errSettingsExist means the file is there, does not hold the hook, and merging // it is a decision rather than a step. var errSettingsExist = errors.New("settings file already exists") // settingsPath is `/settings.json` — the shared file, not // settings.local.json. The convention this registers is a property of a // repository rather than of one developer's checkout, so it belongs in the file // that is committed. func settingsPath(out string) string { return filepath.Join(out, "settings.json") } // writeMirrorHook registers the PreToolUse hook, and refuses to rewrite a file // it did not create. // // Three outcomes, and the third is the interesting one: // // - no file: it is written, hook and all. // - a file already holding a `kettle mirror` hook: nothing happens. // - a file holding something else: REFUSED unless force, and the snippet is // printed for the operator to paste. // // That refusal is deliberate and is the only reason this file is not a dozen // lines shorter. settings.json is a file the operator owns and commits, and Go's // encoding/json cannot preserve key order — so any merge reformats the whole // document, and an operator who asked for a documentation hook would find an // unrelated diff across a file they share with their team. A snippet they paste // costs them ten seconds; a reformat costs them a review. func writeMirrorHook(out string, force, dryRun bool) (string, error) { path := settingsPath(out) rel := filepath.Join(filepath.Base(out), "settings.json") raw, err := os.ReadFile(path) switch { case errors.Is(err, fs.ErrNotExist): if dryRun { return fmt.Sprintf("created %s (PreToolUse: kettle mirror --hook)", rel), nil } if err := writeFile(path, mirrorHookSnippet+"\n"); err != nil { return "", err } return fmt.Sprintf("created %s (PreToolUse: kettle mirror --hook)", rel), nil case err != nil: return "", err } var settings map[string]any if err := json.Unmarshal(raw, &settings); err != nil { return "", Fail("%s is not readable as JSON (%v) — fix it, or add the hook by hand:\n\n%s", path, err, mirrorHookSnippet) } if strings.Contains(string(raw), "kettle mirror") { return "", nil // already registered; nothing to do and nothing to say } if !force { return "", fmt.Errorf("%w: %s. Add this to it, or re-run with --force-settings to have kettle merge it (which reformats the file):\n\n%s", errSettingsExist, path, mirrorHookSnippet) } merged, err := mergeMirrorHook(settings) if err != nil { return "", err } if dryRun { return fmt.Sprintf("merged the hook into %s (reformatting it)", rel), nil } body, err := json.MarshalIndent(merged, "", " ") if err != nil { return "", err } if err := writeFile(path, string(body)+"\n"); err != nil { return "", err } return fmt.Sprintf("merged the hook into %s (reformatting it)", rel), nil } // mergeMirrorHook appends the hook to whatever PreToolUse already holds, // creating the path if it is not there. Nothing existing is removed or // reordered — what is lost is key order, which JSON does not carry, and that // is the whole reason this is behind a flag. func mergeMirrorHook(settings map[string]any) (map[string]any, error) { if settings == nil { settings = map[string]any{} } hooks, _ := settings["hooks"].(map[string]any) if hooks == nil { hooks = map[string]any{} } pre, _ := hooks["PreToolUse"].([]any) pre = append(pre, map[string]any{ "matcher": "Bash", "hooks": []any{ map[string]any{"type": "command", "command": mirrorHookCommand}, }, }) hooks["PreToolUse"] = pre settings["hooks"] = hooks return settings, nil }