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