From 3168917f7faff73c7574b4d833649374db450dbb Mon Sep 17 00:00:00 2001 From: ali Date: Fri, 1 May 2026 15:01:46 +0300 Subject: [PATCH] feat(mcp): --with-output-style auto-activates the style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: "man, keeba install should take care of this honestly" after their interactive Claude Code session rejected `/output-style keeba` and `/style keeba` as Unknown commands. Diagnosis: Claude Code v2.x removed (or renamed) the per-session output-style slash command. The only stable activation surface across versions is `outputStyle: ""` in ~/.claude/settings.json. Without flipping that key, --with-output-style was a UX cliff: the file landed on disk, the user had no way to activate it, and every session kept emitting the chatty default style. Fix: install now does both halves automatically. After dropping ~/.claude/output-styles/keeba.md, it sets outputStyle=keeba in ~/.claude/settings.json, preserving every other key (theme, hooks, effortLevel — critical because the install just registered the UserPromptSubmit hook in the same hooks block). Idempotent on both halves. Re-running prints "no change" for the file path, the style activation, and the MCP server registration. Tests: fresh-file creation, preserves-other-keys (with realistic hooks block), idempotent re-run, overwrites-different-style. End-to-end install against a synthetic HOME confirms settings.json picks up outputStyle=keeba while keeping theme + effortLevel + everything else. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/cli/mcp_install.go | 19 +++++- internal/cli/outputstyle.go | 48 +++++++++++++ internal/cli/outputstyle_test.go | 112 +++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 1 deletion(-) diff --git a/internal/cli/mcp_install.go b/internal/cli/mcp_install.go index a905d7e..cc0beec 100644 --- a/internal/cli/mcp_install.go +++ b/internal/cli/mcp_install.go @@ -183,11 +183,28 @@ func applyClaudeCodePatches(cmd *cobra.Command, patchAgents, withClaudeMD, withH } if changed { _, _ = fmt.Fprintf(cmd.OutOrStdout(), - "installed keeba output style at %s — activate per-session with `/output-style keeba` (suppresses preamble + tool-result restatement + closing summaries; output tokens drop, dollar cost drops with them)\n", path) + "installed keeba output style at %s\n", path) } else { _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s already has the keeba output style (no change)\n", path) } + // Also flip the user's settings.json so the style is *active* + // on every session — the file alone does nothing, the slash + // command was renamed/removed in Claude Code v2.x, settings.json + // is the only stable activation surface. Without this step the + // install is a UX cliff: file on disk, no effect. + settingsPath := filepath.Join(home, ".claude", "settings.json") + activated, err := activateKeebaOutputStyle(settingsPath) + if err != nil { + return fmt.Errorf("activate output style: %w", err) + } + if activated { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), + "set outputStyle=keeba in %s — active on next Claude Code launch (terse style suppresses preamble + restatement + inter-tool prose; tokens drop, dollar cost follows)\n", settingsPath) + } else { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), + "%s already has outputStyle=keeba (no change)\n", settingsPath) + } } return nil } diff --git a/internal/cli/outputstyle.go b/internal/cli/outputstyle.go index eaecabd..b187672 100644 --- a/internal/cli/outputstyle.go +++ b/internal/cli/outputstyle.go @@ -1,6 +1,8 @@ package cli import ( + "encoding/json" + "fmt" "os" "path/filepath" ) @@ -145,3 +147,49 @@ func installKeebaOutputStyle(path string) (bool, error) { } return true, nil } + +// activateKeebaOutputStyle sets `outputStyle: "keeba"` in the user's +// Claude Code settings.json so the style is active on every session +// without the user having to type a slash command. v2.x of Claude Code +// removed (or renamed) the per-session `/output-style ` slash +// command — settings.json is the only stable activation surface across +// versions. Other keys are preserved (theme, hooks, effortLevel, etc.). +// +// Idempotent: re-runs return (changed=false, nil) when the field is +// already set to "keeba". +// +// Without this step, --with-output-style only drops the file on disk +// and the user is left wondering why their session still emits the old +// chatty style. Real reproducible UX cliff — fixing it inside the +// install path, not in docs. +func activateKeebaOutputStyle(settingsPath string) (bool, error) { + body, err := os.ReadFile(settingsPath) //nolint:gosec + if err != nil && !os.IsNotExist(err) { + return false, err + } + + settings := map[string]any{} + if len(body) > 0 { + if err := json.Unmarshal(body, &settings); err != nil { + return false, fmt.Errorf("parse %s: %w", settingsPath, err) + } + } + + if cur, _ := settings["outputStyle"].(string); cur == "keeba" { + return false, nil + } + settings["outputStyle"] = "keeba" + + out, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return false, err + } + out = append(out, '\n') + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil { + return false, err + } + if err := os.WriteFile(settingsPath, out, 0o644); err != nil { + return false, err + } + return true, nil +} diff --git a/internal/cli/outputstyle_test.go b/internal/cli/outputstyle_test.go index 9c22a8d..3f02544 100644 --- a/internal/cli/outputstyle_test.go +++ b/internal/cli/outputstyle_test.go @@ -1,6 +1,7 @@ package cli import ( + "encoding/json" "os" "path/filepath" "strings" @@ -76,6 +77,117 @@ func TestInstallKeebaOutputStyle_OverwritesStale(t *testing.T) { } } +func TestActivateKeebaOutputStyle_FreshFile(t *testing.T) { + // settings.json doesn't exist yet — activation must create it with + // just the outputStyle key. This is the new-user path. + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + changed, err := activateKeebaOutputStyle(path) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Errorf("expected change=true on fresh file") + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("settings.json not written: %v", err) + } + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("settings.json not valid JSON: %v", err) + } + if got["outputStyle"] != "keeba" { + t.Errorf("outputStyle field = %v, want \"keeba\"", got["outputStyle"]) + } +} + +func TestActivateKeebaOutputStyle_PreservesExistingSettings(t *testing.T) { + // Real-world settings.json has unrelated keys (theme, hooks, + // effortLevel, etc.). Activation must merge, not clobber. If we + // blow away their hooks config, we'd silently break the + // UserPromptSubmit hook the same install just registered. + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + original := `{ + "theme": "dark", + "effortLevel": "high", + "hooks": { + "UserPromptSubmit": [{"hooks":[{"type":"command","command":"keeba hook user-prompt-submit"}]}] + } +}` + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + if _, err := activateKeebaOutputStyle(path); err != nil { + t.Fatal(err) + } + body, _ := os.ReadFile(path) + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("settings.json not valid JSON after activation: %v", err) + } + if got["outputStyle"] != "keeba" { + t.Errorf("outputStyle missing or wrong: %v", got["outputStyle"]) + } + if got["theme"] != "dark" { + t.Errorf("theme clobbered: %v", got["theme"]) + } + if got["effortLevel"] != "high" { + t.Errorf("effortLevel clobbered: %v", got["effortLevel"]) + } + if got["hooks"] == nil { + t.Errorf("hooks dropped — would silently break UserPromptSubmit hook") + } +} + +func TestActivateKeebaOutputStyle_Idempotent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + if _, err := activateKeebaOutputStyle(path); err != nil { + t.Fatal(err) + } + first, _ := os.ReadFile(path) + changed, err := activateKeebaOutputStyle(path) + if err != nil { + t.Fatal(err) + } + if changed { + t.Errorf("second activation should be no-op") + } + second, _ := os.ReadFile(path) + if string(first) != string(second) { + t.Errorf("idempotent activation changed bytes") + } +} + +func TestActivateKeebaOutputStyle_OverwritesDifferentStyle(t *testing.T) { + // User had a different output style set; re-running install with + // --with-output-style replaces it with keeba. This is the + // "intentional re-install" path — don't preserve a stale value. + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + if err := os.WriteFile(path, []byte(`{"outputStyle":"verbose","theme":"dark"}`), 0o644); err != nil { + t.Fatal(err) + } + changed, err := activateKeebaOutputStyle(path) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Errorf("expected change=true when overwriting different style") + } + body, _ := os.ReadFile(path) + var got map[string]any + _ = json.Unmarshal(body, &got) + if got["outputStyle"] != "keeba" { + t.Errorf("outputStyle = %v, want \"keeba\"", got["outputStyle"]) + } + if got["theme"] != "dark" { + t.Errorf("theme dropped during overwrite: %v", got["theme"]) + } +} + func TestKeebaOutputStyle_PinsCriticalPhrases(t *testing.T) { // Style content is the actual lever that attacks output-token // bloat. Pin the directives so future edits can't quietly soften