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 len(c.Options.GlobalContextPaths) == 0 {
 421		crushConfigDir := filepath.Dir(GlobalConfig())
 422		c.Options.GlobalContextPaths = []string{
 423			filepath.Join(crushConfigDir, "CRUSH.md"),
 424			filepath.Join(filepath.Dir(crushConfigDir), "AGENTS.md"),
 425		}
 426	}
 427	slices.Sort(c.Options.GlobalContextPaths)
 428	c.Options.GlobalContextPaths = slices.Compact(c.Options.GlobalContextPaths)
 429
 430	if dataDir != "" {
 431		c.Options.DataDirectory = dataDir
 432	} else if c.Options.DataDirectory == "" {
 433		if path, ok := fsext.LookupClosestBounded(workingDir, projectBoundary(workingDir), defaultDataDirectory); ok {
 434			c.Options.DataDirectory = path
 435		} else {
 436			c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory)
 437		}
 438	}
 439	c.Options.DataDirectory = filepath.Clean(filepathext.SmartJoin(workingDir, c.Options.DataDirectory))
 440	if c.Providers == nil {
 441		c.Providers = csync.NewMap[string, ProviderConfig]()
 442	}
 443	if c.Models == nil {
 444		c.Models = make(map[SelectedModelType]SelectedModel)
 445	}
 446	if c.RecentModels == nil {
 447		c.RecentModels = make(map[SelectedModelType][]SelectedModel)
 448	}
 449	if c.MCP == nil {
 450		c.MCP = make(map[string]MCPConfig)
 451	}
 452	if c.LSP == nil {
 453		c.LSP = make(map[string]LSPConfig)
 454	}
 455
 456	// Apply defaults to LSP configurations
 457	c.applyLSPDefaults()
 458
 459	// Add the default context paths if they are not already present
 460	c.Options.ContextPaths = append(slices.Clone(defaultContextPaths), c.Options.ContextPaths...)
 461
 462	slices.Sort(c.Options.ContextPaths)
 463	c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
 464
 465	// Add the default skills directories if not already present.
 466	for _, dir := range GlobalSkillsDirs() {
 467		if !slices.Contains(c.Options.SkillsPaths, dir) {
 468			c.Options.SkillsPaths = append(c.Options.SkillsPaths, dir)
 469		}
 470	}
 471
 472	// Project specific skills dirs.
 473	c.Options.SkillsPaths = append(c.Options.SkillsPaths, ProjectSkillsDir(workingDir)...)
 474
 475	if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
 476		c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
 477	}
 478
 479	if str, ok := os.LookupEnv("CRUSH_DISABLE_DEFAULT_PROVIDERS"); ok {
 480		c.Options.DisableDefaultProviders, _ = strconv.ParseBool(str)
 481	}
 482
 483	if c.Options.Attribution == nil {
 484		c.Options.Attribution = &Attribution{
 485			TrailerStyle:  TrailerStyleAssistedBy,
 486			GeneratedWith: true,
 487		}
 488	} else if c.Options.Attribution.TrailerStyle == "" {
 489		// Migrate deprecated co_authored_by or apply default
 490		if c.Options.Attribution.CoAuthoredBy != nil {
 491			if *c.Options.Attribution.CoAuthoredBy {
 492				c.Options.Attribution.TrailerStyle = TrailerStyleCoAuthoredBy
 493			} else {
 494				c.Options.Attribution.TrailerStyle = TrailerStyleNone
 495			}
 496		} else {
 497			c.Options.Attribution.TrailerStyle = TrailerStyleAssistedBy
 498		}
 499	}
 500
 501	c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs)
 502}
 503
 504// applyLSPDefaults applies default values from powernap to LSP configurations
 505func (c *Config) applyLSPDefaults() {
 506	// Get powernap's default configuration
 507	configManager := powernapConfig.NewManager()
 508	configManager.LoadDefaults()
 509
 510	// Apply defaults to each LSP configuration
 511	for name, cfg := range c.LSP {
 512		// Try to get defaults from powernap based on name or command name.
 513		base, ok := configManager.GetServer(name)
 514		if !ok {
 515			base, ok = configManager.GetServer(cfg.Command)
 516			if !ok {
 517				continue
 518			}
 519		}
 520		if cfg.Options == nil {
 521			cfg.Options = base.Settings
 522		}
 523		if cfg.InitOptions == nil {
 524			cfg.InitOptions = base.InitOptions
 525		}
 526		if len(cfg.FileTypes) == 0 {
 527			cfg.FileTypes = base.FileTypes
 528		}
 529		if len(cfg.RootMarkers) == 0 {
 530			cfg.RootMarkers = base.RootMarkers
 531		}
 532		cfg.Command = cmp.Or(cfg.Command, base.Command)
 533		if len(cfg.Args) == 0 {
 534			cfg.Args = base.Args
 535		}
 536		if len(cfg.Env) == 0 {
 537			cfg.Env = base.Environment
 538		}
 539		// Update the config in the map
 540		c.LSP[name] = cfg
 541	}
 542}
 543
 544func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
 545	if len(knownProviders) == 0 && c.Providers.Len() == 0 {
 546		err = fmt.Errorf("no providers configured, please configure at least one provider")
 547		return largeModel, smallModel, err
 548	}
 549
 550	// Use the first provider enabled based on the known providers order
 551	// if no provider found that is known use the first provider configured
 552	for _, p := range knownProviders {
 553		providerConfig, ok := c.Providers.Get(string(p.ID))
 554		if !ok || providerConfig.Disable {
 555			continue
 556		}
 557		defaultLargeModel := c.GetModel(string(p.ID), p.DefaultLargeModelID)
 558		if defaultLargeModel == nil {
 559			slog.Warn("Default large model %s not found for provider %s", p.DefaultLargeModelID, p.ID)
 560			if len(providerConfig.Models) == 0 {
 561				return largeModel, smallModel, fmt.Errorf("default large model %s not found for provider %s", p.DefaultLargeModelID, p.ID)
 562			}
 563			defaultLargeModel = &providerConfig.Models[0]
 564		}
 565		largeModel = SelectedModel{
 566			Provider:        string(p.ID),
 567			Model:           defaultLargeModel.ID,
 568			MaxTokens:       defaultLargeModel.DefaultMaxTokens,
 569			ReasoningEffort: defaultLargeModel.DefaultReasoningEffort,
 570		}
 571
 572		defaultSmallModel := c.GetModel(string(p.ID), p.DefaultSmallModelID)
 573		if defaultSmallModel == nil {
 574			slog.Warn("Default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
 575			if len(providerConfig.Models) == 0 {
 576				return largeModel, smallModel, fmt.Errorf("default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
 577			}
 578			defaultSmallModel = &providerConfig.Models[0]
 579		}
 580		smallModel = SelectedModel{
 581			Provider:        string(p.ID),
 582			Model:           defaultSmallModel.ID,
 583			MaxTokens:       defaultSmallModel.DefaultMaxTokens,
 584			ReasoningEffort: defaultSmallModel.DefaultReasoningEffort,
 585		}
 586		return largeModel, smallModel, err
 587	}
 588
 589	enabledProviders := c.EnabledProviders()
 590	slices.SortFunc(enabledProviders, func(a, b ProviderConfig) int {
 591		return strings.Compare(a.ID, b.ID)
 592	})
 593
 594	if len(enabledProviders) == 0 {
 595		err = fmt.Errorf("no providers configured, please configure at least one provider")
 596		return largeModel, smallModel, err
 597	}
 598
 599	providerConfig := enabledProviders[0]
 600	if len(providerConfig.Models) == 0 {
 601		err = fmt.Errorf("provider %s has no models configured", providerConfig.ID)
 602		return largeModel, smallModel, err
 603	}
 604	defaultLargeModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
 605	largeModel = SelectedModel{
 606		Provider:  providerConfig.ID,
 607		Model:     defaultLargeModel.ID,
 608		MaxTokens: defaultLargeModel.DefaultMaxTokens,
 609	}
 610	defaultSmallModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
 611	smallModel = SelectedModel{
 612		Provider:  providerConfig.ID,
 613		Model:     defaultSmallModel.ID,
 614		MaxTokens: defaultSmallModel.DefaultMaxTokens,
 615	}
 616	return largeModel, smallModel, err
 617}
 618
 619func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provider, persist bool) error {
 620	c := store.config
 621	defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
 622	if err != nil {
 623		return fmt.Errorf("failed to select default models: %w", err)
 624	}
 625	large, small := defaultLarge, defaultSmall
 626
 627	largeModelSelected, largeModelConfigured := c.Models[SelectedModelTypeLarge]
 628	if largeModelConfigured {
 629		if largeModelSelected.Model != "" {
 630			large.Model = largeModelSelected.Model
 631		}
 632		if largeModelSelected.Provider != "" {
 633			large.Provider = largeModelSelected.Provider
 634		}
 635		model := c.GetModel(large.Provider, large.Model)
 636		if model == nil {
 637			large = defaultLarge
 638			if persist {
 639				if err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, large); err != nil {
 640					return fmt.Errorf("failed to update preferred large model: %w", err)
 641				}
 642			}
 643		} else {
 644			if largeModelSelected.MaxTokens > 0 {
 645				large.MaxTokens = largeModelSelected.MaxTokens
 646			} else {
 647				large.MaxTokens = model.DefaultMaxTokens
 648			}
 649			if largeModelSelected.ReasoningEffort != "" {
 650				large.ReasoningEffort = largeModelSelected.ReasoningEffort
 651			}
 652			large.Think = largeModelSelected.Think
 653			if largeModelSelected.Temperature != nil {
 654				large.Temperature = largeModelSelected.Temperature
 655			}
 656			if largeModelSelected.TopP != nil {
 657				large.TopP = largeModelSelected.TopP
 658			}
 659			if largeModelSelected.TopK != nil {
 660				large.TopK = largeModelSelected.TopK
 661			}
 662			if largeModelSelected.FrequencyPenalty != nil {
 663				large.FrequencyPenalty = largeModelSelected.FrequencyPenalty
 664			}
 665			if largeModelSelected.PresencePenalty != nil {
 666				large.PresencePenalty = largeModelSelected.PresencePenalty
 667			}
 668		}
 669	}
 670	smallModelSelected, smallModelConfigured := c.Models[SelectedModelTypeSmall]
 671	if smallModelConfigured {
 672		if smallModelSelected.Model != "" {
 673			small.Model = smallModelSelected.Model
 674		}
 675		if smallModelSelected.Provider != "" {
 676			small.Provider = smallModelSelected.Provider
 677		}
 678
 679		model := c.GetModel(small.Provider, small.Model)
 680		if model == nil {
 681			small = defaultSmall
 682			if persist {
 683				if err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, small); err != nil {
 684					return fmt.Errorf("failed to update preferred small model: %w", err)
 685				}
 686			}
 687		} else {
 688			if smallModelSelected.MaxTokens > 0 {
 689				small.MaxTokens = smallModelSelected.MaxTokens
 690			} else {
 691				small.MaxTokens = model.DefaultMaxTokens
 692			}
 693			if smallModelSelected.ReasoningEffort != "" {
 694				small.ReasoningEffort = smallModelSelected.ReasoningEffort
 695			}
 696			if smallModelSelected.Temperature != nil {
 697				small.Temperature = smallModelSelected.Temperature
 698			}
 699			if smallModelSelected.TopP != nil {
 700				small.TopP = smallModelSelected.TopP
 701			}
 702			if smallModelSelected.TopK != nil {
 703				small.TopK = smallModelSelected.TopK
 704			}
 705			if smallModelSelected.FrequencyPenalty != nil {
 706				small.FrequencyPenalty = smallModelSelected.FrequencyPenalty
 707			}
 708			if smallModelSelected.PresencePenalty != nil {
 709				small.PresencePenalty = smallModelSelected.PresencePenalty
 710			}
 711			small.Think = smallModelSelected.Think
 712		}
 713	}
 714
 715	// When small isn't explicitly configured and the provider isn't a
 716	// known built-in, use the large model as the small model. This
 717	// prevents two different models from being requested concurrently
 718	// for local/openai-compat providers.
 719	if !smallModelConfigured {
 720		isKnownProvider := false
 721		for _, kp := range knownProviders {
 722			if string(kp.ID) == small.Provider {
 723				isKnownProvider = true
 724				break
 725			}
 726		}
 727		if !isKnownProvider {
 728			slog.Warn("Using large model as small model for unknown provider", "provider", large.Provider, "model", large.Model)
 729			small = large
 730		}
 731	}
 732
 733	c.Models[SelectedModelTypeLarge] = large
 734	c.Models[SelectedModelTypeSmall] = small
 735	return nil
 736}
 737
 738// lookupConfigs searches config files starting at cwd and walking up
 739// through the current project. The upward walk stops at the git
 740// working tree root when one can be detected, otherwise at cwd itself,
 741// so an unrelated crush.json placed above the project is never picked
 742// up. Global user-level config locations are always included
 743// regardless of the boundary.
 744func lookupConfigs(cwd string) []string {
 745	// prepend default config paths
 746	configPaths := []string{
 747		GlobalConfig(),
 748		GlobalConfigData(),
 749	}
 750
 751	configNames := []string{appName + ".json", "." + appName + ".json"}
 752
 753	foundConfigs, err := fsext.LookupBounded(cwd, projectBoundary(cwd), configNames...)
 754	if err != nil {
 755		// returns at least default configs
 756		return configPaths
 757	}
 758
 759	// reverse order so last config has more priority
 760	slices.Reverse(foundConfigs)
 761
 762	return append(configPaths, foundConfigs...)
 763}
 764
 765func loadFromConfigPaths(configPaths []string) (*Config, []string, error) {
 766	var configs [][]byte
 767	var loaded []string
 768
 769	for _, path := range configPaths {
 770		data, err := os.ReadFile(path)
 771		if err != nil {
 772			if os.IsNotExist(err) {
 773				continue
 774			}
 775			return nil, nil, fmt.Errorf("failed to open config file %s: %w", path, err)
 776		}
 777		if len(data) == 0 {
 778			continue
 779		}
 780		if !json.Valid(data) {
 781			return nil, nil, fmt.Errorf("invalid JSON in config file %s", path)
 782		}
 783		configs = append(configs, data)
 784		loaded = append(loaded, path)
 785	}
 786
 787	cfg, err := loadFromBytes(configs)
 788	if err != nil {
 789		return nil, nil, err
 790	}
 791	return cfg, loaded, nil
 792}
 793
 794func loadFromBytes(configs [][]byte) (*Config, error) {
 795	if len(configs) == 0 {
 796		return &Config{}, nil
 797	}
 798
 799	data, err := jsons.Merge(configs)
 800	if err != nil {
 801		return nil, err
 802	}
 803	var config Config
 804	if err := json.Unmarshal(data, &config); err != nil {
 805		return nil, err
 806	}
 807	return &config, nil
 808}
 809
 810func hasAWSCredentials(env env.Env) bool {
 811	if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" {
 812		return true
 813	}
 814
 815	if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" {
 816		return true
 817	}
 818
 819	if env.Get("AWS_PROFILE") != "" || env.Get("AWS_DEFAULT_PROFILE") != "" {
 820		return true
 821	}
 822
 823	if env.Get("AWS_REGION") != "" || env.Get("AWS_DEFAULT_REGION") != "" {
 824		return true
 825	}
 826
 827	if env.Get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
 828		env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
 829		return true
 830	}
 831
 832	if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil && !testing.Testing() {
 833		return true
 834	}
 835	if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/login")); err == nil && !testing.Testing() {
 836		return true
 837	}
 838
 839	return false
 840}
 841
 842// migrateDisableNotifications migrates the deprecated disable_notifications
 843// field to notification_style. It checks both the user config (~/.config) and
 844// data config (~/.local) files. If disable_notifications is true, it sets
 845// notification_style to "disabled" in the data file. Regardless of value, it
 846// removes disable_notifications from any file that contains it.
 847func migrateDisableNotifications() {
 848	globalConfig := GlobalConfig()
 849	dataConfig := GlobalConfigData()
 850
 851	var wasDisabled bool
 852	filesToClean := []string{}
 853
 854	for _, path := range []string{globalConfig, dataConfig} {
 855		data, err := os.ReadFile(path)
 856		if err != nil {
 857			continue
 858		}
 859		if gjson.Get(string(data), "options.disable_notifications").Exists() {
 860			filesToClean = append(filesToClean, path)
 861			if gjson.Get(string(data), "options.disable_notifications").Bool() {
 862				wasDisabled = true
 863			}
 864		}
 865	}
 866
 867	if len(filesToClean) == 0 {
 868		return
 869	}
 870
 871	// If notifications were disabled, persist the equivalent notification_style.
 872	if wasDisabled {
 873		data, err := os.ReadFile(dataConfig)
 874		if err == nil {
 875			if !gjson.Get(string(data), "options.notification_style").Exists() {
 876				updated, err := sjson.Set(string(data), "options.notification_style", "disabled")
 877				if err == nil {
 878					if err := atomicWriteFile(dataConfig, []byte(updated), 0o600); err != nil {
 879						slog.Warn("Failed to migrate disable_notifications to notification_style", "error", err)
 880					} else {
 881						slog.Info("Migrated disable_notifications: true to notification_style: disabled")
 882					}
 883				}
 884			}
 885		}
 886	}
 887
 888	// Remove disable_notifications from all files that contain it.
 889	for _, path := range filesToClean {
 890		data, err := os.ReadFile(path)
 891		if err != nil {
 892			continue
 893		}
 894		updated, err := sjson.Delete(string(data), "options.disable_notifications")
 895		if err != nil {
 896			slog.Warn("Failed to remove deprecated disable_notifications field", "path", path, "error", err)
 897			continue
 898		}
 899		if err := atomicWriteFile(path, []byte(updated), 0o600); err != nil {
 900			slog.Warn("Failed to write migrated config", "path", path, "error", err)
 901		}
 902	}
 903}
 904
 905// GlobalConfig returns the global configuration file path for the application.
 906func GlobalConfig() string {
 907	if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {
 908		return filepath.Join(crushGlobal, fmt.Sprintf("%s.json", appName))
 909	}
 910	return filepath.Join(home.Config(), appName, fmt.Sprintf("%s.json", appName))
 911}
 912
 913// GlobalCacheDir returns the path to the global cache directory for the
 914// application.
 915func GlobalCacheDir() string {
 916	if crushCache := os.Getenv("CRUSH_CACHE_DIR"); crushCache != "" {
 917		return crushCache
 918	}
 919	if xdgCacheHome := os.Getenv("XDG_CACHE_HOME"); xdgCacheHome != "" {
 920		return filepath.Join(xdgCacheHome, appName)
 921	}
 922	if runtime.GOOS == "windows" {
 923		localAppData := cmp.Or(
 924			os.Getenv("LOCALAPPDATA"),
 925			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
 926		)
 927		return filepath.Join(localAppData, appName, "cache")
 928	}
 929	return filepath.Join(home.Dir(), ".cache", appName)
 930}
 931
 932// ProjectConfigs returns list of current project configs paths.
 933func ProjectConfigs(cwd string) []string {
 934	return lookupConfigs(cwd)
 935}
 936
 937// GlobalConfigData returns the path to the main data directory for the application.
 938// this config is used when the app overrides configurations instead of updating the global config.
 939func GlobalConfigData() string {
 940	if crushData := os.Getenv("CRUSH_GLOBAL_DATA"); crushData != "" {
 941		return filepath.Join(crushData, fmt.Sprintf("%s.json", appName))
 942	}
 943	if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" {
 944		return filepath.Join(xdgDataHome, appName, fmt.Sprintf("%s.json", appName))
 945	}
 946
 947	// return the path to the main data directory
 948	// for windows, it should be in `%LOCALAPPDATA%/crush/`
 949	// for linux and macOS, it should be in `$HOME/.local/share/crush/`
 950	if runtime.GOOS == "windows" {
 951		localAppData := cmp.Or(
 952			os.Getenv("LOCALAPPDATA"),
 953			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
 954		)
 955		return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
 956	}
 957
 958	return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
 959}
 960
 961// GlobalWorkspaceDir returns the path to the global server workspace
 962// directory. This directory acts as a meta-workspace for the server
 963// process, giving it a real workingDir so that config loading, scoped
 964// writes, and provider resolution behave identically to project
 965// workspaces.
 966func GlobalWorkspaceDir() string {
 967	return filepath.Dir(GlobalConfigData())
 968}
 969
 970func assignIfNil[T any](ptr **T, val T) {
 971	if *ptr == nil {
 972		*ptr = &val
 973	}
 974}
 975
 976func isInsideWorktree() bool {
 977	bts, err := exec.CommandContext(
 978		context.Background(),
 979		"git", "rev-parse",
 980		"--is-inside-work-tree",
 981	).CombinedOutput()
 982	return err == nil && strings.TrimSpace(string(bts)) == "true"
 983}
 984
 985// worktreeRoot returns the absolute path of the git working tree root for
 986// dir, or the empty string if dir is not inside a working tree (bare
 987// repositories, missing git binary, plain directories, or any other
 988// failure mode). Linked worktrees and submodules each report their own
 989// top-level, which is what callers want when bounding lookups.
 990func worktreeRoot(dir string) string {
 991	cmd := exec.CommandContext(
 992		context.Background(),
 993		"git", "rev-parse", "--show-toplevel",
 994	)
 995	cmd.Dir = dir
 996	out, err := cmd.Output()
 997	if err != nil {
 998		return ""
 999	}
1000	root := strings.TrimSpace(string(out))
1001	if root == "" {
1002		return ""
1003	}
1004	abs, err := filepath.Abs(root)
1005	if err != nil {
1006		return ""
1007	}
1008	return abs
1009}
1010
1011// projectBoundary returns the directory at which an upward configuration
1012// search rooted at dir should stop. It is the git working tree root when
1013// one can be detected, otherwise dir itself. Returning dir as a
1014// fallback keeps Crush from silently adopting state files placed above
1015// the current project.
1016func projectBoundary(dir string) string {
1017	if root := worktreeRoot(dir); root != "" {
1018		return root
1019	}
1020	abs, err := filepath.Abs(dir)
1021	if err != nil {
1022		return dir
1023	}
1024	return abs
1025}
1026
1027// GlobalSkillsDirs returns the default directories for Agent Skills.
1028// Skills in these directories are auto-discovered and their files can be read
1029// without permission prompts.
1030func GlobalSkillsDirs() []string {
1031	if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
1032		return []string{crushSkills}
1033	}
1034
1035	paths := []string{
1036		filepath.Join(home.Config(), appName, "skills"),
1037		filepath.Join(home.Config(), "agents", "skills"),
1038		// Per the Agent Skills spec, scan ~/.agents/skills
1039		filepath.Join(home.Dir(), ".agents", "skills"),
1040		filepath.Join(home.Dir(), ".claude", "skills"),
1041	}
1042
1043	// On Windows, also load from app data on top of `$HOME/.config/crush`.
1044	// This is here mostly for backwards compatibility.
1045	if runtime.GOOS == "windows" {
1046		appData := cmp.Or(
1047			os.Getenv("LOCALAPPDATA"),
1048			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
1049		)
1050		paths = append(
1051			paths,
1052			filepath.Join(appData, appName, "skills"),
1053			filepath.Join(appData, "agents", "skills"),
1054		)
1055	}
1056
1057	return paths
1058}
1059
1060// projectSkillSubdirs lists the conventional subdirectories where
1061// project-level skills are discovered. Shared across working-dir and
1062// git-root lookups to prevent drift when a new convention is added.
1063var projectSkillSubdirs = []string{
1064	".agents/skills",
1065	".crush/skills",
1066	".claude/skills",
1067	".cursor/skills",
1068}
1069
1070// ProjectSkillsDir returns the default project directories for which Crush
1071// will look for skills. In addition to the working directory, it also
1072// checks the git working tree root so that monorepo-level skills are
1073// discovered when the user is inside a subdirectory.
1074// Working-directory paths come first so local skills take precedence
1075// over monorepo-level ones.
1076func ProjectSkillsDir(workingDir string) []string {
1077	dirs := make([]string, 0, len(projectSkillSubdirs)*2)
1078	for _, sub := range projectSkillSubdirs {
1079		dirs = append(dirs, filepath.Join(workingDir, sub))
1080	}
1081
1082	// When the working directory is inside a git repository, also look at
1083	// the repository root so monorepo-level .agents/skills are found.
1084	if root := worktreeRoot(workingDir); root != "" && root != workingDir {
1085		for _, sub := range projectSkillSubdirs {
1086			dirs = append(dirs, filepath.Join(root, sub))
1087		}
1088	}
1089
1090	return dirs
1091}
1092
1093func isAppleTerminal() bool { return os.Getenv("TERM_PROGRAM") == "Apple_Terminal" }
1094
1095// normalizeHookEvent maps user-provided event names to their canonical
1096// form. Matching is case-insensitive and accepts snake_case variants
1097// (e.g. "pre_tool_use" → "PreToolUse").
1098func normalizeHookEvent(name string) string {
1099	switch strings.ToLower(strings.ReplaceAll(name, "_", "")) {
1100	case "pretooluse":
1101		return "PreToolUse"
1102	default:
1103		return name
1104	}
1105}
1106
1107// ValidateHooks normalizes event names and checks that every configured
1108// hook has a command and a syntactically valid matcher regex. Matcher
1109// compilation used for matching is owned by hooks.Runner; this function
1110// only validates up front so the user sees config errors at load time
1111// rather than on the first tool call.
1112func (c *Config) ValidateHooks() error {
1113	// Normalize event name keys.
1114	for event, eventHooks := range c.Hooks {
1115		canonical := normalizeHookEvent(event)
1116		if canonical != event {
1117			c.Hooks[canonical] = append(c.Hooks[canonical], eventHooks...)
1118			delete(c.Hooks, event)
1119		}
1120	}
1121
1122	for event, eventHooks := range c.Hooks {
1123		for i, h := range eventHooks {
1124			if h.Command == "" {
1125				return fmt.Errorf("hook %s[%d]: command is required", event, i)
1126			}
1127			if h.Matcher == "" {
1128				continue
1129			}
1130			if _, err := regexp.Compile(h.Matcher); err != nil {
1131				return fmt.Errorf("hook %s[%d]: invalid matcher regex %q: %w", event, i, h.Matcher, err)
1132			}
1133		}
1134	}
1135	return nil
1136}