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