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}