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