fix(config): replace automatic JSON merging with explicit merge rules

Carlos Alexandro Becker created

Replace go-jsons automatic merging with manual merge() methods that
define explicit behavior per field. This gives full control over how
config files are layered (global -> user -> project).

Merge behaviors:
- Booleans: true if any config has true (Disabled, Debug, Progress)
- Strings: later value replaces earlier (Model, InitializeAs)
- Slices (paths): appended, sorted, deduped (SkillsPaths, DisabledTools)
- Slices (args): later replaces entirely (LSP Args)
- Maps: merged, later values overwrite keys (Env, Headers)
- Timeouts: max value wins
- Pointers: later non-nil replaces earlier

Added merge() methods: Config, Options, MCPConfig, LSPConfig, TUIOptions,
Tools, ProviderConfig. Documented rules in AGENTS.md.

Assisted-by: Claude Opus 4.5 via Crush <crush@charm.land>
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

Change summary

AGENTS.md                     |   5 
go.mod                        |   1 
go.sum                        |   2 
internal/agent/coordinator.go |  51 --
internal/config/AGENTS.md     |  27 +
internal/config/config.go     | 145 ++++++
internal/config/load.go       |  32 +
internal/config/merge_test.go | 812 +++++++++++++++++++++++++++++++++++++
8 files changed, 1,018 insertions(+), 57 deletions(-)

Detailed changes

AGENTS.md 🔗

@@ -73,5 +73,8 @@ func TestYourFunction(t *testing.T) {
 - Try to keep commits to one line, not including your attribution. Only use
   multi-line commits when additional context is truly necessary.
 
+## Working on Configuration
+Anytime you need to work on the config before starting work read the internal/config/AGENTS.md file.
+
 ## Working on the TUI (UI)
-Anytime you need to work on the tui before starting work read the internal/ui/AGENTS.md file
+Anytime you need to work on the tui before starting work read the internal/ui/AGENTS.md file.

go.mod 🔗

@@ -54,7 +54,6 @@ require (
 	github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
 	github.com/posthog/posthog-go v1.9.1
 	github.com/pressly/goose/v3 v3.26.0
-	github.com/qjebbs/go-jsons v1.0.0-alpha.4
 	github.com/rivo/uniseg v0.4.7
 	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
 	github.com/sahilm/fuzzy v0.1.1

go.sum 🔗

@@ -304,8 +304,6 @@ github.com/posthog/posthog-go v1.9.1 h1:9bkcRnYSvcgMxL2s9QlCnd1DVnm2qWXxWu5o0HSF
 github.com/posthog/posthog-go v1.9.1/go.mod h1:wB3/9Q7d9gGb1P/yf/Wri9VBlbP8oA8z++prRzL5OcY=
 github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
 github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
-github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs=
-github.com/qjebbs/go-jsons v1.0.0-alpha.4/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=

internal/agent/coordinator.go 🔗

@@ -1,13 +1,10 @@
 package agent
 
 import (
-	"bytes"
 	"cmp"
 	"context"
-	"encoding/json"
 	"errors"
 	"fmt"
-	"io"
 	"log/slog"
 	"maps"
 	"net/http"
@@ -41,7 +38,6 @@ import (
 	"charm.land/fantasy/providers/openrouter"
 	"charm.land/fantasy/providers/vercel"
 	openaisdk "github.com/openai/openai-go/v2/option"
-	"github.com/qjebbs/go-jsons"
 )
 
 type Coordinator interface {
@@ -199,49 +195,16 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
 func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
 	options := fantasy.ProviderOptions{}
 
-	cfgOpts := []byte("{}")
-	providerCfgOpts := []byte("{}")
-	catwalkOpts := []byte("{}")
-
-	if model.ModelCfg.ProviderOptions != nil {
-		data, err := json.Marshal(model.ModelCfg.ProviderOptions)
-		if err == nil {
-			cfgOpts = data
-		}
-	}
-
-	if providerCfg.ProviderOptions != nil {
-		data, err := json.Marshal(providerCfg.ProviderOptions)
-		if err == nil {
-			providerCfgOpts = data
-		}
-	}
-
+	// Merge provider options: catwalk (base) -> provider config -> model config (highest priority)
+	mergedOptions := make(map[string]any)
 	if model.CatwalkCfg.Options.ProviderOptions != nil {
-		data, err := json.Marshal(model.CatwalkCfg.Options.ProviderOptions)
-		if err == nil {
-			catwalkOpts = data
-		}
-	}
-
-	readers := []io.Reader{
-		bytes.NewReader(catwalkOpts),
-		bytes.NewReader(providerCfgOpts),
-		bytes.NewReader(cfgOpts),
+		maps.Copy(mergedOptions, model.CatwalkCfg.Options.ProviderOptions)
 	}
-
-	got, err := jsons.Merge(readers)
-	if err != nil {
-		slog.Error("Could not merge call config", "err", err)
-		return options
+	if providerCfg.ProviderOptions != nil {
+		maps.Copy(mergedOptions, providerCfg.ProviderOptions)
 	}
-
-	mergedOptions := make(map[string]any)
-
-	err = json.Unmarshal([]byte(got), &mergedOptions)
-	if err != nil {
-		slog.Error("Could not create config for call", "err", err)
-		return options
+	if model.ModelCfg.ProviderOptions != nil {
+		maps.Copy(mergedOptions, model.ModelCfg.ProviderOptions)
 	}
 
 	providerType := providerCfg.Type

internal/config/AGENTS.md 🔗

@@ -0,0 +1,27 @@
+# Configuration Development Guide
+
+## Merge Rules
+
+When adding new fields to config structs (`Config`, `Options`, `MCPConfig`, `LSPConfig`, `TUIOptions`, `Tools`, `ProviderConfig`), you **must** update the corresponding `merge()` method in `config.go` and add test cases to `merge_test.go`.
+
+### Merge Behavior Patterns
+
+Each field type has a specific merge strategy:
+
+| Type | Strategy | Example |
+|------|----------|---------|
+| **Booleans** | `true` if any config has `true` | `Disabled`, `Debug`, `Progress` |
+| **Strings** | Later value replaces earlier | `Model`, `InitializeAs`, `TrailerStyle` |
+| **Slices (paths/tools)** | Appended, sorted, deduped | `SkillsPaths`, `DisabledTools` |
+| **Slices (args)** | Later replaces earlier entirely | `Args` in LSPConfig |
+| **Maps** | Merged, later values overwrite keys | `Env`, `Headers`, `Options` |
+| **Timeouts** | Max value wins | `Timeout` in MCPConfig/LSPConfig |
+| **Pointers** | Later non-nil replaces earlier | `MaxTokens`, `Temperature` |
+| **Structs** | Call sub-struct's `merge()` method | `TUI`, `Tools` |
+
+### Adding a New Config Field
+
+1. Add the field to the appropriate struct in `config.go`
+2. Add merge logic to the struct's `merge()` method following the patterns above
+3. Add a test case in `merge_test.go` verifying the merge behavior
+4. Run `go test ./internal/config/... -v -run TestConfigMerging` to verify

internal/config/config.go 🔗

@@ -163,6 +163,26 @@ func (pc *ProviderConfig) SetupGitHubCopilot() {
 	maps.Copy(pc.ExtraHeaders, copilot.Headers())
 }
 
+func (pc ProviderConfig) merge(t ProviderConfig) ProviderConfig {
+	pc.ID = cmp.Or(t.ID, pc.ID)
+	pc.Name = cmp.Or(t.Name, pc.Name)
+	pc.BaseURL = cmp.Or(t.BaseURL, pc.BaseURL)
+	pc.Type = cmp.Or(t.Type, pc.Type)
+	pc.APIKey = cmp.Or(t.APIKey, pc.APIKey)
+	pc.APIKeyTemplate = cmp.Or(t.APIKeyTemplate, pc.APIKeyTemplate)
+	pc.OAuthToken = cmp.Or(t.OAuthToken, pc.OAuthToken)
+	pc.Disable = pc.Disable || t.Disable
+	pc.SystemPromptPrefix = cmp.Or(t.SystemPromptPrefix, pc.SystemPromptPrefix)
+	pc.ExtraHeaders = mergeMaps(pc.ExtraHeaders, t.ExtraHeaders)
+	pc.ExtraBody = mergeMaps(pc.ExtraBody, t.ExtraBody)
+	pc.ProviderOptions = mergeMaps(pc.ProviderOptions, t.ProviderOptions)
+	pc.ExtraParams = mergeMaps(pc.ExtraParams, t.ExtraParams)
+	if len(t.Models) > 0 {
+		pc.Models = t.Models
+	}
+	return pc
+}
+
 type MCPType string
 
 const (
@@ -185,6 +205,21 @@ type MCPConfig struct {
 	Headers map[string]string `json:"headers,omitempty" jsonschema:"description=HTTP headers for HTTP/SSE MCP servers"`
 }
 
+func (m MCPConfig) merge(o MCPConfig) MCPConfig {
+	m.Env = mergeMaps(m.Env, o.Env)
+	m.Headers = mergeMaps(m.Headers, o.Headers)
+	m.Disabled = m.Disabled || o.Disabled
+	m.DisabledTools = append(m.DisabledTools, o.DisabledTools...)
+	m.Timeout = max(m.Timeout, o.Timeout)
+	m.Command = cmp.Or(o.Command, m.Command)
+	if len(o.Args) > 0 {
+		m.Args = o.Args
+	}
+	m.Type = cmp.Or(o.Type, m.Type)
+	m.URL = cmp.Or(o.URL, m.URL)
+	return m
+}
+
 type LSPConfig struct {
 	Disabled    bool              `json:"disabled,omitempty" jsonschema:"description=Whether this LSP server is disabled,default=false"`
 	Command     string            `json:"command,omitempty" jsonschema:"description=Command to execute for the LSP server,example=gopls"`
@@ -197,6 +232,21 @@ type LSPConfig struct {
 	Timeout     int               `json:"timeout,omitempty" jsonschema:"description=Timeout in seconds for LSP server initialization,default=30,example=60,example=120"`
 }
 
+func (l LSPConfig) merge(o LSPConfig) LSPConfig {
+	l.Env = mergeMaps(l.Env, o.Env)
+	l.InitOptions = mergeMaps(l.InitOptions, o.InitOptions)
+	l.Options = mergeMaps(l.Options, o.Options)
+	l.RootMarkers = sortedCompact(append(l.RootMarkers, o.RootMarkers...))
+	l.FileTypes = sortedCompact(append(l.FileTypes, o.FileTypes...))
+	l.Disabled = l.Disabled || o.Disabled
+	l.Timeout = max(l.Timeout, o.Timeout)
+	if len(o.Args) > 0 {
+		l.Args = o.Args
+	}
+	l.Command = cmp.Or(o.Command, l.Command)
+	return l
+}
+
 type TUIOptions struct {
 	CompactMode bool   `json:"compact_mode,omitempty" jsonschema:"description=Enable compact mode for the TUI interface,default=false"`
 	DiffMode    string `json:"diff_mode,omitempty" jsonschema:"description=Diff mode for the TUI interface,enum=unified,enum=split"`
@@ -207,6 +257,15 @@ type TUIOptions struct {
 	Transparent *bool       `json:"transparent,omitempty" jsonschema:"description=Enable transparent background for the TUI interface,default=false"`
 }
 
+func (o TUIOptions) merge(t TUIOptions) TUIOptions {
+	o.CompactMode = o.CompactMode || t.CompactMode
+	o.DiffMode = cmp.Or(t.DiffMode, o.DiffMode)
+	o.Completions.MaxDepth = cmp.Or(t.Completions.MaxDepth, o.Completions.MaxDepth)
+	o.Completions.MaxItems = cmp.Or(t.Completions.MaxItems, o.Completions.MaxItems)
+	o.Transparent = cmp.Or(t.Transparent, o.Transparent)
+	return o
+}
+
 // Completions defines options for the completions UI.
 type Completions struct {
 	MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"`
@@ -263,6 +322,32 @@ type Options struct {
 	Progress                  *bool        `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"`
 }
 
+func (o Options) merge(t Options) Options {
+	o.ContextPaths = append(o.ContextPaths, t.ContextPaths...)
+	o.SkillsPaths = append(o.SkillsPaths, t.SkillsPaths...)
+	o.Debug = o.Debug || t.Debug
+	o.DebugLSP = o.DebugLSP || t.DebugLSP
+	o.DisableAutoSummarize = o.DisableAutoSummarize || t.DisableAutoSummarize
+	o.DisableProviderAutoUpdate = o.DisableProviderAutoUpdate || t.DisableProviderAutoUpdate
+	o.DisableDefaultProviders = o.DisableDefaultProviders || t.DisableDefaultProviders
+	o.DisableMetrics = o.DisableMetrics || t.DisableMetrics
+	o.DataDirectory = cmp.Or(t.DataDirectory, o.DataDirectory)
+	o.InitializeAs = cmp.Or(t.InitializeAs, o.InitializeAs)
+	o.DisabledTools = append(o.DisabledTools, t.DisabledTools...)
+	o.AutoLSP = cmp.Or(t.AutoLSP, o.AutoLSP)
+	o.Progress = cmp.Or(t.Progress, o.Progress)
+	*o.TUI = o.TUI.merge(*t.TUI)
+	if t.Attribution != nil {
+		if o.Attribution == nil {
+			o.Attribution = &Attribution{}
+		}
+		o.Attribution.TrailerStyle = cmp.Or(t.Attribution.TrailerStyle, o.Attribution.TrailerStyle)
+		o.Attribution.CoAuthoredBy = cmp.Or(t.Attribution.CoAuthoredBy, o.Attribution.CoAuthoredBy)
+		o.Attribution.GeneratedWith = o.Attribution.GeneratedWith || t.Attribution.GeneratedWith
+	}
+	return o
+}
+
 type MCPs map[string]MCPConfig
 
 type MCP struct {
@@ -353,6 +438,12 @@ type Tools struct {
 	Ls ToolLs `json:"ls,omitempty"`
 }
 
+func (o Tools) merge(t Tools) Tools {
+	o.Ls.MaxDepth = cmp.Or(t.Ls.MaxDepth, o.Ls.MaxDepth)
+	o.Ls.MaxItems = cmp.Or(t.Ls.MaxItems, o.Ls.MaxItems)
+	return o
+}
+
 type ToolLs struct {
 	MaxDepth *int `json:"max_depth,omitempty" jsonschema:"description=Maximum depth for the ls tool,default=0,example=10"`
 	MaxItems *int `json:"max_items,omitempty" jsonschema:"description=Maximum number of items to return for the ls tool,default=1000,example=100"`
@@ -395,6 +486,47 @@ type Config struct {
 	knownProviders []catwalk.Provider `json:"-"`
 }
 
+func (c Config) merge(t Config) Config {
+	for name, mcp := range t.MCP {
+		existing, ok := c.MCP[name]
+		if !ok {
+			c.MCP[name] = mcp
+			continue
+		}
+		c.MCP[name] = existing.merge(mcp)
+	}
+	for name, lsp := range t.LSP {
+		existing, ok := c.LSP[name]
+		if !ok {
+			c.LSP[name] = lsp
+			continue
+		}
+		c.LSP[name] = existing.merge(lsp)
+	}
+	// simple override
+	maps.Copy(c.Models, t.Models)
+	c.Schema = cmp.Or(c.Schema, t.Schema)
+	if t.Options != nil {
+		*c.Options = c.Options.merge(*t.Options)
+	}
+	if t.Permissions != nil {
+		c.Permissions.AllowedTools = append(c.Permissions.AllowedTools, t.Permissions.AllowedTools...)
+	}
+	if c.Providers != nil {
+		for key, value := range t.Providers.Seq2() {
+			existing, ok := c.Providers.Get(key)
+			if !ok {
+				c.Providers.Set(key, value)
+				continue
+			}
+			c.Providers.Set(key, existing.merge(value))
+		}
+	}
+	c.Tools = c.Tools.merge(t.Tools)
+
+	return c
+}
+
 func (c *Config) WorkingDir() string {
 	return c.workingDir
 }
@@ -860,3 +992,16 @@ func ptrValOr[T any](t *T, el T) T {
 	}
 	return *t
 }
+
+func mergeMaps[K comparable, V any](base, overlay map[K]V) map[K]V {
+	if base == nil {
+		base = make(map[K]V)
+	}
+	maps.Copy(base, overlay)
+	return base
+}
+
+func sortedCompact[S ~[]E, E cmp.Ordered](s S) S {
+	slices.Sort(s)
+	return slices.Compact(s)
+}

internal/config/load.go 🔗

@@ -24,7 +24,6 @@ import (
 	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/log"
 	powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
-	"github.com/qjebbs/go-jsons"
 )
 
 const defaultCatwalkURL = "https://catwalk.charm.sh"
@@ -675,15 +674,15 @@ func loadFromBytes(configs [][]byte) (*Config, error) {
 		return &Config{}, nil
 	}
 
-	data, err := jsons.Merge(configs)
-	if err != nil {
-		return nil, err
-	}
-	var config Config
-	if err := json.Unmarshal(data, &config); err != nil {
-		return nil, err
+	result := newConfig()
+	for _, bts := range configs {
+		config := newConfig()
+		if err := json.Unmarshal(bts, &config); err != nil {
+			return nil, err
+		}
+		*result = result.merge(*config)
 	}
-	return &config, nil
+	return result, nil
 }
 
 func hasVertexCredentials(env env.Env) bool {
@@ -799,3 +798,18 @@ func GlobalSkillsDirs() []string {
 }
 
 func isAppleTerminal() bool { return os.Getenv("TERM_PROGRAM") == "Apple_Terminal" }
+
+func newConfig() *Config {
+	return &Config{
+		Agents:       map[string]Agent{},
+		MCP:          map[string]MCPConfig{},
+		LSP:          map[string]LSPConfig{},
+		Models:       map[SelectedModelType]SelectedModel{},
+		RecentModels: map[SelectedModelType][]SelectedModel{},
+		Options: &Options{
+			TUI: &TUIOptions{},
+		},
+		Permissions: &Permissions{},
+		Providers:   csync.NewMap[string, ProviderConfig](),
+	}
+}

internal/config/merge_test.go 🔗

@@ -0,0 +1,812 @@
+package config
+
+import (
+	"encoding/json"
+	"maps"
+	"slices"
+	"testing"
+
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/stretchr/testify/require"
+)
+
+// TestConfigMerging defines the rules on how configuration merging works.
+// Generally, things are either appended to or replaced by the later configuration.
+// Whether one or the other happen depends on effects its effects.
+func TestConfigMerging(t *testing.T) {
+	t.Run("empty", func(t *testing.T) {
+		c := exerciseMerge(t, Config{}, Config{})
+		require.NotNil(t, c)
+	})
+
+	t.Run("mcps", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			MCP: MCPs{
+				"foo": {
+					Command: "foo-mcp",
+					Args:    []string{"serve"},
+					Type:    MCPSSE,
+					Timeout: 10,
+				},
+				"zaz": {
+					Disabled: true,
+					Env:      map[string]string{"FOO": "bar"},
+					Headers:  map[string]string{"api-key": "exposed"},
+					URL:      "nope",
+				},
+			},
+		}, Config{
+			MCP: MCPs{
+				"foo": {
+					Args:    []string{"serve", "--stdio"},
+					Type:    MCPStdio,
+					Timeout: 7,
+				},
+				"bar": {
+					Command: "bar",
+				},
+				"zaz": {
+					Env:     map[string]string{"FOO": "foo", "BAR": "bar"},
+					Headers: map[string]string{"api-key": "$API"},
+					URL:     "http://bar",
+				},
+			},
+		})
+		require.NotNil(t, c)
+		require.Len(t, slices.Collect(maps.Keys(c.MCP)), 3)
+
+		// foo: merged from both configs
+		foo := c.MCP["foo"]
+		require.Equal(t, "foo-mcp", foo.Command)
+		require.Equal(t, []string{"serve", "--stdio"}, foo.Args)
+		require.Equal(t, MCPStdio, foo.Type)
+		require.Equal(t, 10, foo.Timeout) // max of 10 and 7
+
+		// bar: only in second config
+		require.Equal(t, "bar", c.MCP["bar"].Command)
+
+		// zaz: merged, env/headers merged, disabled stays true
+		zaz := c.MCP["zaz"]
+		require.True(t, zaz.Disabled)
+		require.Equal(t, "http://bar", zaz.URL)
+		require.Equal(t, "foo", zaz.Env["FOO"]) // overwritten
+		require.Equal(t, "bar", zaz.Env["BAR"]) // added
+		require.Equal(t, "$API", zaz.Headers["api-key"])
+	})
+
+	t.Run("lsps", func(t *testing.T) {
+		result := exerciseMerge(t, Config{
+			LSP: LSPs{
+				"gopls": LSPConfig{
+					Env:         map[string]string{"FOO": "bar"},
+					RootMarkers: []string{"go.sum"},
+					FileTypes:   []string{"go"},
+				},
+			},
+		}, Config{
+			LSP: LSPs{
+				"gopls": LSPConfig{
+					Command:     "gopls",
+					InitOptions: map[string]any{"a": 10},
+					RootMarkers: []string{"go.sum"},
+				},
+			},
+		}, Config{
+			LSP: LSPs{
+				"gopls": LSPConfig{
+					Args:        []string{"serve", "--stdio"},
+					InitOptions: map[string]any{"a": 12, "b": 18},
+					RootMarkers: []string{"go.sum", "go.mod"},
+					FileTypes:   []string{"go"},
+					Disabled:    true,
+				},
+			},
+		},
+			Config{
+				LSP: LSPs{
+					"gopls": LSPConfig{
+						Options:     map[string]any{"opt1": "10"},
+						RootMarkers: []string{"go.work"},
+					},
+				},
+			},
+		)
+		require.NotNil(t, result)
+		require.Equal(t, LSPConfig{
+			Disabled:    true,
+			Command:     "gopls",
+			Args:        []string{"serve", "--stdio"},
+			Env:         map[string]string{"FOO": "bar"},
+			FileTypes:   []string{"go"},
+			RootMarkers: []string{"go.mod", "go.sum", "go.work"},
+			InitOptions: map[string]any{"a": 12.0, "b": 18.0},
+			Options:     map[string]any{"opt1": "10"},
+		}, result.LSP["gopls"])
+	})
+
+	t.Run("tui_options", func(t *testing.T) {
+		maxDepth := 5
+		maxItems := 100
+		newMaxDepth := 10
+		newMaxItems := 200
+
+		c := exerciseMerge(t, Config{
+			Options: &Options{
+				TUI: &TUIOptions{
+					CompactMode: false,
+					DiffMode:    "unified",
+					Completions: Completions{
+						MaxDepth: &maxDepth,
+						MaxItems: &maxItems,
+					},
+				},
+			},
+		}, Config{
+			Options: &Options{
+				TUI: &TUIOptions{
+					CompactMode: true,
+					DiffMode:    "split",
+					Completions: Completions{
+						MaxDepth: &newMaxDepth,
+						MaxItems: &newMaxItems,
+					},
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.True(t, c.Options.TUI.CompactMode)
+		require.Equal(t, "split", c.Options.TUI.DiffMode)
+		require.Equal(t, newMaxDepth, *c.Options.TUI.Completions.MaxDepth)
+	})
+
+	t.Run("options", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			Options: &Options{
+				ContextPaths:              []string{"CRUSH.md"},
+				Debug:                     false,
+				DebugLSP:                  false,
+				DisableProviderAutoUpdate: false,
+				DisableMetrics:            false,
+				DataDirectory:             ".crush",
+				DisabledTools:             []string{"bash"},
+				Attribution: &Attribution{
+					TrailerStyle:  TrailerStyleNone,
+					GeneratedWith: false,
+				},
+				TUI: &TUIOptions{},
+			},
+		}, Config{
+			Options: &Options{
+				ContextPaths:              []string{".cursorrules"},
+				Debug:                     true,
+				DebugLSP:                  true,
+				DisableProviderAutoUpdate: true,
+				DisableMetrics:            true,
+				DataDirectory:             ".custom",
+				DisabledTools:             []string{"edit"},
+				Attribution: &Attribution{
+					TrailerStyle:  TrailerStyleCoAuthoredBy,
+					GeneratedWith: true,
+				},
+				TUI: &TUIOptions{},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, []string{"CRUSH.md", ".cursorrules"}, c.Options.ContextPaths)
+		require.True(t, c.Options.Debug)
+		require.True(t, c.Options.DebugLSP)
+		require.True(t, c.Options.DisableProviderAutoUpdate)
+		require.True(t, c.Options.DisableMetrics)
+		require.Equal(t, ".custom", c.Options.DataDirectory)
+		require.Equal(t, []string{"bash", "edit"}, c.Options.DisabledTools)
+		require.Equal(t, TrailerStyleCoAuthoredBy, c.Options.Attribution.TrailerStyle)
+		require.True(t, c.Options.Attribution.GeneratedWith)
+	})
+
+	t.Run("tools", func(t *testing.T) {
+		maxDepth := 5
+		maxItems := 100
+		newMaxDepth := 10
+		newMaxItems := 200
+
+		c := exerciseMerge(t, Config{
+			Tools: Tools{
+				Ls: ToolLs{
+					MaxDepth: &maxDepth,
+					MaxItems: &maxItems,
+				},
+			},
+		}, Config{
+			Tools: Tools{
+				Ls: ToolLs{
+					MaxDepth: &newMaxDepth,
+					MaxItems: &newMaxItems,
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, newMaxDepth, *c.Tools.Ls.MaxDepth)
+	})
+
+	t.Run("models", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			Models: map[SelectedModelType]SelectedModel{
+				"large": {
+					Model:    "gpt-4",
+					Provider: "openai",
+				},
+			},
+		}, Config{
+			Models: map[SelectedModelType]SelectedModel{
+				"large": {
+					Model:    "gpt-4o",
+					Provider: "openai",
+				},
+				"small": {
+					Model:    "gpt-3.5-turbo",
+					Provider: "openai",
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Len(t, c.Models, 2)
+		require.Equal(t, "gpt-4o", c.Models["large"].Model)
+		require.Equal(t, "gpt-3.5-turbo", c.Models["small"].Model)
+	})
+
+	t.Run("schema", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			Schema: "https://example.com/schema.json",
+		}, Config{
+			Schema: "https://example.com/new-schema.json",
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, "https://example.com/schema.json", c.Schema)
+	})
+
+	t.Run("schema_empty_first", func(t *testing.T) {
+		c := exerciseMerge(t, Config{}, Config{
+			Schema: "https://example.com/schema.json",
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, "https://example.com/schema.json", c.Schema)
+	})
+
+	t.Run("permissions", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			Permissions: &Permissions{
+				AllowedTools: []string{"bash", "view"},
+			},
+		}, Config{
+			Permissions: &Permissions{
+				AllowedTools: []string{"edit", "write"},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, []string{"bash", "view", "edit", "write"}, c.Permissions.AllowedTools)
+	})
+
+	t.Run("mcp_timeout_max", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			MCP: MCPs{
+				"test": {
+					Timeout: 10,
+				},
+			},
+		}, Config{
+			MCP: MCPs{
+				"test": {
+					Timeout: 5,
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, 10, c.MCP["test"].Timeout)
+	})
+
+	t.Run("mcp_disabled_true_if_any", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			MCP: MCPs{
+				"test": {
+					Disabled: false,
+				},
+			},
+		}, Config{
+			MCP: MCPs{
+				"test": {
+					Disabled: true,
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.True(t, c.MCP["test"].Disabled)
+	})
+
+	t.Run("lsp_disabled_true_if_any", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			LSP: LSPs{
+				"test": {
+					Disabled: false,
+				},
+			},
+		}, Config{
+			LSP: LSPs{
+				"test": {
+					Disabled: true,
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.True(t, c.LSP["test"].Disabled)
+	})
+
+	t.Run("lsp_args_replaced", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			LSP: LSPs{
+				"test": {
+					Args: []string{"old", "args"},
+				},
+			},
+		}, Config{
+			LSP: LSPs{
+				"test": {
+					Args: []string{"new", "args"},
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, []string{"new", "args"}, c.LSP["test"].Args)
+	})
+
+	t.Run("lsp_filetypes_merged_and_deduplicated", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			LSP: LSPs{
+				"test": {
+					FileTypes: []string{"go", "mod"},
+				},
+			},
+		}, Config{
+			LSP: LSPs{
+				"test": {
+					FileTypes: []string{"go", "sum"},
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, []string{"go", "mod", "sum"}, c.LSP["test"].FileTypes)
+	})
+
+	t.Run("lsp_rootmarkers_merged_and_deduplicated", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			LSP: LSPs{
+				"test": {
+					RootMarkers: []string{"go.mod", "go.sum"},
+				},
+			},
+		}, Config{
+			LSP: LSPs{
+				"test": {
+					RootMarkers: []string{"go.sum", "go.work"},
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, []string{"go.mod", "go.sum", "go.work"}, c.LSP["test"].RootMarkers)
+	})
+
+	t.Run("options_attribution_nil", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			Options: &Options{
+				Attribution: &Attribution{
+					TrailerStyle:  TrailerStyleCoAuthoredBy,
+					GeneratedWith: true,
+				},
+				TUI: &TUIOptions{},
+			},
+		}, Config{
+			Options: &Options{
+				TUI: &TUIOptions{},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, TrailerStyleCoAuthoredBy, c.Options.Attribution.TrailerStyle)
+		require.True(t, c.Options.Attribution.GeneratedWith)
+	})
+
+	t.Run("tui_compact_mode_true_if_any", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			Options: &Options{
+				TUI: &TUIOptions{
+					CompactMode: false,
+				},
+			},
+		}, Config{
+			Options: &Options{
+				TUI: &TUIOptions{
+					CompactMode: true,
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.True(t, c.Options.TUI.CompactMode)
+	})
+
+	t.Run("tui_diff_mode_replaced", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			Options: &Options{
+				TUI: &TUIOptions{
+					DiffMode: "unified",
+				},
+			},
+		}, Config{
+			Options: &Options{
+				TUI: &TUIOptions{
+					DiffMode: "split",
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, "split", c.Options.TUI.DiffMode)
+	})
+
+	t.Run("options_data_directory_replaced", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			Options: &Options{
+				DataDirectory: ".crush",
+				TUI:           &TUIOptions{},
+			},
+		}, Config{
+			Options: &Options{
+				DataDirectory: ".custom",
+				TUI:           &TUIOptions{},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, ".custom", c.Options.DataDirectory)
+	})
+
+	t.Run("mcp_args_replaced", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			MCP: MCPs{
+				"test": {
+					Args: []string{"old"},
+				},
+			},
+		}, Config{
+			MCP: MCPs{
+				"test": {
+					Args: []string{"new"},
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, []string{"new"}, c.MCP["test"].Args)
+	})
+
+	t.Run("mcp_command_replaced", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			MCP: MCPs{
+				"test": {
+					Command: "old-command",
+				},
+			},
+		}, Config{
+			MCP: MCPs{
+				"test": {
+					Command: "new-command",
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, "new-command", c.MCP["test"].Command)
+	})
+
+	t.Run("mcp_type_replaced", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			MCP: MCPs{
+				"test": {
+					Type: MCPSSE,
+				},
+			},
+		}, Config{
+			MCP: MCPs{
+				"test": {
+					Type: MCPStdio,
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, MCPStdio, c.MCP["test"].Type)
+	})
+
+	t.Run("mcp_url_replaced", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			MCP: MCPs{
+				"test": {
+					URL: "http://old",
+				},
+			},
+		}, Config{
+			MCP: MCPs{
+				"test": {
+					URL: "http://new",
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, "http://new", c.MCP["test"].URL)
+	})
+
+	t.Run("lsp_command_replaced", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			LSP: LSPs{
+				"test": {
+					Command: "old-command",
+				},
+			},
+		}, Config{
+			LSP: LSPs{
+				"test": {
+					Command: "new-command",
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, "new-command", c.LSP["test"].Command)
+	})
+
+	t.Run("lsp_timeout_max", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			LSP: LSPs{
+				"test": {
+					Timeout: 60,
+				},
+			},
+		}, Config{
+			LSP: LSPs{
+				"test": {
+					Timeout: 30,
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, 60, c.LSP["test"].Timeout)
+	})
+
+	t.Run("mcp_disabled_tools_appended", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			MCP: MCPs{
+				"test": {
+					DisabledTools: []string{"tool1"},
+				},
+			},
+		}, Config{
+			MCP: MCPs{
+				"test": {
+					DisabledTools: []string{"tool2"},
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, []string{"tool1", "tool2"}, c.MCP["test"].DisabledTools)
+	})
+
+	t.Run("options_skills_paths_appended", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			Options: &Options{
+				SkillsPaths: []string{"/path/1"},
+				TUI:         &TUIOptions{},
+			},
+		}, Config{
+			Options: &Options{
+				SkillsPaths: []string{"/path/2"},
+				TUI:         &TUIOptions{},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, []string{"/path/1", "/path/2"}, c.Options.SkillsPaths)
+	})
+
+	t.Run("tui_transparent_replaced", func(t *testing.T) {
+		trueVal := true
+		falseVal := false
+		c := exerciseMerge(t, Config{
+			Options: &Options{
+				TUI: &TUIOptions{
+					Transparent: &falseVal,
+				},
+			},
+		}, Config{
+			Options: &Options{
+				TUI: &TUIOptions{
+					Transparent: &trueVal,
+				},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.True(t, *c.Options.TUI.Transparent)
+	})
+
+	t.Run("options_initialize_as_replaced", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			Options: &Options{
+				InitializeAs: "CRUSH.md",
+				TUI:          &TUIOptions{},
+			},
+		}, Config{
+			Options: &Options{
+				InitializeAs: "AGENTS.md",
+				TUI:          &TUIOptions{},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.Equal(t, "AGENTS.md", c.Options.InitializeAs)
+	})
+
+	t.Run("options_auto_lsp_replaced", func(t *testing.T) {
+		trueVal := true
+		c := exerciseMerge(t, Config{
+			Options: &Options{
+				TUI: &TUIOptions{},
+			},
+		}, Config{
+			Options: &Options{
+				AutoLSP: &trueVal,
+				TUI:     &TUIOptions{},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.True(t, *c.Options.AutoLSP)
+	})
+
+	t.Run("options_disable_auto_summarize_true_if_any", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			Options: &Options{
+				DisableAutoSummarize: false,
+				TUI:                  &TUIOptions{},
+			},
+		}, Config{
+			Options: &Options{
+				DisableAutoSummarize: true,
+				TUI:                  &TUIOptions{},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.True(t, c.Options.DisableAutoSummarize)
+	})
+
+	t.Run("options_disable_default_providers_true_if_any", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			Options: &Options{
+				DisableDefaultProviders: false,
+				TUI:                     &TUIOptions{},
+			},
+		}, Config{
+			Options: &Options{
+				DisableDefaultProviders: true,
+				TUI:                     &TUIOptions{},
+			},
+		})
+
+		require.NotNil(t, c)
+		require.True(t, c.Options.DisableDefaultProviders)
+	})
+
+	t.Run("provider_config_merge_preserves_fields", func(t *testing.T) {
+		// Tests that merging a later provider config with empty fields
+		// does not overwrite earlier non-empty fields.
+		c := exerciseMerge(t, Config{
+			Providers: csync.NewMapFrom(map[string]ProviderConfig{
+				"openai": {
+					APIKey:  "key1",
+					BaseURL: "https://api.openai.com/v1",
+				},
+			}),
+		}, Config{
+			Providers: csync.NewMapFrom(map[string]ProviderConfig{
+				"openai": {
+					APIKey:  "key2",
+					BaseURL: "https://api.openai.com/v2",
+				},
+			}),
+		}, Config{
+			// Later config with empty provider - should not clear fields.
+			Providers: csync.NewMapFrom(map[string]ProviderConfig{
+				"openai": {},
+			}),
+		})
+
+		require.NotNil(t, c)
+		pc, ok := c.Providers.Get("openai")
+		require.True(t, ok)
+		require.Equal(t, "key2", pc.APIKey)
+		require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
+	})
+
+	t.Run("provider_config_disable_true_if_any", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			Providers: csync.NewMapFrom(map[string]ProviderConfig{
+				"openai": {
+					APIKey:  "key1",
+					Disable: false,
+				},
+			}),
+		}, Config{
+			Providers: csync.NewMapFrom(map[string]ProviderConfig{
+				"openai": {
+					Disable: true,
+				},
+			}),
+		})
+
+		require.NotNil(t, c)
+		pc, ok := c.Providers.Get("openai")
+		require.True(t, ok)
+		require.True(t, pc.Disable)
+		require.Equal(t, "key1", pc.APIKey)
+	})
+
+	t.Run("provider_config_extra_headers_merged", func(t *testing.T) {
+		c := exerciseMerge(t, Config{
+			Providers: csync.NewMapFrom(map[string]ProviderConfig{
+				"openai": {
+					ExtraHeaders: map[string]string{"X-First": "value1"},
+				},
+			}),
+		}, Config{
+			Providers: csync.NewMapFrom(map[string]ProviderConfig{
+				"openai": {
+					ExtraHeaders: map[string]string{"X-Second": "value2"},
+				},
+			}),
+		})
+
+		require.NotNil(t, c)
+		pc, ok := c.Providers.Get("openai")
+		require.True(t, ok)
+		require.Equal(t, "value1", pc.ExtraHeaders["X-First"])
+		require.Equal(t, "value2", pc.ExtraHeaders["X-Second"])
+	})
+}
+
+func exerciseMerge(tb testing.TB, confs ...Config) *Config {
+	tb.Helper()
+	data := make([][]byte, 0, len(confs))
+	for _, c := range confs {
+		bts, err := json.Marshal(c)
+		require.NoError(tb, err)
+		data = append(data, bts)
+	}
+	result, err := loadFromBytes(data)
+	require.NoError(tb, err)
+	return result
+}