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