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