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>
This commit is contained in:
furiosa
2026-01-06 22:50:35 -08:00
committed by Steve Yegge
parent fc4b9de02c
commit 2922affa02
4 changed files with 227 additions and 1 deletions

View File

@@ -83,7 +83,7 @@ Git-backed issue tracking system that stores work state as structured data.
- **Go 1.23+** - [go.dev/dl](https://go.dev/dl/)
- **Git 2.25+** - for worktree support
- **beads (bd)** - [github.com/steveyegge/beads](https://github.com/steveyegge/beads)
- **beads (bd) 0.44.0+** - [github.com/steveyegge/beads](https://github.com/steveyegge/beads) (required for custom type support)
- **tmux 3.0+** - recommended for full experience
- **Claude Code CLI** - [claude.ai/code](https://claude.ai/code)

View File

@@ -0,0 +1,134 @@
// Package cmd provides CLI commands for the gt tool.
package cmd
import (
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
)
// MinBeadsVersion is the minimum required beads version for Gas Town.
// This version must include custom type support (bd-i54l).
const MinBeadsVersion = "0.44.0"
// beadsVersion represents a parsed semantic version.
type beadsVersion struct {
major int
minor int
patch int
}
// parseBeadsVersion parses a version string like "0.44.0" into components.
func parseBeadsVersion(v string) (beadsVersion, error) {
// Strip leading 'v' if present
v = strings.TrimPrefix(v, "v")
// Split on dots
parts := strings.Split(v, ".")
if len(parts) < 2 {
return beadsVersion{}, fmt.Errorf("invalid version format: %s", v)
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return beadsVersion{}, fmt.Errorf("invalid major version: %s", parts[0])
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return beadsVersion{}, fmt.Errorf("invalid minor version: %s", parts[1])
}
patch := 0
if len(parts) >= 3 {
// Handle versions like "0.44.0-dev" - take only numeric prefix
patchStr := parts[2]
if idx := strings.IndexFunc(patchStr, func(r rune) bool {
return r < '0' || r > '9'
}); idx != -1 {
patchStr = patchStr[:idx]
}
if patchStr != "" {
patch, err = strconv.Atoi(patchStr)
if err != nil {
return beadsVersion{}, fmt.Errorf("invalid patch version: %s", parts[2])
}
}
}
return beadsVersion{major: major, minor: minor, patch: patch}, nil
}
// compare returns -1 if v < other, 0 if equal, 1 if v > other.
func (v beadsVersion) compare(other beadsVersion) int {
if v.major != other.major {
if v.major < other.major {
return -1
}
return 1
}
if v.minor != other.minor {
if v.minor < other.minor {
return -1
}
return 1
}
if v.patch != other.patch {
if v.patch < other.patch {
return -1
}
return 1
}
return 0
}
// getBeadsVersion executes `bd --version` and parses the output.
// Returns the version string (e.g., "0.44.0") or error.
func getBeadsVersion() (string, error) {
cmd := exec.Command("bd", "--version")
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("bd --version failed: %s", string(exitErr.Stderr))
}
return "", fmt.Errorf("failed to run bd: %w (is beads installed?)", err)
}
// Parse output like "bd version 0.44.0 (dev)"
// or "bd version 0.44.0"
re := regexp.MustCompile(`bd version (\d+\.\d+(?:\.\d+)?(?:-\w+)?)`)
matches := re.FindStringSubmatch(string(output))
if len(matches) < 2 {
return "", fmt.Errorf("could not parse beads version from: %s", strings.TrimSpace(string(output)))
}
return matches[1], nil
}
// CheckBeadsVersion verifies that the installed beads version meets the minimum requirement.
// Returns nil if the version is sufficient, or an error with details if not.
func CheckBeadsVersion() error {
installedStr, err := getBeadsVersion()
if err != nil {
return fmt.Errorf("cannot verify beads version: %w", err)
}
installed, err := parseBeadsVersion(installedStr)
if err != nil {
return fmt.Errorf("cannot parse installed beads version %q: %w", installedStr, err)
}
required, err := parseBeadsVersion(MinBeadsVersion)
if err != nil {
// This would be a bug in our code
return fmt.Errorf("cannot parse required beads version %q: %w", MinBeadsVersion, err)
}
if installed.compare(required) < 0 {
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)
}
return nil
}

View File

@@ -0,0 +1,68 @@
package cmd
import "testing"
func TestParseBeadsVersion(t *testing.T) {
tests := []struct {
input string
want beadsVersion
wantErr bool
}{
{"0.44.0", beadsVersion{0, 44, 0}, false},
{"1.2.3", beadsVersion{1, 2, 3}, false},
{"0.44.0-dev", beadsVersion{0, 44, 0}, false},
{"v0.44.0", beadsVersion{0, 44, 0}, false},
{"0.44", beadsVersion{0, 44, 0}, false},
{"10.20.30", beadsVersion{10, 20, 30}, false},
{"invalid", beadsVersion{}, true},
{"", beadsVersion{}, true},
{"a.b.c", beadsVersion{}, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := parseBeadsVersion(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("parseBeadsVersion(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if !tt.wantErr && got != tt.want {
t.Errorf("parseBeadsVersion(%q) = %+v, want %+v", tt.input, got, tt.want)
}
})
}
}
func TestBeadsVersionCompare(t *testing.T) {
tests := []struct {
v1 string
v2 string
want int
}{
{"0.44.0", "0.44.0", 0},
{"0.44.0", "0.43.0", 1},
{"0.43.0", "0.44.0", -1},
{"1.0.0", "0.99.99", 1},
{"0.44.1", "0.44.0", 1},
{"0.44.0", "0.44.1", -1},
{"1.2.3", "1.2.3", 0},
}
for _, tt := range tests {
t.Run(tt.v1+"_vs_"+tt.v2, func(t *testing.T) {
v1, err := parseBeadsVersion(tt.v1)
if err != nil {
t.Fatalf("failed to parse v1 %q: %v", tt.v1, err)
}
v2, err := parseBeadsVersion(tt.v2)
if err != nil {
t.Fatalf("failed to parse v2 %q: %v", tt.v2, err)
}
got := v1.compare(v2)
if got != tt.want {
t.Errorf("(%s).compare(%s) = %d, want %d", tt.v1, tt.v2, got, tt.want)
}
})
}
}

View File

@@ -16,6 +16,30 @@ var rootCmd = &cobra.Command{
It coordinates agent spawning, work distribution, and communication
across distributed teams of AI agents working on shared codebases.`,
PersistentPreRunE: checkBeadsDependency,
}
// Commands that don't require beads to be installed/checked.
// These are basic utility commands that should work without beads.
var beadsExemptCommands = map[string]bool{
"version": true,
"help": true,
"completion": true,
}
// checkBeadsDependency verifies beads meets minimum version requirements.
// Skips check for exempt commands (version, help, completion).
func checkBeadsDependency(cmd *cobra.Command, args []string) error {
// Get the root command name being run
cmdName := cmd.Name()
// Skip check for exempt commands
if beadsExemptCommands[cmdName] {
return nil
}
// Check beads version
return CheckBeadsVersion()
}
// Execute runs the root command and returns an exit code.