-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Expand file tree
/
Copy pathconfig.go
More file actions
893 lines (785 loc) · 28.4 KB
/
config.go
File metadata and controls
893 lines (785 loc) · 28.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
package config
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/spf13/viper"
"github.com/steveyegge/beads/internal/debug"
"gopkg.in/yaml.v3"
)
var v *viper.Viper
// overriddenKeys tracks keys explicitly set via Set() at runtime, so
// GetValueSource can distinguish them from Viper defaults.
var overriddenKeys = map[string]bool{}
// Initialize sets up the viper configuration singleton
// Should be called once at application startup
func Initialize() error {
v = viper.New()
// Set config type to yaml (we only load config.yaml, not config.json)
v.SetConfigType("yaml")
// Collect config files from lowest to highest priority.
// We load the lowest first with ReadInConfig, then MergeInConfig each
// subsequent file so higher-priority values overwrite lower-priority ones.
//
// Precedence (highest to lowest):
// BEADS_DIR/config.yaml > project .beads/config.yaml > ~/.config/bd/config.yaml > ~/.beads/config.yaml
//
// Previously, only ONE config file was loaded (the highest-priority match),
// which meant user-level config was silently ignored when project-level
// config existed — e.g., the idle-monitor daemon with BEADS_DIR set (GH#2375).
var configPaths []string // ordered lowest priority first
var primaryConfigPath string // project-level config (for config.local.yaml and SaveConfigValue)
// 3. Legacy: ~/.beads/config.yaml (lowest priority)
if homeDir, err := os.UserHomeDir(); err == nil {
p := filepath.Join(homeDir, ".beads", "config.yaml")
if _, err := os.Stat(p); err == nil {
configPaths = append(configPaths, p)
}
}
// 2. User: ~/.config/bd/config.yaml
if configDir, err := os.UserConfigDir(); err == nil {
p := filepath.Join(configDir, "bd", "config.yaml")
if _, err := os.Stat(p); err == nil {
configPaths = append(configPaths, p)
}
}
// 1. Project: walk up from CWD to find .beads/config.yaml
cwd, err := os.Getwd()
if err == nil {
// In the beads repo, `.beads/config.yaml` is tracked and may set non-default config values.
// In `go test` (especially for `cmd/bd`), we want to avoid unintentionally picking up
// the repo-local config, while still allowing tests to load config.yaml from temp repos.
//
// If BEADS_TEST_IGNORE_REPO_CONFIG is set, we will ignore the config at
// <module-root>/.beads/config.yaml (where module-root is the nearest parent containing go.mod).
ignoreRepoConfig := os.Getenv("BEADS_TEST_IGNORE_REPO_CONFIG") != ""
var moduleRoot string
if ignoreRepoConfig {
// Find module root by walking up to go.mod.
for dir := cwd; dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
moduleRoot = dir
break
}
}
}
// Walk up parent directories to find .beads/config.yaml
for dir := cwd; dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
beadsDir := filepath.Join(dir, ".beads")
p := filepath.Join(beadsDir, "config.yaml")
if _, err := os.Stat(p); err == nil {
if ignoreRepoConfig && moduleRoot != "" {
// Only ignore the repo-local config (moduleRoot/.beads/config.yaml).
wantIgnore := filepath.Clean(p) == filepath.Clean(filepath.Join(moduleRoot, ".beads", "config.yaml"))
if wantIgnore {
continue
}
}
configPaths = append(configPaths, p)
primaryConfigPath = p
break
}
}
}
// 0. BEADS_DIR: highest priority
if beadsDir := os.Getenv("BEADS_DIR"); beadsDir != "" {
p := filepath.Join(beadsDir, "config.yaml")
if _, err := os.Stat(p); err == nil {
// Avoid duplicate if BEADS_DIR points to same config as CWD walk
if primaryConfigPath == "" || filepath.Clean(p) != filepath.Clean(primaryConfigPath) {
configPaths = append(configPaths, p)
}
primaryConfigPath = p
}
}
// Automatic environment variable binding
// Environment variables take precedence over config file
// E.g., BD_JSON, BD_NO_DAEMON, BD_DB (BD_ACTOR deprecated in favor of BEADS_ACTOR)
v.SetEnvPrefix("BD")
// Replace hyphens and dots with underscores for env var mapping
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
v.AutomaticEnv()
// Set defaults for all flags
v.SetDefault("json", false)
v.SetDefault("events-export", false)
v.SetDefault("no-db", false)
v.SetDefault("no-hooks", false)
v.SetDefault("db", "")
v.SetDefault("actor", "")
v.SetDefault("issue-prefix", "")
// Additional environment variables (not prefixed with BD_)
_ = v.BindEnv("identity", "BEADS_IDENTITY") // BindEnv only fails with zero args, which can't happen here
v.SetDefault("identity", "")
// Dolt configuration defaults
// Controls whether beads should automatically create Dolt commits after write commands.
// Values: off | on
v.SetDefault("dolt.auto-commit", "on")
// Routing configuration defaults
v.SetDefault("routing.mode", "")
v.SetDefault("routing.default", ".")
v.SetDefault("routing.maintainer", ".")
v.SetDefault("routing.contributor", "~/.beads-planning")
// Sync configuration defaults (bd-4u8)
v.SetDefault("sync.require_confirmation_on_mass_delete", false)
// Federation configuration (optional Dolt remote)
v.SetDefault("federation.remote", "") // e.g., dolthub://org/beads, gs://bucket/beads, s3://bucket/beads, az://account.blob.core.windows.net/container/beads
v.SetDefault("federation.sovereignty", "") // T1 | T2 | T3 | T4 (empty = no restriction)
v.SetDefault("federation.allowed-remote-patterns", []string{}) // glob patterns restricting allowed remote URLs (enterprise lockdown)
// Push configuration defaults
v.SetDefault("no-push", false)
// Create command defaults
v.SetDefault("create.require-description", false)
// Validation configuration defaults (bd-t7jq)
// Values: "warn" | "error" | "none"
// - "none": no validation (default, backwards compatible)
// - "warn": validate and print warnings but proceed
// - "error": validate and fail on missing sections
v.SetDefault("validation.on-create", "none")
v.SetDefault("validation.on-close", "none")
v.SetDefault("validation.on-sync", "none")
// Metadata schema validation (GH#1416 Phase 2)
// - "none": no metadata schema validation (default)
// - "warn": validate and print warnings but proceed
// - "error": validate and reject invalid metadata
v.SetDefault("validation.metadata.mode", "none")
// Hierarchy configuration defaults (GH#995)
// Maximum nesting depth for hierarchical IDs (e.g., bd-abc.1.2.3)
// Default matches types.MaxHierarchyDepth constant
v.SetDefault("hierarchy.max-depth", 3)
// Git configuration defaults (GH#600)
v.SetDefault("git.no-gpg-sign", false) // Disable GPG signing for beads commits
// Directory-aware label scoping (GH#541)
// Maps directory patterns to labels for automatic filtering in monorepos
v.SetDefault("directory.labels", map[string]string{})
// Backup configuration defaults (JSONL export to .beads/backup/)
v.SetDefault("backup.enabled", false)
v.SetDefault("backup.interval", "15m")
v.SetDefault("backup.git-push", false)
v.SetDefault("backup.git-repo", "")
// Auto-export: write git-tracked JSONL after mutations for portability
// When no Dolt remote is configured, this is the primary way to share
// beads state (issues + memories) across machines via git.
v.SetDefault("export.auto", false)
v.SetDefault("export.interval", "60s")
v.SetDefault("export.path", "export.jsonl") // relative to .beads/
v.SetDefault("export.git-add", false)
// AI configuration defaults
v.SetDefault("ai.model", "claude-haiku-4-5-20251001")
// Output configuration (GH#1384)
// Controls title display in command feedback messages.
// 0 = hide title, N > 0 = truncate to N chars with "…"
v.SetDefault("output.title-length", 255)
// External projects for cross-project dependency resolution (bd-h807)
// Maps project names to paths for resolving external: blocked_by references
v.SetDefault("external_projects", map[string]string{})
// Load config files: lowest priority first, each MergeInConfig overwrites
if len(configPaths) > 0 {
v.SetConfigFile(configPaths[0])
if err := v.ReadInConfig(); err != nil {
return fmt.Errorf("error reading config file: %w", err)
}
debug.Logf("Debug: loaded config from %s\n", configPaths[0])
for _, p := range configPaths[1:] {
v.SetConfigFile(p)
if err := v.MergeInConfig(); err != nil {
return fmt.Errorf("error merging config file %s: %w", p, err)
}
debug.Logf("Debug: merged config from %s\n", p)
}
// Restore primary config path as ConfigFileUsed (used by SaveConfigValue,
// ResolveExternalProjectPath, etc.)
v.SetConfigFile(primaryConfigPath)
// Merge local config overrides if present (config.local.yaml)
// This allows machine-specific settings without polluting tracked config
localConfigPath := filepath.Join(filepath.Dir(primaryConfigPath), "config.local.yaml")
if _, err := os.Stat(localConfigPath); err == nil {
v.SetConfigFile(localConfigPath)
if err := v.MergeInConfig(); err != nil {
return fmt.Errorf("error merging local config file: %w", err)
}
debug.Logf("Debug: merged local config from %s\n", localConfigPath)
// Restore primary as ConfigFileUsed
v.SetConfigFile(primaryConfigPath)
}
} else {
// No config.yaml found - use defaults and environment variables
debug.Logf("Debug: no config.yaml found; using defaults and environment variables\n")
}
return nil
}
// ResetForTesting clears the config state, allowing Initialize() to be called again.
// This is intended for tests that need to change config.yaml between test steps.
// WARNING: Not thread-safe. Only call from single-threaded test contexts.
func ResetForTesting() {
v = nil
overriddenKeys = map[string]bool{}
}
// ConfigSource represents where a configuration value came from
type ConfigSource string
const (
SourceDefault ConfigSource = "default"
SourceConfigFile ConfigSource = "config_file"
SourceEnvVar ConfigSource = "env_var"
SourceFlag ConfigSource = "flag"
)
// ConfigOverride represents a detected configuration override
type ConfigOverride struct {
Key string
EffectiveValue interface{}
OverriddenBy ConfigSource
OriginalSource ConfigSource
OriginalValue interface{}
}
// GetValueSource returns the source of a configuration value.
// Priority (highest to lowest): env var > config file > default
// Note: Flag overrides are handled separately in main.go since viper doesn't know about cobra flags.
func GetValueSource(key string) ConfigSource {
if v == nil {
return SourceDefault
}
// Check if value is set from environment variable.
// Use LookupEnv (not Getenv) so that explicitly-set-but-empty vars like
// BD_BACKUP_ENABLED= are recognized as "set by the user" rather than
// falling through to the default/auto-detect path.
envKey := "BD_" + strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), ".", "_"))
if _, ok := os.LookupEnv(envKey); ok {
return SourceEnvVar
}
// Check BEADS_ prefixed env vars for legacy compatibility
beadsEnvKey := "BEADS_" + strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), ".", "_"))
if _, ok := os.LookupEnv(beadsEnvKey); ok {
return SourceEnvVar
}
// Check if value is set in config file (as opposed to being a default)
if v.InConfig(key) {
return SourceConfigFile
}
// Check if value was explicitly set via Set() at runtime
if overriddenKeys[key] {
return SourceConfigFile
}
return SourceDefault
}
// EnvVarName returns the environment variable name that would override the given
// config key, if one is set. Returns the BD_ or BEADS_ prefixed name, or empty
// string if no env var is set for this key.
func EnvVarName(key string) string {
envKey := "BD_" + strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), ".", "_"))
if _, ok := os.LookupEnv(envKey); ok {
return envKey
}
beadsEnvKey := "BEADS_" + strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), ".", "_"))
if _, ok := os.LookupEnv(beadsEnvKey); ok {
return beadsEnvKey
}
return ""
}
// CheckOverrides checks for configuration overrides and returns a list of detected overrides.
// This is useful for informing users when env vars or flags override config file values.
// flagOverrides is a map of key -> (flagValue, flagWasSet) for flags that were explicitly set.
func CheckOverrides(flagOverrides map[string]struct {
Value interface{}
WasSet bool
}) []ConfigOverride {
var overrides []ConfigOverride
for key, flagInfo := range flagOverrides {
if !flagInfo.WasSet {
continue
}
source := GetValueSource(key)
if source == SourceConfigFile || source == SourceEnvVar {
// Flag is overriding a config file or env var value
var originalValue interface{}
switch v := flagInfo.Value.(type) {
case bool:
originalValue = GetBool(key)
case string:
originalValue = GetString(key)
case int:
originalValue = GetInt(key)
default:
originalValue = v
}
overrides = append(overrides, ConfigOverride{
Key: key,
EffectiveValue: flagInfo.Value,
OverriddenBy: SourceFlag,
OriginalSource: source,
OriginalValue: originalValue,
})
}
}
// Check for env var overriding config file
if v != nil {
for _, key := range v.AllKeys() {
envSource := GetValueSource(key)
if envSource == SourceEnvVar && v.InConfig(key) {
// Env var is overriding config file value.
// Use LookupEnv to detect presence — empty-string env vars
// are still intentional overrides.
envKey := "BD_" + strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), ".", "_"))
if _, ok := os.LookupEnv(envKey); !ok {
envKey = "BEADS_" + strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), ".", "_"))
if _, ok := os.LookupEnv(envKey); !ok {
continue
}
}
overrides = append(overrides, ConfigOverride{
Key: key,
EffectiveValue: v.Get(key),
OverriddenBy: SourceEnvVar,
OriginalSource: SourceConfigFile,
OriginalValue: nil, // We can't easily get the config file value separately
})
}
}
}
return overrides
}
// LogOverride logs a message about a configuration override in verbose mode.
func LogOverride(override ConfigOverride) {
var sourceDesc string
switch override.OriginalSource {
case SourceConfigFile:
sourceDesc = "config file"
case SourceEnvVar:
sourceDesc = "environment variable"
case SourceDefault:
sourceDesc = "default"
default:
sourceDesc = string(override.OriginalSource)
}
var overrideDesc string
switch override.OverriddenBy {
case SourceFlag:
overrideDesc = "command-line flag"
case SourceEnvVar:
overrideDesc = "environment variable"
default:
overrideDesc = string(override.OverriddenBy)
}
// Always emit to stderr when verbose mode is enabled (caller guards on verbose)
fmt.Fprintf(os.Stderr, "Config: %s overridden by %s (was: %v from %s, now: %v)\n",
override.Key, overrideDesc, override.OriginalValue, sourceDesc, override.EffectiveValue)
}
// SaveConfigValue sets a key-value pair and writes it to the config file.
// If no config file is currently loaded, it creates config.yaml in the given beadsDir.
// Only the specified key is modified; other file contents are preserved.
func SaveConfigValue(key string, value interface{}, beadsDir string) error {
if v == nil {
return fmt.Errorf("config not initialized")
}
v.Set(key, value)
configPath := v.ConfigFileUsed()
if configPath == "" {
configPath = filepath.Join(beadsDir, "config.yaml")
v.SetConfigFile(configPath)
}
// Read existing file contents to avoid dumping all merged viper state
// (defaults, env vars, overrides) into the config file.
existing := make(map[string]interface{})
if data, err := os.ReadFile(filepath.Clean(configPath)); err == nil {
_ = yaml.Unmarshal(data, &existing)
}
// Set the single key using dot-path splitting for nested keys (e.g. "routing.mode").
setNestedKey(existing, key, value)
out, err := yaml.Marshal(existing)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
return os.WriteFile(configPath, out, 0o600)
}
// setNestedKey sets a value in a nested map using a dot-separated key path.
func setNestedKey(m map[string]interface{}, key string, value interface{}) {
parts := strings.SplitN(key, ".", 2)
if len(parts) == 1 {
m[key] = value
return
}
sub, ok := m[parts[0]].(map[string]interface{})
if !ok {
sub = make(map[string]interface{})
m[parts[0]] = sub
}
setNestedKey(sub, parts[1], value)
}
// GetString retrieves a string configuration value
func GetString(key string) string {
if v == nil {
return ""
}
return v.GetString(key)
}
// GetStringFromDir reads a single string configuration value directly from
// <beadsDir>/config.yaml without using or modifying global viper state.
// This is intended for library consumers that call NewFromConfigWithOptions
// without first invoking config.Initialize().
//
// The key uses dotted notation (e.g. "dolt.auto-start"). YAML booleans and
// numbers are coerced to their string representations ("true", "false", etc.).
// Returns "" if the file is absent, the key is not found, or any error occurs.
func GetStringFromDir(beadsDir, key string) string {
configPath := filepath.Join(beadsDir, "config.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
return ""
}
var root map[string]interface{}
if err := yaml.Unmarshal(data, &root); err != nil {
return ""
}
parts := strings.SplitN(key, ".", 2)
node := root
for len(parts) == 2 {
val, ok := node[parts[0]]
if !ok {
return ""
}
m, ok := val.(map[string]interface{})
if !ok {
return ""
}
node = m
parts = strings.SplitN(parts[1], ".", 2)
}
val, ok := node[parts[0]]
if !ok {
return ""
}
switch s := val.(type) {
case string:
return s
default:
return fmt.Sprintf("%v", s)
}
}
// GetBool retrieves a boolean configuration value
func GetBool(key string) bool {
if v == nil {
return false
}
return v.GetBool(key)
}
// GetInt retrieves an integer configuration value
func GetInt(key string) int {
if v == nil {
return 0
}
return v.GetInt(key)
}
// GetDuration retrieves a duration configuration value
func GetDuration(key string) time.Duration {
if v == nil {
return 0
}
return v.GetDuration(key)
}
// Set sets a configuration value
func Set(key string, value interface{}) {
if v != nil {
v.Set(key, value)
overriddenKeys[key] = true
}
}
// BindPFlag is reserved for future use if we want to bind Cobra flags directly to Viper
// For now, we handle flag precedence manually in PersistentPreRun
// Uncomment and implement if needed:
//
// func BindPFlag(key string, flag *pflag.Flag) error {
// if v == nil {
// return fmt.Errorf("viper not initialized")
// }
// return v.BindPFlag(key, flag)
// }
// DefaultAIModel returns the configured AI model identifier.
// Override via: bd config set ai.model "model-name" or BD_AI_MODEL=model-name
func DefaultAIModel() string {
return GetString("ai.model")
}
// AllSettings returns all configuration settings as a map
func AllSettings() map[string]interface{} {
if v == nil {
return map[string]interface{}{}
}
return v.AllSettings()
}
// AllKeys returns all keys in the viper registry (defaults + config file + env).
// Keys are returned in lowercase dot-notation (e.g., "federation.remote").
func AllKeys() []string {
if v == nil {
return nil
}
return v.AllKeys()
}
// ConfigFileUsed returns the path to the config file that was loaded.
// Returns empty string if no config file was found or viper is not initialized.
// This is useful for resolving relative paths from the config file's directory.
func ConfigFileUsed() string {
if v == nil {
return ""
}
return v.ConfigFileUsed()
}
// GetStringSlice retrieves a string slice configuration value
func GetStringSlice(key string) []string {
if v == nil {
return []string{}
}
return v.GetStringSlice(key)
}
// GetStringMapString retrieves a map[string]string configuration value
func GetStringMapString(key string) map[string]string {
if v == nil {
return map[string]string{}
}
return v.GetStringMapString(key)
}
// GetDirectoryLabels returns labels for the current working directory based on config.
// It checks directory.labels config for matching patterns.
// Returns nil if no labels are configured for the current directory.
func GetDirectoryLabels() []string {
cwd, err := os.Getwd()
if err != nil {
return nil
}
dirLabels := GetStringMapString("directory.labels")
if len(dirLabels) == 0 {
return nil
}
// Check each configured directory pattern
for pattern, label := range dirLabels {
// Support both exact match and suffix match
// e.g., "packages/maverick" matches "/path/to/repo/packages/maverick"
if strings.HasSuffix(cwd, pattern) || strings.HasSuffix(cwd, filepath.Clean(pattern)) {
return []string{label}
}
// Also try as a path prefix (user might be in a subdirectory)
if strings.Contains(cwd, "/"+pattern+"/") || strings.Contains(cwd, "/"+pattern) {
return []string{label}
}
}
return nil
}
// MultiRepoConfig contains configuration for multi-repo support
type MultiRepoConfig struct {
Primary string // Primary repo path (where canonical issues live)
Additional []string // Additional repos to hydrate from
}
// GetMultiRepoConfig retrieves multi-repo configuration
// Returns nil if multi-repo is not configured (single-repo mode)
func GetMultiRepoConfig() *MultiRepoConfig {
if v == nil {
return nil
}
// Check if repos.primary is set (indicates multi-repo mode)
primary := v.GetString("repos.primary")
if primary == "" {
return nil // Single-repo mode
}
return &MultiRepoConfig{
Primary: primary,
Additional: v.GetStringSlice("repos.additional"),
}
}
// GetExternalProjects returns the external_projects configuration.
// Maps project names to paths for cross-project dependency resolution.
// Example config.yaml:
//
// external_projects:
// beads: ../beads
// other-project: /absolute/path/to/other-project
func GetExternalProjects() map[string]string {
return GetStringMapString("external_projects")
}
// ResolveExternalProjectPath resolves a project name to its absolute path.
// Returns empty string if project not configured or path doesn't exist.
func ResolveExternalProjectPath(projectName string) string {
projects := GetExternalProjects()
path, ok := projects[projectName]
if !ok {
return ""
}
// Resolve relative paths from repo root (parent of .beads/), NOT CWD.
// This ensures paths like "../beads" in config resolve correctly
// when running from different directories.
if !filepath.IsAbs(path) {
// Config is at .beads/config.yaml, so go up twice to get repo root
configFile := ConfigFileUsed()
if configFile != "" {
repoRoot := filepath.Dir(filepath.Dir(configFile)) // .beads/config.yaml -> repo/
path = filepath.Join(repoRoot, path)
} else {
// Fallback: resolve from CWD (legacy behavior)
cwd, err := os.Getwd()
if err != nil {
return ""
}
path = filepath.Join(cwd, path)
}
}
// Verify path exists
if _, err := os.Stat(path); err != nil {
return ""
}
return path
}
// GetIdentity resolves the user's identity for messaging.
// Priority chain:
// 1. flagValue (if non-empty, from --identity flag)
// 2. BEADS_IDENTITY env var / config.yaml identity field (via viper)
// 3. git config user.name
// 4. hostname
//
// This is used as the sender field in bd mail commands.
func GetIdentity(flagValue string) string {
// 1. Command-line flag takes precedence
if flagValue != "" {
return flagValue
}
// 2. BEADS_IDENTITY env var or config.yaml identity (viper handles both)
if identity := GetString("identity"); identity != "" {
return identity
}
// 3. git config user.name
cmd := exec.Command("git", "config", "user.name")
if output, err := cmd.Output(); err == nil {
if gitUser := strings.TrimSpace(string(output)); gitUser != "" {
return gitUser
}
}
// 4. hostname
if hostname, err := os.Hostname(); err == nil && hostname != "" {
return hostname
}
return "unknown"
}
// FederationConfig holds the federation (Dolt remote) configuration.
type FederationConfig struct {
Remote string // dolthub://org/beads, gs://bucket/beads, s3://bucket/beads
Sovereignty Sovereignty // T1, T2, T3, T4
}
// GetFederationConfig returns the current federation configuration.
func GetFederationConfig() FederationConfig {
return FederationConfig{
Remote: GetString("federation.remote"),
Sovereignty: GetSovereignty(),
}
}
// GetCustomTypesFromYAML retrieves custom issue types from config.yaml.
// This is used as a fallback when the database doesn't have types.custom set yet
// (e.g., during bd init auto-import before the database is fully configured).
// Returns nil if no custom types are configured in config.yaml.
func GetCustomTypesFromYAML() []string {
return getConfigList("types.custom")
}
// GetInfraTypesFromYAML retrieves infrastructure type names from config.yaml.
// Infrastructure types are routed to the wisps table instead of the versioned issues table.
// Returns nil if no infra types are configured in config.yaml (caller should use defaults).
func GetInfraTypesFromYAML() []string {
return getConfigList("types.infra")
}
// GetCustomStatusesFromYAML retrieves custom statuses from config.yaml.
// This is used as a fallback when the database doesn't have status.custom set yet
// or when the database connection is temporarily unavailable.
// Returns nil if no custom statuses are configured in config.yaml.
func GetCustomStatusesFromYAML() []string {
return getConfigList("status.custom")
}
// MetadataValidationMode returns the metadata schema validation mode.
// Returns "none" if config is not initialized or mode is empty/unknown.
func MetadataValidationMode() string {
if v == nil {
return "none"
}
mode := v.GetString("validation.metadata.mode")
switch mode {
case "warn", "error":
return mode
default:
return "none"
}
}
// MetadataSchemaFields returns the raw field definitions from config.
// Returns nil if config is not initialized or no fields are defined.
// Each entry maps field name → map of properties (type, values, required, min, max).
func MetadataSchemaFields() map[string]interface{} {
if v == nil {
return nil
}
raw := v.Get("validation.metadata.fields")
if raw == nil {
return nil
}
// Viper returns map[string]interface{} for nested YAML maps
if m, ok := raw.(map[string]interface{}); ok {
return m
}
return nil
}
// DefaultAgentsFile is the default filename for agent instructions.
const DefaultAgentsFile = "AGENTS.md"
// AgentsFile returns the configured agents instruction filename.
// Returns DefaultAgentsFile ("AGENTS.md") if no custom value is set.
// Note: Use SafeAgentsFile() when the value will be used for file I/O,
// as config.yaml may be manually edited with invalid values.
func AgentsFile() string {
if name := GetString("agents.file"); name != "" {
return name
}
return DefaultAgentsFile
}
// SafeAgentsFile returns the configured agents filename after validation.
// If the stored config value is invalid (e.g. manually edited with traversal
// paths), it falls back to DefaultAgentsFile and logs a warning.
func SafeAgentsFile() string {
name := AgentsFile()
if err := ValidateAgentsFile(name); err != nil {
debug.Logf("config: agents.file %q failed validation (%v), using default", name, err)
return DefaultAgentsFile
}
return name
}
// ValidateAgentsFile checks that filename is safe to use as an agents file path.
// It rejects absolute paths, path separators, names longer than 255 characters,
// and non-markdown extensions. This is a pure string validation function — I/O
// checks (e.g. symlink detection) are deferred to the file write layer.
func ValidateAgentsFile(filename string) error {
if filename == "" {
return fmt.Errorf("agents file name must not be empty")
}
if len(filename) > 255 {
return fmt.Errorf("agents file name exceeds 255 characters")
}
if strings.ContainsAny(filename, "/\\") {
return fmt.Errorf("agents file must be a simple filename without path separators, got %q", filename)
}
ext := strings.ToLower(filepath.Ext(filename))
if ext != ".md" {
return fmt.Errorf("agents file must have .md extension, got %q", ext)
}
return nil
}
// getConfigList is a helper that retrieves a comma-separated list from config.yaml.
func getConfigList(key string) []string {
if v == nil {
debug.Logf("config: viper not initialized, returning nil for key %q", key)
return nil
}
value := v.GetString(key)
if value == "" {
return nil
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}