From 2922affa0210813bc9d44b5a47efe7fce3abfc96 Mon Sep 17 00:00:00 2001 From: furiosa Date: Tue, 6 Jan 2026 22:50:35 -0800 Subject: [PATCH] feat(deps): add minimum beads version check (gt-im3fl) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 +- internal/cmd/beads_version.go | 134 +++++++++++++++++++++++++++++ internal/cmd/beads_version_test.go | 68 +++++++++++++++ internal/cmd/root.go | 24 ++++++ 4 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/beads_version.go create mode 100644 internal/cmd/beads_version_test.go diff --git a/README.md b/README.md index 5a0cafb2..fa3e7784 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/internal/cmd/beads_version.go b/internal/cmd/beads_version.go new file mode 100644 index 00000000..26473e72 --- /dev/null +++ b/internal/cmd/beads_version.go @@ -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 +} diff --git a/internal/cmd/beads_version_test.go b/internal/cmd/beads_version_test.go new file mode 100644 index 00000000..ed3a5f69 --- /dev/null +++ b/internal/cmd/beads_version_test.go @@ -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) + } + }) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 83c9aab2..9ebd6b14 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -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.