SaylorTwift HF Staff commited on
Commit
6932c8c
·
verified ·
1 Parent(s): 3b9b3ec

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. internal/config/agent_id_test.go +29 -0
  2. internal/config/atomicwrite.go +63 -0
  3. internal/config/atomicwrite_test.go +43 -0
  4. internal/config/atomicwrite_unix.go +7 -0
  5. internal/config/atomicwrite_windows_test.go +45 -0
  6. internal/config/attribution_migration_test.go +93 -0
  7. internal/config/config_unix.go +7 -0
  8. internal/config/copilot.go +1 -0
  9. internal/config/docker_mcp.go +141 -0
  10. internal/config/docker_mcp_test.go +195 -0
  11. internal/config/init.go +127 -0
  12. internal/config/load.go +1454 -0
  13. internal/config/load_test.go +2536 -0
  14. internal/config/lsp_defaults_test.go +35 -0
  15. internal/config/openai_test.go +220 -0
  16. internal/config/options_request_timeout_test.go +63 -0
  17. internal/config/provider_empty_test.go +39 -0
  18. internal/config/recent_models_test.go +158 -0
  19. internal/config/reload_crushrc_test.go +137 -0
  20. internal/config/reload_hooks_test.go +102 -0
  21. internal/config/resolve_real_test.go +512 -0
  22. internal/config/resolve_test.go +229 -0
  23. internal/config/scope.go +29 -0
  24. internal/config/scopeb_race_test.go +74 -0
  25. internal/config/shellconfig_hook_test.go +37 -0
  26. internal/config/shellconfig_lsp_test.go +28 -0
  27. internal/config/shellconfig_option_test.go +134 -0
  28. internal/config/shellconfig_precedence_test.go +35 -0
  29. internal/config/shellconfig_provider_test.go +85 -0
  30. internal/config/store.go +1430 -0
  31. internal/config/update_model_bench_test.go +108 -0
  32. internal/csync/doc.go +3 -0
  33. internal/csync/maps.go +174 -0
  34. internal/csync/maps_test.go +770 -0
  35. internal/csync/slices.go +121 -0
  36. internal/csync/slices_test.go +214 -0
  37. internal/csync/value.go +44 -0
  38. internal/csync/value_test.go +97 -0
  39. internal/csync/versionedmap.go +56 -0
  40. internal/csync/versionedmap_test.go +89 -0
  41. internal/diff/diff.go +29 -0
  42. internal/diffdetect/detect.go +36 -0
  43. internal/diffdetect/detect_test.go +152 -0
  44. internal/filetracker/service.go +93 -0
  45. internal/filetracker/service_test.go +116 -0
  46. internal/lock/lock.go +75 -0
  47. internal/lock/lock_test.go +168 -0
  48. internal/lock/lock_unix.go +45 -0
  49. internal/lock/lock_windows.go +57 -0
  50. internal/logout/choose.go +160 -0
internal/config/agent_id_test.go ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/stretchr/testify/assert"
7
+ "github.com/stretchr/testify/require"
8
+ )
9
+
10
+ func TestConfig_AgentIDs(t *testing.T) {
11
+ cfg := &Config{
12
+ Options: &Options{
13
+ DisabledTools: []string{},
14
+ },
15
+ }
16
+ cfg.SetupAgents()
17
+
18
+ t.Run("Coder agent should have correct ID", func(t *testing.T) {
19
+ coderAgent, ok := cfg.Agents[AgentCoder]
20
+ require.True(t, ok)
21
+ assert.Equal(t, AgentCoder, coderAgent.ID, "Coder agent ID should be '%s'", AgentCoder)
22
+ })
23
+
24
+ t.Run("Task agent should have correct ID", func(t *testing.T) {
25
+ taskAgent, ok := cfg.Agents[AgentTask]
26
+ require.True(t, ok)
27
+ assert.Equal(t, AgentTask, taskAgent.ID, "Task agent ID should be '%s'", AgentTask)
28
+ })
29
+ }
internal/config/atomicwrite.go ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "time"
7
+ )
8
+
9
+ // atomicWriteFile writes data to a file atomically by writing to a unique
10
+ // temporary file in the same directory and renaming it into place. This
11
+ // prevents concurrent readers from observing a partially-written file.
12
+ func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
13
+ path = filepath.Clean(path)
14
+ dir := filepath.Dir(path)
15
+ f, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
16
+ if err != nil {
17
+ return err
18
+ }
19
+ tmp := f.Name()
20
+ if _, err := f.Write(data); err != nil {
21
+ f.Close()
22
+ os.Remove(tmp)
23
+ return err
24
+ }
25
+ if err := f.Chmod(perm); err != nil {
26
+ f.Close()
27
+ os.Remove(tmp)
28
+ return err
29
+ }
30
+ if err := f.Close(); err != nil {
31
+ os.Remove(tmp)
32
+ return err
33
+ }
34
+ if err := renameFile(tmp, path); err != nil {
35
+ os.Remove(tmp)
36
+ return err
37
+ }
38
+ return nil
39
+ }
40
+
41
+ // renameRetryBudget bounds how long renameFile keeps retrying transient
42
+ // failures before giving up and returning the error.
43
+ const renameRetryBudget = 2 * time.Second
44
+
45
+ // renameFile renames tmp over path. On Windows the rename fails with
46
+ // ERROR_ACCESS_DENIED or ERROR_SHARING_VIOLATION while another process
47
+ // (antivirus, search indexer) or a concurrent reader briefly holds a
48
+ // handle on the destination, so transient failures are retried with
49
+ // backoff. On other platforms isTransientRenameError is always false
50
+ // and this is a plain os.Rename.
51
+ func renameFile(tmp, path string) error {
52
+ var slept time.Duration
53
+ delay := time.Millisecond
54
+ for {
55
+ err := os.Rename(tmp, path)
56
+ if err == nil || !isTransientRenameError(err) || slept >= renameRetryBudget {
57
+ return err
58
+ }
59
+ time.Sleep(delay)
60
+ slept += delay
61
+ delay = min(delay*2, 50*time.Millisecond)
62
+ }
63
+ }
internal/config/atomicwrite_test.go ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "runtime"
7
+ "testing"
8
+
9
+ "github.com/stretchr/testify/require"
10
+ )
11
+
12
+ func TestAtomicWriteFile(t *testing.T) {
13
+ t.Parallel()
14
+ dir := t.TempDir()
15
+ path := filepath.Join(dir, "test.json")
16
+
17
+ require.NoError(t, atomicWriteFile(path, []byte(`{"key":"value"}`), 0o600))
18
+
19
+ data, err := os.ReadFile(path)
20
+ require.NoError(t, err)
21
+ require.Equal(t, `{"key":"value"}`, string(data))
22
+
23
+ // No temp files should linger.
24
+ entries, err := os.ReadDir(dir)
25
+ require.NoError(t, err)
26
+ require.Len(t, entries, 1)
27
+ require.Equal(t, "test.json", entries[0].Name())
28
+ }
29
+
30
+ func TestAtomicWriteFile_PermissionsApplied(t *testing.T) {
31
+ if runtime.GOOS == "windows" {
32
+ t.Skip("Windows does not support Unix file permissions")
33
+ }
34
+ t.Parallel()
35
+ dir := t.TempDir()
36
+ path := filepath.Join(dir, "test.json")
37
+
38
+ require.NoError(t, atomicWriteFile(path, []byte(`{}`), 0o600))
39
+
40
+ info, err := os.Stat(path)
41
+ require.NoError(t, err)
42
+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
43
+ }
internal/config/atomicwrite_unix.go ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ //go:build !windows
2
+
3
+ package config
4
+
5
+ // isTransientRenameError reports whether err is a rename failure that
6
+ // can resolve on its own. Only Windows has such failures.
7
+ func isTransientRenameError(error) bool { return false }
internal/config/atomicwrite_windows_test.go ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //go:build windows
2
+
3
+ package config
4
+
5
+ import (
6
+ "os"
7
+ "path/filepath"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/stretchr/testify/require"
12
+ "golang.org/x/sys/windows"
13
+ )
14
+
15
+ // Regression test for the windows-latest CI flake where
16
+ // TestConfigStore_SetConfigFields_concurrentInProcess failed with
17
+ // "rename ...crush.json.<rand>.tmp ...crush.json: Access is denied":
18
+ // renaming over a destination while another handle is open on it
19
+ // without sharing must be retried until the handle closes, not
20
+ // surfaced as an error.
21
+ func TestAtomicWriteFile_RetriesWhileDestinationHandleOpen(t *testing.T) {
22
+ t.Parallel()
23
+ dir := t.TempDir()
24
+ path := filepath.Join(dir, "test.json")
25
+ require.NoError(t, atomicWriteFile(path, []byte(`{"v":1}`), 0o600))
26
+
27
+ // Hold the destination open with no sharing flags so the rename
28
+ // fails with ERROR_ACCESS_DENIED/ERROR_SHARING_VIOLATION until the
29
+ // handle closes, mimicking an antivirus scan or exclusive reader.
30
+ p, err := windows.UTF16PtrFromString(path)
31
+ require.NoError(t, err)
32
+ h, err := windows.CreateFile(p, windows.GENERIC_READ, 0, nil,
33
+ windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0)
34
+ require.NoError(t, err)
35
+ go func() {
36
+ time.Sleep(200 * time.Millisecond)
37
+ windows.CloseHandle(h)
38
+ }()
39
+
40
+ require.NoError(t, atomicWriteFile(path, []byte(`{"v":2}`), 0o600))
41
+
42
+ data, err := os.ReadFile(path)
43
+ require.NoError(t, err)
44
+ require.JSONEq(t, `{"v":2}`, string(data))
45
+ }
internal/config/attribution_migration_test.go ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/stretchr/testify/require"
7
+ )
8
+
9
+ func TestAttributionMigration(t *testing.T) {
10
+ t.Parallel()
11
+
12
+ tests := []struct {
13
+ name string
14
+ configJSON string
15
+ expectedTrailer TrailerStyle
16
+ expectedGenerate bool
17
+ }{
18
+ {
19
+ name: "old setting co_authored_by=true migrates to co-authored-by",
20
+ configJSON: `{
21
+ "options": {
22
+ "attribution": {
23
+ "co_authored_by": true,
24
+ "generated_with": false
25
+ }
26
+ }
27
+ }`,
28
+ expectedTrailer: TrailerStyleCoAuthoredBy,
29
+ expectedGenerate: false,
30
+ },
31
+ {
32
+ name: "old setting co_authored_by=false migrates to none",
33
+ configJSON: `{
34
+ "options": {
35
+ "attribution": {
36
+ "co_authored_by": false,
37
+ "generated_with": true
38
+ }
39
+ }
40
+ }`,
41
+ expectedTrailer: TrailerStyleNone,
42
+ expectedGenerate: true,
43
+ },
44
+ {
45
+ name: "new setting takes precedence over old setting",
46
+ configJSON: `{
47
+ "options": {
48
+ "attribution": {
49
+ "trailer_style": "assisted-by",
50
+ "co_authored_by": true,
51
+ "generated_with": false
52
+ }
53
+ }
54
+ }`,
55
+ expectedTrailer: TrailerStyleAssistedBy,
56
+ expectedGenerate: false,
57
+ },
58
+ {
59
+ name: "default when neither setting present",
60
+ configJSON: `{
61
+ "options": {
62
+ "attribution": {
63
+ "generated_with": true
64
+ }
65
+ }
66
+ }`,
67
+ expectedTrailer: TrailerStyleAssistedBy,
68
+ expectedGenerate: true,
69
+ },
70
+ {
71
+ name: "default when attribution is null",
72
+ configJSON: `{
73
+ "options": {}
74
+ }`,
75
+ expectedTrailer: TrailerStyleAssistedBy,
76
+ expectedGenerate: true,
77
+ },
78
+ }
79
+
80
+ for _, tt := range tests {
81
+ t.Run(tt.name, func(t *testing.T) {
82
+ t.Parallel()
83
+
84
+ cfg, err := loadFromBytes([][]byte{[]byte(tt.configJSON)})
85
+ require.NoError(t, err)
86
+
87
+ cfg.setDefaults(t.TempDir(), "")
88
+
89
+ require.Equal(t, tt.expectedTrailer, cfg.Options.Attribution.TrailerStyle)
90
+ require.Equal(t, tt.expectedGenerate, cfg.Options.Attribution.GeneratedWith)
91
+ })
92
+ }
93
+ }
internal/config/config_unix.go ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ //go:build !windows
2
+
3
+ package config
4
+
5
+ // systemConfigPath is the system-wide configuration file path. It is
6
+ // loaded at the lowest priority so user and project configs override it.
7
+ const systemConfigPath = "/etc/crush/crush.json"
internal/config/copilot.go ADDED
@@ -0,0 +1 @@
 
 
1
+ package config
internal/config/docker_mcp.go ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "os/exec"
7
+ "sync"
8
+ "time"
9
+ )
10
+
11
+ var dockerMCPVersionRunner = func(ctx context.Context) error {
12
+ cmd := exec.CommandContext(ctx, "docker", "mcp", "version")
13
+ return cmd.Run()
14
+ }
15
+
16
+ const dockerMCPAvailabilityTTL = 10 * time.Second
17
+
18
+ var dockerMCPAvailabilityCache struct {
19
+ mu sync.Mutex
20
+ available bool
21
+ checkedAt time.Time
22
+ known bool
23
+ }
24
+
25
+ // DockerMCPName is the name of the Docker MCP configuration.
26
+ const DockerMCPName = "docker"
27
+
28
+ // IsDockerMCPAvailable checks if Docker MCP is available by running
29
+ // 'docker mcp version'.
30
+ func IsDockerMCPAvailable() bool {
31
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
32
+ defer cancel()
33
+
34
+ err := dockerMCPVersionRunner(ctx)
35
+ return err == nil
36
+ }
37
+
38
+ // DockerMCPAvailabilityCached returns the cached Docker MCP availability and
39
+ // whether the cached value is still fresh.
40
+ func DockerMCPAvailabilityCached() (available bool, known bool) {
41
+ dockerMCPAvailabilityCache.mu.Lock()
42
+ defer dockerMCPAvailabilityCache.mu.Unlock()
43
+
44
+ if !dockerMCPAvailabilityCache.known {
45
+ return false, false
46
+ }
47
+ if time.Since(dockerMCPAvailabilityCache.checkedAt) > dockerMCPAvailabilityTTL {
48
+ return dockerMCPAvailabilityCache.available, false
49
+ }
50
+ return dockerMCPAvailabilityCache.available, true
51
+ }
52
+
53
+ // RefreshDockerMCPAvailability refreshes and caches Docker MCP availability.
54
+ func RefreshDockerMCPAvailability() bool {
55
+ available := IsDockerMCPAvailable()
56
+ dockerMCPAvailabilityCache.mu.Lock()
57
+ dockerMCPAvailabilityCache.available = available
58
+ dockerMCPAvailabilityCache.checkedAt = time.Now()
59
+ dockerMCPAvailabilityCache.known = true
60
+ dockerMCPAvailabilityCache.mu.Unlock()
61
+ return available
62
+ }
63
+
64
+ // IsDockerMCPEnabled checks if Docker MCP is already configured.
65
+ func (c *Config) IsDockerMCPEnabled() bool {
66
+ if c.MCP == nil {
67
+ return false
68
+ }
69
+ _, exists := c.MCP[DockerMCPName]
70
+ return exists
71
+ }
72
+
73
+ // DockerMCPConfig returns the default Docker MCP stdio configuration.
74
+ func DockerMCPConfig() MCPConfig {
75
+ return MCPConfig{
76
+ Type: MCPStdio,
77
+ Command: "docker",
78
+ Args: []string{"mcp", "gateway", "run"},
79
+ Disabled: false,
80
+ }
81
+ }
82
+
83
+ // PrepareDockerMCPConfig validates Docker MCP availability and stages the
84
+ // Docker MCP configuration in memory.
85
+ func (s *ConfigStore) PrepareDockerMCPConfig() (MCPConfig, error) {
86
+ if !IsDockerMCPAvailable() {
87
+ return MCPConfig{}, fmt.Errorf("docker mcp is not available, please ensure docker is installed and 'docker mcp version' succeeds")
88
+ }
89
+
90
+ mcpConfig := DockerMCPConfig()
91
+ // In-memory only; persistence happens in PersistDockerMCPConfig.
92
+ s.mutateInMemory(func(c *Config) {
93
+ if c.MCP == nil {
94
+ c.MCP = make(map[string]MCPConfig)
95
+ }
96
+ c.MCP[DockerMCPName] = mcpConfig
97
+ })
98
+ return mcpConfig, nil
99
+ }
100
+
101
+ // PersistDockerMCPConfig persists a previously prepared Docker MCP
102
+ // configuration to the global config file.
103
+ func (s *ConfigStore) PersistDockerMCPConfig(mcpConfig MCPConfig) error {
104
+ if err := s.SetConfigField(ScopeGlobal, "mcp."+DockerMCPName, mcpConfig); err != nil {
105
+ return fmt.Errorf("failed to persist docker mcp configuration: %w", err)
106
+ }
107
+ return nil
108
+ }
109
+
110
+ // EnableDockerMCP adds Docker MCP configuration and persists it.
111
+ func (s *ConfigStore) EnableDockerMCP() error {
112
+ mcpConfig, err := s.PrepareDockerMCPConfig()
113
+ if err != nil {
114
+ return err
115
+ }
116
+ if err := s.PersistDockerMCPConfig(mcpConfig); err != nil {
117
+ return err
118
+ }
119
+ return nil
120
+ }
121
+
122
+ // DisableDockerMCP removes Docker MCP configuration and persists the change.
123
+ func (s *ConfigStore) DisableDockerMCP() error {
124
+ return s.update(ScopeGlobal, func(c *Config) map[string]any {
125
+ if c.MCP == nil {
126
+ return nil
127
+ }
128
+ delete(c.MCP, DockerMCPName)
129
+ return map[string]any{"mcp": c.MCP}
130
+ })
131
+ }
132
+
133
+ // RemoveDockerMCPInMemory removes the Docker MCP entry from the in-memory
134
+ // config via copy-on-write, without persisting to disk. It rolls back a
135
+ // staged PrepareDockerMCPConfig when starting or persisting the server
136
+ // fails, so callers must not mutate Config().MCP directly.
137
+ func (s *ConfigStore) RemoveDockerMCPInMemory() {
138
+ s.mutateInMemory(func(c *Config) {
139
+ delete(c.MCP, DockerMCPName)
140
+ })
141
+ }
internal/config/docker_mcp_test.go ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "os"
7
+ "path/filepath"
8
+ "testing"
9
+
10
+ "github.com/charmbracelet/crush/internal/env"
11
+ "github.com/stretchr/testify/require"
12
+ )
13
+
14
+ var errDockerUnavailable = errors.New("docker unavailable")
15
+
16
+ func setDockerMCPVersionRunner(t *testing.T, runner func(context.Context) error) {
17
+ t.Helper()
18
+ orig := dockerMCPVersionRunner
19
+ dockerMCPVersionRunner = runner
20
+ t.Cleanup(func() {
21
+ dockerMCPVersionRunner = orig
22
+ })
23
+ }
24
+
25
+ func TestIsDockerMCPEnabled(t *testing.T) {
26
+ t.Parallel()
27
+
28
+ t.Run("returns false when MCP is nil", func(t *testing.T) {
29
+ t.Parallel()
30
+ cfg := &Config{
31
+ MCP: nil,
32
+ }
33
+ require.False(t, cfg.IsDockerMCPEnabled())
34
+ })
35
+
36
+ t.Run("returns false when docker mcp not configured", func(t *testing.T) {
37
+ t.Parallel()
38
+ cfg := &Config{
39
+ MCP: make(map[string]MCPConfig),
40
+ }
41
+ require.False(t, cfg.IsDockerMCPEnabled())
42
+ })
43
+
44
+ t.Run("returns true when docker mcp is configured", func(t *testing.T) {
45
+ t.Parallel()
46
+ cfg := &Config{
47
+ MCP: map[string]MCPConfig{
48
+ DockerMCPName: {
49
+ Type: MCPStdio,
50
+ Command: "docker",
51
+ },
52
+ },
53
+ }
54
+ require.True(t, cfg.IsDockerMCPEnabled())
55
+ })
56
+ }
57
+
58
+ func TestEnableDockerMCP(t *testing.T) {
59
+ t.Run("adds docker mcp to config", func(t *testing.T) {
60
+ setDockerMCPVersionRunner(t, func(context.Context) error { return nil })
61
+
62
+ // Create a temporary directory for config.
63
+ tmpDir := t.TempDir()
64
+ configPath := filepath.Join(tmpDir, "crush.json")
65
+
66
+ cfg := &Config{
67
+ MCP: make(map[string]MCPConfig),
68
+ }
69
+ store := &ConfigStore{
70
+ config: cfg,
71
+ globalDataPath: configPath,
72
+ resolver: NewShellVariableResolver(env.New()),
73
+ }
74
+
75
+ err := store.EnableDockerMCP()
76
+ require.NoError(t, err)
77
+
78
+ // Check in-memory config via the store (copy-on-write publishes
79
+ // a new Config; the captured cfg pointer stays unchanged).
80
+ require.True(t, store.Config().IsDockerMCPEnabled())
81
+ mcpConfig, exists := store.Config().MCP[DockerMCPName]
82
+ require.True(t, exists)
83
+ require.Equal(t, MCPStdio, mcpConfig.Type)
84
+ require.Equal(t, "docker", mcpConfig.Command)
85
+ require.Equal(t, []string{"mcp", "gateway", "run"}, mcpConfig.Args)
86
+ require.False(t, mcpConfig.Disabled)
87
+
88
+ // Check persisted config.
89
+ data, err := os.ReadFile(configPath)
90
+ require.NoError(t, err)
91
+ require.Contains(t, string(data), "docker")
92
+ require.Contains(t, string(data), "gateway")
93
+ })
94
+
95
+ t.Run("fails when docker mcp not available", func(t *testing.T) {
96
+ setDockerMCPVersionRunner(t, func(context.Context) error { return errDockerUnavailable })
97
+
98
+ // Create a temporary directory for config.
99
+ tmpDir := t.TempDir()
100
+ configPath := filepath.Join(tmpDir, "crush.json")
101
+
102
+ cfg := &Config{
103
+ MCP: make(map[string]MCPConfig),
104
+ }
105
+ store := &ConfigStore{
106
+ config: cfg,
107
+ globalDataPath: configPath,
108
+ resolver: NewShellVariableResolver(env.New()),
109
+ }
110
+
111
+ err := store.EnableDockerMCP()
112
+ require.Error(t, err)
113
+ require.Contains(t, err.Error(), "docker mcp is not available")
114
+ })
115
+ }
116
+
117
+ func TestDisableDockerMCP(t *testing.T) {
118
+ t.Parallel()
119
+
120
+ t.Run("removes docker mcp from config", func(t *testing.T) {
121
+ t.Parallel()
122
+
123
+ // Create a temporary directory for config.
124
+ tmpDir := t.TempDir()
125
+ configPath := filepath.Join(tmpDir, "crush.json")
126
+
127
+ cfg := &Config{
128
+ MCP: map[string]MCPConfig{
129
+ DockerMCPName: {
130
+ Type: MCPStdio,
131
+ Command: "docker",
132
+ Args: []string{"mcp", "gateway", "run"},
133
+ Disabled: false,
134
+ },
135
+ },
136
+ }
137
+ store := &ConfigStore{
138
+ config: cfg,
139
+ globalDataPath: configPath,
140
+ resolver: NewShellVariableResolver(env.New()),
141
+ }
142
+
143
+ // Verify it's enabled first.
144
+ require.True(t, cfg.IsDockerMCPEnabled())
145
+
146
+ err := store.DisableDockerMCP()
147
+ require.NoError(t, err)
148
+
149
+ // Check in-memory config via the store (copy-on-write publishes a
150
+ // new Config; the original cfg pointer is intentionally unchanged).
151
+ require.False(t, store.Config().IsDockerMCPEnabled())
152
+ _, exists := store.Config().MCP[DockerMCPName]
153
+ require.False(t, exists)
154
+ })
155
+
156
+ t.Run("does nothing when MCP is nil", func(t *testing.T) {
157
+ t.Parallel()
158
+
159
+ cfg := &Config{
160
+ MCP: nil,
161
+ }
162
+ store := &ConfigStore{
163
+ config: cfg,
164
+ globalDataPath: filepath.Join(t.TempDir(), "crush.json"),
165
+ resolver: NewShellVariableResolver(env.New()),
166
+ }
167
+
168
+ err := store.DisableDockerMCP()
169
+ require.NoError(t, err)
170
+ })
171
+ }
172
+
173
+ func TestEnableDockerMCPWithRealDockerWhenAvailable(t *testing.T) {
174
+ t.Parallel()
175
+
176
+ if !IsDockerMCPAvailable() {
177
+ t.Skip("docker mcp not available on this machine")
178
+ }
179
+
180
+ tmpDir := t.TempDir()
181
+ configPath := filepath.Join(tmpDir, "crush.json")
182
+
183
+ cfg := &Config{
184
+ MCP: make(map[string]MCPConfig),
185
+ }
186
+ store := &ConfigStore{
187
+ config: cfg,
188
+ globalDataPath: configPath,
189
+ resolver: NewShellVariableResolver(env.New()),
190
+ }
191
+
192
+ err := store.EnableDockerMCP()
193
+ require.NoError(t, err)
194
+ require.True(t, store.Config().IsDockerMCPEnabled())
195
+ }
internal/config/init.go ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "path/filepath"
7
+ "slices"
8
+ "strings"
9
+
10
+ "github.com/charmbracelet/crush/internal/fsext"
11
+ )
12
+
13
+ const (
14
+ InitFlagFilename = "init"
15
+ )
16
+
17
+ type ProjectInitFlag struct {
18
+ Initialized bool `json:"initialized"`
19
+ }
20
+
21
+ func Init(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
22
+ store, err := Load(workingDir, dataDir, debug)
23
+ if err != nil {
24
+ return nil, err
25
+ }
26
+ return store, nil
27
+ }
28
+
29
+ func ProjectNeedsInitialization(store *ConfigStore) (bool, error) {
30
+ if store == nil {
31
+ return false, fmt.Errorf("config not loaded")
32
+ }
33
+
34
+ cfg := store.Config()
35
+ flagFilePath := filepath.Join(cfg.Options.DataDirectory, InitFlagFilename)
36
+
37
+ _, err := os.Stat(flagFilePath)
38
+ if err == nil {
39
+ return false, nil
40
+ }
41
+
42
+ if !os.IsNotExist(err) {
43
+ return false, fmt.Errorf("failed to check init flag file: %w", err)
44
+ }
45
+
46
+ someContextFileExists, err := contextPathsExist(store.WorkingDir())
47
+ if err != nil {
48
+ return false, fmt.Errorf("failed to check for context files: %w", err)
49
+ }
50
+ if someContextFileExists {
51
+ return false, nil
52
+ }
53
+
54
+ // If the working directory has no non-ignored files, skip initialization step
55
+ empty, err := dirHasNoVisibleFiles(store.WorkingDir())
56
+ if err != nil {
57
+ return false, fmt.Errorf("failed to check if directory is empty: %w", err)
58
+ }
59
+ if empty {
60
+ return false, nil
61
+ }
62
+
63
+ return true, nil
64
+ }
65
+
66
+ func contextPathsExist(dir string) (bool, error) {
67
+ entries, err := os.ReadDir(dir)
68
+ if err != nil {
69
+ return false, err
70
+ }
71
+
72
+ // Create a slice of lowercase filenames for lookup with slices.Contains
73
+ var files []string
74
+ for _, entry := range entries {
75
+ if !entry.IsDir() {
76
+ files = append(files, strings.ToLower(entry.Name()))
77
+ }
78
+ }
79
+
80
+ // Check if any of the default context paths exist in the directory
81
+ for _, path := range defaultContextPaths {
82
+ // Extract just the filename from the path
83
+ _, filename := filepath.Split(path)
84
+ filename = strings.ToLower(filename)
85
+
86
+ if slices.Contains(files, filename) {
87
+ return true, nil
88
+ }
89
+ }
90
+
91
+ return false, nil
92
+ }
93
+
94
+ // dirHasNoVisibleFiles returns true if the directory has no files/dirs after applying ignore rules.
95
+ func dirHasNoVisibleFiles(dir string) (bool, error) {
96
+ files, _, err := fsext.ListDirectory(dir, nil, 1, 1)
97
+ if err != nil {
98
+ return false, err
99
+ }
100
+ return len(files) == 0, nil
101
+ }
102
+
103
+ func MarkProjectInitialized(store *ConfigStore) error {
104
+ if store == nil {
105
+ return fmt.Errorf("config not loaded")
106
+ }
107
+ flagFilePath := filepath.Join(store.Config().Options.DataDirectory, InitFlagFilename)
108
+
109
+ file, err := os.Create(flagFilePath)
110
+ if err != nil {
111
+ return fmt.Errorf("failed to create init flag file: %w", err)
112
+ }
113
+ defer file.Close()
114
+
115
+ return nil
116
+ }
117
+
118
+ func HasInitialDataConfig(store *ConfigStore) bool {
119
+ if store == nil {
120
+ return false
121
+ }
122
+ cfgPath := GlobalConfigData()
123
+ if _, err := os.Stat(cfgPath); err != nil {
124
+ return false
125
+ }
126
+ return store.Config().IsConfigured()
127
+ }
internal/config/load.go ADDED
@@ -0,0 +1,1454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "cmp"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "log/slog"
9
+ "maps"
10
+ "os"
11
+ "os/exec"
12
+ "path/filepath"
13
+ "regexp"
14
+ "runtime"
15
+ "slices"
16
+ "strconv"
17
+ "strings"
18
+ "sync"
19
+ "testing"
20
+ "time"
21
+
22
+ "charm.land/catwalk/pkg/catwalk"
23
+ "github.com/charmbracelet/crush/internal/agent/hyper"
24
+ "github.com/charmbracelet/crush/internal/csync"
25
+ "github.com/charmbracelet/crush/internal/discover"
26
+ "github.com/charmbracelet/crush/internal/env"
27
+ "github.com/charmbracelet/crush/internal/filepathext"
28
+ "github.com/charmbracelet/crush/internal/fsext"
29
+ "github.com/charmbracelet/crush/internal/home"
30
+ "github.com/charmbracelet/crush/internal/shellconfig"
31
+ powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
32
+ "github.com/qjebbs/go-jsons"
33
+ "github.com/tidwall/gjson"
34
+ "github.com/tidwall/sjson"
35
+ )
36
+
37
+ const defaultCatwalkURL = "https://catwalk.charm.land"
38
+
39
+ // Load loads the configuration from the default paths and returns a
40
+ // ConfigStore that owns both the pure-data Config and all runtime state.
41
+ func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
42
+ // Migrate deprecated disable_notifications before loading config.
43
+ migrateDisableNotifications()
44
+
45
+ configPaths := lookupConfigs(workingDir)
46
+
47
+ cfg, loadedPaths, err := loadFromConfigPaths(context.Background(), configPaths)
48
+ if err != nil {
49
+ return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
50
+ }
51
+
52
+ cfg.setDefaults(workingDir, dataDir)
53
+
54
+ store := &ConfigStore{
55
+ config: cfg,
56
+ workingDir: workingDir,
57
+ globalDataPath: GlobalConfigData(),
58
+ workspacePath: filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName)),
59
+ loadedPaths: loadedPaths,
60
+ }
61
+
62
+ if debug {
63
+ cfg.Options.Debug = true
64
+ }
65
+
66
+ // Load workspace config last so it has highest priority.
67
+ if wsData, err := os.ReadFile(store.workspacePath); err == nil && len(wsData) > 0 {
68
+ if !json.Valid(wsData) {
69
+ return nil, fmt.Errorf("invalid JSON in config file %s", store.workspacePath)
70
+ }
71
+ merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
72
+ if mergeErr == nil {
73
+ // Preserve defaults that setDefaults already applied.
74
+ dataDir := cfg.Options.DataDirectory
75
+ *cfg = *merged
76
+ cfg.setDefaults(workingDir, dataDir)
77
+ store.config = cfg
78
+ store.loadedPaths = append(store.loadedPaths, store.workspacePath)
79
+ }
80
+ }
81
+
82
+ // Validate hooks after all config merging is complete so workspace
83
+ // hooks also get their matcher regexes compiled.
84
+ if err := cfg.ValidateHooks(); err != nil {
85
+ return nil, fmt.Errorf("invalid hook configuration: %w", err)
86
+ }
87
+
88
+ if !isInsideWorktree() {
89
+ const depth = 2
90
+ const items = 100
91
+ slog.Warn("No git repository detected in working directory, will limit file walk operations", "depth", depth, "items", items)
92
+ assignIfNil(&cfg.Tools.Ls.MaxDepth, depth)
93
+ assignIfNil(&cfg.Tools.Ls.MaxItems, items)
94
+ assignIfNil(&cfg.Options.TUI.Completions.MaxDepth, depth)
95
+ assignIfNil(&cfg.Options.TUI.Completions.MaxItems, items)
96
+ }
97
+
98
+ if isAppleTerminal() {
99
+ slog.Warn("Detected Apple Terminal, enabling transparent mode")
100
+ assignIfNil(&cfg.Options.TUI.Transparent, true)
101
+ }
102
+
103
+ // Load known providers, this loads the config from catwalk. A failed
104
+ // refresh still yields the cached or embedded catalog, so only an empty
105
+ // list is fatal: starting up without providers is worse than starting
106
+ // up with slightly stale ones. Pass a Hyper token refresher so the
107
+ // catalog fetch can retry on 401.
108
+ providers, err := Providers(cfg, func(ctx context.Context) error {
109
+ return store.RefreshOAuthToken(ctx, ScopeGlobal, "hyper")
110
+ })
111
+ if err != nil {
112
+ if len(providers) == 0 {
113
+ return nil, err
114
+ }
115
+ slog.Warn("Continuing with the previously known providers", "error", err)
116
+ }
117
+ store.knownProviders = providers
118
+
119
+ env := env.New()
120
+ // Configure providers
121
+ valueResolver := NewShellVariableResolver(env)
122
+ store.resolver = valueResolver
123
+
124
+ // Hold writeMu during initial load to prevent configureProviders
125
+ // from triggering auto-reload via RemoveConfigField.
126
+ store.writeMu.Lock()
127
+ defer store.writeMu.Unlock()
128
+
129
+ // Apply top-level env vars before configuring providers so variables
130
+ // like AWS_PROFILE are visible to the AWS SDK credential chain.
131
+ cfg.applyEnv(valueResolver)
132
+
133
+ if err := cfg.configureProviders(context.Background(), store, env, valueResolver, store.knownProviders); err != nil {
134
+ return nil, fmt.Errorf("failed to configure providers: %w", err)
135
+ }
136
+
137
+ if !cfg.IsConfigured() {
138
+ slog.Warn("No providers configured")
139
+ return store, nil
140
+ }
141
+
142
+ resolved, err := resolveSelectedModels(cfg, store.knownProviders)
143
+ if err != nil {
144
+ return nil, fmt.Errorf("failed to configure selected models: %w", err)
145
+ }
146
+ cfg.Models[SelectedModelTypeLarge] = resolved.Large
147
+ cfg.Models[SelectedModelTypeSmall] = resolved.Small
148
+
149
+ // Persist any fallback corrections while we still hold writeMu.
150
+ if resolved.LargeFallback {
151
+ if err := store.updateLocked(ScopeGlobal, func(c *Config) map[string]any {
152
+ return store.updatePreferredModelFields(c, SelectedModelTypeLarge, resolved.Large)
153
+ }); err != nil {
154
+ return nil, fmt.Errorf("failed to update preferred large model: %w", err)
155
+ }
156
+ }
157
+ if resolved.SmallFallback {
158
+ if err := store.updateLocked(ScopeGlobal, func(c *Config) map[string]any {
159
+ return store.updatePreferredModelFields(c, SelectedModelTypeSmall, resolved.Small)
160
+ }); err != nil {
161
+ return nil, fmt.Errorf("failed to update preferred small model: %w", err)
162
+ }
163
+ }
164
+ store.SetupAgents()
165
+
166
+ // Capture initial staleness snapshot
167
+ // Capture initial staleness snapshot. Track every discovered config path,
168
+ // not just the ones that loaded, so a config file created after startup
169
+ // (e.g. a crushrc added mid-session) is detected as a change.
170
+ store.captureStalenessSnapshot(append(slices.Clone(configPaths), loadedPaths...))
171
+
172
+ return store, nil
173
+ }
174
+
175
+ // mustMarshalConfig marshals the config to JSON bytes, returning empty JSON on
176
+ // error.
177
+ func mustMarshalConfig(cfg *Config) []byte {
178
+ data, err := json.Marshal(cfg)
179
+ if err != nil {
180
+ return []byte("{}")
181
+ }
182
+ return data
183
+ }
184
+
185
+ func PushPopCrushEnv() func() {
186
+ var found []string
187
+ for _, ev := range os.Environ() {
188
+ if strings.HasPrefix(ev, "CRUSH_") {
189
+ pair := strings.SplitN(ev, "=", 2)
190
+ if len(pair) != 2 {
191
+ continue
192
+ }
193
+ found = append(found, strings.TrimPrefix(pair[0], "CRUSH_"))
194
+ }
195
+ }
196
+ backups := make(map[string]string)
197
+ for _, ev := range found {
198
+ backups[ev] = os.Getenv(ev)
199
+ }
200
+
201
+ for _, ev := range found {
202
+ os.Setenv(ev, os.Getenv("CRUSH_"+ev))
203
+ }
204
+
205
+ restore := func() {
206
+ for k, v := range backups {
207
+ os.Setenv(k, v)
208
+ }
209
+ }
210
+ return restore
211
+ }
212
+
213
+ func (c *Config) configureProviders(ctx context.Context, store *ConfigStore, env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
214
+ knownProviderNames := make(map[string]bool)
215
+ restore := PushPopCrushEnv()
216
+ defer restore()
217
+
218
+ // When disable_default_providers is enabled, skip all default/embedded
219
+ // providers entirely. Users must fully specify any providers they want.
220
+ // We skip to the custom provider validation loop which handles all
221
+ // user-configured providers uniformly.
222
+ if c.Options.DisableDefaultProviders {
223
+ knownProviders = nil
224
+ }
225
+
226
+ for _, p := range knownProviders {
227
+ knownProviderNames[string(p.ID)] = true
228
+ config, configExists := c.Providers.Get(string(p.ID))
229
+ // if the user configured a known provider we need to allow it to override a couple of parameters
230
+ if configExists {
231
+ if config.BaseURL != "" {
232
+ p.APIEndpoint = config.BaseURL
233
+ }
234
+ if config.APIKey != "" {
235
+ p.APIKey = config.APIKey
236
+ }
237
+ if len(config.Models) > 0 {
238
+ models := []catwalk.Model{}
239
+ seen := make(map[string]bool)
240
+
241
+ for _, model := range config.Models {
242
+ if seen[model.ID] {
243
+ continue
244
+ }
245
+ seen[model.ID] = true
246
+ if model.Name == "" {
247
+ model.Name = model.ID
248
+ }
249
+ models = append(models, model)
250
+ }
251
+ for _, model := range p.Models {
252
+ if seen[model.ID] {
253
+ continue
254
+ }
255
+ seen[model.ID] = true
256
+ if model.Name == "" {
257
+ model.Name = model.ID
258
+ }
259
+ models = append(models, model)
260
+ }
261
+
262
+ p.Models = models
263
+ }
264
+ }
265
+
266
+ headers := map[string]string{}
267
+ if len(p.DefaultHeaders) > 0 {
268
+ maps.Copy(headers, p.DefaultHeaders)
269
+ }
270
+ if len(config.ExtraHeaders) > 0 {
271
+ maps.Copy(headers, config.ExtraHeaders)
272
+ }
273
+ // Provider headers use the same error contract as MCP headers:
274
+ // a failing $(...) aborts the provider load with a clear
275
+ // message, and a header that resolves to the empty string
276
+ // (unset bare $VAR under lenient nounset, $(echo), or literal
277
+ // "") is dropped from the outgoing request.
278
+ for k, v := range headers {
279
+ resolved, err := resolver.ResolveValue(v)
280
+ if err != nil {
281
+ return fmt.Errorf("resolving provider %s header %q: %w", p.ID, k, err)
282
+ }
283
+ if resolved == "" {
284
+ delete(headers, k)
285
+ continue
286
+ }
287
+ headers[k] = resolved
288
+ }
289
+ // Start from user config so all user fields survive without
290
+ // explicit copying. Overlay catwalk identity/endpoint fields
291
+ // (already merged with user overrides above).
292
+ prepared := config
293
+ prepared.ID = string(p.ID)
294
+ prepared.Name = p.Name
295
+ prepared.BaseURL = p.APIEndpoint
296
+ prepared.APIKey = p.APIKey
297
+ prepared.APIKeyTemplate = p.APIKey // Store original template for re-resolution
298
+ prepared.Type = p.Type
299
+ prepared.Models = p.Models
300
+ prepared.ExtraHeaders = headers
301
+ if prepared.ExtraParams == nil {
302
+ prepared.ExtraParams = make(map[string]string)
303
+ }
304
+
305
+ switch {
306
+ case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
307
+ // Claude Code subscription is not supported anymore. Remove to show onboarding.
308
+ // RemoveConfigField persists the deletion to disk. The in-memory
309
+ // state is kept consistent by the Providers.Del call below; any
310
+ // concurrent reload that races with this write will also see the
311
+ // removal because it re-reads from disk.
312
+ store.RemoveConfigField(ScopeGlobal, "providers.anthropic")
313
+ c.Providers.Del(string(p.ID))
314
+ continue
315
+ case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
316
+ prepared.SetupGitHubCopilot()
317
+ }
318
+
319
+ switch p.ID {
320
+ // Handle specific providers that require additional configuration
321
+ case catwalk.InferenceProviderVertexAI:
322
+ var (
323
+ project = env.Get("VERTEXAI_PROJECT")
324
+ location = env.Get("VERTEXAI_LOCATION")
325
+ )
326
+ if project == "" || location == "" {
327
+ if configExists {
328
+ slog.Warn("Skipping Vertex AI provider due to missing credentials")
329
+ c.Providers.Del(string(p.ID))
330
+ }
331
+ continue
332
+ }
333
+ prepared.ExtraParams["project"] = project
334
+ prepared.ExtraParams["location"] = location
335
+ case catwalk.InferenceProviderAzure:
336
+ endpoint, err := resolver.ResolveValue(p.APIEndpoint)
337
+ if err != nil || endpoint == "" {
338
+ if configExists {
339
+ slog.Warn("Skipping Azure provider due to missing API endpoint", "provider", p.ID, "error", err)
340
+ c.Providers.Del(string(p.ID))
341
+ }
342
+ continue
343
+ }
344
+ prepared.BaseURL = endpoint
345
+ prepared.ExtraParams["apiVersion"] = env.Get("AZURE_OPENAI_API_VERSION")
346
+ case catwalk.InferenceProviderBedrock, catwalk.InferenceProviderBedrockEurope:
347
+ if p.APIKey == "" && !hasAWSCredentials(env) {
348
+ if configExists {
349
+ slog.Warn("Skipping Bedrock provider due to missing AWS credentials")
350
+ c.Providers.Del(string(p.ID))
351
+ }
352
+ continue
353
+ }
354
+ case catwalk.InferenceProvider("hyper"):
355
+ if apiKey := env.Get("HYPER_API_KEY"); apiKey != "" {
356
+ prepared.APIKey = apiKey
357
+ prepared.APIKeyTemplate = apiKey
358
+ } else {
359
+ v, err := resolver.ResolveValue(p.APIKey)
360
+ if v == "" || err != nil {
361
+ if configExists {
362
+ slog.Warn("Skipping Hyper provider due to missing API key", "provider", p.ID)
363
+ c.Providers.Del(string(p.ID))
364
+ }
365
+ continue
366
+ }
367
+ }
368
+ default:
369
+ // An OAuth login is a credential too: providers signed in
370
+ // through OAuth (e.g. OpenAI with a ChatGPT account) are
371
+ // configured even when no API key is present.
372
+ if config.OAuthToken != nil {
373
+ break
374
+ }
375
+ // if the provider api or endpoint are missing we skip them
376
+ v, err := resolver.ResolveValue(p.APIKey)
377
+ if v == "" || err != nil {
378
+ if configExists {
379
+ slog.Warn("Skipping provider due to missing API key", "provider", p.ID)
380
+ c.Providers.Del(string(p.ID))
381
+ }
382
+ continue
383
+ }
384
+ }
385
+ c.Providers.Set(string(p.ID), prepared)
386
+ }
387
+
388
+ // Discover models concurrently for custom providers that need it.
389
+ // A provider needs discovery when discover_models is explicitly true,
390
+ // or when the models list is empty (auto-trigger, unless opted out).
391
+ type discoveryResult struct {
392
+ models []catwalk.Model
393
+ err error
394
+ }
395
+
396
+ discoveryResults := make(map[string]discoveryResult)
397
+ var mu sync.Mutex
398
+ var wg sync.WaitGroup
399
+
400
+ discoverCtx, discoverCancel := context.WithTimeout(ctx, 3*time.Second)
401
+ for id, pc := range c.Providers.Seq2() {
402
+ if knownProviderNames[id] {
403
+ continue
404
+ }
405
+ if pc.Disable || pc.BaseURL == "" {
406
+ continue
407
+ }
408
+ wantsDiscovery := pc.AutoDiscoverModels != nil && *pc.AutoDiscoverModels
409
+ autoTrigger := len(pc.Models) == 0 && (pc.AutoDiscoverModels == nil || *pc.AutoDiscoverModels)
410
+ if !wantsDiscovery && !autoTrigger {
411
+ continue
412
+ }
413
+ providerID := cmp.Or(pc.ID, id)
414
+ cfg := discover.Config{
415
+ ID: providerID,
416
+ BaseURL: pc.BaseURL,
417
+ APIKey: pc.APIKey,
418
+ ExtraHeaders: pc.ExtraHeaders,
419
+ ExistingModels: pc.Models,
420
+ }
421
+ providerType := cmp.Or(pc.Type, catwalk.TypeOpenAICompat)
422
+ wg.Go(func() {
423
+ models, err := discover.DiscoverModels(discoverCtx, cfg, resolver)
424
+ if err == nil && len(models) > 0 {
425
+ if enricher := discover.GetEnricher(string(providerType)); enricher != nil {
426
+ models, _ = enricher.EnrichModels(discoverCtx, cfg, resolver, models)
427
+ }
428
+ }
429
+ mu.Lock()
430
+ discoveryResults[id] = discoveryResult{models: models, err: err}
431
+ mu.Unlock()
432
+ })
433
+ }
434
+ wg.Wait()
435
+ discoverCancel()
436
+
437
+ // Validate the custom providers.
438
+ for id, providerConfig := range c.Providers.Seq2() {
439
+ if knownProviderNames[id] {
440
+ continue
441
+ }
442
+
443
+ // Make sure the provider ID is set.
444
+ providerConfig.ID = id
445
+ providerConfig.Name = cmp.Or(providerConfig.Name, id) // Use ID as name if not set
446
+ // Default to OpenAI if not set.
447
+ providerConfig.Type = cmp.Or(providerConfig.Type, catwalk.TypeOpenAICompat)
448
+ if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) &&
449
+ providerConfig.Type != hyper.Name &&
450
+ !discover.IsKnownCustomProvider(string(providerConfig.Type)) {
451
+ slog.Warn("Skipping custom provider due to unsupported provider type", "provider", id)
452
+ c.Providers.Del(id)
453
+ continue
454
+ }
455
+
456
+ if providerConfig.Disable {
457
+ slog.Debug("Skipping custom provider due to disable flag", "provider", id)
458
+ c.Providers.Del(id)
459
+ continue
460
+ }
461
+ apiKey, err := resolver.ResolveValue(providerConfig.APIKey)
462
+ if apiKey == "" || err != nil {
463
+ slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id)
464
+ }
465
+ baseURL, err := resolver.ResolveValue(providerConfig.BaseURL)
466
+ if baseURL == "" || err != nil {
467
+ slog.Warn("Skipping custom provider due to missing API endpoint", "provider", id, "error", err)
468
+ c.Providers.Del(id)
469
+ continue
470
+ }
471
+
472
+ // Apply discovery results if available.
473
+ if result, ok := discoveryResults[id]; ok {
474
+ if result.err != nil {
475
+ slog.Warn("Model discovery failed", "provider", id, "error", result.err)
476
+ if len(providerConfig.Models) == 0 {
477
+ slog.Warn("Skipping provider with no models after failed discovery", "provider", id)
478
+ c.Providers.Del(id)
479
+ continue
480
+ }
481
+ } else if len(result.models) > 0 {
482
+ providerConfig.Models = result.models
483
+ slog.Info("Discovered models for provider", "provider", id, "count", len(result.models))
484
+ }
485
+ }
486
+
487
+ if len(providerConfig.Models) == 0 {
488
+ slog.Warn("Skipping custom provider because the provider has no models", "provider", id)
489
+ c.Providers.Del(id)
490
+ continue
491
+ }
492
+
493
+ // Custom-provider headers share the MCP error contract; see
494
+ // the known-provider loop above.
495
+ for k, v := range providerConfig.ExtraHeaders {
496
+ resolved, err := resolver.ResolveValue(v)
497
+ if err != nil {
498
+ return fmt.Errorf("resolving provider %s header %q: %w", id, k, err)
499
+ }
500
+ if resolved == "" {
501
+ delete(providerConfig.ExtraHeaders, k)
502
+ continue
503
+ }
504
+ providerConfig.ExtraHeaders[k] = resolved
505
+ }
506
+
507
+ c.Providers.Set(id, providerConfig)
508
+ }
509
+
510
+ if c.Providers.Len() == 0 && c.Options.DisableDefaultProviders {
511
+ return fmt.Errorf("default providers are disabled and there are no custom providers are configured")
512
+ }
513
+
514
+ return nil
515
+ }
516
+
517
+ // applyEnv sets top-level env vars from the config. Keys are sorted for
518
+ // deterministic ordering so that vars referencing other vars via the
519
+ // value resolver produce consistent results.
520
+ func (c *Config) applyEnv(resolver VariableResolver) {
521
+ keys := make([]string, 0, len(c.Env))
522
+ for k := range c.Env {
523
+ keys = append(keys, k)
524
+ }
525
+ slices.Sort(keys)
526
+ for _, k := range keys {
527
+ resolved, err := resolver.ResolveValue(c.Env[k])
528
+ if err != nil {
529
+ slog.Warn("Skipping env var due to resolution failure.", "key", k, "value", c.Env[k], "error", err)
530
+ continue
531
+ }
532
+ os.Setenv(k, resolved)
533
+ }
534
+ }
535
+
536
+ // NormalizeOptions allocates Options and Options.TUI and fills in the option
537
+ // defaults the UI relies on, so readers can dereference them without guarding.
538
+ // Configs loaded from disk get this via setDefaults; configs arriving over the
539
+ // wire from a Crush server need the same treatment before the UI reads them.
540
+ //
541
+ // DiffMode is deliberately left alone: the permissions dialog reads its zero
542
+ // value as "choose split or unified from the terminal width".
543
+ func (c *Config) NormalizeOptions() {
544
+ if c.Options == nil {
545
+ c.Options = &Options{}
546
+ }
547
+ if c.Options.TUI == nil {
548
+ c.Options.TUI = &TUIOptions{}
549
+ }
550
+ if c.Options.TUI.Scrollbar == "" {
551
+ c.Options.TUI.Scrollbar = ScrollbarDefault
552
+ }
553
+ if c.Options.TUI.ExitBanner == "" {
554
+ c.Options.TUI.ExitBanner = ExitBannerDefault
555
+ }
556
+ }
557
+
558
+ func (c *Config) setDefaults(workingDir, dataDir string) {
559
+ c.NormalizeOptions()
560
+ if len(c.Options.GlobalContextPaths) == 0 {
561
+ crushConfigDir := filepath.Dir(GlobalConfig())
562
+ c.Options.GlobalContextPaths = []string{
563
+ filepath.Join(crushConfigDir, "CRUSH.md"),
564
+ filepath.Join(filepath.Dir(crushConfigDir), "AGENTS.md"),
565
+ }
566
+ }
567
+ slices.Sort(c.Options.GlobalContextPaths)
568
+ c.Options.GlobalContextPaths = slices.Compact(c.Options.GlobalContextPaths)
569
+
570
+ if dataDir != "" {
571
+ c.Options.DataDirectory = dataDir
572
+ } else if c.Options.DataDirectory == "" {
573
+ if path, ok := fsext.LookupClosestBounded(workingDir, projectBoundary(workingDir), defaultDataDirectory); ok {
574
+ c.Options.DataDirectory = path
575
+ } else {
576
+ c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory)
577
+ }
578
+ }
579
+ c.Options.DataDirectory = filepath.Clean(filepathext.SmartJoin(workingDir, c.Options.DataDirectory))
580
+ if c.Providers == nil {
581
+ c.Providers = csync.NewMap[string, ProviderConfig]()
582
+ }
583
+ if c.Models == nil {
584
+ c.Models = make(map[SelectedModelType]SelectedModel)
585
+ }
586
+ if c.RecentModels == nil {
587
+ c.RecentModels = make(map[SelectedModelType][]SelectedModel)
588
+ }
589
+ if c.MCP == nil {
590
+ c.MCP = make(map[string]MCPConfig)
591
+ }
592
+ // Drop orphaned OAuth token entries left behind when a user removes
593
+ // an MCP from crush.json. See MCPConfig.isOrphanedToken.
594
+ for name, m := range c.MCP {
595
+ if m.isOrphanedToken() {
596
+ delete(c.MCP, name)
597
+ }
598
+ }
599
+ if c.LSP == nil {
600
+ c.LSP = make(map[string]LSPConfig)
601
+ }
602
+
603
+ // Apply defaults to LSP configurations
604
+ c.applyLSPDefaults()
605
+
606
+ // Add the default context paths if they are not already present
607
+ c.Options.ContextPaths = append(slices.Clone(defaultContextPaths), c.Options.ContextPaths...)
608
+
609
+ slices.Sort(c.Options.ContextPaths)
610
+ c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
611
+
612
+ // Add the default skills directories if not already present.
613
+ for _, dir := range GlobalSkillsDirs() {
614
+ if !slices.Contains(c.Options.SkillsPaths, dir) {
615
+ c.Options.SkillsPaths = append(c.Options.SkillsPaths, dir)
616
+ }
617
+ }
618
+
619
+ // Project specific skills dirs.
620
+ c.Options.SkillsPaths = append(c.Options.SkillsPaths, ProjectSkillsDir(workingDir)...)
621
+
622
+ if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
623
+ c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
624
+ }
625
+
626
+ if str, ok := os.LookupEnv("CRUSH_DISABLE_DEFAULT_PROVIDERS"); ok {
627
+ c.Options.DisableDefaultProviders, _ = strconv.ParseBool(str)
628
+ }
629
+
630
+ if c.Options.Attribution == nil {
631
+ c.Options.Attribution = &Attribution{
632
+ TrailerStyle: TrailerStyleAssistedBy,
633
+ GeneratedWith: true,
634
+ }
635
+ } else if c.Options.Attribution.TrailerStyle == "" {
636
+ // Migrate deprecated co_authored_by or apply default
637
+ if c.Options.Attribution.CoAuthoredBy != nil {
638
+ if *c.Options.Attribution.CoAuthoredBy {
639
+ c.Options.Attribution.TrailerStyle = TrailerStyleCoAuthoredBy
640
+ } else {
641
+ c.Options.Attribution.TrailerStyle = TrailerStyleNone
642
+ }
643
+ } else {
644
+ c.Options.Attribution.TrailerStyle = TrailerStyleAssistedBy
645
+ }
646
+ }
647
+
648
+ c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs)
649
+ }
650
+
651
+ // powernapDefaults caches the powernap default LSP server catalog. The
652
+ // catalog is static and immutable for the life of the process, but
653
+ // building it (NewManager + LoadDefaults) is expensive and was previously
654
+ // repeated on every config reload. We load it once and only ever read from
655
+ // it via GetServer, so a shared instance is safe.
656
+ var (
657
+ powernapDefaultsOnce sync.Once
658
+ powernapDefaults *powernapConfig.Manager
659
+ )
660
+
661
+ func lspDefaultsManager() *powernapConfig.Manager {
662
+ powernapDefaultsOnce.Do(func() {
663
+ m := powernapConfig.NewManager()
664
+ // LoadDefaults only fails on malformed embedded defaults, which
665
+ // would be a build-time bug; treat the manager as usable either
666
+ // way so a transient error never wedges config loading.
667
+ _ = m.LoadDefaults()
668
+ powernapDefaults = m
669
+ })
670
+ return powernapDefaults
671
+ }
672
+
673
+ // applyLSPDefaults applies default values from powernap to LSP configurations
674
+ func (c *Config) applyLSPDefaults() {
675
+ // Reuse the process-wide default catalog; building it per reload was a
676
+ // significant chunk of reload latency.
677
+ configManager := lspDefaultsManager()
678
+
679
+ // Apply defaults to each LSP configuration
680
+ for name, cfg := range c.LSP {
681
+ // Try to get defaults from powernap based on name or command name.
682
+ base, ok := configManager.GetServer(name)
683
+ if !ok {
684
+ base, ok = configManager.GetServer(cfg.Command)
685
+ if !ok {
686
+ continue
687
+ }
688
+ }
689
+ if cfg.Options == nil {
690
+ cfg.Options = base.Settings
691
+ }
692
+ if cfg.InitOptions == nil {
693
+ cfg.InitOptions = base.InitOptions
694
+ }
695
+ if len(cfg.FileTypes) == 0 {
696
+ cfg.FileTypes = base.FileTypes
697
+ }
698
+ if len(cfg.RootMarkers) == 0 {
699
+ cfg.RootMarkers = base.RootMarkers
700
+ }
701
+ cfg.Command = cmp.Or(cfg.Command, base.Command)
702
+ if len(cfg.Args) == 0 {
703
+ cfg.Args = base.Args
704
+ }
705
+ if len(cfg.Env) == 0 {
706
+ cfg.Env = base.Environment
707
+ }
708
+ // Update the config in the map
709
+ c.LSP[name] = cfg
710
+ }
711
+ }
712
+
713
+ func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
714
+ if len(knownProviders) == 0 && c.Providers.Len() == 0 {
715
+ err = fmt.Errorf("no providers configured, please configure at least one provider")
716
+ return largeModel, smallModel, err
717
+ }
718
+
719
+ // Use the first provider enabled based on the known providers order
720
+ // if no provider found that is known use the first provider configured
721
+ for _, p := range knownProviders {
722
+ providerConfig, ok := c.Providers.Get(string(p.ID))
723
+ if !ok || providerConfig.Disable {
724
+ continue
725
+ }
726
+ defaultLargeModel := c.GetModel(string(p.ID), p.DefaultLargeModelID)
727
+ if defaultLargeModel == nil {
728
+ slog.Warn("Default large model %s not found for provider %s", p.DefaultLargeModelID, p.ID)
729
+ if len(providerConfig.Models) == 0 {
730
+ return largeModel, smallModel, fmt.Errorf("default large model %s not found for provider %s", p.DefaultLargeModelID, p.ID)
731
+ }
732
+ defaultLargeModel = &providerConfig.Models[0]
733
+ }
734
+ largeModel = SelectedModel{
735
+ Provider: string(p.ID),
736
+ Model: defaultLargeModel.ID,
737
+ MaxTokens: defaultLargeModel.DefaultMaxTokens,
738
+ ReasoningEffort: defaultLargeModel.DefaultReasoningEffort,
739
+ }
740
+
741
+ defaultSmallModel := c.GetModel(string(p.ID), p.DefaultSmallModelID)
742
+ if defaultSmallModel == nil {
743
+ slog.Warn("Default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
744
+ if len(providerConfig.Models) == 0 {
745
+ return largeModel, smallModel, fmt.Errorf("default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
746
+ }
747
+ defaultSmallModel = &providerConfig.Models[0]
748
+ }
749
+ smallModel = SelectedModel{
750
+ Provider: string(p.ID),
751
+ Model: defaultSmallModel.ID,
752
+ MaxTokens: defaultSmallModel.DefaultMaxTokens,
753
+ ReasoningEffort: defaultSmallModel.DefaultReasoningEffort,
754
+ }
755
+ return largeModel, smallModel, err
756
+ }
757
+
758
+ enabledProviders := c.EnabledProviders()
759
+ slices.SortFunc(enabledProviders, func(a, b ProviderConfig) int {
760
+ return strings.Compare(a.ID, b.ID)
761
+ })
762
+
763
+ if len(enabledProviders) == 0 {
764
+ err = fmt.Errorf("no providers configured, please configure at least one provider")
765
+ return largeModel, smallModel, err
766
+ }
767
+
768
+ providerConfig := enabledProviders[0]
769
+ if len(providerConfig.Models) == 0 {
770
+ err = fmt.Errorf("provider %s has no models configured", providerConfig.ID)
771
+ return largeModel, smallModel, err
772
+ }
773
+ defaultLargeModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
774
+ largeModel = SelectedModel{
775
+ Provider: providerConfig.ID,
776
+ Model: defaultLargeModel.ID,
777
+ MaxTokens: defaultLargeModel.DefaultMaxTokens,
778
+ }
779
+ defaultSmallModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
780
+ smallModel = SelectedModel{
781
+ Provider: providerConfig.ID,
782
+ Model: defaultSmallModel.ID,
783
+ MaxTokens: defaultSmallModel.DefaultMaxTokens,
784
+ }
785
+ return largeModel, smallModel, err
786
+ }
787
+
788
+ // resolvedModels holds the result of resolving user-configured model
789
+ // selections against the provider catalog.
790
+ type resolvedModels struct {
791
+ Large SelectedModel
792
+ Small SelectedModel
793
+ LargeFallback bool // true if Large was corrected to a default
794
+ SmallFallback bool // true if Small was corrected to a default
795
+ }
796
+
797
+ // resolveSelectedModels validates the user's configured model selections
798
+ // against the provider catalog, falling back to defaults when a model ID is
799
+ // invalid. It is pure resolution logic: it does not mutate the store or
800
+ // touch disk. The caller assigns the results to c.Models and persists any
801
+ // fallback corrections as appropriate.
802
+ func resolveSelectedModels(cfg *Config, knownProviders []catwalk.Provider) (resolvedModels, error) {
803
+ var result resolvedModels
804
+ defaultLarge, defaultSmall, err := cfg.defaultModelSelection(knownProviders)
805
+ if err != nil {
806
+ return result, fmt.Errorf("failed to select default models: %w", err)
807
+ }
808
+ large, small := defaultLarge, defaultSmall
809
+
810
+ largeModelSelected, largeModelConfigured := cfg.Models[SelectedModelTypeLarge]
811
+ if largeModelConfigured {
812
+ if largeModelSelected.Model != "" {
813
+ large.Model = largeModelSelected.Model
814
+ }
815
+ if largeModelSelected.Provider != "" {
816
+ large.Provider = largeModelSelected.Provider
817
+ }
818
+ model := cfg.GetModel(large.Provider, large.Model)
819
+ if model == nil {
820
+ large = defaultLarge
821
+ result.LargeFallback = true
822
+ } else {
823
+ if largeModelSelected.MaxTokens > 0 {
824
+ large.MaxTokens = largeModelSelected.MaxTokens
825
+ } else {
826
+ large.MaxTokens = model.DefaultMaxTokens
827
+ }
828
+ if largeModelSelected.ReasoningEffort != "" {
829
+ large.ReasoningEffort = largeModelSelected.ReasoningEffort
830
+ } else {
831
+ large.ReasoningEffort = model.DefaultReasoningEffort
832
+ }
833
+ large.Think = largeModelSelected.Think
834
+ if largeModelSelected.Temperature != nil {
835
+ large.Temperature = largeModelSelected.Temperature
836
+ }
837
+ if largeModelSelected.TopP != nil {
838
+ large.TopP = largeModelSelected.TopP
839
+ }
840
+ if largeModelSelected.TopK != nil {
841
+ large.TopK = largeModelSelected.TopK
842
+ }
843
+ if largeModelSelected.FrequencyPenalty != nil {
844
+ large.FrequencyPenalty = largeModelSelected.FrequencyPenalty
845
+ }
846
+ if largeModelSelected.PresencePenalty != nil {
847
+ large.PresencePenalty = largeModelSelected.PresencePenalty
848
+ }
849
+ if largeModelSelected.ProviderOptions != nil {
850
+ large.ProviderOptions = maps.Clone(largeModelSelected.ProviderOptions)
851
+ }
852
+ }
853
+ }
854
+ smallModelSelected, smallModelConfigured := cfg.Models[SelectedModelTypeSmall]
855
+ if smallModelConfigured {
856
+ if smallModelSelected.Model != "" {
857
+ small.Model = smallModelSelected.Model
858
+ }
859
+ if smallModelSelected.Provider != "" {
860
+ small.Provider = smallModelSelected.Provider
861
+ }
862
+
863
+ model := cfg.GetModel(small.Provider, small.Model)
864
+ if model == nil {
865
+ small = defaultSmall
866
+ result.SmallFallback = true
867
+ } else {
868
+ if smallModelSelected.MaxTokens > 0 {
869
+ small.MaxTokens = smallModelSelected.MaxTokens
870
+ } else {
871
+ small.MaxTokens = model.DefaultMaxTokens
872
+ }
873
+ if smallModelSelected.ReasoningEffort != "" {
874
+ small.ReasoningEffort = smallModelSelected.ReasoningEffort
875
+ } else {
876
+ small.ReasoningEffort = model.DefaultReasoningEffort
877
+ }
878
+ if smallModelSelected.Temperature != nil {
879
+ small.Temperature = smallModelSelected.Temperature
880
+ }
881
+ if smallModelSelected.TopP != nil {
882
+ small.TopP = smallModelSelected.TopP
883
+ }
884
+ if smallModelSelected.TopK != nil {
885
+ small.TopK = smallModelSelected.TopK
886
+ }
887
+ if smallModelSelected.FrequencyPenalty != nil {
888
+ small.FrequencyPenalty = smallModelSelected.FrequencyPenalty
889
+ }
890
+ if smallModelSelected.PresencePenalty != nil {
891
+ small.PresencePenalty = smallModelSelected.PresencePenalty
892
+ }
893
+ if smallModelSelected.ProviderOptions != nil {
894
+ small.ProviderOptions = maps.Clone(smallModelSelected.ProviderOptions)
895
+ }
896
+ small.Think = smallModelSelected.Think
897
+ }
898
+ }
899
+
900
+ // When small isn't explicitly configured and the provider isn't a
901
+ // known built-in, use the large model as the small model. This
902
+ // prevents two different models from being requested concurrently
903
+ // for local/openai-compat providers.
904
+ if !smallModelConfigured {
905
+ isKnownProvider := false
906
+ for _, kp := range knownProviders {
907
+ if string(kp.ID) == small.Provider {
908
+ isKnownProvider = true
909
+ break
910
+ }
911
+ }
912
+ if !isKnownProvider {
913
+ slog.Warn("Using large model as small model for unknown provider", "provider", large.Provider, "model", large.Model)
914
+ small = large
915
+ }
916
+ }
917
+
918
+ result.Large = large
919
+ result.Small = small
920
+ return result, nil
921
+ }
922
+
923
+ // lookupConfigs searches config files starting at cwd and walking up
924
+ // through the current project. The upward walk stops at the git
925
+ // working tree root when one can be detected, otherwise at cwd itself,
926
+ // so an unrelated crush.json placed above the project is never picked
927
+ // up. Global user-level config locations are always included
928
+ // regardless of the boundary.
929
+ func lookupConfigs(cwd string) []string {
930
+ // Prepend global user config and machine-owned data JSON. Only the user
931
+ // config directory contributes a crushrc; the data directory is writable
932
+ // machine state and must never be executed as Bash. Missing files are
933
+ // skipped when loaded.
934
+ configPaths := []string{
935
+ systemConfigPath,
936
+ GlobalConfig(),
937
+ shellConfigSibling(GlobalConfig()),
938
+ GlobalConfigData(),
939
+ }
940
+
941
+ // Ordered high-to-low priority within a directory. LookupBounded returns
942
+ // matches in this order, and the later reverse + merge make the earliest
943
+ // listed name win on conflict. So: .crushrc beats crushrc, both beat the
944
+ // JSON configs, and .crush.json beats crush.json.
945
+ configNames := []string{
946
+ "." + appName + "rc",
947
+ appName + "rc",
948
+ "." + appName + ".json",
949
+ appName + ".json",
950
+ }
951
+
952
+ foundConfigs, err := fsext.LookupBounded(cwd, projectBoundary(cwd), configNames...)
953
+ if err != nil {
954
+ // returns at least default configs
955
+ return configPaths
956
+ }
957
+
958
+ // reverse order so last config has more priority
959
+ slices.Reverse(foundConfigs)
960
+
961
+ return append(configPaths, foundConfigs...)
962
+ }
963
+
964
+ func loadFromConfigPaths(ctx context.Context, configPaths []string) (*Config, []string, error) {
965
+ var configs [][]byte
966
+ var loaded []string
967
+
968
+ // Track directories that have both crush.json and crushrc to warn
969
+ // about potential confusion, along with the top-level keys each
970
+ // defines so we can report conflicts.
971
+ jsonDirKeys := make(map[string]map[string]bool)
972
+ shDirKeys := make(map[string]map[string]bool)
973
+
974
+ for _, path := range configPaths {
975
+ if path == "" {
976
+ continue
977
+ }
978
+ data, err := os.ReadFile(path)
979
+ if err != nil {
980
+ if os.IsNotExist(err) {
981
+ continue
982
+ }
983
+ return nil, nil, fmt.Errorf("failed to open config file %s: %w", path, err)
984
+ }
985
+ if len(data) == 0 {
986
+ continue
987
+ }
988
+
989
+ dir := filepath.Dir(path)
990
+ if isShellConfig(path) {
991
+ jsonBytes, err := shellconfig.LoadShellConfig(ctx, path, data)
992
+ if err != nil {
993
+ return nil, nil, fmt.Errorf("failed to load shell config %s: %w", path, err)
994
+ }
995
+ if len(jsonBytes) > 0 {
996
+ if !json.Valid(jsonBytes) {
997
+ return nil, nil, fmt.Errorf("shell config %s produced invalid JSON", path)
998
+ }
999
+ addTopLevelKeys(shDirKeys, dir, jsonBytes)
1000
+ configs = append(configs, jsonBytes)
1001
+ loaded = append(loaded, path)
1002
+ }
1003
+ } else {
1004
+ if !json.Valid(data) {
1005
+ return nil, nil, fmt.Errorf("invalid JSON in config file %s", path)
1006
+ }
1007
+ addTopLevelKeys(jsonDirKeys, dir, data)
1008
+ configs = append(configs, data)
1009
+ loaded = append(loaded, path)
1010
+ }
1011
+ }
1012
+
1013
+ // Warn if both a JSON config and a crushrc exist in the same directory
1014
+ // and define overlapping top-level keys. Disjoint coexistence is
1015
+ // intentional and not worth warning about.
1016
+ for dir, jKeys := range jsonDirKeys {
1017
+ sKeys, ok := shDirKeys[dir]
1018
+ if !ok {
1019
+ continue
1020
+ }
1021
+ var conflicts []string
1022
+ for k := range jKeys {
1023
+ if sKeys[k] {
1024
+ conflicts = append(conflicts, k)
1025
+ }
1026
+ }
1027
+ if len(conflicts) > 0 {
1028
+ slices.Sort(conflicts)
1029
+ slog.Warn("Found both a JSON config and a crushrc in the same directory; merging with crushrc taking precedence",
1030
+ "dir", dir, "conflicting_keys", strings.Join(conflicts, ", "))
1031
+ }
1032
+ }
1033
+
1034
+ cfg, err := loadFromBytes(configs)
1035
+ if err != nil {
1036
+ return nil, nil, err
1037
+ }
1038
+ return cfg, loaded, nil
1039
+ }
1040
+
1041
+ // addTopLevelKeys records the top-level JSON keys present in data into the
1042
+ // set for dir.
1043
+ func addTopLevelKeys(m map[string]map[string]bool, dir string, data []byte) {
1044
+ keys := m[dir]
1045
+ if keys == nil {
1046
+ keys = make(map[string]bool)
1047
+ m[dir] = keys
1048
+ }
1049
+ gjson.ParseBytes(data).ForEach(func(key, _ gjson.Result) bool {
1050
+ keys[key.String()] = true
1051
+ return true
1052
+ })
1053
+ }
1054
+
1055
+ func loadFromBytes(configs [][]byte) (*Config, error) {
1056
+ if len(configs) == 0 {
1057
+ return &Config{}, nil
1058
+ }
1059
+
1060
+ data, err := jsons.Merge(configs)
1061
+ if err != nil {
1062
+ return nil, err
1063
+ }
1064
+ var config Config
1065
+ if err := json.Unmarshal(data, &config); err != nil {
1066
+ return nil, err
1067
+ }
1068
+ return &config, nil
1069
+ }
1070
+
1071
+ func hasAWSCredentials(env env.Env) bool {
1072
+ if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" {
1073
+ return true
1074
+ }
1075
+
1076
+ if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" {
1077
+ return true
1078
+ }
1079
+
1080
+ if env.Get("AWS_PROFILE") != "" || env.Get("AWS_DEFAULT_PROFILE") != "" {
1081
+ return true
1082
+ }
1083
+
1084
+ if env.Get("AWS_REGION") != "" || env.Get("AWS_DEFAULT_REGION") != "" {
1085
+ return true
1086
+ }
1087
+
1088
+ if env.Get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
1089
+ env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
1090
+ return true
1091
+ }
1092
+
1093
+ // File-based credential discovery requires filesystem stats, so do it
1094
+ // last and skip it under test. Checking testing.Testing() before the
1095
+ // os.Stat call (rather than after, in the && tail) ensures the syscall
1096
+ // is never issued during tests, where it otherwise ran unconditionally
1097
+ // and only had its result discarded.
1098
+ if testing.Testing() {
1099
+ return false
1100
+ }
1101
+ if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil {
1102
+ return true
1103
+ }
1104
+ if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/login")); err == nil {
1105
+ return true
1106
+ }
1107
+
1108
+ return false
1109
+ }
1110
+
1111
+ // migrateDisableNotifications migrates the deprecated disable_notifications
1112
+ // and notification_style fields to the unified notifications field. It checks
1113
+ // both the user config (~/.config) and data config (~/.local) files. If
1114
+ // disable_notifications is true, it sets notifications to "disabled" in the
1115
+ // data file. If notification_style is set, it moves the value to notifications.
1116
+ // Regardless of value, it removes the deprecated fields from any file that
1117
+ // contains them.
1118
+ func migrateDisableNotifications() {
1119
+ globalConfig := GlobalConfig()
1120
+ dataConfig := GlobalConfigData()
1121
+
1122
+ var wasDisabled bool
1123
+ var styleValue string
1124
+ filesToClean := []string{}
1125
+
1126
+ for _, path := range []string{globalConfig, dataConfig} {
1127
+ data, err := os.ReadFile(path)
1128
+ if err != nil {
1129
+ continue
1130
+ }
1131
+ needsClean := false
1132
+ if gjson.Get(string(data), "options.disable_notifications").Exists() {
1133
+ needsClean = true
1134
+ if gjson.Get(string(data), "options.disable_notifications").Bool() {
1135
+ wasDisabled = true
1136
+ }
1137
+ }
1138
+ if v := gjson.Get(string(data), "options.notification_style"); v.Exists() {
1139
+ needsClean = true
1140
+ if styleValue == "" {
1141
+ styleValue = v.String()
1142
+ }
1143
+ }
1144
+ if needsClean {
1145
+ filesToClean = append(filesToClean, path)
1146
+ }
1147
+ }
1148
+
1149
+ if len(filesToClean) == 0 {
1150
+ return
1151
+ }
1152
+
1153
+ // Determine the value to persist: notification_style takes precedence,
1154
+ // then disable_notifications: true maps to "disabled".
1155
+ migratedValue := styleValue
1156
+ if migratedValue == "" && wasDisabled {
1157
+ migratedValue = "disabled"
1158
+ }
1159
+
1160
+ if migratedValue != "" {
1161
+ data, err := os.ReadFile(dataConfig)
1162
+ if err == nil {
1163
+ if !gjson.Get(string(data), "options.notifications").Exists() {
1164
+ updated, err := sjson.Set(string(data), "options.notifications", migratedValue)
1165
+ if err == nil {
1166
+ if err := atomicWriteFile(dataConfig, []byte(updated), 0o600); err != nil {
1167
+ slog.Warn("Failed to migrate to notifications field", "error", err)
1168
+ } else {
1169
+ slog.Info("Migrated notification settings to notifications field", "value", migratedValue)
1170
+ }
1171
+ }
1172
+ }
1173
+ }
1174
+ }
1175
+
1176
+ // Remove deprecated fields from all files that contain them.
1177
+ for _, path := range filesToClean {
1178
+ data, err := os.ReadFile(path)
1179
+ if err != nil {
1180
+ continue
1181
+ }
1182
+ updated := string(data)
1183
+ updated, _ = sjson.Delete(updated, "options.disable_notifications")
1184
+ updated, _ = sjson.Delete(updated, "options.notification_style")
1185
+ if updated == string(data) {
1186
+ continue
1187
+ }
1188
+ if err := atomicWriteFile(path, []byte(updated), 0o600); err != nil {
1189
+ slog.Warn("Failed to write migrated config", "path", path, "error", err)
1190
+ }
1191
+ }
1192
+ }
1193
+
1194
+ // GlobalConfig returns the global configuration file path for the application.
1195
+ func GlobalConfig() string {
1196
+ if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {
1197
+ return filepath.Join(crushGlobal, fmt.Sprintf("%s.json", appName))
1198
+ }
1199
+ return filepath.Join(home.Config(), appName, fmt.Sprintf("%s.json", appName))
1200
+ }
1201
+
1202
+ // shellConfigSibling returns the crushrc path that sits alongside a given
1203
+ // crush.json path (same directory). Used so global config locations pick up a
1204
+ // shell config, not just JSON.
1205
+ func shellConfigSibling(jsonPath string) string {
1206
+ return filepath.Join(filepath.Dir(jsonPath), appName+"rc")
1207
+ }
1208
+
1209
+ // isShellConfig reports whether a config path is a shell config (crushrc or
1210
+ // the hidden .crushrc), as opposed to a JSON config.
1211
+ func isShellConfig(path string) bool {
1212
+ base := filepath.Base(path)
1213
+ return base == appName+"rc" || base == "."+appName+"rc"
1214
+ }
1215
+
1216
+ // GlobalCacheDir returns the path to the global cache directory for the
1217
+ // application.
1218
+ func GlobalCacheDir() string {
1219
+ if crushCache := os.Getenv("CRUSH_CACHE_DIR"); crushCache != "" {
1220
+ return crushCache
1221
+ }
1222
+ if xdgCacheHome := os.Getenv("XDG_CACHE_HOME"); xdgCacheHome != "" {
1223
+ return filepath.Join(xdgCacheHome, appName)
1224
+ }
1225
+ if runtime.GOOS == "windows" {
1226
+ localAppData := cmp.Or(
1227
+ os.Getenv("LOCALAPPDATA"),
1228
+ filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
1229
+ )
1230
+ return filepath.Join(localAppData, appName, "cache")
1231
+ }
1232
+ return filepath.Join(home.Dir(), ".cache", appName)
1233
+ }
1234
+
1235
+ // ProjectConfigs returns list of current project configs paths.
1236
+ func ProjectConfigs(cwd string) []string {
1237
+ return lookupConfigs(cwd)
1238
+ }
1239
+
1240
+ // GlobalConfigData returns the path to the main data directory for the application.
1241
+ // this config is used when the app overrides configurations instead of updating the global config.
1242
+ func GlobalConfigData() string {
1243
+ if crushData := os.Getenv("CRUSH_GLOBAL_DATA"); crushData != "" {
1244
+ return filepath.Join(crushData, fmt.Sprintf("%s.json", appName))
1245
+ }
1246
+ if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" {
1247
+ return filepath.Join(xdgDataHome, appName, fmt.Sprintf("%s.json", appName))
1248
+ }
1249
+
1250
+ // return the path to the main data directory
1251
+ // for windows, it should be in `%LOCALAPPDATA%/crush/`
1252
+ // for linux and macOS, it should be in `$HOME/.local/share/crush/`
1253
+ if runtime.GOOS == "windows" {
1254
+ localAppData := cmp.Or(
1255
+ os.Getenv("LOCALAPPDATA"),
1256
+ filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
1257
+ )
1258
+ return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
1259
+ }
1260
+
1261
+ return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
1262
+ }
1263
+
1264
+ // GlobalWorkspaceDir returns the path to the global server workspace
1265
+ // directory. This directory acts as a meta-workspace for the server
1266
+ // process, giving it a real workingDir so that config loading, scoped
1267
+ // writes, and provider resolution behave identically to project
1268
+ // workspaces.
1269
+ func GlobalWorkspaceDir() string {
1270
+ return filepath.Dir(GlobalConfigData())
1271
+ }
1272
+
1273
+ func assignIfNil[T any](ptr **T, val T) {
1274
+ if *ptr == nil {
1275
+ *ptr = &val
1276
+ }
1277
+ }
1278
+
1279
+ func isInsideWorktree() bool {
1280
+ bts, err := exec.CommandContext(
1281
+ context.Background(),
1282
+ "git", "rev-parse",
1283
+ "--is-inside-work-tree",
1284
+ ).CombinedOutput()
1285
+ return err == nil && strings.TrimSpace(string(bts)) == "true"
1286
+ }
1287
+
1288
+ // worktreeRoot returns the absolute path of the git working tree root for
1289
+ // dir, or the empty string if dir is not inside a working tree (bare
1290
+ // repositories, missing git binary, plain directories, or any other
1291
+ // failure mode). Linked worktrees and submodules each report their own
1292
+ // top-level, which is what callers want when bounding lookups.
1293
+ // worktreeRootCache memoizes the git worktree root per directory. The root
1294
+ // is stable for the life of the process, so we avoid re-shelling out to
1295
+ // "git rev-parse" on every config reload. Keyed by the requested dir; the
1296
+ // value is the resolved root ("" when dir is not in a git worktree).
1297
+ var worktreeRootCache sync.Map // map[string]string
1298
+
1299
+ func worktreeRoot(dir string) string {
1300
+ if cached, ok := worktreeRootCache.Load(dir); ok {
1301
+ return cached.(string)
1302
+ }
1303
+ root := computeWorktreeRoot(dir)
1304
+ worktreeRootCache.Store(dir, root)
1305
+ return root
1306
+ }
1307
+
1308
+ func computeWorktreeRoot(dir string) string {
1309
+ cmd := exec.CommandContext(
1310
+ context.Background(),
1311
+ "git", "rev-parse", "--show-toplevel",
1312
+ )
1313
+ cmd.Dir = dir
1314
+ out, err := cmd.Output()
1315
+ if err != nil {
1316
+ return ""
1317
+ }
1318
+ root := strings.TrimSpace(string(out))
1319
+ if root == "" {
1320
+ return ""
1321
+ }
1322
+ abs, err := filepath.Abs(root)
1323
+ if err != nil {
1324
+ return ""
1325
+ }
1326
+ return abs
1327
+ }
1328
+
1329
+ // projectBoundary returns the directory at which an upward configuration
1330
+ // search rooted at dir should stop. It is the git working tree root when
1331
+ // one can be detected, otherwise dir itself. Returning dir as a
1332
+ // fallback keeps Crush from silently adopting state files placed above
1333
+ // the current project.
1334
+ func projectBoundary(dir string) string {
1335
+ if root := worktreeRoot(dir); root != "" {
1336
+ return root
1337
+ }
1338
+ abs, err := filepath.Abs(dir)
1339
+ if err != nil {
1340
+ return dir
1341
+ }
1342
+ return abs
1343
+ }
1344
+
1345
+ // GlobalSkillsDirs returns the default directories for Agent Skills.
1346
+ // Skills in these directories are auto-discovered and their files can be read
1347
+ // without permission prompts.
1348
+ func GlobalSkillsDirs() []string {
1349
+ if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
1350
+ return []string{crushSkills}
1351
+ }
1352
+
1353
+ paths := []string{
1354
+ filepath.Join(home.Config(), appName, "skills"),
1355
+ filepath.Join(home.Config(), "agents", "skills"),
1356
+ // Per the Agent Skills spec, scan ~/.agents/skills
1357
+ filepath.Join(home.Dir(), ".agents", "skills"),
1358
+ filepath.Join(home.Dir(), ".claude", "skills"),
1359
+ }
1360
+
1361
+ // On Windows, also load from app data on top of `$HOME/.config/crush`.
1362
+ // This is here mostly for backwards compatibility.
1363
+ if runtime.GOOS == "windows" {
1364
+ appData := cmp.Or(
1365
+ os.Getenv("LOCALAPPDATA"),
1366
+ filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
1367
+ )
1368
+ paths = append(
1369
+ paths,
1370
+ filepath.Join(appData, appName, "skills"),
1371
+ filepath.Join(appData, "agents", "skills"),
1372
+ )
1373
+ }
1374
+
1375
+ return paths
1376
+ }
1377
+
1378
+ // projectSkillSubdirs lists the conventional subdirectories where
1379
+ // project-level skills are discovered. Shared across working-dir and
1380
+ // git-root lookups to prevent drift when a new convention is added.
1381
+ var projectSkillSubdirs = []string{
1382
+ ".agents/skills",
1383
+ ".crush/skills",
1384
+ ".claude/skills",
1385
+ ".cursor/skills",
1386
+ }
1387
+
1388
+ // ProjectSkillsDir returns the default project directories for which Crush
1389
+ // will look for skills. In addition to the working directory, it also
1390
+ // checks the git working tree root so that monorepo-level skills are
1391
+ // discovered when the user is inside a subdirectory.
1392
+ // Working-directory paths come first so local skills take precedence
1393
+ // over monorepo-level ones.
1394
+ func ProjectSkillsDir(workingDir string) []string {
1395
+ dirs := make([]string, 0, len(projectSkillSubdirs)*2)
1396
+ for _, sub := range projectSkillSubdirs {
1397
+ dirs = append(dirs, filepath.Join(workingDir, sub))
1398
+ }
1399
+
1400
+ // When the working directory is inside a git repository, also look at
1401
+ // the repository root so monorepo-level .agents/skills are found.
1402
+ if root := worktreeRoot(workingDir); root != "" && root != workingDir {
1403
+ for _, sub := range projectSkillSubdirs {
1404
+ dirs = append(dirs, filepath.Join(root, sub))
1405
+ }
1406
+ }
1407
+
1408
+ return dirs
1409
+ }
1410
+
1411
+ func isAppleTerminal() bool { return os.Getenv("TERM_PROGRAM") == "Apple_Terminal" }
1412
+
1413
+ // normalizeHookEvent maps user-provided event names to their canonical
1414
+ // form. Matching is case-insensitive and accepts snake_case variants
1415
+ // (e.g. "pre_tool_use" → "PreToolUse").
1416
+ func normalizeHookEvent(name string) string {
1417
+ switch strings.ToLower(strings.ReplaceAll(name, "_", "")) {
1418
+ case "pretooluse":
1419
+ return "PreToolUse"
1420
+ default:
1421
+ return name
1422
+ }
1423
+ }
1424
+
1425
+ // ValidateHooks normalizes event names and checks that every configured
1426
+ // hook has a command and a syntactically valid matcher regex. Matcher
1427
+ // compilation used for matching is owned by hooks.Runner; this function
1428
+ // only validates up front so the user sees config errors at load time
1429
+ // rather than on the first tool call.
1430
+ func (c *Config) ValidateHooks() error {
1431
+ // Normalize event name keys.
1432
+ for event, eventHooks := range c.Hooks {
1433
+ canonical := normalizeHookEvent(event)
1434
+ if canonical != event {
1435
+ c.Hooks[canonical] = append(c.Hooks[canonical], eventHooks...)
1436
+ delete(c.Hooks, event)
1437
+ }
1438
+ }
1439
+
1440
+ for event, eventHooks := range c.Hooks {
1441
+ for i, h := range eventHooks {
1442
+ if h.Command == "" {
1443
+ return fmt.Errorf("hook %s[%d]: command is required", event, i)
1444
+ }
1445
+ if h.Matcher == "" {
1446
+ continue
1447
+ }
1448
+ if _, err := regexp.Compile(h.Matcher); err != nil {
1449
+ return fmt.Errorf("hook %s[%d]: invalid matcher regex %q: %w", event, i, h.Matcher, err)
1450
+ }
1451
+ }
1452
+ }
1453
+ return nil
1454
+ }
internal/config/load_test.go ADDED
@@ -0,0 +1,2536 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "context"
5
+ "io"
6
+ "log/slog"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "os"
10
+ "os/exec"
11
+ "path/filepath"
12
+ "runtime"
13
+ "strings"
14
+ "testing"
15
+ "time"
16
+
17
+ "charm.land/catwalk/pkg/catwalk"
18
+ "github.com/charmbracelet/crush/internal/csync"
19
+ "github.com/charmbracelet/crush/internal/env"
20
+ "github.com/charmbracelet/crush/internal/oauth"
21
+ "github.com/stretchr/testify/assert"
22
+ "github.com/stretchr/testify/require"
23
+ )
24
+
25
+ func TestMain(m *testing.M) {
26
+ slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
27
+
28
+ exitVal := m.Run()
29
+ os.Exit(exitVal)
30
+ }
31
+
32
+ func TestConfig_LoadFromBytes(t *testing.T) {
33
+ data1 := []byte(`{"providers": {"openai": {"api_key": "key1", "base_url": "https://api.openai.com/v1"}}}`)
34
+ data2 := []byte(`{"providers": {"openai": {"api_key": "key2", "base_url": "https://api.openai.com/v2"}}}`)
35
+ data3 := []byte(`{"providers": {"openai": {}}}`)
36
+
37
+ loadedConfig, err := loadFromBytes([][]byte{data1, data2, data3})
38
+
39
+ require.NoError(t, err)
40
+ require.NotNil(t, loadedConfig)
41
+ require.Equal(t, 1, loadedConfig.Providers.Len())
42
+ pc, _ := loadedConfig.Providers.Get("openai")
43
+ require.Equal(t, "key2", pc.APIKey)
44
+ require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
45
+ }
46
+
47
+ func TestLookupConfigs_BoundedByProject(t *testing.T) {
48
+ // Force GlobalConfig and GlobalConfigData to point at locations we
49
+ // control so they can be present in the result without polluting
50
+ // the developer's real config.
51
+ globalDir := t.TempDir()
52
+ dataDir := t.TempDir()
53
+ t.Setenv("CRUSH_GLOBAL_CONFIG", globalDir)
54
+ t.Setenv("CRUSH_GLOBAL_DATA", dataDir)
55
+
56
+ t.Run("does not pick up crush.json above non-git project", func(t *testing.T) {
57
+ parent := t.TempDir()
58
+
59
+ // crush.json above the project must not be adopted.
60
+ require.NoError(t, os.WriteFile(
61
+ filepath.Join(parent, "crush.json"),
62
+ []byte(`{}`),
63
+ 0o644,
64
+ ))
65
+
66
+ project := filepath.Join(parent, "project")
67
+ require.NoError(t, os.Mkdir(project, 0o755))
68
+
69
+ got := lookupConfigs(project)
70
+ for _, p := range got {
71
+ require.NotEqual(t, filepath.Join(parent, "crush.json"), p)
72
+ }
73
+ })
74
+
75
+ t.Run("does not climb out of git worktree to find crush.json", func(t *testing.T) {
76
+ if _, err := exec.LookPath("git"); err != nil {
77
+ t.Skip("git not available")
78
+ }
79
+
80
+ parent := t.TempDir()
81
+
82
+ require.NoError(t, os.WriteFile(
83
+ filepath.Join(parent, "crush.json"),
84
+ []byte(`{}`),
85
+ 0o644,
86
+ ))
87
+
88
+ worktree := filepath.Join(parent, "worktree")
89
+ require.NoError(t, os.Mkdir(worktree, 0o755))
90
+ gitInit := exec.CommandContext(t.Context(), "git", "init", "-q")
91
+ gitInit.Dir = worktree
92
+ require.NoError(t, gitInit.Run())
93
+
94
+ got := lookupConfigs(worktree)
95
+ strayEval, err := filepath.EvalSymlinks(filepath.Join(parent, "crush.json"))
96
+ require.NoError(t, err)
97
+ for _, p := range got {
98
+ pEval, err := filepath.EvalSymlinks(p)
99
+ if err != nil {
100
+ continue
101
+ }
102
+ require.NotEqual(t, strayEval, pEval, "must not adopt parent crush.json")
103
+ }
104
+ })
105
+
106
+ t.Run("picks up crush.json inside the project", func(t *testing.T) {
107
+ project := t.TempDir()
108
+ local := filepath.Join(project, "crush.json")
109
+ require.NoError(t, os.WriteFile(local, []byte(`{}`), 0o644))
110
+
111
+ got := lookupConfigs(project)
112
+
113
+ localEval, err := filepath.EvalSymlinks(local)
114
+ require.NoError(t, err)
115
+ var foundLocal bool
116
+ for _, p := range got {
117
+ pEval, err := filepath.EvalSymlinks(p)
118
+ if err != nil {
119
+ continue
120
+ }
121
+ if pEval == localEval {
122
+ foundLocal = true
123
+ break
124
+ }
125
+ }
126
+ require.True(t, foundLocal, "expected project crush.json to be in lookup result: %v", got)
127
+ })
128
+
129
+ t.Run("global config is always included regardless of boundary", func(t *testing.T) {
130
+ project := t.TempDir()
131
+
132
+ got := lookupConfigs(project)
133
+ // Global config and global data path are always prepended,
134
+ // even when no project file exists.
135
+ require.Contains(t, got, GlobalConfig())
136
+ require.Contains(t, got, GlobalConfigData())
137
+ })
138
+
139
+ t.Run("global shell config (crushrc) is included", func(t *testing.T) {
140
+ project := t.TempDir()
141
+
142
+ got := lookupConfigs(project)
143
+ // A global crushrc is discovered only beside the user config. The data
144
+ // directory is machine-owned state and must never execute a crushrc.
145
+ require.Contains(t, got, shellConfigSibling(GlobalConfig()))
146
+ require.NotContains(t, got, shellConfigSibling(GlobalConfigData()))
147
+ })
148
+
149
+ t.Run("project crushrc and .crushrc are discovered", func(t *testing.T) {
150
+ project := t.TempDir()
151
+ require.NoError(t, os.WriteFile(filepath.Join(project, "crushrc"), []byte(""), 0o644))
152
+ require.NoError(t, os.WriteFile(filepath.Join(project, ".crushrc"), []byte(""), 0o644))
153
+
154
+ got := lookupConfigs(project)
155
+ require.Contains(t, got, filepath.Join(project, "crushrc"))
156
+ require.Contains(t, got, filepath.Join(project, ".crushrc"))
157
+ })
158
+
159
+ t.Run("system config is loaded first", func(t *testing.T) {
160
+ if runtime.GOOS == "windows" {
161
+ t.Skip("system config not supported on Windows")
162
+ }
163
+
164
+ got := lookupConfigs(t.TempDir())
165
+ require.NotEmpty(t, got)
166
+ // The system-wide config must be first so it has the lowest
167
+ // priority when configs are merged.
168
+ require.Equal(t, "/etc/crush/crush.json", got[0])
169
+ })
170
+ }
171
+
172
+ func TestLoadFromConfigPaths_InvalidJSON(t *testing.T) {
173
+ t.Parallel()
174
+
175
+ t.Run("identifies the offending file", func(t *testing.T) {
176
+ t.Parallel()
177
+ tmpDir := t.TempDir()
178
+ good := filepath.Join(tmpDir, "good.json")
179
+ bad := filepath.Join(tmpDir, "bad.json")
180
+ require.NoError(t, os.WriteFile(good, []byte(`{"providers":{}}`), 0o644))
181
+ require.NoError(t, os.WriteFile(bad, []byte(`{not valid json}`), 0o644))
182
+
183
+ _, _, err := loadFromConfigPaths(context.Background(), []string{good, bad})
184
+ require.Error(t, err)
185
+ require.Contains(t, err.Error(), "invalid JSON in config file")
186
+ require.Contains(t, err.Error(), "bad.json")
187
+ })
188
+
189
+ t.Run("skips missing and empty files", func(t *testing.T) {
190
+ t.Parallel()
191
+ tmpDir := t.TempDir()
192
+ empty := filepath.Join(tmpDir, "empty.json")
193
+ require.NoError(t, os.WriteFile(empty, []byte(""), 0o644))
194
+
195
+ cfg, _, err := loadFromConfigPaths(context.Background(), []string{
196
+ filepath.Join(tmpDir, "nonexistent.json"),
197
+ empty,
198
+ })
199
+ require.NoError(t, err)
200
+ require.NotNil(t, cfg)
201
+ })
202
+ }
203
+
204
+ // TestLoadFromConfigPaths_ConflictWarningNamesKeys verifies that when a JSON
205
+ // config and a crushrc coexist in the same directory, the merge warning names
206
+ // the overlapping top-level keys so incremental migrations can spot stale
207
+ // duplicates.
208
+ func TestLoadFromConfigPaths_ConflictWarningNamesKeys(t *testing.T) {
209
+ capture := func(t *testing.T) *strings.Builder {
210
+ t.Helper()
211
+ var buf strings.Builder
212
+ prev := slog.Default()
213
+ slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
214
+ t.Cleanup(func() { slog.SetDefault(prev) })
215
+ return &buf
216
+ }
217
+
218
+ t.Run("names overlapping keys", func(t *testing.T) {
219
+ buf := capture(t)
220
+ tmpDir := t.TempDir()
221
+ jsonPath := filepath.Join(tmpDir, "crush.json")
222
+ rcPath := filepath.Join(tmpDir, "crushrc")
223
+ require.NoError(t, os.WriteFile(jsonPath, []byte(`{"options":{"debug":true},"providers":{}}`), 0o644))
224
+ require.NoError(t, os.WriteFile(rcPath, []byte("option debug true\n"), 0o644))
225
+
226
+ _, _, err := loadFromConfigPaths(context.Background(), []string{jsonPath, rcPath})
227
+ require.NoError(t, err)
228
+ require.Contains(t, buf.String(), "crushrc taking precedence")
229
+ require.Contains(t, buf.String(), `"conflicting_keys":"options"`)
230
+ })
231
+
232
+ t.Run("no warning when nothing overlaps", func(t *testing.T) {
233
+ buf := capture(t)
234
+ tmpDir := t.TempDir()
235
+ jsonPath := filepath.Join(tmpDir, "crush.json")
236
+ rcPath := filepath.Join(tmpDir, "crushrc")
237
+ require.NoError(t, os.WriteFile(jsonPath, []byte(`{"providers":{}}`), 0o644))
238
+ require.NoError(t, os.WriteFile(rcPath, []byte("option debug true\n"), 0o644))
239
+
240
+ _, _, err := loadFromConfigPaths(context.Background(), []string{jsonPath, rcPath})
241
+ require.NoError(t, err)
242
+ require.NotContains(t, buf.String(), "crushrc taking precedence",
243
+ "disjoint coexistence should not warn")
244
+ })
245
+ }
246
+
247
+ // testStore wraps a Config in a minimal ConfigStore for testing.
248
+ func testStore(cfg *Config) *ConfigStore {
249
+ return &ConfigStore{config: cfg}
250
+ }
251
+
252
+ func TestConfig_setDefaults(t *testing.T) {
253
+ t.Run("sets default data directory", func(t *testing.T) {
254
+ cfg := &Config{}
255
+ workingDir := t.TempDir()
256
+
257
+ cfg.setDefaults(workingDir, "")
258
+
259
+ require.NotNil(t, cfg.Options)
260
+ require.NotNil(t, cfg.Options.TUI)
261
+ require.NotNil(t, cfg.Options.ContextPaths)
262
+ require.NotNil(t, cfg.Providers)
263
+ require.NotNil(t, cfg.Models)
264
+ require.NotNil(t, cfg.LSP)
265
+ require.NotNil(t, cfg.MCP)
266
+ require.Equal(t, filepath.Join(workingDir, ".crush"), cfg.Options.DataDirectory)
267
+ require.Equal(t, "AGENTS.md", cfg.Options.InitializeAs)
268
+ // DiffMode is deliberately left empty: the permissions dialog treats
269
+ // the zero value as "pick split or unified based on terminal width".
270
+ require.Empty(t, cfg.Options.TUI.DiffMode)
271
+ require.Equal(t, ScrollbarDefault, cfg.Options.TUI.Scrollbar)
272
+ require.Equal(t, ExitBannerDefault, cfg.Options.TUI.ExitBanner)
273
+ for _, path := range defaultContextPaths {
274
+ require.Contains(t, cfg.Options.ContextPaths, path)
275
+ }
276
+ })
277
+
278
+ t.Run("sets TUI defaults only when unset", func(t *testing.T) {
279
+ cfg := &Config{}
280
+ workingDir := t.TempDir()
281
+
282
+ cfg.setDefaults(workingDir, "")
283
+
284
+ require.Empty(t, cfg.Options.TUI.DiffMode)
285
+ require.Equal(t, ScrollbarDefault, cfg.Options.TUI.Scrollbar)
286
+ require.Equal(t, ExitBannerDefault, cfg.Options.TUI.ExitBanner)
287
+
288
+ cfg.Options.TUI.DiffMode = DiffModeSplit
289
+ cfg.Options.TUI.Scrollbar = ScrollbarNever
290
+ cfg.Options.TUI.ExitBanner = ExitBannerCompact
291
+ cfg.setDefaults(workingDir, "")
292
+
293
+ require.Equal(t, DiffModeSplit, cfg.Options.TUI.DiffMode)
294
+ require.Equal(t, ScrollbarNever, cfg.Options.TUI.Scrollbar)
295
+ require.Equal(t, ExitBannerCompact, cfg.Options.TUI.ExitBanner)
296
+ })
297
+
298
+ t.Run("prunes orphaned OAuth token MCP entries but keeps real ones", func(t *testing.T) {
299
+ cfg := &Config{
300
+ MCP: map[string]MCPConfig{
301
+ "orphan": {OAuthToken: &oauth.Token{AccessToken: "stale"}},
302
+ "real-http": {Type: MCPHttp, URL: "https://example.com/mcp", OAuthToken: &oauth.Token{AccessToken: "live"}},
303
+ "real-stdio": {Type: MCPStdio, Command: "npx"},
304
+ "malformed": {Command: "npx"}, // missing type but has a command: surface the error, don't prune
305
+ },
306
+ }
307
+
308
+ cfg.setDefaults(t.TempDir(), "")
309
+
310
+ require.NotContains(t, cfg.MCP, "orphan", "orphaned token entry should be pruned")
311
+ require.Contains(t, cfg.MCP, "real-http")
312
+ require.Contains(t, cfg.MCP, "real-stdio")
313
+ require.Contains(t, cfg.MCP, "malformed", "malformed entry should survive so its error surfaces")
314
+ })
315
+
316
+ t.Run("resolves relative configured data directory from working directory", func(t *testing.T) {
317
+ cfg := &Config{Options: &Options{DataDirectory: "."}}
318
+ workingDir := filepath.Join(t.TempDir(), "worktree")
319
+
320
+ cfg.setDefaults(workingDir, "")
321
+
322
+ require.Equal(t, workingDir, cfg.Options.DataDirectory)
323
+ })
324
+
325
+ t.Run("resolves relative flag data directory from working directory", func(t *testing.T) {
326
+ cfg := &Config{}
327
+ workingDir := filepath.Join(t.TempDir(), "worktree")
328
+
329
+ cfg.setDefaults(workingDir, "./state")
330
+
331
+ require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory)
332
+ })
333
+
334
+ t.Run("preserves absolute configured data directory", func(t *testing.T) {
335
+ // Use a platform-appropriate absolute path so the test runs
336
+ // the same way on POSIX and Windows.
337
+ absDir := filepath.Join(t.TempDir(), "data")
338
+ cfg := &Config{Options: &Options{DataDirectory: absDir}}
339
+
340
+ cfg.setDefaults(filepath.Join(t.TempDir(), "worktree"), "")
341
+
342
+ require.Equal(t, absDir, cfg.Options.DataDirectory)
343
+ })
344
+
345
+ t.Run("workspace merge re-entry keeps an absolute data directory", func(t *testing.T) {
346
+ // Simulate the load and reload paths: defaults are applied
347
+ // twice with the data directory potentially carried through
348
+ // from an earlier merge as a relative string.
349
+ workingDir := filepath.Join(t.TempDir(), "worktree")
350
+ cfg := &Config{}
351
+ cfg.setDefaults(workingDir, "")
352
+
353
+ // Workspace JSON sets data_directory to a relative value; the
354
+ // merge replaces the struct, then setDefaults runs again.
355
+ cfg.Options.DataDirectory = "./state"
356
+ cfg.setDefaults(workingDir, "")
357
+
358
+ require.True(t, filepath.IsAbs(cfg.Options.DataDirectory),
359
+ "data directory must remain absolute after re-merge, got %q",
360
+ cfg.Options.DataDirectory)
361
+ require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory)
362
+ })
363
+
364
+ t.Run("does not adopt .crush from a parent project", func(t *testing.T) {
365
+ parent := t.TempDir()
366
+
367
+ // .crush in the parent: it should not be reused by the child
368
+ // because there is no git context joining them.
369
+ require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755))
370
+
371
+ child := filepath.Join(parent, "child")
372
+ require.NoError(t, os.Mkdir(child, 0o755))
373
+
374
+ cfg := &Config{}
375
+ cfg.setDefaults(child, "")
376
+
377
+ require.Equal(
378
+ t,
379
+ filepath.Clean(filepath.Join(child, defaultDataDirectory)),
380
+ filepath.Clean(cfg.Options.DataDirectory),
381
+ )
382
+ })
383
+
384
+ t.Run("does not climb out of git worktree to find .crush", func(t *testing.T) {
385
+ if _, err := exec.LookPath("git"); err != nil {
386
+ t.Skip("git not available")
387
+ }
388
+
389
+ parent := t.TempDir()
390
+
391
+ // Stray .crush above the worktree root.
392
+ require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755))
393
+
394
+ worktree := filepath.Join(parent, "worktree")
395
+ require.NoError(t, os.Mkdir(worktree, 0o755))
396
+
397
+ sub := filepath.Join(worktree, "pkg")
398
+ require.NoError(t, os.Mkdir(sub, 0o755))
399
+
400
+ // Make worktree a real git repo so the boundary detection
401
+ // resolves to it, mirroring what happens with linked worktrees
402
+ // in real usage.
403
+ gitInit := exec.CommandContext(t.Context(), "git", "init", "-q")
404
+ gitInit.Dir = worktree
405
+ require.NoError(t, gitInit.Run())
406
+
407
+ cfg := &Config{}
408
+ cfg.setDefaults(sub, "")
409
+
410
+ // Resolve symlinks because TempDir on macOS sits under /var
411
+ // which is a symlink to /private/var. The data directory has
412
+ // not been created yet, so resolve its parent and join.
413
+ gotDir, gotName := filepath.Split(cfg.Options.DataDirectory)
414
+ gotEvalDir, err := filepath.EvalSymlinks(filepath.Clean(gotDir))
415
+ require.NoError(t, err)
416
+ gotEval := filepath.Join(gotEvalDir, gotName)
417
+
418
+ strayEval, err := filepath.EvalSymlinks(filepath.Join(parent, defaultDataDirectory))
419
+ require.NoError(t, err)
420
+ require.NotEqual(t, strayEval, gotEval, "must not adopt parent .crush")
421
+
422
+ subEval, err := filepath.EvalSymlinks(sub)
423
+ require.NoError(t, err)
424
+ require.Equal(t, filepath.Join(subEval, defaultDataDirectory), gotEval)
425
+ })
426
+ }
427
+
428
+ func TestConfig_configureProviders(t *testing.T) {
429
+ knownProviders := []catwalk.Provider{
430
+ {
431
+ ID: "openai",
432
+ APIKey: "$OPENAI_API_KEY",
433
+ APIEndpoint: "https://api.openai.com/v1",
434
+ Models: []catwalk.Model{{
435
+ ID: "test-model",
436
+ }},
437
+ },
438
+ }
439
+
440
+ cfg := &Config{}
441
+ cfg.setDefaults("/tmp", "")
442
+ env := env.NewFromMap(map[string]string{
443
+ "OPENAI_API_KEY": "test-key",
444
+ })
445
+ resolver := NewShellVariableResolver(env)
446
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
447
+ require.NoError(t, err)
448
+ require.Equal(t, 1, cfg.Providers.Len())
449
+
450
+ // We want to make sure that we keep the configured API key as a placeholder
451
+ pc, _ := cfg.Providers.Get("openai")
452
+ require.Equal(t, "$OPENAI_API_KEY", pc.APIKey)
453
+ }
454
+
455
+ func TestConfig_configureProvidersWithOverride(t *testing.T) {
456
+ knownProviders := []catwalk.Provider{
457
+ {
458
+ ID: "openai",
459
+ APIKey: "$OPENAI_API_KEY",
460
+ APIEndpoint: "https://api.openai.com/v1",
461
+ Models: []catwalk.Model{{
462
+ ID: "test-model",
463
+ }},
464
+ },
465
+ }
466
+
467
+ cfg := &Config{
468
+ Providers: csync.NewMap[string, ProviderConfig](),
469
+ }
470
+ cfg.Providers.Set("openai", ProviderConfig{
471
+ APIKey: "xyz",
472
+ BaseURL: "https://api.openai.com/v2",
473
+ Models: []catwalk.Model{
474
+ {
475
+ ID: "test-model",
476
+ Name: "Updated",
477
+ },
478
+ {
479
+ ID: "another-model",
480
+ },
481
+ },
482
+ })
483
+ cfg.setDefaults("/tmp", "")
484
+
485
+ env := env.NewFromMap(map[string]string{
486
+ "OPENAI_API_KEY": "test-key",
487
+ })
488
+ resolver := NewShellVariableResolver(env)
489
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
490
+ require.NoError(t, err)
491
+ require.Equal(t, 1, cfg.Providers.Len())
492
+
493
+ // We want to make sure that we keep the configured API key as a placeholder
494
+ pc, _ := cfg.Providers.Get("openai")
495
+ require.Equal(t, "xyz", pc.APIKey)
496
+ require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
497
+ require.Len(t, pc.Models, 2)
498
+ require.Equal(t, "Updated", pc.Models[0].Name)
499
+ }
500
+
501
+ func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
502
+ knownProviders := []catwalk.Provider{
503
+ {
504
+ ID: "openai",
505
+ APIKey: "$OPENAI_API_KEY",
506
+ APIEndpoint: "https://api.openai.com/v1",
507
+ Models: []catwalk.Model{{
508
+ ID: "test-model",
509
+ }},
510
+ },
511
+ }
512
+
513
+ cfg := &Config{
514
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
515
+ "custom": {
516
+ APIKey: "xyz",
517
+ BaseURL: "https://api.someendpoint.com/v2",
518
+ Models: []catwalk.Model{
519
+ {
520
+ ID: "test-model",
521
+ },
522
+ },
523
+ },
524
+ }),
525
+ }
526
+ cfg.setDefaults("/tmp", "")
527
+ env := env.NewFromMap(map[string]string{
528
+ "OPENAI_API_KEY": "test-key",
529
+ })
530
+ resolver := NewShellVariableResolver(env)
531
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
532
+ require.NoError(t, err)
533
+ // Should be to because of the env variable
534
+ require.Equal(t, cfg.Providers.Len(), 2)
535
+
536
+ // We want to make sure that we keep the configured API key as a placeholder
537
+ pc, _ := cfg.Providers.Get("custom")
538
+ require.Equal(t, "xyz", pc.APIKey)
539
+ // Make sure we set the ID correctly
540
+ require.Equal(t, "custom", pc.ID)
541
+ require.Equal(t, "https://api.someendpoint.com/v2", pc.BaseURL)
542
+ require.Len(t, pc.Models, 1)
543
+
544
+ _, ok := cfg.Providers.Get("openai")
545
+ require.True(t, ok, "OpenAI provider should still be present")
546
+ }
547
+
548
+ func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
549
+ knownProviders := []catwalk.Provider{
550
+ {
551
+ ID: catwalk.InferenceProviderBedrock,
552
+ APIKey: "",
553
+ APIEndpoint: "",
554
+ Models: []catwalk.Model{{
555
+ ID: "anthropic.claude-sonnet-4-20250514-v1:0",
556
+ }},
557
+ },
558
+ }
559
+
560
+ cfg := &Config{}
561
+ cfg.setDefaults("/tmp", "")
562
+ env := env.NewFromMap(map[string]string{
563
+ "AWS_ACCESS_KEY_ID": "test-key-id",
564
+ "AWS_SECRET_ACCESS_KEY": "test-secret-key",
565
+ })
566
+ resolver := NewShellVariableResolver(env)
567
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
568
+ require.NoError(t, err)
569
+ require.Equal(t, cfg.Providers.Len(), 1)
570
+
571
+ bedrockProvider, ok := cfg.Providers.Get("bedrock")
572
+ require.True(t, ok, "Bedrock provider should be present")
573
+ require.Len(t, bedrockProvider.Models, 1)
574
+ require.Equal(t, "anthropic.claude-sonnet-4-20250514-v1:0", bedrockProvider.Models[0].ID)
575
+ }
576
+
577
+ func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
578
+ knownProviders := []catwalk.Provider{
579
+ {
580
+ ID: catwalk.InferenceProviderBedrock,
581
+ APIKey: "",
582
+ APIEndpoint: "",
583
+ Models: []catwalk.Model{{
584
+ ID: "anthropic.claude-sonnet-4-20250514-v1:0",
585
+ }},
586
+ },
587
+ }
588
+
589
+ cfg := &Config{}
590
+ cfg.setDefaults("/tmp", "")
591
+ env := env.NewFromMap(map[string]string{})
592
+ resolver := NewShellVariableResolver(env)
593
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
594
+ require.NoError(t, err)
595
+ // Provider should not be configured without credentials
596
+ require.Equal(t, cfg.Providers.Len(), 0)
597
+ }
598
+
599
+ func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
600
+ knownProviders := []catwalk.Provider{
601
+ {
602
+ ID: catwalk.InferenceProviderVertexAI,
603
+ APIKey: "",
604
+ APIEndpoint: "",
605
+ Models: []catwalk.Model{{
606
+ ID: "gemini-pro",
607
+ }},
608
+ },
609
+ }
610
+
611
+ cfg := &Config{}
612
+ cfg.setDefaults("/tmp", "")
613
+ env := env.NewFromMap(map[string]string{
614
+ "VERTEXAI_PROJECT": "test-project",
615
+ "VERTEXAI_LOCATION": "us-central1",
616
+ })
617
+ resolver := NewShellVariableResolver(env)
618
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
619
+ require.NoError(t, err)
620
+ require.Equal(t, cfg.Providers.Len(), 1)
621
+
622
+ vertexProvider, ok := cfg.Providers.Get("vertexai")
623
+ require.True(t, ok, "VertexAI provider should be present")
624
+ require.Len(t, vertexProvider.Models, 1)
625
+ require.Equal(t, "gemini-pro", vertexProvider.Models[0].ID)
626
+ require.Equal(t, "test-project", vertexProvider.ExtraParams["project"])
627
+ require.Equal(t, "us-central1", vertexProvider.ExtraParams["location"])
628
+ }
629
+
630
+ func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
631
+ knownProviders := []catwalk.Provider{
632
+ {
633
+ ID: catwalk.InferenceProviderVertexAI,
634
+ APIKey: "",
635
+ APIEndpoint: "",
636
+ Models: []catwalk.Model{{
637
+ ID: "gemini-pro",
638
+ }},
639
+ },
640
+ }
641
+
642
+ cfg := &Config{}
643
+ cfg.setDefaults("/tmp", "")
644
+ env := env.NewFromMap(map[string]string{
645
+ "GOOGLE_GENAI_USE_VERTEXAI": "false",
646
+ "GOOGLE_CLOUD_PROJECT": "test-project",
647
+ "GOOGLE_CLOUD_LOCATION": "us-central1",
648
+ })
649
+ resolver := NewShellVariableResolver(env)
650
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
651
+ require.NoError(t, err)
652
+ // Provider should not be configured without proper credentials
653
+ require.Equal(t, cfg.Providers.Len(), 0)
654
+ }
655
+
656
+ func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
657
+ knownProviders := []catwalk.Provider{
658
+ {
659
+ ID: catwalk.InferenceProviderVertexAI,
660
+ APIKey: "",
661
+ APIEndpoint: "",
662
+ Models: []catwalk.Model{{
663
+ ID: "gemini-pro",
664
+ }},
665
+ },
666
+ }
667
+
668
+ cfg := &Config{}
669
+ cfg.setDefaults("/tmp", "")
670
+ env := env.NewFromMap(map[string]string{
671
+ "GOOGLE_GENAI_USE_VERTEXAI": "true",
672
+ "GOOGLE_CLOUD_LOCATION": "us-central1",
673
+ })
674
+ resolver := NewShellVariableResolver(env)
675
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
676
+ require.NoError(t, err)
677
+ // Provider should not be configured without project
678
+ require.Equal(t, cfg.Providers.Len(), 0)
679
+ }
680
+
681
+ func TestConfig_configureProvidersSetProviderID(t *testing.T) {
682
+ knownProviders := []catwalk.Provider{
683
+ {
684
+ ID: "openai",
685
+ APIKey: "$OPENAI_API_KEY",
686
+ APIEndpoint: "https://api.openai.com/v1",
687
+ Models: []catwalk.Model{{
688
+ ID: "test-model",
689
+ }},
690
+ },
691
+ }
692
+
693
+ cfg := &Config{}
694
+ cfg.setDefaults("/tmp", "")
695
+ env := env.NewFromMap(map[string]string{
696
+ "OPENAI_API_KEY": "test-key",
697
+ })
698
+ resolver := NewShellVariableResolver(env)
699
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
700
+ require.NoError(t, err)
701
+ require.Equal(t, cfg.Providers.Len(), 1)
702
+
703
+ // Provider ID should be set
704
+ pc, _ := cfg.Providers.Get("openai")
705
+ require.Equal(t, "openai", pc.ID)
706
+ }
707
+
708
+ func TestConfig_EnabledProviders(t *testing.T) {
709
+ t.Run("all providers enabled", func(t *testing.T) {
710
+ cfg := &Config{
711
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
712
+ "openai": {
713
+ ID: "openai",
714
+ APIKey: "key1",
715
+ Disable: false,
716
+ },
717
+ "anthropic": {
718
+ ID: "anthropic",
719
+ APIKey: "key2",
720
+ Disable: false,
721
+ },
722
+ }),
723
+ }
724
+
725
+ enabled := cfg.EnabledProviders()
726
+ require.Len(t, enabled, 2)
727
+ })
728
+
729
+ t.Run("some providers disabled", func(t *testing.T) {
730
+ cfg := &Config{
731
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
732
+ "openai": {
733
+ ID: "openai",
734
+ APIKey: "key1",
735
+ Disable: false,
736
+ },
737
+ "anthropic": {
738
+ ID: "anthropic",
739
+ APIKey: "key2",
740
+ Disable: true,
741
+ },
742
+ }),
743
+ }
744
+
745
+ enabled := cfg.EnabledProviders()
746
+ require.Len(t, enabled, 1)
747
+ require.Equal(t, "openai", enabled[0].ID)
748
+ })
749
+
750
+ t.Run("empty providers map", func(t *testing.T) {
751
+ cfg := &Config{
752
+ Providers: csync.NewMap[string, ProviderConfig](),
753
+ }
754
+
755
+ enabled := cfg.EnabledProviders()
756
+ require.Len(t, enabled, 0)
757
+ })
758
+ }
759
+
760
+ func TestConfig_IsConfigured(t *testing.T) {
761
+ t.Run("returns true when at least one provider is enabled", func(t *testing.T) {
762
+ cfg := &Config{
763
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
764
+ "openai": {
765
+ ID: "openai",
766
+ APIKey: "key1",
767
+ Disable: false,
768
+ },
769
+ }),
770
+ }
771
+
772
+ require.True(t, cfg.IsConfigured())
773
+ })
774
+
775
+ t.Run("returns false when no providers are configured", func(t *testing.T) {
776
+ cfg := &Config{
777
+ Providers: csync.NewMap[string, ProviderConfig](),
778
+ }
779
+
780
+ require.False(t, cfg.IsConfigured())
781
+ })
782
+
783
+ t.Run("returns false when all providers are disabled", func(t *testing.T) {
784
+ cfg := &Config{
785
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
786
+ "openai": {
787
+ ID: "openai",
788
+ APIKey: "key1",
789
+ Disable: true,
790
+ },
791
+ "anthropic": {
792
+ ID: "anthropic",
793
+ APIKey: "key2",
794
+ Disable: true,
795
+ },
796
+ }),
797
+ }
798
+
799
+ require.False(t, cfg.IsConfigured())
800
+ })
801
+ }
802
+
803
+ func TestConfig_setupAgentsWithNoDisabledTools(t *testing.T) {
804
+ cfg := &Config{
805
+ Options: &Options{
806
+ DisabledTools: []string{},
807
+ },
808
+ }
809
+
810
+ cfg.SetupAgents()
811
+ coderAgent, ok := cfg.Agents[AgentCoder]
812
+ require.True(t, ok)
813
+ assert.Equal(t, allToolNames(), coderAgent.AllowedTools)
814
+
815
+ taskAgent, ok := cfg.Agents[AgentTask]
816
+ require.True(t, ok)
817
+ assert.Equal(t, []string{"lsp_symbols", "lsp_definition", "lsp_call_hierarchy", "glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
818
+ }
819
+
820
+ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
821
+ cfg := &Config{
822
+ Options: &Options{
823
+ DisabledTools: []string{
824
+ "edit",
825
+ "download",
826
+ "grep",
827
+ },
828
+ },
829
+ }
830
+
831
+ cfg.SetupAgents()
832
+ coderAgent, ok := cfg.Agents[AgentCoder]
833
+ require.True(t, ok)
834
+
835
+ assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "lsp_symbols", "lsp_definition", "lsp_call_hierarchy", "lsp_rename", "lsp_replace_symbol", "fetch", "agentic_fetch", "glob", "ls", "question", "sourcegraph", "todos", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
836
+
837
+ taskAgent, ok := cfg.Agents[AgentTask]
838
+ require.True(t, ok)
839
+ assert.Equal(t, []string{"lsp_symbols", "lsp_definition", "lsp_call_hierarchy", "glob", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
840
+ }
841
+
842
+ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
843
+ cfg := &Config{
844
+ Options: &Options{
845
+ DisabledTools: []string{
846
+ "glob",
847
+ "grep",
848
+ "ls",
849
+ "lsp_call_hierarchy",
850
+ "lsp_definition",
851
+ "lsp_symbols",
852
+ "sourcegraph",
853
+ "view",
854
+ },
855
+ },
856
+ }
857
+
858
+ cfg.SetupAgents()
859
+ coderAgent, ok := cfg.Agents[AgentCoder]
860
+ require.True(t, ok)
861
+ assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "lsp_rename", "lsp_replace_symbol", "fetch", "agentic_fetch", "question", "todos", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
862
+
863
+ taskAgent, ok := cfg.Agents[AgentTask]
864
+ require.True(t, ok)
865
+ assert.Len(t, taskAgent.AllowedTools, 0)
866
+ }
867
+
868
+ func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
869
+ knownProviders := []catwalk.Provider{
870
+ {
871
+ ID: "openai",
872
+ APIKey: "$OPENAI_API_KEY",
873
+ APIEndpoint: "https://api.openai.com/v1",
874
+ Models: []catwalk.Model{{
875
+ ID: "test-model",
876
+ }},
877
+ },
878
+ }
879
+
880
+ cfg := &Config{
881
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
882
+ "openai": {
883
+ Disable: true,
884
+ },
885
+ }),
886
+ }
887
+ cfg.setDefaults("/tmp", "")
888
+
889
+ env := env.NewFromMap(map[string]string{
890
+ "OPENAI_API_KEY": "test-key",
891
+ })
892
+ resolver := NewShellVariableResolver(env)
893
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
894
+ require.NoError(t, err)
895
+
896
+ require.Equal(t, cfg.Providers.Len(), 1)
897
+ prov, exists := cfg.Providers.Get("openai")
898
+ require.True(t, exists)
899
+ require.True(t, prov.Disable)
900
+ }
901
+
902
+ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
903
+ t.Run("custom provider with missing API key is allowed, but not known providers", func(t *testing.T) {
904
+ cfg := &Config{
905
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
906
+ "custom": {
907
+ BaseURL: "https://api.custom.com/v1",
908
+ Models: []catwalk.Model{{
909
+ ID: "test-model",
910
+ }},
911
+ },
912
+ "openai": {
913
+ APIKey: "$MISSING",
914
+ },
915
+ }),
916
+ }
917
+ cfg.setDefaults("/tmp", "")
918
+
919
+ env := env.NewFromMap(map[string]string{})
920
+ resolver := NewShellVariableResolver(env)
921
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
922
+ require.NoError(t, err)
923
+
924
+ require.Equal(t, cfg.Providers.Len(), 1)
925
+ _, exists := cfg.Providers.Get("custom")
926
+ require.True(t, exists)
927
+ })
928
+
929
+ t.Run("custom provider with missing BaseURL is removed", func(t *testing.T) {
930
+ cfg := &Config{
931
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
932
+ "custom": {
933
+ APIKey: "test-key",
934
+ Models: []catwalk.Model{{
935
+ ID: "test-model",
936
+ }},
937
+ },
938
+ }),
939
+ }
940
+ cfg.setDefaults("/tmp", "")
941
+
942
+ env := env.NewFromMap(map[string]string{})
943
+ resolver := NewShellVariableResolver(env)
944
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
945
+ require.NoError(t, err)
946
+
947
+ require.Equal(t, cfg.Providers.Len(), 0)
948
+ _, exists := cfg.Providers.Get("custom")
949
+ require.False(t, exists)
950
+ })
951
+
952
+ t.Run("custom provider with shell-expanded BaseURL is kept", func(t *testing.T) {
953
+ cfg := &Config{
954
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
955
+ "custom": {
956
+ APIKey: "test-key",
957
+ BaseURL: "$(echo https://api.custom.com/v1)",
958
+ Models: []catwalk.Model{{
959
+ ID: "test-model",
960
+ }},
961
+ },
962
+ }),
963
+ }
964
+ cfg.setDefaults("/tmp", "")
965
+
966
+ env := env.NewFromMap(map[string]string{})
967
+ resolver := NewShellVariableResolver(env)
968
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
969
+ require.NoError(t, err)
970
+
971
+ require.Equal(t, 1, cfg.Providers.Len())
972
+ _, exists := cfg.Providers.Get("custom")
973
+ require.True(t, exists, "a BaseURL supplied via shell expansion must pass endpoint validation")
974
+ })
975
+
976
+ t.Run("custom provider with env var BaseURL is kept", func(t *testing.T) {
977
+ cfg := &Config{
978
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
979
+ "custom": {
980
+ APIKey: "test-key",
981
+ BaseURL: "$CUSTOM_API_URL",
982
+ Models: []catwalk.Model{{
983
+ ID: "test-model",
984
+ }},
985
+ },
986
+ }),
987
+ }
988
+ cfg.setDefaults("/tmp", "")
989
+
990
+ env := env.NewFromMap(map[string]string{
991
+ "CUSTOM_API_URL": "https://api.custom.com/v1",
992
+ })
993
+ resolver := NewShellVariableResolver(env)
994
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
995
+ require.NoError(t, err)
996
+
997
+ require.Equal(t, 1, cfg.Providers.Len())
998
+ _, exists := cfg.Providers.Get("custom")
999
+ require.True(t, exists, "a BaseURL supplied via an env variable must pass endpoint validation")
1000
+ })
1001
+
1002
+ t.Run("custom provider whose BaseURL resolves to empty is removed", func(t *testing.T) {
1003
+ cfg := &Config{
1004
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1005
+ "custom": {
1006
+ APIKey: "test-key",
1007
+ BaseURL: "$MISSING_API_URL",
1008
+ Models: []catwalk.Model{{
1009
+ ID: "test-model",
1010
+ }},
1011
+ },
1012
+ }),
1013
+ }
1014
+ cfg.setDefaults("/tmp", "")
1015
+
1016
+ env := env.NewFromMap(map[string]string{})
1017
+ resolver := NewShellVariableResolver(env)
1018
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
1019
+ require.NoError(t, err)
1020
+
1021
+ require.Equal(t, 0, cfg.Providers.Len())
1022
+ _, exists := cfg.Providers.Get("custom")
1023
+ require.False(t, exists, "a raw-non-empty BaseURL that resolves to empty must be rejected")
1024
+ })
1025
+
1026
+ t.Run("BaseURL resolving to empty with disable_default_providers returns an error", func(t *testing.T) {
1027
+ cfg := &Config{
1028
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1029
+ "custom": {
1030
+ APIKey: "test-key",
1031
+ BaseURL: "$MISSING_API_URL",
1032
+ Models: []catwalk.Model{{
1033
+ ID: "test-model",
1034
+ }},
1035
+ },
1036
+ }),
1037
+ }
1038
+ cfg.setDefaults("/tmp", "")
1039
+ cfg.Options.DisableDefaultProviders = true
1040
+
1041
+ env := env.NewFromMap(map[string]string{})
1042
+ resolver := NewShellVariableResolver(env)
1043
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
1044
+ require.Error(t, err, "the sole custom provider is invalid after resolution, so configuration must fail")
1045
+ })
1046
+
1047
+ t.Run("custom provider with no models attempts discovery and is removed on failure", func(t *testing.T) {
1048
+ cfg := &Config{
1049
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1050
+ "custom": {
1051
+ APIKey: "test-key",
1052
+ BaseURL: "https://api.custom.com/v1",
1053
+ Models: []catwalk.Model{},
1054
+ },
1055
+ }),
1056
+ }
1057
+ cfg.setDefaults("/tmp", "")
1058
+
1059
+ env := env.NewFromMap(map[string]string{})
1060
+ resolver := NewShellVariableResolver(env)
1061
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
1062
+ require.NoError(t, err)
1063
+
1064
+ // Discovery fails (unreachable URL) so provider is removed.
1065
+ require.Equal(t, 0, cfg.Providers.Len())
1066
+ _, exists := cfg.Providers.Get("custom")
1067
+ require.False(t, exists)
1068
+ })
1069
+
1070
+ t.Run("custom provider with no models and discover_models:false is removed", func(t *testing.T) {
1071
+ discoverFalse := false
1072
+ cfg := &Config{
1073
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1074
+ "custom": {
1075
+ APIKey: "test-key",
1076
+ BaseURL: "https://api.custom.com/v1",
1077
+ Models: []catwalk.Model{},
1078
+ AutoDiscoverModels: &discoverFalse,
1079
+ },
1080
+ }),
1081
+ }
1082
+ cfg.setDefaults("/tmp", "")
1083
+
1084
+ env := env.NewFromMap(map[string]string{})
1085
+ resolver := NewShellVariableResolver(env)
1086
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
1087
+ require.NoError(t, err)
1088
+
1089
+ require.Equal(t, 0, cfg.Providers.Len())
1090
+ _, exists := cfg.Providers.Get("custom")
1091
+ require.False(t, exists)
1092
+ })
1093
+
1094
+ t.Run("custom provider with models and discover_models:true merges discovered models", func(t *testing.T) {
1095
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1096
+ w.Header().Set("Content-Type", "application/json")
1097
+ _, _ = w.Write([]byte(`{"data": [
1098
+ {"id": "existing-model", "object": "model"},
1099
+ {"id": "discovered-model", "object": "model"}
1100
+ ]}`))
1101
+ }))
1102
+ defer server.Close()
1103
+
1104
+ discoverTrue := true
1105
+ cfg := &Config{
1106
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1107
+ "custom": {
1108
+ APIKey: "test-key",
1109
+ BaseURL: server.URL + "/v1",
1110
+ Models: []catwalk.Model{
1111
+ {ID: "existing-model", Name: "My Custom Name", ContextWindow: 200000},
1112
+ },
1113
+ AutoDiscoverModels: &discoverTrue,
1114
+ },
1115
+ }),
1116
+ }
1117
+ cfg.setDefaults("/tmp", "")
1118
+
1119
+ env := env.NewFromMap(map[string]string{})
1120
+ resolver := NewShellVariableResolver(env)
1121
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
1122
+ require.NoError(t, err)
1123
+
1124
+ require.Equal(t, 1, cfg.Providers.Len())
1125
+ p, exists := cfg.Providers.Get("custom")
1126
+ require.True(t, exists)
1127
+ require.Len(t, p.Models, 2)
1128
+
1129
+ // User-specified model keeps its custom fields.
1130
+ require.Equal(t, "existing-model", p.Models[0].ID)
1131
+ require.Equal(t, "My Custom Name", p.Models[0].Name)
1132
+ require.Equal(t, int64(200000), p.Models[0].ContextWindow)
1133
+
1134
+ // Discovered model is appended.
1135
+ require.Equal(t, "discovered-model", p.Models[1].ID)
1136
+ })
1137
+
1138
+ t.Run("custom provider with models and no discover_models uses only listed models", func(t *testing.T) {
1139
+ cfg := &Config{
1140
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1141
+ "custom": {
1142
+ APIKey: "test-key",
1143
+ BaseURL: "https://api.custom.com/v1",
1144
+ Models: []catwalk.Model{
1145
+ {ID: "my-model", Name: "My Model"},
1146
+ },
1147
+ },
1148
+ }),
1149
+ }
1150
+ cfg.setDefaults("/tmp", "")
1151
+
1152
+ env := env.NewFromMap(map[string]string{})
1153
+ resolver := NewShellVariableResolver(env)
1154
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
1155
+ require.NoError(t, err)
1156
+
1157
+ require.Equal(t, 1, cfg.Providers.Len())
1158
+ p, exists := cfg.Providers.Get("custom")
1159
+ require.True(t, exists)
1160
+ require.Len(t, p.Models, 1)
1161
+ require.Equal(t, "my-model", p.Models[0].ID)
1162
+ })
1163
+
1164
+ t.Run("custom provider with no models auto-discovers successfully", func(t *testing.T) {
1165
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1166
+ w.Header().Set("Content-Type", "application/json")
1167
+ _, _ = w.Write([]byte(`{"data": [
1168
+ {"id": "auto-model-a", "object": "model"},
1169
+ {"id": "auto-model-b", "object": "model"}
1170
+ ]}`))
1171
+ }))
1172
+ defer server.Close()
1173
+
1174
+ cfg := &Config{
1175
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1176
+ "custom": {
1177
+ APIKey: "test-key",
1178
+ BaseURL: server.URL + "/v1",
1179
+ },
1180
+ }),
1181
+ }
1182
+ cfg.setDefaults("/tmp", "")
1183
+
1184
+ env := env.NewFromMap(map[string]string{})
1185
+ resolver := NewShellVariableResolver(env)
1186
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
1187
+ require.NoError(t, err)
1188
+
1189
+ require.Equal(t, 1, cfg.Providers.Len())
1190
+ p, exists := cfg.Providers.Get("custom")
1191
+ require.True(t, exists)
1192
+ require.Len(t, p.Models, 2)
1193
+ require.Equal(t, "auto-model-a", p.Models[0].ID)
1194
+ require.Equal(t, "auto-model-b", p.Models[1].ID)
1195
+ })
1196
+
1197
+ t.Run("custom provider with unsupported type is removed", func(t *testing.T) {
1198
+ cfg := &Config{
1199
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1200
+ "custom": {
1201
+ APIKey: "test-key",
1202
+ BaseURL: "https://api.custom.com/v1",
1203
+ Type: "unsupported",
1204
+ Models: []catwalk.Model{{
1205
+ ID: "test-model",
1206
+ }},
1207
+ },
1208
+ }),
1209
+ }
1210
+ cfg.setDefaults("/tmp", "")
1211
+
1212
+ env := env.NewFromMap(map[string]string{})
1213
+ resolver := NewShellVariableResolver(env)
1214
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
1215
+ require.NoError(t, err)
1216
+
1217
+ require.Equal(t, cfg.Providers.Len(), 0)
1218
+ _, exists := cfg.Providers.Get("custom")
1219
+ require.False(t, exists)
1220
+ })
1221
+
1222
+ t.Run("valid custom provider is kept and ID is set", func(t *testing.T) {
1223
+ cfg := &Config{
1224
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1225
+ "custom": {
1226
+ APIKey: "test-key",
1227
+ BaseURL: "https://api.custom.com/v1",
1228
+ Type: catwalk.TypeOpenAI,
1229
+ Models: []catwalk.Model{{
1230
+ ID: "test-model",
1231
+ }},
1232
+ },
1233
+ }),
1234
+ }
1235
+ cfg.setDefaults("/tmp", "")
1236
+
1237
+ env := env.NewFromMap(map[string]string{})
1238
+ resolver := NewShellVariableResolver(env)
1239
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
1240
+ require.NoError(t, err)
1241
+
1242
+ require.Equal(t, cfg.Providers.Len(), 1)
1243
+ customProvider, exists := cfg.Providers.Get("custom")
1244
+ require.True(t, exists)
1245
+ require.Equal(t, "custom", customProvider.ID)
1246
+ require.Equal(t, "test-key", customProvider.APIKey)
1247
+ require.Equal(t, "https://api.custom.com/v1", customProvider.BaseURL)
1248
+ })
1249
+
1250
+ t.Run("custom anthropic provider is supported", func(t *testing.T) {
1251
+ cfg := &Config{
1252
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1253
+ "custom-anthropic": {
1254
+ APIKey: "test-key",
1255
+ BaseURL: "https://api.anthropic.com/v1",
1256
+ Type: catwalk.TypeAnthropic,
1257
+ Models: []catwalk.Model{{
1258
+ ID: "claude-3-sonnet",
1259
+ }},
1260
+ },
1261
+ }),
1262
+ }
1263
+ cfg.setDefaults("/tmp", "")
1264
+
1265
+ env := env.NewFromMap(map[string]string{})
1266
+ resolver := NewShellVariableResolver(env)
1267
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
1268
+ require.NoError(t, err)
1269
+
1270
+ require.Equal(t, cfg.Providers.Len(), 1)
1271
+ customProvider, exists := cfg.Providers.Get("custom-anthropic")
1272
+ require.True(t, exists)
1273
+ require.Equal(t, "custom-anthropic", customProvider.ID)
1274
+ require.Equal(t, "test-key", customProvider.APIKey)
1275
+ require.Equal(t, "https://api.anthropic.com/v1", customProvider.BaseURL)
1276
+ require.Equal(t, catwalk.TypeAnthropic, customProvider.Type)
1277
+ })
1278
+
1279
+ t.Run("disabled custom provider is removed", func(t *testing.T) {
1280
+ cfg := &Config{
1281
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1282
+ "custom": {
1283
+ APIKey: "test-key",
1284
+ BaseURL: "https://api.custom.com/v1",
1285
+ Type: catwalk.TypeOpenAI,
1286
+ Disable: true,
1287
+ Models: []catwalk.Model{{
1288
+ ID: "test-model",
1289
+ }},
1290
+ },
1291
+ }),
1292
+ }
1293
+ cfg.setDefaults("/tmp", "")
1294
+
1295
+ env := env.NewFromMap(map[string]string{})
1296
+ resolver := NewShellVariableResolver(env)
1297
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
1298
+ require.NoError(t, err)
1299
+
1300
+ require.Equal(t, cfg.Providers.Len(), 0)
1301
+ _, exists := cfg.Providers.Get("custom")
1302
+ require.False(t, exists)
1303
+ })
1304
+ }
1305
+
1306
+ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
1307
+ t.Run("VertexAI provider removed when credentials missing with existing config", func(t *testing.T) {
1308
+ knownProviders := []catwalk.Provider{
1309
+ {
1310
+ ID: catwalk.InferenceProviderVertexAI,
1311
+ APIKey: "",
1312
+ APIEndpoint: "",
1313
+ Models: []catwalk.Model{{
1314
+ ID: "gemini-pro",
1315
+ }},
1316
+ },
1317
+ }
1318
+
1319
+ cfg := &Config{
1320
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1321
+ "vertexai": {
1322
+ BaseURL: "custom-url",
1323
+ },
1324
+ }),
1325
+ }
1326
+ cfg.setDefaults("/tmp", "")
1327
+
1328
+ env := env.NewFromMap(map[string]string{
1329
+ "GOOGLE_GENAI_USE_VERTEXAI": "false",
1330
+ })
1331
+ resolver := NewShellVariableResolver(env)
1332
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1333
+ require.NoError(t, err)
1334
+
1335
+ require.Equal(t, cfg.Providers.Len(), 0)
1336
+ _, exists := cfg.Providers.Get("vertexai")
1337
+ require.False(t, exists)
1338
+ })
1339
+
1340
+ t.Run("Bedrock provider removed when AWS credentials missing with existing config", func(t *testing.T) {
1341
+ knownProviders := []catwalk.Provider{
1342
+ {
1343
+ ID: catwalk.InferenceProviderBedrock,
1344
+ APIKey: "",
1345
+ APIEndpoint: "",
1346
+ Models: []catwalk.Model{{
1347
+ ID: "anthropic.claude-sonnet-4-20250514-v1:0",
1348
+ }},
1349
+ },
1350
+ }
1351
+
1352
+ cfg := &Config{
1353
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1354
+ "bedrock": {
1355
+ BaseURL: "custom-url",
1356
+ },
1357
+ }),
1358
+ }
1359
+ cfg.setDefaults("/tmp", "")
1360
+
1361
+ env := env.NewFromMap(map[string]string{})
1362
+ resolver := NewShellVariableResolver(env)
1363
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1364
+ require.NoError(t, err)
1365
+
1366
+ require.Equal(t, cfg.Providers.Len(), 0)
1367
+ _, exists := cfg.Providers.Get("bedrock")
1368
+ require.False(t, exists)
1369
+ })
1370
+
1371
+ t.Run("provider removed when API key missing with existing config", func(t *testing.T) {
1372
+ knownProviders := []catwalk.Provider{
1373
+ {
1374
+ ID: "openai",
1375
+ APIKey: "$MISSING_API_KEY",
1376
+ APIEndpoint: "https://api.openai.com/v1",
1377
+ Models: []catwalk.Model{{
1378
+ ID: "test-model",
1379
+ }},
1380
+ },
1381
+ }
1382
+
1383
+ cfg := &Config{
1384
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1385
+ "openai": {
1386
+ BaseURL: "custom-url",
1387
+ },
1388
+ }),
1389
+ }
1390
+ cfg.setDefaults("/tmp", "")
1391
+
1392
+ env := env.NewFromMap(map[string]string{})
1393
+ resolver := NewShellVariableResolver(env)
1394
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1395
+ require.NoError(t, err)
1396
+
1397
+ require.Equal(t, cfg.Providers.Len(), 0)
1398
+ _, exists := cfg.Providers.Get("openai")
1399
+ require.False(t, exists)
1400
+ })
1401
+
1402
+ t.Run("known provider should still be added if the endpoint is missing the client will use default endpoints", func(t *testing.T) {
1403
+ knownProviders := []catwalk.Provider{
1404
+ {
1405
+ ID: "openai",
1406
+ APIKey: "$OPENAI_API_KEY",
1407
+ APIEndpoint: "$MISSING_ENDPOINT",
1408
+ Models: []catwalk.Model{{
1409
+ ID: "test-model",
1410
+ }},
1411
+ },
1412
+ }
1413
+
1414
+ cfg := &Config{
1415
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1416
+ "openai": {
1417
+ APIKey: "test-key",
1418
+ },
1419
+ }),
1420
+ }
1421
+ cfg.setDefaults("/tmp", "")
1422
+
1423
+ env := env.NewFromMap(map[string]string{
1424
+ "OPENAI_API_KEY": "test-key",
1425
+ })
1426
+ resolver := NewShellVariableResolver(env)
1427
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1428
+ require.NoError(t, err)
1429
+
1430
+ require.Equal(t, cfg.Providers.Len(), 1)
1431
+ _, exists := cfg.Providers.Get("openai")
1432
+ require.True(t, exists)
1433
+ })
1434
+ }
1435
+
1436
+ func TestConfig_defaultModelSelection(t *testing.T) {
1437
+ t.Run("default behavior uses the default models for given provider", func(t *testing.T) {
1438
+ knownProviders := []catwalk.Provider{
1439
+ {
1440
+ ID: "openai",
1441
+ APIKey: "abc",
1442
+ DefaultLargeModelID: "large-model",
1443
+ DefaultSmallModelID: "small-model",
1444
+ Models: []catwalk.Model{
1445
+ {
1446
+ ID: "large-model",
1447
+ DefaultMaxTokens: 1000,
1448
+ },
1449
+ {
1450
+ ID: "small-model",
1451
+ DefaultMaxTokens: 500,
1452
+ },
1453
+ },
1454
+ },
1455
+ }
1456
+
1457
+ cfg := &Config{}
1458
+ cfg.setDefaults("/tmp", "")
1459
+ env := env.NewFromMap(map[string]string{})
1460
+ resolver := NewShellVariableResolver(env)
1461
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1462
+ require.NoError(t, err)
1463
+
1464
+ large, small, err := cfg.defaultModelSelection(knownProviders)
1465
+ require.NoError(t, err)
1466
+ require.Equal(t, "large-model", large.Model)
1467
+ require.Equal(t, "openai", large.Provider)
1468
+ require.Equal(t, int64(1000), large.MaxTokens)
1469
+ require.Equal(t, "small-model", small.Model)
1470
+ require.Equal(t, "openai", small.Provider)
1471
+ require.Equal(t, int64(500), small.MaxTokens)
1472
+ })
1473
+ t.Run("should error if no providers configured", func(t *testing.T) {
1474
+ knownProviders := []catwalk.Provider{
1475
+ {
1476
+ ID: "openai",
1477
+ APIKey: "$MISSING_KEY",
1478
+ DefaultLargeModelID: "large-model",
1479
+ DefaultSmallModelID: "small-model",
1480
+ Models: []catwalk.Model{
1481
+ {
1482
+ ID: "large-model",
1483
+ DefaultMaxTokens: 1000,
1484
+ },
1485
+ {
1486
+ ID: "small-model",
1487
+ DefaultMaxTokens: 500,
1488
+ },
1489
+ },
1490
+ },
1491
+ }
1492
+
1493
+ cfg := &Config{}
1494
+ cfg.setDefaults("/tmp", "")
1495
+ env := env.NewFromMap(map[string]string{})
1496
+ resolver := NewShellVariableResolver(env)
1497
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1498
+ require.NoError(t, err)
1499
+
1500
+ _, _, err = cfg.defaultModelSelection(knownProviders)
1501
+ require.Error(t, err)
1502
+ })
1503
+ t.Run("should not error if model is missing", func(t *testing.T) {
1504
+ knownProviders := []catwalk.Provider{
1505
+ {
1506
+ ID: "openai",
1507
+ APIKey: "abc",
1508
+ DefaultLargeModelID: "large-model",
1509
+ DefaultSmallModelID: "small-model",
1510
+ Models: []catwalk.Model{
1511
+ {
1512
+ ID: "not-large-model",
1513
+ DefaultMaxTokens: 1000,
1514
+ },
1515
+ {
1516
+ ID: "small-model",
1517
+ DefaultMaxTokens: 500,
1518
+ },
1519
+ },
1520
+ },
1521
+ }
1522
+
1523
+ cfg := &Config{}
1524
+ cfg.setDefaults("/tmp", "")
1525
+ env := env.NewFromMap(map[string]string{})
1526
+ resolver := NewShellVariableResolver(env)
1527
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1528
+ require.NoError(t, err)
1529
+ _, _, err = cfg.defaultModelSelection(knownProviders)
1530
+ require.NoError(t, err)
1531
+ })
1532
+
1533
+ t.Run("should configure the default models with a custom provider", func(t *testing.T) {
1534
+ knownProviders := []catwalk.Provider{
1535
+ {
1536
+ ID: "openai",
1537
+ APIKey: "$MISSING", // will not be included in the config
1538
+ DefaultLargeModelID: "large-model",
1539
+ DefaultSmallModelID: "small-model",
1540
+ Models: []catwalk.Model{
1541
+ {
1542
+ ID: "not-large-model",
1543
+ DefaultMaxTokens: 1000,
1544
+ },
1545
+ {
1546
+ ID: "small-model",
1547
+ DefaultMaxTokens: 500,
1548
+ },
1549
+ },
1550
+ },
1551
+ }
1552
+
1553
+ cfg := &Config{
1554
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1555
+ "custom": {
1556
+ APIKey: "test-key",
1557
+ BaseURL: "https://api.custom.com/v1",
1558
+ Models: []catwalk.Model{
1559
+ {
1560
+ ID: "model",
1561
+ DefaultMaxTokens: 600,
1562
+ },
1563
+ },
1564
+ },
1565
+ }),
1566
+ }
1567
+ cfg.setDefaults("/tmp", "")
1568
+ env := env.NewFromMap(map[string]string{})
1569
+ resolver := NewShellVariableResolver(env)
1570
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1571
+ require.NoError(t, err)
1572
+ large, small, err := cfg.defaultModelSelection(knownProviders)
1573
+ require.NoError(t, err)
1574
+ require.Equal(t, "model", large.Model)
1575
+ require.Equal(t, "custom", large.Provider)
1576
+ require.Equal(t, int64(600), large.MaxTokens)
1577
+ require.Equal(t, "model", small.Model)
1578
+ require.Equal(t, "custom", small.Provider)
1579
+ require.Equal(t, int64(600), small.MaxTokens)
1580
+ })
1581
+
1582
+ t.Run("should fail if no model configured", func(t *testing.T) {
1583
+ knownProviders := []catwalk.Provider{
1584
+ {
1585
+ ID: "openai",
1586
+ APIKey: "$MISSING", // will not be included in the config
1587
+ DefaultLargeModelID: "large-model",
1588
+ DefaultSmallModelID: "small-model",
1589
+ Models: []catwalk.Model{
1590
+ {
1591
+ ID: "not-large-model",
1592
+ DefaultMaxTokens: 1000,
1593
+ },
1594
+ {
1595
+ ID: "small-model",
1596
+ DefaultMaxTokens: 500,
1597
+ },
1598
+ },
1599
+ },
1600
+ }
1601
+
1602
+ cfg := &Config{
1603
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1604
+ "custom": {
1605
+ APIKey: "test-key",
1606
+ BaseURL: "https://api.custom.com/v1",
1607
+ Models: []catwalk.Model{},
1608
+ },
1609
+ }),
1610
+ }
1611
+ cfg.setDefaults("/tmp", "")
1612
+ env := env.NewFromMap(map[string]string{})
1613
+ resolver := NewShellVariableResolver(env)
1614
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1615
+ require.NoError(t, err)
1616
+ _, _, err = cfg.defaultModelSelection(knownProviders)
1617
+ require.Error(t, err)
1618
+ })
1619
+ t.Run("should use the default provider first", func(t *testing.T) {
1620
+ knownProviders := []catwalk.Provider{
1621
+ {
1622
+ ID: "openai",
1623
+ APIKey: "set",
1624
+ DefaultLargeModelID: "large-model",
1625
+ DefaultSmallModelID: "small-model",
1626
+ Models: []catwalk.Model{
1627
+ {
1628
+ ID: "large-model",
1629
+ DefaultMaxTokens: 1000,
1630
+ },
1631
+ {
1632
+ ID: "small-model",
1633
+ DefaultMaxTokens: 500,
1634
+ },
1635
+ },
1636
+ },
1637
+ }
1638
+
1639
+ cfg := &Config{
1640
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1641
+ "custom": {
1642
+ APIKey: "test-key",
1643
+ BaseURL: "https://api.custom.com/v1",
1644
+ Models: []catwalk.Model{
1645
+ {
1646
+ ID: "large-model",
1647
+ DefaultMaxTokens: 1000,
1648
+ },
1649
+ },
1650
+ },
1651
+ }),
1652
+ }
1653
+ cfg.setDefaults("/tmp", "")
1654
+ env := env.NewFromMap(map[string]string{})
1655
+ resolver := NewShellVariableResolver(env)
1656
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1657
+ require.NoError(t, err)
1658
+ large, small, err := cfg.defaultModelSelection(knownProviders)
1659
+ require.NoError(t, err)
1660
+ require.Equal(t, "large-model", large.Model)
1661
+ require.Equal(t, "openai", large.Provider)
1662
+ require.Equal(t, int64(1000), large.MaxTokens)
1663
+ require.Equal(t, "small-model", small.Model)
1664
+ require.Equal(t, "openai", small.Provider)
1665
+ require.Equal(t, int64(500), small.MaxTokens)
1666
+ })
1667
+ }
1668
+
1669
+ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
1670
+ t.Run("when enabled, ignores all default providers and requires full specification", func(t *testing.T) {
1671
+ knownProviders := []catwalk.Provider{
1672
+ {
1673
+ ID: "openai",
1674
+ APIKey: "$OPENAI_API_KEY",
1675
+ APIEndpoint: "https://api.openai.com/v1",
1676
+ Models: []catwalk.Model{{
1677
+ ID: "gpt-4",
1678
+ }},
1679
+ },
1680
+ }
1681
+
1682
+ // User references openai but doesn't fully specify it (no base_url, no
1683
+ // models). This should be rejected because disable_default_providers
1684
+ // treats all providers as custom.
1685
+ cfg := &Config{
1686
+ Options: &Options{
1687
+ DisableDefaultProviders: true,
1688
+ },
1689
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1690
+ "openai": {
1691
+ APIKey: "$OPENAI_API_KEY",
1692
+ },
1693
+ }),
1694
+ }
1695
+ cfg.setDefaults("/tmp", "")
1696
+
1697
+ env := env.NewFromMap(map[string]string{
1698
+ "OPENAI_API_KEY": "test-key",
1699
+ })
1700
+ resolver := NewShellVariableResolver(env)
1701
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1702
+ require.ErrorContains(t, err, "no custom providers")
1703
+
1704
+ // openai should NOT be present because it lacks base_url and models.
1705
+ require.Equal(t, 0, cfg.Providers.Len())
1706
+ _, exists := cfg.Providers.Get("openai")
1707
+ require.False(t, exists, "openai should not be present without full specification")
1708
+ })
1709
+
1710
+ t.Run("when enabled, fully specified providers work", func(t *testing.T) {
1711
+ knownProviders := []catwalk.Provider{
1712
+ {
1713
+ ID: "openai",
1714
+ APIKey: "$OPENAI_API_KEY",
1715
+ APIEndpoint: "https://api.openai.com/v1",
1716
+ Models: []catwalk.Model{{
1717
+ ID: "gpt-4",
1718
+ }},
1719
+ },
1720
+ }
1721
+
1722
+ // User fully specifies their provider.
1723
+ cfg := &Config{
1724
+ Options: &Options{
1725
+ DisableDefaultProviders: true,
1726
+ },
1727
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1728
+ "my-llm": {
1729
+ APIKey: "$MY_API_KEY",
1730
+ BaseURL: "https://my-llm.example.com/v1",
1731
+ Models: []catwalk.Model{{
1732
+ ID: "my-model",
1733
+ }},
1734
+ },
1735
+ }),
1736
+ }
1737
+ cfg.setDefaults("/tmp", "")
1738
+
1739
+ env := env.NewFromMap(map[string]string{
1740
+ "MY_API_KEY": "test-key",
1741
+ "OPENAI_API_KEY": "test-key",
1742
+ })
1743
+ resolver := NewShellVariableResolver(env)
1744
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1745
+ require.NoError(t, err)
1746
+
1747
+ // Only fully specified provider should be present.
1748
+ require.Equal(t, 1, cfg.Providers.Len())
1749
+ provider, exists := cfg.Providers.Get("my-llm")
1750
+ require.True(t, exists, "my-llm should be present")
1751
+ require.Equal(t, "https://my-llm.example.com/v1", provider.BaseURL)
1752
+ require.Len(t, provider.Models, 1)
1753
+
1754
+ // Default openai should NOT be present.
1755
+ _, exists = cfg.Providers.Get("openai")
1756
+ require.False(t, exists, "openai should not be present")
1757
+ })
1758
+
1759
+ t.Run("when disabled, includes all known providers with valid credentials", func(t *testing.T) {
1760
+ knownProviders := []catwalk.Provider{
1761
+ {
1762
+ ID: "openai",
1763
+ APIKey: "$OPENAI_API_KEY",
1764
+ APIEndpoint: "https://api.openai.com/v1",
1765
+ Models: []catwalk.Model{{
1766
+ ID: "gpt-4",
1767
+ }},
1768
+ },
1769
+ {
1770
+ ID: "anthropic",
1771
+ APIKey: "$ANTHROPIC_API_KEY",
1772
+ APIEndpoint: "https://api.anthropic.com/v1",
1773
+ Models: []catwalk.Model{{
1774
+ ID: "claude-3",
1775
+ }},
1776
+ },
1777
+ }
1778
+
1779
+ // User only configures openai, both API keys are available, but option
1780
+ // is disabled.
1781
+ cfg := &Config{
1782
+ Options: &Options{
1783
+ DisableDefaultProviders: false,
1784
+ },
1785
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1786
+ "openai": {
1787
+ APIKey: "$OPENAI_API_KEY",
1788
+ },
1789
+ }),
1790
+ }
1791
+ cfg.setDefaults("/tmp", "")
1792
+
1793
+ env := env.NewFromMap(map[string]string{
1794
+ "OPENAI_API_KEY": "test-key",
1795
+ "ANTHROPIC_API_KEY": "test-key",
1796
+ })
1797
+ resolver := NewShellVariableResolver(env)
1798
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1799
+ require.NoError(t, err)
1800
+
1801
+ // Both providers should be present.
1802
+ require.Equal(t, 2, cfg.Providers.Len())
1803
+ _, exists := cfg.Providers.Get("openai")
1804
+ require.True(t, exists, "openai should be present")
1805
+ _, exists = cfg.Providers.Get("anthropic")
1806
+ require.True(t, exists, "anthropic should be present")
1807
+ })
1808
+
1809
+ t.Run("when enabled, provider missing models attempts discovery but still triggers no-custom-providers error", func(t *testing.T) {
1810
+ cfg := &Config{
1811
+ Options: &Options{
1812
+ DisableDefaultProviders: true,
1813
+ },
1814
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1815
+ "my-llm": {
1816
+ APIKey: "test-key",
1817
+ BaseURL: "https://my-llm.example.com/v1",
1818
+ Models: []catwalk.Model{}, // No models.
1819
+ },
1820
+ }),
1821
+ }
1822
+ cfg.setDefaults("/tmp", "")
1823
+
1824
+ env := env.NewFromMap(map[string]string{})
1825
+ resolver := NewShellVariableResolver(env)
1826
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
1827
+ require.ErrorContains(t, err, "no custom providers")
1828
+
1829
+ // Discovery fails (unreachable URL) so provider is removed.
1830
+ require.Equal(t, 0, cfg.Providers.Len())
1831
+ })
1832
+
1833
+ t.Run("when enabled, provider missing base_url is rejected", func(t *testing.T) {
1834
+ cfg := &Config{
1835
+ Options: &Options{
1836
+ DisableDefaultProviders: true,
1837
+ },
1838
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
1839
+ "my-llm": {
1840
+ APIKey: "test-key",
1841
+ Models: []catwalk.Model{{ID: "model"}},
1842
+ // No BaseURL.
1843
+ },
1844
+ }),
1845
+ }
1846
+ cfg.setDefaults("/tmp", "")
1847
+
1848
+ env := env.NewFromMap(map[string]string{})
1849
+ resolver := NewShellVariableResolver(env)
1850
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
1851
+ require.ErrorContains(t, err, "no custom providers")
1852
+
1853
+ // Provider should be rejected for missing base_url.
1854
+ require.Equal(t, 0, cfg.Providers.Len())
1855
+ })
1856
+ }
1857
+
1858
+ func TestConfig_setDefaultsDisableDefaultProvidersEnvVar(t *testing.T) {
1859
+ t.Run("sets option from environment variable", func(t *testing.T) {
1860
+ t.Setenv("CRUSH_DISABLE_DEFAULT_PROVIDERS", "true")
1861
+
1862
+ cfg := &Config{}
1863
+ cfg.setDefaults("/tmp", "")
1864
+
1865
+ require.True(t, cfg.Options.DisableDefaultProviders)
1866
+ })
1867
+
1868
+ t.Run("does not override when env var is not set", func(t *testing.T) {
1869
+ cfg := &Config{
1870
+ Options: &Options{
1871
+ DisableDefaultProviders: true,
1872
+ },
1873
+ }
1874
+ cfg.setDefaults("/tmp", "")
1875
+
1876
+ require.True(t, cfg.Options.DisableDefaultProviders)
1877
+ })
1878
+ }
1879
+
1880
+ func TestConfig_configureSelectedModels(t *testing.T) {
1881
+ t.Run("reload mode should not persist fallback defaults", func(t *testing.T) {
1882
+ dir := t.TempDir()
1883
+ globalPath := filepath.Join(dir, "crush.json")
1884
+ require.NoError(t, os.WriteFile(globalPath, []byte(`{"models":{"large":{"provider":"ghost","model":"missing"}}}`), 0o600))
1885
+
1886
+ knownProviders := []catwalk.Provider{
1887
+ {
1888
+ ID: "openai",
1889
+ APIKey: "abc",
1890
+ DefaultLargeModelID: "large-model",
1891
+ DefaultSmallModelID: "small-model",
1892
+ Models: []catwalk.Model{
1893
+ {ID: "large-model", DefaultMaxTokens: 1000},
1894
+ {ID: "small-model", DefaultMaxTokens: 500},
1895
+ },
1896
+ },
1897
+ }
1898
+
1899
+ cfg := &Config{
1900
+ Models: map[SelectedModelType]SelectedModel{
1901
+ SelectedModelTypeLarge: {Provider: "ghost", Model: "missing"},
1902
+ },
1903
+ }
1904
+ cfg.setDefaults(dir, "")
1905
+ store := &ConfigStore{config: cfg, globalDataPath: globalPath}
1906
+ env := env.NewFromMap(map[string]string{})
1907
+ resolver := NewShellVariableResolver(env)
1908
+ err := cfg.configureProviders(context.Background(), store, env, resolver, knownProviders)
1909
+ require.NoError(t, err)
1910
+
1911
+ resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
1912
+ require.NoError(t, resolveErr)
1913
+ cfg.Models[SelectedModelTypeLarge] = resolved.Large
1914
+ cfg.Models[SelectedModelTypeSmall] = resolved.Small
1915
+
1916
+ // In-memory falls back to default.
1917
+ require.True(t, resolved.LargeFallback)
1918
+ require.Equal(t, "openai", cfg.Models[SelectedModelTypeLarge].Provider)
1919
+ require.Equal(t, "large-model", cfg.Models[SelectedModelTypeLarge].Model)
1920
+
1921
+ // Disk remains unchanged (resolveSelectedModels never persists).
1922
+ data, readErr := os.ReadFile(globalPath)
1923
+ require.NoError(t, readErr)
1924
+ require.Contains(t, string(data), `"provider":"ghost"`)
1925
+ require.Contains(t, string(data), `"model":"missing"`)
1926
+ })
1927
+ t.Run("should override defaults", func(t *testing.T) {
1928
+ knownProviders := []catwalk.Provider{
1929
+ {
1930
+ ID: "openai",
1931
+ APIKey: "abc",
1932
+ DefaultLargeModelID: "large-model",
1933
+ DefaultSmallModelID: "small-model",
1934
+ Models: []catwalk.Model{
1935
+ {
1936
+ ID: "larger-model",
1937
+ DefaultMaxTokens: 2000,
1938
+ },
1939
+ {
1940
+ ID: "large-model",
1941
+ DefaultMaxTokens: 1000,
1942
+ },
1943
+ {
1944
+ ID: "small-model",
1945
+ DefaultMaxTokens: 500,
1946
+ },
1947
+ },
1948
+ },
1949
+ }
1950
+
1951
+ cfg := &Config{
1952
+ Models: map[SelectedModelType]SelectedModel{
1953
+ "large": {
1954
+ Model: "larger-model",
1955
+ },
1956
+ },
1957
+ }
1958
+ cfg.setDefaults("/tmp", "")
1959
+ env := env.NewFromMap(map[string]string{})
1960
+ resolver := NewShellVariableResolver(env)
1961
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
1962
+ require.NoError(t, err)
1963
+
1964
+ resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
1965
+ require.NoError(t, resolveErr)
1966
+ cfg.Models[SelectedModelTypeLarge] = resolved.Large
1967
+ cfg.Models[SelectedModelTypeSmall] = resolved.Small
1968
+ large := cfg.Models[SelectedModelTypeLarge]
1969
+ small := cfg.Models[SelectedModelTypeSmall]
1970
+ require.Equal(t, "larger-model", large.Model)
1971
+ require.Equal(t, "openai", large.Provider)
1972
+ require.Equal(t, int64(2000), large.MaxTokens)
1973
+ require.Equal(t, "small-model", small.Model)
1974
+ require.Equal(t, "openai", small.Provider)
1975
+ require.Equal(t, int64(500), small.MaxTokens)
1976
+ })
1977
+ t.Run("should be possible to use multiple providers", func(t *testing.T) {
1978
+ knownProviders := []catwalk.Provider{
1979
+ {
1980
+ ID: "openai",
1981
+ APIKey: "abc",
1982
+ DefaultLargeModelID: "large-model",
1983
+ DefaultSmallModelID: "small-model",
1984
+ Models: []catwalk.Model{
1985
+ {
1986
+ ID: "large-model",
1987
+ DefaultMaxTokens: 1000,
1988
+ },
1989
+ {
1990
+ ID: "small-model",
1991
+ DefaultMaxTokens: 500,
1992
+ },
1993
+ },
1994
+ },
1995
+ {
1996
+ ID: "anthropic",
1997
+ APIKey: "abc",
1998
+ DefaultLargeModelID: "a-large-model",
1999
+ DefaultSmallModelID: "a-small-model",
2000
+ Models: []catwalk.Model{
2001
+ {
2002
+ ID: "a-large-model",
2003
+ DefaultMaxTokens: 1000,
2004
+ },
2005
+ {
2006
+ ID: "a-small-model",
2007
+ DefaultMaxTokens: 200,
2008
+ },
2009
+ },
2010
+ },
2011
+ }
2012
+
2013
+ cfg := &Config{
2014
+ Models: map[SelectedModelType]SelectedModel{
2015
+ "small": {
2016
+ Model: "a-small-model",
2017
+ Provider: "anthropic",
2018
+ MaxTokens: 300,
2019
+ },
2020
+ },
2021
+ }
2022
+ cfg.setDefaults("/tmp", "")
2023
+ env := env.NewFromMap(map[string]string{})
2024
+ resolver := NewShellVariableResolver(env)
2025
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
2026
+ require.NoError(t, err)
2027
+
2028
+ resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
2029
+ require.NoError(t, resolveErr)
2030
+ cfg.Models[SelectedModelTypeLarge] = resolved.Large
2031
+ cfg.Models[SelectedModelTypeSmall] = resolved.Small
2032
+ large := cfg.Models[SelectedModelTypeLarge]
2033
+ small := cfg.Models[SelectedModelTypeSmall]
2034
+ require.Equal(t, "large-model", large.Model)
2035
+ require.Equal(t, "openai", large.Provider)
2036
+ require.Equal(t, int64(1000), large.MaxTokens)
2037
+ require.Equal(t, "a-small-model", small.Model)
2038
+ require.Equal(t, "anthropic", small.Provider)
2039
+ require.Equal(t, int64(300), small.MaxTokens)
2040
+ })
2041
+
2042
+ t.Run("should override the max tokens only", func(t *testing.T) {
2043
+ knownProviders := []catwalk.Provider{
2044
+ {
2045
+ ID: "openai",
2046
+ APIKey: "abc",
2047
+ DefaultLargeModelID: "large-model",
2048
+ DefaultSmallModelID: "small-model",
2049
+ Models: []catwalk.Model{
2050
+ {
2051
+ ID: "large-model",
2052
+ DefaultMaxTokens: 1000,
2053
+ },
2054
+ {
2055
+ ID: "small-model",
2056
+ DefaultMaxTokens: 500,
2057
+ },
2058
+ },
2059
+ },
2060
+ }
2061
+
2062
+ cfg := &Config{
2063
+ Models: map[SelectedModelType]SelectedModel{
2064
+ "large": {
2065
+ MaxTokens: 100,
2066
+ },
2067
+ },
2068
+ }
2069
+ cfg.setDefaults("/tmp", "")
2070
+ env := env.NewFromMap(map[string]string{})
2071
+ resolver := NewShellVariableResolver(env)
2072
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
2073
+ require.NoError(t, err)
2074
+
2075
+ resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
2076
+ require.NoError(t, resolveErr)
2077
+ cfg.Models[SelectedModelTypeLarge] = resolved.Large
2078
+ cfg.Models[SelectedModelTypeSmall] = resolved.Small
2079
+ large := cfg.Models[SelectedModelTypeLarge]
2080
+ require.Equal(t, "large-model", large.Model)
2081
+ require.Equal(t, "openai", large.Provider)
2082
+ require.Equal(t, int64(100), large.MaxTokens)
2083
+ })
2084
+ t.Run("resolve and persist fallback under writeMu does not deadlock", func(t *testing.T) {
2085
+ dir := t.TempDir()
2086
+ globalPath := filepath.Join(dir, "crush.json")
2087
+ require.NoError(t, os.WriteFile(globalPath, []byte(`{}`), 0o600))
2088
+
2089
+ knownProviders := []catwalk.Provider{
2090
+ {
2091
+ ID: "openai",
2092
+ APIKey: "abc",
2093
+ DefaultLargeModelID: "large-model",
2094
+ DefaultSmallModelID: "small-model",
2095
+ Models: []catwalk.Model{
2096
+ {ID: "large-model", DefaultMaxTokens: 1000},
2097
+ {ID: "small-model", DefaultMaxTokens: 500},
2098
+ },
2099
+ },
2100
+ }
2101
+
2102
+ cfg := &Config{
2103
+ Models: map[SelectedModelType]SelectedModel{
2104
+ SelectedModelTypeLarge: {Provider: "openai", Model: "this-model-does-not-exist"},
2105
+ SelectedModelTypeSmall: {Provider: "openai", Model: "also-does-not-exist"},
2106
+ },
2107
+ }
2108
+ cfg.setDefaults(dir, "")
2109
+ store := &ConfigStore{config: cfg, globalDataPath: globalPath}
2110
+ env := env.NewFromMap(map[string]string{})
2111
+ resolver := NewShellVariableResolver(env)
2112
+ err := cfg.configureProviders(context.Background(), store, env, resolver, knownProviders)
2113
+ require.NoError(t, err)
2114
+
2115
+ // Simulate the Load path: resolve (pure), then persist fallbacks
2116
+ // under writeMu using updateLocked. Before the refactor, the
2117
+ // combined configureSelectedModels(persist=true) self-deadlocked
2118
+ // because UpdatePreferredModel re-acquired writeMu.
2119
+ done := make(chan error, 1)
2120
+ go func() {
2121
+ resolved, resolveErr := resolveSelectedModels(cfg, knownProviders)
2122
+ if resolveErr != nil {
2123
+ done <- resolveErr
2124
+ return
2125
+ }
2126
+ cfg.Models[SelectedModelTypeLarge] = resolved.Large
2127
+ cfg.Models[SelectedModelTypeSmall] = resolved.Small
2128
+
2129
+ store.writeMu.Lock()
2130
+ defer store.writeMu.Unlock()
2131
+ if resolved.LargeFallback {
2132
+ if err := store.updateLocked(ScopeGlobal, func(c *Config) map[string]any {
2133
+ return store.updatePreferredModelFields(c, SelectedModelTypeLarge, resolved.Large)
2134
+ }); err != nil {
2135
+ done <- err
2136
+ return
2137
+ }
2138
+ }
2139
+ if resolved.SmallFallback {
2140
+ if err := store.updateLocked(ScopeGlobal, func(c *Config) map[string]any {
2141
+ return store.updatePreferredModelFields(c, SelectedModelTypeSmall, resolved.Small)
2142
+ }); err != nil {
2143
+ done <- err
2144
+ return
2145
+ }
2146
+ }
2147
+ done <- nil
2148
+ }()
2149
+
2150
+ select {
2151
+ case err := <-done:
2152
+ require.NoError(t, err)
2153
+ // Should have fallen back to defaults.
2154
+ require.Equal(t, "large-model", cfg.Models[SelectedModelTypeLarge].Model)
2155
+ require.Equal(t, "small-model", cfg.Models[SelectedModelTypeSmall].Model)
2156
+ case <-time.After(5 * time.Second):
2157
+ t.Fatal("resolve + persist deadlocked under writeMu")
2158
+ }
2159
+ })
2160
+ }
2161
+
2162
+ func TestConfig_configureProviders_HyperAPIKeyFromEnv(t *testing.T) {
2163
+ // Test that HYPER_API_KEY environment variable works without config
2164
+ knownProviders := []catwalk.Provider{
2165
+ {
2166
+ ID: "hyper",
2167
+ APIKey: "", // No API key in provider definition
2168
+ DefaultLargeModelID: "large-model",
2169
+ DefaultSmallModelID: "small-model",
2170
+ Models: []catwalk.Model{
2171
+ {
2172
+ ID: "large-model",
2173
+ DefaultMaxTokens: 1000,
2174
+ },
2175
+ {
2176
+ ID: "small-model",
2177
+ DefaultMaxTokens: 500,
2178
+ },
2179
+ },
2180
+ },
2181
+ }
2182
+
2183
+ cfg := &Config{}
2184
+ cfg.setDefaults("/tmp", "")
2185
+ env := env.NewFromMap(map[string]string{
2186
+ "HYPER_API_KEY": "env-api-key",
2187
+ })
2188
+ resolver := NewShellVariableResolver(env)
2189
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
2190
+ require.NoError(t, err)
2191
+ require.Equal(t, 1, cfg.Providers.Len())
2192
+
2193
+ // Verify Hyper provider is configured with the env var API key
2194
+ pc, ok := cfg.Providers.Get("hyper")
2195
+ require.True(t, ok, "Hyper provider should be configured")
2196
+ require.Equal(t, "env-api-key", pc.APIKey)
2197
+ require.Equal(t, "env-api-key", pc.APIKeyTemplate)
2198
+ }
2199
+
2200
+ func TestConfig_configureProviders_HyperAPIKeyFromConfigOverrides(t *testing.T) {
2201
+ // Test that config API key takes precedence when HYPER_API_KEY is also set
2202
+ knownProviders := []catwalk.Provider{
2203
+ {
2204
+ ID: "hyper",
2205
+ APIKey: "provider-api-key",
2206
+ DefaultLargeModelID: "large-model",
2207
+ DefaultSmallModelID: "small-model",
2208
+ Models: []catwalk.Model{
2209
+ {
2210
+ ID: "large-model",
2211
+ DefaultMaxTokens: 1000,
2212
+ },
2213
+ {
2214
+ ID: "small-model",
2215
+ DefaultMaxTokens: 500,
2216
+ },
2217
+ },
2218
+ },
2219
+ }
2220
+
2221
+ // User has Hyper configured with an API key
2222
+ cfg := &Config{
2223
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
2224
+ "hyper": {
2225
+ APIKey: "config-api-key",
2226
+ },
2227
+ }),
2228
+ }
2229
+ cfg.setDefaults("/tmp", "")
2230
+
2231
+ // But they also have HYPER_API_KEY set - env var should take precedence
2232
+ env := env.NewFromMap(map[string]string{
2233
+ "HYPER_API_KEY": "env-api-key",
2234
+ })
2235
+ resolver := NewShellVariableResolver(env)
2236
+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
2237
+ require.NoError(t, err)
2238
+ require.Equal(t, 1, cfg.Providers.Len())
2239
+
2240
+ // Verify env var takes precedence (as per requirements)
2241
+ pc, ok := cfg.Providers.Get("hyper")
2242
+ require.True(t, ok, "Hyper provider should be configured")
2243
+ require.Equal(t, "env-api-key", pc.APIKey)
2244
+ }
2245
+
2246
+ // TestConfig_configureProviders_ProviderHeaderResolveError verifies
2247
+ // that a failing $(cmd) in a provider header fails the provider load
2248
+ // with a clear message that names the offending header. Provider
2249
+ // headers share the MCP error contract.
2250
+ func TestConfig_configureProviders_ProviderHeaderResolveError(t *testing.T) {
2251
+ knownProviders := []catwalk.Provider{
2252
+ {
2253
+ ID: "openai",
2254
+ APIKey: "$OPENAI_API_KEY",
2255
+ APIEndpoint: "https://api.openai.com/v1",
2256
+ Models: []catwalk.Model{{ID: "test-model"}},
2257
+ },
2258
+ }
2259
+
2260
+ cfg := &Config{
2261
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
2262
+ "openai": {
2263
+ ExtraHeaders: map[string]string{
2264
+ // Failing $(...) — inner command exits 1. Must
2265
+ // propagate as an error, not a silent truncation.
2266
+ "X-Broken": "$(false)",
2267
+ },
2268
+ },
2269
+ }),
2270
+ }
2271
+ cfg.setDefaults("/tmp", "")
2272
+
2273
+ testEnv := env.NewFromMap(map[string]string{
2274
+ "OPENAI_API_KEY": "test-key",
2275
+ "PATH": os.Getenv("PATH"),
2276
+ })
2277
+ resolver := NewShellVariableResolver(testEnv)
2278
+
2279
+ err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
2280
+ require.Error(t, err, "failing $(cmd) in a header must fail the provider load")
2281
+ require.Contains(t, err.Error(), "X-Broken", "error must name the offending header")
2282
+ }
2283
+
2284
+ // TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads
2285
+ // verifies that a Catwalk-style default header like
2286
+ // "OpenAI-Organization": "$OPENAI_ORG_ID" loads cleanly under lenient
2287
+ // nounset (unset → "" → header dropped), and does not fail the load
2288
+ // or leave the literal template on the wire.
2289
+ func TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads(t *testing.T) {
2290
+ knownProviders := []catwalk.Provider{
2291
+ {
2292
+ ID: "openai",
2293
+ APIKey: "$OPENAI_API_KEY",
2294
+ APIEndpoint: "https://api.openai.com/v1",
2295
+ Models: []catwalk.Model{{ID: "test-model"}},
2296
+ DefaultHeaders: map[string]string{
2297
+ "OpenAI-Organization": "$OPENAI_ORG_ID",
2298
+ },
2299
+ },
2300
+ }
2301
+
2302
+ cfg := &Config{}
2303
+ cfg.setDefaults("/tmp", "")
2304
+
2305
+ testEnv := env.NewFromMap(map[string]string{
2306
+ "OPENAI_API_KEY": "test-key",
2307
+ "PATH": os.Getenv("PATH"),
2308
+ })
2309
+ resolver := NewShellVariableResolver(testEnv)
2310
+
2311
+ err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
2312
+ require.NoError(t, err, "optional env-gated header must not fail the load")
2313
+
2314
+ pc, ok := cfg.Providers.Get("openai")
2315
+ require.True(t, ok, "openai provider must still be configured")
2316
+ _, present := pc.ExtraHeaders["OpenAI-Organization"]
2317
+ require.False(t, present, "header whose value resolves to empty must be absent")
2318
+ }
2319
+
2320
+ // TestConfig_configureProviders_LiteralEmptyHeaderDropped pins design
2321
+ // decision #18 for the literal case: a user-authored
2322
+ // "X-Custom": "" in extra_headers is absent from the resolved map.
2323
+ // Applies to both known- and custom-provider paths; this test
2324
+ // exercises the custom-provider loop.
2325
+ func TestConfig_configureProviders_LiteralEmptyHeaderDropped(t *testing.T) {
2326
+ cfg := &Config{
2327
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
2328
+ "my-llm": {
2329
+ APIKey: "test-key",
2330
+ BaseURL: "https://my-llm.example.com/v1",
2331
+ Type: catwalk.TypeOpenAI,
2332
+ Models: []catwalk.Model{{ID: "m"}},
2333
+ ExtraHeaders: map[string]string{
2334
+ "X-Custom": "",
2335
+ "X-Kept": "present",
2336
+ },
2337
+ },
2338
+ }),
2339
+ }
2340
+ cfg.setDefaults("/tmp", "")
2341
+
2342
+ testEnv := env.NewFromMap(map[string]string{
2343
+ "PATH": os.Getenv("PATH"),
2344
+ })
2345
+ resolver := NewShellVariableResolver(testEnv)
2346
+
2347
+ err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, []catwalk.Provider{})
2348
+ require.NoError(t, err)
2349
+
2350
+ pc, ok := cfg.Providers.Get("my-llm")
2351
+ require.True(t, ok)
2352
+ _, present := pc.ExtraHeaders["X-Custom"]
2353
+ require.False(t, present, "literal empty-string header must be dropped")
2354
+ require.Equal(t, "present", pc.ExtraHeaders["X-Kept"])
2355
+ }
2356
+
2357
+ // TestConfig_configureProviders_EchoEmptyHeaderDropped pins design
2358
+ // decision #18 for the non-failing empty case: $(echo) exits 0 with
2359
+ // empty output, resolves cleanly to "", and must be dropped the same
2360
+ // way an unset bare $VAR is. Exercises the known-provider loop.
2361
+ func TestConfig_configureProviders_EchoEmptyHeaderDropped(t *testing.T) {
2362
+ knownProviders := []catwalk.Provider{
2363
+ {
2364
+ ID: "openai",
2365
+ APIKey: "$OPENAI_API_KEY",
2366
+ APIEndpoint: "https://api.openai.com/v1",
2367
+ Models: []catwalk.Model{{ID: "test-model"}},
2368
+ DefaultHeaders: map[string]string{
2369
+ "X-Empty": "$(echo)",
2370
+ "X-Kept": "present",
2371
+ },
2372
+ },
2373
+ }
2374
+
2375
+ cfg := &Config{}
2376
+ cfg.setDefaults("/tmp", "")
2377
+
2378
+ testEnv := env.NewFromMap(map[string]string{
2379
+ "OPENAI_API_KEY": "test-key",
2380
+ "PATH": os.Getenv("PATH"),
2381
+ })
2382
+ resolver := NewShellVariableResolver(testEnv)
2383
+
2384
+ err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
2385
+ require.NoError(t, err)
2386
+
2387
+ pc, ok := cfg.Providers.Get("openai")
2388
+ require.True(t, ok)
2389
+ _, present := pc.ExtraHeaders["X-Empty"]
2390
+ require.False(t, present, "$(echo) → empty → header must be dropped")
2391
+ require.Equal(t, "present", pc.ExtraHeaders["X-Kept"])
2392
+ }
2393
+
2394
+ // TestConfig_configureProviders_UnsetAPIKeySkipsProvider verifies that
2395
+ // under the lenient-nounset shell resolver, $UNSET_API_KEY expands to
2396
+ // ("", nil) rather than ("", err), and the existing
2397
+ // `v == "" || err != nil` skip path at load.go:331 still drops the
2398
+ // provider. The slog.Warn line is emitted on the same
2399
+ // path but is not asserted here — internal/config/load_test.go's
2400
+ // TestMain replaces the default slog handler with an io.Discard
2401
+ // writer, so capturing that log line would require mid-test handler
2402
+ // swapping and a sync.Mutex dance that adds more flake surface than
2403
+ // signal. The observable outcome (provider absent from the map) is
2404
+ // what downstream code — model picker, agent wiring — actually reads,
2405
+ // so that's what we pin.
2406
+ func TestConfig_configureProviders_UnsetAPIKeySkipsProvider(t *testing.T) {
2407
+ knownProviders := []catwalk.Provider{
2408
+ {
2409
+ ID: "openai",
2410
+ APIKey: "$SOMETHING_UNSET",
2411
+ APIEndpoint: "https://api.openai.com/v1",
2412
+ Models: []catwalk.Model{{ID: "test-model"}},
2413
+ },
2414
+ }
2415
+
2416
+ // Existing user config for this known provider so the load.go:332
2417
+ // `if configExists` branch fires and actually calls Providers.Del.
2418
+ // Without it the provider was never in the map to begin with and
2419
+ // the test would pass trivially.
2420
+ cfg := &Config{
2421
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
2422
+ "openai": {BaseURL: "custom-url"},
2423
+ }),
2424
+ }
2425
+ cfg.setDefaults("/tmp", "")
2426
+
2427
+ testEnv := env.NewFromMap(map[string]string{
2428
+ "PATH": os.Getenv("PATH"),
2429
+ })
2430
+ resolver := NewShellVariableResolver(testEnv)
2431
+
2432
+ err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
2433
+ require.NoError(t, err, "skip path must not surface as a load error")
2434
+
2435
+ require.Equal(t, 0, cfg.Providers.Len(), "provider with unset API key must be skipped")
2436
+ _, exists := cfg.Providers.Get("openai")
2437
+ require.False(t, exists)
2438
+ }
2439
+
2440
+ // TestConfig_configureProviders_FailingAPIKeyCmdSkipsProvider pins
2441
+ // that the two failure modes for APIKey — ("", nil) from an unset var
2442
+ // under lenient nounset and ("", err) from a failing $(cmd) — are
2443
+ // equivalent for the skip outcome at load.go:331. The `v == "" ||
2444
+ // err != nil` check fires on either branch; this test locks in that
2445
+ // equivalence so a future refactor that splits the check into two
2446
+ // paths doesn't accidentally start propagating $(false) as a load
2447
+ // error while keeping unset-var as a silent skip (or vice versa).
2448
+ func TestConfig_configureProviders_FailingAPIKeyCmdSkipsProvider(t *testing.T) {
2449
+ knownProviders := []catwalk.Provider{
2450
+ {
2451
+ ID: "openai",
2452
+ APIKey: "$(false)",
2453
+ APIEndpoint: "https://api.openai.com/v1",
2454
+ Models: []catwalk.Model{{ID: "test-model"}},
2455
+ },
2456
+ }
2457
+
2458
+ cfg := &Config{
2459
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
2460
+ "openai": {BaseURL: "custom-url"},
2461
+ }),
2462
+ }
2463
+ cfg.setDefaults("/tmp", "")
2464
+
2465
+ testEnv := env.NewFromMap(map[string]string{
2466
+ "PATH": os.Getenv("PATH"),
2467
+ })
2468
+ resolver := NewShellVariableResolver(testEnv)
2469
+
2470
+ err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
2471
+ require.NoError(t, err, "failing $(cmd) in API key must skip provider, not fail load")
2472
+
2473
+ require.Equal(t, 0, cfg.Providers.Len(), "provider with failing $(cmd) API key must be skipped")
2474
+ _, exists := cfg.Providers.Get("openai")
2475
+ require.False(t, exists)
2476
+ }
2477
+
2478
+ // TestConfig_configureProviders_UnsetAzureEndpointSkipsProvider pins
2479
+ // the same contract on the Azure path at load.go:287 — APIEndpoint is
2480
+ // the field that gates Azure and goes through the same
2481
+ // `v == "" || err != nil` skip check. Covered here so both branches
2482
+ // of the shared skip pattern (APIKey default path and APIEndpoint
2483
+ // Azure path) are tested; a future refactor that unifies them can
2484
+ // rely on these two tests to catch drift.
2485
+ func TestConfig_configureProviders_UnsetAzureEndpointSkipsProvider(t *testing.T) {
2486
+ knownProviders := []catwalk.Provider{
2487
+ {
2488
+ ID: catwalk.InferenceProviderAzure,
2489
+ APIKey: "test-key",
2490
+ APIEndpoint: "$UNSET_AZURE_ENDPOINT",
2491
+ Models: []catwalk.Model{{ID: "test-model"}},
2492
+ },
2493
+ }
2494
+
2495
+ cfg := &Config{
2496
+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
2497
+ "azure": {BaseURL: ""},
2498
+ }),
2499
+ }
2500
+ cfg.setDefaults("/tmp", "")
2501
+
2502
+ testEnv := env.NewFromMap(map[string]string{
2503
+ "PATH": os.Getenv("PATH"),
2504
+ })
2505
+ resolver := NewShellVariableResolver(testEnv)
2506
+
2507
+ err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
2508
+ require.NoError(t, err)
2509
+
2510
+ require.Equal(t, 0, cfg.Providers.Len(), "azure provider with unset endpoint must be skipped")
2511
+ _, exists := cfg.Providers.Get("azure")
2512
+ require.False(t, exists)
2513
+ }
2514
+
2515
+ func TestConfig_LoadFromBytes_Env(t *testing.T) {
2516
+ data := []byte(`{"env": {"AWS_PROFILE": "my-profile", "AWS_REGION": "us-west-2"}}`)
2517
+
2518
+ loadedConfig, err := loadFromBytes([][]byte{data})
2519
+
2520
+ require.NoError(t, err)
2521
+ require.NotNil(t, loadedConfig.Env)
2522
+ require.Equal(t, "my-profile", loadedConfig.Env["AWS_PROFILE"])
2523
+ require.Equal(t, "us-west-2", loadedConfig.Env["AWS_REGION"])
2524
+ }
2525
+
2526
+ func TestConfig_LoadFromBytes_EnvMerge(t *testing.T) {
2527
+ data1 := []byte(`{"env": {"AWS_PROFILE": "first", "AWS_REGION": "us-east-1"}}`)
2528
+ data2 := []byte(`{"env": {"AWS_PROFILE": "second"}}`)
2529
+
2530
+ loadedConfig, err := loadFromBytes([][]byte{data1, data2})
2531
+
2532
+ require.NoError(t, err)
2533
+ require.NotNil(t, loadedConfig.Env)
2534
+ require.Equal(t, "second", loadedConfig.Env["AWS_PROFILE"])
2535
+ require.Equal(t, "us-east-1", loadedConfig.Env["AWS_REGION"])
2536
+ }
internal/config/lsp_defaults_test.go ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/stretchr/testify/require"
7
+ )
8
+
9
+ func TestApplyLSPDefaults(t *testing.T) {
10
+ t.Parallel()
11
+
12
+ // Create a config with an LSP that should get defaults
13
+ config := &Config{
14
+ LSP: map[string]LSPConfig{
15
+ "gopls": {
16
+ Command: "gopls", // This should get defaults from powernap
17
+ },
18
+ "custom": {
19
+ Command: "custom-lsp",
20
+ RootMarkers: []string{"custom.toml"}, // This should keep its explicit config
21
+ },
22
+ },
23
+ }
24
+
25
+ // Apply defaults
26
+ config.applyLSPDefaults()
27
+
28
+ // Check that gopls got defaults (it should have some root markers now)
29
+ goplsConfig := config.LSP["gopls"]
30
+ require.NotEmpty(t, goplsConfig.RootMarkers, "gopls should have received default root markers")
31
+
32
+ // Check that custom LSP kept its explicit config
33
+ customConfig := config.LSP["custom"]
34
+ require.Equal(t, []string{"custom.toml"}, customConfig.RootMarkers, "custom LSP should keep its explicit root markers")
35
+ }
internal/config/openai_test.go ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ "time"
11
+
12
+ "charm.land/catwalk/pkg/catwalk"
13
+ "github.com/charmbracelet/crush/internal/csync"
14
+ "github.com/charmbracelet/crush/internal/oauth"
15
+ "github.com/stretchr/testify/require"
16
+ )
17
+
18
+ func TestGetModelIncludesChatGPTModels(t *testing.T) {
19
+ t.Parallel()
20
+
21
+ providers := csync.NewMap[string, ProviderConfig]()
22
+ providers.Set("openai", ProviderConfig{
23
+ Models: []catwalk.Model{{ID: "gpt-5.1"}},
24
+ ChatGPTModels: []catwalk.Model{{ID: "gpt-5.1-codex", Name: "GPT-5.1 Codex"}},
25
+ })
26
+ cfg := &Config{Providers: providers}
27
+
28
+ model := cfg.GetModel("openai", "gpt-5.1-codex")
29
+ require.NotNil(t, model)
30
+ require.Equal(t, "GPT-5.1 Codex", model.Name)
31
+
32
+ require.NotNil(t, cfg.GetModel("openai", "gpt-5.1"))
33
+ require.Nil(t, cfg.GetModel("openai", "missing"))
34
+ }
35
+
36
+ func TestTokenFields(t *testing.T) {
37
+ t.Parallel()
38
+
39
+ t.Run("openai keeps the api key", func(t *testing.T) {
40
+ t.Parallel()
41
+
42
+ fields := tokenFields("openai", &oauth.Token{AccessToken: "at"})
43
+ require.Contains(t, fields, "providers.openai.oauth")
44
+ require.NotContains(t, fields, "providers.openai.api_key")
45
+ })
46
+
47
+ t.Run("other providers mirror the access token", func(t *testing.T) {
48
+ t.Parallel()
49
+
50
+ fields := tokenFields("hyper", &oauth.Token{AccessToken: "at"})
51
+ require.Contains(t, fields, "providers.hyper.oauth")
52
+ require.Equal(t, "at", fields["providers.hyper.api_key"])
53
+ })
54
+ }
55
+
56
+ func TestTokenAccountIDRoundTrip(t *testing.T) {
57
+ t.Parallel()
58
+
59
+ // The account ID is persisted with the token in crush.json; verify
60
+ // it survives the JSON round trip loadTokenFromDisk performs.
61
+ token := &oauth.Token{
62
+ AccessToken: "at",
63
+ RefreshToken: "rt",
64
+ IDToken: "idt",
65
+ AccountID: "acct-42",
66
+ ExpiresIn: 3600,
67
+ ExpiresAt: 4102444800,
68
+ }
69
+
70
+ data, err := json.Marshal(token)
71
+ require.NoError(t, err)
72
+
73
+ var restored oauth.Token
74
+ require.NoError(t, json.Unmarshal(data, &restored))
75
+ require.Equal(t, "acct-42", restored.AccountID)
76
+ require.Equal(t, "idt", restored.IDToken)
77
+ require.Equal(t, "rt", restored.RefreshToken)
78
+ }
79
+
80
+ // testResolver mimics the shell resolver for templates: variables
81
+ // resolve to empty when unset, literals pass through.
82
+ type testResolver struct{}
83
+
84
+ func (testResolver) ResolveValue(v string) (string, error) {
85
+ if strings.HasPrefix(v, "$") {
86
+ return "", nil
87
+ }
88
+ return v, nil
89
+ }
90
+
91
+ func TestHasAPIKey(t *testing.T) {
92
+ t.Parallel()
93
+
94
+ t.Run("empty", func(t *testing.T) {
95
+ t.Parallel()
96
+
97
+ require.False(t, (&ProviderConfig{}).HasAPIKey(testResolver{}))
98
+ })
99
+
100
+ t.Run("literal key", func(t *testing.T) {
101
+ t.Parallel()
102
+
103
+ require.True(t, (&ProviderConfig{APIKey: "sk-key"}).HasAPIKey(testResolver{}))
104
+ })
105
+
106
+ t.Run("unresolved template", func(t *testing.T) {
107
+ t.Parallel()
108
+
109
+ require.False(t, (&ProviderConfig{APIKey: "$OPENAI_API_KEY"}).HasAPIKey(testResolver{}))
110
+ })
111
+ }
112
+
113
+ // TestSetProviderAPIKeyOpenAIIsEitherOr proves the OpenAI provider holds
114
+ // exactly one credential: a ChatGPT login retires a previously entered
115
+ // API key, and entering an API key retires a previous ChatGPT login.
116
+ // Copilot keeps mirroring the access token into api_key as before.
117
+ func TestSetProviderAPIKeyOpenAIIsEitherOr(t *testing.T) {
118
+ // Not parallel: t.Setenv below.
119
+
120
+ // Point config discovery at the test sandbox: the write below
121
+ // triggers an auto-reload, which must not pick up the developer's
122
+ // real crush.json.
123
+ t.Setenv("CRUSH_GLOBAL_CONFIG", t.TempDir())
124
+ t.Setenv("CRUSH_GLOBAL_DATA", t.TempDir())
125
+ t.Setenv("XDG_DATA_HOME", t.TempDir())
126
+
127
+ token := &oauth.Token{
128
+ AccessToken: "chatgpt-at",
129
+ RefreshToken: "chatgpt-rt",
130
+ ExpiresIn: 3600,
131
+ ExpiresAt: time.Now().Add(time.Hour).Unix(),
132
+ }
133
+
134
+ newStore := func(t *testing.T, providerID string, initialConfig string) *ConfigStore {
135
+ t.Helper()
136
+ dir := t.TempDir()
137
+ configPath := filepath.Join(dir, "crush.json")
138
+ require.NoError(t, os.WriteFile(configPath, []byte(initialConfig), 0o600))
139
+
140
+ // The in-memory config mirrors what a real load would produce.
141
+ // SetConfigFields auto-reloads from configPath, so any state the
142
+ // assertions expect to survive must be on disk.
143
+ providers := csync.NewMap[string, ProviderConfig]()
144
+ require.NoError(t, json.Unmarshal([]byte(initialConfig), &struct {
145
+ Providers *csync.Map[string, ProviderConfig] `json:"providers"`
146
+ }{Providers: providers}))
147
+
148
+ return &ConfigStore{
149
+ config: &Config{Providers: providers},
150
+ globalDataPath: configPath,
151
+ workingDir: dir,
152
+ fetchOpenAIModels: func(context.Context, *oauth.Token) ([]catwalk.Model, error) {
153
+ return []catwalk.Model{{ID: "gpt-5.1-codex"}}, nil
154
+ },
155
+ }
156
+ }
157
+
158
+ t.Run("chatgpt login retires the api key", func(t *testing.T) {
159
+ store := newStore(t, "openai", `{
160
+ "providers": {
161
+ "openai": {
162
+ "id": "openai",
163
+ "api_key": "sk-keep",
164
+ "models": [{"id": "gpt-5.1", "name": "GPT-5.1"}]
165
+ }
166
+ }
167
+ }`)
168
+
169
+ require.NoError(t, store.SetProviderAPIKey(ScopeGlobal, "openai", token))
170
+
171
+ pc, ok := store.Config().Providers.Get("openai")
172
+ require.True(t, ok)
173
+ require.Empty(t, pc.APIKey, "the ChatGPT login replaces the API key")
174
+ require.Equal(t, token, pc.OAuthToken)
175
+ require.Equal(t, "gpt-5.1-codex", pc.ChatGPTModels[0].ID, "the subscription catalog lands in its own field")
176
+ require.Equal(t, "gpt-5.1", pc.Models[0].ID, "the API catalog is untouched")
177
+
178
+ disk, err := os.ReadFile(store.globalDataPath)
179
+ require.NoError(t, err)
180
+ require.NotContains(t, string(disk), "sk-keep", "the retired key is gone from the config file")
181
+ require.Contains(t, string(disk), "chatgpt-rt", "the login is persisted")
182
+ })
183
+
184
+ t.Run("api key retires the chatgpt login", func(t *testing.T) {
185
+ store := newStore(t, "openai", `{
186
+ "providers": {
187
+ "openai": {
188
+ "id": "openai",
189
+ "api_key": "",
190
+ "oauth": {"access_token": "old-at", "refresh_token": "old-rt"},
191
+ "chatgpt_models": [{"id": "gpt-5.6-luna"}]
192
+ }
193
+ }
194
+ }`)
195
+
196
+ require.NoError(t, store.SetProviderAPIKey(ScopeGlobal, "openai", "sk-new"))
197
+
198
+ pc, ok := store.Config().Providers.Get("openai")
199
+ require.True(t, ok)
200
+ require.Equal(t, "sk-new", pc.APIKey)
201
+ require.Nil(t, pc.OAuthToken, "the API key replaces the ChatGPT login")
202
+ require.Empty(t, pc.ChatGPTModels, "the subscription catalog goes with it")
203
+
204
+ disk, err := os.ReadFile(store.globalDataPath)
205
+ require.NoError(t, err)
206
+ require.NotContains(t, string(disk), "old-rt", "the retired login is gone from the config file")
207
+ require.NotContains(t, string(disk), "gpt-5.6-luna")
208
+ })
209
+
210
+ t.Run("copilot", func(t *testing.T) {
211
+ store := newStore(t, "copilot", `{"providers":{"copilot":{"id":"copilot"}}}`)
212
+
213
+ require.NoError(t, store.SetProviderAPIKey(ScopeGlobal, "copilot", token))
214
+
215
+ pc, ok := store.Config().Providers.Get("copilot")
216
+ require.True(t, ok)
217
+ require.Equal(t, "chatgpt-at", pc.APIKey, "copilot still mirrors the access token into api_key")
218
+ require.Equal(t, token, pc.OAuthToken)
219
+ })
220
+ }
internal/config/options_request_timeout_test.go ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "encoding/json"
5
+ "testing"
6
+ "time"
7
+
8
+ "github.com/stretchr/testify/require"
9
+ )
10
+
11
+ func ptr[T any](v T) *T { return &v }
12
+
13
+ func TestOptionsGetRequestTimeout(t *testing.T) {
14
+ t.Parallel()
15
+
16
+ tests := []struct {
17
+ name string
18
+ options *Options
19
+ expected time.Duration
20
+ }{
21
+ {
22
+ name: "nil options",
23
+ options: nil,
24
+ expected: DefaultRequestTimeout,
25
+ },
26
+ {
27
+ name: "unset field",
28
+ options: &Options{},
29
+ expected: DefaultRequestTimeout,
30
+ },
31
+ {
32
+ name: "disabled",
33
+ options: &Options{RequestTimeout: ptr(0)},
34
+ expected: 0,
35
+ },
36
+ {
37
+ name: "negative disables too",
38
+ options: &Options{RequestTimeout: ptr(-1)},
39
+ expected: 0,
40
+ },
41
+ {
42
+ name: "seconds to duration",
43
+ options: &Options{RequestTimeout: ptr(300)},
44
+ expected: 5 * time.Minute,
45
+ },
46
+ }
47
+
48
+ for _, tt := range tests {
49
+ t.Run(tt.name, func(t *testing.T) {
50
+ t.Parallel()
51
+ require.Equal(t, tt.expected, tt.options.GetRequestTimeout())
52
+ })
53
+ }
54
+ }
55
+
56
+ func TestOptionsRequestTimeoutFromJSON(t *testing.T) {
57
+ t.Parallel()
58
+
59
+ var cfg Config
60
+ require.NoError(t, json.Unmarshal([]byte(`{"options":{"request_timeout":300}}`), &cfg))
61
+ require.Equal(t, 300, *cfg.Options.RequestTimeout)
62
+ require.Equal(t, 5*time.Minute, cfg.Options.GetRequestTimeout())
63
+ }
internal/config/provider_empty_test.go ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "testing"
7
+
8
+ "charm.land/catwalk/pkg/catwalk"
9
+ "github.com/stretchr/testify/require"
10
+ )
11
+
12
+ type emptyProviderClient struct{}
13
+
14
+ func (m *emptyProviderClient) GetProviders(context.Context, string) ([]catwalk.Provider, error) {
15
+ return []catwalk.Provider{}, nil
16
+ }
17
+
18
+ // TestCatwalkSync_GetEmptyResultFromClient tests that when the client returns
19
+ // an empty list, we fall back to cached providers and return an error.
20
+ func TestCatwalkSync_GetEmptyResultFromClient(t *testing.T) {
21
+ t.Parallel()
22
+
23
+ tmpDir := t.TempDir()
24
+ path := tmpDir + "/providers.json"
25
+
26
+ syncer := &catwalkSync{}
27
+ client := &emptyProviderClient{}
28
+
29
+ syncer.Init(client, path, true)
30
+
31
+ providers, err := syncer.Get(t.Context())
32
+ require.Error(t, err)
33
+ require.Contains(t, err.Error(), "empty providers list from catwalk")
34
+ require.NotEmpty(t, providers) // Should have embedded providers as fallback.
35
+
36
+ // Check that no cache file was created for empty results.
37
+ _, statErr := os.Stat(path)
38
+ require.True(t, os.IsNotExist(statErr), "Cache file should not exist for empty results")
39
+ }
internal/config/recent_models_test.go ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "encoding/json"
5
+ "io/fs"
6
+ "os"
7
+ "path/filepath"
8
+ "testing"
9
+
10
+ "github.com/stretchr/testify/require"
11
+ )
12
+
13
+ // readConfigJSON reads and unmarshals the JSON config file at path.
14
+ func readConfigJSON(t *testing.T, path string) map[string]any {
15
+ t.Helper()
16
+ baseDir := filepath.Dir(path)
17
+ fileName := filepath.Base(path)
18
+ b, err := fs.ReadFile(os.DirFS(baseDir), fileName)
19
+ require.NoError(t, err)
20
+ var out map[string]any
21
+ require.NoError(t, json.Unmarshal(b, &out))
22
+ return out
23
+ }
24
+
25
+ // readRecentModels reads the recent_models section from the config file.
26
+ func readRecentModels(t *testing.T, path string) map[string]any {
27
+ t.Helper()
28
+ out := readConfigJSON(t, path)
29
+ rm, ok := out["recent_models"].(map[string]any)
30
+ require.True(t, ok)
31
+ return rm
32
+ }
33
+
34
+ // testStoreWithPath creates a ConfigStore backed by a Config for recent model tests.
35
+ func testStoreWithPath(cfg *Config, dir string) *ConfigStore {
36
+ return &ConfigStore{
37
+ config: cfg,
38
+ globalDataPath: filepath.Join(dir, "config.json"),
39
+ }
40
+ }
41
+
42
+ // configWithRecents builds a Config seeded with the given recent models for
43
+ // the large type, for exercising the pure nextRecentModels helper.
44
+ func configWithRecents(recents ...SelectedModel) *Config {
45
+ return &Config{
46
+ RecentModels: map[SelectedModelType][]SelectedModel{
47
+ SelectedModelTypeLarge: recents,
48
+ },
49
+ }
50
+ }
51
+
52
+ func TestNextRecentModels_AddsToFront(t *testing.T) {
53
+ t.Parallel()
54
+
55
+ cfg := configWithRecents()
56
+ updated, changed := nextRecentModels(cfg, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})
57
+ require.True(t, changed)
58
+ require.Equal(t, []SelectedModel{{Provider: "openai", Model: "gpt-4o"}}, updated)
59
+ }
60
+
61
+ func TestNextRecentModels_DedupeAndMoveToFront(t *testing.T) {
62
+ t.Parallel()
63
+
64
+ cfg := configWithRecents(
65
+ SelectedModel{Provider: "anthropic", Model: "claude"},
66
+ SelectedModel{Provider: "openai", Model: "gpt-4o"},
67
+ )
68
+ updated, changed := nextRecentModels(cfg, SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})
69
+ require.True(t, changed)
70
+ require.Equal(t, []SelectedModel{
71
+ {Provider: "openai", Model: "gpt-4o"},
72
+ {Provider: "anthropic", Model: "claude"},
73
+ }, updated)
74
+ }
75
+
76
+ func TestNextRecentModels_TrimsToMax(t *testing.T) {
77
+ t.Parallel()
78
+
79
+ var seed []SelectedModel
80
+ for _, id := range []string{"m5", "m4", "m3", "m2", "m1"} {
81
+ seed = append(seed, SelectedModel{Provider: "p", Model: id})
82
+ }
83
+ cfg := configWithRecents(seed...)
84
+
85
+ updated, changed := nextRecentModels(cfg, SelectedModelTypeLarge, SelectedModel{Provider: "p", Model: "m6"})
86
+ require.True(t, changed)
87
+ require.Len(t, updated, maxRecentModelsPerType)
88
+ require.Equal(t, SelectedModel{Provider: "p", Model: "m6"}, updated[0])
89
+ require.Equal(t, SelectedModel{Provider: "p", Model: "m2"}, updated[maxRecentModelsPerType-1])
90
+ }
91
+
92
+ func TestNextRecentModels_SkipsEmptyValues(t *testing.T) {
93
+ t.Parallel()
94
+
95
+ cfg := configWithRecents()
96
+ _, changed := nextRecentModels(cfg, SelectedModelTypeLarge, SelectedModel{Provider: "", Model: "m"})
97
+ require.False(t, changed)
98
+ _, changed = nextRecentModels(cfg, SelectedModelTypeLarge, SelectedModel{Provider: "p", Model: ""})
99
+ require.False(t, changed)
100
+ }
101
+
102
+ func TestNextRecentModels_NoChangeWhenAlreadyFront(t *testing.T) {
103
+ t.Parallel()
104
+
105
+ entry := SelectedModel{Provider: "openai", Model: "gpt-4o"}
106
+ cfg := configWithRecents(entry)
107
+ _, changed := nextRecentModels(cfg, SelectedModelTypeLarge, entry)
108
+ require.False(t, changed)
109
+ }
110
+
111
+ func TestUpdatePreferredModel_PersistsModelAndRecents(t *testing.T) {
112
+ t.Parallel()
113
+
114
+ dir := t.TempDir()
115
+ cfg := &Config{}
116
+ cfg.setDefaults(dir, "")
117
+ store := testStoreWithPath(cfg, dir)
118
+
119
+ sel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
120
+ require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, sel))
121
+
122
+ // in-memory state (read through the store; copy-on-write publishes a
123
+ // new Config, so the seed cfg pointer is intentionally unchanged).
124
+ require.Equal(t, sel, store.Config().Models[SelectedModelTypeLarge])
125
+ require.Len(t, store.Config().RecentModels[SelectedModelTypeLarge], 1)
126
+
127
+ // persisted state
128
+ rm := readRecentModels(t, store.globalDataPath)
129
+ large, ok := rm[string(SelectedModelTypeLarge)].([]any)
130
+ require.True(t, ok)
131
+ require.Len(t, large, 1)
132
+ item := large[0].(map[string]any)
133
+ require.Equal(t, "openai", item["provider"])
134
+ require.Equal(t, "gpt-4o", item["model"])
135
+ }
136
+
137
+ func TestUpdatePreferredModel_TypeIsolation(t *testing.T) {
138
+ t.Parallel()
139
+
140
+ dir := t.TempDir()
141
+ cfg := &Config{}
142
+ cfg.setDefaults(dir, "")
143
+ store := testStoreWithPath(cfg, dir)
144
+
145
+ largeModel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
146
+ smallModel := SelectedModel{Provider: "anthropic", Model: "claude"}
147
+ require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, largeModel))
148
+ require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, smallModel))
149
+
150
+ // Adding to large leaves small untouched.
151
+ anotherLarge := SelectedModel{Provider: "google", Model: "gemini"}
152
+ require.NoError(t, store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, anotherLarge))
153
+
154
+ require.Len(t, store.Config().RecentModels[SelectedModelTypeLarge], 2)
155
+ require.Equal(t, anotherLarge, store.Config().RecentModels[SelectedModelTypeLarge][0])
156
+ require.Len(t, store.Config().RecentModels[SelectedModelTypeSmall], 1)
157
+ require.Equal(t, smallModel, store.Config().RecentModels[SelectedModelTypeSmall][0])
158
+ }
internal/config/reload_crushrc_test.go ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config_test
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "path/filepath"
7
+ "testing"
8
+ "time"
9
+
10
+ "github.com/charmbracelet/crush/internal/config"
11
+ "github.com/stretchr/testify/require"
12
+ )
13
+
14
+ // isolateReloadEnv points HOME/XDG/CRUSH_* at a throwaway directory so a
15
+ // reload only sees the config files the test writes. No t.Parallel(): these
16
+ // tests Setenv.
17
+ func isolateReloadEnv(t *testing.T) (workDir, dataDir string) {
18
+ t.Helper()
19
+ isolated := t.TempDir()
20
+ t.Setenv("HOME", isolated)
21
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(isolated, ".config"))
22
+ t.Setenv("XDG_DATA_HOME", filepath.Join(isolated, ".local", "share"))
23
+ t.Setenv("CRUSH_GLOBAL_CONFIG", filepath.Join(isolated, ".config", "crush"))
24
+ t.Setenv("CRUSH_GLOBAL_DATA", filepath.Join(isolated, ".local", "share", "crush"))
25
+ return t.TempDir(), t.TempDir()
26
+ }
27
+
28
+ // TestReloadFromDisk_PicksUpEditedCrushrc verifies that a crushrc edit on
29
+ // disk is reflected in memory after a reload, matching how JSON config
30
+ // already behaves.
31
+ func TestReloadFromDisk_PicksUpEditedCrushrc(t *testing.T) {
32
+ workDir, dataDir := isolateReloadEnv(t)
33
+ rcPath := filepath.Join(workDir, "crushrc")
34
+ require.NoError(t, os.WriteFile(rcPath, []byte("option notifications bell\n"), 0o644))
35
+
36
+ store, err := config.Load(workDir, dataDir, false)
37
+ require.NoError(t, err)
38
+ require.Equal(t, "bell", store.Config().Options.Notifications)
39
+
40
+ require.NoError(t, os.WriteFile(rcPath, []byte("option notifications osc\n"), 0o644))
41
+ require.NoError(t, store.ReloadFromDisk(context.Background()))
42
+ require.Equal(t, "osc", store.Config().Options.Notifications,
43
+ "reload must re-execute the crushrc and pick up the new value")
44
+ }
45
+
46
+ // TestReloadFromDisk_FailingCrushrcKeepsOldConfig verifies that a crushrc
47
+ // which fails to load during a reload does not clobber the in-memory config.
48
+ // The reload must return an error and leave the previously-loaded config
49
+ // intact (the swap happens only after a successful parse).
50
+ func TestReloadFromDisk_FailingCrushrcKeepsOldConfig(t *testing.T) {
51
+ workDir, dataDir := isolateReloadEnv(t)
52
+ rcPath := filepath.Join(workDir, "crushrc")
53
+ require.NoError(t, os.WriteFile(rcPath, []byte("option notifications bell\n"), 0o644))
54
+
55
+ store, err := config.Load(workDir, dataDir, false)
56
+ require.NoError(t, err)
57
+ require.Equal(t, "bell", store.Config().Options.Notifications)
58
+
59
+ // Overwrite with a crushrc that errors at load time (unknown option key).
60
+ require.NoError(t, os.WriteFile(rcPath, []byte("option totally-bogus-key value\n"), 0o644))
61
+
62
+ err = store.ReloadFromDisk(context.Background())
63
+ require.Error(t, err, "a failing crushrc must surface a reload error")
64
+ require.Equal(t, "bell", store.Config().Options.Notifications,
65
+ "in-memory config must be preserved when a reload fails")
66
+ }
67
+
68
+ // TestReloadFromDisk_HangingCrushrcIsInterruptible is the core regression test
69
+ // for the reload-path blocker: config reloads run while holding the store's
70
+ // write lock, so a crushrc that blocks (busy loop, hung command substitution)
71
+ // must be interruptible via the reload context rather than wedging the store
72
+ // forever. A cancelled reload must return an error and leave the old config
73
+ // in place. The test bounds its own wait so a regression can't hang CI.
74
+ func TestReloadFromDisk_HangingCrushrcIsInterruptible(t *testing.T) {
75
+ workDir, dataDir := isolateReloadEnv(t)
76
+ rcPath := filepath.Join(workDir, "crushrc")
77
+ require.NoError(t, os.WriteFile(rcPath, []byte("option notifications bell\n"), 0o644))
78
+
79
+ store, err := config.Load(workDir, dataDir, false)
80
+ require.NoError(t, err)
81
+ require.Equal(t, "bell", store.Config().Options.Notifications)
82
+
83
+ // Overwrite with a crushrc that never terminates.
84
+ require.NoError(t, os.WriteFile(rcPath, []byte("while true; do :; done\n"), 0o644))
85
+
86
+ ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
87
+ t.Cleanup(cancel)
88
+
89
+ done := make(chan error, 1)
90
+ go func() {
91
+ done <- store.ReloadFromDisk(ctx)
92
+ }()
93
+
94
+ select {
95
+ case err := <-done:
96
+ require.Error(t, err, "a cancelled reload must fail, not succeed")
97
+ case <-time.After(3 * time.Second):
98
+ t.Fatal("ReloadFromDisk did not return after context cancellation; the store would be wedged")
99
+ }
100
+
101
+ require.Equal(t, "bell", store.Config().Options.Notifications,
102
+ "in-memory config must be preserved when a reload is cancelled")
103
+ }
104
+
105
+ // TestLoad_TracksNotYetCreatedGlobalCrushrc verifies that config.Load tracks
106
+ // the global crushrc path even when the file does not exist yet, so a crushrc
107
+ // created after startup is detected as a staleness change. Previously only
108
+ // successfully-loaded paths were tracked, so a mid-session global crushrc went
109
+ // unnoticed until something else triggered a reload.
110
+ //
111
+ // Project-level crushrc files are discovered by walking the tree for existing
112
+ // files, so a not-yet-created project crushrc is still only picked up on the
113
+ // next reload; the global path is the common case this covers.
114
+ func TestLoad_TracksNotYetCreatedGlobalCrushrc(t *testing.T) {
115
+ workDir, dataDir := isolateReloadEnv(t)
116
+ globalRC := filepath.Join(t.TempDir(), "crushrc")
117
+ t.Setenv("CRUSH_GLOBAL_CONFIG", filepath.Dir(globalRC))
118
+
119
+ // A provider must be configured so Load runs past its early
120
+ // "not configured" return and reaches the staleness snapshot capture.
121
+ require.NoError(t, os.WriteFile(
122
+ filepath.Join(workDir, "crushrc"),
123
+ []byte("provider add openai --api-key k\n"), 0o644,
124
+ ))
125
+
126
+ // Load with no global crushrc present.
127
+ store, err := config.Load(workDir, dataDir, false)
128
+ require.NoError(t, err)
129
+ require.False(t, store.ConfigStaleness().Dirty, "fresh load should be clean")
130
+
131
+ // Create the global crushrc after startup.
132
+ require.NoError(t, os.WriteFile(globalRC, []byte("option debug true\n"), 0o644))
133
+
134
+ staleness := store.ConfigStaleness()
135
+ require.True(t, staleness.Dirty, "creating a global crushrc must be detected")
136
+ require.Contains(t, staleness.Changed, globalRC)
137
+ }
internal/config/reload_hooks_test.go ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config_test
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "path/filepath"
7
+ "testing"
8
+
9
+ "github.com/charmbracelet/crush/internal/config"
10
+ "github.com/charmbracelet/crush/internal/hooks"
11
+ "github.com/stretchr/testify/require"
12
+ )
13
+
14
+ // TestReloadFromDisk_CompilesHookMatchers is a regression test for a bug
15
+ // where ReloadFromDisk dropped the compiled matcher regex on every hook,
16
+ // causing a matcher like "^bash$" to match every tool call after any
17
+ // SetConfigField-triggered reload.
18
+ //
19
+ // The assertion is phrased in terms of observable Runner behavior (not
20
+ // internal field presence) so it stays valid if the Runner later owns
21
+ // matcher compilation itself.
22
+ func TestReloadFromDisk_CompilesHookMatchers(t *testing.T) {
23
+ // No t.Parallel(): we Setenv HOME/XDG_CONFIG_HOME to isolate from the
24
+ // developer's real global config, which may define its own hooks.
25
+ isolated := t.TempDir()
26
+ t.Setenv("HOME", isolated)
27
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(isolated, ".config"))
28
+ t.Setenv("XDG_DATA_HOME", filepath.Join(isolated, ".local", "share"))
29
+
30
+ workDir := t.TempDir()
31
+ dataDir := t.TempDir()
32
+ configPath := filepath.Join(workDir, "crush.json")
33
+ cfgJSON := `{
34
+ "hooks": {
35
+ "PreToolUse": [
36
+ {"matcher": "^bash$", "command": "exit 0"}
37
+ ]
38
+ }
39
+ }`
40
+ require.NoError(t, os.WriteFile(configPath, []byte(cfgJSON), 0o600))
41
+
42
+ store, err := config.Load(workDir, dataDir, false)
43
+ require.NoError(t, err)
44
+
45
+ // Sanity: hook filtering works immediately after Load.
46
+ assertHookFilters(t, store)
47
+
48
+ require.NoError(t, store.ReloadFromDisk(context.Background()))
49
+
50
+ // The actual regression check: filtering must still work after a
51
+ // reload, not silently collapse to match-everything.
52
+ assertHookFilters(t, store)
53
+ }
54
+
55
+ // assertHookFilters builds a Runner from the store's current hooks and
56
+ // verifies the "^bash$" matcher rejects a non-bash tool while accepting
57
+ // bash.
58
+ func assertHookFilters(t *testing.T, store *config.ConfigStore) {
59
+ t.Helper()
60
+ preHooks := store.Config().Hooks[hooks.EventPreToolUse]
61
+ require.Len(t, preHooks, 1)
62
+
63
+ runner := hooks.NewRunner(preHooks, t.TempDir(), t.TempDir())
64
+
65
+ nonMatch, err := runner.Run(context.Background(), hooks.EventPreToolUse, "sess", "view", `{}`)
66
+ require.NoError(t, err)
67
+ require.Equal(t, 0, nonMatch.HookCount, "view must not match ^bash$ matcher")
68
+
69
+ match, err := runner.Run(context.Background(), hooks.EventPreToolUse, "sess", "bash", `{}`)
70
+ require.NoError(t, err)
71
+ require.Equal(t, 1, match.HookCount, "bash must match ^bash$ matcher")
72
+ }
73
+
74
+ // TestSetConfigField_AutoReload_PreservesHookMatcherFiltering verifies the
75
+ // dominant real-world trigger path: config writes call autoReload,
76
+ // autoReload calls ReloadFromDisk, and hook matching must remain correct.
77
+ func TestSetConfigField_AutoReload_PreservesHookMatcherFiltering(t *testing.T) {
78
+ isolated := t.TempDir()
79
+ t.Setenv("HOME", isolated)
80
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(isolated, ".config"))
81
+ t.Setenv("XDG_DATA_HOME", filepath.Join(isolated, ".local", "share"))
82
+
83
+ workDir := t.TempDir()
84
+ dataDir := t.TempDir()
85
+ configPath := filepath.Join(workDir, "crush.json")
86
+ cfgJSON := `{
87
+ "hooks": {
88
+ "PreToolUse": [
89
+ {"matcher": "^bash$", "command": "exit 0"}
90
+ ]
91
+ }
92
+ }`
93
+ require.NoError(t, os.WriteFile(configPath, []byte(cfgJSON), 0o600))
94
+
95
+ store, err := config.Load(workDir, dataDir, false)
96
+ require.NoError(t, err)
97
+ assertHookFilters(t, store)
98
+
99
+ require.NoError(t, store.SetConfigField(config.ScopeGlobal, "options.debug", true))
100
+
101
+ assertHookFilters(t, store)
102
+ }
internal/config/resolve_real_test.go ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "fmt"
5
+ "maps"
6
+ "os"
7
+ "path/filepath"
8
+ "slices"
9
+ "strings"
10
+ "testing"
11
+
12
+ "github.com/charmbracelet/crush/internal/env"
13
+ "github.com/stretchr/testify/require"
14
+ )
15
+
16
+ // These tests exercise the full shell-expansion path (no mocks,
17
+ // no injected Expander) to catch regressions that only surface when
18
+ // internal/shell actually runs the value. Table-level unit tests with
19
+ // fake expanders live in resolve_test.go.
20
+
21
+ // realShellResolver builds a resolver backed by a shell env that
22
+ // contains PATH + the caller-supplied overrides. Production callers
23
+ // get PATH for free via env.New(); these tests need it so $(cat ...)
24
+ // and similar inner commands can resolve.
25
+ func realShellResolver(vars map[string]string) VariableResolver {
26
+ m := map[string]string{"PATH": os.Getenv("PATH")}
27
+ maps.Copy(m, vars)
28
+ return NewShellVariableResolver(env.NewFromMap(m))
29
+ }
30
+
31
+ func writeTempFile(t *testing.T, content string) string {
32
+ t.Helper()
33
+ dir := t.TempDir()
34
+ p := filepath.Join(dir, "secret")
35
+ require.NoError(t, os.WriteFile(p, []byte(content), 0o600))
36
+ return p
37
+ }
38
+
39
+ // TestResolvedEnv_RealShell_Success covers the shell constructs the
40
+ // PLAN calls out: $(cat tempfile) with and without trailing newline,
41
+ // ${VAR:-default} for unset vars, literal-space preservation around
42
+ // $(...), nested parens, quoted args inside $(echo ...), and a
43
+ // glob-like literal round-tripping unchanged.
44
+ func TestResolvedEnv_RealShell_Success(t *testing.T) {
45
+ t.Parallel()
46
+
47
+ // filepath.ToSlash so Windows temp paths (C:\Users\...) survive
48
+ // being injected into a shell command string — the embedded shell
49
+ // treats backslashes as escapes, forward slashes work on every OS.
50
+ withNL := filepath.ToSlash(writeTempFile(t, "token-with-nl\n"))
51
+ noNL := filepath.ToSlash(writeTempFile(t, "token-no-nl"))
52
+
53
+ m := MCPConfig{
54
+ Env: map[string]string{
55
+ // POSIX strips exactly one trailing newline from $(...)
56
+ // output, so both forms land on the same value.
57
+ "TOK_NL": fmt.Sprintf("$(cat %s)", withNL),
58
+ "TOK_NO": fmt.Sprintf("$(cat %s)", noNL),
59
+
60
+ // ${VAR:-default} must not error on unset: this is the
61
+ // opt-in escape hatch for "empty is fine".
62
+ "FALLBACK": "${MCP_MISSING:-fallback}",
63
+
64
+ // Leading/trailing literal spaces around $(...) must be
65
+ // preserved — single-value contract, no field splitting.
66
+ "PADDED": " $(echo v) ",
67
+
68
+ // ")" inside a quoted arg to echo is a regression guard
69
+ // for the old hand-rolled paren matcher.
70
+ "PAREN": `$(echo ")")`,
71
+
72
+ // Embedded space inside a quoted arg must survive
73
+ // verbatim; no word-splitting side effect.
74
+ "SPACEY": `$(echo "a b")`,
75
+
76
+ // Glob-like literals must not expand.
77
+ "GLOB": "*.go",
78
+ },
79
+ }
80
+
81
+ got, err := m.ResolvedEnv(realShellResolver(nil))
82
+ require.NoError(t, err)
83
+
84
+ // ResolvedEnv returns "KEY=value" sorted by key.
85
+ want := []string{
86
+ "FALLBACK=fallback",
87
+ "GLOB=*.go",
88
+ "PADDED= v ",
89
+ "PAREN=)",
90
+ "SPACEY=a b",
91
+ "TOK_NL=token-with-nl",
92
+ "TOK_NO=token-no-nl",
93
+ }
94
+ require.Equal(t, want, got)
95
+ }
96
+
97
+ // TestResolvedEnv_RealShell_DoesNotMutate pins that both success and
98
+ // failure paths leave m.Env untouched. Prior behaviour rewrote the
99
+ // value in place on error; that was the exact mechanism that shipped
100
+ // empty credentials to MCP servers.
101
+ func TestResolvedEnv_RealShell_DoesNotMutate(t *testing.T) {
102
+ t.Parallel()
103
+
104
+ t.Run("success path leaves Env untouched", func(t *testing.T) {
105
+ t.Parallel()
106
+ m := MCPConfig{Env: map[string]string{"TOKEN": "$(echo shh)"}}
107
+ orig := maps.Clone(m.Env)
108
+
109
+ _, err := m.ResolvedEnv(realShellResolver(nil))
110
+ require.NoError(t, err)
111
+ require.Equal(t, orig, m.Env)
112
+ })
113
+
114
+ t.Run("failure path leaves Env untouched", func(t *testing.T) {
115
+ t.Parallel()
116
+ m := MCPConfig{Env: map[string]string{"BROKEN": "$(false)"}}
117
+ orig := maps.Clone(m.Env)
118
+
119
+ _, err := m.ResolvedEnv(realShellResolver(nil))
120
+ require.Error(t, err)
121
+ require.Equal(t, orig, m.Env, "map must be preserved on error")
122
+ })
123
+ }
124
+
125
+ // TestResolvedEnv_RealShell_Idempotent pins the pure-function contract:
126
+ // two calls on the same config return deeply-equal slices.
127
+ func TestResolvedEnv_RealShell_Idempotent(t *testing.T) {
128
+ t.Parallel()
129
+
130
+ m := MCPConfig{
131
+ Env: map[string]string{
132
+ "A": "$(echo one)",
133
+ "B": "$(echo two)",
134
+ "C": "literal",
135
+ },
136
+ }
137
+ r := realShellResolver(nil)
138
+
139
+ first, err := m.ResolvedEnv(r)
140
+ require.NoError(t, err)
141
+ second, err := m.ResolvedEnv(r)
142
+ require.NoError(t, err)
143
+ require.Equal(t, first, second)
144
+ }
145
+
146
+ // TestResolvedEnv_RealShell_Deterministic guards against Go's
147
+ // randomized map iteration leaking into the returned slice order.
148
+ func TestResolvedEnv_RealShell_Deterministic(t *testing.T) {
149
+ t.Parallel()
150
+
151
+ m := MCPConfig{Env: map[string]string{
152
+ "Z": "z",
153
+ "A": "a",
154
+ "M": "m",
155
+ }}
156
+
157
+ got, err := m.ResolvedEnv(realShellResolver(nil))
158
+ require.NoError(t, err)
159
+ require.True(t, slices.IsSorted(got), "env slice must be sorted; got %v", got)
160
+ }
161
+
162
+ // TestResolvedEnv_RealShell_UnsetExpandsEmpty pins the lenient
163
+ // default: an unset bare $VAR expands to the empty string, matching
164
+ // bash. The silent-empty-credential class of bug is prevented by the
165
+ // pure-function error-returning contract of ResolvedEnv, so we don't
166
+ // rely on nounset to catch typo'd variable names. Users who want
167
+ // strict behaviour for a required credential opt in per-reference
168
+ // with ${VAR:?msg}; see TestResolvedEnv_RealShell_ColonQuestionIsStrict.
169
+ func TestResolvedEnv_RealShell_UnsetExpandsEmpty(t *testing.T) {
170
+ t.Parallel()
171
+
172
+ m := MCPConfig{Env: map[string]string{
173
+ // Intentional typo: user meant $FORGEJO_TOKEN. Under the
174
+ // lenient default this expands to "" rather than erroring,
175
+ // matching bash's behaviour on bare $VAR.
176
+ "FORGEJO_ACCESS_TOKEN": "$FORGJO_TOKEN",
177
+ }}
178
+ got, err := m.ResolvedEnv(realShellResolver(nil))
179
+ require.NoError(t, err, "unset var must expand to empty, not error")
180
+ require.Equal(t, []string{"FORGEJO_ACCESS_TOKEN="}, got)
181
+ }
182
+
183
+ // TestResolvedEnv_RealShell_ColonQuestionIsStrict pins the opt-in
184
+ // strictness contract: ${VAR:?msg} must hard-error when VAR is unset,
185
+ // regardless of the global NoUnset toggle. This is the recommended
186
+ // mechanism for required credentials under the lenient default, so a
187
+ // future refactor that accidentally swallows ${VAR:?...} errors would
188
+ // silently ship empty tokens to MCP servers again.
189
+ func TestResolvedEnv_RealShell_ColonQuestionIsStrict(t *testing.T) {
190
+ t.Parallel()
191
+
192
+ m := MCPConfig{Env: map[string]string{
193
+ "FORGEJO_ACCESS_TOKEN": "${FORGJO_TOKEN:?set FORGJO_TOKEN}",
194
+ }}
195
+ got, err := m.ResolvedEnv(realShellResolver(nil))
196
+ require.Error(t, err, "${VAR:?msg} must error when VAR is unset")
197
+ require.Nil(t, got)
198
+ // The resolver wraps with the env key and the user-written
199
+ // template; the inner shell error carries the :? message so
200
+ // users learn which credential is missing and why.
201
+ msg := err.Error()
202
+ require.Contains(t, msg, "FORGEJO_ACCESS_TOKEN")
203
+ require.Contains(t, msg, "${FORGJO_TOKEN:?set FORGJO_TOKEN}")
204
+ require.Contains(t, msg, "set FORGJO_TOKEN")
205
+ }
206
+
207
+ // TestResolvedEnv_RealShell_FailureDetail pins that a failing inner
208
+ // command surfaces enough detail (exit code + stderr on POSIX, the
209
+ // underlying OS error on Windows where coreutils runs in-process) to
210
+ // diagnose without forcing the user to re-run the command by hand.
211
+ // Also verifies the template is included so they know which Env
212
+ // entry blew up.
213
+ func TestResolvedEnv_RealShell_FailureDetail(t *testing.T) {
214
+ t.Parallel()
215
+
216
+ // Forward slashes so the path survives shell-string injection on
217
+ // Windows; see TestResolvedEnv_RealShell_Success for the same note.
218
+ missing := filepath.ToSlash(filepath.Join(t.TempDir(), "definitely-not-here"))
219
+ m := MCPConfig{Env: map[string]string{
220
+ "FORGEJO_ACCESS_TOKEN": fmt.Sprintf("$(cat %s)", missing),
221
+ }}
222
+
223
+ _, err := m.ResolvedEnv(realShellResolver(nil))
224
+ require.Error(t, err)
225
+ msg := err.Error()
226
+ require.Contains(t, msg, "FORGEJO_ACCESS_TOKEN", "must identify the failing env var")
227
+ require.Contains(t, msg, missing, "must include the template so users see what failed")
228
+
229
+ // Inner diagnostic detail must survive. POSIX surfaces "exit
230
+ // status N" + stderr; Windows' in-process coreutils surfaces the
231
+ // Go OS error instead. Accept either shape so the test is
232
+ // portable without weakening the intent.
233
+ lower := strings.ToLower(msg)
234
+ hasDetail := strings.Contains(lower, "exit status") ||
235
+ strings.Contains(lower, "no such file") ||
236
+ strings.Contains(lower, "cannot find")
237
+ require.True(t, hasDetail, "must surface inner error detail: %s", msg)
238
+ }
239
+
240
+ // TestResolvedHeaders_RealShell_FailurePreservesOriginal pins two
241
+ // invariants simultaneously: on failure the returned map is nil (not
242
+ // a partially-populated map) and the receiver's Headers map is
243
+ // unchanged. A test that only asserted on the returned value could
244
+ // hide an in-place mutation regression.
245
+ func TestResolvedHeaders_RealShell_FailurePreservesOriginal(t *testing.T) {
246
+ t.Parallel()
247
+
248
+ m := MCPConfig{Headers: map[string]string{
249
+ "Authorization": "Bearer $(false)",
250
+ "X-Static": "kept",
251
+ }}
252
+ orig := maps.Clone(m.Headers)
253
+
254
+ got, err := m.ResolvedHeaders(realShellResolver(nil))
255
+ require.Error(t, err)
256
+ require.Nil(t, got, "headers map must be nil on failure")
257
+ require.Contains(t, err.Error(), "header Authorization")
258
+ require.Equal(t, orig, m.Headers, "receiver Headers must be preserved")
259
+ }
260
+
261
+ // TestResolvedHeaders_RealShell_DropEmpty verifies that a header
262
+ // whose value resolves to the empty string is omitted from the
263
+ // returned map. Covers the three ways a value can legitimately land
264
+ // on empty — unset bare $VAR under lenient nounset, a literal "",
265
+ // and a non-failing command whose stdout is empty — and also pins
266
+ // that a failing $(cmd) still errors rather than silently dropping.
267
+ func TestResolvedHeaders_RealShell_DropEmpty(t *testing.T) {
268
+ t.Parallel()
269
+
270
+ t.Run("unset $VAR is absent", func(t *testing.T) {
271
+ t.Parallel()
272
+ m := MCPConfig{Headers: map[string]string{
273
+ "X-Missing": "$MCP_HEADER_NEVER_SET",
274
+ "X-Kept": "present",
275
+ }}
276
+ got, err := m.ResolvedHeaders(realShellResolver(nil))
277
+ require.NoError(t, err)
278
+ _, present := got["X-Missing"]
279
+ require.False(t, present, "unset bare $VAR → empty → header dropped")
280
+ require.Equal(t, "present", got["X-Kept"])
281
+ })
282
+
283
+ t.Run("literal empty string is absent", func(t *testing.T) {
284
+ t.Parallel()
285
+ m := MCPConfig{Headers: map[string]string{
286
+ "X-Custom": "",
287
+ "X-Kept": "present",
288
+ }}
289
+ got, err := m.ResolvedHeaders(realShellResolver(nil))
290
+ require.NoError(t, err)
291
+ _, present := got["X-Custom"]
292
+ require.False(t, present, "literal empty-string header must be dropped")
293
+ require.Equal(t, "present", got["X-Kept"])
294
+ })
295
+
296
+ t.Run("$(echo) is absent", func(t *testing.T) {
297
+ t.Parallel()
298
+ m := MCPConfig{Headers: map[string]string{
299
+ "X-Empty": "$(echo)",
300
+ "X-Kept": "present",
301
+ }}
302
+ got, err := m.ResolvedHeaders(realShellResolver(nil))
303
+ require.NoError(t, err)
304
+ _, present := got["X-Empty"]
305
+ require.False(t, present, "$(echo) → empty → header dropped")
306
+ require.Equal(t, "present", got["X-Kept"])
307
+ })
308
+
309
+ t.Run("$(false) errors and does not mutate", func(t *testing.T) {
310
+ t.Parallel()
311
+ m := MCPConfig{Headers: map[string]string{
312
+ "X-Broken": "$(false)",
313
+ "X-Kept": "present",
314
+ }}
315
+ orig := maps.Clone(m.Headers)
316
+
317
+ got, err := m.ResolvedHeaders(realShellResolver(nil))
318
+ require.Error(t, err)
319
+ require.Empty(t, got, "map must be nil/empty on failure, not a partial")
320
+ require.Contains(t, err.Error(), "header X-Broken")
321
+ require.Equal(t, orig, m.Headers, "receiver Headers must be preserved")
322
+ })
323
+ }
324
+
325
+ // TestResolvedArgs_RealShell exercises both success and failure for
326
+ // m.Args symmetrically with Env. Args are ordered so error messages
327
+ // must identify a positional index, not a key.
328
+ func TestResolvedArgs_RealShell(t *testing.T) {
329
+ t.Parallel()
330
+
331
+ t.Run("success expands each element", func(t *testing.T) {
332
+ t.Parallel()
333
+ m := MCPConfig{Args: []string{"--token", "$(echo shh)", "--host", "example.com"}}
334
+ got, err := m.ResolvedArgs(realShellResolver(nil))
335
+ require.NoError(t, err)
336
+ require.Equal(t, []string{"--token", "shh", "--host", "example.com"}, got)
337
+ })
338
+
339
+ t.Run("failure identifies offending index", func(t *testing.T) {
340
+ t.Parallel()
341
+ m := MCPConfig{Args: []string{"--token", "$(false)"}}
342
+ orig := slices.Clone(m.Args)
343
+
344
+ got, err := m.ResolvedArgs(realShellResolver(nil))
345
+ require.Error(t, err)
346
+ require.Nil(t, got)
347
+ require.Contains(t, err.Error(), "arg 1")
348
+ require.Equal(t, orig, m.Args, "receiver Args must be preserved")
349
+ })
350
+
351
+ t.Run("nil args returns nil, no error", func(t *testing.T) {
352
+ t.Parallel()
353
+ m := MCPConfig{}
354
+ got, err := m.ResolvedArgs(realShellResolver(nil))
355
+ require.NoError(t, err)
356
+ require.Nil(t, got)
357
+ })
358
+ }
359
+
360
+ // TestLSPConfig_ResolvedArgs_RealShell exercises both success and
361
+ // failure for l.Args symmetrically with MCP args. Args are ordered so
362
+ // error messages must identify a positional index, not a key.
363
+ func TestLSPConfig_ResolvedArgs_RealShell(t *testing.T) {
364
+ t.Parallel()
365
+
366
+ t.Run("success expands $VAR in each element", func(t *testing.T) {
367
+ t.Parallel()
368
+ l := LSPConfig{Args: []string{"--root", "$HOME", "--flag", "literal"}}
369
+ r := realShellResolver(map[string]string{"HOME": "/home/tester"})
370
+ got, err := l.ResolvedArgs(r)
371
+ require.NoError(t, err)
372
+ require.Equal(t, []string{"--root", "/home/tester", "--flag", "literal"}, got)
373
+ })
374
+
375
+ t.Run("failure identifies offending index", func(t *testing.T) {
376
+ t.Parallel()
377
+ l := LSPConfig{Args: []string{"--root", "$(false)"}}
378
+ orig := slices.Clone(l.Args)
379
+
380
+ got, err := l.ResolvedArgs(realShellResolver(nil))
381
+ require.Error(t, err)
382
+ require.Nil(t, got)
383
+ require.Contains(t, err.Error(), "arg 1")
384
+ require.Equal(t, orig, l.Args, "receiver Args must be preserved")
385
+ })
386
+
387
+ t.Run("nil args returns nil, no error", func(t *testing.T) {
388
+ t.Parallel()
389
+ l := LSPConfig{}
390
+ got, err := l.ResolvedArgs(realShellResolver(nil))
391
+ require.NoError(t, err)
392
+ require.Nil(t, got)
393
+ })
394
+ }
395
+
396
+ // TestLSPConfig_ResolvedEnv_RealShell pins the LSP env contract:
397
+ // success expands $VAR, failure wraps with the key name, and the
398
+ // receiver map is never mutated. The shape is map[string]string
399
+ // (not the MCP []string form) because powernap.ClientConfig.Environment
400
+ // takes a map directly.
401
+ func TestLSPConfig_ResolvedEnv_RealShell(t *testing.T) {
402
+ t.Parallel()
403
+
404
+ t.Run("success expands $VAR", func(t *testing.T) {
405
+ t.Parallel()
406
+ l := LSPConfig{Env: map[string]string{"GOPATH": "$HOME/go"}}
407
+ r := realShellResolver(map[string]string{"HOME": "/home/tester"})
408
+ got, err := l.ResolvedEnv(r)
409
+ require.NoError(t, err)
410
+ require.Equal(t, map[string]string{"GOPATH": "/home/tester/go"}, got)
411
+ })
412
+
413
+ t.Run("failure identifies offending key", func(t *testing.T) {
414
+ t.Parallel()
415
+ l := LSPConfig{Env: map[string]string{
416
+ "GOPATH": "$(false)",
417
+ "OTHER": "literal",
418
+ }}
419
+ orig := maps.Clone(l.Env)
420
+
421
+ got, err := l.ResolvedEnv(realShellResolver(nil))
422
+ require.Error(t, err)
423
+ require.Nil(t, got)
424
+ require.Contains(t, err.Error(), `env "GOPATH"`)
425
+ require.Equal(t, orig, l.Env, "receiver Env must be preserved")
426
+ })
427
+
428
+ t.Run("idempotent and non-mutating", func(t *testing.T) {
429
+ t.Parallel()
430
+ l := LSPConfig{Env: map[string]string{
431
+ "A": "$(echo one)",
432
+ "B": "literal",
433
+ }}
434
+ orig := maps.Clone(l.Env)
435
+ r := realShellResolver(nil)
436
+
437
+ first, err := l.ResolvedEnv(r)
438
+ require.NoError(t, err)
439
+ second, err := l.ResolvedEnv(r)
440
+ require.NoError(t, err)
441
+ require.Equal(t, first, second)
442
+ require.Equal(t, orig, l.Env, "receiver Env must be preserved")
443
+ })
444
+ }
445
+
446
+ // TestLSPConfig_IdentityResolver pins the client-mode contract: both
447
+ // ResolvedArgs and ResolvedEnv round-trip the template verbatim under
448
+ // IdentityResolver and never error on unset variables. Local
449
+ // expansion would double-expand when the server does its own — this
450
+ // has to stay a pure pass-through.
451
+ func TestLSPConfig_IdentityResolver(t *testing.T) {
452
+ t.Parallel()
453
+
454
+ l := LSPConfig{
455
+ Args: []string{"--root", "$LSP_ROOT", "$(vault read -f lsp)"},
456
+ Env: map[string]string{
457
+ "GOPATH": "$HOME/go",
458
+ "TOKEN": "$(cat /run/secrets/x)",
459
+ },
460
+ }
461
+ r := IdentityResolver()
462
+
463
+ args, err := l.ResolvedArgs(r)
464
+ require.NoError(t, err)
465
+ require.Equal(t, l.Args, args)
466
+
467
+ envs, err := l.ResolvedEnv(r)
468
+ require.NoError(t, err)
469
+ require.Equal(t, l.Env, envs)
470
+ }
471
+
472
+ // TestMCPConfig_IdentityResolver pins the client-mode contract: every
473
+ // Resolved* method round-trips the template verbatim and never errors
474
+ // on unset variables. Local expansion would double-expand when the
475
+ // server does its own — this has to stay a pure pass-through.
476
+ func TestMCPConfig_IdentityResolver(t *testing.T) {
477
+ t.Parallel()
478
+
479
+ m := MCPConfig{
480
+ Command: "$CMD",
481
+ Args: []string{"--token", "$MCP_MISSING_TOKEN", "$(vault read -f secret)"},
482
+ Env: map[string]string{
483
+ "TOKEN": "$(cat /run/secrets/x)",
484
+ "HOST": "$MCP_MISSING_HOST",
485
+ },
486
+ Headers: map[string]string{
487
+ "Authorization": "Bearer $(vault read -f token)",
488
+ },
489
+ URL: "https://$MCP_HOST/$(vault read -f path)",
490
+ }
491
+ r := IdentityResolver()
492
+
493
+ args, err := m.ResolvedArgs(r)
494
+ require.NoError(t, err)
495
+ require.Equal(t, m.Args, args)
496
+
497
+ envs, err := m.ResolvedEnv(r)
498
+ require.NoError(t, err)
499
+ // Sorted "KEY=value".
500
+ require.Equal(t, []string{
501
+ "HOST=$MCP_MISSING_HOST",
502
+ "TOKEN=$(cat /run/secrets/x)",
503
+ }, envs)
504
+
505
+ headers, err := m.ResolvedHeaders(r)
506
+ require.NoError(t, err)
507
+ require.Equal(t, m.Headers, headers)
508
+
509
+ u, err := m.ResolvedURL(r)
510
+ require.NoError(t, err)
511
+ require.Equal(t, m.URL, u)
512
+ }
internal/config/resolve_test.go ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "strings"
7
+ "testing"
8
+
9
+ "github.com/charmbracelet/crush/internal/env"
10
+ "github.com/stretchr/testify/require"
11
+ )
12
+
13
+ // fakeExpander returns a canned value/error for the last passed value and
14
+ // records the context, raw value, and env slice it was called with. It
15
+ // lets the config-layer tests assert on delegation behaviour without
16
+ // spinning up a real interpreter — real-shell coverage lives in
17
+ // internal/shell/expand_test.go and resolve_real_test.go.
18
+ type fakeExpander struct {
19
+ expand func(ctx context.Context, value string, env []string) (string, error)
20
+ lastValue string
21
+ lastEnv []string
22
+ calls int
23
+ }
24
+
25
+ func (f *fakeExpander) Expand(ctx context.Context, value string, env []string) (string, error) {
26
+ f.calls++
27
+ f.lastValue = value
28
+ f.lastEnv = env
29
+ if f.expand == nil {
30
+ return value, nil
31
+ }
32
+ return f.expand(ctx, value, env)
33
+ }
34
+
35
+ func TestShellVariableResolver_DelegatesToExpander(t *testing.T) {
36
+ t.Parallel()
37
+
38
+ fe := &fakeExpander{
39
+ expand: func(_ context.Context, value string, _ []string) (string, error) {
40
+ if value == "hello $FOO" {
41
+ return "hello bar", nil
42
+ }
43
+ return value, nil
44
+ },
45
+ }
46
+
47
+ e := env.NewFromMap(map[string]string{"FOO": "bar"})
48
+ r := NewShellVariableResolver(e, WithExpander(fe.Expand))
49
+
50
+ got, err := r.ResolveValue("hello $FOO")
51
+ require.NoError(t, err)
52
+ require.Equal(t, "hello bar", got)
53
+ require.Equal(t, 1, fe.calls)
54
+ require.Equal(t, "hello $FOO", fe.lastValue)
55
+ require.Contains(t, fe.lastEnv, "FOO=bar")
56
+ }
57
+
58
+ func TestShellVariableResolver_LoneDollarIsError(t *testing.T) {
59
+ t.Parallel()
60
+
61
+ // Lone "$" must short-circuit before reaching the expander: the
62
+ // underlying shell parser would accept it as a literal, but this
63
+ // resolver has historically rejected it and callers depend on
64
+ // that early-fail behaviour.
65
+ fe := &fakeExpander{}
66
+ r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
67
+
68
+ _, err := r.ResolveValue("$")
69
+ require.Error(t, err)
70
+ require.Equal(t, 0, fe.calls, "expander must not be called for lone $")
71
+ }
72
+
73
+ func TestShellVariableResolver_PassesThroughLiterals(t *testing.T) {
74
+ t.Parallel()
75
+
76
+ fe := &fakeExpander{
77
+ expand: func(_ context.Context, value string, _ []string) (string, error) {
78
+ return value, nil
79
+ },
80
+ }
81
+ r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
82
+
83
+ got, err := r.ResolveValue("plain-string")
84
+ require.NoError(t, err)
85
+ require.Equal(t, "plain-string", got)
86
+ }
87
+
88
+ func TestShellVariableResolver_WrapsErrorsWithTemplate(t *testing.T) {
89
+ t.Parallel()
90
+
91
+ inner := errors.New("cat: /run/secrets/x: permission denied")
92
+ fe := &fakeExpander{
93
+ expand: func(_ context.Context, _ string, _ []string) (string, error) {
94
+ return "", inner
95
+ },
96
+ }
97
+ r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
98
+
99
+ _, err := r.ResolveValue("$(cat /run/secrets/x)")
100
+ require.Error(t, err)
101
+ require.ErrorIs(t, err, inner)
102
+ require.Contains(t, err.Error(), "$(cat /run/secrets/x)")
103
+ require.Contains(t, err.Error(), "permission denied")
104
+ }
105
+
106
+ func TestSanitizeResolveError(t *testing.T) {
107
+ t.Parallel()
108
+
109
+ t.Run("nil passes through", func(t *testing.T) {
110
+ t.Parallel()
111
+ require.NoError(t, sanitizeResolveError("anything", nil))
112
+ })
113
+
114
+ t.Run("includes template and wraps inner", func(t *testing.T) {
115
+ t.Parallel()
116
+ inner := errors.New("cat: /run/secrets/x: permission denied")
117
+ got := sanitizeResolveError("$(cat /run/secrets/x)", inner)
118
+ require.Error(t, got)
119
+ require.ErrorIs(t, got, inner)
120
+ require.Contains(t, got.Error(), "$(cat /run/secrets/x)")
121
+ require.Contains(t, got.Error(), "permission denied")
122
+ })
123
+
124
+ t.Run("unwrap preserves original for errors.Is", func(t *testing.T) {
125
+ t.Parallel()
126
+ inner := errors.New("sentinel")
127
+ got := sanitizeResolveError("$FOO", inner)
128
+ require.ErrorIs(t, got, inner)
129
+ })
130
+
131
+ t.Run("truncates over-budget inner message", func(t *testing.T) {
132
+ t.Parallel()
133
+ // Inner message holds far more than the budget. After
134
+ // sanitization the rendered inner portion must not exceed
135
+ // maxResolveErrBytes, and the characters beyond the budget
136
+ // (marked by a distinct tail sentinel) must be gone.
137
+ const tailSentinel = "TAIL_SENTINEL_BEYOND_BUDGET"
138
+ body := strings.Repeat("x", maxResolveErrBytes)
139
+ inner := errors.New(body + tailSentinel)
140
+
141
+ got := sanitizeResolveError("$TEMPLATE", inner)
142
+ require.Error(t, got)
143
+
144
+ prefix := `resolving "$TEMPLATE": `
145
+ rendered := got.Error()
146
+ require.True(
147
+ t,
148
+ strings.HasPrefix(rendered, prefix),
149
+ "rendered error must start with template prefix",
150
+ )
151
+ innerRendered := strings.TrimPrefix(rendered, prefix)
152
+ require.LessOrEqual(
153
+ t,
154
+ len(innerRendered),
155
+ maxResolveErrBytes,
156
+ "inner message must be bounded to maxResolveErrBytes",
157
+ )
158
+ require.NotContains(
159
+ t,
160
+ rendered,
161
+ tailSentinel,
162
+ "content past the budget must not leak",
163
+ )
164
+ })
165
+
166
+ t.Run("replaces non-printable bytes", func(t *testing.T) {
167
+ t.Parallel()
168
+ // NUL, BEL, ESC, DEL, and a UTF-8 high byte should all be
169
+ // scrubbed to '?'. Tab and newline are preserved because
170
+ // they show up legitimately in command stderr.
171
+ inner := errors.New("ok\x00bad\x07\x1b\x7f\xffend\ttab\nline")
172
+ got := sanitizeResolveError("$T", inner)
173
+ rendered := got.Error()
174
+
175
+ require.NotContains(t, rendered, "\x00")
176
+ require.NotContains(t, rendered, "\x07")
177
+ require.NotContains(t, rendered, "\x1b")
178
+ require.NotContains(t, rendered, "\x7f")
179
+ require.NotContains(t, rendered, "\xff")
180
+ require.Contains(t, rendered, "ok?bad????end\ttab\nline")
181
+ })
182
+
183
+ t.Run("scrubbing does not depend on shell.ExpandValue upstream", func(t *testing.T) {
184
+ t.Parallel()
185
+ // A custom Expander can inject arbitrary error text. The
186
+ // config-layer helper is the single chokepoint; it must
187
+ // bound + scrub regardless of the error source.
188
+ nasty := strings.Repeat("A", maxResolveErrBytes+64) + "\x00BEYOND"
189
+ fe := &fakeExpander{
190
+ expand: func(_ context.Context, _ string, _ []string) (string, error) {
191
+ return "", errors.New(nasty)
192
+ },
193
+ }
194
+ r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
195
+
196
+ _, err := r.ResolveValue("$T")
197
+ require.Error(t, err)
198
+ require.NotContains(t, err.Error(), "BEYOND", "over-budget tail must not leak")
199
+ require.NotContains(t, err.Error(), "\x00", "non-printables must be scrubbed")
200
+ })
201
+ }
202
+
203
+ func TestScrubErrorMessage(t *testing.T) {
204
+ t.Parallel()
205
+
206
+ t.Run("bounds output to maxResolveErrBytes", func(t *testing.T) {
207
+ t.Parallel()
208
+ got := scrubErrorMessage(strings.Repeat("a", maxResolveErrBytes*3))
209
+ require.Len(t, got, maxResolveErrBytes)
210
+ })
211
+
212
+ t.Run("preserves printable ASCII tab and newline", func(t *testing.T) {
213
+ t.Parallel()
214
+ require.Equal(t, "a\tb\nc d!", scrubErrorMessage("a\tb\nc d!"))
215
+ })
216
+
217
+ t.Run("replaces control and non-ASCII bytes", func(t *testing.T) {
218
+ t.Parallel()
219
+ require.Equal(t, "a?b??c", scrubErrorMessage("a\x01b\x1b\xe2c"))
220
+ })
221
+ }
222
+
223
+ func TestNewShellVariableResolver(t *testing.T) {
224
+ testEnv := env.NewFromMap(map[string]string{"TEST": "value"})
225
+ resolver := NewShellVariableResolver(testEnv)
226
+
227
+ require.NotNil(t, resolver)
228
+ require.Implements(t, (*VariableResolver)(nil), resolver)
229
+ }
internal/config/scope.go ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import "fmt"
4
+
5
+ // Scope determines which config file is targeted for read/write operations.
6
+ type Scope int
7
+
8
+ const (
9
+ // ScopeGlobal targets the global data config (~/.local/share/crush/crush.json).
10
+ ScopeGlobal Scope = iota
11
+ // ScopeWorkspace targets the workspace config (.crush/crush.json).
12
+ ScopeWorkspace
13
+ )
14
+
15
+ // String returns a human-readable label for the scope.
16
+ func (s Scope) String() string {
17
+ switch s {
18
+ case ScopeGlobal:
19
+ return "global"
20
+ case ScopeWorkspace:
21
+ return "workspace"
22
+ default:
23
+ return fmt.Sprintf("Scope(%d)", int(s))
24
+ }
25
+ }
26
+
27
+ // ErrNoWorkspaceConfig is returned when a workspace-scoped write is
28
+ // attempted on a ConfigStore that has no workspace config path.
29
+ var ErrNoWorkspaceConfig = fmt.Errorf("no workspace config path configured")
internal/config/scopeb_race_test.go ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "sync"
7
+ "testing"
8
+ )
9
+
10
+ // TestScopeB_InPlaceMutationRace probes whether the store's in-place field
11
+ // mutators (e.g. SetCompactMode) race with concurrent Config() readers that
12
+ // walk the same field. This is the "Scope B" race left open after the
13
+ // pointer-swap fix. Run with -race.
14
+ // TestScopeB_InPlaceMutationRace verifies that the store's typed field
15
+ // mutators (e.g. SetCompactMode) no longer race concurrent Config()
16
+ // readers walking the same field. Copy-on-write publishing means the
17
+ // mutator swaps in a fresh Config rather than writing through the live
18
+ // pointer, so a reader always sees an immutable snapshot. Run with -race.
19
+ func TestScopeB_InPlaceMutationRace(t *testing.T) {
20
+ dir := t.TempDir()
21
+ configPath := filepath.Join(dir, "crush.json")
22
+
23
+ t.Setenv("CRUSH_GLOBAL_CONFIG", dir)
24
+ t.Setenv("CRUSH_GLOBAL_DATA", dir)
25
+ resetProviderState()
26
+ t.Cleanup(resetProviderState)
27
+
28
+ cfg := `{
29
+ "models": {"large": {"provider": "openai", "model": "gpt-4"}},
30
+ "providers": {"openai": {"api_key": "k", "models": [{"id": "gpt-4", "name": "GPT-4"}]}},
31
+ "options": {"tui": {"compact_mode": false}}
32
+ }`
33
+ if err := os.WriteFile(configPath, []byte(cfg), 0o600); err != nil {
34
+ t.Fatal(err)
35
+ }
36
+
37
+ store, err := Load(dir, dir, false)
38
+ if err != nil {
39
+ t.Fatal(err)
40
+ }
41
+ store.globalDataPath = configPath
42
+
43
+ var wg sync.WaitGroup
44
+ stop := make(chan struct{})
45
+
46
+ // Reader: walks Options.TUI.CompactMode, the field SetCompactMode writes.
47
+ wg.Add(1)
48
+ go func() {
49
+ defer wg.Done()
50
+ for {
51
+ select {
52
+ case <-stop:
53
+ return
54
+ default:
55
+ c := store.Config()
56
+ if c != nil && c.Options != nil {
57
+ _ = c.Options.TUI.CompactMode
58
+ }
59
+ }
60
+ }
61
+ }()
62
+
63
+ // Writer: flips compact mode, mutating Options in place.
64
+ wg.Add(1)
65
+ go func() {
66
+ defer wg.Done()
67
+ for i := 0; i < 50; i++ {
68
+ _ = store.SetCompactMode(ScopeGlobal, i%2 == 0)
69
+ }
70
+ close(stop)
71
+ }()
72
+
73
+ wg.Wait()
74
+ }
internal/config/shellconfig_hook_test.go ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config_test
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/charmbracelet/crush/internal/hooks"
7
+ "github.com/stretchr/testify/require"
8
+ )
9
+
10
+ func TestShellConfigHookAdd(t *testing.T) {
11
+ store := loadCrushSh(t, `hook add PreToolUse --matcher "^bash$" --command "echo hi" --name greet --timeout 10`)
12
+
13
+ hs := store.Config().Hooks[hooks.EventPreToolUse]
14
+ require.Len(t, hs, 1)
15
+ require.Equal(t, "greet", hs[0].Name)
16
+ require.Equal(t, "^bash$", hs[0].Matcher)
17
+ require.Equal(t, "echo hi", hs[0].Command)
18
+ require.Equal(t, 10, hs[0].Timeout)
19
+ }
20
+
21
+ func TestShellConfigHookRemoveByName(t *testing.T) {
22
+ store := loadCrushSh(t, `hook add PreToolUse --command a --name keep
23
+ hook add PreToolUse --command b --name drop
24
+ hook remove PreToolUse --name drop`)
25
+
26
+ hs := store.Config().Hooks[hooks.EventPreToolUse]
27
+ require.Len(t, hs, 1)
28
+ require.Equal(t, "keep", hs[0].Name)
29
+ }
30
+
31
+ func TestShellConfigHookClearEvent(t *testing.T) {
32
+ store := loadCrushSh(t, `hook add PreToolUse --command a --name a
33
+ hook add PreToolUse --command b --name b
34
+ hook remove PreToolUse`)
35
+
36
+ require.Empty(t, store.Config().Hooks[hooks.EventPreToolUse])
37
+ }
internal/config/shellconfig_lsp_test.go ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config_test
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/stretchr/testify/require"
7
+ )
8
+
9
+ func TestShellConfigLSPAdd(t *testing.T) {
10
+ store := loadCrushSh(t, `lsp add gopls --command gopls --filetypes go --filetypes mod --root-markers go.mod --timeout 60`)
11
+
12
+ l, ok := store.Config().LSP["gopls"]
13
+ require.True(t, ok, "gopls LSP should be configured")
14
+ require.Equal(t, "gopls", l.Command)
15
+ require.Subset(t, l.FileTypes, []string{"go", "mod"})
16
+ require.Contains(t, l.RootMarkers, "go.mod")
17
+ require.Equal(t, 60, l.Timeout)
18
+ }
19
+
20
+ func TestShellConfigLSPRemove(t *testing.T) {
21
+ store := loadCrushSh(t, `lsp add keepls --command keep-server
22
+ lsp add dropls --command drop-server
23
+ lsp remove dropls`)
24
+
25
+ lsps := store.Config().LSP
26
+ require.Contains(t, lsps, "keepls")
27
+ require.NotContains(t, lsps, "dropls")
28
+ }
internal/config/shellconfig_option_test.go ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config_test
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/stretchr/testify/require"
7
+ )
8
+
9
+ func TestShellConfigOptionBooleans(t *testing.T) {
10
+ store := loadCrushSh(t, `option debug true
11
+ option progress false
12
+ option auto-lsp false`)
13
+
14
+ opts := store.Config().Options
15
+ require.True(t, opts.Debug, "debug should be on")
16
+ require.NotNil(t, opts.Progress)
17
+ require.False(t, *opts.Progress, "progress should be off")
18
+ require.NotNil(t, opts.AutoLSP)
19
+ require.False(t, *opts.AutoLSP, "auto-lsp should be off")
20
+ }
21
+
22
+ // Config phrases this field negatively (disable_metrics) but the command
23
+ // exposes it positively. "metrics false" must land as disable_metrics = true.
24
+ func TestShellConfigOptionPositiveMetricsFalse(t *testing.T) {
25
+ store := loadCrushSh(t, `option metrics false`)
26
+ require.True(t, store.Config().Options.DisableMetrics, "metrics off => disable_metrics true")
27
+ }
28
+
29
+ // The bare positive form defaults to true, which inverts to disable = false.
30
+ func TestShellConfigOptionPositiveMetricsBare(t *testing.T) {
31
+ store := loadCrushSh(t, `option metrics`)
32
+ require.False(t, store.Config().Options.DisableMetrics, "metrics on => disable_metrics false")
33
+ }
34
+
35
+ func TestShellConfigOptionUI(t *testing.T) {
36
+ store := loadCrushSh(t, `option ui compact true
37
+ option ui diff split
38
+ option ui transparent false
39
+ option ui mouse false
40
+ option ui scrollbar always
41
+ option ui completions-max-depth 4
42
+ option ui completions-max-items 200`)
43
+
44
+ ui := store.Config().Options.TUI
45
+ require.NotNil(t, ui)
46
+ require.True(t, ui.CompactMode)
47
+ require.Equal(t, "split", ui.DiffMode)
48
+ require.NotNil(t, ui.Transparent)
49
+ require.False(t, *ui.Transparent)
50
+ require.NotNil(t, ui.Mouse)
51
+ require.False(t, *ui.Mouse)
52
+ require.Equal(t, "always", ui.Scrollbar)
53
+ require.NotNil(t, ui.Completions.MaxDepth)
54
+ require.Equal(t, 4, *ui.Completions.MaxDepth)
55
+ require.NotNil(t, ui.Completions.MaxItems)
56
+ require.Equal(t, 200, *ui.Completions.MaxItems)
57
+ }
58
+
59
+ func TestShellConfigOptionUIRejectsInvalidValue(t *testing.T) {
60
+ _, err := loadCrushShErr(t, `option ui diff side-by-side`)
61
+ require.Error(t, err)
62
+ require.Contains(t, err.Error(), "expects unified or split")
63
+ }
64
+
65
+ func TestShellConfigOptionAttribution(t *testing.T) {
66
+ store := loadCrushSh(t, `option attribution-trailer-style none
67
+ option attribution-generated-with false`)
68
+
69
+ attribution := store.Config().Options.Attribution
70
+ require.NotNil(t, attribution)
71
+ require.Equal(t, "none", string(attribution.TrailerStyle))
72
+ require.False(t, attribution.GeneratedWith)
73
+ }
74
+
75
+ func TestShellConfigOptionAttributionTrailerStylePreservesGeneratedWithDefault(t *testing.T) {
76
+ store := loadCrushSh(t, `option attribution-trailer-style co-authored-by`)
77
+
78
+ attribution := store.Config().Options.Attribution
79
+ require.NotNil(t, attribution)
80
+ require.Equal(t, "co-authored-by", string(attribution.TrailerStyle))
81
+ require.True(t, attribution.GeneratedWith)
82
+ }
83
+
84
+ func TestShellConfigOptionAttributionGeneratedWithCaseInsensitive(t *testing.T) {
85
+ store := loadCrushSh(t, `option attribution-generated-with YES`)
86
+
87
+ require.NotNil(t, store.Config().Options.Attribution)
88
+ require.True(t, store.Config().Options.Attribution.GeneratedWith)
89
+ }
90
+
91
+ func TestShellConfigOptionAttributionRejectsInvalidStyle(t *testing.T) {
92
+ _, err := loadCrushShErr(t, `option attribution-trailer-style bogus`)
93
+ require.Error(t, err)
94
+ require.Contains(t, err.Error(), "expects none, co-authored-by, or assisted-by")
95
+ }
96
+
97
+ func TestShellConfigOptionListAppends(t *testing.T) {
98
+ store := loadCrushSh(t, `option disable-skill crush-config
99
+ option disable-skill jq`)
100
+
101
+ require.Subset(t, store.Config().Options.DisabledSkills, []string{"crush-config", "jq"})
102
+ }
103
+
104
+ // reset wipes values added earlier (or via source) while keeping anything
105
+ // added after it — observable in the effective config.
106
+ func TestShellConfigOptionReset(t *testing.T) {
107
+ store := loadCrushSh(t, `option skill-path ./inherited-a
108
+ option skill-path ./inherited-b
109
+ option reset skill-path
110
+ option skill-path ./mine`)
111
+
112
+ paths := store.Config().Options.SkillsPaths
113
+ require.Contains(t, paths, "./mine")
114
+ require.NotContains(t, paths, "./inherited-a")
115
+ require.NotContains(t, paths, "./inherited-b")
116
+ }
117
+
118
+ func TestShellConfigOptionUnknownKeyFails(t *testing.T) {
119
+ _, err := loadCrushShErr(t, `option bogus-key value`)
120
+ require.Error(t, err)
121
+ require.Contains(t, err.Error(), "unknown key")
122
+ }
123
+
124
+ func TestShellConfigOptionDisableToolRemoved(t *testing.T) {
125
+ _, err := loadCrushShErr(t, `option disable-tool bash`)
126
+ require.Error(t, err)
127
+ require.Contains(t, err.Error(), "unknown key")
128
+ }
129
+
130
+ func TestShellConfigOptionResetRejectsNonList(t *testing.T) {
131
+ _, err := loadCrushShErr(t, `option reset debug`)
132
+ require.Error(t, err)
133
+ require.Contains(t, err.Error(), "not one")
134
+ }
internal/config/shellconfig_precedence_test.go ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config_test
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+
8
+ "github.com/charmbracelet/crush/internal/config"
9
+ "github.com/stretchr/testify/require"
10
+ )
11
+
12
+ // TestShellConfigDotCrushrcTakesPrecedence verifies that a project-local
13
+ // .crushrc overrides crushrc in the same directory on conflicting settings.
14
+ func TestShellConfigDotCrushrcTakesPrecedence(t *testing.T) {
15
+ isolated := t.TempDir()
16
+ t.Setenv("HOME", isolated)
17
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(isolated, ".config"))
18
+ t.Setenv("XDG_DATA_HOME", filepath.Join(isolated, ".local", "share"))
19
+
20
+ workDir := t.TempDir()
21
+ dataDir := t.TempDir()
22
+ require.NoError(t, os.WriteFile(
23
+ filepath.Join(workDir, "crushrc"),
24
+ []byte("option notifications bell\n"), 0o644,
25
+ ))
26
+ require.NoError(t, os.WriteFile(
27
+ filepath.Join(workDir, ".crushrc"),
28
+ []byte("option notifications osc\n"), 0o644,
29
+ ))
30
+
31
+ store, err := config.Load(workDir, dataDir, false)
32
+ require.NoError(t, err)
33
+ require.Equal(t, "osc", store.Config().Options.Notifications,
34
+ ".crushrc should win over crushrc")
35
+ }
internal/config/shellconfig_provider_test.go ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config_test
2
+
3
+ import (
4
+ "slices"
5
+ "testing"
6
+
7
+ "charm.land/catwalk/pkg/catwalk"
8
+ "github.com/charmbracelet/crush/internal/config"
9
+ "github.com/stretchr/testify/require"
10
+ )
11
+
12
+ func TestShellConfigProviderAddAndModel(t *testing.T) {
13
+ store := loadCrushSh(t, `provider add myllm \
14
+ --type openai-compat \
15
+ --base-url "http://localhost:1234/v1" \
16
+ --api-key "sk-test" \
17
+ --discover-models false \
18
+ --extra-body '{"service_tier":"flex"}' \
19
+ --provider-options '{"region":"local"}'
20
+ provider add myllm \
21
+ --extra-body '{"stream":true}' \
22
+ --provider-options '{"mode":"test"}'
23
+ model add myllm/foo-1 --name "Foo 1" --context-window 8000 \
24
+ --price-input 1.25 --price-output 5 \
25
+ --price-cache-create 2 --price-cache-hit 0.25
26
+ model large myllm/foo-1 \
27
+ --top-p 0.9 --top-k 40 \
28
+ --frequency-penalty 0.2 --presence-penalty 0.1 \
29
+ --provider-options '{"routing":{"tier":"fast"}}'
30
+ model large myllm/foo-1 --provider-options '{"timeout":30}'`)
31
+
32
+ cfg := store.Config()
33
+
34
+ p, ok := cfg.Providers.Get("myllm")
35
+ require.True(t, ok, "myllm provider should be configured")
36
+ require.Equal(t, "sk-test", p.APIKey)
37
+ require.Equal(t, "http://localhost:1234/v1", p.BaseURL)
38
+ require.NotNil(t, p.AutoDiscoverModels)
39
+ require.False(t, *p.AutoDiscoverModels)
40
+ require.Equal(t, "flex", p.ExtraBody["service_tier"])
41
+ require.Equal(t, true, p.ExtraBody["stream"])
42
+ require.Equal(t, "local", p.ProviderOptions["region"])
43
+ require.Equal(t, "test", p.ProviderOptions["mode"])
44
+ require.True(
45
+ t,
46
+ slices.ContainsFunc(p.Models, func(m catwalk.Model) bool { return m.ID == "foo-1" }),
47
+ "custom model foo-1 should be in the provider catalog",
48
+ )
49
+ model := p.Models[0]
50
+ require.Equal(t, 1.25, model.CostPer1MIn)
51
+ require.Equal(t, 5.0, model.CostPer1MOut)
52
+ require.Equal(t, 2.0, model.CostPer1MInCached)
53
+ require.Equal(t, 0.25, model.CostPer1MOutCached)
54
+
55
+ large := cfg.Models[config.SelectedModelTypeLarge]
56
+ require.Equal(t, "myllm", large.Provider)
57
+ require.Equal(t, "foo-1", large.Model)
58
+ require.NotNil(t, large.TopP)
59
+ require.Equal(t, 0.9, *large.TopP)
60
+ require.NotNil(t, large.TopK)
61
+ require.Equal(t, int64(40), *large.TopK)
62
+ require.NotNil(t, large.FrequencyPenalty)
63
+ require.Equal(t, 0.2, *large.FrequencyPenalty)
64
+ require.NotNil(t, large.PresencePenalty)
65
+ require.Equal(t, 0.1, *large.PresencePenalty)
66
+ require.Equal(t, map[string]any{
67
+ "routing": map[string]any{"tier": "fast"},
68
+ "timeout": float64(30),
69
+ }, large.ProviderOptions)
70
+ }
71
+
72
+ func TestShellConfigProviderRemove(t *testing.T) {
73
+ // Both providers get a model so they survive provider configuration
74
+ // (model-less providers are dropped); the only difference is the remove.
75
+ store := loadCrushSh(t, `provider add keepme --type openai-compat --base-url "http://localhost:1/v1" --api-key k
76
+ model add keepme/m1 --name M1
77
+ provider add dropme --type openai-compat --base-url "http://localhost:2/v1" --api-key k
78
+ model add dropme/m2 --name M2
79
+ provider remove dropme`)
80
+
81
+ _, keep := store.Config().Providers.Get("keepme")
82
+ _, drop := store.Config().Providers.Get("dropme")
83
+ require.True(t, keep, "keepme should remain")
84
+ require.False(t, drop, "dropme should be gone after remove")
85
+ }
internal/config/store.go ADDED
@@ -0,0 +1,1430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "log/slog"
8
+ "maps"
9
+ "os"
10
+ "path/filepath"
11
+ "slices"
12
+ "sync"
13
+ "time"
14
+
15
+ "charm.land/catwalk/pkg/catwalk"
16
+ hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
17
+ "github.com/charmbracelet/crush/internal/env"
18
+ "github.com/charmbracelet/crush/internal/lock"
19
+ "github.com/charmbracelet/crush/internal/oauth"
20
+ "github.com/charmbracelet/crush/internal/oauth/copilot"
21
+ "github.com/charmbracelet/crush/internal/oauth/hyper"
22
+ "github.com/charmbracelet/crush/internal/oauth/openai"
23
+ "github.com/tidwall/gjson"
24
+ "github.com/tidwall/sjson"
25
+ "golang.org/x/sync/singleflight"
26
+ )
27
+
28
+ // configLockDeadline bounds how long lockConfig waits for the
29
+ // cross-process flock before giving up. A few seconds is plenty for
30
+ // honest contention; longer suggests something is wedged.
31
+ const configLockDeadline = 5 * time.Second
32
+
33
+ // refreshLockDeadline bounds how long RefreshOAuthToken waits for the
34
+ // per-provider cross-process refresh lock. It must exceed the token
35
+ // exchange HTTP timeout (30s) so that a peer mid-exchange is given time
36
+ // to finish and publish its result, which we then adopt instead of
37
+ // running our own exchange. Running our own would reuse an
38
+ // already-rotated refresh token and trip the provider's reuse detection,
39
+ // revoking the whole token family.
40
+ const refreshLockDeadline = 45 * time.Second
41
+
42
+ // credentialWriteLockDeadline bounds how long a credential write (e.g.
43
+ // storing the token from a fresh interactive login) waits for the
44
+ // per-provider refresh lock. It is deliberately shorter than
45
+ // refreshLockDeadline because a user is watching: if a peer is wedged we
46
+ // would rather write and risk a rare clobber than hang the UI.
47
+ const credentialWriteLockDeadline = 10 * time.Second
48
+
49
+ // fileSnapshot captures metadata about a config file at a point in time.
50
+ type fileSnapshot struct {
51
+ Path string
52
+ Exists bool
53
+ Size int64
54
+ ModTime int64 // UnixNano
55
+ }
56
+
57
+ // RuntimeOverrides holds per-session settings that are never persisted to
58
+ // disk. They are applied on top of the loaded Config and survive only for
59
+ // the lifetime of the process (or workspace).
60
+ type RuntimeOverrides struct {
61
+ SkipPermissionRequests bool
62
+ // EnabledChannels lists the MCP servers opted in as channels for this
63
+ // session (via the --channels flag). A server present in MCP config only
64
+ // pushes channel events when it also appears here. Entries may be written
65
+ // as "server:<name>" or as a bare "<name>".
66
+ EnabledChannels []string
67
+ // Models records the model choices made in this instance, whether
68
+ // persisted or not. They are reapplied after a config reload so that a
69
+ // selection made here always outranks whatever the shared config file
70
+ // happens to hold — see pinPreferredModelLocked.
71
+ Models map[SelectedModelType]SelectedModel
72
+ }
73
+
74
+ // ConfigStore is the single entry point for all config access. It owns the
75
+ // pure-data Config, runtime state (working directory, resolver, known
76
+ // providers), and persistence to both global and workspace config files.
77
+ //
78
+ // mu serialises all config file mutations (SetConfigFields,
79
+ // RemoveConfigField, RefreshOAuthToken) to prevent both in-process
80
+ // goroutine races and, together with the shared lock.File, cross-process
81
+ // races on the config file.
82
+ //
83
+ // writeMu serialises every operation that produces a new in-memory Config:
84
+ // the typed copy-on-write mutators (SetCompactMode, UpdatePreferredModel,
85
+ // ...) and ReloadFromDisk. Typed mutators take Lock; autoReload takes
86
+ // TryLock so a write triggered re-entrantly during a reload (e.g.
87
+ // configureProviders calling RemoveConfigField) skips the nested reload
88
+ // instead of deadlocking. This is what lets published Configs be treated
89
+ // as immutable: a mutator clones, mutates the clone, and swaps it in under
90
+ // writeMu rather than mutating the live Config in place.
91
+ type ConfigStore struct {
92
+ config *Config
93
+ workingDir string
94
+ resolver VariableResolver
95
+ globalDataPath string // ~/.local/share/crush/crush.json
96
+ workspacePath string // .crush/crush.json
97
+ loadedPaths []string // config files that were successfully loaded
98
+ knownProviders []catwalk.Provider
99
+ overrides RuntimeOverrides
100
+ trackedConfigPaths []string // unique, normalized config file paths
101
+ snapshots map[string]fileSnapshot // path -> snapshot at last capture
102
+
103
+ // configMu guards the config pointer field against concurrent
104
+ // readers (Config) and the writeMu-serialised swap (setConfig). It
105
+ // protects the pointer word only; the pointed-to Config is treated
106
+ // as immutable once published, since both reloads and typed mutators
107
+ // build a fresh Config rather than mutating the live one.
108
+ configMu sync.RWMutex
109
+
110
+ mu sync.Mutex // serialises config file writes
111
+ writeMu sync.RWMutex // serialises in-memory config production (mutators + reload); RLock for readers
112
+
113
+ // refreshSF collapses concurrent in-process OAuth refreshes for the
114
+ // same provider into a single attempt. Combined with the per-provider
115
+ // cross-process refresh lock, it ensures only one token exchange runs
116
+ // at a time. See RefreshOAuthToken.
117
+ refreshSF singleflight.Group
118
+
119
+ // exchangeToken performs the provider-specific OAuth token exchange.
120
+ // It is a field so tests can substitute a fake exchange without making
121
+ // real network calls. Production code leaves it nil, and exchange falls
122
+ // back to the real provider clients.
123
+ exchangeToken func(ctx context.Context, providerID, refreshToken string) (*oauth.Token, error)
124
+
125
+ // fetchOpenAIModels fetches the ChatGPT model catalog. A field for the
126
+ // same reason as exchangeToken: tests stub it to avoid network calls,
127
+ // production leaves it nil and refetchOpenAIModels calls the real one.
128
+ fetchOpenAIModels func(ctx context.Context, token *oauth.Token) ([]catwalk.Model, error)
129
+
130
+ // authSignalMu guards authSignals, which maps provider IDs to
131
+ // channels that WaitForTokenChange blocks on. SignalAuthComplete
132
+ // closes the channel to unblock waiters; a new channel is created
133
+ // on the next wait.
134
+ authSignalMu sync.Mutex
135
+ authSignals map[string]chan struct{}
136
+ }
137
+
138
+ // Config returns the pure-data config struct (read-only after load).
139
+ //
140
+ // The pointer read is guarded by configMu so it can never tear against
141
+ // the reload swap in reloadFromDiskLocked. Reloads build a brand-new
142
+ // Config and swap it in rather than mutating the live one, so holding the
143
+ // returned pointer stays safe even across a concurrent reload — the reader
144
+ // keeps reading its (now immutable) snapshot.
145
+ func (s *ConfigStore) Config() *Config {
146
+ s.configMu.RLock()
147
+ defer s.configMu.RUnlock()
148
+ return s.config
149
+ }
150
+
151
+ // setConfig atomically swaps the active config pointer under configMu.
152
+ // Used by the reload path; in-place field mutators leave the pointer
153
+ // untouched and run under mu instead.
154
+ func (s *ConfigStore) setConfig(cfg *Config) {
155
+ s.configMu.Lock()
156
+ defer s.configMu.Unlock()
157
+ s.config = cfg
158
+ }
159
+
160
+ // WorkingDir returns the current working directory.
161
+ func (s *ConfigStore) WorkingDir() string {
162
+ return s.workingDir
163
+ }
164
+
165
+ // Resolver returns the variable resolver.
166
+ func (s *ConfigStore) Resolver() VariableResolver {
167
+ s.writeMu.RLock()
168
+ defer s.writeMu.RUnlock()
169
+ return s.resolver
170
+ }
171
+
172
+ // Resolve resolves a variable reference using the configured resolver.
173
+ func (s *ConfigStore) Resolve(key string) (string, error) {
174
+ s.writeMu.RLock()
175
+ r := s.resolver
176
+ s.writeMu.RUnlock()
177
+ if r == nil {
178
+ return "", fmt.Errorf("no variable resolver configured")
179
+ }
180
+ return r.ResolveValue(key)
181
+ }
182
+
183
+ // KnownProviders returns the list of known providers.
184
+ func (s *ConfigStore) KnownProviders() []catwalk.Provider {
185
+ s.writeMu.RLock()
186
+ defer s.writeMu.RUnlock()
187
+ return s.knownProviders
188
+ }
189
+
190
+ // RefetchHyperProvider re-fetches the Hyper provider catalog from the
191
+ // remote API and updates the in-memory known providers list and config.
192
+ // This is called after OAuth authentication completes so the latest
193
+ // models are available without restarting.
194
+ func (s *ConfigStore) RefetchHyperProvider(ctx context.Context) error {
195
+ // Build a fresh client that reads the API key from the live config,
196
+ // not the stale snapshot captured at startup. The syncer's original
197
+ // client closes over the startup config and would send an expired
198
+ // token after OAuth re-authentication.
199
+ freshClient := realHyperClient{
200
+ baseURL: hyperp.BaseURL(),
201
+ resolveKey: func() string { return resolveHyperAPIKey(s.Config()) },
202
+ }
203
+ hyperSyncer.SetClient(freshClient)
204
+
205
+ hyperProvider, err := hyperSyncer.Refetch(ctx)
206
+ if err != nil {
207
+ return fmt.Errorf("failed to refetch Hyper provider: %w", err)
208
+ }
209
+ if hyperProvider.ID == "" {
210
+ return nil
211
+ }
212
+
213
+ s.writeMu.Lock()
214
+ defer s.writeMu.Unlock()
215
+
216
+ // Replace or insert the Hyper entry in knownProviders.
217
+ found := false
218
+ for i, p := range s.knownProviders {
219
+ if string(p.ID) == string(hyperProvider.ID) {
220
+ s.knownProviders[i] = hyperProvider
221
+ found = true
222
+ break
223
+ }
224
+ }
225
+ if !found {
226
+ s.knownProviders = append([]catwalk.Provider{hyperProvider}, s.knownProviders...)
227
+ }
228
+
229
+ // Update the Hyper provider config with the refreshed model list
230
+ // and endpoint. Use cloneForWrite so readers always see a consistent
231
+ // snapshot (the store's contract forbids in-place config mutation).
232
+ nc := s.config.cloneForWrite()
233
+ if pc, ok := nc.Providers.Get(string(hyperProvider.ID)); ok {
234
+ pc.Models = hyperProvider.Models
235
+ if hyperProvider.APIEndpoint != "" {
236
+ pc.BaseURL = hyperProvider.APIEndpoint
237
+ }
238
+ nc.Providers.Set(string(hyperProvider.ID), pc)
239
+ }
240
+ s.setConfig(nc)
241
+
242
+ // Also update the memoized provider list so callers of
243
+ // config.Providers() (e.g. the models dialog) see fresh data.
244
+ UpdateProviderInList(hyperProvider)
245
+
246
+ s.SetupAgents()
247
+ return nil
248
+ }
249
+
250
+ // SetupAgents configures the coder and task agents on the config.
251
+ func (s *ConfigStore) SetupAgents() {
252
+ s.Config().SetupAgents()
253
+ }
254
+
255
+ // Overrides returns the runtime overrides for this store.
256
+ func (s *ConfigStore) Overrides() *RuntimeOverrides {
257
+ s.writeMu.RLock()
258
+ defer s.writeMu.RUnlock()
259
+ return &s.overrides
260
+ }
261
+
262
+ // LoadedPaths returns the config file paths that were successfully loaded.
263
+ func (s *ConfigStore) LoadedPaths() []string {
264
+ s.writeMu.RLock()
265
+ defer s.writeMu.RUnlock()
266
+ return slices.Clone(s.loadedPaths)
267
+ }
268
+
269
+ // lockConfig acquires both the in-process mutex and a cross-process flock
270
+ // on the config file for the given scope. Callers that need to do I/O
271
+ // between reading and writing (e.g. an HTTP token exchange) must use
272
+ // lockConfig explicitly rather than atomicWrite.
273
+ //
274
+ // The returned release function drops both locks. Callers must call it
275
+ // as soon as the file access is complete — no I/O should be performed
276
+ // while the lock is held.
277
+ func (s *ConfigStore) lockConfig(scope Scope) (func(), error) {
278
+ s.mu.Lock()
279
+ path, err := s.configPath(scope)
280
+ if err != nil {
281
+ s.mu.Unlock()
282
+ return nil, err
283
+ }
284
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
285
+ s.mu.Unlock()
286
+ return nil, fmt.Errorf("create config directory: %w", err)
287
+ }
288
+ ctx, cancel := context.WithTimeout(context.Background(), configLockDeadline)
289
+ defer cancel()
290
+ release, err := lock.File(ctx, path+".lock")
291
+ if err != nil {
292
+ s.mu.Unlock()
293
+ return nil, fmt.Errorf("acquire config lock: %w", err)
294
+ }
295
+ return func() {
296
+ release()
297
+ s.mu.Unlock()
298
+ }, nil
299
+ }
300
+
301
+ // atomicWrite handles the lock-read-transform-write-unlock cycle for
302
+ // config file mutations. The fn callback receives the current file
303
+ // contents (raw bytes, or {} if the file is missing) and must return the
304
+ // new contents. fn must be pure — no I/O, no network calls.
305
+ func (s *ConfigStore) atomicWrite(scope Scope, fn func(current []byte) ([]byte, error)) error {
306
+ unlock, err := s.lockConfig(scope)
307
+ if err != nil {
308
+ return err
309
+ }
310
+ defer unlock()
311
+
312
+ path, err := s.configPath(scope)
313
+ if err != nil {
314
+ return err
315
+ }
316
+
317
+ data, err := os.ReadFile(path)
318
+ if err != nil {
319
+ if os.IsNotExist(err) {
320
+ data = []byte("{}")
321
+ } else {
322
+ return fmt.Errorf("read config file: %w", err)
323
+ }
324
+ }
325
+
326
+ newData, err := fn(data)
327
+ if err != nil {
328
+ return err
329
+ }
330
+
331
+ return atomicWriteFile(path, newData, 0o600)
332
+ }
333
+
334
+ // configPath returns the file path for the given scope.
335
+ func (s *ConfigStore) configPath(scope Scope) (string, error) {
336
+ switch scope {
337
+ case ScopeWorkspace:
338
+ if s.workspacePath == "" {
339
+ return "", ErrNoWorkspaceConfig
340
+ }
341
+ return s.workspacePath, nil
342
+ default:
343
+ return s.globalDataPath, nil
344
+ }
345
+ }
346
+
347
+ // HasConfigField checks whether a key exists in the config file for the given
348
+ // scope.
349
+ func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
350
+ path, err := s.configPath(scope)
351
+ if err != nil {
352
+ return false
353
+ }
354
+ data, err := os.ReadFile(path)
355
+ if err != nil {
356
+ return false
357
+ }
358
+ return gjson.Get(string(data), key).Exists()
359
+ }
360
+
361
+ // SetConfigField sets a key/value pair in the config file for the given scope.
362
+ // After a successful write, it automatically reloads config to keep in-memory
363
+ // state fresh.
364
+ func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
365
+ return s.SetConfigFields(scope, map[string]any{key: value})
366
+ }
367
+
368
+ // SetConfigFields sets multiple key/value pairs in the config file for the
369
+ // given scope in a single write, then reloads in-memory state from disk.
370
+ //
371
+ // Use this for arbitrary external edits where the in-memory effect of the
372
+ // change is not known ahead of time. The typed mutators (which know exactly
373
+ // what changed) go through update instead and skip the reload.
374
+ //
375
+ // The write is protected by an in-process mutex and a cross-process flock
376
+ // to prevent races between concurrent writers in different processes.
377
+ func (s *ConfigStore) SetConfigFields(scope Scope, kv map[string]any) error {
378
+ if err := s.writeConfigFields(scope, kv); err != nil {
379
+ return err
380
+ }
381
+ // Auto-reload to keep in-memory state fresh after config edits.
382
+ // We use context.Background() since this is an internal operation that
383
+ // shouldn't be cancelled by user context.
384
+ if err := s.autoReload(context.Background()); err != nil {
385
+ // Log warning but don't fail the write - disk is already updated.
386
+ slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
387
+ }
388
+ return nil
389
+ }
390
+
391
+ // writeConfigFields persists key/value pairs to the config file. It does not
392
+ // touch in-memory config state or the staleness snapshot: callers either
393
+ // reload (SetConfigFields, whose reload recaptures the snapshot) or have
394
+ // already published an updated clone and capture the snapshot themselves
395
+ // (update). Both of those run under writeMu, which is what keeps the
396
+ // snapshot map free of concurrent writers.
397
+ func (s *ConfigStore) writeConfigFields(scope Scope, kv map[string]any) error {
398
+ // Sort keys for deterministic output regardless of map iteration
399
+ // order. This also ensures consistent results when callers pass
400
+ // overlapping JSONPath keys (e.g. "a" and "a.b").
401
+ keys := make([]string, 0, len(kv))
402
+ for k := range kv {
403
+ keys = append(keys, k)
404
+ }
405
+ slices.Sort(keys)
406
+
407
+ return s.atomicWrite(scope, func(data []byte) ([]byte, error) {
408
+ v := string(data)
409
+ for _, key := range keys {
410
+ var sErr error
411
+ if v, sErr = sjson.Set(v, key, kv[key]); sErr != nil {
412
+ return nil, fmt.Errorf("failed to set config field %s: %w", key, sErr)
413
+ }
414
+ }
415
+ return []byte(v), nil
416
+ })
417
+ }
418
+
419
+ // mutateInMemory applies a copy-on-write change to the config without
420
+ // persisting. Under writeMu it clones the live config, lets mutate edit the
421
+ // clone, and publishes it. This is the single primitive every in-memory
422
+ // config change goes through, so a published Config is never mutated in
423
+ // place and readers always see a consistent snapshot.
424
+ func (s *ConfigStore) mutateInMemory(mutate func(*Config)) {
425
+ s.writeMu.Lock()
426
+ defer s.writeMu.Unlock()
427
+
428
+ nc := s.Config().cloneForWrite()
429
+ mutate(nc)
430
+ s.setConfig(nc)
431
+ }
432
+
433
+ // update applies a copy-on-write change and persists the reported fields.
434
+ // mutate edits the clone and returns the JSON-path fields to write to disk;
435
+ // because the clone already reflects the change, no reload is needed.
436
+ // Returning an empty map publishes the clone without a disk write.
437
+ func (s *ConfigStore) update(scope Scope, mutate func(*Config) map[string]any) error {
438
+ s.writeMu.Lock()
439
+ defer s.writeMu.Unlock()
440
+ return s.updateLocked(scope, mutate)
441
+ }
442
+
443
+ // updateLocked is the lock-free core of update. Caller must hold writeMu.
444
+ func (s *ConfigStore) updateLocked(scope Scope, mutate func(*Config) map[string]any) error {
445
+ nc := s.Config().cloneForWrite()
446
+ fields := mutate(nc)
447
+ s.setConfig(nc)
448
+ if len(fields) == 0 {
449
+ return nil
450
+ }
451
+ if err := s.writeConfigFields(scope, fields); err != nil {
452
+ return err
453
+ }
454
+ // Refresh the staleness snapshot so the file watcher does not treat
455
+ // our own write as an external change. Safe to touch the snapshot map
456
+ // here because we hold writeMu.
457
+ if path, err := s.configPath(scope); err == nil {
458
+ s.captureStalenessSnapshot(append(slices.Clone(s.loadedPaths), path))
459
+ }
460
+ return nil
461
+ }
462
+
463
+ // OverridePreferredModel sets the preferred model for the given type in
464
+ // memory only, without persisting. It is for per-run overrides (such as the
465
+ // non-interactive --model flags) that must not be written to the user's
466
+ // config file.
467
+ func (s *ConfigStore) OverridePreferredModel(modelType SelectedModelType, model SelectedModel) {
468
+ s.mutateInMemory(func(c *Config) {
469
+ if c.Models == nil {
470
+ c.Models = make(map[SelectedModelType]SelectedModel)
471
+ }
472
+ c.Models[modelType] = model
473
+ s.pinPreferredModelLocked(modelType, model)
474
+ })
475
+ }
476
+
477
+ // pinPreferredModelLocked records a model choice made in this instance so
478
+ // that a later config reload cannot replace it with a choice made
479
+ // somewhere else. Several Crush instances share one global config file, so
480
+ // a reload triggered by an unrelated write (a token refresh, say) would
481
+ // otherwise import whichever model a sibling instance last selected and
482
+ // switch models out from under the user mid-session.
483
+ //
484
+ // Caller must hold writeMu.
485
+ func (s *ConfigStore) pinPreferredModelLocked(modelType SelectedModelType, model SelectedModel) {
486
+ if s.overrides.Models == nil {
487
+ s.overrides.Models = make(map[SelectedModelType]SelectedModel)
488
+ }
489
+ s.overrides.Models[modelType] = model
490
+ }
491
+
492
+ // RemoveConfigField removes a key from the config file for the given scope.
493
+ // After a successful write, it automatically reloads config to keep in-memory
494
+ // state fresh.
495
+ //
496
+ // The write is protected by an in-process mutex and a cross-process flock.
497
+ func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
498
+ err := s.atomicWrite(scope, func(data []byte) ([]byte, error) {
499
+ v, sErr := sjson.Delete(string(data), key)
500
+ if sErr != nil {
501
+ return nil, fmt.Errorf("failed to delete config field %s: %w", key, sErr)
502
+ }
503
+ return []byte(v), nil
504
+ })
505
+ if err != nil {
506
+ return err
507
+ }
508
+
509
+ if err := s.autoReload(context.Background()); err != nil {
510
+ slog.Warn("Config file updated but failed to reload in-memory state", "error", err)
511
+ }
512
+
513
+ return nil
514
+ }
515
+
516
+ // UpdatePreferredModel updates the preferred model for the given type and
517
+ // persists it to the config file at the given scope. The selected model and
518
+ // the recent-models list are written together in a single config write.
519
+ //
520
+ // The write skips the full disk reparse/reload (which would rebuild the
521
+ // provider catalog and agents on every model switch and dominate selection
522
+ // latency); agents are refreshed separately by the caller (see
523
+ // UpdateAgentModel).
524
+ func (s *ConfigStore) UpdatePreferredModel(scope Scope, modelType SelectedModelType, model SelectedModel) error {
525
+ return s.update(scope, func(c *Config) map[string]any {
526
+ return s.updatePreferredModelFields(c, modelType, model)
527
+ })
528
+ }
529
+
530
+ // updatePreferredModelFields builds the fields map for persisting a preferred
531
+ // model change. Shared between UpdatePreferredModel and direct updateLocked
532
+ // callers (e.g. Load). Caller must hold writeMu.
533
+ func (s *ConfigStore) updatePreferredModelFields(c *Config, modelType SelectedModelType, model SelectedModel) map[string]any {
534
+ if c.Models == nil {
535
+ c.Models = make(map[SelectedModelType]SelectedModel)
536
+ }
537
+ c.Models[modelType] = model
538
+ s.pinPreferredModelLocked(modelType, model)
539
+
540
+ fields := map[string]any{
541
+ fmt.Sprintf("models.%s", modelType): model,
542
+ }
543
+ if updated, changed := nextRecentModels(c, modelType, model); changed {
544
+ if c.RecentModels == nil {
545
+ c.RecentModels = make(map[SelectedModelType][]SelectedModel)
546
+ }
547
+ c.RecentModels[modelType] = updated
548
+ fields[fmt.Sprintf("recent_models.%s", modelType)] = updated
549
+ }
550
+ return fields
551
+ }
552
+
553
+ // SetCompactMode sets the compact mode setting and persists it.
554
+ func (s *ConfigStore) SetCompactMode(scope Scope, enabled bool) error {
555
+ return s.update(scope, func(c *Config) map[string]any {
556
+ c.ensureTUI().CompactMode = enabled
557
+ return map[string]any{"options.tui.compact_mode": enabled}
558
+ })
559
+ }
560
+
561
+ // SetTransparentBackground sets the transparent background setting and persists it.
562
+ func (s *ConfigStore) SetTransparentBackground(scope Scope, enabled bool) error {
563
+ return s.update(scope, func(c *Config) map[string]any {
564
+ c.ensureTUI().Transparent = &enabled
565
+ return map[string]any{"options.tui.transparent": enabled}
566
+ })
567
+ }
568
+
569
+ // SetProviderAPIKey sets the API key for a provider and persists it.
570
+ // The OpenAI provider holds exactly one credential: storing a ChatGPT
571
+ // token removes a previously entered API key, and storing an API key
572
+ // removes a previous ChatGPT login.
573
+ func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey any) error {
574
+ var providerConfig ProviderConfig
575
+ var exists bool
576
+ var setKeyOrToken func()
577
+ isToken := false
578
+
579
+ switch v := apiKey.(type) {
580
+ case string:
581
+ if err := s.SetConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID), v); err != nil {
582
+ return fmt.Errorf("failed to save api key to config file: %w", err)
583
+ }
584
+ setKeyOrToken = func() {
585
+ providerConfig.APIKey = v
586
+ if providerID == string(catwalk.InferenceProviderOpenAI) {
587
+ // Either OAuth or an API key, never both: the login
588
+ // leaves nothing usable on the API-key side behind.
589
+ providerConfig.OAuthToken = nil
590
+ providerConfig.ChatGPTModels = nil
591
+ }
592
+ }
593
+ if providerID == string(catwalk.InferenceProviderOpenAI) {
594
+ // Either OAuth or an API key, never both: the new key
595
+ // leaves nothing usable on the ChatGPT side behind.
596
+ if err := s.RemoveConfigField(scope, fmt.Sprintf("providers.%s.oauth", providerID)); err != nil {
597
+ return err
598
+ }
599
+ if err := s.RemoveConfigField(scope, fmt.Sprintf("providers.%s.chatgpt_models", providerID)); err != nil {
600
+ return err
601
+ }
602
+ }
603
+ case *oauth.Token:
604
+ // Hold the refresh lock across the write so a peer's in-flight
605
+ // token exchange cannot land on top of a credential the user just
606
+ // obtained interactively — which would silently invalidate the
607
+ // login they only just completed.
608
+ fields := map[string]any{
609
+ fmt.Sprintf("providers.%s.oauth", providerID): v,
610
+ }
611
+ if providerID != string(catwalk.InferenceProviderOpenAI) {
612
+ fields[fmt.Sprintf("providers.%s.api_key", providerID)] = v.AccessToken
613
+ }
614
+ if err := s.withRefreshLock(providerID, func() error {
615
+ return s.SetConfigFields(scope, fields)
616
+ }); err != nil {
617
+ return err
618
+ }
619
+ if providerID == string(catwalk.InferenceProviderOpenAI) {
620
+ // Either OAuth or an API key, never both: the login retires
621
+ // any key that came before it.
622
+ if err := s.RemoveConfigField(scope, fmt.Sprintf("providers.%s.api_key", providerID)); err != nil {
623
+ return err
624
+ }
625
+ }
626
+ setKeyOrToken = func() {
627
+ providerConfig.OAuthToken = v
628
+ if providerID == string(catwalk.InferenceProviderOpenAI) {
629
+ isToken = true
630
+ providerConfig.APIKey = ""
631
+ return
632
+ }
633
+ providerConfig.APIKey = v.AccessToken
634
+ if providerID == string(catwalk.InferenceProviderCopilot) {
635
+ providerConfig.SetupGitHubCopilot()
636
+ }
637
+ }
638
+ }
639
+
640
+ cfg := s.Config()
641
+ providerConfig, exists = cfg.Providers.Get(providerID)
642
+ if exists {
643
+ setKeyOrToken()
644
+ cfg.Providers.Set(providerID, providerConfig)
645
+ } else {
646
+ var foundProvider *catwalk.Provider
647
+ for _, p := range s.knownProviders {
648
+ if string(p.ID) == providerID {
649
+ foundProvider = &p
650
+ break
651
+ }
652
+ }
653
+
654
+ if foundProvider != nil {
655
+ providerConfig = ProviderConfig{
656
+ ID: providerID,
657
+ Name: foundProvider.Name,
658
+ BaseURL: foundProvider.APIEndpoint,
659
+ Type: foundProvider.Type,
660
+ Disable: false,
661
+ ExtraHeaders: make(map[string]string),
662
+ ExtraParams: make(map[string]string),
663
+ Models: foundProvider.Models,
664
+ }
665
+ setKeyOrToken()
666
+ } else {
667
+ return fmt.Errorf("provider with ID %s not found in known providers", providerID)
668
+ }
669
+ cfg.Providers.Set(providerID, providerConfig)
670
+ }
671
+
672
+ // After authenticating with Hyper, re-fetch the provider catalog so
673
+ // the latest models are available without restarting.
674
+ if providerID == "hyper" {
675
+ if refetchErr := s.RefetchHyperProvider(context.Background()); refetchErr != nil {
676
+ slog.Warn("Failed to refetch Hyper provider after auth", "error", refetchErr)
677
+ }
678
+ }
679
+ // After authenticating with a ChatGPT account, fetch the Codex model
680
+ // catalog the subscription grants and persist it so the models
681
+ // dialog can offer it beside the API-key catalog.
682
+ if providerID == string(catwalk.InferenceProviderOpenAI) && isToken {
683
+ s.refetchOpenAIModels(context.Background(), scope)
684
+ }
685
+ return nil
686
+ }
687
+
688
+ // refetchOpenAIModels stores the Codex model catalog the ChatGPT plan
689
+ // grants next to the provider's API-key models. Best effort: a failure
690
+ // leaves the existing catalog in place and the login still succeeds.
691
+ func (s *ConfigStore) refetchOpenAIModels(ctx context.Context, scope Scope) {
692
+ cfg := s.Config()
693
+ pc, ok := cfg.Providers.Get(string(catwalk.InferenceProviderOpenAI))
694
+ if !ok || pc.OAuthToken == nil {
695
+ return
696
+ }
697
+ fetchModels := s.fetchOpenAIModels
698
+ if fetchModels == nil {
699
+ fetchModels = openai.Models
700
+ }
701
+ models, err := fetchModels(ctx, pc.OAuthToken)
702
+ if err != nil {
703
+ slog.Warn("Failed to fetch ChatGPT model catalog after auth", "error", err)
704
+ return
705
+ }
706
+ if err := s.update(scope, func(c *Config) map[string]any {
707
+ p, ok := c.Providers.Get(string(catwalk.InferenceProviderOpenAI))
708
+ if !ok {
709
+ return nil
710
+ }
711
+ p.ChatGPTModels = models
712
+ c.Providers.Set(string(catwalk.InferenceProviderOpenAI), p)
713
+ return map[string]any{
714
+ "providers.openai.chatgpt_models": models,
715
+ }
716
+ }); err != nil {
717
+ slog.Warn("Failed to persist ChatGPT model catalog", "error", err)
718
+ }
719
+ }
720
+
721
+ // RefetchOpenAIChatGPTModels fills in the ChatGPT model catalog when the
722
+ // OpenAI provider is signed in but has none — because the fetch at login
723
+ // time failed, or the credentials predate the catalog. A no-op once the
724
+ // catalog exists, so callers can invoke it freely on model updates.
725
+ func (s *ConfigStore) RefetchOpenAIChatGPTModels(ctx context.Context) {
726
+ cfg := s.Config()
727
+ pc, ok := cfg.Providers.Get(string(catwalk.InferenceProviderOpenAI))
728
+ if !ok || pc.OAuthToken == nil || len(pc.ChatGPTModels) > 0 {
729
+ return
730
+ }
731
+ s.refetchOpenAIModels(ctx, ScopeGlobal)
732
+ }
733
+
734
+ // RefreshOAuthToken refreshes the OAuth token for the given provider.
735
+ //
736
+ // Providers like Hyper rotate refresh tokens: each exchange consumes the
737
+ // caller's refresh token, issues a new pair, and revokes the old one. If
738
+ // two crush instances (or two goroutines) refresh concurrently with the
739
+ // same stored refresh token, the second exchange reuses an already-revoked
740
+ // token, trips the provider's reuse detection, and revokes the entire
741
+ // token family — leaving both with dead tokens even though each refresh
742
+ // "succeeded".
743
+ //
744
+ // To prevent that, refreshes are single-flighted at two levels:
745
+ //
746
+ // - In-process: refreshSF collapses concurrent goroutines for the same
747
+ // provider into one attempt.
748
+ // - Cross-process: a per-provider advisory lock is held across the whole
749
+ // read-decide-exchange-write cycle, so only one process exchanges at a
750
+ // time. A process that acquires the lock after a peer rotated finds the
751
+ // peer's fresh token on disk and adopts it instead of exchanging.
752
+ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
753
+ key := fmt.Sprintf("%d\x00%s", scope, providerID)
754
+ _, err, _ := s.refreshSF.Do(key, func() (any, error) {
755
+ return nil, s.refreshOAuthTokenLocked(ctx, scope, providerID)
756
+ })
757
+ return err
758
+ }
759
+
760
+ // refreshOAuthTokenLocked performs the cross-process single-flighted
761
+ // refresh. It is invoked through refreshSF, so at most one goroutine per
762
+ // provider runs it at a time within this process.
763
+ func (s *ConfigStore) refreshOAuthTokenLocked(ctx context.Context, scope Scope, providerID string) error {
764
+ cfg := s.Config()
765
+ providerConfig, exists := cfg.Providers.Get(providerID)
766
+ if !exists {
767
+ return fmt.Errorf("provider %s not found", providerID)
768
+ }
769
+ if providerConfig.OAuthToken == nil {
770
+ return fmt.Errorf("provider %s does not have an OAuth token", providerID)
771
+ }
772
+ entryToken := providerConfig.OAuthToken
773
+
774
+ // Acquire the per-provider cross-process refresh lock. This is a
775
+ // dedicated lock file, not the config-write lock, and it does not take
776
+ // s.mu — so the network exchange below cannot stall unrelated config
777
+ // operations. The deadline exceeds the exchange timeout so that a peer
778
+ // mid-exchange has time to publish a token we can adopt. Lock ordering:
779
+ // the refresh lock is always taken before the config-write lock (via
780
+ // SetConfigFields), never the reverse, so no deadlock is possible.
781
+ lockCtx, cancel := context.WithTimeout(ctx, refreshLockDeadline)
782
+ defer cancel()
783
+ release, lockErr := lock.File(lockCtx, s.refreshLockPath(providerID))
784
+ if lockErr != nil {
785
+ // Could not acquire the lock (peer wedged or deadline hit). Prefer a
786
+ // usable token already on disk over forcing our own exchange, which
787
+ // would risk reusing a rotated refresh token.
788
+ if diskToken := s.usableDiskToken(scope, providerID, entryToken); diskToken != nil {
789
+ slog.Warn("Refresh lock unavailable; adopting token from disk", "provider", providerID, "error", lockErr)
790
+ return s.applyToken(providerConfig, diskToken, providerID)
791
+ }
792
+ return fmt.Errorf("acquire refresh lock for provider %s: %w", providerID, lockErr)
793
+ }
794
+ defer release()
795
+
796
+ // Now that we hold the lock, disk is the authority on which credential
797
+ // is current: a peer may have rotated ours away while we waited. Adopt
798
+ // a newer token outright when it is still usable, and otherwise switch
799
+ // to its refresh token for the exchange below. Presenting our own
800
+ // already-rotated refresh token would trip the provider's reuse
801
+ // detection and revoke the whole family, forcing an interactive login.
802
+ if diskToken := s.newerDiskToken(scope, providerID, entryToken); diskToken != nil {
803
+ if !diskToken.IsExpired() {
804
+ slog.Info("Adopting token refreshed by another session", "provider", providerID)
805
+ return s.applyToken(providerConfig, diskToken, providerID)
806
+ }
807
+ slog.Info("Exchanging with refresh token rotated by another session", "provider", providerID)
808
+ entryToken = diskToken
809
+ }
810
+
811
+ // Disk still holds our token (or no newer peer token exists) and we hold
812
+ // the lock, so we are the sole exchanger. Perform the exchange.
813
+ refreshedToken, refreshErr := s.exchange(ctx, providerID, entryToken.RefreshToken)
814
+ if refreshErr != nil {
815
+ // The exchange may have failed because a peer rotated the refresh
816
+ // token in a window we did not cover. Re-check disk: adopt a usable
817
+ // token, or retry once with the peer's newer refresh token.
818
+ if diskToken := s.newerDiskToken(scope, providerID, entryToken); diskToken != nil {
819
+ if !diskToken.IsExpired() {
820
+ slog.Info("Adopting token refreshed by another session after exchange failure", "provider", providerID)
821
+ return s.applyToken(providerConfig, diskToken, providerID)
822
+ }
823
+ slog.Info("Retrying exchange with refresh token rotated by another session", "provider", providerID)
824
+ refreshedToken, refreshErr = s.exchange(ctx, providerID, diskToken.RefreshToken)
825
+ }
826
+ }
827
+ if refreshErr != nil {
828
+ return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
829
+ }
830
+
831
+ slog.Info("Successfully refreshed OAuth token", "provider", providerID)
832
+ if err := s.applyToken(providerConfig, refreshedToken, providerID); err != nil {
833
+ return err
834
+ }
835
+
836
+ if err := s.SetConfigFields(scope, tokenFields(providerID, refreshedToken)); err != nil {
837
+ return fmt.Errorf("failed to persist refreshed token: %w", err)
838
+ }
839
+ return nil
840
+ }
841
+
842
+ // tokenFields builds the config fields that persist an OAuth token. The
843
+ // OpenAI provider does not mirror the access token into api_key so a
844
+ // manually entered API key can coexist with the ChatGPT login.
845
+ func tokenFields(providerID string, token *oauth.Token) map[string]any {
846
+ fields := map[string]any{
847
+ fmt.Sprintf("providers.%s.oauth", providerID): token,
848
+ }
849
+ if providerID != string(catwalk.InferenceProviderOpenAI) {
850
+ fields[fmt.Sprintf("providers.%s.api_key", providerID)] = token.AccessToken
851
+ }
852
+ return fields
853
+ }
854
+
855
+ // WaitForTokenChange blocks until SignalAuthComplete is called for the
856
+ // given provider or the context is cancelled. It is used by OnAuthRefresh
857
+ // callbacks to wait for interactive re-authentication to complete before
858
+ // retrying a failed request. The channel is created atomically with the
859
+ // wait registration so a concurrent SignalAuthComplete cannot miss it.
860
+ func (s *ConfigStore) WaitForTokenChange(ctx context.Context, providerID string) error {
861
+ s.authSignalMu.Lock()
862
+ ch, ok := s.authSignals[providerID]
863
+ if !ok {
864
+ ch = make(chan struct{})
865
+ if s.authSignals == nil {
866
+ s.authSignals = make(map[string]chan struct{})
867
+ }
868
+ s.authSignals[providerID] = ch
869
+ }
870
+ s.authSignalMu.Unlock()
871
+
872
+ select {
873
+ case <-ch:
874
+ // Remove the consumed signal so a subsequent
875
+ // SignalAuthComplete does not close an already-closed
876
+ // channel.
877
+ s.authSignalMu.Lock()
878
+ if s.authSignals[providerID] == ch {
879
+ delete(s.authSignals, providerID)
880
+ }
881
+ s.authSignalMu.Unlock()
882
+ return nil
883
+ case <-ctx.Done():
884
+ return ctx.Err()
885
+ }
886
+ }
887
+
888
+ // SignalAuthComplete unblocks any goroutine waiting in WaitForTokenChange
889
+ // for the given provider. If no waiter exists yet, it pre-creates and
890
+ // immediately closes the channel so a subsequent WaitForTokenChange
891
+ // returns without blocking. This eliminates the race where the signal
892
+ // fires before the waiter registers.
893
+ func (s *ConfigStore) SignalAuthComplete(providerID string) {
894
+ s.authSignalMu.Lock()
895
+ defer s.authSignalMu.Unlock()
896
+ if ch, ok := s.authSignals[providerID]; ok {
897
+ delete(s.authSignals, providerID)
898
+ select {
899
+ case <-ch:
900
+ // Already closed by a previous signal; nothing to do.
901
+ default:
902
+ close(ch)
903
+ }
904
+ } else {
905
+ // No waiter yet. Pre-create a closed channel so the next
906
+ // WaitForTokenChange returns immediately.
907
+ if s.authSignals == nil {
908
+ s.authSignals = make(map[string]chan struct{})
909
+ }
910
+ ch := make(chan struct{})
911
+ close(ch)
912
+ s.authSignals[providerID] = ch
913
+ }
914
+ }
915
+
916
+ // newerDiskToken returns the on-disk token for the provider when it is
917
+ // newer than entryToken — i.e. another session (possibly in another
918
+ // process) has already rotated the credential. It returns nil when disk
919
+ // holds nothing newer than what we started with.
920
+ //
921
+ // Newness is judged by expiry as well as identity, so a config file that
922
+ // somehow holds an older token cannot drag us backwards. The result may
923
+ // itself be expired: providers that rotate refresh tokens invalidate ours
924
+ // the moment a peer refreshes, so the peer's refresh token is the only one
925
+ // the provider will still accept even after its access token ages out.
926
+ // Callers decide whether to adopt the token wholesale or merely borrow its
927
+ // refresh token.
928
+ func (s *ConfigStore) newerDiskToken(scope Scope, providerID string, entryToken *oauth.Token) *oauth.Token {
929
+ diskToken, err := s.loadTokenFromDisk(scope, providerID)
930
+ if err != nil {
931
+ slog.Warn("Failed to read token from config file", "provider", providerID, "error", err)
932
+ return nil
933
+ }
934
+ if diskToken == nil {
935
+ return nil
936
+ }
937
+ if diskToken.AccessToken == entryToken.AccessToken {
938
+ // Same token we started with; nobody rotated since.
939
+ return nil
940
+ }
941
+ if diskToken.RefreshToken == "" && entryToken.RefreshToken != "" {
942
+ // Adopting would strand us with no way to refresh later, and
943
+ // there is nothing to borrow for an exchange.
944
+ return nil
945
+ }
946
+ if diskToken.ExpiresAt < entryToken.ExpiresAt {
947
+ // Older than ours; nothing to gain from adopting it.
948
+ return nil
949
+ }
950
+ return diskToken
951
+ }
952
+
953
+ // usableDiskToken returns the on-disk token only when it is both newer
954
+ // than entryToken and still valid, meaning it can be adopted as-is with
955
+ // no exchange at all.
956
+ func (s *ConfigStore) usableDiskToken(scope Scope, providerID string, entryToken *oauth.Token) *oauth.Token {
957
+ diskToken := s.newerDiskToken(scope, providerID, entryToken)
958
+ if diskToken == nil || diskToken.IsExpired() {
959
+ return nil
960
+ }
961
+ return diskToken
962
+ }
963
+
964
+ // exchange performs the provider-specific OAuth token exchange. Tests may
965
+ // override it via the exchangeToken field; production uses the real
966
+ // provider clients.
967
+ func (s *ConfigStore) exchange(ctx context.Context, providerID, refreshToken string) (*oauth.Token, error) {
968
+ if s.exchangeToken != nil {
969
+ return s.exchangeToken(ctx, providerID, refreshToken)
970
+ }
971
+ switch providerID {
972
+ case string(catwalk.InferenceProviderCopilot):
973
+ return copilot.RefreshToken(ctx, refreshToken)
974
+ case string(catwalk.InferenceProviderOpenAI):
975
+ return openai.RefreshToken(ctx, refreshToken)
976
+ case hyperp.Name:
977
+ return hyper.ExchangeToken(ctx, refreshToken)
978
+ default:
979
+ return nil, fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
980
+ }
981
+ }
982
+
983
+ // withRefreshLock runs fn while holding the per-provider cross-process
984
+ // refresh lock, so a credential write cannot interleave with a peer's
985
+ // token exchange. Acquisition is best effort: when the lock cannot be
986
+ // taken in time, fn runs anyway rather than blocking a write the user is
987
+ // waiting on.
988
+ func (s *ConfigStore) withRefreshLock(providerID string, fn func() error) error {
989
+ ctx, cancel := context.WithTimeout(context.Background(), credentialWriteLockDeadline)
990
+ defer cancel()
991
+ release, err := lock.File(ctx, s.refreshLockPath(providerID))
992
+ if err != nil {
993
+ slog.Warn("Writing credentials without the refresh lock", "provider", providerID, "error", err)
994
+ return fn()
995
+ }
996
+ defer release()
997
+ return fn()
998
+ }
999
+
1000
+ // refreshLockPath returns the path to the per-provider cross-process refresh
1001
+ // lock file. Lock files live under a dedicated locks/ subdirectory of the
1002
+ // data dir so they do not clutter the config directory. The file is created
1003
+ // on demand by lock.File and is never removed (flock keys on inode, not
1004
+ // path).
1005
+ func (s *ConfigStore) refreshLockPath(providerID string) string {
1006
+ dir := filepath.Join(filepath.Dir(s.globalDataPath), "locks")
1007
+ _ = os.MkdirAll(dir, 0o755)
1008
+ return filepath.Join(dir, fmt.Sprintf("%s.refresh.lock", providerID))
1009
+ }
1010
+
1011
+ // applyToken updates the in-memory provider config with the given token.
1012
+ func (s *ConfigStore) applyToken(providerConfig ProviderConfig, token *oauth.Token, providerID string) error {
1013
+ providerConfig.OAuthToken = token
1014
+ // The OpenAI provider holds exactly one credential, so a ChatGPT
1015
+ // token means there is no API key side by side with it.
1016
+ if providerID != string(catwalk.InferenceProviderOpenAI) {
1017
+ providerConfig.APIKey = token.AccessToken
1018
+ }
1019
+ if providerID == string(catwalk.InferenceProviderCopilot) {
1020
+ providerConfig.SetupGitHubCopilot()
1021
+ }
1022
+ s.Config().Providers.Set(providerID, providerConfig)
1023
+ return nil
1024
+ }
1025
+
1026
+ // loadTokenFromDisk reads the OAuth token for the given provider from the
1027
+ // config file on disk. Returns nil if the token is not found or matches the
1028
+ // current in-memory token.
1029
+ func (s *ConfigStore) loadTokenFromDisk(scope Scope, providerID string) (*oauth.Token, error) {
1030
+ path, err := s.configPath(scope)
1031
+ if err != nil {
1032
+ return nil, err
1033
+ }
1034
+
1035
+ data, err := os.ReadFile(path)
1036
+ if err != nil {
1037
+ if os.IsNotExist(err) {
1038
+ return nil, nil
1039
+ }
1040
+ return nil, err
1041
+ }
1042
+
1043
+ oauthKey := fmt.Sprintf("providers.%s.oauth", providerID)
1044
+ oauthResult := gjson.Get(string(data), oauthKey)
1045
+ if !oauthResult.Exists() {
1046
+ return nil, nil
1047
+ }
1048
+
1049
+ var token oauth.Token
1050
+ if err := json.Unmarshal([]byte(oauthResult.Raw), &token); err != nil {
1051
+ return nil, err
1052
+ }
1053
+
1054
+ if token.AccessToken == "" {
1055
+ return nil, nil
1056
+ }
1057
+
1058
+ return &token, nil
1059
+ }
1060
+
1061
+ // nextRecentModels computes the recent-models list for the given type
1062
+ // after recording the supplied model at the front, operating on the
1063
+ // provided config without persisting anything. It returns the new slice
1064
+ // and whether it differs from cfg's current list. Callers fold the result
1065
+ // into a clone they are about to publish.
1066
+ func nextRecentModels(cfg *Config, modelType SelectedModelType, model SelectedModel) ([]SelectedModel, bool) {
1067
+ if model.Provider == "" || model.Model == "" {
1068
+ return nil, false
1069
+ }
1070
+
1071
+ eq := func(a, b SelectedModel) bool {
1072
+ return a.Provider == b.Provider && a.Model == b.Model
1073
+ }
1074
+
1075
+ entry := SelectedModel{
1076
+ Provider: model.Provider,
1077
+ Model: model.Model,
1078
+ }
1079
+
1080
+ current := cfg.RecentModels[modelType]
1081
+ withoutCurrent := slices.DeleteFunc(slices.Clone(current), func(existing SelectedModel) bool {
1082
+ return eq(existing, entry)
1083
+ })
1084
+
1085
+ updated := append([]SelectedModel{entry}, withoutCurrent...)
1086
+ if len(updated) > maxRecentModelsPerType {
1087
+ updated = updated[:maxRecentModelsPerType]
1088
+ }
1089
+
1090
+ if slices.EqualFunc(current, updated, eq) {
1091
+ return current, false
1092
+ }
1093
+
1094
+ return updated, true
1095
+ }
1096
+
1097
+ // NewTestStore creates a ConfigStore for testing purposes.
1098
+ func NewTestStore(cfg *Config, loadedPaths ...string) *ConfigStore {
1099
+ return &ConfigStore{
1100
+ config: cfg,
1101
+ loadedPaths: loadedPaths,
1102
+ resolver: NewShellVariableResolver(env.New()),
1103
+ }
1104
+ }
1105
+
1106
+ // ImportCopilot attempts to import a GitHub Copilot token from disk.
1107
+ func (s *ConfigStore) ImportCopilot() (*oauth.Token, bool) {
1108
+ if s.HasConfigField(ScopeGlobal, "providers.copilot.api_key") || s.HasConfigField(ScopeGlobal, "providers.copilot.oauth") {
1109
+ return nil, false
1110
+ }
1111
+
1112
+ diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
1113
+ if !hasDiskToken {
1114
+ return nil, false
1115
+ }
1116
+
1117
+ slog.Info("Found existing GitHub Copilot token on disk. Authenticating...")
1118
+ token, err := copilot.RefreshToken(context.TODO(), diskToken)
1119
+ if err != nil {
1120
+ slog.Error("Unable to import GitHub Copilot token", "error", err)
1121
+ return nil, false
1122
+ }
1123
+
1124
+ if err := s.SetProviderAPIKey(ScopeGlobal, string(catwalk.InferenceProviderCopilot), token); err != nil {
1125
+ return token, false
1126
+ }
1127
+
1128
+ if err := s.SetConfigFields(ScopeGlobal, map[string]any{
1129
+ "providers.copilot.api_key": token.AccessToken,
1130
+ "providers.copilot.oauth": token,
1131
+ }); err != nil {
1132
+ slog.Error("Unable to save GitHub Copilot token to disk", "error", err)
1133
+ }
1134
+
1135
+ slog.Info("GitHub Copilot successfully imported")
1136
+ return token, true
1137
+ }
1138
+
1139
+ // StalenessResult contains the result of a staleness check.
1140
+ type StalenessResult struct {
1141
+ Dirty bool
1142
+ Changed []string
1143
+ Missing []string
1144
+ Errors map[string]error // stat errors by path
1145
+ }
1146
+
1147
+ // ConfigStaleness checks whether any tracked config files have changed on disk
1148
+ // since the last snapshot. Returns dirty=true if any files changed or went
1149
+ // missing, along with sorted lists of affected paths. Stat errors are
1150
+ // captured in Errors map but still treated as non-existence for dirty detection.
1151
+ func (s *ConfigStore) ConfigStaleness() StalenessResult {
1152
+ var result StalenessResult
1153
+ result.Errors = make(map[string]error)
1154
+
1155
+ for _, path := range s.trackedConfigPaths {
1156
+ snapshot, hadSnapshot := s.snapshots[path]
1157
+
1158
+ info, err := os.Stat(path)
1159
+ exists := err == nil && !info.IsDir()
1160
+
1161
+ if err != nil && !os.IsNotExist(err) {
1162
+ // Capture permission/IO errors separately from non-existence
1163
+ result.Errors[path] = err
1164
+ result.Dirty = true
1165
+ }
1166
+
1167
+ if !exists {
1168
+ if hadSnapshot && snapshot.Exists {
1169
+ // File existed before but now missing
1170
+ result.Missing = append(result.Missing, path)
1171
+ result.Dirty = true
1172
+ }
1173
+ continue
1174
+ }
1175
+
1176
+ // File exists now
1177
+ if !hadSnapshot || !snapshot.Exists {
1178
+ // File didn't exist before but does now
1179
+ result.Changed = append(result.Changed, path)
1180
+ result.Dirty = true
1181
+ continue
1182
+ }
1183
+
1184
+ // Check for content or metadata changes
1185
+ if snapshot.Size != info.Size() || snapshot.ModTime != info.ModTime().UnixNano() {
1186
+ result.Changed = append(result.Changed, path)
1187
+ result.Dirty = true
1188
+ }
1189
+ }
1190
+
1191
+ // Sort for deterministic output
1192
+ slices.Sort(result.Changed)
1193
+ slices.Sort(result.Missing)
1194
+
1195
+ return result
1196
+ }
1197
+
1198
+ // RefreshStalenessSnapshot captures fresh snapshots of all tracked config files.
1199
+ // Call this after reloading config to clear dirty state.
1200
+ func (s *ConfigStore) RefreshStalenessSnapshot() error {
1201
+ if s.snapshots == nil {
1202
+ s.snapshots = make(map[string]fileSnapshot)
1203
+ }
1204
+
1205
+ for _, path := range s.trackedConfigPaths {
1206
+ info, err := os.Stat(path)
1207
+ exists := err == nil && !info.IsDir()
1208
+
1209
+ snapshot := fileSnapshot{
1210
+ Path: path,
1211
+ Exists: exists,
1212
+ }
1213
+
1214
+ if exists {
1215
+ snapshot.Size = info.Size()
1216
+ snapshot.ModTime = info.ModTime().UnixNano()
1217
+ }
1218
+
1219
+ s.snapshots[path] = snapshot
1220
+ }
1221
+
1222
+ return nil
1223
+ }
1224
+
1225
+ // CaptureStalenessSnapshot captures snapshots for the given paths, building the
1226
+ // tracked config paths list. Paths are deduplicated and normalized.
1227
+ func (s *ConfigStore) CaptureStalenessSnapshot(paths []string) {
1228
+ // Build unique set of normalized paths
1229
+ seen := make(map[string]struct{})
1230
+ for _, p := range paths {
1231
+ if p == "" {
1232
+ continue
1233
+ }
1234
+ // Normalize path
1235
+ abs, err := filepath.Abs(p)
1236
+ if err != nil {
1237
+ abs = p
1238
+ }
1239
+ seen[abs] = struct{}{}
1240
+ }
1241
+
1242
+ // Also track workspace and global config paths if set
1243
+ if s.workspacePath != "" {
1244
+ abs, err := filepath.Abs(s.workspacePath)
1245
+ if err == nil {
1246
+ seen[abs] = struct{}{}
1247
+ }
1248
+ }
1249
+ if s.globalDataPath != "" {
1250
+ abs, err := filepath.Abs(s.globalDataPath)
1251
+ if err == nil {
1252
+ seen[abs] = struct{}{}
1253
+ }
1254
+ }
1255
+
1256
+ // Build sorted list for deterministic ordering
1257
+ s.trackedConfigPaths = make([]string, 0, len(seen))
1258
+ for p := range seen {
1259
+ s.trackedConfigPaths = append(s.trackedConfigPaths, p)
1260
+ }
1261
+ slices.Sort(s.trackedConfigPaths)
1262
+
1263
+ // Capture initial snapshots
1264
+ s.RefreshStalenessSnapshot()
1265
+ }
1266
+
1267
+ // captureStalenessSnapshot is an alias for CaptureStalenessSnapshot for internal use.
1268
+ func (s *ConfigStore) captureStalenessSnapshot(paths []string) {
1269
+ s.CaptureStalenessSnapshot(paths)
1270
+ }
1271
+
1272
+ // ReloadFromDisk re-runs the config load/merge flow and updates the in-memory
1273
+ // config atomically. It rebuilds the staleness snapshot after successful reload.
1274
+ // On failure, the store state is rolled back to its previous state.
1275
+ // Concurrent calls are serialised via writeMu.
1276
+ func (s *ConfigStore) ReloadFromDisk(ctx context.Context) error {
1277
+ if s.workingDir == "" {
1278
+ return fmt.Errorf("cannot reload: working directory not set")
1279
+ }
1280
+ s.writeMu.Lock()
1281
+ defer s.writeMu.Unlock()
1282
+ return s.reloadFromDiskLocked(ctx)
1283
+ }
1284
+
1285
+ // reloadFromDiskLocked performs the actual reload. Caller must hold writeMu.
1286
+ func (s *ConfigStore) reloadFromDiskLocked(ctx context.Context) error {
1287
+ // Migrate deprecated disable_notifications before reloading config.
1288
+ migrateDisableNotifications()
1289
+
1290
+ configPaths := lookupConfigs(s.workingDir)
1291
+ cfg, loadedPaths, err := loadFromConfigPaths(ctx, configPaths)
1292
+ if err != nil {
1293
+ return fmt.Errorf("failed to reload config: %w", err)
1294
+ }
1295
+
1296
+ // Apply defaults (using existing data directory if set)
1297
+ var dataDir string
1298
+ if cur := s.Config(); cur != nil && cur.Options != nil {
1299
+ dataDir = cur.Options.DataDirectory
1300
+ }
1301
+ cfg.setDefaults(s.workingDir, dataDir)
1302
+
1303
+ // Merge workspace config if present
1304
+ workspacePath := filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName))
1305
+ if wsData, err := os.ReadFile(workspacePath); err == nil && len(wsData) > 0 {
1306
+ if !json.Valid(wsData) {
1307
+ return fmt.Errorf("invalid JSON in config file %s", workspacePath)
1308
+ }
1309
+ merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
1310
+ if mergeErr == nil {
1311
+ dataDir := cfg.Options.DataDirectory
1312
+ *cfg = *merged
1313
+ cfg.setDefaults(s.workingDir, dataDir)
1314
+ loadedPaths = append(loadedPaths, workspacePath)
1315
+ }
1316
+ }
1317
+
1318
+ // Validate hooks after all config merging is complete so matcher
1319
+ // regexes are recompiled on the reloaded config (mirrors Load).
1320
+ if err := cfg.ValidateHooks(); err != nil {
1321
+ return fmt.Errorf("invalid hook configuration on reload: %w", err)
1322
+ }
1323
+
1324
+ // Save current state for potential rollback BEFORE configureProviders,
1325
+ // which may write to disk via RemoveConfigField (e.g. removing stale
1326
+ // OAuth providers). Capturing after would snapshot a config that has
1327
+ // already been mutated, and the rollback would restore corrupted state.
1328
+ oldConfig := s.Config()
1329
+ oldLoadedPaths := s.loadedPaths
1330
+ oldResolver := s.resolver
1331
+ oldKnownProviders := s.knownProviders
1332
+ oldOverrides := s.overrides
1333
+ oldWorkspacePath := s.workspacePath
1334
+
1335
+ // Preserve runtime overrides
1336
+ overrides := s.overrides
1337
+
1338
+ // Reapply model choices made in this instance. The global config file is
1339
+ // shared, so it may now name a model a sibling instance selected; a
1340
+ // reload triggered by an unrelated write must not swap the user's model
1341
+ // mid-session. An external edit to the config still takes effect for any
1342
+ // model type this instance never chose.
1343
+ maps.Copy(cfg.Models, overrides.Models)
1344
+
1345
+ // Reconfigure providers
1346
+ env := env.New()
1347
+ resolver := NewShellVariableResolver(env)
1348
+
1349
+ // Apply top-level env vars before configuring providers so variables
1350
+ // like AWS_PROFILE are visible to the AWS SDK credential chain.
1351
+ cfg.applyEnv(resolver)
1352
+
1353
+ providers, err := Providers(cfg)
1354
+ if err != nil {
1355
+ if len(providers) == 0 {
1356
+ return fmt.Errorf("failed to load providers during reload: %w", err)
1357
+ }
1358
+ slog.Warn("Reload continuing with the previously known providers", "error", err)
1359
+ }
1360
+
1361
+ if err := cfg.configureProviders(ctx, s, env, resolver, providers); err != nil {
1362
+ return fmt.Errorf("failed to configure providers during reload: %w", err)
1363
+ }
1364
+
1365
+ // Update store state BEFORE running model/agent setup (so they see new config)
1366
+ s.setConfig(cfg)
1367
+ s.loadedPaths = loadedPaths
1368
+ s.resolver = resolver
1369
+ s.knownProviders = providers
1370
+ s.overrides = overrides
1371
+ s.workspacePath = workspacePath
1372
+
1373
+ // Mirror startup flow: setup models and agents against NEW config.
1374
+ var setupErr error
1375
+ if !cfg.IsConfigured() {
1376
+ slog.Warn("No providers configured after reload")
1377
+ } else {
1378
+ resolved, resolveErr := resolveSelectedModels(cfg, providers)
1379
+ if resolveErr != nil {
1380
+ setupErr = fmt.Errorf("failed to configure selected models during reload: %w", resolveErr)
1381
+ } else {
1382
+ cfg.Models[SelectedModelTypeLarge] = resolved.Large
1383
+ cfg.Models[SelectedModelTypeSmall] = resolved.Small
1384
+ s.SetupAgents()
1385
+ }
1386
+ }
1387
+
1388
+ // Rollback on setup failure
1389
+ if setupErr != nil {
1390
+ s.setConfig(oldConfig)
1391
+ s.loadedPaths = oldLoadedPaths
1392
+ s.resolver = oldResolver
1393
+ s.knownProviders = oldKnownProviders
1394
+ s.overrides = oldOverrides
1395
+ s.workspacePath = oldWorkspacePath
1396
+ return setupErr
1397
+ }
1398
+
1399
+ // Rebuild staleness tracking. Track every discovered config path, not
1400
+ // just the ones that loaded, so a config file created after this reload
1401
+ // is detected as a change on the next staleness check.
1402
+ s.captureStalenessSnapshot(append(slices.Clone(configPaths), loadedPaths...))
1403
+
1404
+ return nil
1405
+ }
1406
+
1407
+ // autoReload conditionally reloads config from disk after writes.
1408
+ // It returns nil (no error) for expected skip cases: when auto-reload is
1409
+ // disabled during load/reload flows, or when working directory is not set
1410
+ // (e.g., during testing). Only actual reload failures return an error.
1411
+ func (s *ConfigStore) autoReload(ctx context.Context) error {
1412
+ if s.workingDir == "" {
1413
+ return nil // Expected skip: working directory not set
1414
+ }
1415
+ // Skip if a reload is already in progress. This handles both
1416
+ // concurrent auto-reloads after parallel writes and re-entrant
1417
+ // calls from configureProviders during a reload.
1418
+ //
1419
+ // Note: if a write completes after the in-progress reload has
1420
+ // already read the config file, that write won't be reflected in
1421
+ // memory until the next reload. This is acceptable because writes
1422
+ // are rare and the next user action or file-watch tick will pick
1423
+ // up the change. Callers that need guaranteed fresh state after a
1424
+ // write should call ReloadFromDisk explicitly.
1425
+ if !s.writeMu.TryLock() {
1426
+ return nil
1427
+ }
1428
+ defer s.writeMu.Unlock()
1429
+ return s.reloadFromDiskLocked(ctx)
1430
+ }
internal/config/update_model_bench_test.go ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "path/filepath"
7
+ "testing"
8
+ )
9
+
10
+ // BenchmarkUpdatePreferredModel measures the full write cost of a single
11
+ // model selection, which is what the user experiences when picking a model
12
+ // in the TUI. It guards against regressing back to a full config reload on
13
+ // every selection.
14
+ func BenchmarkUpdatePreferredModel(b *testing.B) {
15
+ dir := b.TempDir()
16
+ configPath := filepath.Join(dir, "crush.json")
17
+
18
+ b.Setenv("CRUSH_GLOBAL_CONFIG", dir)
19
+ b.Setenv("CRUSH_GLOBAL_DATA", dir)
20
+ resetProviderState()
21
+ b.Cleanup(resetProviderState)
22
+
23
+ cfg := `{
24
+ "models": {
25
+ "large": {"provider": "openai", "model": "gpt-4"},
26
+ "small": {"provider": "openai", "model": "gpt-4o-mini"}
27
+ },
28
+ "providers": {
29
+ "openai": {
30
+ "api_key": "test-key",
31
+ "models": [
32
+ {"id": "gpt-4", "name": "GPT-4"},
33
+ {"id": "gpt-4o", "name": "GPT-4o"},
34
+ {"id": "gpt-4o-mini", "name": "GPT-4o mini"}
35
+ ]
36
+ },
37
+ "anthropic": {
38
+ "api_key": "test-key-2",
39
+ "models": [
40
+ {"id": "claude-3", "name": "Claude 3"},
41
+ {"id": "claude-3-5", "name": "Claude 3.5"}
42
+ ]
43
+ }
44
+ }
45
+ }`
46
+ if err := os.WriteFile(configPath, []byte(cfg), 0o600); err != nil {
47
+ b.Fatal(err)
48
+ }
49
+
50
+ store, err := Load(dir, dir, false)
51
+ if err != nil {
52
+ b.Fatal(err)
53
+ }
54
+ store.globalDataPath = configPath
55
+
56
+ models := []SelectedModel{
57
+ {Provider: "openai", Model: "gpt-4"},
58
+ {Provider: "anthropic", Model: "claude-3"},
59
+ {Provider: "openai", Model: "gpt-4o"},
60
+ {Provider: "anthropic", Model: "claude-3-5"},
61
+ }
62
+
63
+ b.ReportAllocs()
64
+ i := 0
65
+ for b.Loop() {
66
+ m := models[i%len(models)]
67
+ if err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, m); err != nil {
68
+ b.Fatal(err)
69
+ }
70
+ i++
71
+ }
72
+ }
73
+
74
+ // BenchmarkReloadFromDisk isolates the full reload cost for comparison with
75
+ // BenchmarkUpdatePreferredModel.
76
+ func BenchmarkReloadFromDisk(b *testing.B) {
77
+ dir := b.TempDir()
78
+ configPath := filepath.Join(dir, "crush.json")
79
+
80
+ b.Setenv("CRUSH_GLOBAL_CONFIG", dir)
81
+ b.Setenv("CRUSH_GLOBAL_DATA", dir)
82
+ resetProviderState()
83
+ b.Cleanup(resetProviderState)
84
+
85
+ cfg := `{
86
+ "models": {"large": {"provider": "openai", "model": "gpt-4"}},
87
+ "providers": {
88
+ "openai": {"api_key": "test-key", "models": [{"id": "gpt-4", "name": "GPT-4"}]}
89
+ }
90
+ }`
91
+ if err := os.WriteFile(configPath, []byte(cfg), 0o600); err != nil {
92
+ b.Fatal(err)
93
+ }
94
+
95
+ store, err := Load(dir, dir, false)
96
+ if err != nil {
97
+ b.Fatal(err)
98
+ }
99
+ store.globalDataPath = configPath
100
+
101
+ ctx := context.Background()
102
+ b.ReportAllocs()
103
+ for b.Loop() {
104
+ if err := store.ReloadFromDisk(ctx); err != nil {
105
+ b.Fatal(err)
106
+ }
107
+ }
108
+ }
internal/csync/doc.go ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ // Package csync provides concurrent data structures for safe access in
2
+ // multi-threaded environments.
3
+ package csync
internal/csync/maps.go ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package csync
2
+
3
+ import (
4
+ "encoding/json"
5
+ "iter"
6
+ "maps"
7
+ "sync"
8
+ )
9
+
10
+ // Map is a concurrent map implementation that provides thread-safe access.
11
+ type Map[K comparable, V any] struct {
12
+ inner map[K]V
13
+ mu sync.RWMutex
14
+ }
15
+
16
+ // NewMap creates a new thread-safe map with the specified key and value types.
17
+ func NewMap[K comparable, V any]() *Map[K, V] {
18
+ return &Map[K, V]{
19
+ inner: make(map[K]V),
20
+ }
21
+ }
22
+
23
+ // NewMapFrom creates a new thread-safe map from an existing map.
24
+ func NewMapFrom[K comparable, V any](m map[K]V) *Map[K, V] {
25
+ return &Map[K, V]{
26
+ inner: m,
27
+ }
28
+ }
29
+
30
+ // NewLazyMap creates a new lazy-loaded map. The provided load function is
31
+ // executed in a separate goroutine to populate the map.
32
+ func NewLazyMap[K comparable, V any](load func() map[K]V) *Map[K, V] {
33
+ m := &Map[K, V]{}
34
+ m.mu.Lock()
35
+ go func() {
36
+ defer m.mu.Unlock()
37
+ m.inner = load()
38
+ }()
39
+ return m
40
+ }
41
+
42
+ // Reset replaces the inner map with the new one.
43
+ func (m *Map[K, V]) Reset(input map[K]V) {
44
+ m.mu.Lock()
45
+ defer m.mu.Unlock()
46
+ m.inner = input
47
+ }
48
+
49
+ // Set sets the value for the specified key in the map.
50
+ func (m *Map[K, V]) Set(key K, value V) {
51
+ m.mu.Lock()
52
+ defer m.mu.Unlock()
53
+ m.inner[key] = value
54
+ }
55
+
56
+ // Del deletes the specified key from the map.
57
+ func (m *Map[K, V]) Del(key K) {
58
+ m.mu.Lock()
59
+ defer m.mu.Unlock()
60
+ delete(m.inner, key)
61
+ }
62
+
63
+ // CompareAndDelete deletes the key only if the current value matches the
64
+ // expected pointer. Returns true if the deletion occurred. This is the
65
+ // ABA-safe cleanup primitive: it prevents a deferred cleanup from removing
66
+ // a value that was replaced by a newer writer in the window between the
67
+ // explicit Del and the deferred Del.
68
+ func (m *Map[K, V]) CompareAndDelete(key K, expected any) bool {
69
+ m.mu.Lock()
70
+ defer m.mu.Unlock()
71
+ current, ok := m.inner[key]
72
+ if !ok {
73
+ return false
74
+ }
75
+ if any(current) != expected {
76
+ return false
77
+ }
78
+ delete(m.inner, key)
79
+ return true
80
+ }
81
+
82
+ // Get gets the value for the specified key from the map.
83
+ func (m *Map[K, V]) Get(key K) (V, bool) {
84
+ m.mu.RLock()
85
+ defer m.mu.RUnlock()
86
+ v, ok := m.inner[key]
87
+ return v, ok
88
+ }
89
+
90
+ // Len returns the number of items in the map.
91
+ func (m *Map[K, V]) Len() int {
92
+ m.mu.RLock()
93
+ defer m.mu.RUnlock()
94
+ return len(m.inner)
95
+ }
96
+
97
+ // GetOrSet gets and returns the key if it exists, otherwise, it executes the
98
+ // given function, set its return value for the given key, and returns it.
99
+ func (m *Map[K, V]) GetOrSet(key K, fn func() V) V {
100
+ got, ok := m.Get(key)
101
+ if ok {
102
+ return got
103
+ }
104
+ value := fn()
105
+ m.Set(key, value)
106
+ return value
107
+ }
108
+
109
+ // Take gets an item and then deletes it.
110
+ func (m *Map[K, V]) Take(key K) (V, bool) {
111
+ m.mu.Lock()
112
+ defer m.mu.Unlock()
113
+ v, ok := m.inner[key]
114
+ delete(m.inner, key)
115
+ return v, ok
116
+ }
117
+
118
+ // Copy returns a copy of the inner map.
119
+ func (m *Map[K, V]) Copy() map[K]V {
120
+ m.mu.RLock()
121
+ defer m.mu.RUnlock()
122
+ return maps.Clone(m.inner)
123
+ }
124
+
125
+ // Seq2 returns an iter.Seq2 that yields key-value pairs from the map.
126
+ func (m *Map[K, V]) Seq2() iter.Seq2[K, V] {
127
+ dst := m.Copy()
128
+ return func(yield func(K, V) bool) {
129
+ for k, v := range dst {
130
+ if !yield(k, v) {
131
+ return
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ // Seq returns an iter.Seq that yields values from the map.
138
+ func (m *Map[K, V]) Seq() iter.Seq[V] {
139
+ return func(yield func(V) bool) {
140
+ for _, v := range m.Seq2() {
141
+ if !yield(v) {
142
+ return
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ var (
149
+ _ json.Unmarshaler = &Map[string, any]{}
150
+ _ json.Marshaler = &Map[string, any]{}
151
+ )
152
+
153
+ // JSONSchemaAlias returns the underlying map type for JSON schema generation.
154
+ // Value receiver is required because github.com/invopop/jsonschema checks
155
+ // interface satisfaction on the non-pointer type after stripping pointers.
156
+ func (Map[K, V]) JSONSchemaAlias() any { //nolint
157
+ m := map[K]V{}
158
+ return m
159
+ }
160
+
161
+ // UnmarshalJSON implements json.Unmarshaler.
162
+ func (m *Map[K, V]) UnmarshalJSON(data []byte) error {
163
+ m.mu.Lock()
164
+ defer m.mu.Unlock()
165
+ m.inner = make(map[K]V)
166
+ return json.Unmarshal(data, &m.inner)
167
+ }
168
+
169
+ // MarshalJSON implements json.Marshaler.
170
+ func (m *Map[K, V]) MarshalJSON() ([]byte, error) {
171
+ m.mu.RLock()
172
+ defer m.mu.RUnlock()
173
+ return json.Marshal(m.inner)
174
+ }
internal/csync/maps_test.go ADDED
@@ -0,0 +1,770 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package csync
2
+
3
+ import (
4
+ "encoding/json"
5
+ "maps"
6
+ "sync"
7
+ "sync/atomic"
8
+ "testing"
9
+ "testing/synctest"
10
+ "time"
11
+
12
+ "github.com/stretchr/testify/require"
13
+ )
14
+
15
+ func TestNewMap(t *testing.T) {
16
+ t.Parallel()
17
+
18
+ m := NewMap[string, int]()
19
+ require.NotNil(t, m)
20
+ require.NotNil(t, m.inner)
21
+ require.Equal(t, 0, m.Len())
22
+ }
23
+
24
+ func TestNewMapFrom(t *testing.T) {
25
+ t.Parallel()
26
+
27
+ original := map[string]int{
28
+ "key1": 1,
29
+ "key2": 2,
30
+ }
31
+
32
+ m := NewMapFrom(original)
33
+ require.NotNil(t, m)
34
+ require.Equal(t, original, m.inner)
35
+ require.Equal(t, 2, m.Len())
36
+
37
+ value, ok := m.Get("key1")
38
+ require.True(t, ok)
39
+ require.Equal(t, 1, value)
40
+ }
41
+
42
+ func TestNewLazyMap(t *testing.T) {
43
+ t.Parallel()
44
+
45
+ synctest.Test(t, func(t *testing.T) {
46
+ t.Helper()
47
+
48
+ waiter := sync.Mutex{}
49
+ waiter.Lock()
50
+ var loadCalled atomic.Bool
51
+
52
+ loadFunc := func() map[string]int {
53
+ waiter.Lock()
54
+ defer waiter.Unlock()
55
+ loadCalled.Store(true)
56
+ return map[string]int{
57
+ "key1": 1,
58
+ "key2": 2,
59
+ }
60
+ }
61
+
62
+ m := NewLazyMap(loadFunc)
63
+ require.NotNil(t, m)
64
+
65
+ waiter.Unlock() // Allow the load function to proceed
66
+ time.Sleep(100 * time.Millisecond)
67
+ require.True(t, loadCalled.Load())
68
+ require.Equal(t, 2, m.Len())
69
+
70
+ value, ok := m.Get("key1")
71
+ require.True(t, ok)
72
+ require.Equal(t, 1, value)
73
+ })
74
+ }
75
+
76
+ func TestMap_Reset(t *testing.T) {
77
+ t.Parallel()
78
+
79
+ m := NewMapFrom(map[string]int{
80
+ "a": 10,
81
+ })
82
+
83
+ m.Reset(map[string]int{
84
+ "b": 20,
85
+ })
86
+ value, ok := m.Get("b")
87
+ require.True(t, ok)
88
+ require.Equal(t, 20, value)
89
+ require.Equal(t, 1, m.Len())
90
+ }
91
+
92
+ func TestMap_Set(t *testing.T) {
93
+ t.Parallel()
94
+
95
+ m := NewMap[string, int]()
96
+
97
+ m.Set("key1", 42)
98
+ value, ok := m.Get("key1")
99
+ require.True(t, ok)
100
+ require.Equal(t, 42, value)
101
+ require.Equal(t, 1, m.Len())
102
+
103
+ m.Set("key1", 100)
104
+ value, ok = m.Get("key1")
105
+ require.True(t, ok)
106
+ require.Equal(t, 100, value)
107
+ require.Equal(t, 1, m.Len())
108
+ }
109
+
110
+ func TestMap_GetOrSet(t *testing.T) {
111
+ t.Parallel()
112
+
113
+ m := NewMap[string, int]()
114
+
115
+ require.Equal(t, 42, m.GetOrSet("key1", func() int { return 42 }))
116
+ require.Equal(t, 42, m.GetOrSet("key1", func() int { return 99999 }))
117
+ require.Equal(t, 1, m.Len())
118
+ }
119
+
120
+ func TestMap_Get(t *testing.T) {
121
+ t.Parallel()
122
+
123
+ m := NewMap[string, int]()
124
+
125
+ value, ok := m.Get("nonexistent")
126
+ require.False(t, ok)
127
+ require.Equal(t, 0, value)
128
+
129
+ m.Set("key1", 42)
130
+ value, ok = m.Get("key1")
131
+ require.True(t, ok)
132
+ require.Equal(t, 42, value)
133
+ }
134
+
135
+ func TestMap_Del(t *testing.T) {
136
+ t.Parallel()
137
+
138
+ m := NewMap[string, int]()
139
+ m.Set("key1", 42)
140
+ m.Set("key2", 100)
141
+
142
+ require.Equal(t, 2, m.Len())
143
+
144
+ m.Del("key1")
145
+ _, ok := m.Get("key1")
146
+ require.False(t, ok)
147
+ require.Equal(t, 1, m.Len())
148
+
149
+ value, ok := m.Get("key2")
150
+ require.True(t, ok)
151
+ require.Equal(t, 100, value)
152
+
153
+ m.Del("nonexistent")
154
+ require.Equal(t, 1, m.Len())
155
+ }
156
+
157
+ func TestMap_Len(t *testing.T) {
158
+ t.Parallel()
159
+
160
+ m := NewMap[string, int]()
161
+ require.Equal(t, 0, m.Len())
162
+
163
+ m.Set("key1", 1)
164
+ require.Equal(t, 1, m.Len())
165
+
166
+ m.Set("key2", 2)
167
+ require.Equal(t, 2, m.Len())
168
+
169
+ m.Del("key1")
170
+ require.Equal(t, 1, m.Len())
171
+
172
+ m.Del("key2")
173
+ require.Equal(t, 0, m.Len())
174
+ }
175
+
176
+ // TestMap_CompareAndDelete verifies that CompareAndDelete only removes the
177
+ // entry when the stored pointer matches the expected value. This is the
178
+ // ABA-safe cleanup primitive used by the dispatch completion path to prevent
179
+ // a deferred cleanup from wiping a newer run's entry.
180
+ func TestMap_CompareAndDelete(t *testing.T) {
181
+ t.Parallel()
182
+
183
+ type entry struct{ val int }
184
+ m := NewMap[string, *entry]()
185
+
186
+ original := &entry{val: 1}
187
+ m.Set("k", original)
188
+
189
+ // Matching pointer: deletion succeeds.
190
+ require.True(t, m.CompareAndDelete("k", any(original)))
191
+ _, ok := m.Get("k")
192
+ require.False(t, ok)
193
+
194
+ // Re-set with a new value, try to delete with the stale pointer.
195
+ second := &entry{val: 2}
196
+ m.Set("k", second)
197
+ require.False(t, m.CompareAndDelete("k", any(original)), "stale pointer must not delete")
198
+ got, ok := m.Get("k")
199
+ require.True(t, ok)
200
+ require.Equal(t, 2, got.val)
201
+
202
+ // Missing key: returns false.
203
+ require.False(t, m.CompareAndDelete("absent", any(original)))
204
+ }
205
+
206
+ func TestMap_Take(t *testing.T) {
207
+ t.Parallel()
208
+
209
+ m := NewMap[string, int]()
210
+ m.Set("key1", 42)
211
+ m.Set("key2", 100)
212
+
213
+ require.Equal(t, 2, m.Len())
214
+
215
+ value, ok := m.Take("key1")
216
+ require.True(t, ok)
217
+ require.Equal(t, 42, value)
218
+ require.Equal(t, 1, m.Len())
219
+
220
+ _, exists := m.Get("key1")
221
+ require.False(t, exists)
222
+
223
+ value, ok = m.Get("key2")
224
+ require.True(t, ok)
225
+ require.Equal(t, 100, value)
226
+ }
227
+
228
+ func TestMap_Take_NonexistentKey(t *testing.T) {
229
+ t.Parallel()
230
+
231
+ m := NewMap[string, int]()
232
+ m.Set("key1", 42)
233
+
234
+ value, ok := m.Take("nonexistent")
235
+ require.False(t, ok)
236
+ require.Equal(t, 0, value)
237
+ require.Equal(t, 1, m.Len())
238
+
239
+ value, ok = m.Get("key1")
240
+ require.True(t, ok)
241
+ require.Equal(t, 42, value)
242
+ }
243
+
244
+ func TestMap_Take_EmptyMap(t *testing.T) {
245
+ t.Parallel()
246
+
247
+ m := NewMap[string, int]()
248
+
249
+ value, ok := m.Take("key1")
250
+ require.False(t, ok)
251
+ require.Equal(t, 0, value)
252
+ require.Equal(t, 0, m.Len())
253
+ }
254
+
255
+ func TestMap_Take_SameKeyTwice(t *testing.T) {
256
+ t.Parallel()
257
+
258
+ m := NewMap[string, int]()
259
+ m.Set("key1", 42)
260
+
261
+ value, ok := m.Take("key1")
262
+ require.True(t, ok)
263
+ require.Equal(t, 42, value)
264
+ require.Equal(t, 0, m.Len())
265
+
266
+ value, ok = m.Take("key1")
267
+ require.False(t, ok)
268
+ require.Equal(t, 0, value)
269
+ require.Equal(t, 0, m.Len())
270
+ }
271
+
272
+ func TestMap_Seq2(t *testing.T) {
273
+ t.Parallel()
274
+
275
+ m := NewMap[string, int]()
276
+ m.Set("key1", 1)
277
+ m.Set("key2", 2)
278
+ m.Set("key3", 3)
279
+
280
+ collected := maps.Collect(m.Seq2())
281
+
282
+ require.Equal(t, 3, len(collected))
283
+ require.Equal(t, 1, collected["key1"])
284
+ require.Equal(t, 2, collected["key2"])
285
+ require.Equal(t, 3, collected["key3"])
286
+ }
287
+
288
+ func TestMap_Seq2_EarlyReturn(t *testing.T) {
289
+ t.Parallel()
290
+
291
+ m := NewMap[string, int]()
292
+ m.Set("key1", 1)
293
+ m.Set("key2", 2)
294
+ m.Set("key3", 3)
295
+
296
+ count := 0
297
+ for range m.Seq2() {
298
+ count++
299
+ if count == 2 {
300
+ break
301
+ }
302
+ }
303
+
304
+ require.Equal(t, 2, count)
305
+ }
306
+
307
+ func TestMap_Seq2_EmptyMap(t *testing.T) {
308
+ t.Parallel()
309
+
310
+ m := NewMap[string, int]()
311
+
312
+ count := 0
313
+ for range m.Seq2() {
314
+ count++
315
+ }
316
+
317
+ require.Equal(t, 0, count)
318
+ }
319
+
320
+ func TestMap_Seq(t *testing.T) {
321
+ t.Parallel()
322
+
323
+ m := NewMap[string, int]()
324
+ m.Set("key1", 1)
325
+ m.Set("key2", 2)
326
+ m.Set("key3", 3)
327
+
328
+ collected := make([]int, 0)
329
+ for v := range m.Seq() {
330
+ collected = append(collected, v)
331
+ }
332
+
333
+ require.Equal(t, 3, len(collected))
334
+ require.Contains(t, collected, 1)
335
+ require.Contains(t, collected, 2)
336
+ require.Contains(t, collected, 3)
337
+ }
338
+
339
+ func TestMap_Seq_EarlyReturn(t *testing.T) {
340
+ t.Parallel()
341
+
342
+ m := NewMap[string, int]()
343
+ m.Set("key1", 1)
344
+ m.Set("key2", 2)
345
+ m.Set("key3", 3)
346
+
347
+ count := 0
348
+ for range m.Seq() {
349
+ count++
350
+ if count == 2 {
351
+ break
352
+ }
353
+ }
354
+
355
+ require.Equal(t, 2, count)
356
+ }
357
+
358
+ func TestMap_Seq_EmptyMap(t *testing.T) {
359
+ t.Parallel()
360
+
361
+ m := NewMap[string, int]()
362
+
363
+ count := 0
364
+ for range m.Seq() {
365
+ count++
366
+ }
367
+
368
+ require.Equal(t, 0, count)
369
+ }
370
+
371
+ func TestMap_MarshalJSON(t *testing.T) {
372
+ t.Parallel()
373
+
374
+ m := NewMap[string, int]()
375
+ m.Set("key1", 1)
376
+ m.Set("key2", 2)
377
+
378
+ data, err := json.Marshal(m)
379
+ require.NoError(t, err)
380
+
381
+ result := &Map[string, int]{}
382
+ err = json.Unmarshal(data, result)
383
+ require.NoError(t, err)
384
+ require.Equal(t, 2, result.Len())
385
+ v1, _ := result.Get("key1")
386
+ v2, _ := result.Get("key2")
387
+ require.Equal(t, 1, v1)
388
+ require.Equal(t, 2, v2)
389
+ }
390
+
391
+ func TestMap_MarshalJSON_EmptyMap(t *testing.T) {
392
+ t.Parallel()
393
+
394
+ m := NewMap[string, int]()
395
+
396
+ data, err := json.Marshal(m)
397
+ require.NoError(t, err)
398
+ require.Equal(t, "{}", string(data))
399
+ }
400
+
401
+ func TestMap_UnmarshalJSON(t *testing.T) {
402
+ t.Parallel()
403
+
404
+ jsonData := `{"key1": 1, "key2": 2}`
405
+
406
+ m := NewMap[string, int]()
407
+ err := json.Unmarshal([]byte(jsonData), m)
408
+ require.NoError(t, err)
409
+
410
+ require.Equal(t, 2, m.Len())
411
+ value, ok := m.Get("key1")
412
+ require.True(t, ok)
413
+ require.Equal(t, 1, value)
414
+
415
+ value, ok = m.Get("key2")
416
+ require.True(t, ok)
417
+ require.Equal(t, 2, value)
418
+ }
419
+
420
+ func TestMap_UnmarshalJSON_EmptyJSON(t *testing.T) {
421
+ t.Parallel()
422
+
423
+ jsonData := `{}`
424
+
425
+ m := NewMap[string, int]()
426
+ err := json.Unmarshal([]byte(jsonData), m)
427
+ require.NoError(t, err)
428
+ require.Equal(t, 0, m.Len())
429
+ }
430
+
431
+ func TestMap_UnmarshalJSON_InvalidJSON(t *testing.T) {
432
+ t.Parallel()
433
+
434
+ jsonData := `{"key1": 1, "key2":}`
435
+
436
+ m := NewMap[string, int]()
437
+ err := json.Unmarshal([]byte(jsonData), m)
438
+ require.Error(t, err)
439
+ }
440
+
441
+ func TestMap_UnmarshalJSON_OverwritesExistingData(t *testing.T) {
442
+ t.Parallel()
443
+
444
+ m := NewMap[string, int]()
445
+ m.Set("existing", 999)
446
+
447
+ jsonData := `{"key1": 1, "key2": 2}`
448
+ err := json.Unmarshal([]byte(jsonData), m)
449
+ require.NoError(t, err)
450
+
451
+ require.Equal(t, 2, m.Len())
452
+ _, ok := m.Get("existing")
453
+ require.False(t, ok)
454
+
455
+ value, ok := m.Get("key1")
456
+ require.True(t, ok)
457
+ require.Equal(t, 1, value)
458
+ }
459
+
460
+ func TestMap_JSONRoundTrip(t *testing.T) {
461
+ t.Parallel()
462
+
463
+ original := NewMap[string, int]()
464
+ original.Set("key1", 1)
465
+ original.Set("key2", 2)
466
+ original.Set("key3", 3)
467
+
468
+ data, err := json.Marshal(original)
469
+ require.NoError(t, err)
470
+
471
+ restored := NewMap[string, int]()
472
+ err = json.Unmarshal(data, restored)
473
+ require.NoError(t, err)
474
+
475
+ require.Equal(t, original.Len(), restored.Len())
476
+
477
+ for k, v := range original.Seq2() {
478
+ restoredValue, ok := restored.Get(k)
479
+ require.True(t, ok)
480
+ require.Equal(t, v, restoredValue)
481
+ }
482
+ }
483
+
484
+ func TestMap_ConcurrentAccess(t *testing.T) {
485
+ t.Parallel()
486
+
487
+ m := NewMap[int, int]()
488
+ const numGoroutines = 100
489
+ const numOperations = 100
490
+
491
+ var wg sync.WaitGroup
492
+ wg.Add(numGoroutines)
493
+
494
+ for i := range numGoroutines {
495
+ go func(id int) {
496
+ defer wg.Done()
497
+ for j := range numOperations {
498
+ key := id*numOperations + j
499
+ m.Set(key, key*2)
500
+ value, ok := m.Get(key)
501
+ require.True(t, ok)
502
+ require.Equal(t, key*2, value)
503
+ }
504
+ }(i)
505
+ }
506
+
507
+ wg.Wait()
508
+
509
+ require.Equal(t, numGoroutines*numOperations, m.Len())
510
+ }
511
+
512
+ func TestMap_ConcurrentReadWrite(t *testing.T) {
513
+ t.Parallel()
514
+
515
+ m := NewMap[int, int]()
516
+ const numReaders = 50
517
+ const numWriters = 50
518
+ const numOperations = 100
519
+
520
+ for i := range 1000 {
521
+ m.Set(i, i)
522
+ }
523
+
524
+ var wg sync.WaitGroup
525
+ wg.Add(numReaders + numWriters)
526
+
527
+ for range numReaders {
528
+ go func() {
529
+ defer wg.Done()
530
+ for j := range numOperations {
531
+ key := j % 1000
532
+ value, ok := m.Get(key)
533
+ if ok {
534
+ require.Equal(t, key, value)
535
+ }
536
+ _ = m.Len()
537
+ }
538
+ }()
539
+ }
540
+
541
+ for i := range numWriters {
542
+ go func(id int) {
543
+ defer wg.Done()
544
+ for j := range numOperations {
545
+ key := 1000 + id*numOperations + j
546
+ m.Set(key, key)
547
+ if j%10 == 0 {
548
+ m.Del(key)
549
+ }
550
+ }
551
+ }(i)
552
+ }
553
+
554
+ wg.Wait()
555
+ }
556
+
557
+ func TestMap_ConcurrentSeq2(t *testing.T) {
558
+ t.Parallel()
559
+
560
+ m := NewMap[int, int]()
561
+ for i := range 100 {
562
+ m.Set(i, i*2)
563
+ }
564
+
565
+ var wg sync.WaitGroup
566
+ const numIterators = 10
567
+
568
+ wg.Add(numIterators)
569
+ for range numIterators {
570
+ go func() {
571
+ defer wg.Done()
572
+ count := 0
573
+ for k, v := range m.Seq2() {
574
+ require.Equal(t, k*2, v)
575
+ count++
576
+ }
577
+ require.Equal(t, 100, count)
578
+ }()
579
+ }
580
+
581
+ wg.Wait()
582
+ }
583
+
584
+ func TestMap_ConcurrentSeq(t *testing.T) {
585
+ t.Parallel()
586
+
587
+ m := NewMap[int, int]()
588
+ for i := range 100 {
589
+ m.Set(i, i*2)
590
+ }
591
+
592
+ var wg sync.WaitGroup
593
+ const numIterators = 10
594
+
595
+ wg.Add(numIterators)
596
+ for range numIterators {
597
+ go func() {
598
+ defer wg.Done()
599
+ count := 0
600
+ values := make(map[int]bool)
601
+ for v := range m.Seq() {
602
+ values[v] = true
603
+ count++
604
+ }
605
+ require.Equal(t, 100, count)
606
+ for i := range 100 {
607
+ require.True(t, values[i*2])
608
+ }
609
+ }()
610
+ }
611
+
612
+ wg.Wait()
613
+ }
614
+
615
+ func TestMap_ConcurrentTake(t *testing.T) {
616
+ t.Parallel()
617
+
618
+ m := NewMap[int, int]()
619
+ const numItems = 1000
620
+
621
+ for i := range numItems {
622
+ m.Set(i, i*2)
623
+ }
624
+
625
+ var wg sync.WaitGroup
626
+ const numWorkers = 10
627
+ taken := make([][]int, numWorkers)
628
+
629
+ wg.Add(numWorkers)
630
+ for i := range numWorkers {
631
+ go func(workerID int) {
632
+ defer wg.Done()
633
+ taken[workerID] = make([]int, 0)
634
+ for j := workerID; j < numItems; j += numWorkers {
635
+ if value, ok := m.Take(j); ok {
636
+ taken[workerID] = append(taken[workerID], value)
637
+ }
638
+ }
639
+ }(i)
640
+ }
641
+
642
+ wg.Wait()
643
+
644
+ require.Equal(t, 0, m.Len())
645
+
646
+ allTaken := make(map[int]bool)
647
+ for _, workerTaken := range taken {
648
+ for _, value := range workerTaken {
649
+ require.False(t, allTaken[value], "Value %d was taken multiple times", value)
650
+ allTaken[value] = true
651
+ }
652
+ }
653
+
654
+ require.Equal(t, numItems, len(allTaken))
655
+ for i := range numItems {
656
+ require.True(t, allTaken[i*2], "Expected value %d to be taken", i*2)
657
+ }
658
+ }
659
+
660
+ func TestMap_TypeSafety(t *testing.T) {
661
+ t.Parallel()
662
+
663
+ stringIntMap := NewMap[string, int]()
664
+ stringIntMap.Set("key", 42)
665
+ value, ok := stringIntMap.Get("key")
666
+ require.True(t, ok)
667
+ require.Equal(t, 42, value)
668
+
669
+ intStringMap := NewMap[int, string]()
670
+ intStringMap.Set(42, "value")
671
+ strValue, ok := intStringMap.Get(42)
672
+ require.True(t, ok)
673
+ require.Equal(t, "value", strValue)
674
+
675
+ structMap := NewMap[string, struct{ Name string }]()
676
+ structMap.Set("key", struct{ Name string }{Name: "test"})
677
+ structValue, ok := structMap.Get("key")
678
+ require.True(t, ok)
679
+ require.Equal(t, "test", structValue.Name)
680
+ }
681
+
682
+ func TestMap_InterfaceCompliance(t *testing.T) {
683
+ t.Parallel()
684
+
685
+ var _ json.Marshaler = &Map[string, any]{}
686
+ var _ json.Unmarshaler = &Map[string, any]{}
687
+ }
688
+
689
+ func BenchmarkMap_Set(b *testing.B) {
690
+ m := NewMap[int, int]()
691
+
692
+ for i := 0; b.Loop(); i++ {
693
+ m.Set(i, i*2)
694
+ }
695
+ }
696
+
697
+ func BenchmarkMap_Get(b *testing.B) {
698
+ m := NewMap[int, int]()
699
+ for i := range 1000 {
700
+ m.Set(i, i*2)
701
+ }
702
+
703
+ for i := 0; b.Loop(); i++ {
704
+ m.Get(i % 1000)
705
+ }
706
+ }
707
+
708
+ func BenchmarkMap_Seq2(b *testing.B) {
709
+ m := NewMap[int, int]()
710
+ for i := range 1000 {
711
+ m.Set(i, i*2)
712
+ }
713
+
714
+ for b.Loop() {
715
+ for range m.Seq2() {
716
+ }
717
+ }
718
+ }
719
+
720
+ func BenchmarkMap_Seq(b *testing.B) {
721
+ m := NewMap[int, int]()
722
+ for i := range 1000 {
723
+ m.Set(i, i*2)
724
+ }
725
+
726
+ for b.Loop() {
727
+ for range m.Seq() {
728
+ }
729
+ }
730
+ }
731
+
732
+ func BenchmarkMap_Take(b *testing.B) {
733
+ m := NewMap[int, int]()
734
+ for i := range 1000 {
735
+ m.Set(i, i*2)
736
+ }
737
+
738
+ b.ResetTimer()
739
+ for i := 0; b.Loop(); i++ {
740
+ key := i % 1000
741
+ m.Take(key)
742
+ if i%1000 == 999 {
743
+ b.StopTimer()
744
+ for j := range 1000 {
745
+ m.Set(j, j*2)
746
+ }
747
+ b.StartTimer()
748
+ }
749
+ }
750
+ }
751
+
752
+ func BenchmarkMap_ConcurrentReadWrite(b *testing.B) {
753
+ m := NewMap[int, int]()
754
+ for i := range 1000 {
755
+ m.Set(i, i*2)
756
+ }
757
+
758
+ b.ResetTimer()
759
+ b.RunParallel(func(pb *testing.PB) {
760
+ i := 0
761
+ for pb.Next() {
762
+ if i%2 == 0 {
763
+ m.Get(i % 1000)
764
+ } else {
765
+ m.Set(i+1000, i*2)
766
+ }
767
+ i++
768
+ }
769
+ })
770
+ }
internal/csync/slices.go ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package csync
2
+
3
+ import (
4
+ "iter"
5
+ "sync"
6
+ )
7
+
8
+ // LazySlice is a thread-safe lazy-loaded slice.
9
+ type LazySlice[K any] struct {
10
+ inner []K
11
+ wg sync.WaitGroup
12
+ }
13
+
14
+ // NewLazySlice creates a new slice and runs the [load] function in a goroutine
15
+ // to populate it.
16
+ func NewLazySlice[K any](load func() []K) *LazySlice[K] {
17
+ s := &LazySlice[K]{}
18
+ s.wg.Go(func() {
19
+ s.inner = load()
20
+ })
21
+ return s
22
+ }
23
+
24
+ // Seq returns an iterator that yields elements from the slice.
25
+ func (s *LazySlice[K]) Seq() iter.Seq[K] {
26
+ s.wg.Wait()
27
+ return func(yield func(K) bool) {
28
+ for _, v := range s.inner {
29
+ if !yield(v) {
30
+ return
31
+ }
32
+ }
33
+ }
34
+ }
35
+
36
+ // Slice is a thread-safe slice implementation that provides concurrent access.
37
+ type Slice[T any] struct {
38
+ inner []T
39
+ mu sync.RWMutex
40
+ }
41
+
42
+ // NewSlice creates a new thread-safe slice.
43
+ func NewSlice[T any]() *Slice[T] {
44
+ return &Slice[T]{
45
+ inner: make([]T, 0),
46
+ }
47
+ }
48
+
49
+ // NewSliceFrom creates a new thread-safe slice from an existing slice.
50
+ func NewSliceFrom[T any](s []T) *Slice[T] {
51
+ inner := make([]T, len(s))
52
+ copy(inner, s)
53
+ return &Slice[T]{
54
+ inner: inner,
55
+ }
56
+ }
57
+
58
+ // Append adds an element to the end of the slice.
59
+ func (s *Slice[T]) Append(items ...T) {
60
+ s.mu.Lock()
61
+ defer s.mu.Unlock()
62
+ s.inner = append(s.inner, items...)
63
+ }
64
+
65
+ // Get returns the element at the specified index.
66
+ func (s *Slice[T]) Get(index int) (T, bool) {
67
+ s.mu.RLock()
68
+ defer s.mu.RUnlock()
69
+ var zero T
70
+ if index < 0 || index >= len(s.inner) {
71
+ return zero, false
72
+ }
73
+ return s.inner[index], true
74
+ }
75
+
76
+ // Len returns the number of elements in the slice.
77
+ func (s *Slice[T]) Len() int {
78
+ s.mu.RLock()
79
+ defer s.mu.RUnlock()
80
+ return len(s.inner)
81
+ }
82
+
83
+ // SetSlice replaces the entire slice with a new one.
84
+ func (s *Slice[T]) SetSlice(items []T) {
85
+ s.mu.Lock()
86
+ defer s.mu.Unlock()
87
+ s.inner = make([]T, len(items))
88
+ copy(s.inner, items)
89
+ }
90
+
91
+ // Seq returns an iterator that yields elements from the slice.
92
+ func (s *Slice[T]) Seq() iter.Seq[T] {
93
+ return func(yield func(T) bool) {
94
+ for _, v := range s.Seq2() {
95
+ if !yield(v) {
96
+ return
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ // Seq2 returns an iterator that yields index-value pairs from the slice.
103
+ func (s *Slice[T]) Seq2() iter.Seq2[int, T] {
104
+ items := s.Copy()
105
+ return func(yield func(int, T) bool) {
106
+ for i, v := range items {
107
+ if !yield(i, v) {
108
+ return
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ // Copy returns a copy of the inner slice.
115
+ func (s *Slice[T]) Copy() []T {
116
+ s.mu.RLock()
117
+ defer s.mu.RUnlock()
118
+ items := make([]T, len(s.inner))
119
+ copy(items, s.inner)
120
+ return items
121
+ }
internal/csync/slices_test.go ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package csync
2
+
3
+ import (
4
+ "slices"
5
+ "sync"
6
+ "sync/atomic"
7
+ "testing"
8
+ "testing/synctest"
9
+ "time"
10
+
11
+ "github.com/stretchr/testify/require"
12
+ )
13
+
14
+ func TestLazySlice_Seq(t *testing.T) {
15
+ t.Parallel()
16
+
17
+ synctest.Test(t, func(t *testing.T) {
18
+ t.Helper()
19
+ data := []string{"a", "b", "c"}
20
+ s := NewLazySlice(func() []string {
21
+ time.Sleep(10 * time.Millisecond) // Small delay to ensure loading happens
22
+ return data
23
+ })
24
+ require.Equal(t, data, slices.Collect(s.Seq()))
25
+ })
26
+ }
27
+
28
+ func TestLazySlice_SeqWaitsForLoading(t *testing.T) {
29
+ t.Parallel()
30
+ synctest.Test(t, func(t *testing.T) {
31
+ t.Helper()
32
+
33
+ var loaded atomic.Bool
34
+ data := []string{"x", "y", "z"}
35
+
36
+ s := NewLazySlice(func() []string {
37
+ time.Sleep(100 * time.Millisecond)
38
+ loaded.Store(true)
39
+ return data
40
+ })
41
+
42
+ require.False(t, loaded.Load(), "should not be loaded immediately")
43
+ require.Equal(t, data, slices.Collect(s.Seq()))
44
+ require.True(t, loaded.Load(), "should be loaded after Seq")
45
+ })
46
+ }
47
+
48
+ func TestLazySlice_EmptySlice(t *testing.T) {
49
+ t.Parallel()
50
+ s := NewLazySlice(func() []string {
51
+ return []string{}
52
+ })
53
+ require.Empty(t, slices.Collect(s.Seq()))
54
+ }
55
+
56
+ func TestLazySlice_EarlyBreak(t *testing.T) {
57
+ t.Parallel()
58
+
59
+ synctest.Test(t, func(t *testing.T) {
60
+ t.Helper()
61
+ data := []string{"a", "b", "c", "d", "e"}
62
+ s := NewLazySlice(func() []string {
63
+ time.Sleep(10 * time.Millisecond) // Small delay to ensure loading happens
64
+ return data
65
+ })
66
+
67
+ var result []string
68
+ for v := range s.Seq() {
69
+ result = append(result, v)
70
+ if len(result) == 2 {
71
+ break
72
+ }
73
+ }
74
+
75
+ require.Equal(t, []string{"a", "b"}, result)
76
+ })
77
+ }
78
+
79
+ func TestSlice(t *testing.T) {
80
+ t.Run("NewSlice", func(t *testing.T) {
81
+ s := NewSlice[int]()
82
+ require.Equal(t, 0, s.Len())
83
+ })
84
+
85
+ t.Run("NewSliceFrom", func(t *testing.T) {
86
+ original := []int{1, 2, 3}
87
+ s := NewSliceFrom(original)
88
+ require.Equal(t, 3, s.Len())
89
+
90
+ // Verify it's a copy, not a reference
91
+ original[0] = 999
92
+ val, ok := s.Get(0)
93
+ require.True(t, ok)
94
+ require.Equal(t, 1, val)
95
+ })
96
+
97
+ t.Run("Append", func(t *testing.T) {
98
+ s := NewSlice[string]()
99
+ s.Append("hello")
100
+ s.Append("world")
101
+
102
+ require.Equal(t, 2, s.Len())
103
+ val, ok := s.Get(0)
104
+ require.True(t, ok)
105
+ require.Equal(t, "hello", val)
106
+
107
+ val, ok = s.Get(1)
108
+ require.True(t, ok)
109
+ require.Equal(t, "world", val)
110
+ })
111
+
112
+ t.Run("Get", func(t *testing.T) {
113
+ s := NewSliceFrom([]string{"a", "b", "c"})
114
+
115
+ val, ok := s.Get(1)
116
+ require.True(t, ok)
117
+ require.Equal(t, "b", val)
118
+
119
+ // Out of bounds
120
+ _, ok = s.Get(10)
121
+ require.False(t, ok)
122
+
123
+ // Negative index
124
+ _, ok = s.Get(-1)
125
+ require.False(t, ok)
126
+ })
127
+
128
+ t.Run("SetSlice", func(t *testing.T) {
129
+ s := NewSlice[int]()
130
+ s.Append(1)
131
+ s.Append(2)
132
+
133
+ newItems := []int{10, 20, 30}
134
+ s.SetSlice(newItems)
135
+
136
+ require.Equal(t, 3, s.Len())
137
+ require.Equal(t, newItems, slices.Collect(s.Seq()))
138
+
139
+ // Verify it's a copy
140
+ newItems[0] = 999
141
+ val, ok := s.Get(0)
142
+ require.True(t, ok)
143
+ require.Equal(t, 10, val)
144
+ })
145
+
146
+ t.Run("Slice", func(t *testing.T) {
147
+ original := []int{1, 2, 3}
148
+ s := NewSliceFrom(original)
149
+
150
+ copied := slices.Collect(s.Seq())
151
+ require.Equal(t, original, copied)
152
+
153
+ // Verify it's a copy
154
+ copied[0] = 999
155
+ val, ok := s.Get(0)
156
+ require.True(t, ok)
157
+ require.Equal(t, 1, val)
158
+ })
159
+
160
+ t.Run("Seq", func(t *testing.T) {
161
+ s := NewSliceFrom([]int{1, 2, 3})
162
+
163
+ var result []int
164
+ for v := range s.Seq() {
165
+ result = append(result, v)
166
+ }
167
+
168
+ require.Equal(t, []int{1, 2, 3}, result)
169
+ })
170
+
171
+ t.Run("SeqWithIndex", func(t *testing.T) {
172
+ s := NewSliceFrom([]string{"a", "b", "c"})
173
+
174
+ var indices []int
175
+ var values []string
176
+ for i, v := range s.Seq2() {
177
+ indices = append(indices, i)
178
+ values = append(values, v)
179
+ }
180
+
181
+ require.Equal(t, []int{0, 1, 2}, indices)
182
+ require.Equal(t, []string{"a", "b", "c"}, values)
183
+ })
184
+
185
+ t.Run("ConcurrentAccess", func(t *testing.T) {
186
+ s := NewSlice[int]()
187
+ const numGoroutines = 100
188
+ const itemsPerGoroutine = 10
189
+
190
+ var wg sync.WaitGroup
191
+
192
+ // Concurrent appends
193
+ for i := range numGoroutines {
194
+ wg.Add(2)
195
+ go func(start int) {
196
+ defer wg.Done()
197
+ for j := range itemsPerGoroutine {
198
+ s.Append(start*itemsPerGoroutine + j)
199
+ }
200
+ }(i)
201
+ go func() {
202
+ defer wg.Done()
203
+ for range itemsPerGoroutine {
204
+ s.Len() // Just read the length
205
+ }
206
+ }()
207
+ }
208
+
209
+ wg.Wait()
210
+
211
+ // Should have all items
212
+ require.Equal(t, numGoroutines*itemsPerGoroutine, s.Len())
213
+ })
214
+ }
internal/csync/value.go ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package csync
2
+
3
+ import (
4
+ "reflect"
5
+ "sync"
6
+ )
7
+
8
+ // Value is a generic thread-safe wrapper for any value type.
9
+ //
10
+ // For slices, use [Slice]. For maps, use [Map]. Pointers are not supported.
11
+ type Value[T any] struct {
12
+ v T
13
+ mu sync.RWMutex
14
+ }
15
+
16
+ // NewValue creates a new Value with the given initial value.
17
+ //
18
+ // Panics if t is a pointer, slice, or map. Use the dedicated types for those.
19
+ func NewValue[T any](t T) *Value[T] {
20
+ v := reflect.ValueOf(t)
21
+ switch v.Kind() {
22
+ case reflect.Pointer:
23
+ panic("csync.Value does not support pointer types")
24
+ case reflect.Slice:
25
+ panic("csync.Value does not support slice types; use csync.Slice")
26
+ case reflect.Map:
27
+ panic("csync.Value does not support map types; use csync.Map")
28
+ }
29
+ return &Value[T]{v: t}
30
+ }
31
+
32
+ // Get returns the current value.
33
+ func (v *Value[T]) Get() T {
34
+ v.mu.RLock()
35
+ defer v.mu.RUnlock()
36
+ return v.v
37
+ }
38
+
39
+ // Set updates the value.
40
+ func (v *Value[T]) Set(t T) {
41
+ v.mu.Lock()
42
+ defer v.mu.Unlock()
43
+ v.v = t
44
+ }
internal/csync/value_test.go ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package csync
2
+
3
+ import (
4
+ "sync"
5
+ "testing"
6
+
7
+ "github.com/stretchr/testify/require"
8
+ )
9
+
10
+ func TestValue_GetSet(t *testing.T) {
11
+ t.Parallel()
12
+
13
+ v := NewValue(42)
14
+ require.Equal(t, 42, v.Get())
15
+
16
+ v.Set(100)
17
+ require.Equal(t, 100, v.Get())
18
+ }
19
+
20
+ func TestValue_ZeroValue(t *testing.T) {
21
+ t.Parallel()
22
+
23
+ v := NewValue("")
24
+ require.Equal(t, "", v.Get())
25
+
26
+ v.Set("hello")
27
+ require.Equal(t, "hello", v.Get())
28
+ }
29
+
30
+ func TestValue_Struct(t *testing.T) {
31
+ t.Parallel()
32
+
33
+ type config struct {
34
+ Name string
35
+ Count int
36
+ }
37
+
38
+ v := NewValue(config{Name: "test", Count: 1})
39
+ require.Equal(t, config{Name: "test", Count: 1}, v.Get())
40
+
41
+ v.Set(config{Name: "updated", Count: 2})
42
+ require.Equal(t, config{Name: "updated", Count: 2}, v.Get())
43
+ }
44
+
45
+ func TestValue_PointerPanics(t *testing.T) {
46
+ t.Parallel()
47
+
48
+ require.Panics(t, func() {
49
+ NewValue(&struct{}{})
50
+ })
51
+ }
52
+
53
+ func TestValue_SlicePanics(t *testing.T) {
54
+ t.Parallel()
55
+
56
+ require.Panics(t, func() {
57
+ NewValue([]string{"a", "b"})
58
+ })
59
+ }
60
+
61
+ func TestValue_MapPanics(t *testing.T) {
62
+ t.Parallel()
63
+
64
+ require.Panics(t, func() {
65
+ NewValue(map[string]int{"a": 1})
66
+ })
67
+ }
68
+
69
+ func TestValue_ConcurrentAccess(t *testing.T) {
70
+ t.Parallel()
71
+
72
+ v := NewValue(0)
73
+ var wg sync.WaitGroup
74
+
75
+ // Concurrent writers.
76
+ for i := range 100 {
77
+ wg.Add(1)
78
+ go func(val int) {
79
+ defer wg.Done()
80
+ v.Set(val)
81
+ }(i)
82
+ }
83
+
84
+ // Concurrent readers.
85
+ for range 100 {
86
+ wg.Go(func() {
87
+ _ = v.Get()
88
+ })
89
+ }
90
+
91
+ wg.Wait()
92
+
93
+ // Value should be one of the set values (0-99).
94
+ got := v.Get()
95
+ require.GreaterOrEqual(t, got, 0)
96
+ require.Less(t, got, 100)
97
+ }
internal/csync/versionedmap.go ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package csync
2
+
3
+ import (
4
+ "iter"
5
+ "sync/atomic"
6
+ )
7
+
8
+ // NewVersionedMap creates a new versioned, thread-safe map.
9
+ func NewVersionedMap[K comparable, V any]() *VersionedMap[K, V] {
10
+ return &VersionedMap[K, V]{
11
+ m: NewMap[K, V](),
12
+ }
13
+ }
14
+
15
+ // VersionedMap is a thread-safe map that keeps track of its version.
16
+ type VersionedMap[K comparable, V any] struct {
17
+ m *Map[K, V]
18
+ v atomic.Uint64
19
+ }
20
+
21
+ // Get gets the value for the specified key from the map.
22
+ func (m *VersionedMap[K, V]) Get(key K) (V, bool) {
23
+ return m.m.Get(key)
24
+ }
25
+
26
+ // Set sets the value for the specified key in the map and increments the version.
27
+ func (m *VersionedMap[K, V]) Set(key K, value V) {
28
+ m.m.Set(key, value)
29
+ m.v.Add(1)
30
+ }
31
+
32
+ // Del deletes the specified key from the map and increments the version.
33
+ func (m *VersionedMap[K, V]) Del(key K) {
34
+ m.m.Del(key)
35
+ m.v.Add(1)
36
+ }
37
+
38
+ // Seq2 returns an iter.Seq2 that yields key-value pairs from the map.
39
+ func (m *VersionedMap[K, V]) Seq2() iter.Seq2[K, V] {
40
+ return m.m.Seq2()
41
+ }
42
+
43
+ // Copy returns a copy of the inner map.
44
+ func (m *VersionedMap[K, V]) Copy() map[K]V {
45
+ return m.m.Copy()
46
+ }
47
+
48
+ // Len returns the number of items in the map.
49
+ func (m *VersionedMap[K, V]) Len() int {
50
+ return m.m.Len()
51
+ }
52
+
53
+ // Version returns the current version of the map.
54
+ func (m *VersionedMap[K, V]) Version() uint64 {
55
+ return m.v.Load()
56
+ }
internal/csync/versionedmap_test.go ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package csync
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/stretchr/testify/require"
7
+ )
8
+
9
+ func TestVersionedMap_Set(t *testing.T) {
10
+ t.Parallel()
11
+
12
+ vm := NewVersionedMap[string, int]()
13
+ require.Equal(t, uint64(0), vm.Version())
14
+
15
+ vm.Set("key1", 42)
16
+ require.Equal(t, uint64(1), vm.Version())
17
+
18
+ value, ok := vm.Get("key1")
19
+ require.True(t, ok)
20
+ require.Equal(t, 42, value)
21
+ }
22
+
23
+ func TestVersionedMap_Del(t *testing.T) {
24
+ t.Parallel()
25
+
26
+ vm := NewVersionedMap[string, int]()
27
+ vm.Set("key1", 42)
28
+ initialVersion := vm.Version()
29
+
30
+ vm.Del("key1")
31
+ require.Equal(t, initialVersion+1, vm.Version())
32
+
33
+ _, ok := vm.Get("key1")
34
+ require.False(t, ok)
35
+ }
36
+
37
+ func TestVersionedMap_VersionIncrement(t *testing.T) {
38
+ t.Parallel()
39
+
40
+ vm := NewVersionedMap[string, int]()
41
+ initialVersion := vm.Version()
42
+
43
+ // Setting a value should increment the version
44
+ vm.Set("key1", 42)
45
+ require.Equal(t, initialVersion+1, vm.Version())
46
+
47
+ // Deleting a value should increment the version
48
+ vm.Del("key1")
49
+ require.Equal(t, initialVersion+2, vm.Version())
50
+
51
+ // Deleting a non-existent key should still increment the version
52
+ vm.Del("nonexistent")
53
+ require.Equal(t, initialVersion+3, vm.Version())
54
+ }
55
+
56
+ func TestVersionedMap_ConcurrentAccess(t *testing.T) {
57
+ t.Parallel()
58
+
59
+ vm := NewVersionedMap[int, int]()
60
+ const numGoroutines = 100
61
+ const numOperations = 100
62
+
63
+ // Initial version
64
+ initialVersion := vm.Version()
65
+
66
+ // Perform concurrent Set and Del operations
67
+ for i := range numGoroutines {
68
+ go func(id int) {
69
+ for j := range numOperations {
70
+ key := id*numOperations + j
71
+ vm.Set(key, key*2)
72
+ vm.Del(key)
73
+ }
74
+ }(i)
75
+ }
76
+
77
+ // Wait for operations to complete by checking the version
78
+ // This is a simplified check - in a real test you might want to use sync.WaitGroup
79
+ expectedMinVersion := initialVersion + uint64(numGoroutines*numOperations*2)
80
+
81
+ // Allow some time for operations to complete
82
+ for vm.Version() < expectedMinVersion {
83
+ // Busy wait - in a real test you'd use proper synchronization
84
+ }
85
+
86
+ // Final version should be at least the expected minimum
87
+ require.GreaterOrEqual(t, vm.Version(), expectedMinVersion)
88
+ require.Equal(t, 0, vm.Len())
89
+ }
internal/diff/diff.go ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package diff
2
+
3
+ import (
4
+ "strings"
5
+
6
+ "github.com/aymanbagabas/go-udiff"
7
+ )
8
+
9
+ // GenerateDiff creates a unified diff from two file contents
10
+ func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, int) {
11
+ fileName = strings.TrimPrefix(fileName, "/")
12
+
13
+ var (
14
+ unified = udiff.Unified("a/"+fileName, "b/"+fileName, beforeContent, afterContent)
15
+ additions = 0
16
+ removals = 0
17
+ )
18
+
19
+ lines := strings.SplitSeq(unified, "\n")
20
+ for line := range lines {
21
+ if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
22
+ additions++
23
+ } else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
24
+ removals++
25
+ }
26
+ }
27
+
28
+ return unified, additions, removals
29
+ }
internal/diffdetect/detect.go ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package diffdetect
2
+
3
+ import "strings"
4
+
5
+ // Signal describes which unified-diff markers were found while scanning text.
6
+ type Signal struct {
7
+ HasHunk bool
8
+ HasFileHeader bool
9
+ HasGitHeader bool
10
+ }
11
+
12
+ // Inspect scans content for unified-diff markers.
13
+ func Inspect(content string) Signal {
14
+ var signal Signal
15
+ for line := range strings.SplitSeq(content, "\n") {
16
+ if strings.HasPrefix(line, "@@") {
17
+ signal.HasHunk = true
18
+ }
19
+ if strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") {
20
+ signal.HasFileHeader = true
21
+ }
22
+ if strings.HasPrefix(line, "diff --git ") {
23
+ signal.HasGitHeader = true
24
+ }
25
+ }
26
+ return signal
27
+ }
28
+
29
+ // IsUnifiedDiff reports whether content appears to be a unified diff.
30
+ func IsUnifiedDiff(content string) bool {
31
+ signal := Inspect(content)
32
+ if signal.HasGitHeader && signal.HasFileHeader {
33
+ return true
34
+ }
35
+ return signal.HasHunk && signal.HasFileHeader
36
+ }
internal/diffdetect/detect_test.go ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package diffdetect
2
+
3
+ import "testing"
4
+
5
+ func TestInspect(t *testing.T) {
6
+ t.Parallel()
7
+
8
+ tests := []struct {
9
+ name string
10
+ content string
11
+ want Signal
12
+ }{
13
+ {
14
+ name: "git unified diff",
15
+ content: `diff --git a/main.go b/main.go
16
+ --- a/main.go
17
+ +++ b/main.go
18
+ @@ -1,2 +1,3 @@
19
+ package main
20
+ +import "fmt"
21
+ `,
22
+ want: Signal{HasHunk: true, HasFileHeader: true, HasGitHeader: true},
23
+ },
24
+ {
25
+ name: "non-git unified diff",
26
+ content: `--- old.c
27
+ +++ old.c
28
+ @@ -1 +1 @@
29
+ -old
30
+ +new
31
+ `,
32
+ want: Signal{HasHunk: true, HasFileHeader: true, HasGitHeader: false},
33
+ },
34
+ {
35
+ name: "plain text",
36
+ content: "hello world",
37
+ want: Signal{},
38
+ },
39
+ {
40
+ name: "hunk only",
41
+ content: "@@ -1 +1 @@\n-old\n+new\n",
42
+ want: Signal{HasHunk: true},
43
+ },
44
+ {
45
+ name: "headers only",
46
+ content: "--- a/file\n+++ b/file\n",
47
+ want: Signal{HasFileHeader: true},
48
+ },
49
+ }
50
+
51
+ for _, tt := range tests {
52
+ t.Run(tt.name, func(t *testing.T) {
53
+ t.Parallel()
54
+ got := Inspect(tt.content)
55
+ if got != tt.want {
56
+ t.Errorf("Inspect() = %+v, want %+v", got, tt.want)
57
+ }
58
+ })
59
+ }
60
+ }
61
+
62
+ func TestIsUnifiedDiff(t *testing.T) {
63
+ t.Parallel()
64
+
65
+ tests := []struct {
66
+ name string
67
+ content string
68
+ want bool
69
+ }{
70
+ {
71
+ name: "github-style multi-file diff",
72
+ content: `diff --git a/one.txt b/one.txt
73
+ --- a/one.txt
74
+ +++ b/one.txt
75
+ @@ -1 +1 @@
76
+ -a
77
+ +b
78
+ diff --git a/two.txt b/two.txt
79
+ --- a/two.txt
80
+ +++ b/two.txt
81
+ @@ -1 +1 @@
82
+ -c
83
+ +d
84
+ `,
85
+ want: true,
86
+ },
87
+ {
88
+ name: "non-git unified patch",
89
+ content: `--- a/old.c
90
+ +++ b/old.c
91
+ @@ -1,3 +1,4 @@
92
+ #include <stdio.h>
93
+ -int main() {
94
+ +int main(int argc, char **argv) {
95
+ return 0;
96
+ }
97
+ `,
98
+ want: true,
99
+ },
100
+ {
101
+ name: "new file from dev null",
102
+ content: `--- /dev/null
103
+ +++ newfile.txt
104
+ @@ -0,0 +1,2 @@
105
+ +hello
106
+ +world
107
+ `,
108
+ want: true,
109
+ },
110
+ {
111
+ name: "markdown false positive candidate",
112
+ content: `- Item one
113
+ - Item two
114
+ + Bonus item
115
+ - Item three
116
+ `,
117
+ want: false,
118
+ },
119
+ {
120
+ name: "headers without hunk",
121
+ content: `--- a/somefile.txt
122
+ +++ b/somefile.txt
123
+ Just some content here
124
+ No hunk markers at all
125
+ `,
126
+ want: false,
127
+ },
128
+ {
129
+ name: "hunk without headers",
130
+ content: `@@ -1,3 +1,4 @@
131
+ some line
132
+ -another line
133
+ +changed line
134
+ `,
135
+ want: false,
136
+ },
137
+ {
138
+ name: "empty",
139
+ content: "",
140
+ want: false,
141
+ },
142
+ }
143
+
144
+ for _, tt := range tests {
145
+ t.Run(tt.name, func(t *testing.T) {
146
+ t.Parallel()
147
+ if got := IsUnifiedDiff(tt.content); got != tt.want {
148
+ t.Errorf("IsUnifiedDiff() = %v, want %v", got, tt.want)
149
+ }
150
+ })
151
+ }
152
+ }
internal/filetracker/service.go ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package filetracker provides functionality to track file reads in sessions.
2
+ package filetracker
3
+
4
+ import (
5
+ "context"
6
+ "fmt"
7
+ "log/slog"
8
+ "os"
9
+ "path/filepath"
10
+ "time"
11
+
12
+ "github.com/charmbracelet/crush/internal/db"
13
+ )
14
+
15
+ // Service defines the interface for tracking file reads in sessions.
16
+ type Service interface {
17
+ // RecordRead records when a file was read.
18
+ RecordRead(ctx context.Context, sessionID, path string)
19
+
20
+ // LastReadTime returns when a file was last read.
21
+ // Returns zero time if never read.
22
+ LastReadTime(ctx context.Context, sessionID, path string) time.Time
23
+
24
+ // ListReadFiles returns the paths of all files read in a session.
25
+ ListReadFiles(ctx context.Context, sessionID string) ([]string, error)
26
+ }
27
+
28
+ type service struct {
29
+ q *db.Queries
30
+ }
31
+
32
+ // NewService creates a new file tracker service.
33
+ func NewService(q *db.Queries) Service {
34
+ return &service{q: q}
35
+ }
36
+
37
+ // RecordRead records when a file was read.
38
+ func (s *service) RecordRead(ctx context.Context, sessionID, path string) {
39
+ if err := s.q.RecordFileRead(ctx, db.RecordFileReadParams{
40
+ SessionID: sessionID,
41
+ Path: relpath(path),
42
+ }); err != nil {
43
+ slog.Error("Error recording file read", "error", err, "file", path)
44
+ }
45
+ }
46
+
47
+ // LastReadTime returns when a file was last read.
48
+ // Returns zero time if never read.
49
+ func (s *service) LastReadTime(ctx context.Context, sessionID, path string) time.Time {
50
+ readFile, err := s.q.GetFileRead(ctx, db.GetFileReadParams{
51
+ SessionID: sessionID,
52
+ Path: relpath(path),
53
+ })
54
+ if err != nil {
55
+ return time.Time{}
56
+ }
57
+
58
+ return time.Unix(readFile.ReadAt, 0)
59
+ }
60
+
61
+ func relpath(path string) string {
62
+ path = filepath.Clean(path)
63
+ basepath, err := os.Getwd()
64
+ if err != nil {
65
+ slog.Warn("Error getting basepath", "error", err)
66
+ return path
67
+ }
68
+ relpath, err := filepath.Rel(basepath, path)
69
+ if err != nil {
70
+ slog.Warn("Error getting relpath", "error", err)
71
+ return path
72
+ }
73
+ return relpath
74
+ }
75
+
76
+ // ListReadFiles returns the paths of all files read in a session.
77
+ func (s *service) ListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
78
+ readFiles, err := s.q.ListSessionReadFiles(ctx, sessionID)
79
+ if err != nil {
80
+ return nil, fmt.Errorf("listing read files: %w", err)
81
+ }
82
+
83
+ basepath, err := os.Getwd()
84
+ if err != nil {
85
+ return nil, fmt.Errorf("getting working directory: %w", err)
86
+ }
87
+
88
+ paths := make([]string, 0, len(readFiles))
89
+ for _, rf := range readFiles {
90
+ paths = append(paths, filepath.Join(basepath, rf.Path))
91
+ }
92
+ return paths, nil
93
+ }
internal/filetracker/service_test.go ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package filetracker
2
+
3
+ import (
4
+ "context"
5
+ "testing"
6
+ "testing/synctest"
7
+ "time"
8
+
9
+ "github.com/charmbracelet/crush/internal/db"
10
+ "github.com/stretchr/testify/require"
11
+ )
12
+
13
+ type testEnv struct {
14
+ ctx context.Context
15
+ q *db.Queries
16
+ svc Service
17
+ }
18
+
19
+ func setupTest(t *testing.T) *testEnv {
20
+ t.Helper()
21
+
22
+ conn, err := db.Connect(t.Context(), t.TempDir())
23
+ require.NoError(t, err)
24
+ t.Cleanup(func() { conn.Close() })
25
+
26
+ q := db.New(conn)
27
+ return &testEnv{
28
+ ctx: t.Context(),
29
+ q: q,
30
+ svc: NewService(q),
31
+ }
32
+ }
33
+
34
+ func (e *testEnv) createSession(t *testing.T, sessionID string) {
35
+ t.Helper()
36
+ _, err := e.q.CreateSession(e.ctx, db.CreateSessionParams{
37
+ ID: sessionID,
38
+ Title: "Test Session",
39
+ })
40
+ require.NoError(t, err)
41
+ }
42
+
43
+ func TestService_RecordRead(t *testing.T) {
44
+ env := setupTest(t)
45
+
46
+ sessionID := "test-session-1"
47
+ path := "/path/to/file.go"
48
+ env.createSession(t, sessionID)
49
+
50
+ env.svc.RecordRead(env.ctx, sessionID, path)
51
+
52
+ lastRead := env.svc.LastReadTime(env.ctx, sessionID, path)
53
+ require.False(t, lastRead.IsZero(), "expected non-zero time after recording read")
54
+ require.WithinDuration(t, time.Now(), lastRead, 2*time.Second)
55
+ }
56
+
57
+ func TestService_LastReadTime_NotFound(t *testing.T) {
58
+ env := setupTest(t)
59
+
60
+ lastRead := env.svc.LastReadTime(env.ctx, "nonexistent-session", "/nonexistent/path")
61
+ require.True(t, lastRead.IsZero(), "expected zero time for unread file")
62
+ }
63
+
64
+ func TestService_RecordRead_UpdatesTimestamp(t *testing.T) {
65
+ env := setupTest(t)
66
+
67
+ sessionID := "test-session-2"
68
+ path := "/path/to/file.go"
69
+ env.createSession(t, sessionID)
70
+
71
+ env.svc.RecordRead(env.ctx, sessionID, path)
72
+ firstRead := env.svc.LastReadTime(env.ctx, sessionID, path)
73
+ require.False(t, firstRead.IsZero())
74
+
75
+ synctest.Test(t, func(t *testing.T) {
76
+ time.Sleep(100 * time.Millisecond)
77
+ synctest.Wait()
78
+ env.svc.RecordRead(env.ctx, sessionID, path)
79
+ secondRead := env.svc.LastReadTime(env.ctx, sessionID, path)
80
+
81
+ require.False(t, secondRead.Before(firstRead), "second read time should not be before first")
82
+ })
83
+ }
84
+
85
+ func TestService_RecordRead_DifferentSessions(t *testing.T) {
86
+ env := setupTest(t)
87
+
88
+ path := "/shared/file.go"
89
+ session1, session2 := "session-1", "session-2"
90
+ env.createSession(t, session1)
91
+ env.createSession(t, session2)
92
+
93
+ env.svc.RecordRead(env.ctx, session1, path)
94
+
95
+ lastRead1 := env.svc.LastReadTime(env.ctx, session1, path)
96
+ require.False(t, lastRead1.IsZero())
97
+
98
+ lastRead2 := env.svc.LastReadTime(env.ctx, session2, path)
99
+ require.True(t, lastRead2.IsZero(), "session 2 should not see session 1's read")
100
+ }
101
+
102
+ func TestService_RecordRead_DifferentPaths(t *testing.T) {
103
+ env := setupTest(t)
104
+
105
+ sessionID := "test-session-3"
106
+ path1, path2 := "/path/to/file1.go", "/path/to/file2.go"
107
+ env.createSession(t, sessionID)
108
+
109
+ env.svc.RecordRead(env.ctx, sessionID, path1)
110
+
111
+ lastRead1 := env.svc.LastReadTime(env.ctx, sessionID, path1)
112
+ require.False(t, lastRead1.IsZero())
113
+
114
+ lastRead2 := env.svc.LastReadTime(env.ctx, sessionID, path2)
115
+ require.True(t, lastRead2.IsZero(), "path2 should not be recorded")
116
+ }
internal/lock/lock.go ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package lock provides cross-process advisory file locking.
2
+ //
3
+ // File acquires an exclusive lock on the file at path, blocking until
4
+ // the context is cancelled (or its deadline elapses). TryFile does the
5
+ // same but returns ErrContended immediately if the lock is already
6
+ // held. In both cases the returned release function drops the lock and
7
+ // closes the underlying file descriptor.
8
+ //
9
+ // The lock is released automatically by the kernel on process
10
+ // termination (including crash), so no stale-lock recovery is needed.
11
+ //
12
+ // The lock file at path is created if it does not exist. It is never
13
+ // unlinked — flock is keyed by inode, not path, and unlinking could
14
+ // create a window where two processes lock different inodes at the
15
+ // same path.
16
+ //
17
+ // This is the canonical file-locking helper for Crush. Callers should
18
+ // prefer it over rolling their own platform-specific code.
19
+ package lock
20
+
21
+ import (
22
+ "context"
23
+ "errors"
24
+ "fmt"
25
+ "os"
26
+ )
27
+
28
+ // ErrContended is returned by TryFile when the lock is already held by
29
+ // another process.
30
+ var ErrContended = errors.New("file lock is held by another process")
31
+
32
+ // File acquires an exclusive advisory lock on the file at path, blocking
33
+ // until the lock is acquired or ctx is cancelled. It returns a release
34
+ // function that drops the lock and closes the underlying file descriptor.
35
+ //
36
+ // Pass a context with a deadline (e.g. context.WithTimeout) to bound the
37
+ // wait. Pass context.Background() to block indefinitely.
38
+ func File(ctx context.Context, path string) (func(), error) {
39
+ f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
40
+ if err != nil {
41
+ return nil, fmt.Errorf("open lock file %q: %w", path, err)
42
+ }
43
+
44
+ release, err := lockFile(ctx, f)
45
+ if err != nil {
46
+ f.Close()
47
+ return nil, err
48
+ }
49
+
50
+ return func() {
51
+ release()
52
+ f.Close()
53
+ }, nil
54
+ }
55
+
56
+ // TryFile is like File but returns ErrContended immediately if the lock
57
+ // is already held by another process. Use this when you want to fail
58
+ // fast rather than wait.
59
+ func TryFile(path string) (func(), error) {
60
+ f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
61
+ if err != nil {
62
+ return nil, fmt.Errorf("open lock file %q: %w", path, err)
63
+ }
64
+
65
+ release, err := tryLockFile(f)
66
+ if err != nil {
67
+ f.Close()
68
+ return nil, err
69
+ }
70
+
71
+ return func() {
72
+ release()
73
+ f.Close()
74
+ }, nil
75
+ }
internal/lock/lock_test.go ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package lock
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "path/filepath"
7
+ "sync"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/stretchr/testify/require"
12
+ )
13
+
14
+ func TestTryFile_AcquiresWhenFree(t *testing.T) {
15
+ t.Parallel()
16
+ path := filepath.Join(t.TempDir(), "test.lock")
17
+
18
+ release, err := TryFile(path)
19
+ require.NoError(t, err)
20
+ require.NotNil(t, release)
21
+ release()
22
+ }
23
+
24
+ func TestTryFile_ReturnsErrContendedWhenHeld(t *testing.T) {
25
+ t.Parallel()
26
+ path := filepath.Join(t.TempDir(), "test.lock")
27
+
28
+ release, err := TryFile(path)
29
+ require.NoError(t, err)
30
+ t.Cleanup(release)
31
+
32
+ _, err = TryFile(path)
33
+ require.ErrorIs(t, err, ErrContended)
34
+ }
35
+
36
+ func TestTryFile_ReacquireAfterRelease(t *testing.T) {
37
+ t.Parallel()
38
+ path := filepath.Join(t.TempDir(), "test.lock")
39
+
40
+ release, err := TryFile(path)
41
+ require.NoError(t, err)
42
+ release()
43
+
44
+ release2, err := TryFile(path)
45
+ require.NoError(t, err)
46
+ t.Cleanup(release2)
47
+ }
48
+
49
+ func TestFile_AcquiresWhenFree(t *testing.T) {
50
+ t.Parallel()
51
+ path := filepath.Join(t.TempDir(), "test.lock")
52
+
53
+ release, err := File(context.Background(), path)
54
+ require.NoError(t, err)
55
+ t.Cleanup(release)
56
+ }
57
+
58
+ func TestFile_BlocksThenSucceeds(t *testing.T) {
59
+ t.Parallel()
60
+ path := filepath.Join(t.TempDir(), "test.lock")
61
+
62
+ release, err := TryFile(path)
63
+ require.NoError(t, err)
64
+
65
+ // Release the lock after a short delay so the blocking acquirer
66
+ // can complete within the test timeout.
67
+ go func() {
68
+ time.Sleep(150 * time.Millisecond)
69
+ release()
70
+ }()
71
+
72
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
73
+ defer cancel()
74
+ release2, err := File(ctx, path)
75
+ require.NoError(t, err, "should acquire after first releases")
76
+ release2()
77
+ }
78
+
79
+ func TestFile_RespectsContextDeadline(t *testing.T) {
80
+ t.Parallel()
81
+ path := filepath.Join(t.TempDir(), "test.lock")
82
+
83
+ release, err := TryFile(path)
84
+ require.NoError(t, err)
85
+ t.Cleanup(release)
86
+
87
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
88
+ defer cancel()
89
+ start := time.Now()
90
+ _, err = File(ctx, path)
91
+ elapsed := time.Since(start)
92
+ require.Error(t, err)
93
+ require.True(t, errors.Is(err, context.DeadlineExceeded), "expected deadline exceeded, got %v", err)
94
+ require.Less(t, elapsed, 1*time.Second, "should return promptly after deadline")
95
+ }
96
+
97
+ func TestFile_RespectsContextCancellation(t *testing.T) {
98
+ t.Parallel()
99
+ path := filepath.Join(t.TempDir(), "test.lock")
100
+
101
+ release, err := TryFile(path)
102
+ require.NoError(t, err)
103
+ t.Cleanup(release)
104
+
105
+ ctx, cancel := context.WithCancel(context.Background())
106
+ done := make(chan error, 1)
107
+ go func() {
108
+ _, err := File(ctx, path)
109
+ done <- err
110
+ }()
111
+
112
+ time.Sleep(50 * time.Millisecond)
113
+ cancel()
114
+
115
+ select {
116
+ case err := <-done:
117
+ require.ErrorIs(t, err, context.Canceled)
118
+ case <-time.After(2 * time.Second):
119
+ t.Fatal("File did not return after context cancellation")
120
+ }
121
+ }
122
+
123
+ // TestFile_ConcurrentAcquirers verifies that multiple blocking acquirers
124
+ // queue up correctly: each gets the lock in turn, exactly one at a time.
125
+ func TestFile_ConcurrentAcquirers(t *testing.T) {
126
+ t.Parallel()
127
+ path := filepath.Join(t.TempDir(), "test.lock")
128
+
129
+ const n = 5
130
+ var (
131
+ mu sync.Mutex
132
+ inside int
133
+ maxSeen int
134
+ finished int
135
+ )
136
+ var wg sync.WaitGroup
137
+ wg.Add(n)
138
+ for range n {
139
+ go func() {
140
+ defer wg.Done()
141
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
142
+ defer cancel()
143
+ release, err := File(ctx, path)
144
+ if err != nil {
145
+ t.Errorf("acquire failed: %v", err)
146
+ return
147
+ }
148
+ mu.Lock()
149
+ inside++
150
+ if inside > maxSeen {
151
+ maxSeen = inside
152
+ }
153
+ mu.Unlock()
154
+
155
+ time.Sleep(20 * time.Millisecond)
156
+
157
+ mu.Lock()
158
+ inside--
159
+ finished++
160
+ mu.Unlock()
161
+ release()
162
+ }()
163
+ }
164
+ wg.Wait()
165
+
166
+ require.Equal(t, n, finished)
167
+ require.Equal(t, 1, maxSeen, "lock must be mutually exclusive")
168
+ }
internal/lock/lock_unix.go ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //go:build !windows
2
+
3
+ package lock
4
+
5
+ import (
6
+ "context"
7
+ "errors"
8
+ "fmt"
9
+ "os"
10
+ "time"
11
+
12
+ "golang.org/x/sys/unix"
13
+ )
14
+
15
+ // retrySleep is the interval between non-blocking flock retries in the
16
+ // blocking File path. Small enough that contention resolution feels
17
+ // snappy; large enough that we don't burn a CPU spinning.
18
+ const retrySleep = 100 * time.Millisecond
19
+
20
+ func lockFile(ctx context.Context, f *os.File) (func(), error) {
21
+ for {
22
+ err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB)
23
+ if err == nil {
24
+ return func() { _ = unix.Flock(int(f.Fd()), unix.LOCK_UN) }, nil
25
+ }
26
+ if !errors.Is(err, unix.EWOULDBLOCK) {
27
+ return nil, fmt.Errorf("flock: %w", err)
28
+ }
29
+ select {
30
+ case <-ctx.Done():
31
+ return nil, fmt.Errorf("acquire lock: %w", ctx.Err())
32
+ case <-time.After(retrySleep):
33
+ }
34
+ }
35
+ }
36
+
37
+ func tryLockFile(f *os.File) (func(), error) {
38
+ if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
39
+ if errors.Is(err, unix.EWOULDBLOCK) {
40
+ return nil, ErrContended
41
+ }
42
+ return nil, fmt.Errorf("flock: %w", err)
43
+ }
44
+ return func() { _ = unix.Flock(int(f.Fd()), unix.LOCK_UN) }, nil
45
+ }
internal/lock/lock_windows.go ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //go:build windows
2
+
3
+ package lock
4
+
5
+ import (
6
+ "context"
7
+ "errors"
8
+ "fmt"
9
+ "math"
10
+ "os"
11
+ "time"
12
+
13
+ "golang.org/x/sys/windows"
14
+ )
15
+
16
+ // retrySleep is the interval between non-blocking lock retries in the
17
+ // blocking File path.
18
+ const retrySleep = 100 * time.Millisecond
19
+
20
+ func lockFile(ctx context.Context, f *os.File) (func(), error) {
21
+ h := windows.Handle(f.Fd())
22
+ for {
23
+ ol := new(windows.Overlapped)
24
+ flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
25
+ err := windows.LockFileEx(h, flags, 0, math.MaxUint32, math.MaxUint32, ol)
26
+ if err == nil {
27
+ return func() {
28
+ ol := new(windows.Overlapped)
29
+ _ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, math.MaxUint32, math.MaxUint32, ol)
30
+ }, nil
31
+ }
32
+ if !errors.Is(err, windows.ERROR_LOCK_VIOLATION) && !errors.Is(err, windows.ERROR_IO_PENDING) {
33
+ return nil, fmt.Errorf("LockFileEx: %w", err)
34
+ }
35
+ select {
36
+ case <-ctx.Done():
37
+ return nil, fmt.Errorf("acquire lock: %w", ctx.Err())
38
+ case <-time.After(retrySleep):
39
+ }
40
+ }
41
+ }
42
+
43
+ func tryLockFile(f *os.File) (func(), error) {
44
+ h := windows.Handle(f.Fd())
45
+ ol := new(windows.Overlapped)
46
+ flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
47
+ if err := windows.LockFileEx(h, flags, 0, math.MaxUint32, math.MaxUint32, ol); err != nil {
48
+ if errors.Is(err, windows.ERROR_LOCK_VIOLATION) || errors.Is(err, windows.ERROR_IO_PENDING) {
49
+ return nil, ErrContended
50
+ }
51
+ return nil, fmt.Errorf("LockFileEx: %w", err)
52
+ }
53
+ return func() {
54
+ ol := new(windows.Overlapped)
55
+ _ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, math.MaxUint32, math.MaxUint32, ol)
56
+ }, nil
57
+ }
internal/logout/choose.go ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package logout
2
+
3
+ import (
4
+ "errors"
5
+ "strings"
6
+
7
+ "charm.land/bubbles/v2/help"
8
+ "charm.land/bubbles/v2/key"
9
+ tea "charm.land/bubbletea/v2"
10
+ "charm.land/lipgloss/v2"
11
+ )
12
+
13
+ type chooseKeyMap struct {
14
+ Up key.Binding
15
+ Down key.Binding
16
+ Confirm key.Binding
17
+ Cancel key.Binding
18
+ }
19
+
20
+ func defaultChooseKeyMap() chooseKeyMap {
21
+ return chooseKeyMap{
22
+ Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/↓", "select")),
23
+ Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("", "")),
24
+ Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
25
+ Cancel: key.NewBinding(key.WithKeys("ctrl+c", "esc"), key.WithHelp("esc", "cancel")),
26
+ }
27
+ }
28
+
29
+ func (k chooseKeyMap) ShortHelp() []key.Binding {
30
+ return []key.Binding{k.Up, k.Confirm, k.Cancel}
31
+ }
32
+
33
+ func (k chooseKeyMap) FullHelp() [][]key.Binding {
34
+ return [][]key.Binding{{k.Up, k.Down, k.Confirm, k.Cancel}}
35
+ }
36
+
37
+ // chooseModel is the Bubble Tea model for a multiple choice question with
38
+ // a ">" cursor.
39
+ type chooseModel struct {
40
+ question string
41
+ options []string
42
+ cursor int
43
+ chosen int
44
+
45
+ help help.Model
46
+ keymap chooseKeyMap
47
+
48
+ quitting bool
49
+ blankLines int
50
+ width int
51
+ }
52
+
53
+ func newChooseModel(question string, options []string) chooseModel {
54
+ return chooseModel{
55
+ question: question,
56
+ options: options,
57
+ chosen: -1,
58
+ help: help.New(),
59
+ keymap: defaultChooseKeyMap(),
60
+ }
61
+ }
62
+
63
+ // runChoose runs the multiple choice TUI and returns the selected option
64
+ // index, or -1 when canceled.
65
+ func runChoose(question string, options []string) (int, error) {
66
+ p := tea.NewProgram(newChooseModel(question, options))
67
+ final, err := p.Run()
68
+ if err != nil {
69
+ return -1, err
70
+ }
71
+ m, ok := final.(chooseModel)
72
+ if !ok {
73
+ return -1, errors.New("unexpected choose program model")
74
+ }
75
+ return m.chosen, nil
76
+ }
77
+
78
+ // Init initializes the choose TUI.
79
+ func (m chooseModel) Init() tea.Cmd {
80
+ return nil
81
+ }
82
+
83
+ // Update handles messages for the choose TUI.
84
+ func (m chooseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
85
+ switch msg := msg.(type) {
86
+ case tea.WindowSizeMsg:
87
+ m.width = msg.Width
88
+ m.help.SetWidth(msg.Width)
89
+ return m, nil
90
+
91
+ case tea.KeyPressMsg:
92
+ switch {
93
+ case key.Matches(msg, m.keymap.Cancel):
94
+ m.quitting = true
95
+ m.blankLines = m.contentHeight()
96
+ return m, tea.Quit
97
+ case key.Matches(msg, m.keymap.Confirm):
98
+ m.chosen = m.cursor
99
+ m.quitting = true
100
+ m.blankLines = m.contentHeight()
101
+ return m, tea.Quit
102
+ case key.Matches(msg, m.keymap.Up):
103
+ m.cursor--
104
+ if m.cursor < 0 {
105
+ m.cursor = len(m.options) - 1
106
+ }
107
+ return m, nil
108
+ case key.Matches(msg, m.keymap.Down):
109
+ m.cursor++
110
+ if m.cursor >= len(m.options) {
111
+ m.cursor = 0
112
+ }
113
+ return m, nil
114
+ }
115
+ }
116
+ return m, nil
117
+ }
118
+
119
+ // content renders the full choose view. The trailing zero-width space
120
+ // keeps the final blank line alive (the renderer erases trailing empty
121
+ // lines).
122
+ func (m chooseModel) content() string {
123
+ var b strings.Builder
124
+
125
+ b.WriteString("\n ")
126
+ b.WriteString(questionStyle.Render(m.question))
127
+ b.WriteString("\n\n")
128
+ for i, option := range m.options {
129
+ if i == m.cursor {
130
+ b.WriteString(" " + choiceSelectedStyle.Render("> "+option))
131
+ } else {
132
+ b.WriteString(" " + option)
133
+ }
134
+ if i < len(m.options)-1 {
135
+ b.WriteString("\n")
136
+ }
137
+ }
138
+ b.WriteString("\n\n ")
139
+ b.WriteString(m.help.View(m.keymap))
140
+ b.WriteString("\n")
141
+ return b.String()
142
+ }
143
+
144
+ // contentHeight returns the height of the current view in lines.
145
+ func (m chooseModel) contentHeight() int {
146
+ return lipgloss.Height(m.content())
147
+ }
148
+
149
+ // View renders the choose TUI.
150
+ func (m chooseModel) View() tea.View {
151
+ if m.quitting {
152
+ // Render the same number of blank lines as the last frame so the
153
+ // renderer overwrites it and the TUI disappears on exit. A truly
154
+ // empty view doesn't work under Fang's wrapped output: the renderer
155
+ // drops the touched-lines buffer when the height shrinks to zero
156
+ // and leaves the frame on screen.
157
+ return tea.NewView("")
158
+ }
159
+ return tea.NewView(m.content())
160
+ }