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 if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
411 c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
412 }
413
414 if str, ok := os.LookupEnv("CRUSH_DISABLE_DEFAULT_PROVIDERS"); ok {
415 c.Options.DisableDefaultProviders, _ = strconv.ParseBool(str)
416 }
417
418 if c.Options.Attribution == nil {
419 c.Options.Attribution = &Attribution{
420 TrailerStyle: TrailerStyleAssistedBy,
421 GeneratedWith: true,
422 }
423 } else if c.Options.Attribution.TrailerStyle == "" {
424 // Migrate deprecated co_authored_by or apply default
425 if c.Options.Attribution.CoAuthoredBy != nil {
426 if *c.Options.Attribution.CoAuthoredBy {
427 c.Options.Attribution.TrailerStyle = TrailerStyleCoAuthoredBy
428 } else {
429 c.Options.Attribution.TrailerStyle = TrailerStyleNone
430 }
431 } else {
432 c.Options.Attribution.TrailerStyle = TrailerStyleAssistedBy
433 }
434 }
435 c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs)
436}
437
438// applyLSPDefaults applies default values from powernap to LSP configurations
439func (c *Config) applyLSPDefaults() {
440 // Get powernap's default configuration
441 configManager := powernapConfig.NewManager()
442 configManager.LoadDefaults()
443
444 // Apply defaults to each LSP configuration
445 for name, cfg := range c.LSP {
446 // Try to get defaults from powernap based on name or command name.
447 base, ok := configManager.GetServer(name)
448 if !ok {
449 base, ok = configManager.GetServer(cfg.Command)
450 if !ok {
451 continue
452 }
453 }
454 if cfg.Options == nil {
455 cfg.Options = base.Settings
456 }
457 if cfg.InitOptions == nil {
458 cfg.InitOptions = base.InitOptions
459 }
460 if len(cfg.FileTypes) == 0 {
461 cfg.FileTypes = base.FileTypes
462 }
463 if len(cfg.RootMarkers) == 0 {
464 cfg.RootMarkers = base.RootMarkers
465 }
466 cfg.Command = cmp.Or(cfg.Command, base.Command)
467 if len(cfg.Args) == 0 {
468 cfg.Args = base.Args
469 }
470 if len(cfg.Env) == 0 {
471 cfg.Env = base.Environment
472 }
473 // Update the config in the map
474 c.LSP[name] = cfg
475 }
476}
477
478func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
479 if len(knownProviders) == 0 && c.Providers.Len() == 0 {
480 err = fmt.Errorf("no providers configured, please configure at least one provider")
481 return largeModel, smallModel, err
482 }
483
484 // Use the first provider enabled based on the known providers order
485 // if no provider found that is known use the first provider configured
486 for _, p := range knownProviders {
487 providerConfig, ok := c.Providers.Get(string(p.ID))
488 if !ok || providerConfig.Disable {
489 continue
490 }
491 defaultLargeModel := c.GetModel(string(p.ID), p.DefaultLargeModelID)
492 if defaultLargeModel == nil {
493 err = fmt.Errorf("default large model %s not found for provider %s", p.DefaultLargeModelID, p.ID)
494 return largeModel, smallModel, err
495 }
496 largeModel = SelectedModel{
497 Provider: string(p.ID),
498 Model: defaultLargeModel.ID,
499 MaxTokens: defaultLargeModel.DefaultMaxTokens,
500 ReasoningEffort: defaultLargeModel.DefaultReasoningEffort,
501 }
502
503 defaultSmallModel := c.GetModel(string(p.ID), p.DefaultSmallModelID)
504 if defaultSmallModel == nil {
505 err = fmt.Errorf("default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
506 return largeModel, smallModel, err
507 }
508 smallModel = SelectedModel{
509 Provider: string(p.ID),
510 Model: defaultSmallModel.ID,
511 MaxTokens: defaultSmallModel.DefaultMaxTokens,
512 ReasoningEffort: defaultSmallModel.DefaultReasoningEffort,
513 }
514 return largeModel, smallModel, err
515 }
516
517 enabledProviders := c.EnabledProviders()
518 slices.SortFunc(enabledProviders, func(a, b ProviderConfig) int {
519 return strings.Compare(a.ID, b.ID)
520 })
521
522 if len(enabledProviders) == 0 {
523 err = fmt.Errorf("no providers configured, please configure at least one provider")
524 return largeModel, smallModel, err
525 }
526
527 providerConfig := enabledProviders[0]
528 if len(providerConfig.Models) == 0 {
529 err = fmt.Errorf("provider %s has no models configured", providerConfig.ID)
530 return largeModel, smallModel, err
531 }
532 defaultLargeModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
533 largeModel = SelectedModel{
534 Provider: providerConfig.ID,
535 Model: defaultLargeModel.ID,
536 MaxTokens: defaultLargeModel.DefaultMaxTokens,
537 }
538 defaultSmallModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
539 smallModel = SelectedModel{
540 Provider: providerConfig.ID,
541 Model: defaultSmallModel.ID,
542 MaxTokens: defaultSmallModel.DefaultMaxTokens,
543 }
544 return largeModel, smallModel, err
545}
546
547func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provider) error {
548 c := store.config
549 defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
550 if err != nil {
551 return fmt.Errorf("failed to select default models: %w", err)
552 }
553 large, small := defaultLarge, defaultSmall
554
555 largeModelSelected, largeModelConfigured := c.Models[SelectedModelTypeLarge]
556 if largeModelConfigured {
557 if largeModelSelected.Model != "" {
558 large.Model = largeModelSelected.Model
559 }
560 if largeModelSelected.Provider != "" {
561 large.Provider = largeModelSelected.Provider
562 }
563 model := c.GetModel(large.Provider, large.Model)
564 if model == nil {
565 large = defaultLarge
566 // override the model type to large
567 err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, large)
568 if err != nil {
569 return fmt.Errorf("failed to update preferred large model: %w", err)
570 }
571 } else {
572 if largeModelSelected.MaxTokens > 0 {
573 large.MaxTokens = largeModelSelected.MaxTokens
574 } else {
575 large.MaxTokens = model.DefaultMaxTokens
576 }
577 if largeModelSelected.ReasoningEffort != "" {
578 large.ReasoningEffort = largeModelSelected.ReasoningEffort
579 }
580 large.Think = largeModelSelected.Think
581 if largeModelSelected.Temperature != nil {
582 large.Temperature = largeModelSelected.Temperature
583 }
584 if largeModelSelected.TopP != nil {
585 large.TopP = largeModelSelected.TopP
586 }
587 if largeModelSelected.TopK != nil {
588 large.TopK = largeModelSelected.TopK
589 }
590 if largeModelSelected.FrequencyPenalty != nil {
591 large.FrequencyPenalty = largeModelSelected.FrequencyPenalty
592 }
593 if largeModelSelected.PresencePenalty != nil {
594 large.PresencePenalty = largeModelSelected.PresencePenalty
595 }
596 }
597 }
598 smallModelSelected, smallModelConfigured := c.Models[SelectedModelTypeSmall]
599 if smallModelConfigured {
600 if smallModelSelected.Model != "" {
601 small.Model = smallModelSelected.Model
602 }
603 if smallModelSelected.Provider != "" {
604 small.Provider = smallModelSelected.Provider
605 }
606
607 model := c.GetModel(small.Provider, small.Model)
608 if model == nil {
609 small = defaultSmall
610 // override the model type to small
611 err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, small)
612 if err != nil {
613 return fmt.Errorf("failed to update preferred small model: %w", err)
614 }
615 } else {
616 if smallModelSelected.MaxTokens > 0 {
617 small.MaxTokens = smallModelSelected.MaxTokens
618 } else {
619 small.MaxTokens = model.DefaultMaxTokens
620 }
621 if smallModelSelected.ReasoningEffort != "" {
622 small.ReasoningEffort = smallModelSelected.ReasoningEffort
623 }
624 if smallModelSelected.Temperature != nil {
625 small.Temperature = smallModelSelected.Temperature
626 }
627 if smallModelSelected.TopP != nil {
628 small.TopP = smallModelSelected.TopP
629 }
630 if smallModelSelected.TopK != nil {
631 small.TopK = smallModelSelected.TopK
632 }
633 if smallModelSelected.FrequencyPenalty != nil {
634 small.FrequencyPenalty = smallModelSelected.FrequencyPenalty
635 }
636 if smallModelSelected.PresencePenalty != nil {
637 small.PresencePenalty = smallModelSelected.PresencePenalty
638 }
639 small.Think = smallModelSelected.Think
640 }
641 }
642 c.Models[SelectedModelTypeLarge] = large
643 c.Models[SelectedModelTypeSmall] = small
644 return nil
645}
646
647// lookupConfigs searches config files recursively from CWD up to FS root
648func lookupConfigs(cwd string) []string {
649 // prepend default config paths
650 configPaths := []string{
651 GlobalConfig(),
652 GlobalConfigData(),
653 }
654
655 configNames := []string{appName + ".json", "." + appName + ".json"}
656
657 foundConfigs, err := fsext.Lookup(cwd, configNames...)
658 if err != nil {
659 // returns at least default configs
660 return configPaths
661 }
662
663 // reverse order so last config has more priority
664 slices.Reverse(foundConfigs)
665
666 return append(configPaths, foundConfigs...)
667}
668
669func loadFromConfigPaths(configPaths []string) (*Config, error) {
670 var configs [][]byte
671
672 for _, path := range configPaths {
673 data, err := os.ReadFile(path)
674 if err != nil {
675 if os.IsNotExist(err) {
676 continue
677 }
678 return nil, fmt.Errorf("failed to open config file %s: %w", path, err)
679 }
680 if len(data) == 0 {
681 continue
682 }
683 configs = append(configs, data)
684 }
685
686 return loadFromBytes(configs)
687}
688
689func loadFromBytes(configs [][]byte) (*Config, error) {
690 if len(configs) == 0 {
691 return &Config{}, nil
692 }
693
694 data, err := jsons.Merge(configs)
695 if err != nil {
696 return nil, err
697 }
698 var config Config
699 if err := json.Unmarshal(data, &config); err != nil {
700 return nil, err
701 }
702 return &config, nil
703}
704
705func hasAWSCredentials(env env.Env) bool {
706 if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" {
707 return true
708 }
709
710 if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" {
711 return true
712 }
713
714 if env.Get("AWS_PROFILE") != "" || env.Get("AWS_DEFAULT_PROFILE") != "" {
715 return true
716 }
717
718 if env.Get("AWS_REGION") != "" || env.Get("AWS_DEFAULT_REGION") != "" {
719 return true
720 }
721
722 if env.Get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
723 env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
724 return true
725 }
726
727 if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil && !testing.Testing() {
728 return true
729 }
730
731 return false
732}
733
734// GlobalConfig returns the global configuration file path for the application.
735func GlobalConfig() string {
736 if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {
737 return filepath.Join(crushGlobal, fmt.Sprintf("%s.json", appName))
738 }
739 if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
740 return filepath.Join(xdgConfigHome, appName, fmt.Sprintf("%s.json", appName))
741 }
742 return filepath.Join(home.Dir(), ".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 // Determine the base config directory.
821 var configBase string
822 if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
823 configBase = xdgConfigHome
824 } else if runtime.GOOS == "windows" {
825 configBase = cmp.Or(
826 os.Getenv("LOCALAPPDATA"),
827 filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
828 )
829 } else {
830 configBase = filepath.Join(home.Dir(), ".config")
831 }
832
833 return []string{
834 filepath.Join(configBase, appName, "skills"),
835 filepath.Join(configBase, "agents", "skills"),
836 }
837}
838
839func isAppleTerminal() bool { return os.Getenv("TERM_PROGRAM") == "Apple_Terminal" }