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