Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion internal/cli/mcp_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
48 changes: 48 additions & 0 deletions internal/cli/outputstyle.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cli

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
Expand Down Expand Up @@ -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 <name>` 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
}
112 changes: 112 additions & 0 deletions internal/cli/outputstyle_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"encoding/json"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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
Expand Down
Loading