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

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,36 +212,93 @@ bd.db
}
}
// Create config.json for explicit configuration
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)
// 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 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)
issueCount, jsonlPath := checkGitForIssues()
if issueCount > 0 {
// Check if git has existing issues to import (fresh clone scenario)
issueCount, jsonlPath := checkGitForIssues()
if issueCount > 0 {
if !quiet {
fmt.Fprintf(os.Stderr, "\n✓ Database initialized. Found %d issues in git, importing...\n", issueCount)
fmt.Fprintf(os.Stderr, "\n✓ Database initialized. Found %d issues in git, importing...\n", issueCount)
}
if err := importFromGit(ctx, initDBPath, store, jsonlPath); err != nil {
if !quiet {
fmt.Fprintf(os.Stderr, "Warning: auto-import failed: %v\n", err)
fmt.Fprintf(os.Stderr, "Try manually: git show HEAD:%s | bd import -i /dev/stdin\n", jsonlPath)
}
// Non-fatal - continue with empty database
if !quiet {
fmt.Fprintf(os.Stderr, "Warning: auto-import failed: %v\n", err)
fmt.Fprintf(os.Stderr, "Try manually: git show HEAD:%s | bd import -i /dev/stdin\n", jsonlPath)
}
// Non-fatal - continue with empty database
} else if !quiet {
fmt.Fprintf(os.Stderr, "✓ Successfully imported %d issues from git.\n\n", issueCount)
fmt.Fprintf(os.Stderr, "✓ Successfully imported %d issues from git.\n\n", issueCount)
}
}
}
if err := store.Close(); err != nil {
if err := store.Close(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
}
}
// Check if we're in a git repo and hooks aren't installed
// Do this BEFORE quiet mode return so hooks get installed for agents

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