Adds a lightweight redirect mechanism that allows a stub .beads directory to point to the actual beads location. This solves the workspace problem where an AI agent runs in one directory but needs to operate on beads stored elsewhere. The redirect file is a simple text file containing a path (relative or absolute) to the target .beads directory. Comments (lines starting with #) are supported. Redirect chains are prevented - only one level is followed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
563 lines
18 KiB
Go
563 lines
18 KiB
Go
// Package beads provides a minimal public API for extending bd with custom orchestration.
|
|
//
|
|
// Most extensions should use direct SQL queries against bd's database.
|
|
// This package exports only the essential types and functions needed for
|
|
// Go-based extensions that want to use bd's storage layer programmatically.
|
|
//
|
|
// For detailed guidance on extending bd, see EXTENDING.md.
|
|
package beads
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/steveyegge/beads/internal/configfile"
|
|
"github.com/steveyegge/beads/internal/storage"
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
"github.com/steveyegge/beads/internal/utils"
|
|
)
|
|
|
|
// CanonicalDatabaseName is the required database filename for all beads repositories
|
|
const CanonicalDatabaseName = "beads.db"
|
|
|
|
// RedirectFileName is the name of the file that redirects to another .beads directory
|
|
const RedirectFileName = "redirect"
|
|
|
|
// LegacyDatabaseNames are old names that should be migrated
|
|
var LegacyDatabaseNames = []string{"bd.db", "issues.db", "bugs.db"}
|
|
|
|
// followRedirect checks if a .beads directory contains a redirect file and follows it.
|
|
// If a redirect file exists, it returns the target .beads directory path.
|
|
// If no redirect exists or there's an error, it returns the original path unchanged.
|
|
//
|
|
// The redirect file should contain a single path (relative or absolute) to the target
|
|
// .beads directory. Relative paths are resolved from the parent directory of the
|
|
// original .beads directory (i.e., the project root).
|
|
//
|
|
// Redirect chains are not followed - only one level of redirection is supported.
|
|
// This prevents infinite loops and keeps the behavior predictable.
|
|
func followRedirect(beadsDir string) string {
|
|
redirectFile := filepath.Join(beadsDir, RedirectFileName)
|
|
data, err := os.ReadFile(redirectFile)
|
|
if err != nil {
|
|
// No redirect file or can't read it - use original path
|
|
return beadsDir
|
|
}
|
|
|
|
// Parse the redirect target (trim whitespace and handle comments)
|
|
target := strings.TrimSpace(string(data))
|
|
|
|
// Skip empty lines and comments to find the actual path
|
|
lines := strings.Split(target, "\n")
|
|
target = ""
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line != "" && !strings.HasPrefix(line, "#") {
|
|
target = line
|
|
break
|
|
}
|
|
}
|
|
|
|
if target == "" {
|
|
return beadsDir
|
|
}
|
|
|
|
// Resolve relative paths from the parent of the .beads directory (project root)
|
|
if !filepath.IsAbs(target) {
|
|
projectRoot := filepath.Dir(beadsDir)
|
|
target = filepath.Join(projectRoot, target)
|
|
}
|
|
|
|
// Canonicalize the target path
|
|
target = utils.CanonicalizePath(target)
|
|
|
|
// Verify the target exists and is a directory
|
|
info, err := os.Stat(target)
|
|
if err != nil || !info.IsDir() {
|
|
// Invalid redirect target - fall back to original
|
|
fmt.Fprintf(os.Stderr, "Warning: redirect target does not exist or is not a directory: %s\n", target)
|
|
return beadsDir
|
|
}
|
|
|
|
// Prevent redirect chains - don't follow if target also has a redirect
|
|
targetRedirect := filepath.Join(target, RedirectFileName)
|
|
if _, err := os.Stat(targetRedirect); err == nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: redirect chains not allowed, ignoring redirect in %s\n", target)
|
|
}
|
|
|
|
return target
|
|
}
|
|
|
|
// findDatabaseInBeadsDir searches for a database file within a .beads directory.
|
|
// It implements the standard search order:
|
|
// 1. Check config.json first (single source of truth)
|
|
// 2. Fall back to canonical beads.db
|
|
// 3. Search for *.db files, filtering out backups and vc.db
|
|
//
|
|
// If warnOnIssues is true, warnings are printed to stderr for:
|
|
// - Multiple databases found (ambiguous state)
|
|
// - Legacy database names that should be migrated
|
|
//
|
|
// Returns empty string if no database is found.
|
|
func findDatabaseInBeadsDir(beadsDir string, warnOnIssues bool) string {
|
|
// Check for config.json first (single source of truth)
|
|
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil {
|
|
dbPath := cfg.DatabasePath(beadsDir)
|
|
if _, err := os.Stat(dbPath); err == nil {
|
|
return dbPath
|
|
}
|
|
}
|
|
|
|
// Fall back to canonical beads.db for backward compatibility
|
|
canonicalDB := filepath.Join(beadsDir, 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(beadsDir, "*.db"))
|
|
if err != nil || len(matches) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// Filter out backup files and vc.db
|
|
var validDBs []string
|
|
for _, match := range matches {
|
|
baseName := filepath.Base(match)
|
|
// Skip backup files (contains ".backup" in name) and vc.db
|
|
if !strings.Contains(baseName, ".backup") && baseName != "vc.db" {
|
|
validDBs = append(validDBs, match)
|
|
}
|
|
}
|
|
|
|
if len(validDBs) == 0 {
|
|
return ""
|
|
}
|
|
|
|
if warnOnIssues {
|
|
// Warn about multiple databases found
|
|
if len(validDBs) > 1 {
|
|
fmt.Fprintf(os.Stderr, "Warning: Multiple database files found in %s:\n", beadsDir)
|
|
for _, db := range validDBs {
|
|
fmt.Fprintf(os.Stderr, " - %s\n", filepath.Base(db))
|
|
}
|
|
fmt.Fprintf(os.Stderr, "Run 'bd init' to migrate to %s or manually remove old databases.\n\n", CanonicalDatabaseName)
|
|
}
|
|
|
|
// Warn about legacy database names
|
|
dbName := filepath.Base(validDBs[0])
|
|
if dbName != CanonicalDatabaseName {
|
|
for _, legacy := range LegacyDatabaseNames {
|
|
if dbName == legacy {
|
|
fmt.Fprintf(os.Stderr, "WARNING: Using legacy database name: %s\n", dbName)
|
|
fmt.Fprintf(os.Stderr, "Run 'bd migrate' to upgrade to canonical name: %s\n\n", CanonicalDatabaseName)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return validDBs[0]
|
|
}
|
|
|
|
// Issue represents a tracked work item with metadata, dependencies, and status.
|
|
type (
|
|
Issue = types.Issue
|
|
// Status represents the current state of an issue (open, in progress, closed, blocked).
|
|
Status = types.Status
|
|
// IssueType represents the type of issue (bug, feature, task, epic, chore).
|
|
IssueType = types.IssueType
|
|
// Dependency represents a relationship between issues.
|
|
Dependency = types.Dependency
|
|
// DependencyType represents the type of dependency (blocks, related, parent-child, discovered-from).
|
|
DependencyType = types.DependencyType
|
|
// Comment represents a user comment on an issue.
|
|
Comment = types.Comment
|
|
// Event represents an audit log event.
|
|
Event = types.Event
|
|
// EventType represents the type of audit event.
|
|
EventType = types.EventType
|
|
// Label represents a tag attached to an issue.
|
|
Label = types.Label
|
|
// BlockedIssue represents an issue with blocking dependencies.
|
|
BlockedIssue = types.BlockedIssue
|
|
// TreeNode represents a node in a dependency tree.
|
|
TreeNode = types.TreeNode
|
|
// Statistics represents project-wide metrics.
|
|
Statistics = types.Statistics
|
|
// IssueFilter represents filtering criteria for issue queries.
|
|
IssueFilter = types.IssueFilter
|
|
// WorkFilter represents filtering criteria for work queries.
|
|
WorkFilter = types.WorkFilter
|
|
// SortPolicy determines how ready work is ordered.
|
|
SortPolicy = types.SortPolicy
|
|
// EpicStatus represents the status of an epic issue.
|
|
EpicStatus = types.EpicStatus
|
|
)
|
|
|
|
// Status constants
|
|
const (
|
|
StatusOpen = types.StatusOpen
|
|
StatusInProgress = types.StatusInProgress
|
|
StatusClosed = types.StatusClosed
|
|
StatusBlocked = types.StatusBlocked
|
|
)
|
|
|
|
// IssueType constants
|
|
const (
|
|
TypeBug = types.TypeBug
|
|
TypeFeature = types.TypeFeature
|
|
TypeTask = types.TypeTask
|
|
TypeEpic = types.TypeEpic
|
|
TypeChore = types.TypeChore
|
|
)
|
|
|
|
// DependencyType constants
|
|
const (
|
|
DepBlocks = types.DepBlocks
|
|
DepRelated = types.DepRelated
|
|
DepParentChild = types.DepParentChild
|
|
DepDiscoveredFrom = types.DepDiscoveredFrom
|
|
)
|
|
|
|
// SortPolicy constants
|
|
const (
|
|
SortPolicyHybrid = types.SortPolicyHybrid
|
|
SortPolicyPriority = types.SortPolicyPriority
|
|
SortPolicyOldest = types.SortPolicyOldest
|
|
)
|
|
|
|
// EventType constants
|
|
const (
|
|
EventCreated = types.EventCreated
|
|
EventUpdated = types.EventUpdated
|
|
EventStatusChanged = types.EventStatusChanged
|
|
EventCommented = types.EventCommented
|
|
EventClosed = types.EventClosed
|
|
EventReopened = types.EventReopened
|
|
EventDependencyAdded = types.EventDependencyAdded
|
|
EventDependencyRemoved = types.EventDependencyRemoved
|
|
EventLabelAdded = types.EventLabelAdded
|
|
EventLabelRemoved = types.EventLabelRemoved
|
|
EventCompacted = types.EventCompacted
|
|
)
|
|
|
|
// Storage provides the minimal interface for extension orchestration
|
|
type Storage = storage.Storage
|
|
|
|
// Transaction provides atomic multi-operation support within a database transaction.
|
|
// Use Storage.RunInTransaction() to obtain a Transaction instance.
|
|
type Transaction = storage.Transaction
|
|
|
|
// NewSQLiteStorage opens a bd SQLite database for programmatic access.
|
|
// Most extensions should use this to query ready work and update issue status.
|
|
func NewSQLiteStorage(ctx context.Context, dbPath string) (Storage, error) {
|
|
return sqlite.New(ctx, dbPath)
|
|
}
|
|
|
|
// FindDatabasePath discovers the bd database path using bd's standard search order:
|
|
// 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
|
|
//
|
|
// Redirect files are supported: if a .beads/redirect file exists, its contents
|
|
// are used as the actual .beads directory path.
|
|
//
|
|
// Returns empty string if no database is found.
|
|
func FindDatabasePath() string {
|
|
// 1. Check BEADS_DIR environment variable (preferred)
|
|
if beadsDir := os.Getenv("BEADS_DIR"); beadsDir != "" {
|
|
// Canonicalize the path to prevent nested .beads directories
|
|
absBeadsDir := utils.CanonicalizePath(beadsDir)
|
|
|
|
// Follow redirect if present
|
|
absBeadsDir = followRedirect(absBeadsDir)
|
|
|
|
// Use helper to find database (no warnings for BEADS_DIR - user explicitly set it)
|
|
if dbPath := findDatabaseInBeadsDir(absBeadsDir, false); dbPath != "" {
|
|
return dbPath
|
|
}
|
|
|
|
// 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 != "" {
|
|
return utils.CanonicalizePath(envDB)
|
|
}
|
|
|
|
// 3. Search for .beads/*.db in current directory and ancestors
|
|
if foundDB := findDatabaseInTree(); foundDB != "" {
|
|
return utils.CanonicalizePath(foundDB)
|
|
}
|
|
|
|
// No fallback to ~/.beads - return empty string
|
|
return ""
|
|
}
|
|
|
|
// hasBeadsProjectFiles checks if a .beads directory contains actual project files.
|
|
// Returns true if the directory contains any of:
|
|
// - metadata.json or config.yaml (project configuration)
|
|
// - Any *.db file (excluding backups and vc.db)
|
|
// - Any *.jsonl file (JSONL-only mode or git-tracked issues)
|
|
//
|
|
// Returns false for directories that only contain daemon registry files (bd-420).
|
|
// This prevents FindBeadsDir from returning ~/.beads/ which only has registry.json.
|
|
func hasBeadsProjectFiles(beadsDir string) bool {
|
|
// Check for project configuration files
|
|
if _, err := os.Stat(filepath.Join(beadsDir, "metadata.json")); err == nil {
|
|
return true
|
|
}
|
|
if _, err := os.Stat(filepath.Join(beadsDir, "config.yaml")); err == nil {
|
|
return true
|
|
}
|
|
|
|
// Check for database files (excluding backups and vc.db)
|
|
dbMatches, _ := filepath.Glob(filepath.Join(beadsDir, "*.db"))
|
|
for _, match := range dbMatches {
|
|
baseName := filepath.Base(match)
|
|
if !strings.Contains(baseName, ".backup") && baseName != "vc.db" {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Check for JSONL files (JSONL-only mode or fresh clone)
|
|
jsonlMatches, _ := filepath.Glob(filepath.Join(beadsDir, "*.jsonl"))
|
|
if len(jsonlMatches) > 0 {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// FindBeadsDir finds the .beads/ directory in the current directory tree
|
|
// Returns empty string if not found. Supports both database and JSONL-only mode.
|
|
// Stops at the git repository root to avoid finding unrelated directories (bd-c8x).
|
|
// Validates that the directory contains actual project files (bd-420).
|
|
// Redirect files are supported: if a .beads/redirect file exists, its contents
|
|
// are used as the actual .beads directory path.
|
|
// This is useful for commands that need to detect beads projects without requiring a database.
|
|
func FindBeadsDir() string {
|
|
// 1. Check BEADS_DIR environment variable (preferred)
|
|
if beadsDir := os.Getenv("BEADS_DIR"); beadsDir != "" {
|
|
absBeadsDir := utils.CanonicalizePath(beadsDir)
|
|
|
|
// Follow redirect if present
|
|
absBeadsDir = followRedirect(absBeadsDir)
|
|
|
|
if info, err := os.Stat(absBeadsDir); err == nil && info.IsDir() {
|
|
// Validate directory contains actual project files (bd-420)
|
|
if hasBeadsProjectFiles(absBeadsDir) {
|
|
return absBeadsDir
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Search for .beads/ in current directory and ancestors
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
// Find git root to limit the search (bd-c8x)
|
|
gitRoot := findGitRoot()
|
|
|
|
for dir := cwd; dir != "/" && dir != "."; dir = filepath.Dir(dir) {
|
|
beadsDir := filepath.Join(dir, ".beads")
|
|
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
|
|
// Follow redirect if present
|
|
beadsDir = followRedirect(beadsDir)
|
|
|
|
// Validate directory contains actual project files (bd-420)
|
|
if hasBeadsProjectFiles(beadsDir) {
|
|
return beadsDir
|
|
}
|
|
}
|
|
|
|
// Stop at git root to avoid finding unrelated directories (bd-c8x)
|
|
if gitRoot != "" && dir == gitRoot {
|
|
break
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// FindJSONLPath returns the expected JSONL file path for the given database path.
|
|
// It searches for existing *.jsonl files in the database directory and returns
|
|
// the first one found, preferring issues.jsonl over beads.jsonl (bd-6xd).
|
|
//
|
|
// This function does not create directories or files - it only discovers paths.
|
|
// Use this when you need to know where bd stores its JSONL export.
|
|
func FindJSONLPath(dbPath string) string {
|
|
if dbPath == "" {
|
|
return ""
|
|
}
|
|
|
|
// Get the directory containing the database and delegate to shared utility
|
|
return utils.FindJSONLInDir(filepath.Dir(dbPath))
|
|
}
|
|
|
|
// DatabaseInfo contains information about a discovered beads database
|
|
type DatabaseInfo struct {
|
|
Path string // Full path to the .db file
|
|
BeadsDir string // Parent .beads directory
|
|
IssueCount int // Number of issues (-1 if unknown)
|
|
}
|
|
|
|
// findGitRoot returns the root directory of the current git repository,
|
|
// or empty string if not in a git repository. Used to limit directory
|
|
// tree walking to within the current git repo (bd-c8x).
|
|
func findGitRoot() string {
|
|
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(output))
|
|
}
|
|
|
|
// findDatabaseInTree walks up the directory tree looking for .beads/*.db
|
|
// Stops at the git repository root to avoid finding unrelated databases (bd-c8x).
|
|
// Prefers config.json, falls back to beads.db, and warns if multiple .db files exist.
|
|
// Redirect files are supported: if a .beads/redirect file exists, its contents
|
|
// are used as the actual .beads directory path.
|
|
func findDatabaseInTree() string {
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
// Resolve symlinks in working directory to ensure consistent path handling
|
|
// This prevents issues when repos are accessed via symlinks (e.g. /Users/user/Code -> /Users/user/Documents/Code)
|
|
if resolvedDir, err := filepath.EvalSymlinks(dir); err == nil {
|
|
dir = resolvedDir
|
|
}
|
|
|
|
// Find git root to limit the search (bd-c8x)
|
|
gitRoot := findGitRoot()
|
|
|
|
// Walk up directory tree
|
|
for {
|
|
beadsDir := filepath.Join(dir, ".beads")
|
|
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
|
|
// Follow redirect if present
|
|
beadsDir = followRedirect(beadsDir)
|
|
|
|
// Use helper to find database (with warnings for auto-discovery)
|
|
if dbPath := findDatabaseInBeadsDir(beadsDir, true); dbPath != "" {
|
|
return dbPath
|
|
}
|
|
}
|
|
|
|
// Move up one directory
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
// Reached filesystem root
|
|
break
|
|
}
|
|
|
|
// Stop at git root to avoid finding unrelated databases (bd-c8x)
|
|
if gitRoot != "" && dir == gitRoot {
|
|
break
|
|
}
|
|
|
|
dir = parent
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// FindAllDatabases scans the directory hierarchy for all .beads directories
|
|
// Returns a slice of DatabaseInfo for each database found, starting from the
|
|
// closest to CWD (most relevant) to the furthest (least relevant).
|
|
// Stops at the git repository root to avoid finding unrelated databases (bd-c8x).
|
|
// Redirect files are supported: if a .beads/redirect file exists, its contents
|
|
// are used as the actual .beads directory path.
|
|
func FindAllDatabases() []DatabaseInfo {
|
|
databases := []DatabaseInfo{} // Initialize to empty slice, never return nil
|
|
seen := make(map[string]bool) // Track canonical paths to avoid duplicates
|
|
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return databases
|
|
}
|
|
|
|
// Find git root to limit the search (bd-c8x)
|
|
gitRoot := findGitRoot()
|
|
|
|
// Walk up directory tree
|
|
for {
|
|
beadsDir := filepath.Join(dir, ".beads")
|
|
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
|
|
// Follow redirect if present
|
|
beadsDir = followRedirect(beadsDir)
|
|
|
|
// Found .beads/ directory, look for *.db files
|
|
matches, err := filepath.Glob(filepath.Join(beadsDir, "*.db"))
|
|
if err == nil && len(matches) > 0 {
|
|
dbPath := matches[0]
|
|
|
|
// Resolve symlinks to get canonical path for deduplication
|
|
canonicalPath := dbPath
|
|
if resolved, err := filepath.EvalSymlinks(dbPath); err == nil {
|
|
canonicalPath = resolved
|
|
}
|
|
|
|
// Skip if we've already seen this database (via symlink or other path)
|
|
if seen[canonicalPath] {
|
|
// Move up one directory
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
break
|
|
}
|
|
dir = parent
|
|
continue
|
|
}
|
|
seen[canonicalPath] = true
|
|
|
|
// Count issues if we can open the database (best-effort)
|
|
issueCount := -1
|
|
// Don't fail if we can't open/query the database - it might be locked
|
|
// or corrupted, but we still want to detect and warn about it
|
|
ctx := context.Background()
|
|
store, err := sqlite.New(ctx, dbPath)
|
|
if err == nil {
|
|
if issues, err := store.SearchIssues(ctx, "", types.IssueFilter{}); err == nil {
|
|
issueCount = len(issues)
|
|
}
|
|
_ = store.Close()
|
|
}
|
|
|
|
databases = append(databases, DatabaseInfo{
|
|
Path: dbPath,
|
|
BeadsDir: beadsDir,
|
|
IssueCount: issueCount,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Move up one directory
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
// Reached filesystem root
|
|
break
|
|
}
|
|
|
|
// Stop at git root to avoid finding unrelated databases (bd-c8x)
|
|
if gitRoot != "" && dir == gitRoot {
|
|
break
|
|
}
|
|
|
|
dir = parent
|
|
}
|
|
|
|
return databases
|
|
}
|