load.go

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