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 len(c.Options.GlobalContextPaths) == 0 {
421 crushConfigDir := filepath.Dir(GlobalConfig())
422 c.Options.GlobalContextPaths = []string{
423 filepath.Join(crushConfigDir, "CRUSH.md"),
424 filepath.Join(filepath.Dir(crushConfigDir), "AGENTS.md"),
425 }
426 }
427 slices.Sort(c.Options.GlobalContextPaths)
428 c.Options.GlobalContextPaths = slices.Compact(c.Options.GlobalContextPaths)
429
430 if dataDir != "" {
431 c.Options.DataDirectory = dataDir
432 } else if c.Options.DataDirectory == "" {
433 if path, ok := fsext.LookupClosestBounded(workingDir, projectBoundary(workingDir), defaultDataDirectory); ok {
434 c.Options.DataDirectory = path
435 } else {
436 c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory)
437 }
438 }
439 c.Options.DataDirectory = filepath.Clean(filepathext.SmartJoin(workingDir, c.Options.DataDirectory))
440 if c.Providers == nil {
441 c.Providers = csync.NewMap[string, ProviderConfig]()
442 }
443 if c.Models == nil {
444 c.Models = make(map[SelectedModelType]SelectedModel)
445 }
446 if c.RecentModels == nil {
447 c.RecentModels = make(map[SelectedModelType][]SelectedModel)
448 }
449 if c.MCP == nil {
450 c.MCP = make(map[string]MCPConfig)
451 }
452 if c.LSP == nil {
453 c.LSP = make(map[string]LSPConfig)
454 }
455
456 // Apply defaults to LSP configurations
457 c.applyLSPDefaults()
458
459 // Add the default context paths if they are not already present
460 c.Options.ContextPaths = append(slices.Clone(defaultContextPaths), c.Options.ContextPaths...)
461
462 slices.Sort(c.Options.ContextPaths)
463 c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
464
465 // Add the default skills directories if not already present.
466 for _, dir := range GlobalSkillsDirs() {
467 if !slices.Contains(c.Options.SkillsPaths, dir) {
468 c.Options.SkillsPaths = append(c.Options.SkillsPaths, dir)
469 }
470 }
471
472 // Project specific skills dirs.
473 c.Options.SkillsPaths = append(c.Options.SkillsPaths, ProjectSkillsDir(workingDir)...)
474
475 if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
476 c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
477 }
478
479 if str, ok := os.LookupEnv("CRUSH_DISABLE_DEFAULT_PROVIDERS"); ok {
480 c.Options.DisableDefaultProviders, _ = strconv.ParseBool(str)
481 }
482
483 if c.Options.Attribution == nil {
484 c.Options.Attribution = &Attribution{
485 TrailerStyle: TrailerStyleAssistedBy,
486 GeneratedWith: true,
487 }
488 } else if c.Options.Attribution.TrailerStyle == "" {
489 // Migrate deprecated co_authored_by or apply default
490 if c.Options.Attribution.CoAuthoredBy != nil {
491 if *c.Options.Attribution.CoAuthoredBy {
492 c.Options.Attribution.TrailerStyle = TrailerStyleCoAuthoredBy
493 } else {
494 c.Options.Attribution.TrailerStyle = TrailerStyleNone
495 }
496 } else {
497 c.Options.Attribution.TrailerStyle = TrailerStyleAssistedBy
498 }
499 }
500
501 c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs)
502}
503
504// applyLSPDefaults applies default values from powernap to LSP configurations
505func (c *Config) applyLSPDefaults() {
506 // Get powernap's default configuration
507 configManager := powernapConfig.NewManager()
508 configManager.LoadDefaults()
509
510 // Apply defaults to each LSP configuration
511 for name, cfg := range c.LSP {
512 // Try to get defaults from powernap based on name or command name.
513 base, ok := configManager.GetServer(name)
514 if !ok {
515 base, ok = configManager.GetServer(cfg.Command)
516 if !ok {
517 continue
518 }
519 }
520 if cfg.Options == nil {
521 cfg.Options = base.Settings
522 }
523 if cfg.InitOptions == nil {
524 cfg.InitOptions = base.InitOptions
525 }
526 if len(cfg.FileTypes) == 0 {
527 cfg.FileTypes = base.FileTypes
528 }
529 if len(cfg.RootMarkers) == 0 {
530 cfg.RootMarkers = base.RootMarkers
531 }
532 cfg.Command = cmp.Or(cfg.Command, base.Command)
533 if len(cfg.Args) == 0 {
534 cfg.Args = base.Args
535 }
536 if len(cfg.Env) == 0 {
537 cfg.Env = base.Environment
538 }
539 // Update the config in the map
540 c.LSP[name] = cfg
541 }
542}
543
544func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
545 if len(knownProviders) == 0 && c.Providers.Len() == 0 {
546 err = fmt.Errorf("no providers configured, please configure at least one provider")
547 return largeModel, smallModel, err
548 }
549
550 // Use the first provider enabled based on the known providers order
551 // if no provider found that is known use the first provider configured
552 for _, p := range knownProviders {
553 providerConfig, ok := c.Providers.Get(string(p.ID))
554 if !ok || providerConfig.Disable {
555 continue
556 }
557 defaultLargeModel := c.GetModel(string(p.ID), p.DefaultLargeModelID)
558 if defaultLargeModel == nil {
559 slog.Warn("Default large model %s not found for provider %s", p.DefaultLargeModelID, p.ID)
560 if len(providerConfig.Models) == 0 {
561 return largeModel, smallModel, fmt.Errorf("default large model %s not found for provider %s", p.DefaultLargeModelID, p.ID)
562 }
563 defaultLargeModel = &providerConfig.Models[0]
564 }
565 largeModel = SelectedModel{
566 Provider: string(p.ID),
567 Model: defaultLargeModel.ID,
568 MaxTokens: defaultLargeModel.DefaultMaxTokens,
569 ReasoningEffort: defaultLargeModel.DefaultReasoningEffort,
570 }
571
572 defaultSmallModel := c.GetModel(string(p.ID), p.DefaultSmallModelID)
573 if defaultSmallModel == nil {
574 slog.Warn("Default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
575 if len(providerConfig.Models) == 0 {
576 return largeModel, smallModel, fmt.Errorf("default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
577 }
578 defaultSmallModel = &providerConfig.Models[0]
579 }
580 smallModel = SelectedModel{
581 Provider: string(p.ID),
582 Model: defaultSmallModel.ID,
583 MaxTokens: defaultSmallModel.DefaultMaxTokens,
584 ReasoningEffort: defaultSmallModel.DefaultReasoningEffort,
585 }
586 return largeModel, smallModel, err
587 }
588
589 enabledProviders := c.EnabledProviders()
590 slices.SortFunc(enabledProviders, func(a, b ProviderConfig) int {
591 return strings.Compare(a.ID, b.ID)
592 })
593
594 if len(enabledProviders) == 0 {
595 err = fmt.Errorf("no providers configured, please configure at least one provider")
596 return largeModel, smallModel, err
597 }
598
599 providerConfig := enabledProviders[0]
600 if len(providerConfig.Models) == 0 {
601 err = fmt.Errorf("provider %s has no models configured", providerConfig.ID)
602 return largeModel, smallModel, err
603 }
604 defaultLargeModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
605 largeModel = SelectedModel{
606 Provider: providerConfig.ID,
607 Model: defaultLargeModel.ID,
608 MaxTokens: defaultLargeModel.DefaultMaxTokens,
609 }
610 defaultSmallModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
611 smallModel = SelectedModel{
612 Provider: providerConfig.ID,
613 Model: defaultSmallModel.ID,
614 MaxTokens: defaultSmallModel.DefaultMaxTokens,
615 }
616 return largeModel, smallModel, err
617}
618
619func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provider, persist bool) error {
620 c := store.config
621 defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
622 if err != nil {
623 return fmt.Errorf("failed to select default models: %w", err)
624 }
625 large, small := defaultLarge, defaultSmall
626
627 largeModelSelected, largeModelConfigured := c.Models[SelectedModelTypeLarge]
628 if largeModelConfigured {
629 if largeModelSelected.Model != "" {
630 large.Model = largeModelSelected.Model
631 }
632 if largeModelSelected.Provider != "" {
633 large.Provider = largeModelSelected.Provider
634 }
635 model := c.GetModel(large.Provider, large.Model)
636 if model == nil {
637 large = defaultLarge
638 if persist {
639 if err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, large); err != nil {
640 return fmt.Errorf("failed to update preferred large model: %w", err)
641 }
642 }
643 } else {
644 if largeModelSelected.MaxTokens > 0 {
645 large.MaxTokens = largeModelSelected.MaxTokens
646 } else {
647 large.MaxTokens = model.DefaultMaxTokens
648 }
649 if largeModelSelected.ReasoningEffort != "" {
650 large.ReasoningEffort = largeModelSelected.ReasoningEffort
651 }
652 large.Think = largeModelSelected.Think
653 if largeModelSelected.Temperature != nil {
654 large.Temperature = largeModelSelected.Temperature
655 }
656 if largeModelSelected.TopP != nil {
657 large.TopP = largeModelSelected.TopP
658 }
659 if largeModelSelected.TopK != nil {
660 large.TopK = largeModelSelected.TopK
661 }
662 if largeModelSelected.FrequencyPenalty != nil {
663 large.FrequencyPenalty = largeModelSelected.FrequencyPenalty
664 }
665 if largeModelSelected.PresencePenalty != nil {
666 large.PresencePenalty = largeModelSelected.PresencePenalty
667 }
668 }
669 }
670 smallModelSelected, smallModelConfigured := c.Models[SelectedModelTypeSmall]
671 if smallModelConfigured {
672 if smallModelSelected.Model != "" {
673 small.Model = smallModelSelected.Model
674 }
675 if smallModelSelected.Provider != "" {
676 small.Provider = smallModelSelected.Provider
677 }
678
679 model := c.GetModel(small.Provider, small.Model)
680 if model == nil {
681 small = defaultSmall
682 if persist {
683 if err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, small); err != nil {
684 return fmt.Errorf("failed to update preferred small model: %w", err)
685 }
686 }
687 } else {
688 if smallModelSelected.MaxTokens > 0 {
689 small.MaxTokens = smallModelSelected.MaxTokens
690 } else {
691 small.MaxTokens = model.DefaultMaxTokens
692 }
693 if smallModelSelected.ReasoningEffort != "" {
694 small.ReasoningEffort = smallModelSelected.ReasoningEffort
695 }
696 if smallModelSelected.Temperature != nil {
697 small.Temperature = smallModelSelected.Temperature
698 }
699 if smallModelSelected.TopP != nil {
700 small.TopP = smallModelSelected.TopP
701 }
702 if smallModelSelected.TopK != nil {
703 small.TopK = smallModelSelected.TopK
704 }
705 if smallModelSelected.FrequencyPenalty != nil {
706 small.FrequencyPenalty = smallModelSelected.FrequencyPenalty
707 }
708 if smallModelSelected.PresencePenalty != nil {
709 small.PresencePenalty = smallModelSelected.PresencePenalty
710 }
711 small.Think = smallModelSelected.Think
712 }
713 }
714
715 // When small isn't explicitly configured and the provider isn't a
716 // known built-in, use the large model as the small model. This
717 // prevents two different models from being requested concurrently
718 // for local/openai-compat providers.
719 if !smallModelConfigured {
720 isKnownProvider := false
721 for _, kp := range knownProviders {
722 if string(kp.ID) == small.Provider {
723 isKnownProvider = true
724 break
725 }
726 }
727 if !isKnownProvider {
728 slog.Warn("Using large model as small model for unknown provider", "provider", large.Provider, "model", large.Model)
729 small = large
730 }
731 }
732
733 c.Models[SelectedModelTypeLarge] = large
734 c.Models[SelectedModelTypeSmall] = small
735 return nil
736}
737
738// lookupConfigs searches config files starting at cwd and walking up
739// through the current project. The upward walk stops at the git
740// working tree root when one can be detected, otherwise at cwd itself,
741// so an unrelated crush.json placed above the project is never picked
742// up. Global user-level config locations are always included
743// regardless of the boundary.
744func lookupConfigs(cwd string) []string {
745 // prepend default config paths
746 configPaths := []string{
747 GlobalConfig(),
748 GlobalConfigData(),
749 }
750
751 configNames := []string{appName + ".json", "." + appName + ".json"}
752
753 foundConfigs, err := fsext.LookupBounded(cwd, projectBoundary(cwd), configNames...)
754 if err != nil {
755 // returns at least default configs
756 return configPaths
757 }
758
759 // reverse order so last config has more priority
760 slices.Reverse(foundConfigs)
761
762 return append(configPaths, foundConfigs...)
763}
764
765func loadFromConfigPaths(configPaths []string) (*Config, []string, error) {
766 var configs [][]byte
767 var loaded []string
768
769 for _, path := range configPaths {
770 data, err := os.ReadFile(path)
771 if err != nil {
772 if os.IsNotExist(err) {
773 continue
774 }
775 return nil, nil, fmt.Errorf("failed to open config file %s: %w", path, err)
776 }
777 if len(data) == 0 {
778 continue
779 }
780 if !json.Valid(data) {
781 return nil, nil, fmt.Errorf("invalid JSON in config file %s", path)
782 }
783 configs = append(configs, data)
784 loaded = append(loaded, path)
785 }
786
787 cfg, err := loadFromBytes(configs)
788 if err != nil {
789 return nil, nil, err
790 }
791 return cfg, loaded, nil
792}
793
794func loadFromBytes(configs [][]byte) (*Config, error) {
795 if len(configs) == 0 {
796 return &Config{}, nil
797 }
798
799 data, err := jsons.Merge(configs)
800 if err != nil {
801 return nil, err
802 }
803 var config Config
804 if err := json.Unmarshal(data, &config); err != nil {
805 return nil, err
806 }
807 return &config, nil
808}
809
810func hasAWSCredentials(env env.Env) bool {
811 if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" {
812 return true
813 }
814
815 if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" {
816 return true
817 }
818
819 if env.Get("AWS_PROFILE") != "" || env.Get("AWS_DEFAULT_PROFILE") != "" {
820 return true
821 }
822
823 if env.Get("AWS_REGION") != "" || env.Get("AWS_DEFAULT_REGION") != "" {
824 return true
825 }
826
827 if env.Get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
828 env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
829 return true
830 }
831
832 if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil && !testing.Testing() {
833 return true
834 }
835 if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/login")); err == nil && !testing.Testing() {
836 return true
837 }
838
839 return false
840}
841
842// migrateDisableNotifications migrates the deprecated disable_notifications
843// field to notification_style. It checks both the user config (~/.config) and
844// data config (~/.local) files. If disable_notifications is true, it sets
845// notification_style to "disabled" in the data file. Regardless of value, it
846// removes disable_notifications from any file that contains it.
847func migrateDisableNotifications() {
848 globalConfig := GlobalConfig()
849 dataConfig := GlobalConfigData()
850
851 var wasDisabled bool
852 filesToClean := []string{}
853
854 for _, path := range []string{globalConfig, dataConfig} {
855 data, err := os.ReadFile(path)
856 if err != nil {
857 continue
858 }
859 if gjson.Get(string(data), "options.disable_notifications").Exists() {
860 filesToClean = append(filesToClean, path)
861 if gjson.Get(string(data), "options.disable_notifications").Bool() {
862 wasDisabled = true
863 }
864 }
865 }
866
867 if len(filesToClean) == 0 {
868 return
869 }
870
871 // If notifications were disabled, persist the equivalent notification_style.
872 if wasDisabled {
873 data, err := os.ReadFile(dataConfig)
874 if err == nil {
875 if !gjson.Get(string(data), "options.notification_style").Exists() {
876 updated, err := sjson.Set(string(data), "options.notification_style", "disabled")
877 if err == nil {
878 if err := atomicWriteFile(dataConfig, []byte(updated), 0o600); err != nil {
879 slog.Warn("Failed to migrate disable_notifications to notification_style", "error", err)
880 } else {
881 slog.Info("Migrated disable_notifications: true to notification_style: disabled")
882 }
883 }
884 }
885 }
886 }
887
888 // Remove disable_notifications from all files that contain it.
889 for _, path := range filesToClean {
890 data, err := os.ReadFile(path)
891 if err != nil {
892 continue
893 }
894 updated, err := sjson.Delete(string(data), "options.disable_notifications")
895 if err != nil {
896 slog.Warn("Failed to remove deprecated disable_notifications field", "path", path, "error", err)
897 continue
898 }
899 if err := atomicWriteFile(path, []byte(updated), 0o600); err != nil {
900 slog.Warn("Failed to write migrated config", "path", path, "error", err)
901 }
902 }
903}
904
905// GlobalConfig returns the global configuration file path for the application.
906func GlobalConfig() string {
907 if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {
908 return filepath.Join(crushGlobal, fmt.Sprintf("%s.json", appName))
909 }
910 return filepath.Join(home.Config(), appName, fmt.Sprintf("%s.json", appName))
911}
912
913// GlobalCacheDir returns the path to the global cache directory for the
914// application.
915func GlobalCacheDir() string {
916 if crushCache := os.Getenv("CRUSH_CACHE_DIR"); crushCache != "" {
917 return crushCache
918 }
919 if xdgCacheHome := os.Getenv("XDG_CACHE_HOME"); xdgCacheHome != "" {
920 return filepath.Join(xdgCacheHome, appName)
921 }
922 if runtime.GOOS == "windows" {
923 localAppData := cmp.Or(
924 os.Getenv("LOCALAPPDATA"),
925 filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
926 )
927 return filepath.Join(localAppData, appName, "cache")
928 }
929 return filepath.Join(home.Dir(), ".cache", appName)
930}
931
932// ProjectConfigs returns list of current project configs paths.
933func ProjectConfigs(cwd string) []string {
934 return lookupConfigs(cwd)
935}
936
937// GlobalConfigData returns the path to the main data directory for the application.
938// this config is used when the app overrides configurations instead of updating the global config.
939func GlobalConfigData() string {
940 if crushData := os.Getenv("CRUSH_GLOBAL_DATA"); crushData != "" {
941 return filepath.Join(crushData, fmt.Sprintf("%s.json", appName))
942 }
943 if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" {
944 return filepath.Join(xdgDataHome, appName, fmt.Sprintf("%s.json", appName))
945 }
946
947 // return the path to the main data directory
948 // for windows, it should be in `%LOCALAPPDATA%/crush/`
949 // for linux and macOS, it should be in `$HOME/.local/share/crush/`
950 if runtime.GOOS == "windows" {
951 localAppData := cmp.Or(
952 os.Getenv("LOCALAPPDATA"),
953 filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
954 )
955 return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
956 }
957
958 return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
959}
960
961// GlobalWorkspaceDir returns the path to the global server workspace
962// directory. This directory acts as a meta-workspace for the server
963// process, giving it a real workingDir so that config loading, scoped
964// writes, and provider resolution behave identically to project
965// workspaces.
966func GlobalWorkspaceDir() string {
967 return filepath.Dir(GlobalConfigData())
968}
969
970func assignIfNil[T any](ptr **T, val T) {
971 if *ptr == nil {
972 *ptr = &val
973 }
974}
975
976func isInsideWorktree() bool {
977 bts, err := exec.CommandContext(
978 context.Background(),
979 "git", "rev-parse",
980 "--is-inside-work-tree",
981 ).CombinedOutput()
982 return err == nil && strings.TrimSpace(string(bts)) == "true"
983}
984
985// worktreeRoot returns the absolute path of the git working tree root for
986// dir, or the empty string if dir is not inside a working tree (bare
987// repositories, missing git binary, plain directories, or any other
988// failure mode). Linked worktrees and submodules each report their own
989// top-level, which is what callers want when bounding lookups.
990func worktreeRoot(dir string) string {
991 cmd := exec.CommandContext(
992 context.Background(),
993 "git", "rev-parse", "--show-toplevel",
994 )
995 cmd.Dir = dir
996 out, err := cmd.Output()
997 if err != nil {
998 return ""
999 }
1000 root := strings.TrimSpace(string(out))
1001 if root == "" {
1002 return ""
1003 }
1004 abs, err := filepath.Abs(root)
1005 if err != nil {
1006 return ""
1007 }
1008 return abs
1009}
1010
1011// projectBoundary returns the directory at which an upward configuration
1012// search rooted at dir should stop. It is the git working tree root when
1013// one can be detected, otherwise dir itself. Returning dir as a
1014// fallback keeps Crush from silently adopting state files placed above
1015// the current project.
1016func projectBoundary(dir string) string {
1017 if root := worktreeRoot(dir); root != "" {
1018 return root
1019 }
1020 abs, err := filepath.Abs(dir)
1021 if err != nil {
1022 return dir
1023 }
1024 return abs
1025}
1026
1027// GlobalSkillsDirs returns the default directories for Agent Skills.
1028// Skills in these directories are auto-discovered and their files can be read
1029// without permission prompts.
1030func GlobalSkillsDirs() []string {
1031 if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
1032 return []string{crushSkills}
1033 }
1034
1035 paths := []string{
1036 filepath.Join(home.Config(), appName, "skills"),
1037 filepath.Join(home.Config(), "agents", "skills"),
1038 // Per the Agent Skills spec, scan ~/.agents/skills
1039 filepath.Join(home.Dir(), ".agents", "skills"),
1040 filepath.Join(home.Dir(), ".claude", "skills"),
1041 }
1042
1043 // On Windows, also load from app data on top of `$HOME/.config/crush`.
1044 // This is here mostly for backwards compatibility.
1045 if runtime.GOOS == "windows" {
1046 appData := cmp.Or(
1047 os.Getenv("LOCALAPPDATA"),
1048 filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
1049 )
1050 paths = append(
1051 paths,
1052 filepath.Join(appData, appName, "skills"),
1053 filepath.Join(appData, "agents", "skills"),
1054 )
1055 }
1056
1057 return paths
1058}
1059
1060// projectSkillSubdirs lists the conventional subdirectories where
1061// project-level skills are discovered. Shared across working-dir and
1062// git-root lookups to prevent drift when a new convention is added.
1063var projectSkillSubdirs = []string{
1064 ".agents/skills",
1065 ".crush/skills",
1066 ".claude/skills",
1067 ".cursor/skills",
1068}
1069
1070// ProjectSkillsDir returns the default project directories for which Crush
1071// will look for skills. In addition to the working directory, it also
1072// checks the git working tree root so that monorepo-level skills are
1073// discovered when the user is inside a subdirectory.
1074// Working-directory paths come first so local skills take precedence
1075// over monorepo-level ones.
1076func ProjectSkillsDir(workingDir string) []string {
1077 dirs := make([]string, 0, len(projectSkillSubdirs)*2)
1078 for _, sub := range projectSkillSubdirs {
1079 dirs = append(dirs, filepath.Join(workingDir, sub))
1080 }
1081
1082 // When the working directory is inside a git repository, also look at
1083 // the repository root so monorepo-level .agents/skills are found.
1084 if root := worktreeRoot(workingDir); root != "" && root != workingDir {
1085 for _, sub := range projectSkillSubdirs {
1086 dirs = append(dirs, filepath.Join(root, sub))
1087 }
1088 }
1089
1090 return dirs
1091}
1092
1093func isAppleTerminal() bool { return os.Getenv("TERM_PROGRAM") == "Apple_Terminal" }
1094
1095// normalizeHookEvent maps user-provided event names to their canonical
1096// form. Matching is case-insensitive and accepts snake_case variants
1097// (e.g. "pre_tool_use" → "PreToolUse").
1098func normalizeHookEvent(name string) string {
1099 switch strings.ToLower(strings.ReplaceAll(name, "_", "")) {
1100 case "pretooluse":
1101 return "PreToolUse"
1102 default:
1103 return name
1104 }
1105}
1106
1107// ValidateHooks normalizes event names and checks that every configured
1108// hook has a command and a syntactically valid matcher regex. Matcher
1109// compilation used for matching is owned by hooks.Runner; this function
1110// only validates up front so the user sees config errors at load time
1111// rather than on the first tool call.
1112func (c *Config) ValidateHooks() error {
1113 // Normalize event name keys.
1114 for event, eventHooks := range c.Hooks {
1115 canonical := normalizeHookEvent(event)
1116 if canonical != event {
1117 c.Hooks[canonical] = append(c.Hooks[canonical], eventHooks...)
1118 delete(c.Hooks, event)
1119 }
1120 }
1121
1122 for event, eventHooks := range c.Hooks {
1123 for i, h := range eventHooks {
1124 if h.Command == "" {
1125 return fmt.Errorf("hook %s[%d]: command is required", event, i)
1126 }
1127 if h.Matcher == "" {
1128 continue
1129 }
1130 if _, err := regexp.Compile(h.Matcher); err != nil {
1131 return fmt.Errorf("hook %s[%d]: invalid matcher regex %q: %w", event, i, h.Matcher, err)
1132 }
1133 }
1134 }
1135 return nil
1136}