load.go

  1package config
  2
  3import (
  4	"cmp"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"log/slog"
  9	"maps"
 10	"os"
 11	"os/exec"
 12	"path/filepath"
 13	"runtime"
 14	"slices"
 15	"strconv"
 16	"strings"
 17	"testing"
 18
 19	"charm.land/catwalk/pkg/catwalk"
 20	"github.com/charmbracelet/crush/internal/agent/hyper"
 21	"github.com/charmbracelet/crush/internal/csync"
 22	"github.com/charmbracelet/crush/internal/env"
 23	"github.com/charmbracelet/crush/internal/fsext"
 24	"github.com/charmbracelet/crush/internal/home"
 25	powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
 26	"github.com/qjebbs/go-jsons"
 27)
 28
 29const defaultCatwalkURL = "https://catwalk.charm.sh"
 30
 31// Load loads the configuration from the default paths and returns a
 32// ConfigStore that owns both the pure-data Config and all runtime state.
 33func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
 34	configPaths := lookupConfigs(workingDir)
 35
 36	cfg, loadedPaths, err := loadFromConfigPaths(configPaths)
 37	if err != nil {
 38		return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
 39	}
 40
 41	cfg.setDefaults(workingDir, dataDir)
 42
 43	store := &ConfigStore{
 44		config:         cfg,
 45		workingDir:     workingDir,
 46		globalDataPath: GlobalConfigData(),
 47		workspacePath:  filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName)),
 48		loadedPaths:    loadedPaths,
 49	}
 50
 51	if debug {
 52		cfg.Options.Debug = true
 53	}
 54
 55	// Load workspace config last so it has highest priority.
 56	if wsData, err := os.ReadFile(store.workspacePath); err == nil && len(wsData) > 0 {
 57		merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
 58		if mergeErr == nil {
 59			// Preserve defaults that setDefaults already applied.
 60			dataDir := cfg.Options.DataDirectory
 61			*cfg = *merged
 62			cfg.setDefaults(workingDir, dataDir)
 63			store.config = cfg
 64			store.loadedPaths = append(store.loadedPaths, store.workspacePath)
 65		}
 66	}
 67
 68	if !isInsideWorktree() {
 69		const depth = 2
 70		const items = 100
 71		slog.Warn("No git repository detected in working directory, will limit file walk operations", "depth", depth, "items", items)
 72		assignIfNil(&cfg.Tools.Ls.MaxDepth, depth)
 73		assignIfNil(&cfg.Tools.Ls.MaxItems, items)
 74		assignIfNil(&cfg.Options.TUI.Completions.MaxDepth, depth)
 75		assignIfNil(&cfg.Options.TUI.Completions.MaxItems, items)
 76	}
 77
 78	if isAppleTerminal() {
 79		slog.Warn("Detected Apple Terminal, enabling transparent mode")
 80		assignIfNil(&cfg.Options.TUI.Transparent, true)
 81	}
 82
 83	// Load known providers, this loads the config from catwalk
 84	providers, err := Providers(cfg)
 85	if err != nil {
 86		return nil, err
 87	}
 88	store.knownProviders = providers
 89
 90	env := env.New()
 91	// Configure providers
 92	valueResolver := NewShellVariableResolver(env)
 93	store.resolver = valueResolver
 94
 95	// Disable auto-reload during initial load to prevent nested calls from
 96	// config-modifying operations inside configureProviders.
 97	store.autoReloadDisabled = true
 98	defer func() { store.autoReloadDisabled = false }()
 99
100	if err := cfg.configureProviders(store, env, valueResolver, store.knownProviders); err != nil {
101		return nil, fmt.Errorf("failed to configure providers: %w", err)
102	}
103
104	if !cfg.IsConfigured() {
105		slog.Warn("No providers configured")
106		return store, nil
107	}
108
109	if err := configureSelectedModels(store, store.knownProviders, true); err != nil {
110		return nil, fmt.Errorf("failed to configure selected models: %w", err)
111	}
112	store.SetupAgents()
113
114	// Capture initial staleness snapshot
115	store.captureStalenessSnapshot(loadedPaths)
116
117	return store, nil
118}
119
120// mustMarshalConfig marshals the config to JSON bytes, returning empty JSON on
121// error.
122func mustMarshalConfig(cfg *Config) []byte {
123	data, err := json.Marshal(cfg)
124	if err != nil {
125		return []byte("{}")
126	}
127	return data
128}
129
130func PushPopCrushEnv() func() {
131	var found []string
132	for _, ev := range os.Environ() {
133		if strings.HasPrefix(ev, "CRUSH_") {
134			pair := strings.SplitN(ev, "=", 2)
135			if len(pair) != 2 {
136				continue
137			}
138			found = append(found, strings.TrimPrefix(pair[0], "CRUSH_"))
139		}
140	}
141	backups := make(map[string]string)
142	for _, ev := range found {
143		backups[ev] = os.Getenv(ev)
144	}
145
146	for _, ev := range found {
147		os.Setenv(ev, os.Getenv("CRUSH_"+ev))
148	}
149
150	restore := func() {
151		for k, v := range backups {
152			os.Setenv(k, v)
153		}
154	}
155	return restore
156}
157
158func (c *Config) configureProviders(store *ConfigStore, env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
159	knownProviderNames := make(map[string]bool)
160	restore := PushPopCrushEnv()
161	defer restore()
162
163	// When disable_default_providers is enabled, skip all default/embedded
164	// providers entirely. Users must fully specify any providers they want.
165	// We skip to the custom provider validation loop which handles all
166	// user-configured providers uniformly.
167	if c.Options.DisableDefaultProviders {
168		knownProviders = nil
169	}
170
171	for _, p := range knownProviders {
172		knownProviderNames[string(p.ID)] = true
173		config, configExists := c.Providers.Get(string(p.ID))
174		// if the user configured a known provider we need to allow it to override a couple of parameters
175		if configExists {
176			if config.BaseURL != "" {
177				p.APIEndpoint = config.BaseURL
178			}
179			if config.APIKey != "" {
180				p.APIKey = config.APIKey
181			}
182			if len(config.Models) > 0 {
183				models := []catwalk.Model{}
184				seen := make(map[string]bool)
185
186				for _, model := range config.Models {
187					if seen[model.ID] {
188						continue
189					}
190					seen[model.ID] = true
191					if model.Name == "" {
192						model.Name = model.ID
193					}
194					models = append(models, model)
195				}
196				for _, model := range p.Models {
197					if seen[model.ID] {
198						continue
199					}
200					seen[model.ID] = true
201					if model.Name == "" {
202						model.Name = model.ID
203					}
204					models = append(models, model)
205				}
206
207				p.Models = models
208			}
209		}
210
211		headers := map[string]string{}
212		if len(p.DefaultHeaders) > 0 {
213			maps.Copy(headers, p.DefaultHeaders)
214		}
215		if len(config.ExtraHeaders) > 0 {
216			maps.Copy(headers, config.ExtraHeaders)
217		}
218		for k, v := range headers {
219			resolved, err := resolver.ResolveValue(v)
220			if err != nil {
221				slog.Error("Could not resolve provider header", "err", err.Error())
222				continue
223			}
224			headers[k] = resolved
225		}
226		prepared := ProviderConfig{
227			ID:                 string(p.ID),
228			Name:               p.Name,
229			BaseURL:            p.APIEndpoint,
230			APIKey:             p.APIKey,
231			APIKeyTemplate:     p.APIKey, // Store original template for re-resolution
232			OAuthToken:         config.OAuthToken,
233			Type:               p.Type,
234			Disable:            config.Disable,
235			SystemPromptPrefix: config.SystemPromptPrefix,
236			ExtraHeaders:       headers,
237			ExtraBody:          config.ExtraBody,
238			ExtraParams:        make(map[string]string),
239			Models:             p.Models,
240		}
241
242		switch {
243		case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
244			// Claude Code subscription is not supported anymore. Remove to show onboarding.
245			if !store.reloadInProgress {
246				store.RemoveConfigField(ScopeGlobal, "providers.anthropic")
247			}
248			c.Providers.Del(string(p.ID))
249			continue
250		case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
251			prepared.SetupGitHubCopilot()
252		}
253
254		switch p.ID {
255		// Handle specific providers that require additional configuration
256		case catwalk.InferenceProviderVertexAI:
257			var (
258				project  = env.Get("VERTEXAI_PROJECT")
259				location = env.Get("VERTEXAI_LOCATION")
260			)
261			if project == "" || location == "" {
262				if configExists {
263					slog.Warn("Skipping Vertex AI provider due to missing credentials")
264					c.Providers.Del(string(p.ID))
265				}
266				continue
267			}
268			prepared.ExtraParams["project"] = project
269			prepared.ExtraParams["location"] = location
270		case catwalk.InferenceProviderAzure:
271			endpoint, err := resolver.ResolveValue(p.APIEndpoint)
272			if err != nil || endpoint == "" {
273				if configExists {
274					slog.Warn("Skipping Azure provider due to missing API endpoint", "provider", p.ID, "error", err)
275					c.Providers.Del(string(p.ID))
276				}
277				continue
278			}
279			prepared.BaseURL = endpoint
280			prepared.ExtraParams["apiVersion"] = env.Get("AZURE_OPENAI_API_VERSION")
281		case catwalk.InferenceProviderBedrock:
282			if !hasAWSCredentials(env) {
283				if configExists {
284					slog.Warn("Skipping Bedrock provider due to missing AWS credentials")
285					c.Providers.Del(string(p.ID))
286				}
287				continue
288			}
289			prepared.ExtraParams["region"] = env.Get("AWS_REGION")
290			if prepared.ExtraParams["region"] == "" {
291				prepared.ExtraParams["region"] = env.Get("AWS_DEFAULT_REGION")
292			}
293			for _, model := range p.Models {
294				if !strings.HasPrefix(model.ID, "anthropic.") {
295					return fmt.Errorf("bedrock provider only supports anthropic models for now, found: %s", model.ID)
296				}
297			}
298		case catwalk.InferenceProvider("hyper"):
299			if apiKey := env.Get("HYPER_API_KEY"); apiKey != "" {
300				prepared.APIKey = apiKey
301				prepared.APIKeyTemplate = apiKey
302			} else {
303				v, err := resolver.ResolveValue(p.APIKey)
304				if v == "" || err != nil {
305					if configExists {
306						slog.Warn("Skipping Hyper provider due to missing API key", "provider", p.ID)
307						c.Providers.Del(string(p.ID))
308					}
309					continue
310				}
311			}
312		default:
313			// if the provider api or endpoint are missing we skip them
314			v, err := resolver.ResolveValue(p.APIKey)
315			if v == "" || err != nil {
316				if configExists {
317					slog.Warn("Skipping provider due to missing API key", "provider", p.ID)
318					c.Providers.Del(string(p.ID))
319				}
320				continue
321			}
322		}
323		c.Providers.Set(string(p.ID), prepared)
324	}
325
326	// validate the custom providers
327	for id, providerConfig := range c.Providers.Seq2() {
328		if knownProviderNames[id] {
329			continue
330		}
331
332		// Make sure the provider ID is set
333		providerConfig.ID = id
334		providerConfig.Name = cmp.Or(providerConfig.Name, id) // Use ID as name if not set
335		// default to OpenAI if not set
336		providerConfig.Type = cmp.Or(providerConfig.Type, catwalk.TypeOpenAICompat)
337		if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) && providerConfig.Type != hyper.Name {
338			slog.Warn("Skipping custom provider due to unsupported provider type", "provider", id)
339			c.Providers.Del(id)
340			continue
341		}
342
343		if providerConfig.Disable {
344			slog.Debug("Skipping custom provider due to disable flag", "provider", id)
345			c.Providers.Del(id)
346			continue
347		}
348		if providerConfig.APIKey == "" {
349			slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id)
350		}
351		if providerConfig.BaseURL == "" {
352			slog.Warn("Skipping custom provider due to missing API endpoint", "provider", id)
353			c.Providers.Del(id)
354			continue
355		}
356		if len(providerConfig.Models) == 0 {
357			slog.Warn("Skipping custom provider because the provider has no models", "provider", id)
358			c.Providers.Del(id)
359			continue
360		}
361		apiKey, err := resolver.ResolveValue(providerConfig.APIKey)
362		if apiKey == "" || err != nil {
363			slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id)
364		}
365		baseURL, err := resolver.ResolveValue(providerConfig.BaseURL)
366		if baseURL == "" || err != nil {
367			slog.Warn("Skipping custom provider due to missing API endpoint", "provider", id, "error", err)
368			c.Providers.Del(id)
369			continue
370		}
371
372		for k, v := range providerConfig.ExtraHeaders {
373			resolved, err := resolver.ResolveValue(v)
374			if err != nil {
375				slog.Error("Could not resolve provider header", "err", err.Error())
376				continue
377			}
378			providerConfig.ExtraHeaders[k] = resolved
379		}
380
381		c.Providers.Set(id, providerConfig)
382	}
383
384	if c.Providers.Len() == 0 && c.Options.DisableDefaultProviders {
385		return fmt.Errorf("default providers are disabled and there are no custom providers are configured")
386	}
387
388	return nil
389}
390
391func (c *Config) setDefaults(workingDir, dataDir string) {
392	if c.Options == nil {
393		c.Options = &Options{}
394	}
395	if c.Options.TUI == nil {
396		c.Options.TUI = &TUIOptions{}
397	}
398	if dataDir != "" {
399		c.Options.DataDirectory = dataDir
400	} else if c.Options.DataDirectory == "" {
401		if path, ok := fsext.LookupClosest(workingDir, defaultDataDirectory); ok {
402			c.Options.DataDirectory = path
403		} else {
404			c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory)
405		}
406	}
407	if c.Providers == nil {
408		c.Providers = csync.NewMap[string, ProviderConfig]()
409	}
410	if c.Models == nil {
411		c.Models = make(map[SelectedModelType]SelectedModel)
412	}
413	if c.RecentModels == nil {
414		c.RecentModels = make(map[SelectedModelType][]SelectedModel)
415	}
416	if c.MCP == nil {
417		c.MCP = make(map[string]MCPConfig)
418	}
419	if c.LSP == nil {
420		c.LSP = make(map[string]LSPConfig)
421	}
422
423	// Apply defaults to LSP configurations
424	c.applyLSPDefaults()
425
426	// Add the default context paths if they are not already present
427	c.Options.ContextPaths = append(defaultContextPaths, c.Options.ContextPaths...)
428	slices.Sort(c.Options.ContextPaths)
429	c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
430
431	// Add the default skills directories if not already present.
432	for _, dir := range GlobalSkillsDirs() {
433		if !slices.Contains(c.Options.SkillsPaths, dir) {
434			c.Options.SkillsPaths = append(c.Options.SkillsPaths, dir)
435		}
436	}
437
438	// Project specific skills dirs.
439	c.Options.SkillsPaths = append(c.Options.SkillsPaths, ProjectSkillsDir(workingDir)...)
440
441	if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
442		c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
443	}
444
445	if str, ok := os.LookupEnv("CRUSH_DISABLE_DEFAULT_PROVIDERS"); ok {
446		c.Options.DisableDefaultProviders, _ = strconv.ParseBool(str)
447	}
448
449	if c.Options.Attribution == nil {
450		c.Options.Attribution = &Attribution{
451			TrailerStyle:  TrailerStyleAssistedBy,
452			GeneratedWith: true,
453		}
454	} else if c.Options.Attribution.TrailerStyle == "" {
455		// Migrate deprecated co_authored_by or apply default
456		if c.Options.Attribution.CoAuthoredBy != nil {
457			if *c.Options.Attribution.CoAuthoredBy {
458				c.Options.Attribution.TrailerStyle = TrailerStyleCoAuthoredBy
459			} else {
460				c.Options.Attribution.TrailerStyle = TrailerStyleNone
461			}
462		} else {
463			c.Options.Attribution.TrailerStyle = TrailerStyleAssistedBy
464		}
465	}
466	c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs)
467}
468
469// applyLSPDefaults applies default values from powernap to LSP configurations
470func (c *Config) applyLSPDefaults() {
471	// Get powernap's default configuration
472	configManager := powernapConfig.NewManager()
473	configManager.LoadDefaults()
474
475	// Apply defaults to each LSP configuration
476	for name, cfg := range c.LSP {
477		// Try to get defaults from powernap based on name or command name.
478		base, ok := configManager.GetServer(name)
479		if !ok {
480			base, ok = configManager.GetServer(cfg.Command)
481			if !ok {
482				continue
483			}
484		}
485		if cfg.Options == nil {
486			cfg.Options = base.Settings
487		}
488		if cfg.InitOptions == nil {
489			cfg.InitOptions = base.InitOptions
490		}
491		if len(cfg.FileTypes) == 0 {
492			cfg.FileTypes = base.FileTypes
493		}
494		if len(cfg.RootMarkers) == 0 {
495			cfg.RootMarkers = base.RootMarkers
496		}
497		cfg.Command = cmp.Or(cfg.Command, base.Command)
498		if len(cfg.Args) == 0 {
499			cfg.Args = base.Args
500		}
501		if len(cfg.Env) == 0 {
502			cfg.Env = base.Environment
503		}
504		// Update the config in the map
505		c.LSP[name] = cfg
506	}
507}
508
509func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
510	if len(knownProviders) == 0 && c.Providers.Len() == 0 {
511		err = fmt.Errorf("no providers configured, please configure at least one provider")
512		return largeModel, smallModel, err
513	}
514
515	// Use the first provider enabled based on the known providers order
516	// if no provider found that is known use the first provider configured
517	for _, p := range knownProviders {
518		providerConfig, ok := c.Providers.Get(string(p.ID))
519		if !ok || providerConfig.Disable {
520			continue
521		}
522		defaultLargeModel := c.GetModel(string(p.ID), p.DefaultLargeModelID)
523		if defaultLargeModel == nil {
524			err = fmt.Errorf("default large model %s not found for provider %s", p.DefaultLargeModelID, p.ID)
525			return largeModel, smallModel, err
526		}
527		largeModel = SelectedModel{
528			Provider:        string(p.ID),
529			Model:           defaultLargeModel.ID,
530			MaxTokens:       defaultLargeModel.DefaultMaxTokens,
531			ReasoningEffort: defaultLargeModel.DefaultReasoningEffort,
532		}
533
534		defaultSmallModel := c.GetModel(string(p.ID), p.DefaultSmallModelID)
535		if defaultSmallModel == nil {
536			err = fmt.Errorf("default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
537			return largeModel, smallModel, err
538		}
539		smallModel = SelectedModel{
540			Provider:        string(p.ID),
541			Model:           defaultSmallModel.ID,
542			MaxTokens:       defaultSmallModel.DefaultMaxTokens,
543			ReasoningEffort: defaultSmallModel.DefaultReasoningEffort,
544		}
545		return largeModel, smallModel, err
546	}
547
548	enabledProviders := c.EnabledProviders()
549	slices.SortFunc(enabledProviders, func(a, b ProviderConfig) int {
550		return strings.Compare(a.ID, b.ID)
551	})
552
553	if len(enabledProviders) == 0 {
554		err = fmt.Errorf("no providers configured, please configure at least one provider")
555		return largeModel, smallModel, err
556	}
557
558	providerConfig := enabledProviders[0]
559	if len(providerConfig.Models) == 0 {
560		err = fmt.Errorf("provider %s has no models configured", providerConfig.ID)
561		return largeModel, smallModel, err
562	}
563	defaultLargeModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
564	largeModel = SelectedModel{
565		Provider:  providerConfig.ID,
566		Model:     defaultLargeModel.ID,
567		MaxTokens: defaultLargeModel.DefaultMaxTokens,
568	}
569	defaultSmallModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
570	smallModel = SelectedModel{
571		Provider:  providerConfig.ID,
572		Model:     defaultSmallModel.ID,
573		MaxTokens: defaultSmallModel.DefaultMaxTokens,
574	}
575	return largeModel, smallModel, err
576}
577
578func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provider, persist bool) error {
579	c := store.config
580	defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
581	if err != nil {
582		return fmt.Errorf("failed to select default models: %w", err)
583	}
584	large, small := defaultLarge, defaultSmall
585
586	largeModelSelected, largeModelConfigured := c.Models[SelectedModelTypeLarge]
587	if largeModelConfigured {
588		if largeModelSelected.Model != "" {
589			large.Model = largeModelSelected.Model
590		}
591		if largeModelSelected.Provider != "" {
592			large.Provider = largeModelSelected.Provider
593		}
594		model := c.GetModel(large.Provider, large.Model)
595		if model == nil {
596			large = defaultLarge
597			if persist {
598				if err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, large); err != nil {
599					return fmt.Errorf("failed to update preferred large model: %w", err)
600				}
601			}
602		} else {
603			if largeModelSelected.MaxTokens > 0 {
604				large.MaxTokens = largeModelSelected.MaxTokens
605			} else {
606				large.MaxTokens = model.DefaultMaxTokens
607			}
608			if largeModelSelected.ReasoningEffort != "" {
609				large.ReasoningEffort = largeModelSelected.ReasoningEffort
610			}
611			large.Think = largeModelSelected.Think
612			if largeModelSelected.Temperature != nil {
613				large.Temperature = largeModelSelected.Temperature
614			}
615			if largeModelSelected.TopP != nil {
616				large.TopP = largeModelSelected.TopP
617			}
618			if largeModelSelected.TopK != nil {
619				large.TopK = largeModelSelected.TopK
620			}
621			if largeModelSelected.FrequencyPenalty != nil {
622				large.FrequencyPenalty = largeModelSelected.FrequencyPenalty
623			}
624			if largeModelSelected.PresencePenalty != nil {
625				large.PresencePenalty = largeModelSelected.PresencePenalty
626			}
627		}
628	}
629	smallModelSelected, smallModelConfigured := c.Models[SelectedModelTypeSmall]
630	if smallModelConfigured {
631		if smallModelSelected.Model != "" {
632			small.Model = smallModelSelected.Model
633		}
634		if smallModelSelected.Provider != "" {
635			small.Provider = smallModelSelected.Provider
636		}
637
638		model := c.GetModel(small.Provider, small.Model)
639		if model == nil {
640			small = defaultSmall
641			if persist {
642				if err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, small); err != nil {
643					return fmt.Errorf("failed to update preferred small model: %w", err)
644				}
645			}
646		} else {
647			if smallModelSelected.MaxTokens > 0 {
648				small.MaxTokens = smallModelSelected.MaxTokens
649			} else {
650				small.MaxTokens = model.DefaultMaxTokens
651			}
652			if smallModelSelected.ReasoningEffort != "" {
653				small.ReasoningEffort = smallModelSelected.ReasoningEffort
654			}
655			if smallModelSelected.Temperature != nil {
656				small.Temperature = smallModelSelected.Temperature
657			}
658			if smallModelSelected.TopP != nil {
659				small.TopP = smallModelSelected.TopP
660			}
661			if smallModelSelected.TopK != nil {
662				small.TopK = smallModelSelected.TopK
663			}
664			if smallModelSelected.FrequencyPenalty != nil {
665				small.FrequencyPenalty = smallModelSelected.FrequencyPenalty
666			}
667			if smallModelSelected.PresencePenalty != nil {
668				small.PresencePenalty = smallModelSelected.PresencePenalty
669			}
670			small.Think = smallModelSelected.Think
671		}
672	}
673	c.Models[SelectedModelTypeLarge] = large
674	c.Models[SelectedModelTypeSmall] = small
675	return nil
676}
677
678// lookupConfigs searches config files recursively from CWD up to FS root
679func lookupConfigs(cwd string) []string {
680	// prepend default config paths
681	configPaths := []string{
682		GlobalConfig(),
683		GlobalConfigData(),
684	}
685
686	configNames := []string{appName + ".json", "." + appName + ".json"}
687
688	foundConfigs, err := fsext.Lookup(cwd, configNames...)
689	if err != nil {
690		// returns at least default configs
691		return configPaths
692	}
693
694	// reverse order so last config has more priority
695	slices.Reverse(foundConfigs)
696
697	return append(configPaths, foundConfigs...)
698}
699
700func loadFromConfigPaths(configPaths []string) (*Config, []string, error) {
701	var configs [][]byte
702	var loaded []string
703
704	for _, path := range configPaths {
705		data, err := os.ReadFile(path)
706		if err != nil {
707			if os.IsNotExist(err) {
708				continue
709			}
710			return nil, nil, fmt.Errorf("failed to open config file %s: %w", path, err)
711		}
712		if len(data) == 0 {
713			continue
714		}
715		configs = append(configs, data)
716		loaded = append(loaded, path)
717	}
718
719	cfg, err := loadFromBytes(configs)
720	if err != nil {
721		return nil, nil, err
722	}
723	return cfg, loaded, nil
724}
725
726func loadFromBytes(configs [][]byte) (*Config, error) {
727	if len(configs) == 0 {
728		return &Config{}, nil
729	}
730
731	data, err := jsons.Merge(configs)
732	if err != nil {
733		return nil, err
734	}
735	var config Config
736	if err := json.Unmarshal(data, &config); err != nil {
737		return nil, err
738	}
739	return &config, nil
740}
741
742func hasAWSCredentials(env env.Env) bool {
743	if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" {
744		return true
745	}
746
747	if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" {
748		return true
749	}
750
751	if env.Get("AWS_PROFILE") != "" || env.Get("AWS_DEFAULT_PROFILE") != "" {
752		return true
753	}
754
755	if env.Get("AWS_REGION") != "" || env.Get("AWS_DEFAULT_REGION") != "" {
756		return true
757	}
758
759	if env.Get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
760		env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
761		return true
762	}
763
764	if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil && !testing.Testing() {
765		return true
766	}
767
768	return false
769}
770
771// GlobalConfig returns the global configuration file path for the application.
772func GlobalConfig() string {
773	if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {
774		return filepath.Join(crushGlobal, fmt.Sprintf("%s.json", appName))
775	}
776	return filepath.Join(home.Config(), appName, fmt.Sprintf("%s.json", appName))
777}
778
779// GlobalCacheDir returns the path to the global cache directory for the
780// application.
781func GlobalCacheDir() string {
782	if crushCache := os.Getenv("CRUSH_CACHE_DIR"); crushCache != "" {
783		return crushCache
784	}
785	if xdgCacheHome := os.Getenv("XDG_CACHE_HOME"); xdgCacheHome != "" {
786		return filepath.Join(xdgCacheHome, appName)
787	}
788	if runtime.GOOS == "windows" {
789		localAppData := cmp.Or(
790			os.Getenv("LOCALAPPDATA"),
791			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
792		)
793		return filepath.Join(localAppData, appName, "cache")
794	}
795	return filepath.Join(home.Dir(), ".cache", appName)
796}
797
798// GlobalConfigData returns the path to the main data directory for the application.
799// this config is used when the app overrides configurations instead of updating the global config.
800func GlobalConfigData() string {
801	if crushData := os.Getenv("CRUSH_GLOBAL_DATA"); crushData != "" {
802		return filepath.Join(crushData, fmt.Sprintf("%s.json", appName))
803	}
804	if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" {
805		return filepath.Join(xdgDataHome, appName, fmt.Sprintf("%s.json", appName))
806	}
807
808	// return the path to the main data directory
809	// for windows, it should be in `%LOCALAPPDATA%/crush/`
810	// for linux and macOS, it should be in `$HOME/.local/share/crush/`
811	if runtime.GOOS == "windows" {
812		localAppData := cmp.Or(
813			os.Getenv("LOCALAPPDATA"),
814			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
815		)
816		return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
817	}
818
819	return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
820}
821
822// GlobalWorkspaceDir returns the path to the global server workspace
823// directory. This directory acts as a meta-workspace for the server
824// process, giving it a real workingDir so that config loading, scoped
825// writes, and provider resolution behave identically to project
826// workspaces.
827func GlobalWorkspaceDir() string {
828	return filepath.Dir(GlobalConfigData())
829}
830
831func assignIfNil[T any](ptr **T, val T) {
832	if *ptr == nil {
833		*ptr = &val
834	}
835}
836
837func isInsideWorktree() bool {
838	bts, err := exec.CommandContext(
839		context.Background(),
840		"git", "rev-parse",
841		"--is-inside-work-tree",
842	).CombinedOutput()
843	return err == nil && strings.TrimSpace(string(bts)) == "true"
844}
845
846// GlobalSkillsDirs returns the default directories for Agent Skills.
847// Skills in these directories are auto-discovered and their files can be read
848// without permission prompts.
849func GlobalSkillsDirs() []string {
850	if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
851		return []string{crushSkills}
852	}
853
854	paths := []string{
855		filepath.Join(home.Config(), appName, "skills"),
856		filepath.Join(home.Config(), "agents", "skills"),
857	}
858
859	// On Windows, also load from app data on top of `$HOME/.config/crush`.
860	// This is here mostly for backwards compatibility.
861	if runtime.GOOS == "windows" {
862		appData := cmp.Or(
863			os.Getenv("LOCALAPPDATA"),
864			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
865		)
866		paths = append(
867			paths,
868			filepath.Join(appData, appName, "skills"),
869			filepath.Join(appData, "agents", "skills"),
870		)
871	}
872
873	return paths
874}
875
876// ProjectSkillsDir returns the default project directories for which Crush
877// will look for skills.
878func ProjectSkillsDir(workingDir string) []string {
879	return []string{
880		filepath.Join(workingDir, ".agents/skills"),
881		filepath.Join(workingDir, ".crush/skills"),
882		filepath.Join(workingDir, ".claude/skills"),
883		filepath.Join(workingDir, ".cursor/skills"),
884	}
885}
886
887func isAppleTerminal() bool { return os.Getenv("TERM_PROGRAM") == "Apple_Terminal" }