Skip to content

Commit 2922aff

Browse files
steveyeggeclaude
andcommitted
feat(deps): add minimum beads version check (gt-im3fl)
Add version check that enforces beads >= 0.44.0 at CLI startup, required for custom type support (bd-i54l). Commands like version, help, and completion bypass the check. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fc4b9de commit 2922aff

4 files changed

Lines changed: 227 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ Git-backed issue tracking system that stores work state as structured data.
8383

8484
- **Go 1.23+** - [go.dev/dl](https://go.dev/dl/)
8585
- **Git 2.25+** - for worktree support
86-
- **beads (bd)** - [github.com/steveyegge/beads](https://github.com/steveyegge/beads)
86+
- **beads (bd) 0.44.0+** - [github.com/steveyegge/beads](https://github.com/steveyegge/beads) (required for custom type support)
8787
- **tmux 3.0+** - recommended for full experience
8888
- **Claude Code CLI** - [claude.ai/code](https://claude.ai/code)
8989

internal/cmd/beads_version.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Package cmd provides CLI commands for the gt tool.
2+
package cmd
3+
4+
import (
5+
"fmt"
6+
"os/exec"
7+
"regexp"
8+
"strconv"
9+
"strings"
10+
)
11+
12+
// MinBeadsVersion is the minimum required beads version for Gas Town.
13+
// This version must include custom type support (bd-i54l).
14+
const MinBeadsVersion = "0.44.0"
15+
16+
// beadsVersion represents a parsed semantic version.
17+
type beadsVersion struct {
18+
major int
19+
minor int
20+
patch int
21+
}
22+
23+
// parseBeadsVersion parses a version string like "0.44.0" into components.
24+
func parseBeadsVersion(v string) (beadsVersion, error) {
25+
// Strip leading 'v' if present
26+
v = strings.TrimPrefix(v, "v")
27+
28+
// Split on dots
29+
parts := strings.Split(v, ".")
30+
if len(parts) < 2 {
31+
return beadsVersion{}, fmt.Errorf("invalid version format: %s", v)
32+
}
33+
34+
major, err := strconv.Atoi(parts[0])
35+
if err != nil {
36+
return beadsVersion{}, fmt.Errorf("invalid major version: %s", parts[0])
37+
}
38+
39+
minor, err := strconv.Atoi(parts[1])
40+
if err != nil {
41+
return beadsVersion{}, fmt.Errorf("invalid minor version: %s", parts[1])
42+
}
43+
44+
patch := 0
45+
if len(parts) >= 3 {
46+
// Handle versions like "0.44.0-dev" - take only numeric prefix
47+
patchStr := parts[2]
48+
if idx := strings.IndexFunc(patchStr, func(r rune) bool {
49+
return r < '0' || r > '9'
50+
}); idx != -1 {
51+
patchStr = patchStr[:idx]
52+
}
53+
if patchStr != "" {
54+
patch, err = strconv.Atoi(patchStr)
55+
if err != nil {
56+
return beadsVersion{}, fmt.Errorf("invalid patch version: %s", parts[2])
57+
}
58+
}
59+
}
60+
61+
return beadsVersion{major: major, minor: minor, patch: patch}, nil
62+
}
63+
64+
// compare returns -1 if v < other, 0 if equal, 1 if v > other.
65+
func (v beadsVersion) compare(other beadsVersion) int {
66+
if v.major != other.major {
67+
if v.major < other.major {
68+
return -1
69+
}
70+
return 1
71+
}
72+
if v.minor != other.minor {
73+
if v.minor < other.minor {
74+
return -1
75+
}
76+
return 1
77+
}
78+
if v.patch != other.patch {
79+
if v.patch < other.patch {
80+
return -1
81+
}
82+
return 1
83+
}
84+
return 0
85+
}
86+
87+
// getBeadsVersion executes `bd --version` and parses the output.
88+
// Returns the version string (e.g., "0.44.0") or error.
89+
func getBeadsVersion() (string, error) {
90+
cmd := exec.Command("bd", "--version")
91+
output, err := cmd.Output()
92+
if err != nil {
93+
if exitErr, ok := err.(*exec.ExitError); ok {
94+
return "", fmt.Errorf("bd --version failed: %s", string(exitErr.Stderr))
95+
}
96+
return "", fmt.Errorf("failed to run bd: %w (is beads installed?)", err)
97+
}
98+
99+
// Parse output like "bd version 0.44.0 (dev)"
100+
// or "bd version 0.44.0"
101+
re := regexp.MustCompile(`bd version (\d+\.\d+(?:\.\d+)?(?:-\w+)?)`)
102+
matches := re.FindStringSubmatch(string(output))
103+
if len(matches) < 2 {
104+
return "", fmt.Errorf("could not parse beads version from: %s", strings.TrimSpace(string(output)))
105+
}
106+
107+
return matches[1], nil
108+
}
109+
110+
// CheckBeadsVersion verifies that the installed beads version meets the minimum requirement.
111+
// Returns nil if the version is sufficient, or an error with details if not.
112+
func CheckBeadsVersion() error {
113+
installedStr, err := getBeadsVersion()
114+
if err != nil {
115+
return fmt.Errorf("cannot verify beads version: %w", err)
116+
}
117+
118+
installed, err := parseBeadsVersion(installedStr)
119+
if err != nil {
120+
return fmt.Errorf("cannot parse installed beads version %q: %w", installedStr, err)
121+
}
122+
123+
required, err := parseBeadsVersion(MinBeadsVersion)
124+
if err != nil {
125+
// This would be a bug in our code
126+
return fmt.Errorf("cannot parse required beads version %q: %w", MinBeadsVersion, err)
127+
}
128+
129+
if installed.compare(required) < 0 {
130+
return fmt.Errorf("beads version %s is required, but %s is installed\n\nPlease upgrade beads: go install github.com/steveyegge/beads/cmd/bd@latest", MinBeadsVersion, installedStr)
131+
}
132+
133+
return nil
134+
}

internal/cmd/beads_version_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package cmd
2+
3+
import "testing"
4+
5+
func TestParseBeadsVersion(t *testing.T) {
6+
tests := []struct {
7+
input string
8+
want beadsVersion
9+
wantErr bool
10+
}{
11+
{"0.44.0", beadsVersion{0, 44, 0}, false},
12+
{"1.2.3", beadsVersion{1, 2, 3}, false},
13+
{"0.44.0-dev", beadsVersion{0, 44, 0}, false},
14+
{"v0.44.0", beadsVersion{0, 44, 0}, false},
15+
{"0.44", beadsVersion{0, 44, 0}, false},
16+
{"10.20.30", beadsVersion{10, 20, 30}, false},
17+
{"invalid", beadsVersion{}, true},
18+
{"", beadsVersion{}, true},
19+
{"a.b.c", beadsVersion{}, true},
20+
}
21+
22+
for _, tt := range tests {
23+
t.Run(tt.input, func(t *testing.T) {
24+
got, err := parseBeadsVersion(tt.input)
25+
if (err != nil) != tt.wantErr {
26+
t.Errorf("parseBeadsVersion(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
27+
return
28+
}
29+
if !tt.wantErr && got != tt.want {
30+
t.Errorf("parseBeadsVersion(%q) = %+v, want %+v", tt.input, got, tt.want)
31+
}
32+
})
33+
}
34+
}
35+
36+
func TestBeadsVersionCompare(t *testing.T) {
37+
tests := []struct {
38+
v1 string
39+
v2 string
40+
want int
41+
}{
42+
{"0.44.0", "0.44.0", 0},
43+
{"0.44.0", "0.43.0", 1},
44+
{"0.43.0", "0.44.0", -1},
45+
{"1.0.0", "0.99.99", 1},
46+
{"0.44.1", "0.44.0", 1},
47+
{"0.44.0", "0.44.1", -1},
48+
{"1.2.3", "1.2.3", 0},
49+
}
50+
51+
for _, tt := range tests {
52+
t.Run(tt.v1+"_vs_"+tt.v2, func(t *testing.T) {
53+
v1, err := parseBeadsVersion(tt.v1)
54+
if err != nil {
55+
t.Fatalf("failed to parse v1 %q: %v", tt.v1, err)
56+
}
57+
v2, err := parseBeadsVersion(tt.v2)
58+
if err != nil {
59+
t.Fatalf("failed to parse v2 %q: %v", tt.v2, err)
60+
}
61+
62+
got := v1.compare(v2)
63+
if got != tt.want {
64+
t.Errorf("(%s).compare(%s) = %d, want %d", tt.v1, tt.v2, got, tt.want)
65+
}
66+
})
67+
}
68+
}

internal/cmd/root.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,30 @@ var rootCmd = &cobra.Command{
1616
1717
It coordinates agent spawning, work distribution, and communication
1818
across distributed teams of AI agents working on shared codebases.`,
19+
PersistentPreRunE: checkBeadsDependency,
20+
}
21+
22+
// Commands that don't require beads to be installed/checked.
23+
// These are basic utility commands that should work without beads.
24+
var beadsExemptCommands = map[string]bool{
25+
"version": true,
26+
"help": true,
27+
"completion": true,
28+
}
29+
30+
// checkBeadsDependency verifies beads meets minimum version requirements.
31+
// Skips check for exempt commands (version, help, completion).
32+
func checkBeadsDependency(cmd *cobra.Command, args []string) error {
33+
// Get the root command name being run
34+
cmdName := cmd.Name()
35+
36+
// Skip check for exempt commands
37+
if beadsExemptCommands[cmdName] {
38+
return nil
39+
}
40+
41+
// Check beads version
42+
return CheckBeadsVersion()
1943
}
2044

2145
// Execute runs the root command and returns an exit code.

0 commit comments

Comments
 (0)