Implement BEADS_DIR environment variable (bd-e16b)

Add BEADS_DIR as a replacement for BEADS_DB to point to the .beads
directory instead of the database file directly.

Rationale:
- With --no-db mode, there's no .db file to point to
- The .beads directory is the logical unit (contains config.yaml, db
  files, jsonl files)
- More intuitive: point to the beads directory not the database file

Implementation:
- Add BEADS_DIR environment variable support to FindDatabasePath()
- Priority order: BEADS_DIR > BEADS_DB > auto-discovery
- Maintain backward compatibility with BEADS_DB (now deprecated)
- Update --no-db mode to respect BEADS_DIR
- Update MCP integration (config.py, bd_client.py)
- Update documentation to show BEADS_DIR as preferred method

Testing:
- Backward compatibility: BEADS_DB still works
- BEADS_DIR works with regular database mode
- BEADS_DIR works with --no-db mode
- Priority: BEADS_DIR takes precedence over BEADS_DB

Follow-up issues for refactoring:
- bd-efe8: Refactor path canonicalization into helper function
- bd-c362: Extract database search logic into helper function

Closes bd-e16b

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-11-02 18:34:34 -08:00
parent c5b2fbbc9d
commit 6ecfd04ec8
8 changed files with 187 additions and 54 deletions

File diff suppressed because one or more lines are too long

View File

@@ -146,7 +146,8 @@ This means bd found multiple `.beads` directories in your directory hierarchy. T
1. **If you have nested projects** (intentional):
- This is fine! bd is designed to support this
- Just be aware which database you're using
- Set `BEADS_DB` environment variable if you want to override the default selection
- Set `BEADS_DIR` environment variable to point to your `.beads` directory if you want to override the default selection
- Or use `BEADS_DB` (deprecated) to point directly to the database file
2. **If you have accidental duplicates** (unintentional):
- Decide which database to keep
@@ -156,10 +157,14 @@ This means bd found multiple `.beads` directories in your directory hierarchy. T
3. **Override database selection**:
```bash
# Temporarily use specific database
BEADS_DB=/path/to/.beads/issues.db bd list
# Temporarily use specific .beads directory (recommended)
BEADS_DIR=/path/to/.beads bd list
# Or add to shell config for permanent override
export BEADS_DIR=/path/to/.beads
# Legacy method (deprecated, points to database file directly)
BEADS_DB=/path/to/.beads/issues.db bd list
export BEADS_DB=/path/to/.beads/issues.db
```

View File

@@ -118,12 +118,61 @@ func NewSQLiteStorage(dbPath string) (Storage, error) {
}
// FindDatabasePath discovers the bd database path using bd's standard search order:
// 1. $BEADS_DB environment variable
// 2. .beads/*.db in current directory or ancestors
// 1. $BEADS_DIR environment variable (points to .beads directory)
// 2. $BEADS_DB environment variable (points directly to database file, deprecated)
// 3. .beads/*.db in current directory or ancestors
//
// Returns empty string if no database is found.
func FindDatabasePath() string {
// 1. Check environment variable
// 1. Check BEADS_DIR environment variable (preferred)
if beadsDir := os.Getenv("BEADS_DIR"); beadsDir != "" {
// Canonicalize the path to prevent nested .beads directories
var absBeadsDir string
if absPath, err := filepath.Abs(beadsDir); err == nil {
if canonical, err := filepath.EvalSymlinks(absPath); err == nil {
absBeadsDir = canonical
} else {
absBeadsDir = absPath
}
} else {
absBeadsDir = beadsDir
}
// Check for config.json first (single source of truth)
if cfg, err := configfile.Load(absBeadsDir); err == nil && cfg != nil {
dbPath := cfg.DatabasePath(absBeadsDir)
if _, err := os.Stat(dbPath); err == nil {
return dbPath
}
}
// Fall back to canonical beads.db for backward compatibility
canonicalDB := filepath.Join(absBeadsDir, CanonicalDatabaseName)
if _, err := os.Stat(canonicalDB); err == nil {
return canonicalDB
}
// Look for any .db file in the beads directory
matches, err := filepath.Glob(filepath.Join(absBeadsDir, "*.db"))
if err == nil && len(matches) > 0 {
// Filter out backup files and vc.db
var validDBs []string
for _, match := range matches {
baseName := filepath.Base(match)
if !strings.Contains(baseName, ".backup") && baseName != "vc.db" {
validDBs = append(validDBs, match)
}
}
if len(validDBs) > 0 {
return validDBs[0]
}
}
// BEADS_DIR is set but no database found - this is OK for --no-db mode
// Return empty string and let the caller handle it
}
// 2. Check BEADS_DB environment variable (deprecated but still supported)
if envDB := os.Getenv("BEADS_DB"); envDB != "" {
// Canonicalize the path to prevent nested .beads directories
if absDB, err := filepath.Abs(envDB); err == nil {
@@ -135,7 +184,7 @@ func FindDatabasePath() string {
return envDB // Fallback to original if Abs fails
}
// 2. Search for .beads/*.db in current directory and ancestors
// 3. Search for .beads/*.db in current directory and ancestors
if foundDB := findDatabaseInTree(); foundDB != "" {
// Canonicalize found path
if absDB, err := filepath.Abs(foundDB); err == nil {

View File

@@ -177,7 +177,8 @@ var rootCmd = &cobra.Command{
// No database found - error out instead of falling back to ~/.beads
fmt.Fprintf(os.Stderr, "Error: no beads database found\n")
fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to create a database in the current directory\n")
fmt.Fprintf(os.Stderr, " or set BEADS_DB environment variable to specify a database\n")
fmt.Fprintf(os.Stderr, " or set BEADS_DIR to point to your .beads directory\n")
fmt.Fprintf(os.Stderr, " or set BEADS_DB to point to your database file (deprecated)\n")
os.Exit(1)
}
}
@@ -438,13 +439,29 @@ var rootCmd = &cobra.Command{
// Handle --no-db mode: write memory storage back to JSONL
if noDb {
if store != nil {
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to get current directory: %v\n", err)
os.Exit(1)
// Determine beads directory (respect BEADS_DIR)
var beadsDir string
if envDir := os.Getenv("BEADS_DIR"); envDir != "" {
// Canonicalize the path
if absDir, err := filepath.Abs(envDir); err == nil {
if canonical, err := filepath.EvalSymlinks(absDir); err == nil {
beadsDir = canonical
} else {
beadsDir = absDir
}
} else {
beadsDir = envDir
}
} else {
// Fall back to current directory
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to get current directory: %v\n", err)
os.Exit(1)
}
beadsDir = filepath.Join(cwd, ".beads")
}
beadsDir := filepath.Join(cwd, ".beads")
if memStore, ok := store.(*memory.MemoryStorage); ok {
if err := writeIssuesToJSONL(memStore, beadsDir); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to write JSONL: %v\n", err)

View File

@@ -18,14 +18,31 @@ import (
// This is called when --no-db flag is set
func initializeNoDbMode() error {
// Find .beads directory
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
var beadsDir string
// Check BEADS_DIR environment variable first
if envDir := os.Getenv("BEADS_DIR"); envDir != "" {
// Canonicalize the path
if absDir, err := filepath.Abs(envDir); err == nil {
if canonical, err := filepath.EvalSymlinks(absDir); err == nil {
beadsDir = canonical
} else {
beadsDir = absDir
}
} else {
beadsDir = envDir
}
} else {
// Fall back to current directory
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
beadsDir = filepath.Join(cwd, ".beads")
}
beadsDir := filepath.Join(cwd, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return fmt.Errorf("no .beads directory found (hint: run 'bd init' first)")
return fmt.Errorf("no .beads directory found (hint: run 'bd init' first or set BEADS_DIR)")
}
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")

View File

@@ -371,13 +371,13 @@ Use when parsing programmatically or extracting specific fields.
bd finds database in this order:
1. `--db` flag: `bd ready --db /path/to/db.db`
2. `$BEADS_DB` environment variable
3. `.beads/*.db` in current directory or ancestors
4. `~/.beads/default.db` as fallback
2. `$BEADS_DIR` environment variable (points to .beads directory)
3. `$BEADS_DB` environment variable (deprecated, points to database file)
4. `.beads/*.db` in current directory or ancestors
**Project-local** (`.beads/`): Project-specific work, git-tracked
**Global fallback** (`~/.beads/`): Cross-project tracking, personal tasks
**Recommended**: Use `BEADS_DIR` to point to your `.beads` directory, especially when using `--no-db` mode
---

View File

@@ -144,6 +144,7 @@ class BdCliClient(BdClientBase):
"""Client for calling bd CLI commands and parsing JSON output."""
bd_path: str
beads_dir: str | None
beads_db: str | None
actor: str | None
no_auto_flush: bool
@@ -153,6 +154,7 @@ class BdCliClient(BdClientBase):
def __init__(
self,
bd_path: str | None = None,
beads_dir: str | None = None,
beads_db: str | None = None,
actor: str | None = None,
no_auto_flush: bool | None = None,
@@ -163,7 +165,8 @@ class BdCliClient(BdClientBase):
Args:
bd_path: Path to bd executable (optional, loads from config if not provided)
beads_db: Path to beads database file (optional, loads from config if not provided)
beads_dir: Path to .beads directory (optional, loads from config if not provided)
beads_db: Path to beads database file (deprecated, optional, loads from config if not provided)
actor: Actor name for audit trail (optional, loads from config if not provided)
no_auto_flush: Disable automatic JSONL sync (optional, loads from config if not provided)
no_auto_import: Disable automatic JSONL import (optional, loads from config if not provided)
@@ -171,6 +174,7 @@ class BdCliClient(BdClientBase):
"""
config = load_config()
self.bd_path = bd_path if bd_path is not None else config.beads_path
self.beads_dir = beads_dir if beads_dir is not None else config.beads_dir
self.beads_db = beads_db if beads_db is not None else config.beads_db
self.actor = actor if actor is not None else config.beads_actor
self.no_auto_flush = no_auto_flush if no_auto_flush is not None else config.beads_no_auto_flush
@@ -225,7 +229,12 @@ class BdCliClient(BdClientBase):
# Log database routing for debugging
import sys
working_dir = self._get_working_dir()
db_info = self.beads_db if self.beads_db else "auto-discover"
if self.beads_dir:
db_info = f"BEADS_DIR={self.beads_dir}"
elif self.beads_db:
db_info = f"BEADS_DB={self.beads_db} (deprecated)"
else:
db_info = "auto-discover"
print(f"[beads-mcp] Running bd command: {' '.join(args)}", file=sys.stderr)
print(f"[beads-mcp] Database: {db_info}", file=sys.stderr)
print(f"[beads-mcp] Working dir: {working_dir}", file=sys.stderr)
@@ -656,6 +665,7 @@ BdClient = BdCliClient
def create_bd_client(
prefer_daemon: bool = False,
bd_path: Optional[str] = None,
beads_dir: Optional[str] = None,
beads_db: Optional[str] = None,
actor: Optional[str] = None,
no_auto_flush: Optional[bool] = None,
@@ -667,7 +677,8 @@ def create_bd_client(
Args:
prefer_daemon: If True, attempt to use daemon client first, fall back to CLI
bd_path: Path to bd executable (for CLI client)
beads_db: Path to beads database (for CLI client)
beads_dir: Path to .beads directory (for CLI client)
beads_db: Path to beads database (deprecated, for CLI client)
actor: Actor name for audit trail
no_auto_flush: Disable auto-flush (CLI only)
no_auto_import: Disable auto-import (CLI only)
@@ -732,6 +743,7 @@ def create_bd_client(
# Use CLI client
return BdCliClient(
bd_path=bd_path,
beads_dir=beads_dir,
beads_db=beads_db,
actor=actor,
no_auto_flush=no_auto_flush,

View File

@@ -32,6 +32,7 @@ class Config(BaseSettings):
model_config = SettingsConfigDict(env_prefix="")
beads_path: str = Field(default_factory=_default_beads_path)
beads_dir: str | None = None
beads_db: str | None = None
beads_actor: str | None = None
beads_no_auto_flush: bool = False
@@ -75,6 +76,35 @@ class Config(BaseSettings):
return v
@field_validator("beads_dir")
@classmethod
def validate_beads_dir(cls, v: str | None) -> str | None:
"""Validate BEADS_DIR points to an existing .beads directory.
Args:
v: Path to .beads directory or None
Returns:
Validated path or None
Raises:
ValueError: If path is set but directory doesn't exist
"""
if v is None:
return v
path = Path(v)
if not path.exists():
raise ValueError(
f"BEADS_DIR points to non-existent directory: {v}\n"
+ "Please verify the .beads directory path is correct."
)
if not path.is_dir():
raise ValueError(f"BEADS_DIR must point to a directory, not a file: {v}")
return v
@field_validator("beads_db")
@classmethod
def validate_beads_db(cls, v: str | None) -> str | None:
@@ -129,7 +159,8 @@ def load_config() -> Config:
+ "After installation, restart Claude Code.\n\n"
+ "Advanced configuration (optional):\n"
+ f" BEADS_PATH - Path to bd executable (default: {default_path})\n"
+ " BEADS_DB - Path to beads database file (default: auto-discover)\n"
+ " BEADS_DIR - Path to .beads directory (default: auto-discover)\n"
+ " BEADS_DB - Path to database file (deprecated, use BEADS_DIR)\n"
+ " BEADS_WORKING_DIR - Working directory for bd commands (default: $PWD or cwd)\n"
+ " BEADS_ACTOR - Actor name for audit trail (default: $USER)\n"
+ " BEADS_NO_AUTO_FLUSH - Disable automatic JSONL sync (default: false)\n"