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	if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/login")); err == nil && !testing.Testing() {
 817		return true
 818	}
 819
 820	return false
 821}
 822
 823// migrateDisableNotifications migrates the deprecated disable_notifications
 824// field to notification_style. It checks both the user config (~/.config) and
 825// data config (~/.local) files. If disable_notifications is true, it sets
 826// notification_style to "disabled" in the data file. Regardless of value, it
 827// removes disable_notifications from any file that contains it.
 828func migrateDisableNotifications() {
 829	globalConfig := GlobalConfig()
 830	dataConfig := GlobalConfigData()
 831
 832	var wasDisabled bool
 833	filesToClean := []string{}
 834
 835	for _, path := range []string{globalConfig, dataConfig} {
 836		data, err := os.ReadFile(path)
 837		if err != nil {
 838			continue
 839		}
 840		if gjson.Get(string(data), "options.disable_notifications").Exists() {
 841			filesToClean = append(filesToClean, path)
 842			if gjson.Get(string(data), "options.disable_notifications").Bool() {
 843				wasDisabled = true
 844			}
 845		}
 846	}
 847
 848	if len(filesToClean) == 0 {
 849		return
 850	}
 851
 852	// If notifications were disabled, persist the equivalent notification_style.
 853	if wasDisabled {
 854		data, err := os.ReadFile(dataConfig)
 855		if err == nil {
 856			if !gjson.Get(string(data), "options.notification_style").Exists() {
 857				updated, err := sjson.Set(string(data), "options.notification_style", "disabled")
 858				if err == nil {
 859					if err := atomicWriteFile(dataConfig, []byte(updated), 0o600); err != nil {
 860						slog.Warn("Failed to migrate disable_notifications to notification_style", "error", err)
 861					} else {
 862						slog.Info("Migrated disable_notifications: true to notification_style: disabled")
 863					}
 864				}
 865			}
 866		}
 867	}
 868
 869	// Remove disable_notifications from all files that contain it.
 870	for _, path := range filesToClean {
 871		data, err := os.ReadFile(path)
 872		if err != nil {
 873			continue
 874		}
 875		updated, err := sjson.Delete(string(data), "options.disable_notifications")
 876		if err != nil {
 877			slog.Warn("Failed to remove deprecated disable_notifications field", "path", path, "error", err)
 878			continue
 879		}
 880		if err := atomicWriteFile(path, []byte(updated), 0o600); err != nil {
 881			slog.Warn("Failed to write migrated config", "path", path, "error", err)
 882		}
 883	}
 884}
 885
 886// GlobalConfig returns the global configuration file path for the application.
 887func GlobalConfig() string {
 888	if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {
 889		return filepath.Join(crushGlobal, fmt.Sprintf("%s.json", appName))
 890	}
 891	return filepath.Join(home.Config(), appName, fmt.Sprintf("%s.json", appName))
 892}
 893
 894// GlobalCacheDir returns the path to the global cache directory for the
 895// application.
 896func GlobalCacheDir() string {
 897	if crushCache := os.Getenv("CRUSH_CACHE_DIR"); crushCache != "" {
 898		return crushCache
 899	}
 900	if xdgCacheHome := os.Getenv("XDG_CACHE_HOME"); xdgCacheHome != "" {
 901		return filepath.Join(xdgCacheHome, appName)
 902	}
 903	if runtime.GOOS == "windows" {
 904		localAppData := cmp.Or(
 905			os.Getenv("LOCALAPPDATA"),
 906			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
 907		)
 908		return filepath.Join(localAppData, appName, "cache")
 909	}
 910	return filepath.Join(home.Dir(), ".cache", appName)
 911}
 912
 913// ProjectConfigs returns list of current project configs paths.
 914func ProjectConfigs(cwd string) []string {
 915	return lookupConfigs(cwd)
 916}
 917
 918// GlobalConfigData returns the path to the main data directory for the application.
 919// this config is used when the app overrides configurations instead of updating the global config.
 920func GlobalConfigData() string {
 921	if crushData := os.Getenv("CRUSH_GLOBAL_DATA"); crushData != "" {
 922		return filepath.Join(crushData, fmt.Sprintf("%s.json", appName))
 923	}
 924	if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" {
 925		return filepath.Join(xdgDataHome, appName, fmt.Sprintf("%s.json", appName))
 926	}
 927
 928	// return the path to the main data directory
 929	// for windows, it should be in `%LOCALAPPDATA%/crush/`
 930	// for linux and macOS, it should be in `$HOME/.local/share/crush/`
 931	if runtime.GOOS == "windows" {
 932		localAppData := cmp.Or(
 933			os.Getenv("LOCALAPPDATA"),
 934			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
 935		)
 936		return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
 937	}
 938
 939	return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
 940}
 941
 942// GlobalWorkspaceDir returns the path to the global server workspace
 943// directory. This directory acts as a meta-workspace for the server
 944// process, giving it a real workingDir so that config loading, scoped
 945// writes, and provider resolution behave identically to project
 946// workspaces.
 947func GlobalWorkspaceDir() string {
 948	return filepath.Dir(GlobalConfigData())
 949}
 950
 951func assignIfNil[T any](ptr **T, val T) {
 952	if *ptr == nil {
 953		*ptr = &val
 954	}
 955}
 956
 957func isInsideWorktree() bool {
 958	bts, err := exec.CommandContext(
 959		context.Background(),
 960		"git", "rev-parse",
 961		"--is-inside-work-tree",
 962	).CombinedOutput()
 963	return err == nil && strings.TrimSpace(string(bts)) == "true"
 964}
 965
 966// worktreeRoot returns the absolute path of the git working tree root for
 967// dir, or the empty string if dir is not inside a working tree (bare
 968// repositories, missing git binary, plain directories, or any other
 969// failure mode). Linked worktrees and submodules each report their own
 970// top-level, which is what callers want when bounding lookups.
 971func worktreeRoot(dir string) string {
 972	cmd := exec.CommandContext(
 973		context.Background(),
 974		"git", "rev-parse", "--show-toplevel",
 975	)
 976	cmd.Dir = dir
 977	out, err := cmd.Output()
 978	if err != nil {
 979		return ""
 980	}
 981	root := strings.TrimSpace(string(out))
 982	if root == "" {
 983		return ""
 984	}
 985	abs, err := filepath.Abs(root)
 986	if err != nil {
 987		return ""
 988	}
 989	return abs
 990}
 991
 992// projectBoundary returns the directory at which an upward configuration
 993// search rooted at dir should stop. It is the git working tree root when
 994// one can be detected, otherwise dir itself. Returning dir as a
 995// fallback keeps Crush from silently adopting state files placed above
 996// the current project.
 997func projectBoundary(dir string) string {
 998	if root := worktreeRoot(dir); root != "" {
 999		return root
1000	}
1001	abs, err := filepath.Abs(dir)
1002	if err != nil {
1003		return dir
1004	}
1005	return abs
1006}
1007
1008// GlobalSkillsDirs returns the default directories for Agent Skills.
1009// Skills in these directories are auto-discovered and their files can be read
1010// without permission prompts.
1011func GlobalSkillsDirs() []string {
1012	if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
1013		return []string{crushSkills}
1014	}
1015
1016	paths := []string{
1017		filepath.Join(home.Config(), appName, "skills"),
1018		filepath.Join(home.Config(), "agents", "skills"),
1019		// Per the Agent Skills spec, scan ~/.agents/skills
1020		filepath.Join(home.Dir(), ".agents", "skills"),
1021		filepath.Join(home.Dir(), ".claude", "skills"),
1022	}
1023
1024	// On Windows, also load from app data on top of `$HOME/.config/crush`.
1025	// This is here mostly for backwards compatibility.
1026	if runtime.GOOS == "windows" {
1027		appData := cmp.Or(
1028			os.Getenv("LOCALAPPDATA"),
1029			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
1030		)
1031		paths = append(
1032			paths,
1033			filepath.Join(appData, appName, "skills"),
1034			filepath.Join(appData, "agents", "skills"),
1035		)
1036	}
1037
1038	return paths
1039}
1040
1041// ProjectSkillsDir returns the default project directories for which Crush
1042// will look for skills.
1043func ProjectSkillsDir(workingDir string) []string {
1044	return []string{
1045		filepath.Join(workingDir, ".agents/skills"),
1046		filepath.Join(workingDir, ".crush/skills"),
1047		filepath.Join(workingDir, ".claude/skills"),
1048		filepath.Join(workingDir, ".cursor/skills"),
1049	}
1050}
1051
1052func isAppleTerminal() bool { return os.Getenv("TERM_PROGRAM") == "Apple_Terminal" }
1053
1054// normalizeHookEvent maps user-provided event names to their canonical
1055// form. Matching is case-insensitive and accepts snake_case variants
1056// (e.g. "pre_tool_use" → "PreToolUse").
1057func normalizeHookEvent(name string) string {
1058	switch strings.ToLower(strings.ReplaceAll(name, "_", "")) {
1059	case "pretooluse":
1060		return "PreToolUse"
1061	default:
1062		return name
1063	}
1064}
1065
1066// ValidateHooks normalizes event names and checks that every configured
1067// hook has a command and a syntactically valid matcher regex. Matcher
1068// compilation used for matching is owned by hooks.Runner; this function
1069// only validates up front so the user sees config errors at load time
1070// rather than on the first tool call.
1071func (c *Config) ValidateHooks() error {
1072	// Normalize event name keys.
1073	for event, eventHooks := range c.Hooks {
1074		canonical := normalizeHookEvent(event)
1075		if canonical != event {
1076			c.Hooks[canonical] = append(c.Hooks[canonical], eventHooks...)
1077			delete(c.Hooks, event)
1078		}
1079	}
1080
1081	for event, eventHooks := range c.Hooks {
1082		for i, h := range eventHooks {
1083			if h.Command == "" {
1084				return fmt.Errorf("hook %s[%d]: command is required", event, i)
1085			}
1086			if h.Matcher == "" {
1087				continue
1088			}
1089			if _, err := regexp.Compile(h.Matcher); err != nil {
1090				return fmt.Errorf("hook %s[%d]: invalid matcher regex %q: %w", event, i, h.Matcher, err)
1091			}
1092		}
1093	}
1094	return nil
1095}