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	// Disable auto-reload during initial load to prevent nested calls from
 112	// config-modifying operations inside configureProviders.
 113	store.autoReloadDisabled = true
 114	defer func() { store.autoReloadDisabled = false }()
 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			if !store.reloadInProgress {
 270				store.RemoveConfigField(ScopeGlobal, "providers.anthropic")
 271			}
 272			c.Providers.Del(string(p.ID))
 273			continue
 274		case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
 275			prepared.SetupGitHubCopilot()
 276		}
 277
 278		switch p.ID {
 279		// Handle specific providers that require additional configuration
 280		case catwalk.InferenceProviderVertexAI:
 281			var (
 282				project  = env.Get("VERTEXAI_PROJECT")
 283				location = env.Get("VERTEXAI_LOCATION")
 284			)
 285			if project == "" || location == "" {
 286				if configExists {
 287					slog.Warn("Skipping Vertex AI provider due to missing credentials")
 288					c.Providers.Del(string(p.ID))
 289				}
 290				continue
 291			}
 292			prepared.ExtraParams["project"] = project
 293			prepared.ExtraParams["location"] = location
 294		case catwalk.InferenceProviderAzure:
 295			endpoint, err := resolver.ResolveValue(p.APIEndpoint)
 296			if err != nil || endpoint == "" {
 297				if configExists {
 298					slog.Warn("Skipping Azure provider due to missing API endpoint", "provider", p.ID, "error", err)
 299					c.Providers.Del(string(p.ID))
 300				}
 301				continue
 302			}
 303			prepared.BaseURL = endpoint
 304			prepared.ExtraParams["apiVersion"] = env.Get("AZURE_OPENAI_API_VERSION")
 305		case catwalk.InferenceProviderBedrock, catwalk.InferenceProviderBedrockEurope:
 306			if p.APIKey == "" && !hasAWSCredentials(env) {
 307				if configExists {
 308					slog.Warn("Skipping Bedrock provider due to missing AWS credentials")
 309					c.Providers.Del(string(p.ID))
 310				}
 311				continue
 312			}
 313		case catwalk.InferenceProvider("hyper"):
 314			if apiKey := env.Get("HYPER_API_KEY"); apiKey != "" {
 315				prepared.APIKey = apiKey
 316				prepared.APIKeyTemplate = apiKey
 317			} else {
 318				v, err := resolver.ResolveValue(p.APIKey)
 319				if v == "" || err != nil {
 320					if configExists {
 321						slog.Warn("Skipping Hyper provider due to missing API key", "provider", p.ID)
 322						c.Providers.Del(string(p.ID))
 323					}
 324					continue
 325				}
 326			}
 327		default:
 328			// if the provider api or endpoint are missing we skip them
 329			v, err := resolver.ResolveValue(p.APIKey)
 330			if v == "" || err != nil {
 331				if configExists {
 332					slog.Warn("Skipping provider due to missing API key", "provider", p.ID)
 333					c.Providers.Del(string(p.ID))
 334				}
 335				continue
 336			}
 337		}
 338		c.Providers.Set(string(p.ID), prepared)
 339	}
 340
 341	// validate the custom providers
 342	for id, providerConfig := range c.Providers.Seq2() {
 343		if knownProviderNames[id] {
 344			continue
 345		}
 346
 347		// Make sure the provider ID is set
 348		providerConfig.ID = id
 349		providerConfig.Name = cmp.Or(providerConfig.Name, id) // Use ID as name if not set
 350		// default to OpenAI if not set
 351		providerConfig.Type = cmp.Or(providerConfig.Type, catwalk.TypeOpenAICompat)
 352		if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) && providerConfig.Type != hyper.Name {
 353			slog.Warn("Skipping custom provider due to unsupported provider type", "provider", id)
 354			c.Providers.Del(id)
 355			continue
 356		}
 357
 358		if providerConfig.Disable {
 359			slog.Debug("Skipping custom provider due to disable flag", "provider", id)
 360			c.Providers.Del(id)
 361			continue
 362		}
 363		if providerConfig.APIKey == "" {
 364			slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id)
 365		}
 366		if providerConfig.BaseURL == "" {
 367			slog.Warn("Skipping custom provider due to missing API endpoint", "provider", id)
 368			c.Providers.Del(id)
 369			continue
 370		}
 371		if len(providerConfig.Models) == 0 {
 372			slog.Warn("Skipping custom provider because the provider has no models", "provider", id)
 373			c.Providers.Del(id)
 374			continue
 375		}
 376		apiKey, err := resolver.ResolveValue(providerConfig.APIKey)
 377		if apiKey == "" || err != nil {
 378			slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id)
 379		}
 380		baseURL, err := resolver.ResolveValue(providerConfig.BaseURL)
 381		if baseURL == "" || err != nil {
 382			slog.Warn("Skipping custom provider due to missing API endpoint", "provider", id, "error", err)
 383			c.Providers.Del(id)
 384			continue
 385		}
 386
 387		// Custom-provider headers share the MCP error contract; see
 388		// the known-provider loop above.
 389		for k, v := range providerConfig.ExtraHeaders {
 390			resolved, err := resolver.ResolveValue(v)
 391			if err != nil {
 392				return fmt.Errorf("resolving provider %s header %q: %w", id, k, err)
 393			}
 394			if resolved == "" {
 395				delete(providerConfig.ExtraHeaders, k)
 396				continue
 397			}
 398			providerConfig.ExtraHeaders[k] = resolved
 399		}
 400
 401		c.Providers.Set(id, providerConfig)
 402	}
 403
 404	if c.Providers.Len() == 0 && c.Options.DisableDefaultProviders {
 405		return fmt.Errorf("default providers are disabled and there are no custom providers are configured")
 406	}
 407
 408	return nil
 409}
 410
 411func (c *Config) setDefaults(workingDir, dataDir string) {
 412	if c.Options == nil {
 413		c.Options = &Options{}
 414	}
 415	if c.Options.TUI == nil {
 416		c.Options.TUI = &TUIOptions{}
 417	}
 418	if dataDir != "" {
 419		c.Options.DataDirectory = dataDir
 420	} else if c.Options.DataDirectory == "" {
 421		if path, ok := fsext.LookupClosestBounded(workingDir, projectBoundary(workingDir), defaultDataDirectory); ok {
 422			c.Options.DataDirectory = path
 423		} else {
 424			c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory)
 425		}
 426	}
 427	c.Options.DataDirectory = filepath.Clean(filepathext.SmartJoin(workingDir, c.Options.DataDirectory))
 428	if c.Providers == nil {
 429		c.Providers = csync.NewMap[string, ProviderConfig]()
 430	}
 431	if c.Models == nil {
 432		c.Models = make(map[SelectedModelType]SelectedModel)
 433	}
 434	if c.RecentModels == nil {
 435		c.RecentModels = make(map[SelectedModelType][]SelectedModel)
 436	}
 437	if c.MCP == nil {
 438		c.MCP = make(map[string]MCPConfig)
 439	}
 440	if c.LSP == nil {
 441		c.LSP = make(map[string]LSPConfig)
 442	}
 443
 444	// Apply defaults to LSP configurations
 445	c.applyLSPDefaults()
 446
 447	// Add the default context paths if they are not already present
 448	c.Options.ContextPaths = append(defaultContextPaths, c.Options.ContextPaths...)
 449	slices.Sort(c.Options.ContextPaths)
 450	c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
 451
 452	// Add the default skills directories if not already present.
 453	for _, dir := range GlobalSkillsDirs() {
 454		if !slices.Contains(c.Options.SkillsPaths, dir) {
 455			c.Options.SkillsPaths = append(c.Options.SkillsPaths, dir)
 456		}
 457	}
 458
 459	// Project specific skills dirs.
 460	c.Options.SkillsPaths = append(c.Options.SkillsPaths, ProjectSkillsDir(workingDir)...)
 461
 462	if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
 463		c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
 464	}
 465
 466	if str, ok := os.LookupEnv("CRUSH_DISABLE_DEFAULT_PROVIDERS"); ok {
 467		c.Options.DisableDefaultProviders, _ = strconv.ParseBool(str)
 468	}
 469
 470	if c.Options.Attribution == nil {
 471		c.Options.Attribution = &Attribution{
 472			TrailerStyle:  TrailerStyleAssistedBy,
 473			GeneratedWith: true,
 474		}
 475	} else if c.Options.Attribution.TrailerStyle == "" {
 476		// Migrate deprecated co_authored_by or apply default
 477		if c.Options.Attribution.CoAuthoredBy != nil {
 478			if *c.Options.Attribution.CoAuthoredBy {
 479				c.Options.Attribution.TrailerStyle = TrailerStyleCoAuthoredBy
 480			} else {
 481				c.Options.Attribution.TrailerStyle = TrailerStyleNone
 482			}
 483		} else {
 484			c.Options.Attribution.TrailerStyle = TrailerStyleAssistedBy
 485		}
 486	}
 487
 488	c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs)
 489}
 490
 491// applyLSPDefaults applies default values from powernap to LSP configurations
 492func (c *Config) applyLSPDefaults() {
 493	// Get powernap's default configuration
 494	configManager := powernapConfig.NewManager()
 495	configManager.LoadDefaults()
 496
 497	// Apply defaults to each LSP configuration
 498	for name, cfg := range c.LSP {
 499		// Try to get defaults from powernap based on name or command name.
 500		base, ok := configManager.GetServer(name)
 501		if !ok {
 502			base, ok = configManager.GetServer(cfg.Command)
 503			if !ok {
 504				continue
 505			}
 506		}
 507		if cfg.Options == nil {
 508			cfg.Options = base.Settings
 509		}
 510		if cfg.InitOptions == nil {
 511			cfg.InitOptions = base.InitOptions
 512		}
 513		if len(cfg.FileTypes) == 0 {
 514			cfg.FileTypes = base.FileTypes
 515		}
 516		if len(cfg.RootMarkers) == 0 {
 517			cfg.RootMarkers = base.RootMarkers
 518		}
 519		cfg.Command = cmp.Or(cfg.Command, base.Command)
 520		if len(cfg.Args) == 0 {
 521			cfg.Args = base.Args
 522		}
 523		if len(cfg.Env) == 0 {
 524			cfg.Env = base.Environment
 525		}
 526		// Update the config in the map
 527		c.LSP[name] = cfg
 528	}
 529}
 530
 531func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
 532	if len(knownProviders) == 0 && c.Providers.Len() == 0 {
 533		err = fmt.Errorf("no providers configured, please configure at least one provider")
 534		return largeModel, smallModel, err
 535	}
 536
 537	// Use the first provider enabled based on the known providers order
 538	// if no provider found that is known use the first provider configured
 539	for _, p := range knownProviders {
 540		providerConfig, ok := c.Providers.Get(string(p.ID))
 541		if !ok || providerConfig.Disable {
 542			continue
 543		}
 544		defaultLargeModel := c.GetModel(string(p.ID), p.DefaultLargeModelID)
 545		if defaultLargeModel == nil {
 546			err = fmt.Errorf("default large model %s not found for provider %s", p.DefaultLargeModelID, p.ID)
 547			return largeModel, smallModel, err
 548		}
 549		largeModel = SelectedModel{
 550			Provider:        string(p.ID),
 551			Model:           defaultLargeModel.ID,
 552			MaxTokens:       defaultLargeModel.DefaultMaxTokens,
 553			ReasoningEffort: defaultLargeModel.DefaultReasoningEffort,
 554		}
 555
 556		defaultSmallModel := c.GetModel(string(p.ID), p.DefaultSmallModelID)
 557		if defaultSmallModel == nil {
 558			err = fmt.Errorf("default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
 559			return largeModel, smallModel, err
 560		}
 561		smallModel = SelectedModel{
 562			Provider:        string(p.ID),
 563			Model:           defaultSmallModel.ID,
 564			MaxTokens:       defaultSmallModel.DefaultMaxTokens,
 565			ReasoningEffort: defaultSmallModel.DefaultReasoningEffort,
 566		}
 567		return largeModel, smallModel, err
 568	}
 569
 570	enabledProviders := c.EnabledProviders()
 571	slices.SortFunc(enabledProviders, func(a, b ProviderConfig) int {
 572		return strings.Compare(a.ID, b.ID)
 573	})
 574
 575	if len(enabledProviders) == 0 {
 576		err = fmt.Errorf("no providers configured, please configure at least one provider")
 577		return largeModel, smallModel, err
 578	}
 579
 580	providerConfig := enabledProviders[0]
 581	if len(providerConfig.Models) == 0 {
 582		err = fmt.Errorf("provider %s has no models configured", providerConfig.ID)
 583		return largeModel, smallModel, err
 584	}
 585	defaultLargeModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
 586	largeModel = SelectedModel{
 587		Provider:  providerConfig.ID,
 588		Model:     defaultLargeModel.ID,
 589		MaxTokens: defaultLargeModel.DefaultMaxTokens,
 590	}
 591	defaultSmallModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
 592	smallModel = SelectedModel{
 593		Provider:  providerConfig.ID,
 594		Model:     defaultSmallModel.ID,
 595		MaxTokens: defaultSmallModel.DefaultMaxTokens,
 596	}
 597	return largeModel, smallModel, err
 598}
 599
 600func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provider, persist bool) error {
 601	c := store.config
 602	defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
 603	if err != nil {
 604		return fmt.Errorf("failed to select default models: %w", err)
 605	}
 606	large, small := defaultLarge, defaultSmall
 607
 608	largeModelSelected, largeModelConfigured := c.Models[SelectedModelTypeLarge]
 609	if largeModelConfigured {
 610		if largeModelSelected.Model != "" {
 611			large.Model = largeModelSelected.Model
 612		}
 613		if largeModelSelected.Provider != "" {
 614			large.Provider = largeModelSelected.Provider
 615		}
 616		model := c.GetModel(large.Provider, large.Model)
 617		if model == nil {
 618			large = defaultLarge
 619			if persist {
 620				if err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, large); err != nil {
 621					return fmt.Errorf("failed to update preferred large model: %w", err)
 622				}
 623			}
 624		} else {
 625			if largeModelSelected.MaxTokens > 0 {
 626				large.MaxTokens = largeModelSelected.MaxTokens
 627			} else {
 628				large.MaxTokens = model.DefaultMaxTokens
 629			}
 630			if largeModelSelected.ReasoningEffort != "" {
 631				large.ReasoningEffort = largeModelSelected.ReasoningEffort
 632			}
 633			large.Think = largeModelSelected.Think
 634			if largeModelSelected.Temperature != nil {
 635				large.Temperature = largeModelSelected.Temperature
 636			}
 637			if largeModelSelected.TopP != nil {
 638				large.TopP = largeModelSelected.TopP
 639			}
 640			if largeModelSelected.TopK != nil {
 641				large.TopK = largeModelSelected.TopK
 642			}
 643			if largeModelSelected.FrequencyPenalty != nil {
 644				large.FrequencyPenalty = largeModelSelected.FrequencyPenalty
 645			}
 646			if largeModelSelected.PresencePenalty != nil {
 647				large.PresencePenalty = largeModelSelected.PresencePenalty
 648			}
 649		}
 650	}
 651	smallModelSelected, smallModelConfigured := c.Models[SelectedModelTypeSmall]
 652	if smallModelConfigured {
 653		if smallModelSelected.Model != "" {
 654			small.Model = smallModelSelected.Model
 655		}
 656		if smallModelSelected.Provider != "" {
 657			small.Provider = smallModelSelected.Provider
 658		}
 659
 660		model := c.GetModel(small.Provider, small.Model)
 661		if model == nil {
 662			small = defaultSmall
 663			if persist {
 664				if err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, small); err != nil {
 665					return fmt.Errorf("failed to update preferred small model: %w", err)
 666				}
 667			}
 668		} else {
 669			if smallModelSelected.MaxTokens > 0 {
 670				small.MaxTokens = smallModelSelected.MaxTokens
 671			} else {
 672				small.MaxTokens = model.DefaultMaxTokens
 673			}
 674			if smallModelSelected.ReasoningEffort != "" {
 675				small.ReasoningEffort = smallModelSelected.ReasoningEffort
 676			}
 677			if smallModelSelected.Temperature != nil {
 678				small.Temperature = smallModelSelected.Temperature
 679			}
 680			if smallModelSelected.TopP != nil {
 681				small.TopP = smallModelSelected.TopP
 682			}
 683			if smallModelSelected.TopK != nil {
 684				small.TopK = smallModelSelected.TopK
 685			}
 686			if smallModelSelected.FrequencyPenalty != nil {
 687				small.FrequencyPenalty = smallModelSelected.FrequencyPenalty
 688			}
 689			if smallModelSelected.PresencePenalty != nil {
 690				small.PresencePenalty = smallModelSelected.PresencePenalty
 691			}
 692			small.Think = smallModelSelected.Think
 693		}
 694	}
 695
 696	// When small isn't explicitly configured and the provider isn't a
 697	// known built-in, use the large model as the small model. This
 698	// prevents two different models from being requested concurrently
 699	// for local/openai-compat providers.
 700	if !smallModelConfigured {
 701		isKnownProvider := false
 702		for _, kp := range knownProviders {
 703			if string(kp.ID) == small.Provider {
 704				isKnownProvider = true
 705				break
 706			}
 707		}
 708		if !isKnownProvider {
 709			slog.Warn("Using large model as small model for unknown provider", "provider", large.Provider, "model", large.Model)
 710			small = large
 711		}
 712	}
 713
 714	c.Models[SelectedModelTypeLarge] = large
 715	c.Models[SelectedModelTypeSmall] = small
 716	return nil
 717}
 718
 719// lookupConfigs searches config files starting at cwd and walking up
 720// through the current project. The upward walk stops at the git
 721// working tree root when one can be detected, otherwise at cwd itself,
 722// so an unrelated crush.json placed above the project is never picked
 723// up. Global user-level config locations are always included
 724// regardless of the boundary.
 725func lookupConfigs(cwd string) []string {
 726	// prepend default config paths
 727	configPaths := []string{
 728		GlobalConfig(),
 729		GlobalConfigData(),
 730	}
 731
 732	configNames := []string{appName + ".json", "." + appName + ".json"}
 733
 734	foundConfigs, err := fsext.LookupBounded(cwd, projectBoundary(cwd), configNames...)
 735	if err != nil {
 736		// returns at least default configs
 737		return configPaths
 738	}
 739
 740	// reverse order so last config has more priority
 741	slices.Reverse(foundConfigs)
 742
 743	return append(configPaths, foundConfigs...)
 744}
 745
 746func loadFromConfigPaths(configPaths []string) (*Config, []string, error) {
 747	var configs [][]byte
 748	var loaded []string
 749
 750	for _, path := range configPaths {
 751		data, err := os.ReadFile(path)
 752		if err != nil {
 753			if os.IsNotExist(err) {
 754				continue
 755			}
 756			return nil, nil, fmt.Errorf("failed to open config file %s: %w", path, err)
 757		}
 758		if len(data) == 0 {
 759			continue
 760		}
 761		if !json.Valid(data) {
 762			return nil, nil, fmt.Errorf("invalid JSON in config file %s", path)
 763		}
 764		configs = append(configs, data)
 765		loaded = append(loaded, path)
 766	}
 767
 768	cfg, err := loadFromBytes(configs)
 769	if err != nil {
 770		return nil, nil, err
 771	}
 772	return cfg, loaded, nil
 773}
 774
 775func loadFromBytes(configs [][]byte) (*Config, error) {
 776	if len(configs) == 0 {
 777		return &Config{}, nil
 778	}
 779
 780	data, err := jsons.Merge(configs)
 781	if err != nil {
 782		return nil, err
 783	}
 784	var config Config
 785	if err := json.Unmarshal(data, &config); err != nil {
 786		return nil, err
 787	}
 788	return &config, nil
 789}
 790
 791func hasAWSCredentials(env env.Env) bool {
 792	if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" {
 793		return true
 794	}
 795
 796	if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" {
 797		return true
 798	}
 799
 800	if env.Get("AWS_PROFILE") != "" || env.Get("AWS_DEFAULT_PROFILE") != "" {
 801		return true
 802	}
 803
 804	if env.Get("AWS_REGION") != "" || env.Get("AWS_DEFAULT_REGION") != "" {
 805		return true
 806	}
 807
 808	if env.Get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
 809		env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
 810		return true
 811	}
 812
 813	if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil && !testing.Testing() {
 814		return true
 815	}
 816
 817	return false
 818}
 819
 820// migrateDisableNotifications migrates the deprecated disable_notifications
 821// field to notification_style. It checks both the user config (~/.config) and
 822// data config (~/.local) files. If disable_notifications is true, it sets
 823// notification_style to "disabled" in the data file. Regardless of value, it
 824// removes disable_notifications from any file that contains it.
 825func migrateDisableNotifications() {
 826	globalConfig := GlobalConfig()
 827	dataConfig := GlobalConfigData()
 828
 829	var wasDisabled bool
 830	filesToClean := []string{}
 831
 832	for _, path := range []string{globalConfig, dataConfig} {
 833		data, err := os.ReadFile(path)
 834		if err != nil {
 835			continue
 836		}
 837		if gjson.Get(string(data), "options.disable_notifications").Exists() {
 838			filesToClean = append(filesToClean, path)
 839			if gjson.Get(string(data), "options.disable_notifications").Bool() {
 840				wasDisabled = true
 841			}
 842		}
 843	}
 844
 845	if len(filesToClean) == 0 {
 846		return
 847	}
 848
 849	// If notifications were disabled, persist the equivalent notification_style.
 850	if wasDisabled {
 851		data, err := os.ReadFile(dataConfig)
 852		if err == nil {
 853			if !gjson.Get(string(data), "options.notification_style").Exists() {
 854				updated, err := sjson.Set(string(data), "options.notification_style", "disabled")
 855				if err == nil {
 856					if err := atomicWriteFile(dataConfig, []byte(updated), 0o600); err != nil {
 857						slog.Warn("Failed to migrate disable_notifications to notification_style", "error", err)
 858					} else {
 859						slog.Info("Migrated disable_notifications: true to notification_style: disabled")
 860					}
 861				}
 862			}
 863		}
 864	}
 865
 866	// Remove disable_notifications from all files that contain it.
 867	for _, path := range filesToClean {
 868		data, err := os.ReadFile(path)
 869		if err != nil {
 870			continue
 871		}
 872		updated, err := sjson.Delete(string(data), "options.disable_notifications")
 873		if err != nil {
 874			slog.Warn("Failed to remove deprecated disable_notifications field", "path", path, "error", err)
 875			continue
 876		}
 877		if err := atomicWriteFile(path, []byte(updated), 0o600); err != nil {
 878			slog.Warn("Failed to write migrated config", "path", path, "error", err)
 879		}
 880	}
 881}
 882
 883// GlobalConfig returns the global configuration file path for the application.
 884func GlobalConfig() string {
 885	if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {
 886		return filepath.Join(crushGlobal, fmt.Sprintf("%s.json", appName))
 887	}
 888	return filepath.Join(home.Config(), appName, fmt.Sprintf("%s.json", appName))
 889}
 890
 891// GlobalCacheDir returns the path to the global cache directory for the
 892// application.
 893func GlobalCacheDir() string {
 894	if crushCache := os.Getenv("CRUSH_CACHE_DIR"); crushCache != "" {
 895		return crushCache
 896	}
 897	if xdgCacheHome := os.Getenv("XDG_CACHE_HOME"); xdgCacheHome != "" {
 898		return filepath.Join(xdgCacheHome, appName)
 899	}
 900	if runtime.GOOS == "windows" {
 901		localAppData := cmp.Or(
 902			os.Getenv("LOCALAPPDATA"),
 903			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
 904		)
 905		return filepath.Join(localAppData, appName, "cache")
 906	}
 907	return filepath.Join(home.Dir(), ".cache", appName)
 908}
 909
 910// ProjectConfigs returns list of current project configs paths.
 911func ProjectConfigs(cwd string) []string {
 912	return lookupConfigs(cwd)
 913}
 914
 915// GlobalConfigData returns the path to the main data directory for the application.
 916// this config is used when the app overrides configurations instead of updating the global config.
 917func GlobalConfigData() string {
 918	if crushData := os.Getenv("CRUSH_GLOBAL_DATA"); crushData != "" {
 919		return filepath.Join(crushData, fmt.Sprintf("%s.json", appName))
 920	}
 921	if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" {
 922		return filepath.Join(xdgDataHome, appName, fmt.Sprintf("%s.json", appName))
 923	}
 924
 925	// return the path to the main data directory
 926	// for windows, it should be in `%LOCALAPPDATA%/crush/`
 927	// for linux and macOS, it should be in `$HOME/.local/share/crush/`
 928	if runtime.GOOS == "windows" {
 929		localAppData := cmp.Or(
 930			os.Getenv("LOCALAPPDATA"),
 931			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
 932		)
 933		return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
 934	}
 935
 936	return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
 937}
 938
 939// GlobalWorkspaceDir returns the path to the global server workspace
 940// directory. This directory acts as a meta-workspace for the server
 941// process, giving it a real workingDir so that config loading, scoped
 942// writes, and provider resolution behave identically to project
 943// workspaces.
 944func GlobalWorkspaceDir() string {
 945	return filepath.Dir(GlobalConfigData())
 946}
 947
 948func assignIfNil[T any](ptr **T, val T) {
 949	if *ptr == nil {
 950		*ptr = &val
 951	}
 952}
 953
 954func isInsideWorktree() bool {
 955	bts, err := exec.CommandContext(
 956		context.Background(),
 957		"git", "rev-parse",
 958		"--is-inside-work-tree",
 959	).CombinedOutput()
 960	return err == nil && strings.TrimSpace(string(bts)) == "true"
 961}
 962
 963// worktreeRoot returns the absolute path of the git working tree root for
 964// dir, or the empty string if dir is not inside a working tree (bare
 965// repositories, missing git binary, plain directories, or any other
 966// failure mode). Linked worktrees and submodules each report their own
 967// top-level, which is what callers want when bounding lookups.
 968func worktreeRoot(dir string) string {
 969	cmd := exec.CommandContext(
 970		context.Background(),
 971		"git", "rev-parse", "--show-toplevel",
 972	)
 973	cmd.Dir = dir
 974	out, err := cmd.Output()
 975	if err != nil {
 976		return ""
 977	}
 978	root := strings.TrimSpace(string(out))
 979	if root == "" {
 980		return ""
 981	}
 982	abs, err := filepath.Abs(root)
 983	if err != nil {
 984		return ""
 985	}
 986	return abs
 987}
 988
 989// projectBoundary returns the directory at which an upward configuration
 990// search rooted at dir should stop. It is the git working tree root when
 991// one can be detected, otherwise dir itself. Returning dir as a
 992// fallback keeps Crush from silently adopting state files placed above
 993// the current project.
 994func projectBoundary(dir string) string {
 995	if root := worktreeRoot(dir); root != "" {
 996		return root
 997	}
 998	abs, err := filepath.Abs(dir)
 999	if err != nil {
1000		return dir
1001	}
1002	return abs
1003}
1004
1005// GlobalSkillsDirs returns the default directories for Agent Skills.
1006// Skills in these directories are auto-discovered and their files can be read
1007// without permission prompts.
1008func GlobalSkillsDirs() []string {
1009	if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
1010		return []string{crushSkills}
1011	}
1012
1013	paths := []string{
1014		filepath.Join(home.Config(), appName, "skills"),
1015		filepath.Join(home.Config(), "agents", "skills"),
1016		// Per the Agent Skills spec, scan ~/.agents/skills
1017		filepath.Join(home.Dir(), ".agents", "skills"),
1018		filepath.Join(home.Dir(), ".claude", "skills"),
1019	}
1020
1021	// On Windows, also load from app data on top of `$HOME/.config/crush`.
1022	// This is here mostly for backwards compatibility.
1023	if runtime.GOOS == "windows" {
1024		appData := cmp.Or(
1025			os.Getenv("LOCALAPPDATA"),
1026			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
1027		)
1028		paths = append(
1029			paths,
1030			filepath.Join(appData, appName, "skills"),
1031			filepath.Join(appData, "agents", "skills"),
1032		)
1033	}
1034
1035	return paths
1036}
1037
1038// ProjectSkillsDir returns the default project directories for which Crush
1039// will look for skills.
1040func ProjectSkillsDir(workingDir string) []string {
1041	return []string{
1042		filepath.Join(workingDir, ".agents/skills"),
1043		filepath.Join(workingDir, ".crush/skills"),
1044		filepath.Join(workingDir, ".claude/skills"),
1045		filepath.Join(workingDir, ".cursor/skills"),
1046	}
1047}
1048
1049func isAppleTerminal() bool { return os.Getenv("TERM_PROGRAM") == "Apple_Terminal" }
1050
1051// normalizeHookEvent maps user-provided event names to their canonical
1052// form. Matching is case-insensitive and accepts snake_case variants
1053// (e.g. "pre_tool_use" → "PreToolUse").
1054func normalizeHookEvent(name string) string {
1055	switch strings.ToLower(strings.ReplaceAll(name, "_", "")) {
1056	case "pretooluse":
1057		return "PreToolUse"
1058	default:
1059		return name
1060	}
1061}
1062
1063// ValidateHooks normalizes event names and checks that every configured
1064// hook has a command and a syntactically valid matcher regex. Matcher
1065// compilation used for matching is owned by hooks.Runner; this function
1066// only validates up front so the user sees config errors at load time
1067// rather than on the first tool call.
1068func (c *Config) ValidateHooks() error {
1069	// Normalize event name keys.
1070	for event, eventHooks := range c.Hooks {
1071		canonical := normalizeHookEvent(event)
1072		if canonical != event {
1073			c.Hooks[canonical] = append(c.Hooks[canonical], eventHooks...)
1074			delete(c.Hooks, event)
1075		}
1076	}
1077
1078	for event, eventHooks := range c.Hooks {
1079		for i, h := range eventHooks {
1080			if h.Command == "" {
1081				return fmt.Errorf("hook %s[%d]: command is required", event, i)
1082			}
1083			if h.Matcher == "" {
1084				continue
1085			}
1086			if _, err := regexp.Compile(h.Matcher); err != nil {
1087				return fmt.Errorf("hook %s[%d]: invalid matcher regex %q: %w", event, i, h.Matcher, err)
1088			}
1089		}
1090	}
1091	return nil
1092}