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			err = fmt.Errorf("default large model %s not found for provider %s", p.DefaultLargeModelID, p.ID)
 549			return largeModel, smallModel, err
 550		}
 551		largeModel = SelectedModel{
 552			Provider:        string(p.ID),
 553			Model:           defaultLargeModel.ID,
 554			MaxTokens:       defaultLargeModel.DefaultMaxTokens,
 555			ReasoningEffort: defaultLargeModel.DefaultReasoningEffort,
 556		}
 557
 558		defaultSmallModel := c.GetModel(string(p.ID), p.DefaultSmallModelID)
 559		if defaultSmallModel == nil {
 560			err = fmt.Errorf("default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
 561			return largeModel, smallModel, err
 562		}
 563		smallModel = SelectedModel{
 564			Provider:        string(p.ID),
 565			Model:           defaultSmallModel.ID,
 566			MaxTokens:       defaultSmallModel.DefaultMaxTokens,
 567			ReasoningEffort: defaultSmallModel.DefaultReasoningEffort,
 568		}
 569		return largeModel, smallModel, err
 570	}
 571
 572	enabledProviders := c.EnabledProviders()
 573	slices.SortFunc(enabledProviders, func(a, b ProviderConfig) int {
 574		return strings.Compare(a.ID, b.ID)
 575	})
 576
 577	if len(enabledProviders) == 0 {
 578		err = fmt.Errorf("no providers configured, please configure at least one provider")
 579		return largeModel, smallModel, err
 580	}
 581
 582	providerConfig := enabledProviders[0]
 583	if len(providerConfig.Models) == 0 {
 584		err = fmt.Errorf("provider %s has no models configured", providerConfig.ID)
 585		return largeModel, smallModel, err
 586	}
 587	defaultLargeModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
 588	largeModel = SelectedModel{
 589		Provider:  providerConfig.ID,
 590		Model:     defaultLargeModel.ID,
 591		MaxTokens: defaultLargeModel.DefaultMaxTokens,
 592	}
 593	defaultSmallModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
 594	smallModel = SelectedModel{
 595		Provider:  providerConfig.ID,
 596		Model:     defaultSmallModel.ID,
 597		MaxTokens: defaultSmallModel.DefaultMaxTokens,
 598	}
 599	return largeModel, smallModel, err
 600}
 601
 602func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provider, persist bool) error {
 603	c := store.config
 604	defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
 605	if err != nil {
 606		return fmt.Errorf("failed to select default models: %w", err)
 607	}
 608	large, small := defaultLarge, defaultSmall
 609
 610	largeModelSelected, largeModelConfigured := c.Models[SelectedModelTypeLarge]
 611	if largeModelConfigured {
 612		if largeModelSelected.Model != "" {
 613			large.Model = largeModelSelected.Model
 614		}
 615		if largeModelSelected.Provider != "" {
 616			large.Provider = largeModelSelected.Provider
 617		}
 618		model := c.GetModel(large.Provider, large.Model)
 619		if model == nil {
 620			large = defaultLarge
 621			if persist {
 622				if err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, large); err != nil {
 623					return fmt.Errorf("failed to update preferred large model: %w", err)
 624				}
 625			}
 626		} else {
 627			if largeModelSelected.MaxTokens > 0 {
 628				large.MaxTokens = largeModelSelected.MaxTokens
 629			} else {
 630				large.MaxTokens = model.DefaultMaxTokens
 631			}
 632			if largeModelSelected.ReasoningEffort != "" {
 633				large.ReasoningEffort = largeModelSelected.ReasoningEffort
 634			}
 635			large.Think = largeModelSelected.Think
 636			if largeModelSelected.Temperature != nil {
 637				large.Temperature = largeModelSelected.Temperature
 638			}
 639			if largeModelSelected.TopP != nil {
 640				large.TopP = largeModelSelected.TopP
 641			}
 642			if largeModelSelected.TopK != nil {
 643				large.TopK = largeModelSelected.TopK
 644			}
 645			if largeModelSelected.FrequencyPenalty != nil {
 646				large.FrequencyPenalty = largeModelSelected.FrequencyPenalty
 647			}
 648			if largeModelSelected.PresencePenalty != nil {
 649				large.PresencePenalty = largeModelSelected.PresencePenalty
 650			}
 651		}
 652	}
 653	smallModelSelected, smallModelConfigured := c.Models[SelectedModelTypeSmall]
 654	if smallModelConfigured {
 655		if smallModelSelected.Model != "" {
 656			small.Model = smallModelSelected.Model
 657		}
 658		if smallModelSelected.Provider != "" {
 659			small.Provider = smallModelSelected.Provider
 660		}
 661
 662		model := c.GetModel(small.Provider, small.Model)
 663		if model == nil {
 664			small = defaultSmall
 665			if persist {
 666				if err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, small); err != nil {
 667					return fmt.Errorf("failed to update preferred small model: %w", err)
 668				}
 669			}
 670		} else {
 671			if smallModelSelected.MaxTokens > 0 {
 672				small.MaxTokens = smallModelSelected.MaxTokens
 673			} else {
 674				small.MaxTokens = model.DefaultMaxTokens
 675			}
 676			if smallModelSelected.ReasoningEffort != "" {
 677				small.ReasoningEffort = smallModelSelected.ReasoningEffort
 678			}
 679			if smallModelSelected.Temperature != nil {
 680				small.Temperature = smallModelSelected.Temperature
 681			}
 682			if smallModelSelected.TopP != nil {
 683				small.TopP = smallModelSelected.TopP
 684			}
 685			if smallModelSelected.TopK != nil {
 686				small.TopK = smallModelSelected.TopK
 687			}
 688			if smallModelSelected.FrequencyPenalty != nil {
 689				small.FrequencyPenalty = smallModelSelected.FrequencyPenalty
 690			}
 691			if smallModelSelected.PresencePenalty != nil {
 692				small.PresencePenalty = smallModelSelected.PresencePenalty
 693			}
 694			small.Think = smallModelSelected.Think
 695		}
 696	}
 697
 698	// When small isn't explicitly configured and the provider isn't a
 699	// known built-in, use the large model as the small model. This
 700	// prevents two different models from being requested concurrently
 701	// for local/openai-compat providers.
 702	if !smallModelConfigured {
 703		isKnownProvider := false
 704		for _, kp := range knownProviders {
 705			if string(kp.ID) == small.Provider {
 706				isKnownProvider = true
 707				break
 708			}
 709		}
 710		if !isKnownProvider {
 711			slog.Warn("Using large model as small model for unknown provider", "provider", large.Provider, "model", large.Model)
 712			small = large
 713		}
 714	}
 715
 716	c.Models[SelectedModelTypeLarge] = large
 717	c.Models[SelectedModelTypeSmall] = small
 718	return nil
 719}
 720
 721// lookupConfigs searches config files starting at cwd and walking up
 722// through the current project. The upward walk stops at the git
 723// working tree root when one can be detected, otherwise at cwd itself,
 724// so an unrelated crush.json placed above the project is never picked
 725// up. Global user-level config locations are always included
 726// regardless of the boundary.
 727func lookupConfigs(cwd string) []string {
 728	// prepend default config paths
 729	configPaths := []string{
 730		GlobalConfig(),
 731		GlobalConfigData(),
 732	}
 733
 734	configNames := []string{appName + ".json", "." + appName + ".json"}
 735
 736	foundConfigs, err := fsext.LookupBounded(cwd, projectBoundary(cwd), configNames...)
 737	if err != nil {
 738		// returns at least default configs
 739		return configPaths
 740	}
 741
 742	// reverse order so last config has more priority
 743	slices.Reverse(foundConfigs)
 744
 745	return append(configPaths, foundConfigs...)
 746}
 747
 748func loadFromConfigPaths(configPaths []string) (*Config, []string, error) {
 749	var configs [][]byte
 750	var loaded []string
 751
 752	for _, path := range configPaths {
 753		data, err := os.ReadFile(path)
 754		if err != nil {
 755			if os.IsNotExist(err) {
 756				continue
 757			}
 758			return nil, nil, fmt.Errorf("failed to open config file %s: %w", path, err)
 759		}
 760		if len(data) == 0 {
 761			continue
 762		}
 763		if !json.Valid(data) {
 764			return nil, nil, fmt.Errorf("invalid JSON in config file %s", path)
 765		}
 766		configs = append(configs, data)
 767		loaded = append(loaded, path)
 768	}
 769
 770	cfg, err := loadFromBytes(configs)
 771	if err != nil {
 772		return nil, nil, err
 773	}
 774	return cfg, loaded, nil
 775}
 776
 777func loadFromBytes(configs [][]byte) (*Config, error) {
 778	if len(configs) == 0 {
 779		return &Config{}, nil
 780	}
 781
 782	data, err := jsons.Merge(configs)
 783	if err != nil {
 784		return nil, err
 785	}
 786	var config Config
 787	if err := json.Unmarshal(data, &config); err != nil {
 788		return nil, err
 789	}
 790	return &config, nil
 791}
 792
 793func hasAWSCredentials(env env.Env) bool {
 794	if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" {
 795		return true
 796	}
 797
 798	if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" {
 799		return true
 800	}
 801
 802	if env.Get("AWS_PROFILE") != "" || env.Get("AWS_DEFAULT_PROFILE") != "" {
 803		return true
 804	}
 805
 806	if env.Get("AWS_REGION") != "" || env.Get("AWS_DEFAULT_REGION") != "" {
 807		return true
 808	}
 809
 810	if env.Get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
 811		env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
 812		return true
 813	}
 814
 815	if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil && !testing.Testing() {
 816		return true
 817	}
 818	if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/login")); err == nil && !testing.Testing() {
 819		return true
 820	}
 821
 822	return false
 823}
 824
 825// migrateDisableNotifications migrates the deprecated disable_notifications
 826// field to notification_style. It checks both the user config (~/.config) and
 827// data config (~/.local) files. If disable_notifications is true, it sets
 828// notification_style to "disabled" in the data file. Regardless of value, it
 829// removes disable_notifications from any file that contains it.
 830func migrateDisableNotifications() {
 831	globalConfig := GlobalConfig()
 832	dataConfig := GlobalConfigData()
 833
 834	var wasDisabled bool
 835	filesToClean := []string{}
 836
 837	for _, path := range []string{globalConfig, dataConfig} {
 838		data, err := os.ReadFile(path)
 839		if err != nil {
 840			continue
 841		}
 842		if gjson.Get(string(data), "options.disable_notifications").Exists() {
 843			filesToClean = append(filesToClean, path)
 844			if gjson.Get(string(data), "options.disable_notifications").Bool() {
 845				wasDisabled = true
 846			}
 847		}
 848	}
 849
 850	if len(filesToClean) == 0 {
 851		return
 852	}
 853
 854	// If notifications were disabled, persist the equivalent notification_style.
 855	if wasDisabled {
 856		data, err := os.ReadFile(dataConfig)
 857		if err == nil {
 858			if !gjson.Get(string(data), "options.notification_style").Exists() {
 859				updated, err := sjson.Set(string(data), "options.notification_style", "disabled")
 860				if err == nil {
 861					if err := atomicWriteFile(dataConfig, []byte(updated), 0o600); err != nil {
 862						slog.Warn("Failed to migrate disable_notifications to notification_style", "error", err)
 863					} else {
 864						slog.Info("Migrated disable_notifications: true to notification_style: disabled")
 865					}
 866				}
 867			}
 868		}
 869	}
 870
 871	// Remove disable_notifications from all files that contain it.
 872	for _, path := range filesToClean {
 873		data, err := os.ReadFile(path)
 874		if err != nil {
 875			continue
 876		}
 877		updated, err := sjson.Delete(string(data), "options.disable_notifications")
 878		if err != nil {
 879			slog.Warn("Failed to remove deprecated disable_notifications field", "path", path, "error", err)
 880			continue
 881		}
 882		if err := atomicWriteFile(path, []byte(updated), 0o600); err != nil {
 883			slog.Warn("Failed to write migrated config", "path", path, "error", err)
 884		}
 885	}
 886}
 887
 888// GlobalConfig returns the global configuration file path for the application.
 889func GlobalConfig() string {
 890	if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {
 891		return filepath.Join(crushGlobal, fmt.Sprintf("%s.json", appName))
 892	}
 893	return filepath.Join(home.Config(), appName, fmt.Sprintf("%s.json", appName))
 894}
 895
 896// GlobalCacheDir returns the path to the global cache directory for the
 897// application.
 898func GlobalCacheDir() string {
 899	if crushCache := os.Getenv("CRUSH_CACHE_DIR"); crushCache != "" {
 900		return crushCache
 901	}
 902	if xdgCacheHome := os.Getenv("XDG_CACHE_HOME"); xdgCacheHome != "" {
 903		return filepath.Join(xdgCacheHome, appName)
 904	}
 905	if runtime.GOOS == "windows" {
 906		localAppData := cmp.Or(
 907			os.Getenv("LOCALAPPDATA"),
 908			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
 909		)
 910		return filepath.Join(localAppData, appName, "cache")
 911	}
 912	return filepath.Join(home.Dir(), ".cache", appName)
 913}
 914
 915// ProjectConfigs returns list of current project configs paths.
 916func ProjectConfigs(cwd string) []string {
 917	return lookupConfigs(cwd)
 918}
 919
 920// GlobalConfigData returns the path to the main data directory for the application.
 921// this config is used when the app overrides configurations instead of updating the global config.
 922func GlobalConfigData() string {
 923	if crushData := os.Getenv("CRUSH_GLOBAL_DATA"); crushData != "" {
 924		return filepath.Join(crushData, fmt.Sprintf("%s.json", appName))
 925	}
 926	if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" {
 927		return filepath.Join(xdgDataHome, appName, fmt.Sprintf("%s.json", appName))
 928	}
 929
 930	// return the path to the main data directory
 931	// for windows, it should be in `%LOCALAPPDATA%/crush/`
 932	// for linux and macOS, it should be in `$HOME/.local/share/crush/`
 933	if runtime.GOOS == "windows" {
 934		localAppData := cmp.Or(
 935			os.Getenv("LOCALAPPDATA"),
 936			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
 937		)
 938		return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
 939	}
 940
 941	return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
 942}
 943
 944// GlobalWorkspaceDir returns the path to the global server workspace
 945// directory. This directory acts as a meta-workspace for the server
 946// process, giving it a real workingDir so that config loading, scoped
 947// writes, and provider resolution behave identically to project
 948// workspaces.
 949func GlobalWorkspaceDir() string {
 950	return filepath.Dir(GlobalConfigData())
 951}
 952
 953func assignIfNil[T any](ptr **T, val T) {
 954	if *ptr == nil {
 955		*ptr = &val
 956	}
 957}
 958
 959func isInsideWorktree() bool {
 960	bts, err := exec.CommandContext(
 961		context.Background(),
 962		"git", "rev-parse",
 963		"--is-inside-work-tree",
 964	).CombinedOutput()
 965	return err == nil && strings.TrimSpace(string(bts)) == "true"
 966}
 967
 968// worktreeRoot returns the absolute path of the git working tree root for
 969// dir, or the empty string if dir is not inside a working tree (bare
 970// repositories, missing git binary, plain directories, or any other
 971// failure mode). Linked worktrees and submodules each report their own
 972// top-level, which is what callers want when bounding lookups.
 973func worktreeRoot(dir string) string {
 974	cmd := exec.CommandContext(
 975		context.Background(),
 976		"git", "rev-parse", "--show-toplevel",
 977	)
 978	cmd.Dir = dir
 979	out, err := cmd.Output()
 980	if err != nil {
 981		return ""
 982	}
 983	root := strings.TrimSpace(string(out))
 984	if root == "" {
 985		return ""
 986	}
 987	abs, err := filepath.Abs(root)
 988	if err != nil {
 989		return ""
 990	}
 991	return abs
 992}
 993
 994// projectBoundary returns the directory at which an upward configuration
 995// search rooted at dir should stop. It is the git working tree root when
 996// one can be detected, otherwise dir itself. Returning dir as a
 997// fallback keeps Crush from silently adopting state files placed above
 998// the current project.
 999func projectBoundary(dir string) string {
1000	if root := worktreeRoot(dir); root != "" {
1001		return root
1002	}
1003	abs, err := filepath.Abs(dir)
1004	if err != nil {
1005		return dir
1006	}
1007	return abs
1008}
1009
1010// GlobalSkillsDirs returns the default directories for Agent Skills.
1011// Skills in these directories are auto-discovered and their files can be read
1012// without permission prompts.
1013func GlobalSkillsDirs() []string {
1014	if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
1015		return []string{crushSkills}
1016	}
1017
1018	paths := []string{
1019		filepath.Join(home.Config(), appName, "skills"),
1020		filepath.Join(home.Config(), "agents", "skills"),
1021		// Per the Agent Skills spec, scan ~/.agents/skills
1022		filepath.Join(home.Dir(), ".agents", "skills"),
1023		filepath.Join(home.Dir(), ".claude", "skills"),
1024	}
1025
1026	// On Windows, also load from app data on top of `$HOME/.config/crush`.
1027	// This is here mostly for backwards compatibility.
1028	if runtime.GOOS == "windows" {
1029		appData := cmp.Or(
1030			os.Getenv("LOCALAPPDATA"),
1031			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
1032		)
1033		paths = append(
1034			paths,
1035			filepath.Join(appData, appName, "skills"),
1036			filepath.Join(appData, "agents", "skills"),
1037		)
1038	}
1039
1040	return paths
1041}
1042
1043// ProjectSkillsDir returns the default project directories for which Crush
1044// will look for skills.
1045func ProjectSkillsDir(workingDir string) []string {
1046	return []string{
1047		filepath.Join(workingDir, ".agents/skills"),
1048		filepath.Join(workingDir, ".crush/skills"),
1049		filepath.Join(workingDir, ".claude/skills"),
1050		filepath.Join(workingDir, ".cursor/skills"),
1051	}
1052}
1053
1054func isAppleTerminal() bool { return os.Getenv("TERM_PROGRAM") == "Apple_Terminal" }
1055
1056// normalizeHookEvent maps user-provided event names to their canonical
1057// form. Matching is case-insensitive and accepts snake_case variants
1058// (e.g. "pre_tool_use" → "PreToolUse").
1059func normalizeHookEvent(name string) string {
1060	switch strings.ToLower(strings.ReplaceAll(name, "_", "")) {
1061	case "pretooluse":
1062		return "PreToolUse"
1063	default:
1064		return name
1065	}
1066}
1067
1068// ValidateHooks normalizes event names and checks that every configured
1069// hook has a command and a syntactically valid matcher regex. Matcher
1070// compilation used for matching is owned by hooks.Runner; this function
1071// only validates up front so the user sees config errors at load time
1072// rather than on the first tool call.
1073func (c *Config) ValidateHooks() error {
1074	// Normalize event name keys.
1075	for event, eventHooks := range c.Hooks {
1076		canonical := normalizeHookEvent(event)
1077		if canonical != event {
1078			c.Hooks[canonical] = append(c.Hooks[canonical], eventHooks...)
1079			delete(c.Hooks, event)
1080		}
1081	}
1082
1083	for event, eventHooks := range c.Hooks {
1084		for i, h := range eventHooks {
1085			if h.Command == "" {
1086				return fmt.Errorf("hook %s[%d]: command is required", event, i)
1087			}
1088			if h.Matcher == "" {
1089				continue
1090			}
1091			if _, err := regexp.Compile(h.Matcher); err != nil {
1092				return fmt.Errorf("hook %s[%d]: invalid matcher regex %q: %w", event, i, h.Matcher, err)
1093			}
1094		}
1095	}
1096	return nil
1097}