@@ -0,0 +1,2079 @@
10+ "github.com/charmbracelet/crush/internal/fur/provider"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
16+ // Clear all environment variables that could affect config
17+ envVarsToUnset := []string{
23+ "OPENROUTER_API_KEY",
25+ // Google Cloud / VertexAI
26+ "GOOGLE_GENAI_USE_VERTEXAI",
27+ "GOOGLE_CLOUD_PROJECT",
28+ "GOOGLE_CLOUD_LOCATION",
32+ "AWS_SECRET_ACCESS_KEY",
34+ "AWS_DEFAULT_REGION",
36+ "AWS_DEFAULT_PROFILE",
37+ "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
38+ "AWS_CONTAINER_CREDENTIALS_FULL_URI",
44+ for _, envVar := range envVarsToUnset {
54+ // Enable mock providers for all tests to avoid API calls
55+ UseMockProviders = true
59+// Core Configuration Loading Tests
61+func TestInit_ValidWorkingDirectory(t *testing.T) {
63+ testConfigDir = t.TempDir()
64+ cwdDir := t.TempDir()
66+ cfg, err := Init(cwdDir, false)
68+ require.NoError(t, err)
69+ assert.NotNil(t, cfg)
70+ assert.Equal(t, cwdDir, WorkingDirectory())
71+ assert.Equal(t, defaultDataDirectory, cfg.Options.DataDirectory)
72+ assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
75+func TestInit_WithDebugFlag(t *testing.T) {
77+ testConfigDir = t.TempDir()
78+ cwdDir := t.TempDir()
80+ cfg, err := Init(cwdDir, true)
82+ require.NoError(t, err)
83+ assert.True(t, cfg.Options.Debug)
86+func TestInit_SingletonBehavior(t *testing.T) {
88+ testConfigDir = t.TempDir()
89+ cwdDir := t.TempDir()
91+ cfg1, err1 := Init(cwdDir, false)
92+ cfg2, err2 := Init(cwdDir, false)
94+ require.NoError(t, err1)
95+ require.NoError(t, err2)
96+ assert.Same(t, cfg1, cfg2)
99+func TestGet_BeforeInitialization(t *testing.T) {
102+ assert.Panics(t, func() {
107+func TestGet_AfterInitialization(t *testing.T) {
109+ testConfigDir = t.TempDir()
110+ cwdDir := t.TempDir()
112+ cfg1, err := Init(cwdDir, false)
113+ require.NoError(t, err)
116+ assert.Same(t, cfg1, cfg2)
119+func TestLoadConfig_NoConfigFiles(t *testing.T) {
121+ testConfigDir = t.TempDir()
122+ cwdDir := t.TempDir()
124+ cfg, err := Init(cwdDir, false)
126+ require.NoError(t, err)
127+ assert.Len(t, cfg.Providers, 0)
128+ assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
131+func TestLoadConfig_OnlyGlobalConfig(t *testing.T) {
133+ testConfigDir = t.TempDir()
134+ cwdDir := t.TempDir()
136+ globalConfig := Config{
137+ Providers: map[provider.InferenceProvider]ProviderConfig{
138+ provider.InferenceProviderOpenAI: {
139+ ID: provider.InferenceProviderOpenAI,
141+ ProviderType: provider.TypeOpenAI,
142+ DefaultLargeModel: "gpt-4",
143+ DefaultSmallModel: "gpt-3.5-turbo",
150+ ContextWindow: 8192,
151+ DefaultMaxTokens: 4096,
154+ ID: "gpt-3.5-turbo",
155+ Name: "GPT-3.5 Turbo",
158+ ContextWindow: 4096,
159+ DefaultMaxTokens: 4096,
165+ ContextPaths: []string{"custom-context.md"},
169+ configPath := filepath.Join(testConfigDir, "crush.json")
170+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
172+ data, err := json.Marshal(globalConfig)
173+ require.NoError(t, err)
174+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
176+ cfg, err := Init(cwdDir, false)
178+ require.NoError(t, err)
179+ assert.Len(t, cfg.Providers, 1)
180+ assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
181+ assert.Contains(t, cfg.Options.ContextPaths, "custom-context.md")
184+func TestLoadConfig_OnlyLocalConfig(t *testing.T) {
186+ testConfigDir = t.TempDir()
187+ cwdDir := t.TempDir()
189+ localConfig := Config{
190+ Providers: map[provider.InferenceProvider]ProviderConfig{
191+ provider.InferenceProviderAnthropic: {
192+ ID: provider.InferenceProviderAnthropic,
193+ APIKey: "local-key",
194+ ProviderType: provider.TypeAnthropic,
195+ DefaultLargeModel: "claude-3-opus",
196+ DefaultSmallModel: "claude-3-haiku",
199+ ID: "claude-3-opus",
200+ Name: "Claude 3 Opus",
203+ ContextWindow: 200000,
204+ DefaultMaxTokens: 4096,
207+ ID: "claude-3-haiku",
208+ Name: "Claude 3 Haiku",
211+ ContextWindow: 200000,
212+ DefaultMaxTokens: 4096,
218+ TUI: TUIOptions{CompactMode: true},
222+ localConfigPath := filepath.Join(cwdDir, "crush.json")
223+ data, err := json.Marshal(localConfig)
224+ require.NoError(t, err)
225+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
227+ cfg, err := Init(cwdDir, false)
229+ require.NoError(t, err)
230+ assert.Len(t, cfg.Providers, 1)
231+ assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
232+ assert.True(t, cfg.Options.TUI.CompactMode)
235+func TestLoadConfig_BothGlobalAndLocal(t *testing.T) {
237+ testConfigDir = t.TempDir()
238+ cwdDir := t.TempDir()
240+ globalConfig := Config{
241+ Providers: map[provider.InferenceProvider]ProviderConfig{
242+ provider.InferenceProviderOpenAI: {
243+ ID: provider.InferenceProviderOpenAI,
244+ APIKey: "global-key",
245+ ProviderType: provider.TypeOpenAI,
246+ DefaultLargeModel: "gpt-4",
247+ DefaultSmallModel: "gpt-3.5-turbo",
254+ ContextWindow: 8192,
255+ DefaultMaxTokens: 4096,
258+ ID: "gpt-3.5-turbo",
259+ Name: "GPT-3.5 Turbo",
262+ ContextWindow: 4096,
263+ DefaultMaxTokens: 4096,
269+ ContextPaths: []string{"global-context.md"},
273+ configPath := filepath.Join(testConfigDir, "crush.json")
274+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
275+ data, err := json.Marshal(globalConfig)
276+ require.NoError(t, err)
277+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
279+ localConfig := Config{
280+ Providers: map[provider.InferenceProvider]ProviderConfig{
281+ provider.InferenceProviderOpenAI: {
282+ APIKey: "local-key", // Override global
284+ provider.InferenceProviderAnthropic: {
285+ ID: provider.InferenceProviderAnthropic,
286+ APIKey: "anthropic-key",
287+ ProviderType: provider.TypeAnthropic,
288+ DefaultLargeModel: "claude-3-opus",
289+ DefaultSmallModel: "claude-3-haiku",
292+ ID: "claude-3-opus",
293+ Name: "Claude 3 Opus",
296+ ContextWindow: 200000,
297+ DefaultMaxTokens: 4096,
300+ ID: "claude-3-haiku",
301+ Name: "Claude 3 Haiku",
304+ ContextWindow: 200000,
305+ DefaultMaxTokens: 4096,
311+ ContextPaths: []string{"local-context.md"},
312+ TUI: TUIOptions{CompactMode: true},
316+ localConfigPath := filepath.Join(cwdDir, "crush.json")
317+ data, err = json.Marshal(localConfig)
318+ require.NoError(t, err)
319+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
321+ cfg, err := Init(cwdDir, false)
323+ require.NoError(t, err)
324+ assert.Len(t, cfg.Providers, 2)
326+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
327+ assert.Equal(t, "local-key", openaiProvider.APIKey)
329+ assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
331+ assert.Contains(t, cfg.Options.ContextPaths, "global-context.md")
332+ assert.Contains(t, cfg.Options.ContextPaths, "local-context.md")
333+ assert.True(t, cfg.Options.TUI.CompactMode)
336+func TestLoadConfig_MalformedGlobalJSON(t *testing.T) {
338+ testConfigDir = t.TempDir()
339+ cwdDir := t.TempDir()
341+ configPath := filepath.Join(testConfigDir, "crush.json")
342+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
343+ require.NoError(t, os.WriteFile(configPath, []byte(`{invalid json`), 0o644))
345+ _, err := Init(cwdDir, false)
346+ assert.Error(t, err)
349+func TestLoadConfig_MalformedLocalJSON(t *testing.T) {
351+ testConfigDir = t.TempDir()
352+ cwdDir := t.TempDir()
354+ localConfigPath := filepath.Join(cwdDir, "crush.json")
355+ require.NoError(t, os.WriteFile(localConfigPath, []byte(`{invalid json`), 0o644))
357+ _, err := Init(cwdDir, false)
358+ assert.Error(t, err)
361+func TestConfigWithoutEnv(t *testing.T) {
363+ testConfigDir = t.TempDir()
364+ cwdDir := t.TempDir()
366+ cfg, _ := Init(cwdDir, false)
367+ assert.Len(t, cfg.Providers, 0)
370+func TestConfigWithEnv(t *testing.T) {
372+ testConfigDir = t.TempDir()
373+ cwdDir := t.TempDir()
375+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
376+ os.Setenv("OPENAI_API_KEY", "test-openai-key")
377+ os.Setenv("GEMINI_API_KEY", "test-gemini-key")
378+ os.Setenv("XAI_API_KEY", "test-xai-key")
379+ os.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
381+ cfg, _ := Init(cwdDir, false)
382+ assert.Len(t, cfg.Providers, 5)
385+// Environment Variable Tests
387+func TestEnvVars_NoEnvironmentVariables(t *testing.T) {
389+ testConfigDir = t.TempDir()
390+ cwdDir := t.TempDir()
392+ cfg, err := Init(cwdDir, false)
394+ require.NoError(t, err)
395+ assert.Len(t, cfg.Providers, 0)
398+func TestEnvVars_AllSupportedAPIKeys(t *testing.T) {
400+ testConfigDir = t.TempDir()
401+ cwdDir := t.TempDir()
403+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
404+ os.Setenv("OPENAI_API_KEY", "test-openai-key")
405+ os.Setenv("GEMINI_API_KEY", "test-gemini-key")
406+ os.Setenv("XAI_API_KEY", "test-xai-key")
407+ os.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
409+ cfg, err := Init(cwdDir, false)
411+ require.NoError(t, err)
412+ assert.Len(t, cfg.Providers, 5)
414+ anthropicProvider := cfg.Providers[provider.InferenceProviderAnthropic]
415+ assert.Equal(t, "test-anthropic-key", anthropicProvider.APIKey)
416+ assert.Equal(t, provider.TypeAnthropic, anthropicProvider.ProviderType)
418+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
419+ assert.Equal(t, "test-openai-key", openaiProvider.APIKey)
420+ assert.Equal(t, provider.TypeOpenAI, openaiProvider.ProviderType)
422+ geminiProvider := cfg.Providers[provider.InferenceProviderGemini]
423+ assert.Equal(t, "test-gemini-key", geminiProvider.APIKey)
424+ assert.Equal(t, provider.TypeGemini, geminiProvider.ProviderType)
426+ xaiProvider := cfg.Providers[provider.InferenceProviderXAI]
427+ assert.Equal(t, "test-xai-key", xaiProvider.APIKey)
428+ assert.Equal(t, provider.TypeXAI, xaiProvider.ProviderType)
430+ openrouterProvider := cfg.Providers[provider.InferenceProviderOpenRouter]
431+ assert.Equal(t, "test-openrouter-key", openrouterProvider.APIKey)
432+ assert.Equal(t, provider.TypeOpenAI, openrouterProvider.ProviderType)
433+ assert.Equal(t, "https://openrouter.ai/api/v1", openrouterProvider.BaseURL)
436+func TestEnvVars_PartialEnvironmentVariables(t *testing.T) {
438+ testConfigDir = t.TempDir()
439+ cwdDir := t.TempDir()
441+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
442+ os.Setenv("OPENAI_API_KEY", "test-openai-key")
444+ cfg, err := Init(cwdDir, false)
446+ require.NoError(t, err)
447+ assert.Len(t, cfg.Providers, 2)
448+ assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
449+ assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
450+ assert.NotContains(t, cfg.Providers, provider.InferenceProviderGemini)
453+func TestEnvVars_VertexAIConfiguration(t *testing.T) {
455+ testConfigDir = t.TempDir()
456+ cwdDir := t.TempDir()
458+ os.Setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
459+ os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
460+ os.Setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
462+ cfg, err := Init(cwdDir, false)
464+ require.NoError(t, err)
465+ assert.Contains(t, cfg.Providers, provider.InferenceProviderVertexAI)
467+ vertexProvider := cfg.Providers[provider.InferenceProviderVertexAI]
468+ assert.Equal(t, provider.TypeVertexAI, vertexProvider.ProviderType)
469+ assert.Equal(t, "test-project", vertexProvider.ExtraParams["project"])
470+ assert.Equal(t, "us-central1", vertexProvider.ExtraParams["location"])
473+func TestEnvVars_VertexAIWithoutUseFlag(t *testing.T) {
475+ testConfigDir = t.TempDir()
476+ cwdDir := t.TempDir()
478+ os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
479+ os.Setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
481+ cfg, err := Init(cwdDir, false)
483+ require.NoError(t, err)
484+ assert.NotContains(t, cfg.Providers, provider.InferenceProviderVertexAI)
487+func TestEnvVars_AWSBedrockWithAccessKeys(t *testing.T) {
489+ testConfigDir = t.TempDir()
490+ cwdDir := t.TempDir()
492+ os.Setenv("AWS_ACCESS_KEY_ID", "test-access-key")
493+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key")
494+ os.Setenv("AWS_DEFAULT_REGION", "us-east-1")
496+ cfg, err := Init(cwdDir, false)
498+ require.NoError(t, err)
499+ assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
501+ bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
502+ assert.Equal(t, provider.TypeBedrock, bedrockProvider.ProviderType)
503+ assert.Equal(t, "us-east-1", bedrockProvider.ExtraParams["region"])
506+func TestEnvVars_AWSBedrockWithProfile(t *testing.T) {
508+ testConfigDir = t.TempDir()
509+ cwdDir := t.TempDir()
511+ os.Setenv("AWS_PROFILE", "test-profile")
512+ os.Setenv("AWS_REGION", "eu-west-1")
514+ cfg, err := Init(cwdDir, false)
516+ require.NoError(t, err)
517+ assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
519+ bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
520+ assert.Equal(t, "eu-west-1", bedrockProvider.ExtraParams["region"])
523+func TestEnvVars_AWSBedrockWithContainerCredentials(t *testing.T) {
525+ testConfigDir = t.TempDir()
526+ cwdDir := t.TempDir()
528+ os.Setenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/v2/credentials/test")
529+ os.Setenv("AWS_DEFAULT_REGION", "ap-southeast-1")
531+ cfg, err := Init(cwdDir, false)
533+ require.NoError(t, err)
534+ assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
537+func TestEnvVars_AWSBedrockRegionPriority(t *testing.T) {
539+ testConfigDir = t.TempDir()
540+ cwdDir := t.TempDir()
542+ os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
543+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
544+ os.Setenv("AWS_DEFAULT_REGION", "us-west-2")
545+ os.Setenv("AWS_REGION", "us-east-1")
547+ cfg, err := Init(cwdDir, false)
549+ require.NoError(t, err)
550+ bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
551+ assert.Equal(t, "us-west-2", bedrockProvider.ExtraParams["region"])
554+func TestEnvVars_AWSBedrockFallbackRegion(t *testing.T) {
556+ testConfigDir = t.TempDir()
557+ cwdDir := t.TempDir()
559+ os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
560+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
561+ os.Setenv("AWS_REGION", "us-east-1")
563+ cfg, err := Init(cwdDir, false)
565+ require.NoError(t, err)
566+ bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
567+ assert.Equal(t, "us-east-1", bedrockProvider.ExtraParams["region"])
570+func TestEnvVars_NoAWSCredentials(t *testing.T) {
572+ testConfigDir = t.TempDir()
573+ cwdDir := t.TempDir()
575+ cfg, err := Init(cwdDir, false)
577+ require.NoError(t, err)
578+ assert.NotContains(t, cfg.Providers, provider.InferenceProviderBedrock)
581+func TestEnvVars_CustomEnvironmentVariables(t *testing.T) {
583+ testConfigDir = t.TempDir()
584+ cwdDir := t.TempDir()
586+ os.Setenv("ANTHROPIC_API_KEY", "resolved-anthropic-key")
588+ cfg, err := Init(cwdDir, false)
590+ require.NoError(t, err)
591+ if len(cfg.Providers) > 0 {
592+ if anthropicProvider, exists := cfg.Providers[provider.InferenceProviderAnthropic]; exists {
593+ assert.Equal(t, "resolved-anthropic-key", anthropicProvider.APIKey)
598+func TestEnvVars_CombinedEnvironmentVariables(t *testing.T) {
600+ testConfigDir = t.TempDir()
601+ cwdDir := t.TempDir()
603+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic")
604+ os.Setenv("OPENAI_API_KEY", "test-openai")
605+ os.Setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
606+ os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
607+ os.Setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
608+ os.Setenv("AWS_ACCESS_KEY_ID", "test-aws-key")
609+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-aws-secret")
610+ os.Setenv("AWS_DEFAULT_REGION", "us-west-1")
612+ cfg, err := Init(cwdDir, false)
614+ require.NoError(t, err)
616+ expectedProviders := []provider.InferenceProvider{
617+ provider.InferenceProviderAnthropic,
618+ provider.InferenceProviderOpenAI,
619+ provider.InferenceProviderVertexAI,
620+ provider.InferenceProviderBedrock,
623+ for _, expectedProvider := range expectedProviders {
624+ assert.Contains(t, cfg.Providers, expectedProvider)
628+func TestHasAWSCredentials_AccessKeys(t *testing.T) {
631+ os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
632+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
634+ assert.True(t, hasAWSCredentials())
637+func TestHasAWSCredentials_Profile(t *testing.T) {
640+ os.Setenv("AWS_PROFILE", "test-profile")
642+ assert.True(t, hasAWSCredentials())
645+func TestHasAWSCredentials_DefaultProfile(t *testing.T) {
648+ os.Setenv("AWS_DEFAULT_PROFILE", "default")
650+ assert.True(t, hasAWSCredentials())
653+func TestHasAWSCredentials_Region(t *testing.T) {
656+ os.Setenv("AWS_REGION", "us-east-1")
658+ assert.True(t, hasAWSCredentials())
661+func TestHasAWSCredentials_ContainerCredentials(t *testing.T) {
664+ os.Setenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/v2/credentials/test")
666+ assert.True(t, hasAWSCredentials())
669+func TestHasAWSCredentials_NoCredentials(t *testing.T) {
672+ assert.False(t, hasAWSCredentials())
675+func TestProviderMerging_GlobalToBase(t *testing.T) {
677+ testConfigDir = t.TempDir()
678+ cwdDir := t.TempDir()
680+ globalConfig := Config{
681+ Providers: map[provider.InferenceProvider]ProviderConfig{
682+ provider.InferenceProviderOpenAI: {
683+ ID: provider.InferenceProviderOpenAI,
684+ APIKey: "global-openai-key",
685+ ProviderType: provider.TypeOpenAI,
686+ DefaultLargeModel: "gpt-4",
687+ DefaultSmallModel: "gpt-3.5-turbo",
692+ ContextWindow: 8192,
693+ DefaultMaxTokens: 4096,
696+ ID: "gpt-3.5-turbo",
697+ Name: "GPT-3.5 Turbo",
698+ ContextWindow: 4096,
699+ DefaultMaxTokens: 2048,
706+ configPath := filepath.Join(testConfigDir, "crush.json")
707+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
708+ data, err := json.Marshal(globalConfig)
709+ require.NoError(t, err)
710+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
712+ cfg, err := Init(cwdDir, false)
714+ require.NoError(t, err)
715+ assert.Len(t, cfg.Providers, 1)
717+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
718+ assert.Equal(t, "global-openai-key", openaiProvider.APIKey)
719+ assert.Equal(t, "gpt-4", openaiProvider.DefaultLargeModel)
720+ assert.Equal(t, "gpt-3.5-turbo", openaiProvider.DefaultSmallModel)
721+ assert.Len(t, openaiProvider.Models, 2)
724+func TestProviderMerging_LocalToBase(t *testing.T) {
726+ testConfigDir = t.TempDir()
727+ cwdDir := t.TempDir()
729+ localConfig := Config{
730+ Providers: map[provider.InferenceProvider]ProviderConfig{
731+ provider.InferenceProviderAnthropic: {
732+ ID: provider.InferenceProviderAnthropic,
733+ APIKey: "local-anthropic-key",
734+ ProviderType: provider.TypeAnthropic,
735+ DefaultLargeModel: "claude-3-opus",
736+ DefaultSmallModel: "claude-3-haiku",
739+ ID: "claude-3-opus",
740+ Name: "Claude 3 Opus",
741+ ContextWindow: 200000,
742+ DefaultMaxTokens: 4096,
747+ ID: "claude-3-haiku",
748+ Name: "Claude 3 Haiku",
749+ ContextWindow: 200000,
750+ DefaultMaxTokens: 4096,
759+ localConfigPath := filepath.Join(cwdDir, "crush.json")
760+ data, err := json.Marshal(localConfig)
761+ require.NoError(t, err)
762+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
764+ cfg, err := Init(cwdDir, false)
766+ require.NoError(t, err)
767+ assert.Len(t, cfg.Providers, 1)
769+ anthropicProvider := cfg.Providers[provider.InferenceProviderAnthropic]
770+ assert.Equal(t, "local-anthropic-key", anthropicProvider.APIKey)
771+ assert.Equal(t, "claude-3-opus", anthropicProvider.DefaultLargeModel)
772+ assert.Equal(t, "claude-3-haiku", anthropicProvider.DefaultSmallModel)
773+ assert.Len(t, anthropicProvider.Models, 2)
776+func TestProviderMerging_ConflictingSettings(t *testing.T) {
778+ testConfigDir = t.TempDir()
779+ cwdDir := t.TempDir()
781+ globalConfig := Config{
782+ Providers: map[provider.InferenceProvider]ProviderConfig{
783+ provider.InferenceProviderOpenAI: {
784+ ID: provider.InferenceProviderOpenAI,
785+ APIKey: "global-key",
786+ ProviderType: provider.TypeOpenAI,
787+ DefaultLargeModel: "gpt-4",
788+ DefaultSmallModel: "gpt-3.5-turbo",
793+ ContextWindow: 8192,
794+ DefaultMaxTokens: 4096,
797+ ID: "gpt-3.5-turbo",
798+ Name: "GPT-3.5 Turbo",
799+ ContextWindow: 4096,
800+ DefaultMaxTokens: 2048,
804+ Name: "GPT-4 Turbo",
805+ ContextWindow: 128000,
806+ DefaultMaxTokens: 4096,
813+ configPath := filepath.Join(testConfigDir, "crush.json")
814+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
815+ data, err := json.Marshal(globalConfig)
816+ require.NoError(t, err)
817+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
819+ // Create local config that overrides
820+ localConfig := Config{
821+ Providers: map[provider.InferenceProvider]ProviderConfig{
822+ provider.InferenceProviderOpenAI: {
823+ APIKey: "local-key",
824+ DefaultLargeModel: "gpt-4-turbo",
829+ localConfigPath := filepath.Join(cwdDir, "crush.json")
830+ data, err = json.Marshal(localConfig)
831+ require.NoError(t, err)
832+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
834+ cfg, err := Init(cwdDir, false)
836+ require.NoError(t, err)
838+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
839+ assert.Equal(t, "local-key", openaiProvider.APIKey)
840+ assert.Equal(t, "gpt-4-turbo", openaiProvider.DefaultLargeModel)
841+ assert.False(t, openaiProvider.Disabled)
842+ assert.Equal(t, "gpt-3.5-turbo", openaiProvider.DefaultSmallModel)
845+func TestProviderMerging_CustomVsKnownProviders(t *testing.T) {
847+ testConfigDir = t.TempDir()
848+ cwdDir := t.TempDir()
850+ customProviderID := provider.InferenceProvider("custom-provider")
852+ globalConfig := Config{
853+ Providers: map[provider.InferenceProvider]ProviderConfig{
854+ provider.InferenceProviderOpenAI: {
855+ ID: provider.InferenceProviderOpenAI,
856+ APIKey: "openai-key",
857+ BaseURL: "should-not-override",
858+ ProviderType: provider.TypeAnthropic,
859+ DefaultLargeModel: "gpt-4",
860+ DefaultSmallModel: "gpt-3.5-turbo",
865+ ContextWindow: 8192,
866+ DefaultMaxTokens: 4096,
869+ ID: "gpt-3.5-turbo",
870+ Name: "GPT-3.5 Turbo",
871+ ContextWindow: 4096,
872+ DefaultMaxTokens: 2048,
877+ ID: customProviderID,
878+ APIKey: "custom-key",
879+ BaseURL: "https://custom.api.com",
880+ ProviderType: provider.TypeOpenAI,
881+ DefaultLargeModel: "custom-large",
882+ DefaultSmallModel: "custom-small",
886+ Name: "Custom Large",
887+ ContextWindow: 8192,
888+ DefaultMaxTokens: 4096,
892+ Name: "Custom Small",
893+ ContextWindow: 4096,
894+ DefaultMaxTokens: 2048,
901+ localConfig := Config{
902+ Providers: map[provider.InferenceProvider]ProviderConfig{
903+ provider.InferenceProviderOpenAI: {
904+ BaseURL: "https://should-not-change.com",
905+ ProviderType: provider.TypeGemini, // Should not change
908+ BaseURL: "https://updated-custom.api.com",
909+ ProviderType: provider.TypeOpenAI,
914+ configPath := filepath.Join(testConfigDir, "crush.json")
915+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
916+ data, err := json.Marshal(globalConfig)
917+ require.NoError(t, err)
918+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
920+ localConfigPath := filepath.Join(cwdDir, "crush.json")
921+ data, err = json.Marshal(localConfig)
922+ require.NoError(t, err)
923+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
925+ cfg, err := Init(cwdDir, false)
927+ require.NoError(t, err)
929+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
930+ assert.NotEqual(t, "https://should-not-change.com", openaiProvider.BaseURL)
931+ assert.NotEqual(t, provider.TypeGemini, openaiProvider.ProviderType)
933+ customProvider := cfg.Providers[customProviderID]
934+ assert.Equal(t, "custom-key", customProvider.APIKey)
935+ assert.Equal(t, "https://updated-custom.api.com", customProvider.BaseURL)
936+ assert.Equal(t, provider.TypeOpenAI, customProvider.ProviderType)
939+func TestProviderValidation_CustomProviderMissingBaseURL(t *testing.T) {
941+ testConfigDir = t.TempDir()
942+ cwdDir := t.TempDir()
944+ customProviderID := provider.InferenceProvider("custom-provider")
946+ globalConfig := Config{
947+ Providers: map[provider.InferenceProvider]ProviderConfig{
949+ ID: customProviderID,
950+ APIKey: "custom-key",
951+ ProviderType: provider.TypeOpenAI,
956+ configPath := filepath.Join(testConfigDir, "crush.json")
957+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
958+ data, err := json.Marshal(globalConfig)
959+ require.NoError(t, err)
960+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
962+ cfg, err := Init(cwdDir, false)
964+ require.NoError(t, err)
965+ assert.NotContains(t, cfg.Providers, customProviderID)
968+func TestProviderValidation_CustomProviderMissingAPIKey(t *testing.T) {
970+ testConfigDir = t.TempDir()
971+ cwdDir := t.TempDir()
973+ customProviderID := provider.InferenceProvider("custom-provider")
975+ globalConfig := Config{
976+ Providers: map[provider.InferenceProvider]ProviderConfig{
978+ ID: customProviderID,
979+ BaseURL: "https://custom.api.com",
980+ ProviderType: provider.TypeOpenAI,
985+ configPath := filepath.Join(testConfigDir, "crush.json")
986+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
987+ data, err := json.Marshal(globalConfig)
988+ require.NoError(t, err)
989+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
991+ cfg, err := Init(cwdDir, false)
993+ require.NoError(t, err)
994+ assert.NotContains(t, cfg.Providers, customProviderID)
997+func TestProviderValidation_CustomProviderInvalidType(t *testing.T) {
999+ testConfigDir = t.TempDir()
1000+ cwdDir := t.TempDir()
1002+ customProviderID := provider.InferenceProvider("custom-provider")
1004+ globalConfig := Config{
1005+ Providers: map[provider.InferenceProvider]ProviderConfig{
1006+ customProviderID: {
1007+ ID: customProviderID,
1008+ APIKey: "custom-key",
1009+ BaseURL: "https://custom.api.com",
1010+ ProviderType: provider.Type("invalid-type"),
1015+ configPath := filepath.Join(testConfigDir, "crush.json")
1016+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1017+ data, err := json.Marshal(globalConfig)
1018+ require.NoError(t, err)
1019+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1021+ cfg, err := Init(cwdDir, false)
1023+ require.NoError(t, err)
1024+ assert.NotContains(t, cfg.Providers, customProviderID)
1027+func TestProviderValidation_KnownProviderValid(t *testing.T) {
1029+ testConfigDir = t.TempDir()
1030+ cwdDir := t.TempDir()
1032+ globalConfig := Config{
1033+ Providers: map[provider.InferenceProvider]ProviderConfig{
1034+ provider.InferenceProviderOpenAI: {
1035+ ID: provider.InferenceProviderOpenAI,
1036+ APIKey: "openai-key",
1037+ ProviderType: provider.TypeOpenAI,
1038+ DefaultLargeModel: "gpt-4",
1039+ DefaultSmallModel: "gpt-3.5-turbo",
1044+ ContextWindow: 8192,
1045+ DefaultMaxTokens: 4096,
1048+ ID: "gpt-3.5-turbo",
1049+ Name: "GPT-3.5 Turbo",
1050+ ContextWindow: 4096,
1051+ DefaultMaxTokens: 2048,
1058+ configPath := filepath.Join(testConfigDir, "crush.json")
1059+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1060+ data, err := json.Marshal(globalConfig)
1061+ require.NoError(t, err)
1062+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1064+ cfg, err := Init(cwdDir, false)
1066+ require.NoError(t, err)
1067+ assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
1070+func TestProviderValidation_DisabledProvider(t *testing.T) {
1072+ testConfigDir = t.TempDir()
1073+ cwdDir := t.TempDir()
1075+ globalConfig := Config{
1076+ Providers: map[provider.InferenceProvider]ProviderConfig{
1077+ provider.InferenceProviderOpenAI: {
1078+ ID: provider.InferenceProviderOpenAI,
1079+ APIKey: "openai-key",
1080+ ProviderType: provider.TypeOpenAI,
1082+ DefaultLargeModel: "gpt-4",
1083+ DefaultSmallModel: "gpt-3.5-turbo",
1088+ ContextWindow: 8192,
1089+ DefaultMaxTokens: 4096,
1092+ ID: "gpt-3.5-turbo",
1093+ Name: "GPT-3.5 Turbo",
1094+ ContextWindow: 4096,
1095+ DefaultMaxTokens: 2048,
1099+ provider.InferenceProviderAnthropic: {
1100+ ID: provider.InferenceProviderAnthropic,
1101+ APIKey: "anthropic-key",
1102+ ProviderType: provider.TypeAnthropic,
1103+ Disabled: false, // This one is enabled
1104+ DefaultLargeModel: "claude-3-opus",
1105+ DefaultSmallModel: "claude-3-haiku",
1108+ ID: "claude-3-opus",
1109+ Name: "Claude 3 Opus",
1110+ ContextWindow: 200000,
1111+ DefaultMaxTokens: 4096,
1114+ ID: "claude-3-haiku",
1115+ Name: "Claude 3 Haiku",
1116+ ContextWindow: 200000,
1117+ DefaultMaxTokens: 4096,
1124+ configPath := filepath.Join(testConfigDir, "crush.json")
1125+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1126+ data, err := json.Marshal(globalConfig)
1127+ require.NoError(t, err)
1128+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1130+ cfg, err := Init(cwdDir, false)
1132+ require.NoError(t, err)
1133+ assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
1134+ assert.True(t, cfg.Providers[provider.InferenceProviderOpenAI].Disabled)
1135+ assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
1136+ assert.False(t, cfg.Providers[provider.InferenceProviderAnthropic].Disabled)
1139+func TestProviderModels_AddingNewModels(t *testing.T) {
1141+ testConfigDir = t.TempDir()
1142+ cwdDir := t.TempDir()
1144+ globalConfig := Config{
1145+ Providers: map[provider.InferenceProvider]ProviderConfig{
1146+ provider.InferenceProviderOpenAI: {
1147+ ID: provider.InferenceProviderOpenAI,
1148+ APIKey: "openai-key",
1149+ ProviderType: provider.TypeOpenAI,
1150+ DefaultLargeModel: "gpt-4",
1151+ DefaultSmallModel: "gpt-4-turbo",
1156+ ContextWindow: 8192,
1157+ DefaultMaxTokens: 4096,
1164+ localConfig := Config{
1165+ Providers: map[provider.InferenceProvider]ProviderConfig{
1166+ provider.InferenceProviderOpenAI: {
1170+ Name: "GPT-4 Turbo",
1171+ ContextWindow: 128000,
1172+ DefaultMaxTokens: 4096,
1179+ configPath := filepath.Join(testConfigDir, "crush.json")
1180+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1181+ data, err := json.Marshal(globalConfig)
1182+ require.NoError(t, err)
1183+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1185+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1186+ data, err = json.Marshal(localConfig)
1187+ require.NoError(t, err)
1188+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1190+ cfg, err := Init(cwdDir, false)
1192+ require.NoError(t, err)
1194+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1195+ assert.Len(t, openaiProvider.Models, 2)
1197+ modelIDs := make([]string, len(openaiProvider.Models))
1198+ for i, model := range openaiProvider.Models {
1199+ modelIDs[i] = model.ID
1201+ assert.Contains(t, modelIDs, "gpt-4")
1202+ assert.Contains(t, modelIDs, "gpt-4-turbo")
1205+func TestProviderModels_DuplicateModelHandling(t *testing.T) {
1207+ testConfigDir = t.TempDir()
1208+ cwdDir := t.TempDir()
1210+ globalConfig := Config{
1211+ Providers: map[provider.InferenceProvider]ProviderConfig{
1212+ provider.InferenceProviderOpenAI: {
1213+ ID: provider.InferenceProviderOpenAI,
1214+ APIKey: "openai-key",
1215+ ProviderType: provider.TypeOpenAI,
1216+ DefaultLargeModel: "gpt-4",
1217+ DefaultSmallModel: "gpt-4",
1222+ ContextWindow: 8192,
1223+ DefaultMaxTokens: 4096,
1230+ localConfig := Config{
1231+ Providers: map[provider.InferenceProvider]ProviderConfig{
1232+ provider.InferenceProviderOpenAI: {
1236+ Name: "GPT-4 Updated",
1237+ ContextWindow: 16384,
1238+ DefaultMaxTokens: 8192,
1245+ configPath := filepath.Join(testConfigDir, "crush.json")
1246+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1247+ data, err := json.Marshal(globalConfig)
1248+ require.NoError(t, err)
1249+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1251+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1252+ data, err = json.Marshal(localConfig)
1253+ require.NoError(t, err)
1254+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1256+ cfg, err := Init(cwdDir, false)
1258+ require.NoError(t, err)
1260+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1261+ assert.Len(t, openaiProvider.Models, 1)
1263+ model := openaiProvider.Models[0]
1264+ assert.Equal(t, "gpt-4", model.ID)
1265+ assert.Equal(t, "GPT-4", model.Name)
1266+ assert.Equal(t, int64(8192), model.ContextWindow)
1269+func TestProviderModels_ModelCostAndCapabilities(t *testing.T) {
1271+ testConfigDir = t.TempDir()
1272+ cwdDir := t.TempDir()
1274+ globalConfig := Config{
1275+ Providers: map[provider.InferenceProvider]ProviderConfig{
1276+ provider.InferenceProviderOpenAI: {
1277+ ID: provider.InferenceProviderOpenAI,
1278+ APIKey: "openai-key",
1279+ ProviderType: provider.TypeOpenAI,
1280+ DefaultLargeModel: "gpt-4",
1281+ DefaultSmallModel: "gpt-4",
1287+ CostPer1MOut: 60.0,
1288+ CostPer1MInCached: 15.0,
1289+ CostPer1MOutCached: 30.0,
1290+ ContextWindow: 8192,
1291+ DefaultMaxTokens: 4096,
1293+ ReasoningEffort: "medium",
1294+ SupportsImages: true,
1301+ configPath := filepath.Join(testConfigDir, "crush.json")
1302+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1303+ data, err := json.Marshal(globalConfig)
1304+ require.NoError(t, err)
1305+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1307+ cfg, err := Init(cwdDir, false)
1309+ require.NoError(t, err)
1311+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1312+ require.Len(t, openaiProvider.Models, 1)
1314+ model := openaiProvider.Models[0]
1315+ assert.Equal(t, 30.0, model.CostPer1MIn)
1316+ assert.Equal(t, 60.0, model.CostPer1MOut)
1317+ assert.Equal(t, 15.0, model.CostPer1MInCached)
1318+ assert.Equal(t, 30.0, model.CostPer1MOutCached)
1319+ assert.True(t, model.CanReason)
1320+ assert.Equal(t, "medium", model.ReasoningEffort)
1321+ assert.True(t, model.SupportsImages)
1324+func TestDefaultAgents_CoderAgent(t *testing.T) {
1326+ testConfigDir = t.TempDir()
1327+ cwdDir := t.TempDir()
1329+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1331+ cfg, err := Init(cwdDir, false)
1333+ require.NoError(t, err)
1334+ assert.Contains(t, cfg.Agents, AgentCoder)
1336+ coderAgent := cfg.Agents[AgentCoder]
1337+ assert.Equal(t, AgentCoder, coderAgent.ID)
1338+ assert.Equal(t, "Coder", coderAgent.Name)
1339+ assert.Equal(t, "An agent that helps with executing coding tasks.", coderAgent.Description)
1340+ assert.Equal(t, LargeModel, coderAgent.Model)
1341+ assert.False(t, coderAgent.Disabled)
1342+ assert.Equal(t, cfg.Options.ContextPaths, coderAgent.ContextPaths)
1343+ assert.Nil(t, coderAgent.AllowedTools)
1346+func TestDefaultAgents_TaskAgent(t *testing.T) {
1348+ testConfigDir = t.TempDir()
1349+ cwdDir := t.TempDir()
1351+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1353+ cfg, err := Init(cwdDir, false)
1355+ require.NoError(t, err)
1356+ assert.Contains(t, cfg.Agents, AgentTask)
1358+ taskAgent := cfg.Agents[AgentTask]
1359+ assert.Equal(t, AgentTask, taskAgent.ID)
1360+ assert.Equal(t, "Task", taskAgent.Name)
1361+ assert.Equal(t, "An agent that helps with searching for context and finding implementation details.", taskAgent.Description)
1362+ assert.Equal(t, LargeModel, taskAgent.Model)
1363+ assert.False(t, taskAgent.Disabled)
1364+ assert.Equal(t, cfg.Options.ContextPaths, taskAgent.ContextPaths)
1366+ expectedTools := []string{"glob", "grep", "ls", "sourcegraph", "view"}
1367+ assert.Equal(t, expectedTools, taskAgent.AllowedTools)
1369+ assert.Equal(t, map[string][]string{}, taskAgent.AllowedMCP)
1370+ assert.Equal(t, []string{}, taskAgent.AllowedLSP)
1373+func TestAgentMerging_CustomAgent(t *testing.T) {
1375+ testConfigDir = t.TempDir()
1376+ cwdDir := t.TempDir()
1378+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1380+ globalConfig := Config{
1381+ Agents: map[AgentID]Agent{
1382+ AgentID("custom-agent"): {
1383+ ID: AgentID("custom-agent"),
1384+ Name: "Custom Agent",
1385+ Description: "A custom agent for testing",
1387+ AllowedTools: []string{"glob", "grep"},
1388+ AllowedMCP: map[string][]string{"mcp1": {"tool1", "tool2"}},
1389+ AllowedLSP: []string{"typescript", "go"},
1390+ ContextPaths: []string{"custom-context.md"},
1393+ MCP: map[string]MCP{
1396+ Command: "test-mcp-command",
1397+ Args: []string{"--test"},
1400+ LSP: map[string]LSPConfig{
1402+ Command: "typescript-language-server",
1403+ Args: []string{"--stdio"},
1412+ configPath := filepath.Join(testConfigDir, "crush.json")
1413+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1414+ data, err := json.Marshal(globalConfig)
1415+ require.NoError(t, err)
1416+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1418+ cfg, err := Init(cwdDir, false)
1420+ require.NoError(t, err)
1422+ assert.Contains(t, cfg.Agents, AgentCoder)
1423+ assert.Contains(t, cfg.Agents, AgentTask)
1424+ assert.Contains(t, cfg.Agents, AgentID("custom-agent"))
1426+ customAgent := cfg.Agents[AgentID("custom-agent")]
1427+ assert.Equal(t, "Custom Agent", customAgent.Name)
1428+ assert.Equal(t, "A custom agent for testing", customAgent.Description)
1429+ assert.Equal(t, SmallModel, customAgent.Model)
1430+ assert.Equal(t, []string{"glob", "grep"}, customAgent.AllowedTools)
1431+ assert.Equal(t, map[string][]string{"mcp1": {"tool1", "tool2"}}, customAgent.AllowedMCP)
1432+ assert.Equal(t, []string{"typescript", "go"}, customAgent.AllowedLSP)
1433+ expectedContextPaths := append(defaultContextPaths, "custom-context.md")
1434+ assert.Equal(t, expectedContextPaths, customAgent.ContextPaths)
1437+func TestAgentMerging_ModifyDefaultCoderAgent(t *testing.T) {
1439+ testConfigDir = t.TempDir()
1440+ cwdDir := t.TempDir()
1442+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1444+ globalConfig := Config{
1445+ Agents: map[AgentID]Agent{
1448+ AllowedMCP: map[string][]string{"mcp1": {"tool1"}},
1449+ AllowedLSP: []string{"typescript"},
1450+ ContextPaths: []string{"coder-specific.md"},
1453+ MCP: map[string]MCP{
1456+ Command: "test-mcp-command",
1457+ Args: []string{"--test"},
1460+ LSP: map[string]LSPConfig{
1462+ Command: "typescript-language-server",
1463+ Args: []string{"--stdio"},
1468+ configPath := filepath.Join(testConfigDir, "crush.json")
1469+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1470+ data, err := json.Marshal(globalConfig)
1471+ require.NoError(t, err)
1472+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1474+ cfg, err := Init(cwdDir, false)
1476+ require.NoError(t, err)
1478+ coderAgent := cfg.Agents[AgentCoder]
1479+ assert.Equal(t, AgentCoder, coderAgent.ID)
1480+ assert.Equal(t, "Coder", coderAgent.Name)
1481+ assert.Equal(t, "An agent that helps with executing coding tasks.", coderAgent.Description)
1483+ expectedContextPaths := append(cfg.Options.ContextPaths, "coder-specific.md")
1484+ assert.Equal(t, expectedContextPaths, coderAgent.ContextPaths)
1486+ assert.Equal(t, SmallModel, coderAgent.Model)
1487+ assert.Equal(t, map[string][]string{"mcp1": {"tool1"}}, coderAgent.AllowedMCP)
1488+ assert.Equal(t, []string{"typescript"}, coderAgent.AllowedLSP)
1491+func TestAgentMerging_ModifyDefaultTaskAgent(t *testing.T) {
1493+ testConfigDir = t.TempDir()
1494+ cwdDir := t.TempDir()
1496+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1498+ globalConfig := Config{
1499+ Agents: map[AgentID]Agent{
1502+ AllowedMCP: map[string][]string{"search-mcp": nil},
1503+ AllowedLSP: []string{"python"},
1504+ Name: "Search Agent",
1505+ Description: "Custom search agent",
1507+ AllowedTools: []string{"glob", "grep", "view"},
1510+ MCP: map[string]MCP{
1513+ Command: "search-mcp-command",
1514+ Args: []string{"--search"},
1517+ LSP: map[string]LSPConfig{
1525+ configPath := filepath.Join(testConfigDir, "crush.json")
1526+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1527+ data, err := json.Marshal(globalConfig)
1528+ require.NoError(t, err)
1529+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1531+ cfg, err := Init(cwdDir, false)
1533+ require.NoError(t, err)
1535+ taskAgent := cfg.Agents[AgentTask]
1536+ assert.Equal(t, "Task", taskAgent.Name)
1537+ assert.Equal(t, "An agent that helps with searching for context and finding implementation details.", taskAgent.Description)
1538+ assert.False(t, taskAgent.Disabled)
1539+ assert.Equal(t, []string{"glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
1541+ assert.Equal(t, SmallModel, taskAgent.Model)
1542+ assert.Equal(t, map[string][]string{"search-mcp": nil}, taskAgent.AllowedMCP)
1543+ assert.Equal(t, []string{"python"}, taskAgent.AllowedLSP)
1546+func TestAgentMerging_LocalOverridesGlobal(t *testing.T) {
1548+ testConfigDir = t.TempDir()
1549+ cwdDir := t.TempDir()
1551+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1553+ globalConfig := Config{
1554+ Agents: map[AgentID]Agent{
1555+ AgentID("test-agent"): {
1556+ ID: AgentID("test-agent"),
1557+ Name: "Global Agent",
1558+ Description: "Global description",
1560+ AllowedTools: []string{"glob"},
1565+ configPath := filepath.Join(testConfigDir, "crush.json")
1566+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1567+ data, err := json.Marshal(globalConfig)
1568+ require.NoError(t, err)
1569+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1571+ // Create local config that overrides
1572+ localConfig := Config{
1573+ Agents: map[AgentID]Agent{
1574+ AgentID("test-agent"): {
1575+ Name: "Local Agent",
1576+ Description: "Local description",
1579+ AllowedTools: []string{"grep", "view"},
1580+ AllowedMCP: map[string][]string{"local-mcp": {"tool1"}},
1583+ MCP: map[string]MCP{
1586+ Command: "local-mcp-command",
1587+ Args: []string{"--local"},
1592+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1593+ data, err = json.Marshal(localConfig)
1594+ require.NoError(t, err)
1595+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1597+ cfg, err := Init(cwdDir, false)
1599+ require.NoError(t, err)
1601+ testAgent := cfg.Agents[AgentID("test-agent")]
1602+ assert.Equal(t, "Local Agent", testAgent.Name)
1603+ assert.Equal(t, "Local description", testAgent.Description)
1604+ assert.Equal(t, SmallModel, testAgent.Model)
1605+ assert.True(t, testAgent.Disabled)
1606+ assert.Equal(t, []string{"grep", "view"}, testAgent.AllowedTools)
1607+ assert.Equal(t, map[string][]string{"local-mcp": {"tool1"}}, testAgent.AllowedMCP)
1610+func TestAgentModelTypeAssignment(t *testing.T) {
1612+ testConfigDir = t.TempDir()
1613+ cwdDir := t.TempDir()
1615+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1617+ globalConfig := Config{
1618+ Agents: map[AgentID]Agent{
1619+ AgentID("large-agent"): {
1620+ ID: AgentID("large-agent"),
1621+ Name: "Large Model Agent",
1624+ AgentID("small-agent"): {
1625+ ID: AgentID("small-agent"),
1626+ Name: "Small Model Agent",
1629+ AgentID("default-agent"): {
1630+ ID: AgentID("default-agent"),
1631+ Name: "Default Model Agent",
1636+ configPath := filepath.Join(testConfigDir, "crush.json")
1637+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1638+ data, err := json.Marshal(globalConfig)
1639+ require.NoError(t, err)
1640+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1642+ cfg, err := Init(cwdDir, false)
1644+ require.NoError(t, err)
1646+ assert.Equal(t, LargeModel, cfg.Agents[AgentID("large-agent")].Model)
1647+ assert.Equal(t, SmallModel, cfg.Agents[AgentID("small-agent")].Model)
1648+ assert.Equal(t, LargeModel, cfg.Agents[AgentID("default-agent")].Model)
1651+func TestAgentContextPathOverrides(t *testing.T) {
1653+ testConfigDir = t.TempDir()
1654+ cwdDir := t.TempDir()
1656+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1658+ globalConfig := Config{
1660+ ContextPaths: []string{"global-context.md", "shared-context.md"},
1662+ Agents: map[AgentID]Agent{
1663+ AgentID("custom-context-agent"): {
1664+ ID: AgentID("custom-context-agent"),
1665+ Name: "Custom Context Agent",
1666+ ContextPaths: []string{"agent-specific.md", "custom.md"},
1668+ AgentID("default-context-agent"): {
1669+ ID: AgentID("default-context-agent"),
1670+ Name: "Default Context Agent",
1675+ configPath := filepath.Join(testConfigDir, "crush.json")
1676+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1677+ data, err := json.Marshal(globalConfig)
1678+ require.NoError(t, err)
1679+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1681+ cfg, err := Init(cwdDir, false)
1683+ require.NoError(t, err)
1685+ customAgent := cfg.Agents[AgentID("custom-context-agent")]
1686+ expectedCustomPaths := append(defaultContextPaths, "global-context.md", "shared-context.md", "agent-specific.md", "custom.md")
1687+ assert.Equal(t, expectedCustomPaths, customAgent.ContextPaths)
1689+ defaultAgent := cfg.Agents[AgentID("default-context-agent")]
1690+ expectedContextPaths := append(defaultContextPaths, "global-context.md", "shared-context.md")
1691+ assert.Equal(t, expectedContextPaths, defaultAgent.ContextPaths)
1693+ coderAgent := cfg.Agents[AgentCoder]
1694+ assert.Equal(t, expectedContextPaths, coderAgent.ContextPaths)
1697+func TestOptionsMerging_ContextPaths(t *testing.T) {
1699+ testConfigDir = t.TempDir()
1700+ cwdDir := t.TempDir()
1702+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1704+ globalConfig := Config{
1706+ ContextPaths: []string{"global1.md", "global2.md"},
1710+ configPath := filepath.Join(testConfigDir, "crush.json")
1711+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1712+ data, err := json.Marshal(globalConfig)
1713+ require.NoError(t, err)
1714+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1716+ localConfig := Config{
1718+ ContextPaths: []string{"local1.md", "local2.md"},
1722+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1723+ data, err = json.Marshal(localConfig)
1724+ require.NoError(t, err)
1725+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1727+ cfg, err := Init(cwdDir, false)
1729+ require.NoError(t, err)
1731+ expectedContextPaths := append(defaultContextPaths, "global1.md", "global2.md", "local1.md", "local2.md")
1732+ assert.Equal(t, expectedContextPaths, cfg.Options.ContextPaths)
1735+func TestOptionsMerging_TUIOptions(t *testing.T) {
1737+ testConfigDir = t.TempDir()
1738+ cwdDir := t.TempDir()
1740+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1742+ globalConfig := Config{
1745+ CompactMode: false,
1750+ configPath := filepath.Join(testConfigDir, "crush.json")
1751+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1752+ data, err := json.Marshal(globalConfig)
1753+ require.NoError(t, err)
1754+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1756+ localConfig := Config{
1764+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1765+ data, err = json.Marshal(localConfig)
1766+ require.NoError(t, err)
1767+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1769+ cfg, err := Init(cwdDir, false)
1771+ require.NoError(t, err)
1773+ assert.True(t, cfg.Options.TUI.CompactMode)
1776+func TestOptionsMerging_DebugFlags(t *testing.T) {
1778+ testConfigDir = t.TempDir()
1779+ cwdDir := t.TempDir()
1781+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1783+ globalConfig := Config{
1787+ DisableAutoSummarize: false,
1791+ configPath := filepath.Join(testConfigDir, "crush.json")
1792+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1793+ data, err := json.Marshal(globalConfig)
1794+ require.NoError(t, err)
1795+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1797+ localConfig := Config{
1800+ DisableAutoSummarize: true,
1804+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1805+ data, err = json.Marshal(localConfig)
1806+ require.NoError(t, err)
1807+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1809+ cfg, err := Init(cwdDir, false)
1811+ require.NoError(t, err)
1813+ assert.False(t, cfg.Options.Debug)
1814+ assert.True(t, cfg.Options.DebugLSP)
1815+ assert.True(t, cfg.Options.DisableAutoSummarize)
1818+func TestOptionsMerging_DataDirectory(t *testing.T) {
1820+ testConfigDir = t.TempDir()
1821+ cwdDir := t.TempDir()
1823+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1825+ globalConfig := Config{
1827+ DataDirectory: "global-data",
1831+ configPath := filepath.Join(testConfigDir, "crush.json")
1832+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1833+ data, err := json.Marshal(globalConfig)
1834+ require.NoError(t, err)
1835+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1837+ localConfig := Config{
1839+ DataDirectory: "local-data",
1843+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1844+ data, err = json.Marshal(localConfig)
1845+ require.NoError(t, err)
1846+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1848+ cfg, err := Init(cwdDir, false)
1850+ require.NoError(t, err)
1852+ assert.Equal(t, "local-data", cfg.Options.DataDirectory)
1855+func TestOptionsMerging_DefaultValues(t *testing.T) {
1857+ testConfigDir = t.TempDir()
1858+ cwdDir := t.TempDir()
1860+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1862+ cfg, err := Init(cwdDir, false)
1864+ require.NoError(t, err)
1866+ assert.Equal(t, defaultDataDirectory, cfg.Options.DataDirectory)
1867+ assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
1868+ assert.False(t, cfg.Options.TUI.CompactMode)
1869+ assert.False(t, cfg.Options.Debug)
1870+ assert.False(t, cfg.Options.DebugLSP)
1871+ assert.False(t, cfg.Options.DisableAutoSummarize)
1874+func TestOptionsMerging_DebugFlagFromInit(t *testing.T) {
1876+ testConfigDir = t.TempDir()
1877+ cwdDir := t.TempDir()
1879+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1881+ globalConfig := Config{
1887+ configPath := filepath.Join(testConfigDir, "crush.json")
1888+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1889+ data, err := json.Marshal(globalConfig)
1890+ require.NoError(t, err)
1891+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1893+ cfg, err := Init(cwdDir, true)
1895+ require.NoError(t, err)
1897+ // Debug flag from Init should take precedence
1898+ assert.True(t, cfg.Options.Debug)
1901+func TestOptionsMerging_ComplexScenario(t *testing.T) {
1903+ testConfigDir = t.TempDir()
1904+ cwdDir := t.TempDir()
1906+ // Set up a provider
1907+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1909+ // Create global config with various options
1910+ globalConfig := Config{
1912+ ContextPaths: []string{"global-context.md"},
1913+ DataDirectory: "global-data",
1916+ DisableAutoSummarize: false,
1918+ CompactMode: false,
1923+ configPath := filepath.Join(testConfigDir, "crush.json")
1924+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1925+ data, err := json.Marshal(globalConfig)
1926+ require.NoError(t, err)
1927+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1929+ // Create local config that partially overrides
1930+ localConfig := Config{
1932+ ContextPaths: []string{"local-context.md"},
1933+ DebugLSP: true, // Override
1934+ DisableAutoSummarize: true, // Override
1936+ CompactMode: true, // Override
1938+ // DataDirectory and Debug not specified - should keep global values
1942+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1943+ data, err = json.Marshal(localConfig)
1944+ require.NoError(t, err)
1945+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1947+ cfg, err := Init(cwdDir, false)
1949+ require.NoError(t, err)
1951+ // Check merged results
1952+ expectedContextPaths := append(defaultContextPaths, "global-context.md", "local-context.md")
1953+ assert.Equal(t, expectedContextPaths, cfg.Options.ContextPaths)
1954+ assert.Equal(t, "global-data", cfg.Options.DataDirectory) // From global
1955+ assert.False(t, cfg.Options.Debug) // From global
1956+ assert.True(t, cfg.Options.DebugLSP) // From local
1957+ assert.True(t, cfg.Options.DisableAutoSummarize) // From local
1958+ assert.True(t, cfg.Options.TUI.CompactMode) // From local
1961+// Model Selection Tests
1963+func TestModelSelection_PreferredModelSelection(t *testing.T) {
1965+ testConfigDir = t.TempDir()
1966+ cwdDir := t.TempDir()
1968+ // Set up multiple providers to test selection logic
1969+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
1970+ os.Setenv("OPENAI_API_KEY", "test-openai-key")
1972+ cfg, err := Init(cwdDir, false)
1974+ require.NoError(t, err)
1975+ require.Len(t, cfg.Providers, 2)
1977+ // Should have preferred models set
1978+ assert.NotEmpty(t, cfg.Models.Large.ModelID)
1979+ assert.NotEmpty(t, cfg.Models.Large.Provider)
1980+ assert.NotEmpty(t, cfg.Models.Small.ModelID)
1981+ assert.NotEmpty(t, cfg.Models.Small.Provider)
1983+ // Both should use the same provider (first available)
1984+ assert.Equal(t, cfg.Models.Large.Provider, cfg.Models.Small.Provider)
1987+func TestValidation_InvalidModelReference(t *testing.T) {
1989+ testConfigDir = t.TempDir()
1990+ cwdDir := t.TempDir()
1992+ globalConfig := Config{
1993+ Providers: map[provider.InferenceProvider]ProviderConfig{
1994+ provider.InferenceProviderOpenAI: {
1995+ ID: provider.InferenceProviderOpenAI,
1996+ APIKey: "test-key",
1997+ ProviderType: provider.TypeOpenAI,
1998+ DefaultLargeModel: "non-existent-model",
1999+ DefaultSmallModel: "gpt-3.5-turbo",
2002+ ID: "gpt-3.5-turbo",
2003+ Name: "GPT-3.5 Turbo",
2004+ ContextWindow: 4096,
2005+ DefaultMaxTokens: 2048,
2012+ configPath := filepath.Join(testConfigDir, "crush.json")
2013+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
2014+ data, err := json.Marshal(globalConfig)
2015+ require.NoError(t, err)
2016+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
2018+ _, err = Init(cwdDir, false)
2019+ assert.Error(t, err)
2022+func TestValidation_EmptyAPIKey(t *testing.T) {
2024+ testConfigDir = t.TempDir()
2025+ cwdDir := t.TempDir()
2027+ globalConfig := Config{
2028+ Providers: map[provider.InferenceProvider]ProviderConfig{
2029+ provider.InferenceProviderOpenAI: {
2030+ ID: provider.InferenceProviderOpenAI,
2031+ ProviderType: provider.TypeOpenAI,
2036+ ContextWindow: 8192,
2037+ DefaultMaxTokens: 4096,
2044+ configPath := filepath.Join(testConfigDir, "crush.json")
2045+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
2046+ data, err := json.Marshal(globalConfig)
2047+ require.NoError(t, err)
2048+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
2050+ _, err = Init(cwdDir, false)
2051+ assert.Error(t, err)
2054+func TestValidation_InvalidAgentModelType(t *testing.T) {
2056+ testConfigDir = t.TempDir()
2057+ cwdDir := t.TempDir()
2059+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
2061+ globalConfig := Config{
2062+ Agents: map[AgentID]Agent{
2063+ AgentID("invalid-agent"): {
2064+ ID: AgentID("invalid-agent"),
2065+ Name: "Invalid Agent",
2066+ Model: ModelType("invalid"),
2071+ configPath := filepath.Join(testConfigDir, "crush.json")
2072+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
2073+ data, err := json.Marshal(globalConfig)
2074+ require.NoError(t, err)
2075+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
2077+ _, err = Init(cwdDir, false)
2078+ assert.Error(t, err)