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 // Project specific skills dirs.
418 c.Options.SkillsPaths = append(c.Options.SkillsPaths, ProjectSkillsDir(workingDir)...)
419
420 if str, ok := os.LookupEnv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE"); ok {
421 c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
422 }
423
424 if str, ok := os.LookupEnv("CRUSH_DISABLE_DEFAULT_PROVIDERS"); ok {
425 c.Options.DisableDefaultProviders, _ = strconv.ParseBool(str)
426 }
427
428 if c.Options.Attribution == nil {
429 c.Options.Attribution = &Attribution{
430 TrailerStyle: TrailerStyleAssistedBy,
431 GeneratedWith: true,
432 }
433 } else if c.Options.Attribution.TrailerStyle == "" {
434 // Migrate deprecated co_authored_by or apply default
435 if c.Options.Attribution.CoAuthoredBy != nil {
436 if *c.Options.Attribution.CoAuthoredBy {
437 c.Options.Attribution.TrailerStyle = TrailerStyleCoAuthoredBy
438 } else {
439 c.Options.Attribution.TrailerStyle = TrailerStyleNone
440 }
441 } else {
442 c.Options.Attribution.TrailerStyle = TrailerStyleAssistedBy
443 }
444 }
445 c.Options.InitializeAs = cmp.Or(c.Options.InitializeAs, defaultInitializeAs)
446}
447
448// applyLSPDefaults applies default values from powernap to LSP configurations
449func (c *Config) applyLSPDefaults() {
450 // Get powernap's default configuration
451 configManager := powernapConfig.NewManager()
452 configManager.LoadDefaults()
453
454 // Apply defaults to each LSP configuration
455 for name, cfg := range c.LSP {
456 // Try to get defaults from powernap based on name or command name.
457 base, ok := configManager.GetServer(name)
458 if !ok {
459 base, ok = configManager.GetServer(cfg.Command)
460 if !ok {
461 continue
462 }
463 }
464 if cfg.Options == nil {
465 cfg.Options = base.Settings
466 }
467 if cfg.InitOptions == nil {
468 cfg.InitOptions = base.InitOptions
469 }
470 if len(cfg.FileTypes) == 0 {
471 cfg.FileTypes = base.FileTypes
472 }
473 if len(cfg.RootMarkers) == 0 {
474 cfg.RootMarkers = base.RootMarkers
475 }
476 cfg.Command = cmp.Or(cfg.Command, base.Command)
477 if len(cfg.Args) == 0 {
478 cfg.Args = base.Args
479 }
480 if len(cfg.Env) == 0 {
481 cfg.Env = base.Environment
482 }
483 // Update the config in the map
484 c.LSP[name] = cfg
485 }
486}
487
488func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
489 if len(knownProviders) == 0 && c.Providers.Len() == 0 {
490 err = fmt.Errorf("no providers configured, please configure at least one provider")
491 return largeModel, smallModel, err
492 }
493
494 // Use the first provider enabled based on the known providers order
495 // if no provider found that is known use the first provider configured
496 for _, p := range knownProviders {
497 providerConfig, ok := c.Providers.Get(string(p.ID))
498 if !ok || providerConfig.Disable {
499 continue
500 }
501 defaultLargeModel := c.GetModel(string(p.ID), p.DefaultLargeModelID)
502 if defaultLargeModel == nil {
503 err = fmt.Errorf("default large model %s not found for provider %s", p.DefaultLargeModelID, p.ID)
504 return largeModel, smallModel, err
505 }
506 largeModel = SelectedModel{
507 Provider: string(p.ID),
508 Model: defaultLargeModel.ID,
509 MaxTokens: defaultLargeModel.DefaultMaxTokens,
510 ReasoningEffort: defaultLargeModel.DefaultReasoningEffort,
511 }
512
513 defaultSmallModel := c.GetModel(string(p.ID), p.DefaultSmallModelID)
514 if defaultSmallModel == nil {
515 err = fmt.Errorf("default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
516 return largeModel, smallModel, err
517 }
518 smallModel = SelectedModel{
519 Provider: string(p.ID),
520 Model: defaultSmallModel.ID,
521 MaxTokens: defaultSmallModel.DefaultMaxTokens,
522 ReasoningEffort: defaultSmallModel.DefaultReasoningEffort,
523 }
524 return largeModel, smallModel, err
525 }
526
527 enabledProviders := c.EnabledProviders()
528 slices.SortFunc(enabledProviders, func(a, b ProviderConfig) int {
529 return strings.Compare(a.ID, b.ID)
530 })
531
532 if len(enabledProviders) == 0 {
533 err = fmt.Errorf("no providers configured, please configure at least one provider")
534 return largeModel, smallModel, err
535 }
536
537 providerConfig := enabledProviders[0]
538 if len(providerConfig.Models) == 0 {
539 err = fmt.Errorf("provider %s has no models configured", providerConfig.ID)
540 return largeModel, smallModel, err
541 }
542 defaultLargeModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
543 largeModel = SelectedModel{
544 Provider: providerConfig.ID,
545 Model: defaultLargeModel.ID,
546 MaxTokens: defaultLargeModel.DefaultMaxTokens,
547 }
548 defaultSmallModel := c.GetModel(providerConfig.ID, providerConfig.Models[0].ID)
549 smallModel = SelectedModel{
550 Provider: providerConfig.ID,
551 Model: defaultSmallModel.ID,
552 MaxTokens: defaultSmallModel.DefaultMaxTokens,
553 }
554 return largeModel, smallModel, err
555}
556
557func configureSelectedModels(store *ConfigStore, knownProviders []catwalk.Provider) error {
558 c := store.config
559 defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
560 if err != nil {
561 return fmt.Errorf("failed to select default models: %w", err)
562 }
563 large, small := defaultLarge, defaultSmall
564
565 largeModelSelected, largeModelConfigured := c.Models[SelectedModelTypeLarge]
566 if largeModelConfigured {
567 if largeModelSelected.Model != "" {
568 large.Model = largeModelSelected.Model
569 }
570 if largeModelSelected.Provider != "" {
571 large.Provider = largeModelSelected.Provider
572 }
573 model := c.GetModel(large.Provider, large.Model)
574 if model == nil {
575 large = defaultLarge
576 // override the model type to large
577 err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeLarge, large)
578 if err != nil {
579 return fmt.Errorf("failed to update preferred large model: %w", err)
580 }
581 } else {
582 if largeModelSelected.MaxTokens > 0 {
583 large.MaxTokens = largeModelSelected.MaxTokens
584 } else {
585 large.MaxTokens = model.DefaultMaxTokens
586 }
587 if largeModelSelected.ReasoningEffort != "" {
588 large.ReasoningEffort = largeModelSelected.ReasoningEffort
589 }
590 large.Think = largeModelSelected.Think
591 if largeModelSelected.Temperature != nil {
592 large.Temperature = largeModelSelected.Temperature
593 }
594 if largeModelSelected.TopP != nil {
595 large.TopP = largeModelSelected.TopP
596 }
597 if largeModelSelected.TopK != nil {
598 large.TopK = largeModelSelected.TopK
599 }
600 if largeModelSelected.FrequencyPenalty != nil {
601 large.FrequencyPenalty = largeModelSelected.FrequencyPenalty
602 }
603 if largeModelSelected.PresencePenalty != nil {
604 large.PresencePenalty = largeModelSelected.PresencePenalty
605 }
606 }
607 }
608 smallModelSelected, smallModelConfigured := c.Models[SelectedModelTypeSmall]
609 if smallModelConfigured {
610 if smallModelSelected.Model != "" {
611 small.Model = smallModelSelected.Model
612 }
613 if smallModelSelected.Provider != "" {
614 small.Provider = smallModelSelected.Provider
615 }
616
617 model := c.GetModel(small.Provider, small.Model)
618 if model == nil {
619 small = defaultSmall
620 // override the model type to small
621 err := store.UpdatePreferredModel(ScopeGlobal, SelectedModelTypeSmall, small)
622 if err != nil {
623 return fmt.Errorf("failed to update preferred small model: %w", err)
624 }
625 } else {
626 if smallModelSelected.MaxTokens > 0 {
627 small.MaxTokens = smallModelSelected.MaxTokens
628 } else {
629 small.MaxTokens = model.DefaultMaxTokens
630 }
631 if smallModelSelected.ReasoningEffort != "" {
632 small.ReasoningEffort = smallModelSelected.ReasoningEffort
633 }
634 if smallModelSelected.Temperature != nil {
635 small.Temperature = smallModelSelected.Temperature
636 }
637 if smallModelSelected.TopP != nil {
638 small.TopP = smallModelSelected.TopP
639 }
640 if smallModelSelected.TopK != nil {
641 small.TopK = smallModelSelected.TopK
642 }
643 if smallModelSelected.FrequencyPenalty != nil {
644 small.FrequencyPenalty = smallModelSelected.FrequencyPenalty
645 }
646 if smallModelSelected.PresencePenalty != nil {
647 small.PresencePenalty = smallModelSelected.PresencePenalty
648 }
649 small.Think = smallModelSelected.Think
650 }
651 }
652 c.Models[SelectedModelTypeLarge] = large
653 c.Models[SelectedModelTypeSmall] = small
654 return nil
655}
656
657// lookupConfigs searches config files recursively from CWD up to FS root
658func lookupConfigs(cwd string) []string {
659 // prepend default config paths
660 configPaths := []string{
661 GlobalConfig(),
662 GlobalConfigData(),
663 }
664
665 configNames := []string{appName + ".json", "." + appName + ".json"}
666
667 foundConfigs, err := fsext.Lookup(cwd, configNames...)
668 if err != nil {
669 // returns at least default configs
670 return configPaths
671 }
672
673 // reverse order so last config has more priority
674 slices.Reverse(foundConfigs)
675
676 return append(configPaths, foundConfigs...)
677}
678
679func loadFromConfigPaths(configPaths []string) (*Config, error) {
680 var configs [][]byte
681
682 for _, path := range configPaths {
683 data, err := os.ReadFile(path)
684 if err != nil {
685 if os.IsNotExist(err) {
686 continue
687 }
688 return nil, fmt.Errorf("failed to open config file %s: %w", path, err)
689 }
690 if len(data) == 0 {
691 continue
692 }
693 configs = append(configs, data)
694 }
695
696 return loadFromBytes(configs)
697}
698
699func loadFromBytes(configs [][]byte) (*Config, error) {
700 if len(configs) == 0 {
701 return &Config{}, nil
702 }
703
704 data, err := jsons.Merge(configs)
705 if err != nil {
706 return nil, err
707 }
708 var config Config
709 if err := json.Unmarshal(data, &config); err != nil {
710 return nil, err
711 }
712 return &config, nil
713}
714
715func hasAWSCredentials(env env.Env) bool {
716 if env.Get("AWS_BEARER_TOKEN_BEDROCK") != "" {
717 return true
718 }
719
720 if env.Get("AWS_ACCESS_KEY_ID") != "" && env.Get("AWS_SECRET_ACCESS_KEY") != "" {
721 return true
722 }
723
724 if env.Get("AWS_PROFILE") != "" || env.Get("AWS_DEFAULT_PROFILE") != "" {
725 return true
726 }
727
728 if env.Get("AWS_REGION") != "" || env.Get("AWS_DEFAULT_REGION") != "" {
729 return true
730 }
731
732 if env.Get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
733 env.Get("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
734 return true
735 }
736
737 if _, err := os.Stat(filepath.Join(home.Dir(), ".aws/credentials")); err == nil && !testing.Testing() {
738 return true
739 }
740
741 return false
742}
743
744// GlobalConfig returns the global configuration file path for the application.
745func GlobalConfig() string {
746 if crushGlobal := os.Getenv("CRUSH_GLOBAL_CONFIG"); crushGlobal != "" {
747 return filepath.Join(crushGlobal, fmt.Sprintf("%s.json", appName))
748 }
749 if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
750 return filepath.Join(xdgConfigHome, appName, fmt.Sprintf("%s.json", appName))
751 }
752 return filepath.Join(home.Dir(), ".config", appName, fmt.Sprintf("%s.json", appName))
753}
754
755// GlobalConfigData returns the path to the main data directory for the application.
756// this config is used when the app overrides configurations instead of updating the global config.
757func GlobalConfigData() string {
758 if crushData := os.Getenv("CRUSH_GLOBAL_DATA"); crushData != "" {
759 return filepath.Join(crushData, fmt.Sprintf("%s.json", appName))
760 }
761 if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" {
762 return filepath.Join(xdgDataHome, appName, fmt.Sprintf("%s.json", appName))
763 }
764
765 // return the path to the main data directory
766 // for windows, it should be in `%LOCALAPPDATA%/crush/`
767 // for linux and macOS, it should be in `$HOME/.local/share/crush/`
768 if runtime.GOOS == "windows" {
769 localAppData := cmp.Or(
770 os.Getenv("LOCALAPPDATA"),
771 filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
772 )
773 return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
774 }
775
776 return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
777}
778
779func assignIfNil[T any](ptr **T, val T) {
780 if *ptr == nil {
781 *ptr = &val
782 }
783}
784
785func isInsideWorktree() bool {
786 bts, err := exec.CommandContext(
787 context.Background(),
788 "git", "rev-parse",
789 "--is-inside-work-tree",
790 ).CombinedOutput()
791 return err == nil && strings.TrimSpace(string(bts)) == "true"
792}
793
794// GlobalSkillsDirs returns the default directories for Agent Skills.
795// Skills in these directories are auto-discovered and their files can be read
796// without permission prompts.
797func GlobalSkillsDirs() []string {
798 if crushSkills := os.Getenv("CRUSH_SKILLS_DIR"); crushSkills != "" {
799 return []string{crushSkills}
800 }
801
802 configHome := cmp.Or(
803 os.Getenv("XDG_CONFIG_HOME"),
804 filepath.Join(home.Dir(), ".config"),
805 )
806
807 paths := []string{
808 filepath.Join(configHome, appName, "skills"),
809 filepath.Join(configHome, "agents", "skills"),
810 }
811
812 // On Windows, also load from app data on top of `$HOME/.config/crush`.
813 // This is here mostly for backwards compatibility.
814 if runtime.GOOS == "windows" {
815 appData := cmp.Or(
816 os.Getenv("LOCALAPPDATA"),
817 filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
818 )
819 paths = append(
820 paths,
821 filepath.Join(appData, appName, "skills"),
822 filepath.Join(appData, "agents", "skills"),
823 )
824 }
825
826 return paths
827}
828
829// ProjectSkillsDir returns the default project directories for which Crush
830// will look for skills.
831func ProjectSkillsDir(workingDir string) []string {
832 return []string{
833 filepath.Join(workingDir, ".agents/skills"),
834 filepath.Join(workingDir, ".crush/skills"),
835 filepath.Join(workingDir, ".claude/skills"),
836 filepath.Join(workingDir, ".cursor/skills"),
837 }
838}
839
840func isAppleTerminal() bool { return os.Getenv("TERM_PROGRAM") == "Apple_Terminal" }