Implement JSONL export/import and shift to text-first architecture

This is a fundamental architectural shift from binary SQLite to JSONL as
the source of truth for git workflows.

## New Features

- `bd export --format=jsonl` - Export issues to JSON Lines format
- `bd import` - Import issues from JSONL (create new, update existing)
- `--skip-existing` flag for import to only create new issues

## Architecture Change

**Before:** Binary SQLite database committed to git
**After:** JSONL text files as source of truth, SQLite as ephemeral cache

Benefits:
- Git-friendly text format with clean diffs
- AI-resolvable merge conflicts (append-only is 95% conflict-free)
- Human-readable issue tracking in git
- No binary merge conflicts

## Documentation

- Updated README with JSONL-first workflow and git hooks
- Added TEXT_FORMATS.md analyzing JSONL vs CSV vs binary
- Updated GIT_WORKFLOW.md with historical context
- .gitignore now excludes *.db, includes .beads/*.jsonl

## Implementation Details

- Export sorts issues by ID for consistent diffs
- Import handles both creates and updates atomically
- Proper handling of pointer fields (EstimatedMinutes)
- All tests passing

## Breaking Changes

- Database files (*.db) should now be gitignored
- Use export/import workflow for git collaboration
- Git hooks recommended for automation

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-10-12 01:17:50 -07:00
parent 9105059843
commit 15afb5ad17
25 changed files with 3322 additions and 129 deletions

175
cmd/bd/dep.go Normal file
View File

@@ -0,0 +1,175 @@
package main
import (
"context"
"fmt"
"os"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/types"
)
var depCmd = &cobra.Command{
Use: "dep",
Short: "Manage dependencies",
}
var depAddCmd = &cobra.Command{
Use: "add [issue-id] [depends-on-id]",
Short: "Add a dependency",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
depType, _ := cmd.Flags().GetString("type")
dep := &types.Dependency{
IssueID: args[0],
DependsOnID: args[1],
Type: types.DependencyType(depType),
}
ctx := context.Background()
if err := store.AddDependency(ctx, dep, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
outputJSON(map[string]interface{}{
"status": "added",
"issue_id": args[0],
"depends_on_id": args[1],
"type": depType,
})
return
}
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Added dependency: %s depends on %s (%s)\n",
green("✓"), args[0], args[1], depType)
},
}
var depRemoveCmd = &cobra.Command{
Use: "remove [issue-id] [depends-on-id]",
Short: "Remove a dependency",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
ctx := context.Background()
if err := store.RemoveDependency(ctx, args[0], args[1], actor); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
outputJSON(map[string]interface{}{
"status": "removed",
"issue_id": args[0],
"depends_on_id": args[1],
})
return
}
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Removed dependency: %s no longer depends on %s\n",
green("✓"), args[0], args[1])
},
}
var depTreeCmd = &cobra.Command{
Use: "tree [issue-id]",
Short: "Show dependency tree",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := context.Background()
tree, err := store.GetDependencyTree(ctx, args[0], 50)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
// Always output array, even if empty
if tree == nil {
tree = []*types.TreeNode{}
}
outputJSON(tree)
return
}
if len(tree) == 0 {
fmt.Printf("\n%s has no dependencies\n", args[0])
return
}
cyan := color.New(color.FgCyan).SprintFunc()
fmt.Printf("\n%s Dependency tree for %s:\n\n", cyan("🌲"), args[0])
hasTruncation := false
for _, node := range tree {
indent := ""
for i := 0; i < node.Depth; i++ {
indent += " "
}
fmt.Printf("%s→ %s: %s [P%d] (%s)\n",
indent, node.ID, node.Title, node.Priority, node.Status)
if node.Truncated {
hasTruncation = true
}
}
if hasTruncation {
yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s Warning: Tree truncated at depth 50 (safety limit)\n",
yellow("⚠"))
}
fmt.Println()
},
}
var depCyclesCmd = &cobra.Command{
Use: "cycles",
Short: "Detect dependency cycles",
Run: func(cmd *cobra.Command, args []string) {
ctx := context.Background()
cycles, err := store.DetectCycles(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
// Always output array, even if empty
if cycles == nil {
cycles = [][]*types.Issue{}
}
outputJSON(cycles)
return
}
if len(cycles) == 0 {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("\n%s No dependency cycles detected\n\n", green("✓"))
return
}
red := color.New(color.FgRed).SprintFunc()
fmt.Printf("\n%s Found %d dependency cycles:\n\n", red("⚠"), len(cycles))
for i, cycle := range cycles {
fmt.Printf("%d. Cycle involving:\n", i+1)
for _, issue := range cycle {
fmt.Printf(" - %s: %s\n", issue.ID, issue.Title)
}
fmt.Println()
}
},
}
func init() {
depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|related|parent-child|discovered-from)")
depCmd.AddCommand(depAddCmd)
depCmd.AddCommand(depRemoveCmd)
depCmd.AddCommand(depTreeCmd)
depCmd.AddCommand(depCyclesCmd)
rootCmd.AddCommand(depCmd)
}

79
cmd/bd/export.go Normal file
View File

@@ -0,0 +1,79 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/types"
)
var exportCmd = &cobra.Command{
Use: "export",
Short: "Export issues to JSONL format",
Long: `Export all issues to JSON Lines format (one JSON object per line).
Issues are sorted by ID for consistent diffs.
Output to stdout by default, or use -o flag for file output.`,
Run: func(cmd *cobra.Command, args []string) {
format, _ := cmd.Flags().GetString("format")
output, _ := cmd.Flags().GetString("output")
statusFilter, _ := cmd.Flags().GetString("status")
if format != "jsonl" {
fmt.Fprintf(os.Stderr, "Error: only 'jsonl' format is currently supported\n")
os.Exit(1)
}
// Build filter
filter := types.IssueFilter{}
if statusFilter != "" {
status := types.Status(statusFilter)
filter.Status = &status
}
// Get all issues
ctx := context.Background()
issues, err := store.SearchIssues(ctx, "", filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Sort by ID for consistent output
sort.Slice(issues, func(i, j int) bool {
return issues[i].ID < issues[j].ID
})
// Open output
out := os.Stdout
if output != "" {
f, err := os.Create(output)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating output file: %v\n", err)
os.Exit(1)
}
defer f.Close()
out = f
}
// Write JSONL
encoder := json.NewEncoder(out)
for _, issue := range issues {
if err := encoder.Encode(issue); err != nil {
fmt.Fprintf(os.Stderr, "Error encoding issue %s: %v\n", issue.ID, err)
os.Exit(1)
}
}
},
}
func init() {
exportCmd.Flags().StringP("format", "f", "jsonl", "Export format (jsonl)")
exportCmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
exportCmd.Flags().StringP("status", "s", "", "Filter by status")
rootCmd.AddCommand(exportCmd)
}

133
cmd/bd/import.go Normal file
View File

@@ -0,0 +1,133 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/types"
)
var importCmd = &cobra.Command{
Use: "import",
Short: "Import issues from JSONL format",
Long: `Import issues from JSON Lines format (one JSON object per line).
Reads from stdin by default, or use -i flag for file input.
Behavior:
- Existing issues (same ID) are updated
- New issues are created
- Import is atomic (all or nothing)`,
Run: func(cmd *cobra.Command, args []string) {
input, _ := cmd.Flags().GetString("input")
skipUpdate, _ := cmd.Flags().GetBool("skip-existing")
// Open input
in := os.Stdin
if input != "" {
f, err := os.Open(input)
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening input file: %v\n", err)
os.Exit(1)
}
defer f.Close()
in = f
}
// Read and parse JSONL
ctx := context.Background()
scanner := bufio.NewScanner(in)
var created, updated, skipped int
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
// Skip empty lines
if line == "" {
continue
}
// Parse JSON
var issue types.Issue
if err := json.Unmarshal([]byte(line), &issue); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing line %d: %v\n", lineNum, err)
os.Exit(1)
}
// Check if issue exists
existing, err := store.GetIssue(ctx, issue.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error checking issue %s: %v\n", issue.ID, err)
os.Exit(1)
}
if existing != nil {
if skipUpdate {
skipped++
continue
}
// Update existing issue - convert to updates map
updates := make(map[string]interface{})
if issue.Title != "" {
updates["title"] = issue.Title
}
if issue.Description != "" {
updates["description"] = issue.Description
}
if issue.Status != "" {
updates["status"] = issue.Status
}
if issue.Priority != 0 {
updates["priority"] = issue.Priority
}
if issue.IssueType != "" {
updates["issue_type"] = issue.IssueType
}
if issue.Assignee != "" {
updates["assignee"] = issue.Assignee
}
if issue.EstimatedMinutes != nil {
updates["estimated_minutes"] = *issue.EstimatedMinutes
}
if err := store.UpdateIssue(ctx, issue.ID, updates, "import"); err != nil {
fmt.Fprintf(os.Stderr, "Error updating issue %s: %v\n", issue.ID, err)
os.Exit(1)
}
updated++
} else {
// Create new issue
if err := store.CreateIssue(ctx, &issue, "import"); err != nil {
fmt.Fprintf(os.Stderr, "Error creating issue %s: %v\n", issue.ID, err)
os.Exit(1)
}
created++
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
os.Exit(1)
}
// Print summary
fmt.Fprintf(os.Stderr, "Import complete: %d created, %d updated", created, updated)
if skipped > 0 {
fmt.Fprintf(os.Stderr, ", %d skipped", skipped)
}
fmt.Fprintf(os.Stderr, "\n")
},
}
func init() {
importCmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
importCmd.Flags().BoolP("skip-existing", "s", false, "Skip existing issues instead of updating them")
rootCmd.AddCommand(importCmd)
}

70
cmd/bd/init.go Normal file
View File

@@ -0,0 +1,70 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage/sqlite"
)
var initCmd = &cobra.Command{
Use: "init",
Short: "Initialize bd in the current directory",
Long: `Initialize bd in the current directory by creating a .beads/ directory
and database file. Optionally specify a custom issue prefix.`,
Run: func(cmd *cobra.Command, args []string) {
prefix, _ := cmd.Flags().GetString("prefix")
if prefix == "" {
// Auto-detect from directory name
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to get current directory: %v\n", err)
os.Exit(1)
}
prefix = filepath.Base(cwd)
}
// Create .beads directory
beadsDir := ".beads"
if err := os.MkdirAll(beadsDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create %s directory: %v\n", beadsDir, err)
os.Exit(1)
}
// Create database
dbPath := filepath.Join(beadsDir, prefix+".db")
store, err := sqlite.New(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create database: %v\n", err)
os.Exit(1)
}
// Set the issue prefix in config
ctx := context.Background()
if err := store.SetConfig(ctx, "issue_prefix", prefix); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to set issue prefix: %v\n", err)
store.Close()
os.Exit(1)
}
store.Close()
green := color.New(color.FgGreen).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
fmt.Printf("\n%s bd initialized successfully!\n\n", green("✓"))
fmt.Printf(" Database: %s\n", cyan(dbPath))
fmt.Printf(" Issue prefix: %s\n", cyan(prefix))
fmt.Printf(" Issues will be named: %s\n\n", cyan(prefix+"-1, "+prefix+"-2, ..."))
fmt.Printf("Run %s to get started.\n\n", cyan("bd quickstart"))
},
}
func init() {
initCmd.Flags().StringP("prefix", "p", "", "Issue prefix (default: current directory name)")
rootCmd.AddCommand(initCmd)
}

426
cmd/bd/main.go Normal file
View File

@@ -0,0 +1,426 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
var (
dbPath string
actor string
store storage.Storage
jsonOutput bool
)
var rootCmd = &cobra.Command{
Use: "bd",
Short: "bd - Dependency-aware issue tracker",
Long: `Issues chained together like beads. A lightweight issue tracker with first-class dependency support.`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// Skip database initialization for init command
if cmd.Name() == "init" {
return
}
// 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 != "" {
dbPath = foundDB
} else {
home, _ := os.UserHomeDir()
dbPath = filepath.Join(home, ".beads", "default.db")
}
}
var err error
store, err = sqlite.New(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
os.Exit(1)
}
// Set actor from env or default
if actor == "" {
actor = os.Getenv("USER")
if actor == "" {
actor = "unknown"
}
}
},
PersistentPostRun: func(cmd *cobra.Command, args []string) {
if store != nil {
store.Close()
}
},
}
// 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)
encoder.SetIndent("", " ")
if err := encoder.Encode(v); err != nil {
fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err)
os.Exit(1)
}
}
func init() {
rootCmd.PersistentFlags().StringVar(&dbPath, "db", "", "Database path (default: auto-discover .beads/*.db or ~/.beads/default.db)")
rootCmd.PersistentFlags().StringVar(&actor, "actor", "", "Actor name for audit trail (default: $USER)")
rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
}
var createCmd = &cobra.Command{
Use: "create [title]",
Short: "Create a new issue",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
title := args[0]
description, _ := cmd.Flags().GetString("description")
design, _ := cmd.Flags().GetString("design")
acceptance, _ := cmd.Flags().GetString("acceptance")
priority, _ := cmd.Flags().GetInt("priority")
issueType, _ := cmd.Flags().GetString("type")
assignee, _ := cmd.Flags().GetString("assignee")
labels, _ := cmd.Flags().GetStringSlice("labels")
issue := &types.Issue{
Title: title,
Description: description,
Design: design,
AcceptanceCriteria: acceptance,
Status: types.StatusOpen,
Priority: priority,
IssueType: types.IssueType(issueType),
Assignee: assignee,
}
ctx := context.Background()
if err := store.CreateIssue(ctx, issue, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Add labels if specified
for _, label := range labels {
if err := store.AddLabel(ctx, issue.ID, label, actor); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to add label %s: %v\n", label, err)
}
}
if jsonOutput {
outputJSON(issue)
} else {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Created issue: %s\n", green("✓"), issue.ID)
fmt.Printf(" Title: %s\n", issue.Title)
fmt.Printf(" Priority: P%d\n", issue.Priority)
fmt.Printf(" Status: %s\n", issue.Status)
}
},
}
func init() {
createCmd.Flags().StringP("description", "d", "", "Issue description")
createCmd.Flags().String("design", "", "Design notes")
createCmd.Flags().String("acceptance", "", "Acceptance criteria")
createCmd.Flags().IntP("priority", "p", 2, "Priority (0-4, 0=highest)")
createCmd.Flags().StringP("type", "t", "task", "Issue type (bug|feature|task|epic|chore)")
createCmd.Flags().StringP("assignee", "a", "", "Assignee")
createCmd.Flags().StringSliceP("labels", "l", []string{}, "Labels (comma-separated)")
rootCmd.AddCommand(createCmd)
}
var showCmd = &cobra.Command{
Use: "show [id]",
Short: "Show issue details",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := context.Background()
issue, err := store.GetIssue(ctx, args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if issue == nil {
fmt.Fprintf(os.Stderr, "Issue %s not found\n", args[0])
os.Exit(1)
}
if jsonOutput {
// Include labels and dependencies in JSON output
type IssueDetails struct {
*types.Issue
Labels []string `json:"labels,omitempty"`
Dependencies []*types.Issue `json:"dependencies,omitempty"`
Dependents []*types.Issue `json:"dependents,omitempty"`
}
details := &IssueDetails{Issue: issue}
details.Labels, _ = store.GetLabels(ctx, issue.ID)
details.Dependencies, _ = store.GetDependencies(ctx, issue.ID)
details.Dependents, _ = store.GetDependents(ctx, issue.ID)
outputJSON(details)
return
}
cyan := color.New(color.FgCyan).SprintFunc()
fmt.Printf("\n%s: %s\n", cyan(issue.ID), issue.Title)
fmt.Printf("Status: %s\n", issue.Status)
fmt.Printf("Priority: P%d\n", issue.Priority)
fmt.Printf("Type: %s\n", issue.IssueType)
if issue.Assignee != "" {
fmt.Printf("Assignee: %s\n", issue.Assignee)
}
if issue.EstimatedMinutes != nil {
fmt.Printf("Estimated: %d minutes\n", *issue.EstimatedMinutes)
}
fmt.Printf("Created: %s\n", issue.CreatedAt.Format("2006-01-02 15:04"))
fmt.Printf("Updated: %s\n", issue.UpdatedAt.Format("2006-01-02 15:04"))
if issue.Description != "" {
fmt.Printf("\nDescription:\n%s\n", issue.Description)
}
if issue.Design != "" {
fmt.Printf("\nDesign:\n%s\n", issue.Design)
}
if issue.AcceptanceCriteria != "" {
fmt.Printf("\nAcceptance Criteria:\n%s\n", issue.AcceptanceCriteria)
}
// Show labels
labels, _ := store.GetLabels(ctx, issue.ID)
if len(labels) > 0 {
fmt.Printf("\nLabels: %v\n", labels)
}
// Show dependencies
deps, _ := store.GetDependencies(ctx, issue.ID)
if len(deps) > 0 {
fmt.Printf("\nDepends on (%d):\n", len(deps))
for _, dep := range deps {
fmt.Printf(" → %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
}
}
// Show dependents
dependents, _ := store.GetDependents(ctx, issue.ID)
if len(dependents) > 0 {
fmt.Printf("\nBlocks (%d):\n", len(dependents))
for _, dep := range dependents {
fmt.Printf(" ← %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
}
}
fmt.Println()
},
}
func init() {
rootCmd.AddCommand(showCmd)
}
var listCmd = &cobra.Command{
Use: "list",
Short: "List issues",
Run: func(cmd *cobra.Command, args []string) {
status, _ := cmd.Flags().GetString("status")
assignee, _ := cmd.Flags().GetString("assignee")
issueType, _ := cmd.Flags().GetString("type")
limit, _ := cmd.Flags().GetInt("limit")
filter := types.IssueFilter{
Limit: limit,
}
if status != "" {
s := types.Status(status)
filter.Status = &s
}
// Use Changed() to properly handle P0 (priority=0)
if cmd.Flags().Changed("priority") {
priority, _ := cmd.Flags().GetInt("priority")
filter.Priority = &priority
}
if assignee != "" {
filter.Assignee = &assignee
}
if issueType != "" {
t := types.IssueType(issueType)
filter.IssueType = &t
}
ctx := context.Background()
issues, err := store.SearchIssues(ctx, "", filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
outputJSON(issues)
return
}
fmt.Printf("\nFound %d issues:\n\n", len(issues))
for _, issue := range issues {
fmt.Printf("%s [P%d] %s\n", issue.ID, issue.Priority, issue.Status)
fmt.Printf(" %s\n", issue.Title)
if issue.Assignee != "" {
fmt.Printf(" Assignee: %s\n", issue.Assignee)
}
fmt.Println()
}
},
}
func init() {
listCmd.Flags().StringP("status", "s", "", "Filter by status")
listCmd.Flags().IntP("priority", "p", 0, "Filter by priority")
listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
listCmd.Flags().StringP("type", "t", "", "Filter by type")
listCmd.Flags().IntP("limit", "n", 0, "Limit results")
rootCmd.AddCommand(listCmd)
}
var updateCmd = &cobra.Command{
Use: "update [id]",
Short: "Update an issue",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
updates := make(map[string]interface{})
if cmd.Flags().Changed("status") {
status, _ := cmd.Flags().GetString("status")
updates["status"] = status
}
if cmd.Flags().Changed("priority") {
priority, _ := cmd.Flags().GetInt("priority")
updates["priority"] = priority
}
if cmd.Flags().Changed("title") {
title, _ := cmd.Flags().GetString("title")
updates["title"] = title
}
if cmd.Flags().Changed("assignee") {
assignee, _ := cmd.Flags().GetString("assignee")
updates["assignee"] = assignee
}
if len(updates) == 0 {
fmt.Println("No updates specified")
return
}
ctx := context.Background()
if err := store.UpdateIssue(ctx, args[0], updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
// Fetch updated issue and output
issue, _ := store.GetIssue(ctx, args[0])
outputJSON(issue)
} else {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Updated issue: %s\n", green("✓"), args[0])
}
},
}
func init() {
updateCmd.Flags().StringP("status", "s", "", "New status")
updateCmd.Flags().IntP("priority", "p", 0, "New priority")
updateCmd.Flags().String("title", "", "New title")
updateCmd.Flags().StringP("assignee", "a", "", "New assignee")
rootCmd.AddCommand(updateCmd)
}
var closeCmd = &cobra.Command{
Use: "close [id...]",
Short: "Close one or more issues",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
reason, _ := cmd.Flags().GetString("reason")
if reason == "" {
reason = "Closed"
}
ctx := context.Background()
closedIssues := []*types.Issue{}
for _, id := range args {
if err := store.CloseIssue(ctx, id, reason, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err)
continue
}
if jsonOutput {
issue, _ := store.GetIssue(ctx, id)
if issue != nil {
closedIssues = append(closedIssues, issue)
}
} else {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason)
}
}
if jsonOutput && len(closedIssues) > 0 {
outputJSON(closedIssues)
}
},
}
func init() {
closeCmd.Flags().StringP("reason", "r", "", "Reason for closing")
rootCmd.AddCommand(closeCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}

94
cmd/bd/quickstart.go Normal file
View File

@@ -0,0 +1,94 @@
package main
import (
"fmt"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
var quickstartCmd = &cobra.Command{
Use: "quickstart",
Short: "Quick start guide for bd",
Long: `Display a quick start guide showing common bd workflows and patterns.`,
Run: func(cmd *cobra.Command, args []string) {
cyan := color.New(color.FgCyan).SprintFunc()
green := color.New(color.FgGreen).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
bold := color.New(color.Bold).SprintFunc()
fmt.Printf("\n%s\n\n", bold("bd - Dependency-Aware Issue Tracker"))
fmt.Printf("Issues chained together like beads.\n\n")
fmt.Printf("%s\n", bold("GETTING STARTED"))
fmt.Printf(" %s Initialize bd in your project\n", cyan("bd init"))
fmt.Printf(" Creates .beads/ directory with project-specific database\n")
fmt.Printf(" Auto-detects prefix from directory name (e.g., myapp-1, myapp-2)\n\n")
fmt.Printf(" %s Initialize with custom prefix\n", cyan("bd init --prefix api"))
fmt.Printf(" Issues will be named: api-1, api-2, ...\n\n")
fmt.Printf("%s\n", bold("CREATING ISSUES"))
fmt.Printf(" %s\n", cyan("bd create \"Fix login bug\""))
fmt.Printf(" %s\n", cyan("bd create \"Add auth\" -p 0 -t feature"))
fmt.Printf(" %s\n\n", cyan("bd create \"Write tests\" -d \"Unit tests for auth\" --assignee alice"))
fmt.Printf("%s\n", bold("VIEWING ISSUES"))
fmt.Printf(" %s List all issues\n", cyan("bd list"))
fmt.Printf(" %s List by status\n", cyan("bd list --status open"))
fmt.Printf(" %s List by priority (0-4, 0=highest)\n", cyan("bd list --priority 0"))
fmt.Printf(" %s Show issue details\n\n", cyan("bd show bd-1"))
fmt.Printf("%s\n", bold("MANAGING DEPENDENCIES"))
fmt.Printf(" %s Add dependency (bd-2 blocks bd-1)\n", cyan("bd dep add bd-1 bd-2"))
fmt.Printf(" %s Visualize dependency tree\n", cyan("bd dep tree bd-1"))
fmt.Printf(" %s Detect circular dependencies\n\n", cyan("bd dep cycles"))
fmt.Printf("%s\n", bold("DEPENDENCY TYPES"))
fmt.Printf(" %s Task B must complete before task A\n", yellow("blocks"))
fmt.Printf(" %s Soft connection, doesn't block progress\n", yellow("related"))
fmt.Printf(" %s Epic/subtask hierarchical relationship\n", yellow("parent-child"))
fmt.Printf(" %s Auto-created when AI discovers related work\n\n", yellow("discovered-from"))
fmt.Printf("%s\n", bold("READY WORK"))
fmt.Printf(" %s Show issues ready to work on\n", cyan("bd ready"))
fmt.Printf(" Ready = status is 'open' AND no blocking dependencies\n")
fmt.Printf(" Perfect for agents to claim next work!\n\n")
fmt.Printf("%s\n", bold("UPDATING ISSUES"))
fmt.Printf(" %s\n", cyan("bd update bd-1 --status in_progress"))
fmt.Printf(" %s\n", cyan("bd update bd-1 --priority 0"))
fmt.Printf(" %s\n\n", cyan("bd update bd-1 --assignee bob"))
fmt.Printf("%s\n", bold("CLOSING ISSUES"))
fmt.Printf(" %s\n", cyan("bd close bd-1"))
fmt.Printf(" %s\n\n", cyan("bd close bd-2 bd-3 --reason \"Fixed in PR #42\""))
fmt.Printf("%s\n", bold("DATABASE LOCATION"))
fmt.Printf(" bd automatically discovers your database:\n")
fmt.Printf(" 1. %s flag\n", cyan("--db /path/to/db.db"))
fmt.Printf(" 2. %s environment variable\n", cyan("$BEADS_DB"))
fmt.Printf(" 3. %s in current directory or ancestors\n", cyan(".beads/*.db"))
fmt.Printf(" 4. %s as fallback\n\n", cyan("~/.beads/default.db"))
fmt.Printf("%s\n", bold("AGENT INTEGRATION"))
fmt.Printf(" bd is designed for AI-supervised workflows:\n")
fmt.Printf(" • Agents create issues when discovering new work\n")
fmt.Printf(" • %s shows unblocked work ready to claim\n", cyan("bd ready"))
fmt.Printf(" • Use %s flags for programmatic parsing\n", cyan("--json"))
fmt.Printf(" • Dependencies prevent agents from duplicating effort\n\n")
fmt.Printf("%s\n", bold("DATABASE EXTENSION"))
fmt.Printf(" Applications can extend bd's SQLite database:\n")
fmt.Printf(" • Add your own tables (e.g., %s)\n", cyan("myapp_executions"))
fmt.Printf(" • Join with %s table for powerful queries\n", cyan("issues"))
fmt.Printf(" • See %s for integration patterns\n\n", cyan("EXTENDING.md"))
fmt.Printf("%s\n", green("Ready to start!"))
fmt.Printf("Run %s to create your first issue.\n\n", cyan("bd create \"My first issue\""))
},
}
func init() {
rootCmd.AddCommand(quickstartCmd)
}

152
cmd/bd/ready.go Normal file
View File

@@ -0,0 +1,152 @@
package main
import (
"context"
"fmt"
"os"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/types"
)
var readyCmd = &cobra.Command{
Use: "ready",
Short: "Show ready work (no blockers)",
Run: func(cmd *cobra.Command, args []string) {
limit, _ := cmd.Flags().GetInt("limit")
assignee, _ := cmd.Flags().GetString("assignee")
filter := types.WorkFilter{
Status: types.StatusOpen,
Limit: limit,
}
// Use Changed() to properly handle P0 (priority=0)
if cmd.Flags().Changed("priority") {
priority, _ := cmd.Flags().GetInt("priority")
filter.Priority = &priority
}
if assignee != "" {
filter.Assignee = &assignee
}
ctx := context.Background()
issues, err := store.GetReadyWork(ctx, filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
// Always output array, even if empty
if issues == nil {
issues = []*types.Issue{}
}
outputJSON(issues)
return
}
if len(issues) == 0 {
yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s No ready work found (all issues have blocking dependencies)\n\n",
yellow("✨"))
return
}
cyan := color.New(color.FgCyan).SprintFunc()
fmt.Printf("\n%s Ready work (%d issues with no blockers):\n\n", cyan("📋"), len(issues))
for i, issue := range issues {
fmt.Printf("%d. [P%d] %s: %s\n", i+1, issue.Priority, issue.ID, issue.Title)
if issue.EstimatedMinutes != nil {
fmt.Printf(" Estimate: %d min\n", *issue.EstimatedMinutes)
}
if issue.Assignee != "" {
fmt.Printf(" Assignee: %s\n", issue.Assignee)
}
}
fmt.Println()
},
}
var blockedCmd = &cobra.Command{
Use: "blocked",
Short: "Show blocked issues",
Run: func(cmd *cobra.Command, args []string) {
ctx := context.Background()
blocked, err := store.GetBlockedIssues(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
// Always output array, even if empty
if blocked == nil {
blocked = []*types.BlockedIssue{}
}
outputJSON(blocked)
return
}
if len(blocked) == 0 {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("\n%s No blocked issues\n\n", green("✨"))
return
}
red := color.New(color.FgRed).SprintFunc()
fmt.Printf("\n%s Blocked issues (%d):\n\n", red("🚫"), len(blocked))
for _, issue := range blocked {
fmt.Printf("[P%d] %s: %s\n", issue.Priority, issue.ID, issue.Title)
fmt.Printf(" Blocked by %d open dependencies: %v\n",
issue.BlockedByCount, issue.BlockedBy)
fmt.Println()
}
},
}
var statsCmd = &cobra.Command{
Use: "stats",
Short: "Show statistics",
Run: func(cmd *cobra.Command, args []string) {
ctx := context.Background()
stats, err := store.GetStatistics(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
outputJSON(stats)
return
}
cyan := color.New(color.FgCyan).SprintFunc()
green := color.New(color.FgGreen).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s Beads Statistics:\n\n", cyan("📊"))
fmt.Printf("Total Issues: %d\n", stats.TotalIssues)
fmt.Printf("Open: %s\n", green(fmt.Sprintf("%d", stats.OpenIssues)))
fmt.Printf("In Progress: %s\n", yellow(fmt.Sprintf("%d", stats.InProgressIssues)))
fmt.Printf("Closed: %d\n", stats.ClosedIssues)
fmt.Printf("Blocked: %d\n", stats.BlockedIssues)
fmt.Printf("Ready: %s\n", green(fmt.Sprintf("%d", stats.ReadyIssues)))
if stats.AverageLeadTime > 0 {
fmt.Printf("Avg Lead Time: %.1f hours\n", stats.AverageLeadTime)
}
fmt.Println()
},
}
func init() {
readyCmd.Flags().IntP("limit", "n", 10, "Maximum issues to show")
readyCmd.Flags().IntP("priority", "p", 0, "Filter by priority")
readyCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
rootCmd.AddCommand(readyCmd)
rootCmd.AddCommand(blockedCmd)
rootCmd.AddCommand(statsCmd)
}