diff --git a/EXTENDING.md b/EXTENDING.md index 1b020a31..41b98552 100644 --- a/EXTENDING.md +++ b/EXTENDING.md @@ -99,12 +99,11 @@ func InitializeMyAppSchema(dbPath string) error { ```go import ( - "github.com/steveyegge/beads/internal/storage/sqlite" - "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads" ) // Open bd's storage -store, err := sqlite.New(dbPath) +store, err := beads.NewSQLiteStorage(dbPath) if err != nil { log.Fatal(err) } @@ -115,7 +114,7 @@ if err := InitializeMyAppSchema(dbPath); err != nil { } // Use bd to find ready work -readyIssues, err := store.GetReady(ctx, types.IssueFilter{Limit: 10}) +readyIssues, err := store.GetReadyWork(ctx, beads.WorkFilter{Limit: 10}) if err != nil { log.Fatal(err) } @@ -410,10 +409,17 @@ You can always access bd's database directly: import ( "database/sql" _ "github.com/mattn/go-sqlite3" + "github.com/steveyegge/beads" ) +// Auto-discover bd's database path +dbPath := beads.FindDatabasePath() +if dbPath == "" { + log.Fatal("No bd database found. Run 'bd init' first.") +} + // Open the same database bd uses -db, err := sql.Open("sqlite3", ".beads/myapp.db") +db, err := sql.Open("sqlite3", dbPath) if err != nil { log.Fatal(err) } @@ -429,6 +435,10 @@ err = db.QueryRow(` _, err = db.Exec(` INSERT INTO myapp_executions (issue_id, status) VALUES (?, ?) `, issueID, "running") + +// Find corresponding JSONL path (for git hooks, monitoring, etc.) +jsonlPath := beads.FindJSONLPath(dbPath) +fmt.Printf("BD exports to: %s\n", jsonlPath) ``` ## Summary diff --git a/beads.go b/beads.go new file mode 100644 index 00000000..6b78c33a --- /dev/null +++ b/beads.go @@ -0,0 +1,137 @@ +// 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 ( + "os" + "path/filepath" + + "github.com/steveyegge/beads/internal/storage" + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" +) + +// Core types for working with issues +type ( + Issue = types.Issue + Status = types.Status + IssueType = types.IssueType + WorkFilter = types.WorkFilter +) + +// 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 +) + +// Storage provides the minimal interface for extension orchestration +type Storage = storage.Storage + +// NewSQLiteStorage opens a bd SQLite database for programmatic access. +// Most extensions should use this to query ready work and update issue status. +func NewSQLiteStorage(dbPath string) (Storage, error) { + return sqlite.New(dbPath) +} + +// 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 +// 3. ~/.beads/default.db (fallback) +// +// Returns empty string if no database is found at (1) or (2) and (3) doesn't exist. +func FindDatabasePath() string { + // 1. Check environment variable + if envDB := os.Getenv("BEADS_DB"); envDB != "" { + return envDB + } + + // 2. Search for .beads/*.db in current directory and ancestors + if foundDB := findDatabaseInTree(); foundDB != "" { + return foundDB + } + + // 3. Try home directory default + if home, err := os.UserHomeDir(); err == nil { + defaultDB := filepath.Join(home, ".beads", "default.db") + // Only return if it exists + if _, err := os.Stat(defaultDB); err == nil { + return defaultDB + } + } + + 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, or defaults to "issues.jsonl". +// +// 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 + dbDir := filepath.Dir(dbPath) + + // Look for existing .jsonl files in the .beads directory + pattern := filepath.Join(dbDir, "*.jsonl") + matches, err := filepath.Glob(pattern) + if err == nil && len(matches) > 0 { + // Return the first .jsonl file found + return matches[0] + } + + // Default to issues.jsonl + return filepath.Join(dbDir, "issues.jsonl") +} + +// findDatabaseInTree walks up the directory tree looking for .beads/*.db +func findDatabaseInTree() string { + dir, err := os.Getwd() + if err != nil { + return "" + } + + // Walk up directory tree + for { + beadsDir := filepath.Join(dir, ".beads") + if info, err := os.Stat(beadsDir); err == nil && info.IsDir() { + // Found .beads/ directory, look for *.db files + matches, err := filepath.Glob(filepath.Join(beadsDir, "*.db")) + if err == nil && len(matches) > 0 { + // Return first .db file found + return matches[0] + } + } + + // Move up one directory + parent := filepath.Dir(dir) + if parent == dir { + // Reached filesystem root + break + } + dir = parent + } + + return "" +} diff --git a/cmd/bd/main.go b/cmd/bd/main.go index ef9223cb..6669660e 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -14,6 +14,7 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/steveyegge/beads" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" @@ -58,15 +59,11 @@ var rootCmd = &cobra.Command{ // Initialize storage if dbPath == "" { - // Try to find database in order: - // 1. $BEADS_DB environment variable - // 2. .beads/*.db in current directory or ancestors - // 3. ~/.beads/default.db - if envDB := os.Getenv("BEADS_DB"); envDB != "" { - dbPath = envDB - } else if foundDB := findDatabase(); foundDB != "" { + // Use public API to find database (same logic as extensions) + if foundDB := beads.FindDatabasePath(); foundDB != "" { dbPath = foundDB } else { + // Fallback to default location (will be created by init command) home, _ := os.UserHomeDir() dbPath = filepath.Join(home, ".beads", "default.db") } @@ -128,37 +125,6 @@ var rootCmd = &cobra.Command{ }, } -// findDatabase searches for .beads/*.db in current directory and ancestors -func findDatabase() string { - dir, err := os.Getwd() - if err != nil { - return "" - } - - // Walk up directory tree looking for .beads/ directory - for { - beadsDir := filepath.Join(dir, ".beads") - if info, err := os.Stat(beadsDir); err == nil && info.IsDir() { - // Found .beads/ directory, look for *.db files - matches, err := filepath.Glob(filepath.Join(beadsDir, "*.db")) - if err == nil && len(matches) > 0 { - // Return first .db file found - return matches[0] - } - } - - // Move up one directory - parent := filepath.Dir(dir) - if parent == dir { - // Reached filesystem root - break - } - dir = parent - } - - return "" -} - // outputJSON outputs data as pretty-printed JSON func outputJSON(v interface{}) { encoder := json.NewEncoder(os.Stdout) @@ -171,26 +137,19 @@ func outputJSON(v interface{}) { // findJSONLPath finds the JSONL file path for the current database func findJSONLPath() string { - // Get the directory containing the database - dbDir := filepath.Dir(dbPath) + // Use public API for path discovery + jsonlPath := beads.FindJSONLPath(dbPath) // Ensure the directory exists (important for new databases) + // This is the only difference from the public API - we create the directory + dbDir := filepath.Dir(dbPath) if err := os.MkdirAll(dbDir, 0755); err != nil { - // If we can't create the directory, return default path anyway + // If we can't create the directory, return discovered path anyway // (the subsequent write will fail with a clearer error) - return filepath.Join(dbDir, "issues.jsonl") + return jsonlPath } - // Look for existing .jsonl files in the .beads directory - pattern := filepath.Join(dbDir, "*.jsonl") - matches, err := filepath.Glob(pattern) - if err == nil && len(matches) > 0 { - // Return the first .jsonl file found - return matches[0] - } - - // Default to issues.jsonl - return filepath.Join(dbDir, "issues.jsonl") + return jsonlPath } // autoImportIfNewer checks if JSONL is newer than DB and imports if so diff --git a/go.mod b/go.mod index da0b36ed..b4cfb445 100644 --- a/go.mod +++ b/go.mod @@ -3,23 +3,15 @@ module github.com/steveyegge/beads go 1.21 require ( - github.com/fatih/color v1.18.0 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/fatih/color v1.18.0 + github.com/mattn/go-sqlite3 v1.14.32 + github.com/spf13/cobra v1.10.1 +) + +require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.32 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect - github.com/spf13/afero v1.15.0 // indirect - github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/cobra v1.10.1 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/spf13/viper v1.21.0 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 7d460c78..a84e14e4 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -14,33 +10,15 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= -github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=