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