Fix config system: rename config.json → metadata.json, fix config.yaml loading

- Renamed config.json to metadata.json to clarify purpose (database metadata)
- Fixed config.yaml/config.json conflict by making Viper explicitly load only config.yaml
- Added automatic migration from config.json to metadata.json on first read
- Fixed jsonOutput variable shadowing across 22 command files
- Updated bd init to create both metadata.json and config.yaml template
- Fixed 5 failing JSON output tests
- All tests passing

Resolves config file confusion and makes config.yaml work correctly.
Closes #178 (global flags), addresses config issues from #193

Amp-Thread-ID: https://ampcode.com/threads/T-e6ac8192-e18f-4ed7-83bc-4a5986718bb7
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-11-02 14:31:22 -08:00
parent 2ff800d7a7
commit 0215e73780
25 changed files with 224 additions and 188 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,87 +0,0 @@
# Next Session: Agent-Supervised Migration Safety
## Context
We identified that database migrations can lose user data through edge cases (e.g., GH #201 where `bd migrate` failed to set `issue_prefix`, breaking commands). Since beads is designed for AI agents, we should leverage **agent supervision** to make migrations safer.
## Key Architectural Decision
**Beads provides observability primitives; agents supervise using their own reasoning.**
Beads does NOT:
- ❌ Make AI API calls
- ❌ Invoke external models
- ❌ Call agents
Beads DOES:
- ✅ Provide deterministic invariant checks
- ✅ Expose migration state via `--dry-run --json`
- ✅ Roll back on validation failures
- ✅ Give agents structured data to analyze
## The Work (bd-627d)
### Phase 1: Migration Invariants (Start here!)
Create `internal/storage/sqlite/migration_invariants.go` with:
```go
type MigrationInvariant struct {
Name string
Description string
Check func(*sql.DB, *Snapshot) error
}
type Snapshot struct {
IssueCount int
ConfigKeys []string
DependencyCount int
LabelCount int
}
```
Implement these invariants:
1. **required_config_present** - Would have caught GH #201!
2. **foreign_keys_valid** - Detect orphaned dependencies
3. **issue_count_stable** - Catch unexpected data loss
### Phase 2: Inspection Tools
Add CLI commands for agents to inspect migrations:
1. `bd migrate --dry-run --json` - Shows what will change
2. `bd info --schema --json` - Current schema + detected prefix
3. Update `RunMigrations()` to check invariants and rollback on failure
### Phase 3 & 4: MCP Tools + Agent Workflows
Add MCP tools so agents can:
- Inspect migration plans before running
- Detect missing config (like `issue_prefix`)
- Auto-fix issues before migration
- Validate post-migration state
## Starting Prompt for Next Session
```
Let's implement Phase 1 of bd-627d (agent-supervised migration safety).
We need to create migration invariants that check for common data loss scenarios:
1. Missing required config keys (would have caught GH #201)
2. Foreign key integrity (no orphaned dependencies)
3. Issue count stability (detect unexpected deletions)
Start by creating internal/storage/sqlite/migration_invariants.go with the Snapshot type and invariant infrastructure. Then integrate it into RunMigrations() in migrations.go.
The goal: migrations should automatically roll back if invariants fail, preventing data loss.
```
## Related Issues
- bd-627d: Main epic for agent-supervised migrations
- GH #201: Real-world example of migration data loss (missing issue_prefix)
- bd-d355a07d: False positive data loss warnings
- bd-b245: Migration registry (just completed - makes migrations introspectable!)
## Success Criteria
After Phase 1, migrations should:
- ✅ Check invariants before committing
- ✅ Roll back on any invariant failure
- ✅ Provide clear error messages
- ✅ Have unit tests for each invariant
This prevents silent data loss like GH #201 where users discovered breakage only after migration completed.

View File

@@ -64,7 +64,7 @@ var createCmd = &cobra.Command{
externalRef, _ := cmd.Flags().GetString("external-ref")
deps, _ := cmd.Flags().GetStringSlice("deps")
forceCreate, _ := cmd.Flags().GetBool("force")
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
// Check for conflicting flags
if explicitID != "" && parentID != "" {

View File

@@ -36,7 +36,7 @@ var daemonsListCmd = &cobra.Command{
uptime, last activity, and exclusive lock status.`,
Run: func(cmd *cobra.Command, args []string) {
searchRoots, _ := cmd.Flags().GetStringSlice("search")
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
// Discover daemons
daemons, err := daemon.DiscoverDaemons(searchRoots)
@@ -139,7 +139,7 @@ Sends shutdown command via RPC, with SIGTERM fallback if RPC fails.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
target := args[0]
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
// Discover all daemons
daemons, err := daemon.DiscoverDaemons(nil)
@@ -209,7 +209,7 @@ Supports tail mode (last N lines) and follow mode (like tail -f).`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
target := args[0]
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
follow, _ := cmd.Flags().GetBool("follow")
lines, _ := cmd.Flags().GetInt("lines")
@@ -348,7 +348,7 @@ var daemonsKillallCmd = &cobra.Command{
Uses escalating shutdown strategy: RPC (2s) → SIGTERM (3s) → SIGKILL (1s).`,
Run: func(cmd *cobra.Command, args []string) {
searchRoots, _ := cmd.Flags().GetStringSlice("search")
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
force, _ := cmd.Flags().GetBool("force")
// Discover all daemons
@@ -411,7 +411,7 @@ var daemonsHealthCmd = &cobra.Command{
stale sockets, version mismatches, and unresponsive daemons.`,
Run: func(cmd *cobra.Command, args []string) {
searchRoots, _ := cmd.Flags().GetStringSlice("search")
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
// Discover daemons
daemons, err := daemon.DiscoverDaemons(searchRoots)

View File

@@ -54,7 +54,7 @@ Force: Delete and orphan dependents
force, _ := cmd.Flags().GetBool("force")
dryRun, _ := cmd.Flags().GetBool("dry-run")
cascade, _ := cmd.Flags().GetBool("cascade")
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
// Collect issue IDs from args and/or file
issueIDs := make([]string, 0, len(args))

View File

@@ -63,8 +63,7 @@ Examples:
bd doctor /path/to/repo # Check specific repository
bd doctor --json # Machine-readable output`,
Run: func(cmd *cobra.Command, args []string) {
// Get json flag from command
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
// Determine path to check
checkPath := "."
@@ -201,7 +200,7 @@ func checkInstallation(path string) doctorCheck {
func checkDatabaseVersion(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads")
// Check config.json first for custom database name
// Check metadata.json first for custom database name
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)
@@ -275,7 +274,7 @@ func checkDatabaseVersion(path string) doctorCheck {
func checkIDFormat(path string) doctorCheck {
beadsDir := filepath.Join(path, ".beads")
// Check config.json first for custom database name
// Check metadata.json first for custom database name
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)

View File

@@ -38,7 +38,7 @@ Example:
autoMerge, _ := cmd.Flags().GetBool("auto-merge")
dryRun, _ := cmd.Flags().GetBool("dry-run")
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
ctx := context.Background()

View File

@@ -22,7 +22,7 @@ var epicStatusCmd = &cobra.Command{
Short: "Show epic completion status",
Run: func(cmd *cobra.Command, args []string) {
eligibleOnly, _ := cmd.Flags().GetBool("eligible-only")
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
var epics []*types.EpicStatus
var err error
@@ -115,7 +115,7 @@ var closeEligibleEpicsCmd = &cobra.Command{
Short: "Close epics where all children are complete",
Run: func(cmd *cobra.Command, args []string) {
dryRun, _ := cmd.Flags().GetBool("dry-run")
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
var eligibleEpics []*types.EpicStatus

View File

@@ -149,6 +149,7 @@ bd.db
# Keep JSONL exports and config (source of truth for git)
!*.jsonl
!metadata.json
!config.json
`
if err := os.WriteFile(gitignorePath, []byte(gitignoreContent), 0600); err != nil {
@@ -211,13 +212,70 @@ bd.db
}
}
// Create config.json for explicit configuration
// Create metadata.json for database metadata
if useLocalBeads {
cfg := configfile.DefaultConfig(Version)
if err := cfg.Save(localBeadsDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create config.json: %v\n", err)
fmt.Fprintf(os.Stderr, "Warning: failed to create metadata.json: %v\n", err)
// Non-fatal - continue anyway
}
// Create config.yaml template for user preferences
configYamlPath := filepath.Join(localBeadsDir, "config.yaml")
if _, err := os.Stat(configYamlPath); os.IsNotExist(err) {
configYamlTemplate := `# Beads Configuration File
# This file configures default behavior for all bd commands in this repository
# All settings can also be set via environment variables (BD_* prefix)
# or overridden with command-line flags
# Issue prefix for this repository (used by bd init)
# If not set, bd init will auto-detect from directory name
# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc.
# issue-prefix: ""
# Use no-db mode: load from JSONL, no SQLite, write back after each command
# When true, bd will use .beads/issues.jsonl as the source of truth
# instead of SQLite database
# no-db: false
# Disable daemon for RPC communication (forces direct database access)
# no-daemon: false
# Disable auto-flush of database to JSONL after mutations
# no-auto-flush: false
# Disable auto-import from JSONL when it's newer than database
# no-auto-import: false
# Enable JSON output by default
# json: false
# Default actor for audit trails (overridden by BD_ACTOR or --actor)
# actor: ""
# Path to database (overridden by BEADS_DB or --db)
# db: ""
# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON)
# auto-start-daemon: true
# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE)
# flush-debounce: "5s"
# Integration settings (access with 'bd config get/set')
# These are stored in the database, not in this file:
# - jira.url
# - jira.project
# - linear.url
# - linear.api-key
# - github.org
# - github.repo
`
if err := os.WriteFile(configYamlPath, []byte(configYamlTemplate), 0600); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create config.yaml: %v\n", err)
// Non-fatal - continue anyway
}
}
}
// Check if git has existing issues to import (fresh clone scenario)

View File

@@ -79,7 +79,7 @@ var labelAddCmd = &cobra.Command{
Short: "Add a label to one or more issues",
Args: cobra.MinimumNArgs(2),
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
issueIDs, label := parseLabelArgs(args)
// Resolve partial IDs
@@ -125,7 +125,7 @@ var labelRemoveCmd = &cobra.Command{
Short: "Remove a label from one or more issues",
Args: cobra.MinimumNArgs(2),
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
issueIDs, label := parseLabelArgs(args)
// Resolve partial IDs
@@ -170,7 +170,7 @@ var labelListCmd = &cobra.Command{
Short: "List labels for an issue",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
ctx := context.Background()
// Resolve partial ID first
@@ -245,7 +245,7 @@ var labelListAllCmd = &cobra.Command{
Use: "list-all",
Short: "List all unique labels in the database",
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
ctx := context.Background()
var issues []*types.Issue

View File

@@ -46,7 +46,7 @@ var listCmd = &cobra.Command{
labelsAny, _ := cmd.Flags().GetStringSlice("label-any")
titleSearch, _ := cmd.Flags().GetString("title")
idFilter, _ := cmd.Flags().GetString("id")
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
// Normalize labels: trim, dedupe, remove empty
labels = normalizeLabels(labels)

View File

@@ -78,6 +78,23 @@ var (
noDb bool // Use --no-db mode: load from JSONL, write back after each command
)
func init() {
// Initialize viper configuration
if err := config.Initialize(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to initialize config: %v\n", err)
}
// Register persistent flags
rootCmd.PersistentFlags().StringVar(&dbPath, "db", "", "Database path (default: auto-discover .beads/*.db)")
rootCmd.PersistentFlags().StringVar(&actor, "actor", "", "Actor name for audit trail (default: $BD_ACTOR or $USER)")
rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
rootCmd.PersistentFlags().BoolVar(&noDaemon, "no-daemon", false, "Force direct storage mode, bypass daemon if running")
rootCmd.PersistentFlags().BoolVar(&noAutoFlush, "no-auto-flush", false, "Disable automatic JSONL sync after CRUD operations")
rootCmd.PersistentFlags().BoolVar(&noAutoImport, "no-auto-import", false, "Disable automatic JSONL import when newer than DB")
rootCmd.PersistentFlags().BoolVar(&sandboxMode, "sandbox", false, "Sandbox mode: disables daemon and auto-sync")
rootCmd.PersistentFlags().BoolVar(&noDb, "no-db", false, "Use no-db mode: load from JSONL, no SQLite")
}
var rootCmd = &cobra.Command{
Use: "bd",
Short: "bd - Dependency-aware issue tracker",

View File

@@ -43,7 +43,7 @@ Example:
sourceIDs := args
dryRun, _ := cmd.Flags().GetBool("dry-run")
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
// Validate merge operation
if err := validateMerge(targetID, sourceIDs); err != nil {

View File

@@ -100,7 +100,7 @@ This command:
return
}
// Check if target database exists and is current (use config.json name)
// Check if target database exists and is current (use metadata.json name)
targetPath := cfg.DatabasePath(beadsDir)
var currentDB *dbInfo
var oldDBs []*dbInfo
@@ -444,7 +444,7 @@ This command:
if !dryRun {
if err := cfg.Save(beadsDir); err != nil {
if !jsonOutput {
color.Yellow("Warning: failed to update config.json version: %v\n", err)
color.Yellow("Warning: failed to update metadata.json version: %v\n", err)
}
// Don't fail migration if config save fails
}
@@ -667,7 +667,7 @@ func handleUpdateRepoID(dryRun bool, autoYes bool) {
}
}
// loadOrCreateConfig loads config.json or creates default if not found
// loadOrCreateConfig loads metadata.json or creates default if not found
func loadOrCreateConfig(beadsDir string) (*configfile.Config, error) {
cfg, err := configfile.Load(beadsDir)
if err != nil {

View File

@@ -127,18 +127,18 @@ func TestFormatDBList(t *testing.T) {
}
func TestMigrateRespectsConfigJSON(t *testing.T) {
// Test that migrate respects custom database name from config.json
// Test that migrate respects custom database name from metadata.json
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("Failed to create .beads directory: %v", err)
}
// Create config.json with custom database name
configPath := filepath.Join(beadsDir, "config.json")
// Create metadata.json with custom database name
configPath := filepath.Join(beadsDir, "metadata.json")
configData := `{"database": "beady.db", "version": "0.21.1", "jsonl_export": "beady.jsonl"}`
if err := os.WriteFile(configPath, []byte(configData), 0600); err != nil {
t.Fatalf("Failed to create config.json: %v", err)
t.Fatalf("Failed to create metadata.json: %v", err)
}
// Create old database with custom name

View File

@@ -20,7 +20,7 @@ var readyCmd = &cobra.Command{
limit, _ := cmd.Flags().GetInt("limit")
assignee, _ := cmd.Flags().GetString("assignee")
sortPolicy, _ := cmd.Flags().GetString("sort")
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars)
filter := types.WorkFilter{
// Leave Status empty to get both 'open' and 'in_progress' (bd-165)
@@ -153,7 +153,7 @@ var blockedCmd = &cobra.Command{
Use: "blocked",
Short: "Show blocked issues",
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars)
// If daemon is running but doesn't support this command, use direct storage
if daemonClient != nil && store == nil {
@@ -208,7 +208,7 @@ var statsCmd = &cobra.Command{
Use: "stats",
Short: "Show statistics",
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars)
// If daemon is running, use RPC
if daemonClient != nil {

View File

@@ -22,7 +22,7 @@ This is more explicit than 'bd update --status open' and emits a Reopened event.
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
reason, _ := cmd.Flags().GetString("reason")
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
ctx := context.Background()

View File

@@ -21,7 +21,7 @@ var showCmd = &cobra.Command{
Short: "Show issue details",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
ctx := context.Background()
// Resolve partial IDs first
@@ -346,7 +346,7 @@ var updateCmd = &cobra.Command{
Short: "Update one or more issues",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
updates := make(map[string]interface{})
if cmd.Flags().Changed("status") {
@@ -710,7 +710,7 @@ var closeCmd = &cobra.Command{
if reason == "" {
reason = "Closed"
}
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
ctx := context.Background()

View File

@@ -125,6 +125,8 @@ func TestShowCommand(t *testing.T) {
os.Stdout = w
// Reset command state
jsonOutput = true
defer func() { jsonOutput = false }()
rootCmd.SetArgs([]string{"show", issue1.ID, "--json"})
err := rootCmd.Execute()
@@ -171,6 +173,8 @@ func TestShowCommand(t *testing.T) {
os.Stdout = w
// Reset command state
jsonOutput = true
defer func() { jsonOutput = false }()
rootCmd.SetArgs([]string{"show", issue1.ID, issue2.ID, "--json"})
err := rootCmd.Execute()
@@ -204,6 +208,8 @@ func TestShowCommand(t *testing.T) {
os.Stdout = w
// Reset command state
jsonOutput = true
defer func() { jsonOutput = false }()
rootCmd.SetArgs([]string{"show", issue2.ID, "--json"})
err := rootCmd.Execute()
@@ -490,6 +496,8 @@ func TestUpdateCommand(t *testing.T) {
os.Stdout = w
// Reset command state
jsonOutput = true
defer func() { jsonOutput = false }()
rootCmd.SetArgs([]string{"update", issue.ID, "--priority", "3", "--json"})
err := rootCmd.Execute()
@@ -779,6 +787,8 @@ func TestCloseCommand(t *testing.T) {
os.Stdout = w
// Reset command state
jsonOutput = true
defer func() { jsonOutput = false }()
rootCmd.SetArgs([]string{"close", issue.ID, "--reason", "Fixed", "--json"})
err := rootCmd.Execute()

View File

@@ -26,7 +26,7 @@ This helps identify:
days, _ := cmd.Flags().GetInt("days")
status, _ := cmd.Flags().GetString("status")
limit, _ := cmd.Flags().GetInt("limit")
jsonOutput, _ := cmd.Flags().GetBool("json")
// Use global jsonOutput set by PersistentPreRun
// Validate status if provided
if status != "" && status != "open" && status != "in_progress" && status != "blocked" {

View File

@@ -2,7 +2,7 @@
bd init --prefix test
stdout 'initialized successfully'
exists .beads/beads.db
exists .beads/config.json
exists .beads/metadata.json
exists .beads/.gitignore
grep '^\*\.db$' .beads/.gitignore
grep '^\*\.db-journal$' .beads/.gitignore
@@ -12,4 +12,5 @@ grep '^daemon\.log$' .beads/.gitignore
grep '^daemon\.pid$' .beads/.gitignore
grep '^bd\.sock$' .beads/.gitignore
grep '^!\*\.jsonl$' .beads/.gitignore
grep '^!metadata\.json$' .beads/.gitignore
grep '^!config\.json$' .beads/.gitignore

View File

@@ -17,43 +17,50 @@ var v *viper.Viper
func Initialize() error {
v = viper.New()
// Set config file name and type
v.SetConfigName("config")
// Set config type to yaml (we only load config.yaml, not config.json)
v.SetConfigType("yaml")
// Add config search paths (in order of precedence)
// 1. Walk up from CWD to find project .beads/ directory
// Explicitly locate config.yaml and use SetConfigFile to avoid picking up config.json
// Precedence: project .beads/config.yaml > ~/.config/bd/config.yaml > ~/.beads/config.yaml
configFileSet := false
// 1. Walk up from CWD to find project .beads/config.yaml
// This allows commands to work from subdirectories
cwd, err := os.Getwd()
if err == nil {
if err == nil && !configFileSet {
// 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")
configPath := filepath.Join(beadsDir, "config.yaml")
if _, err := os.Stat(configPath); err == nil {
// Found .beads/config.yaml - add this path
v.AddConfigPath(beadsDir)
break
}
// Also check if .beads directory exists (even without config.yaml)
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
v.AddConfigPath(beadsDir)
// Found .beads/config.yaml - set it explicitly
v.SetConfigFile(configPath)
configFileSet = true
break
}
}
// Also add CWD/.beads for backward compatibility
v.AddConfigPath(filepath.Join(cwd, ".beads"))
}
// 2. User config directory (~/.config/bd/)
// 2. User config directory (~/.config/bd/config.yaml)
if !configFileSet {
if configDir, err := os.UserConfigDir(); err == nil {
v.AddConfigPath(filepath.Join(configDir, "bd"))
configPath := filepath.Join(configDir, "bd", "config.yaml")
if _, err := os.Stat(configPath); err == nil {
v.SetConfigFile(configPath)
configFileSet = true
}
}
}
// 3. Home directory (~/.beads/)
// 3. Home directory (~/.beads/config.yaml)
if !configFileSet {
if homeDir, err := os.UserHomeDir(); err == nil {
v.AddConfigPath(filepath.Join(homeDir, ".beads"))
configPath := filepath.Join(homeDir, ".beads", "config.yaml")
if _, err := os.Stat(configPath); err == nil {
v.SetConfigFile(configPath)
configFileSet = true
}
}
}
// Automatic environment variable binding
@@ -85,13 +92,19 @@ func Initialize() error {
v.SetDefault("flush-debounce", "30s")
v.SetDefault("auto-start-daemon", true)
// Read config file if it exists (don't error if not found)
// Read config file if it was found
if configFileSet {
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
// Config file found but another error occurred
return fmt.Errorf("error reading config file: %w", err)
}
// Config file not found - this is ok, we'll use defaults
if os.Getenv("BD_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "Debug: loaded config from %s\n", v.ConfigFileUsed())
}
} else {
// No config.yaml found - use defaults and environment variables
if os.Getenv("BD_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "Debug: no config.yaml found; using defaults and environment variables\n")
}
}
return nil

View File

@@ -7,7 +7,7 @@ import (
"path/filepath"
)
const ConfigFileName = "config.json"
const ConfigFileName = "metadata.json"
type Config struct {
Database string `json:"database"`
@@ -31,9 +31,33 @@ func Load(beadsDir string) (*Config, error) {
configPath := ConfigPath(beadsDir)
data, err := os.ReadFile(configPath) // #nosec G304 - controlled path from config
if os.IsNotExist(err) {
// Try legacy config.json location (migration path)
legacyPath := filepath.Join(beadsDir, "config.json")
data, err = os.ReadFile(legacyPath) // #nosec G304 - controlled path from config
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("reading legacy config: %w", err)
}
// Migrate: parse legacy config, save as metadata.json, remove old file
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing legacy config: %w", err)
}
// Save to new location
if err := cfg.Save(beadsDir); err != nil {
return nil, fmt.Errorf("migrating config to metadata.json: %w", err)
}
// Remove legacy file (best effort)
_ = os.Remove(legacyPath)
return &cfg, nil
}
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}

View File

@@ -120,7 +120,7 @@ func TestJSONLPath(t *testing.T) {
func TestConfigPath(t *testing.T) {
beadsDir := "/home/user/project/.beads"
got := ConfigPath(beadsDir)
want := filepath.Join(beadsDir, "config.json")
want := filepath.Join(beadsDir, "metadata.json")
if got != want {
t.Errorf("ConfigPath() = %q, want %q", got, want)