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