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 "runtime"
14 "slices"
15 "strconv"
16 "strings"
17 "testing"
18
19 "charm.land/catwalk/pkg/catwalk"
20 "github.com/charmbracelet/crush/internal/agent/hyper"
21 "github.com/charmbracelet/crush/internal/csync"
22 "github.com/charmbracelet/crush/internal/env"
23 "github.com/charmbracelet/crush/internal/fsext"
24 "github.com/charmbracelet/crush/internal/home"
25 powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
26 "github.com/qjebbs/go-jsons"
27)
28
29const defaultCatwalkURL = "https://catwalk.charm.sh"
30
31// Load loads the configuration from the default paths and returns a
32// ConfigStore that owns both the pure-data Config and all runtime state.
33func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
34 configPaths := lookupConfigs(workingDir)
35
36 cfg, err := loadFromConfigPaths(configPaths)
37 if err != nil {
38 return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
39 }
40
41 cfg.setDefaults(workingDir, dataDir)
42
43 store := &ConfigStore{
44 config: cfg,
45 workingDir: workingDir,
46 globalDataPath: GlobalConfigData(),
47 workspacePath: filepath.Join(cfg.Options.DataDirectory, fmt.Sprintf("%s.json", appName)),
48 }
49
50 if debug {
51 cfg.Options.Debug = true
52 }
53
54 // Load workspace config last so it has highest priority.
55 if wsData, err := os.ReadFile(store.workspacePath); err == nil && len(wsData) > 0 {
56 merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
57 if mergeErr == nil {
58 // Preserve defaults that setDefaults already applied.
59 dataDir := cfg.Options.DataDirectory
60 *cfg = *merged
61 cfg.setDefaults(workingDir, dataDir)
62 store.config = cfg
63 }
64 }
65
66 if !isInsideWorktree() {
67 const depth = 2
68 const items = 100
69 slog.Warn("No git repository detected in working directory, will limit file walk operations", "depth", depth, "items", items)
70 assignIfNil(&cfg.Tools.Ls.MaxDepth, depth)
71 assignIfNil(&cfg.Tools.Ls.MaxItems, items)
72 assignIfNil(&cfg.Options.TUI.Completions.MaxDepth, depth)
73 assignIfNil(&cfg.Options.TUI.Completions.MaxItems, items)
74 }
75
76 if isAppleTerminal() {
77 slog.Warn("Detected Apple Terminal, enabling transparent mode")
78 assignIfNil(&cfg.Options.TUI.Transparent, true)
79 }
80
81 // Load known providers, this loads the config from catwalk
82 providers, err := Providers(cfg)
83 if err != nil {
84 return nil, err
85 }
86 store.knownProviders = providers
87
88 env := env.New()
89 // Configure providers
90 valueResolver := NewShellVariableResolver(env)
91 store.resolver = valueResolver
92 if err := cfg.configureProviders(store, env, valueResolver, store.knownProviders); err != nil {
93 return nil, fmt.Errorf("failed to configure providers: %w", err)
94 }
95
96 if !cfg.IsConfigured() {
97 slog.Warn("No providers configured")
98 return store, nil
99 }
100
101 if err := configureSelectedModels(store, store.knownProviders); err != nil {
102 return nil, fmt.Errorf("failed to configure selected models: %w", err)
103 }
104 store.SetupAgents()
105 return store, nil
106}
107
108// mustMarshalConfig marshals the config to JSON bytes, returning empty JSON on
109// error.
110func mustMarshalConfig(cfg *Config) []byte {
111 data, err := json.Marshal(cfg)
112 if err != nil {
113 return []byte("{}")
114 }
115 return data
116}
117
118func PushPopCrushEnv() func() {
119 var found []string
120 for _, ev := range os.Environ() {
121 if strings.HasPrefix(ev, "CRUSH_") {
122 pair := strings.SplitN(ev, "=", 2)
123 if len(pair) != 2 {
124 continue
125 }
126 found = append(found, strings.TrimPrefix(pair[0], "CRUSH_"))
127 }
128 }
129 backups := make(map[string]string)
130 for _, ev := range found {
131 backups[ev] = os.Getenv(ev)
132 }
133
134 for _, ev := range found {
135 os.Setenv(ev, os.Getenv("CRUSH_"+ev))
136 }
137
138 restore := func() {
139 for k, v := range backups {
140 os.Setenv(k, v)
141 }
142 }
143 return restore
144}
145
146func (c *Config) configureProviders(store *ConfigStore, env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
147 knownProviderNames := make(map[string]bool)
148 restore := PushPopCrushEnv()
149 defer restore()
150
151 // When disable_default_providers is enabled, skip all default/embedded
152 // providers entirely. Users must fully specify any providers they want.
153 // We skip to the custom provider validation loop which handles all
154 // user-configured providers uniformly.
155 if c.Options.DisableDefaultProviders {
156 knownProviders = nil
157 }
158
159 for _, p := range knownProviders {
160 knownProviderNames[string(p.ID)] = true
161 config, configExists := c.Providers.Get(string(p.ID))
162 // if the user configured a known provider we need to allow it to override a couple of parameters
163 if configExists {
164 if config.BaseURL != "" {
165 p.APIEndpoint = config.BaseURL
166 }
167 if config.APIKey != "" {
168 p.APIKey = config.APIKey
169 }
170 if len(config.Models) > 0 {
171 models := []catwalk.Model{}
172 seen := make(map[string]bool)
173
174 for _, model := range config.Models {
175 if seen[model.ID] {
176 continue
177 }
178 seen[model.ID] = true
179 if model.Name == "" {
180 model.Name = model.ID
181 }
182 models = append(models, model)
183 }
184 for _, model := range p.Models {
185 if seen[model.ID] {
186 continue
187 }
188 seen[model.ID] = true
189 if model.Name == "" {
190 model.Name = model.ID
191 }
192 models = append(models, model)
193 }
194
195 p.Models = models
196 }
197 }
198
199 headers := map[string]string{}
200 if len(p.DefaultHeaders) > 0 {
201 maps.Copy(headers, p.DefaultHeaders)
202 }
203 if len(config.ExtraHeaders) > 0 {
204 maps.Copy(headers, config.ExtraHeaders)
205 }
206 for k, v := range headers {
207 resolved, err := resolver.ResolveValue(v)
208 if err != nil {
209 slog.Error("Could not resolve provider header", "err", err.Error())
210 continue
211 }
212 headers[k] = resolved
213 }
214 prepared := ProviderConfig{
215 ID: string(p.ID),
216 Name: p.Name,
217 BaseURL: p.APIEndpoint,
218 APIKey: p.APIKey,
219 APIKeyTemplate: p.APIKey, // Store original template for re-resolution
220 OAuthToken: config.OAuthToken,
221 Type: p.Type,
222 Disable: config.Disable,
223 SystemPromptPrefix: config.SystemPromptPrefix,
224 ExtraHeaders: headers,
225 ExtraBody: config.ExtraBody,
226 ExtraParams: make(map[string]string),
227 Models: p.Models,
228 }
229
230 switch {
231 case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil:
232 // Claude Code subscription is not supported anymore. Remove to show onboarding.
233 store.RemoveConfigField(ScopeGlobal, "providers.anthropic")
234 c.Providers.Del(string(p.ID))
235 continue
236 case p.ID == catwalk.InferenceProviderCopilot && config.OAuthToken != nil:
237 prepared.SetupGitHubCopilot()
238 }
239
240 switch p.ID {
241 // Handle specific providers that require additional configuration
242 case catwalk.InferenceProviderVertexAI:
243 var (
244 project = env.Get("VERTEXAI_PROJECT")
245 location = env.Get("VERTEXAI_LOCATION")
246 )
247 if project == "" || location == "" {
248 if configExists {
249 slog.Warn("Skipping Vertex AI provider due to missing credentials")
250 c.Providers.Del(string(p.ID))
251 }
252 continue
253 }
254 prepared.ExtraParams["project"] = project
255 prepared.ExtraParams["location"] = location
256 case catwalk.InferenceProviderAzure:
257 endpoint, err := resolver.ResolveValue(p.APIEndpoint)
258 if err != nil || endpoint == "" {
259 if configExists {
260 slog.Warn("Skipping Azure provider due to missing API endpoint", "provider", p.ID, "error", err)
261 c.Providers.Del(string(p.ID))
262 }
263 continue
264 }
265 prepared.BaseURL = endpoint
266 prepared.ExtraParams["apiVersion"] = env.Get("AZURE_OPENAI_API_VERSION")
267 case catwalk.InferenceProviderBedrock:
268 if !hasAWSCredentials(env) {
269 if configExists {
270 slog.Warn("Skipping Bedrock provider due to missing AWS credentials")
271 c.Providers.Del(string(p.ID))
272 }
273 continue
274 }
275 prepared.ExtraParams["region"] = env.Get("AWS_REGION")
276 if prepared.ExtraParams["region"] == "" {
277 prepared.ExtraParams["region"] = env.Get("AWS_DEFAULT_REGION")
278 }
279 for _, model := range p.Models {
280 if !strings.HasPrefix(model.ID, "anthropic.") {
281 return fmt.Errorf("bedrock provider only supports anthropic models for now, found: %s", model.ID)
282 }
283 }
284 default:
285 // if the provider api or endpoint are missing we skip them
286 v, err := resolver.ResolveValue(p.APIKey)
287 if v == "" || err != nil {
288 if configExists {
289 slog.Warn("Skipping provider due to missing API key", "provider", p.ID)
290 c.Providers.Del(string(p.ID))
291 }
292 continue
293 }
294 }
295 c.Providers.Set(string(p.ID), prepared)
296 }
297
298 // validate the custom providers
299 for id, providerConfig := range c.Providers.Seq2() {
300 if knownProviderNames[id] {
301 continue
302 }
303
304 // Make sure the provider ID is set
305 providerConfig.ID = id
306 providerConfig.Name = cmp.Or(providerConfig.Name, id) // Use ID as name if not set
307 // default to OpenAI if not set
308 providerConfig.Type = cmp.Or(providerConfig.Type, catwalk.TypeOpenAICompat)
309 if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) && providerConfig.Type != hyper.Name {
310 slog.Warn("Skipping custom provider due to unsupported provider type", "provider", id)
311 c.Providers.Del(id)
312 continue
313 }
314
315 if providerConfig.Disable {
316 slog.Debug("Skipping custom provider due to disable flag", "provider", id)
317 c.Providers.Del(id)
318 continue
319 }
320 if providerConfig.APIKey == "" {
321 slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id)
322 }
323 if providerConfig.BaseURL == "" {
324 slog.Warn("Skipping custom provider due to missing API endpoint", "provider", id)
325 c.Providers.Del(id)
326 continue
327 }
328 if len(providerConfig.Models) == 0 {
329 slog.Warn("Skipping custom provider because the provider has no models", "provider", id)
330 c.Providers.Del(id)
331 continue
332 }
333 apiKey, err := resolver.ResolveValue(providerConfig.APIKey)
334 if apiKey == "" || err != nil {
335 slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id)
336 }
337 baseURL, err := resolver.ResolveValue(providerConfig.BaseURL)
338 if baseURL == "" || err != nil {
339 slog.Warn("Skipping custom provider due to missing API endpoint", "provider", id, "error", err)
340 c.Providers.Del(id)
341 continue
342 }
343
344 for k, v := range providerConfig.ExtraHeaders {
345 resolved, err := resolver.ResolveValue(v)
346 if err != nil {
347 slog.Error("Could not resolve provider header", "err", err.Error())
348 continue
349 }
350 providerConfig.ExtraHeaders[k] = resolved
351 }
352
353 c.Providers.Set(id, providerConfig)
354 }
355
356 if c.Providers.Len() == 0 && c.Options.DisableDefaultProviders {
357 return fmt.Errorf("default providers are disabled and there are no custom providers are configured")
358 }
359
360 return nil
361}
362
363func (c *Config) setDefaults(workingDir, dataDir string) {
364 if c.Options == nil {
365 c.Options = &Options{}
366 }
367 if c.Options.TUI == nil {
368 c.Options.TUI = &TUIOptions{}
369 }
370 if dataDir != "" {
371 c.Options.DataDirectory = dataDir
372 } else if c.Options.DataDirectory == "" {
373 if path, ok := fsext.LookupClosest(workingDir, defaultDataDirectory); ok {
374 c.Options.DataDirectory = path
375 } else {
376 c.Options.DataDirectory = filepath.Join(workingDir, defaultDataDirectory)
377 }
378 }
379 if c.Providers == nil {
380 c.Providers = csync.NewMap[string, ProviderConfig]()
381 }
382 if c.Models == nil {
383 c.Models = make(map[SelectedModelType]SelectedModel)
384 }
385 if c.RecentModels == nil {
386 c.RecentModels = make(map[SelectedModelType][]SelectedModel)
387 }
388 if c.MCP == nil {
389 c.MCP = make(map[string]MCPConfig)
390 }
391 if c.LSP == nil {
392 c.LSP = make(map[string]LSPConfig)
393 }
394
395 // Apply defaults to LSP configurations
396 c.applyLSPDefaults()
397
398 // Add the default context paths if they are not already present
399 c.Options.ContextPaths = append(defaultContextPaths, c.Options.ContextPaths...)
400 slices.Sort(c.Options.ContextPaths)
401 c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
402
403 // Add the default skills directories if not already present.
404 for _, dir := range GlobalSkillsDirs() {
405 if !slices.Contains(c.Options.SkillsPaths, dir) {
406 c.Options.SkillsPaths = append(c.Options.SkillsPaths, dir)
407 }
408 }
409
410 // Project specific skills dirs.
411 c.Options.SkillsPaths = append(c.Options.SkillsPaths, ProjectSkillsDir(workingDir)...)
412
413 if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
414 c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
415 }
416
417 if str, ok := os.LookupEnv("CRUSH_DISABLE_DEFAULT_PROVIDERS"); ok {
418 c.Options.DisableDefaultProviders, _ = strconv.ParseBool(str)
419 }
420
421 if c.Options.Attribution == nil {
422 c.Options.Attribution = &Attribution{
423 TrailerStyle: TrailerStyleAssistedBy,
424 GeneratedWith: true,
425 }
426 } else if c.Options.Attribution.TrailerStyle == "" {
427 // Migrate deprecated co_authored_by or apply default
428 if c.Options.Attribution.CoAuthoredBy != nil {
429 if *c.Options.Attribution.CoAuthoredBy {
430 c.Options.Attribution.TrailerStyle = TrailerStyleCoAuthoredBy
431 } else {
432 c.Options.Attribution.TrailerStyle = TrailerStyleNone
433 }
434 } else {
435 c.Options.Attribution.TrailerStyle = TrailerStyleAssistedBy
436 }
437 }
438 c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs)
439}
440
441// applyLSPDefaults applies default values from powernap to LSP configurations
442func (c *Config) applyLSPDefaults() {
443 // Get powernap's default configuration
444 configManager := powernapConfig.NewManager()
445 configManager.LoadDefaults()
446
447 // Apply defaults to each LSP configuration
448 for name, cfg := range c.LSP {
449 // Try to get defaults from powernap based on name or command name.
450 base, ok := configManager.GetServer(name)
451 if !ok {
452 base, ok = configManager.GetServer(cfg.Command)
453 if !ok {
454 continue
455 }
456 }
457 if cfg.Options == nil {
458 cfg.Options = base.Settings
459 }
460 if cfg.InitOptions == nil {
461 cfg.InitOptions = base.InitOptions
462 }
463 if len(cfg.FileTypes) == 0 {
464 cfg.FileTypes = base.FileTypes
465 }
466 if len(cfg.RootMarkers) == 0 {
467 cfg.RootMarkers = base.RootMarkers
468 }
469 cfg.Command = cmp.Or(cfg.Command, base.Command)
470 if len(cfg.Args) == 0 {
471 cfg.Args = base.Args
472 }
473 if len(cfg.Env) == 0 {
474 cfg.Env = base.Environment
475 }
476 // Update the config in the map
477 c.LSP[name] = cfg
478 }
479}
480
481func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
482 if len(knownProviders) == 0 && c.Providers.Len() == 0 {
483 err = fmt.Errorf("no providers configured, please configure at least one provider")
484 return largeModel, smallModel, err
485 }
486
487 // Use the first provider enabled based on the known providers order
488 // if no provider found that is known use the first provider configured
489 for _, p := range knownProviders {
490 providerConfig, ok := c.Providers.Get(string(p.ID))
491 if !ok || providerConfig.Disable {
492 continue
493 }
494 defaultLargeModel := c.GetModel(string(p.ID), p.DefaultLargeModelID)
495 if defaultLargeModel == nil {
496 err = fmt.Errorf("default large model %s not found for provider %s", p.DefaultLargeModelID, p.ID)
497 return largeModel, smallModel, err
498 }
499 largeModel = SelectedModel{
500 Provider: string(p.ID),
501 Model: defaultLargeModel.ID,
502 MaxTokens: defaultLargeModel.DefaultMaxTokens,
503 ReasoningEffort: defaultLargeModel.DefaultReasoningEffort,
504 }
505
506 defaultSmallModel := c.GetModel(string(p.ID), p.DefaultSmallModelID)
507 if defaultSmallModel == nil {
508 err = fmt.Errorf("default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
509 return largeModel, smallModel, err
510 }
511 smallModel = SelectedModel{
512 Provider: string(p.ID),
513 Model: defaultSmallModel.ID,
514 MaxTokens: defaultSmallModel.DefaultMaxTokens,
515 ReasoningEffort: defaultSmallModel.DefaultReasoningEffort,
516 }
517 return largeModel, smallModel, err
518 }
519
520 enabledProviders := c.EnabledProviders()
521 slices.SortFunc(enabledProviders, func(a, b ProviderConfig) int {
522 return strings.Compare(a.ID, b.ID)
523 })
524
525 if len(enabledProviders) == 0 {
526 err = fmt.Errorf("no providers configured, please configure at least one provider")
527 return largeModel, smallModel, err
528 }
529
530 providerConfig := enabledProviders[0]
531 if len(providerConfig.Models) == 0 {
532 err = fmt.Errorf("provider %s has no models configured", providerConfig.ID)
533 return largeModel, smallModel, err
534 }
535 defaultLargeModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
536 largeModel = SelectedModel{
537 Provider: providerConfig.ID,
538 Model: defaultLargeModel.ID,
539 MaxTokens: defaultLargeModel.DefaultMaxTokens,
540 }
541 defaultSmallModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
542 smallModel = SelectedModel{
543 Provider: providerConfig.ID,
544 Model: defaultSmallModel.ID,
545 MaxTokens: defaultSmallModel.DefaultMaxTokens,
546 }
547 return largeModel, smallModel, err
548}
549
550func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provider) error {
551 c := store.config
552 defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
553 if err != nil {
554 return fmt.Errorf("failed to select default models: %w", err)
555 }
556 large, small := defaultLarge, defaultSmall
557
558 largeModelSelected, largeModelConfigured := c.Models[SelectedModelTypeLarge]
559 if largeModelConfigured {
560 if largeModelSelected.Model != "" {
561 large.Model = largeModelSelected.Model
562 }
563 if largeModelSelected.Provider != "" {
564 large.Provider = largeModelSelected.Provider
565 }
566 model := c.GetModel(large.Provider, large.Model)
567 if model == nil {
568 large = defaultLarge
569 // override the model type to large
570 err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, large)
571 if err != nil {
572 return fmt.Errorf("failed to update preferred large model: %w", err)
573 }
574 } else {
575 if largeModelSelected.MaxTokens > 0 {
576 large.MaxTokens = largeModelSelected.MaxTokens
577 } else {
578 large.MaxTokens = model.DefaultMaxTokens
579 }
580 if largeModelSelected.ReasoningEffort != "" {
581 large.ReasoningEffort = largeModelSelected.ReasoningEffort
582 }
583 large.Think = largeModelSelected.Think
584 if largeModelSelected.Temperature != nil {
585 large.Temperature = largeModelSelected.Temperature
586 }
587 if largeModelSelected.TopP != nil {
588 large.TopP = largeModelSelected.TopP
589 }
590 if largeModelSelected.TopK != nil {
591 large.TopK = largeModelSelected.TopK
592 }
593 if largeModelSelected.FrequencyPenalty != nil {
594 large.FrequencyPenalty = largeModelSelected.FrequencyPenalty
595 }
596 if largeModelSelected.PresencePenalty != nil {
597 large.PresencePenalty = largeModelSelected.PresencePenalty
598 }
599 }
600 }
601 smallModelSelected, smallModelConfigured := c.Models[SelectedModelTypeSmall]
602 if smallModelConfigured {
603 if smallModelSelected.Model != "" {
604 small.Model = smallModelSelected.Model
605 }
606 if smallModelSelected.Provider != "" {
607 small.Provider = smallModelSelected.Provider
608 }
609
610 model := c.GetModel(small.Provider, small.Model)
611 if model == nil {
612 small = defaultSmall
613 // override the model type to small
614 err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, small)
615 if err != nil {
616 return fmt.Errorf("failed to update preferred small model: %w", err)
617 }
618 } else {
619 if smallModelSelected.MaxTokens > 0 {
620 small.MaxTokens = smallModelSelected.MaxTokens
621 } else {
622 small.MaxTokens = model.DefaultMaxTokens
623 }
624 if smallModelSelected.ReasoningEffort != "" {
625 small.ReasoningEffort = smallModelSelected.ReasoningEffort
626 }
627 if smallModelSelected.Temperature != nil {
628 small.Temperature = smallModelSelected.Temperature
629 }
630 if smallModelSelected.TopP != nil {
631 small.TopP = smallModelSelected.TopP
632 }
633 if smallModelSelected.TopK != nil {
634 small.TopK = smallModelSelected.TopK
635 }
636 if smallModelSelected.FrequencyPenalty != nil {
637 small.FrequencyPenalty = smallModelSelected.FrequencyPenalty
638 }
639 if smallModelSelected.PresencePenalty != nil {
640 small.PresencePenalty = smallModelSelected.PresencePenalty
641 }
642 small.Think = smallModelSelected.Think
643 }
644 }
645 c.Models[SelectedModelTypeLarge] = large
646 c.Models[SelectedModelTypeSmall] = small
647 return nil
648}
649
650// lookupConfigs searches config files recursively from CWD up to FS root
651func lookupConfigs(cwd string) []string {
652 // prepend default config paths
653 configPaths := []string{
654 GlobalConfig(),
655 GlobalConfigData(),
656 }
657
658 configNames := []string{appName + ".json", "." + appName + ".json"}
659
660 foundConfigs, err := fsext.Lookup(cwd, configNames...)
661 if err != nil {
662 // returns at least default configs
663 return configPaths
664 }
665
666 // reverse order so last config has more priority
667 slices.Reverse(foundConfigs)
668
669 return append(configPaths, foundConfigs...)
670}
671
672func loadFromConfigPaths(configPaths []string) (*Config, error) {
673 var configs [][]byte
674
675 for _, path := range configPaths {
676 data, err := os.ReadFile(path)
677 if err != nil {
678 if os.IsNotExist(err) {
679 continue
680 }
681 return nil, fmt.Errorf("failed to open config file %s: %w", path, err)
682 }
683 if len(data) == 0 {
684 continue
685 }
686 configs = append(configs, data)
687 }
688
689 return loadFromBytes(configs)
690}
691
692func loadFromBytes(configs [][]byte) (*Config, error) {
693 if len(configs) == 0 {
694 return &Config{}, nil
695 }
696
697 data, err := jsons.Merge(configs)
698 if err != nil {
699 return nil, err
700 }
701 var config Config
702 if err := json.Unmarshal(data, &config); err != nil {
703 return nil, err
704 }
705 return &config, nil
706}
707
708func hasAWSCredentials(env env.Env) bool {
709 if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" {
710 return true
711 }
712
713 if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" {
714 return true
715 }
716
717 if env.Get("AWS_PROFILE") != "" || env.Get("AWS_DEFAULT_PROFILE") != "" {
718 return true
719 }
720
721 if env.Get("AWS_REGION") != "" || env.Get("AWS_DEFAULT_REGION") != "" {
722 return true
723 }
724
725 if env.Get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
726 env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
727 return true
728 }
729
730 if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil && !testing.Testing() {
731 return true
732 }
733
734 return false
735}
736
737// GlobalConfig returns the global configuration file path for the application.
738func GlobalConfig() string {
739 if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {
740 return filepath.Join(crushGlobal, fmt.Sprintf("%s.json", appName))
741 }
742 return filepath.Join(home.Config(), appName, fmt.Sprintf("%s.json", appName))
743}
744
745// GlobalCacheDir returns the path to the global cache directory for the
746// application.
747func GlobalCacheDir() string {
748 if crushCache := os.Getenv("CRUSH_CACHE_DIR"); crushCache != "" {
749 return crushCache
750 }
751 if xdgCacheHome := os.Getenv("XDG_CACHE_HOME"); xdgCacheHome != "" {
752 return filepath.Join(xdgCacheHome, appName)
753 }
754 if runtime.GOOS == "windows" {
755 localAppData := cmp.Or(
756 os.Getenv("LOCALAPPDATA"),
757 filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
758 )
759 return filepath.Join(localAppData, appName, "cache")
760 }
761 return filepath.Join(home.Dir(), ".cache", appName)
762}
763
764// GlobalConfigData returns the path to the main data directory for the application.
765// this config is used when the app overrides configurations instead of updating the global config.
766func GlobalConfigData() string {
767 if crushData := os.Getenv("CRUSH_GLOBAL_DATA"); crushData != "" {
768 return filepath.Join(crushData, fmt.Sprintf("%s.json", appName))
769 }
770 if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" {
771 return filepath.Join(xdgDataHome, appName, fmt.Sprintf("%s.json", appName))
772 }
773
774 // return the path to the main data directory
775 // for windows, it should be in `%LOCALAPPDATA%/crush/`
776 // for linux and macOS, it should be in `$HOME/.local/share/crush/`
777 if runtime.GOOS == "windows" {
778 localAppData := cmp.Or(
779 os.Getenv("LOCALAPPDATA"),
780 filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
781 )
782 return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
783 }
784
785 return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
786}
787
788// GlobalWorkspaceDir returns the path to the global server workspace
789// directory. This directory acts as a meta-workspace for the server
790// process, giving it a real workingDir so that config loading, scoped
791// writes, and provider resolution behave identically to project
792// workspaces.
793func GlobalWorkspaceDir() string {
794 return filepath.Dir(GlobalConfigData())
795}
796
797func assignIfNil[T any](ptr **T, val T) {
798 if *ptr == nil {
799 *ptr = &val
800 }
801}
802
803func isInsideWorktree() bool {
804 bts, err := exec.CommandContext(
805 context.Background(),
806 "git", "rev-parse",
807 "--is-inside-work-tree",
808 ).CombinedOutput()
809 return err == nil && strings.TrimSpace(string(bts)) == "true"
810}
811
812// GlobalSkillsDirs returns the default directories for Agent Skills.
813// Skills in these directories are auto-discovered and their files can be read
814// without permission prompts.
815func GlobalSkillsDirs() []string {
816 if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
817 return []string{crushSkills}
818 }
819
820 paths := []string{
821 filepath.Join(home.Config(), appName, "skills"),
822 filepath.Join(home.Config(), "agents", "skills"),
823 }
824
825 // On Windows, also load from app data on top of `$HOME/.config/crush`.
826 // This is here mostly for backwards compatibility.
827 if runtime.GOOS == "windows" {
828 appData := cmp.Or(
829 os.Getenv("LOCALAPPDATA"),
830 filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
831 )
832 paths = append(
833 paths,
834 filepath.Join(appData, appName, "skills"),
835 filepath.Join(appData, "agents", "skills"),
836 )
837 }
838
839 return paths
840}
841
842// ProjectSkillsDir returns the default project directories for which Crush
843// will look for skills.
844func ProjectSkillsDir(workingDir string) []string {
845 return []string{
846 filepath.Join(workingDir, ".agents/skills"),
847 filepath.Join(workingDir, ".crush/skills"),
848 filepath.Join(workingDir, ".claude/skills"),
849 filepath.Join(workingDir, ".cursor/skills"),
850 }
851}
852
853func isAppleTerminal() bool { return os.Getenv("TERM_PROGRAM") == "Apple_Terminal" }