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		default:
299			// if the provider api or endpoint are missing we skip them
300			v, err := resolver.ResolveValue(p.APIKey)
301			if v == "" || err != nil {
302				if configExists {
303					slog.Warn("Skipping provider due to missing API key", "provider", p.ID)
304					c.Providers.Del(string(p.ID))
305				}
306				continue
307			}
308		}
309		c.Providers.Set(string(p.ID), prepared)
310	}
311
312	// validate the custom providers
313	for id, providerConfig := range c.Providers.Seq2() {
314		if knownProviderNames[id] {
315			continue
316		}
317
318		// Make sure the provider ID is set
319		providerConfig.ID = id
320		providerConfig.Name = cmp.Or(providerConfig.Name, id) // Use ID as name if not set
321		// default to OpenAI if not set
322		providerConfig.Type = cmp.Or(providerConfig.Type, catwalk.TypeOpenAICompat)
323		if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) && providerConfig.Type != hyper.Name {
324			slog.Warn("Skipping custom provider due to unsupported provider type", "provider", id)
325			c.Providers.Del(id)
326			continue
327		}
328
329		if providerConfig.Disable {
330			slog.Debug("Skipping custom provider due to disable flag", "provider", id)
331			c.Providers.Del(id)
332			continue
333		}
334		if providerConfig.APIKey == "" {
335			slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id)
336		}
337		if providerConfig.BaseURL == "" {
338			slog.Warn("Skipping custom provider due to missing API endpoint", "provider", id)
339			c.Providers.Del(id)
340			continue
341		}
342		if len(providerConfig.Models) == 0 {
343			slog.Warn("Skipping custom provider because the provider has no models", "provider", id)
344			c.Providers.Del(id)
345			continue
346		}
347		apiKey, err := resolver.ResolveValue(providerConfig.APIKey)
348		if apiKey == "" || err != nil {
349			slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id)
350		}
351		baseURL, err := resolver.ResolveValue(providerConfig.BaseURL)
352		if baseURL == "" || err != nil {
353			slog.Warn("Skipping custom provider due to missing API endpoint", "provider", id, "error", err)
354			c.Providers.Del(id)
355			continue
356		}
357
358		for k, v := range providerConfig.ExtraHeaders {
359			resolved, err := resolver.ResolveValue(v)
360			if err != nil {
361				slog.Error("Could not resolve provider header", "err", err.Error())
362				continue
363			}
364			providerConfig.ExtraHeaders[k] = resolved
365		}
366
367		c.Providers.Set(id, providerConfig)
368	}
369
370	if c.Providers.Len() == 0 && c.Options.DisableDefaultProviders {
371		return fmt.Errorf("default providers are disabled and there are no custom providers are configured")
372	}
373
374	return nil
375}
376
377func (c *Config) setDefaults(workingDir, dataDir string) {
378	if c.Options == nil {
379		c.Options = &Options{}
380	}
381	if c.Options.TUI == nil {
382		c.Options.TUI = &TUIOptions{}
383	}
384	if dataDir != "" {
385		c.Options.DataDirectory = dataDir
386	} else if c.Options.DataDirectory == "" {
387		if path, ok := fsext.LookupClosest(workingDir, defaultDataDirectory); ok {
388			c.Options.DataDirectory = path
389		} else {
390			c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory)
391		}
392	}
393	if c.Providers == nil {
394		c.Providers = csync.NewMap[string, ProviderConfig]()
395	}
396	if c.Models == nil {
397		c.Models = make(map[SelectedModelType]SelectedModel)
398	}
399	if c.RecentModels == nil {
400		c.RecentModels = make(map[SelectedModelType][]SelectedModel)
401	}
402	if c.MCP == nil {
403		c.MCP = make(map[string]MCPConfig)
404	}
405	if c.LSP == nil {
406		c.LSP = make(map[string]LSPConfig)
407	}
408
409	// Apply defaults to LSP configurations
410	c.applyLSPDefaults()
411
412	// Add the default context paths if they are not already present
413	c.Options.ContextPaths = append(defaultContextPaths, c.Options.ContextPaths...)
414	slices.Sort(c.Options.ContextPaths)
415	c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
416
417	// Add the default skills directories if not already present.
418	for _, dir := range GlobalSkillsDirs() {
419		if !slices.Contains(c.Options.SkillsPaths, dir) {
420			c.Options.SkillsPaths = append(c.Options.SkillsPaths, dir)
421		}
422	}
423
424	// Project specific skills dirs.
425	c.Options.SkillsPaths = append(c.Options.SkillsPaths, ProjectSkillsDir(workingDir)...)
426
427	if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
428		c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
429	}
430
431	if str, ok := os.LookupEnv("CRUSH_DISABLE_DEFAULT_PROVIDERS"); ok {
432		c.Options.DisableDefaultProviders, _ = strconv.ParseBool(str)
433	}
434
435	if c.Options.Attribution == nil {
436		c.Options.Attribution = &Attribution{
437			TrailerStyle:  TrailerStyleAssistedBy,
438			GeneratedWith: true,
439		}
440	} else if c.Options.Attribution.TrailerStyle == "" {
441		// Migrate deprecated co_authored_by or apply default
442		if c.Options.Attribution.CoAuthoredBy != nil {
443			if *c.Options.Attribution.CoAuthoredBy {
444				c.Options.Attribution.TrailerStyle = TrailerStyleCoAuthoredBy
445			} else {
446				c.Options.Attribution.TrailerStyle = TrailerStyleNone
447			}
448		} else {
449			c.Options.Attribution.TrailerStyle = TrailerStyleAssistedBy
450		}
451	}
452	c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs)
453}
454
455// applyLSPDefaults applies default values from powernap to LSP configurations
456func (c *Config) applyLSPDefaults() {
457	// Get powernap's default configuration
458	configManager := powernapConfig.NewManager()
459	configManager.LoadDefaults()
460
461	// Apply defaults to each LSP configuration
462	for name, cfg := range c.LSP {
463		// Try to get defaults from powernap based on name or command name.
464		base, ok := configManager.GetServer(name)
465		if !ok {
466			base, ok = configManager.GetServer(cfg.Command)
467			if !ok {
468				continue
469			}
470		}
471		if cfg.Options == nil {
472			cfg.Options = base.Settings
473		}
474		if cfg.InitOptions == nil {
475			cfg.InitOptions = base.InitOptions
476		}
477		if len(cfg.FileTypes) == 0 {
478			cfg.FileTypes = base.FileTypes
479		}
480		if len(cfg.RootMarkers) == 0 {
481			cfg.RootMarkers = base.RootMarkers
482		}
483		cfg.Command = cmp.Or(cfg.Command, base.Command)
484		if len(cfg.Args) == 0 {
485			cfg.Args = base.Args
486		}
487		if len(cfg.Env) == 0 {
488			cfg.Env = base.Environment
489		}
490		// Update the config in the map
491		c.LSP[name] = cfg
492	}
493}
494
495func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
496	if len(knownProviders) == 0 && c.Providers.Len() == 0 {
497		err = fmt.Errorf("no providers configured, please configure at least one provider")
498		return largeModel, smallModel, err
499	}
500
501	// Use the first provider enabled based on the known providers order
502	// if no provider found that is known use the first provider configured
503	for _, p := range knownProviders {
504		providerConfig, ok := c.Providers.Get(string(p.ID))
505		if !ok || providerConfig.Disable {
506			continue
507		}
508		defaultLargeModel := c.GetModel(string(p.ID), p.DefaultLargeModelID)
509		if defaultLargeModel == nil {
510			err = fmt.Errorf("default large model %s not found for provider %s", p.DefaultLargeModelID, p.ID)
511			return largeModel, smallModel, err
512		}
513		largeModel = SelectedModel{
514			Provider:        string(p.ID),
515			Model:           defaultLargeModel.ID,
516			MaxTokens:       defaultLargeModel.DefaultMaxTokens,
517			ReasoningEffort: defaultLargeModel.DefaultReasoningEffort,
518		}
519
520		defaultSmallModel := c.GetModel(string(p.ID), p.DefaultSmallModelID)
521		if defaultSmallModel == nil {
522			err = fmt.Errorf("default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
523			return largeModel, smallModel, err
524		}
525		smallModel = SelectedModel{
526			Provider:        string(p.ID),
527			Model:           defaultSmallModel.ID,
528			MaxTokens:       defaultSmallModel.DefaultMaxTokens,
529			ReasoningEffort: defaultSmallModel.DefaultReasoningEffort,
530		}
531		return largeModel, smallModel, err
532	}
533
534	enabledProviders := c.EnabledProviders()
535	slices.SortFunc(enabledProviders, func(a, b ProviderConfig) int {
536		return strings.Compare(a.ID, b.ID)
537	})
538
539	if len(enabledProviders) == 0 {
540		err = fmt.Errorf("no providers configured, please configure at least one provider")
541		return largeModel, smallModel, err
542	}
543
544	providerConfig := enabledProviders[0]
545	if len(providerConfig.Models) == 0 {
546		err = fmt.Errorf("provider %s has no models configured", providerConfig.ID)
547		return largeModel, smallModel, err
548	}
549	defaultLargeModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
550	largeModel = SelectedModel{
551		Provider:  providerConfig.ID,
552		Model:     defaultLargeModel.ID,
553		MaxTokens: defaultLargeModel.DefaultMaxTokens,
554	}
555	defaultSmallModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
556	smallModel = SelectedModel{
557		Provider:  providerConfig.ID,
558		Model:     defaultSmallModel.ID,
559		MaxTokens: defaultSmallModel.DefaultMaxTokens,
560	}
561	return largeModel, smallModel, err
562}
563
564func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provider, persist bool) error {
565	c := store.config
566	defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
567	if err != nil {
568		return fmt.Errorf("failed to select default models: %w", err)
569	}
570	large, small := defaultLarge, defaultSmall
571
572	largeModelSelected, largeModelConfigured := c.Models[SelectedModelTypeLarge]
573	if largeModelConfigured {
574		if largeModelSelected.Model != "" {
575			large.Model = largeModelSelected.Model
576		}
577		if largeModelSelected.Provider != "" {
578			large.Provider = largeModelSelected.Provider
579		}
580		model := c.GetModel(large.Provider, large.Model)
581		if model == nil {
582			large = defaultLarge
583			if persist {
584				if err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, large); err != nil {
585					return fmt.Errorf("failed to update preferred large model: %w", err)
586				}
587			}
588		} else {
589			if largeModelSelected.MaxTokens > 0 {
590				large.MaxTokens = largeModelSelected.MaxTokens
591			} else {
592				large.MaxTokens = model.DefaultMaxTokens
593			}
594			if largeModelSelected.ReasoningEffort != "" {
595				large.ReasoningEffort = largeModelSelected.ReasoningEffort
596			}
597			large.Think = largeModelSelected.Think
598			if largeModelSelected.Temperature != nil {
599				large.Temperature = largeModelSelected.Temperature
600			}
601			if largeModelSelected.TopP != nil {
602				large.TopP = largeModelSelected.TopP
603			}
604			if largeModelSelected.TopK != nil {
605				large.TopK = largeModelSelected.TopK
606			}
607			if largeModelSelected.FrequencyPenalty != nil {
608				large.FrequencyPenalty = largeModelSelected.FrequencyPenalty
609			}
610			if largeModelSelected.PresencePenalty != nil {
611				large.PresencePenalty = largeModelSelected.PresencePenalty
612			}
613		}
614	}
615	smallModelSelected, smallModelConfigured := c.Models[SelectedModelTypeSmall]
616	if smallModelConfigured {
617		if smallModelSelected.Model != "" {
618			small.Model = smallModelSelected.Model
619		}
620		if smallModelSelected.Provider != "" {
621			small.Provider = smallModelSelected.Provider
622		}
623
624		model := c.GetModel(small.Provider, small.Model)
625		if model == nil {
626			small = defaultSmall
627			if persist {
628				if err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, small); err != nil {
629					return fmt.Errorf("failed to update preferred small model: %w", err)
630				}
631			}
632		} else {
633			if smallModelSelected.MaxTokens > 0 {
634				small.MaxTokens = smallModelSelected.MaxTokens
635			} else {
636				small.MaxTokens = model.DefaultMaxTokens
637			}
638			if smallModelSelected.ReasoningEffort != "" {
639				small.ReasoningEffort = smallModelSelected.ReasoningEffort
640			}
641			if smallModelSelected.Temperature != nil {
642				small.Temperature = smallModelSelected.Temperature
643			}
644			if smallModelSelected.TopP != nil {
645				small.TopP = smallModelSelected.TopP
646			}
647			if smallModelSelected.TopK != nil {
648				small.TopK = smallModelSelected.TopK
649			}
650			if smallModelSelected.FrequencyPenalty != nil {
651				small.FrequencyPenalty = smallModelSelected.FrequencyPenalty
652			}
653			if smallModelSelected.PresencePenalty != nil {
654				small.PresencePenalty = smallModelSelected.PresencePenalty
655			}
656			small.Think = smallModelSelected.Think
657		}
658	}
659	c.Models[SelectedModelTypeLarge] = large
660	c.Models[SelectedModelTypeSmall] = small
661	return nil
662}
663
664// lookupConfigs searches config files recursively from CWD up to FS root
665func lookupConfigs(cwd string) []string {
666	// prepend default config paths
667	configPaths := []string{
668		GlobalConfig(),
669		GlobalConfigData(),
670	}
671
672	configNames := []string{appName + ".json", "." + appName + ".json"}
673
674	foundConfigs, err := fsext.Lookup(cwd, configNames...)
675	if err != nil {
676		// returns at least default configs
677		return configPaths
678	}
679
680	// reverse order so last config has more priority
681	slices.Reverse(foundConfigs)
682
683	return append(configPaths, foundConfigs...)
684}
685
686func loadFromConfigPaths(configPaths []string) (*Config, []string, error) {
687	var configs [][]byte
688	var loaded []string
689
690	for _, path := range configPaths {
691		data, err := os.ReadFile(path)
692		if err != nil {
693			if os.IsNotExist(err) {
694				continue
695			}
696			return nil, nil, fmt.Errorf("failed to open config file %s: %w", path, err)
697		}
698		if len(data) == 0 {
699			continue
700		}
701		configs = append(configs, data)
702		loaded = append(loaded, path)
703	}
704
705	cfg, err := loadFromBytes(configs)
706	if err != nil {
707		return nil, nil, err
708	}
709	return cfg, loaded, nil
710}
711
712func loadFromBytes(configs [][]byte) (*Config, error) {
713	if len(configs) == 0 {
714		return &Config{}, nil
715	}
716
717	data, err := jsons.Merge(configs)
718	if err != nil {
719		return nil, err
720	}
721	var config Config
722	if err := json.Unmarshal(data, &config); err != nil {
723		return nil, err
724	}
725	return &config, nil
726}
727
728func hasAWSCredentials(env env.Env) bool {
729	if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" {
730		return true
731	}
732
733	if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" {
734		return true
735	}
736
737	if env.Get("AWS_PROFILE") != "" || env.Get("AWS_DEFAULT_PROFILE") != "" {
738		return true
739	}
740
741	if env.Get("AWS_REGION") != "" || env.Get("AWS_DEFAULT_REGION") != "" {
742		return true
743	}
744
745	if env.Get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
746		env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
747		return true
748	}
749
750	if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil && !testing.Testing() {
751		return true
752	}
753
754	return false
755}
756
757// GlobalConfig returns the global configuration file path for the application.
758func GlobalConfig() string {
759	if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {
760		return filepath.Join(crushGlobal, fmt.Sprintf("%s.json", appName))
761	}
762	return filepath.Join(home.Config(), appName, fmt.Sprintf("%s.json", appName))
763}
764
765// GlobalCacheDir returns the path to the global cache directory for the
766// application.
767func GlobalCacheDir() string {
768	if crushCache := os.Getenv("CRUSH_CACHE_DIR"); crushCache != "" {
769		return crushCache
770	}
771	if xdgCacheHome := os.Getenv("XDG_CACHE_HOME"); xdgCacheHome != "" {
772		return filepath.Join(xdgCacheHome, appName)
773	}
774	if runtime.GOOS == "windows" {
775		localAppData := cmp.Or(
776			os.Getenv("LOCALAPPDATA"),
777			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
778		)
779		return filepath.Join(localAppData, appName, "cache")
780	}
781	return filepath.Join(home.Dir(), ".cache", appName)
782}
783
784// GlobalConfigData returns the path to the main data directory for the application.
785// this config is used when the app overrides configurations instead of updating the global config.
786func GlobalConfigData() string {
787	if crushData := os.Getenv("CRUSH_GLOBAL_DATA"); crushData != "" {
788		return filepath.Join(crushData, fmt.Sprintf("%s.json", appName))
789	}
790	if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" {
791		return filepath.Join(xdgDataHome, appName, fmt.Sprintf("%s.json", appName))
792	}
793
794	// return the path to the main data directory
795	// for windows, it should be in `%LOCALAPPDATA%/crush/`
796	// for linux and macOS, it should be in `$HOME/.local/share/crush/`
797	if runtime.GOOS == "windows" {
798		localAppData := cmp.Or(
799			os.Getenv("LOCALAPPDATA"),
800			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
801		)
802		return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
803	}
804
805	return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
806}
807
808// GlobalWorkspaceDir returns the path to the global server workspace
809// directory. This directory acts as a meta-workspace for the server
810// process, giving it a real workingDir so that config loading, scoped
811// writes, and provider resolution behave identically to project
812// workspaces.
813func GlobalWorkspaceDir() string {
814	return filepath.Dir(GlobalConfigData())
815}
816
817func assignIfNil[T any](ptr **T, val T) {
818	if *ptr == nil {
819		*ptr = &val
820	}
821}
822
823func isInsideWorktree() bool {
824	bts, err := exec.CommandContext(
825		context.Background(),
826		"git", "rev-parse",
827		"--is-inside-work-tree",
828	).CombinedOutput()
829	return err == nil && strings.TrimSpace(string(bts)) == "true"
830}
831
832// GlobalSkillsDirs returns the default directories for Agent Skills.
833// Skills in these directories are auto-discovered and their files can be read
834// without permission prompts.
835func GlobalSkillsDirs() []string {
836	if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
837		return []string{crushSkills}
838	}
839
840	paths := []string{
841		filepath.Join(home.Config(), appName, "skills"),
842		filepath.Join(home.Config(), "agents", "skills"),
843	}
844
845	// On Windows, also load from app data on top of `$HOME/.config/crush`.
846	// This is here mostly for backwards compatibility.
847	if runtime.GOOS == "windows" {
848		appData := cmp.Or(
849			os.Getenv("LOCALAPPDATA"),
850			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
851		)
852		paths = append(
853			paths,
854			filepath.Join(appData, appName, "skills"),
855			filepath.Join(appData, "agents", "skills"),
856		)
857	}
858
859	return paths
860}
861
862// ProjectSkillsDir returns the default project directories for which Crush
863// will look for skills.
864func ProjectSkillsDir(workingDir string) []string {
865	return []string{
866		filepath.Join(workingDir, ".agents/skills"),
867		filepath.Join(workingDir, ".crush/skills"),
868		filepath.Join(workingDir, ".claude/skills"),
869		filepath.Join(workingDir, ".cursor/skills"),
870	}
871}
872
873func isAppleTerminal() bool { return os.Getenv("TERM_PROGRAM") == "Apple_Terminal" }