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