fix(install): Copy embedded formulas to new installations

When `gt install` creates a new HQ, formulas were not being provisioned
to `.beads/formulas/`. This embeds the formula library into the gt binary
and copies them during installation.

- Add go:generate directive to copy formulas from .beads/formulas/
- Add internal/formula/embed.go with ProvisionFormulas() function
- Call ProvisionFormulas() from runInstall() after beads init
- Add generate target to Makefile (build depends on it)
- Add TestInstallFormulasProvisioned integration test
- Log warning if formula stat fails with unexpected error

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
PV
2026-01-03 13:18:14 -08:00
parent 5186cd90be
commit 4c9bc36d61
5 changed files with 133 additions and 2 deletions

3
.gitignore vendored
View File

@@ -33,3 +33,6 @@ state.json
# Clone-specific CLAUDE.md (regenerated locally per clone)
CLAUDE.md
# Generated by go:generate from .beads/formulas/
internal/formula/formulas/

View File

@@ -1,4 +1,4 @@
.PHONY: build install clean test
.PHONY: build install clean test generate
BINARY := gt
BUILD_DIR := .
@@ -12,7 +12,10 @@ LDFLAGS := -X github.com/steveyegge/gastown/internal/cmd.Version=$(VERSION) \
-X github.com/steveyegge/gastown/internal/cmd.Commit=$(COMMIT) \
-X github.com/steveyegge/gastown/internal/cmd.BuildTime=$(BUILD_TIME)
build:
generate:
go generate ./...
build: generate
go build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY) ./cmd/gt
ifeq ($(shell uname),Darwin)
@codesign -s - -f $(BUILD_DIR)/$(BINARY) 2>/dev/null || true

View File

@@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/deps"
"github.com/steveyegge/gastown/internal/formula"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/templates"
@@ -195,6 +196,14 @@ func runInstall(cmd *cobra.Command, args []string) error {
fmt.Printf(" %s Could not initialize town beads: %v\n", style.Dim.Render("⚠"), err)
} else {
fmt.Printf(" ✓ Initialized .beads/ (town-level beads with hq- prefix)\n")
// Provision embedded formulas to .beads/formulas/
if count, err := formula.ProvisionFormulas(absPath); err != nil {
// Non-fatal: formulas are optional, just convenience
fmt.Printf(" %s Could not provision formulas: %v\n", style.Dim.Render("⚠"), err)
} else if count > 0 {
fmt.Printf(" ✓ Provisioned %d formulas\n", count)
}
}
// NOTE: Agent beads (gt-deacon, gt-mayor) are created by gt rig add,

View File

@@ -163,6 +163,60 @@ func TestInstallIdempotent(t *testing.T) {
}
}
// TestInstallFormulasProvisioned validates that embedded formulas are copied
// to .beads/formulas/ during installation.
func TestInstallFormulasProvisioned(t *testing.T) {
// Skip if bd is not available
if _, err := exec.LookPath("bd"); err != nil {
t.Skip("bd not installed, skipping formulas test")
}
tmpDir := t.TempDir()
hqPath := filepath.Join(tmpDir, "test-hq")
gtBinary := buildGT(t)
// Run gt install (includes beads and formula provisioning)
cmd := exec.Command(gtBinary, "install", hqPath)
cmd.Env = append(os.Environ(), "HOME="+tmpDir)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("gt install failed: %v\nOutput: %s", err, output)
}
// Verify .beads/formulas/ directory exists
formulasDir := filepath.Join(hqPath, ".beads", "formulas")
assertDirExists(t, formulasDir, ".beads/formulas/")
// Verify at least some expected formulas exist
expectedFormulas := []string{
"mol-deacon-patrol.formula.toml",
"mol-refinery-patrol.formula.toml",
"code-review.formula.toml",
}
for _, f := range expectedFormulas {
formulaPath := filepath.Join(formulasDir, f)
assertFileExists(t, formulaPath, f)
}
// Verify the count matches embedded formulas
entries, err := os.ReadDir(formulasDir)
if err != nil {
t.Fatalf("failed to read formulas dir: %v", err)
}
// Count only formula files (not directories)
var fileCount int
for _, e := range entries {
if !e.IsDir() {
fileCount++
}
}
// Should have at least 20 formulas (allows for some variation)
if fileCount < 20 {
t.Errorf("expected at least 20 formulas, got %d", fileCount)
}
}
// TestInstallNoBeadsFlag validates that --no-beads skips beads initialization.
func TestInstallNoBeadsFlag(t *testing.T) {
tmpDir := t.TempDir()

62
internal/formula/embed.go Normal file
View File

@@ -0,0 +1,62 @@
package formula
import (
"embed"
"fmt"
"log"
"os"
"path/filepath"
)
// Generate formulas directory from canonical source at .beads/formulas/
//go:generate sh -c "rm -rf formulas && mkdir -p formulas && cp ../../.beads/formulas/*.formula.toml ../../.beads/formulas/*.formula.json formulas/ 2>/dev/null || cp ../../.beads/formulas/*.formula.toml formulas/"
//go:embed formulas/*.formula.toml formulas/*.formula.json
var formulasFS embed.FS
// ProvisionFormulas creates the .beads/formulas/ directory with embedded formulas.
// This ensures new installations have the standard formula library.
// If a formula already exists, it is skipped (no overwrite).
// Returns the number of formulas provisioned.
func ProvisionFormulas(beadsPath string) (int, error) {
entries, err := formulasFS.ReadDir("formulas")
if err != nil {
return 0, fmt.Errorf("reading formulas directory: %w", err)
}
// Create .beads/formulas/ directory
formulasDir := filepath.Join(beadsPath, ".beads", "formulas")
if err := os.MkdirAll(formulasDir, 0755); err != nil {
return 0, fmt.Errorf("creating formulas directory: %w", err)
}
count := 0
for _, entry := range entries {
if entry.IsDir() {
continue
}
destPath := filepath.Join(formulasDir, entry.Name())
// Skip if formula already exists (don't overwrite user customizations)
if _, err := os.Stat(destPath); err == nil {
continue
} else if !os.IsNotExist(err) {
// Log unexpected errors (e.g., permission denied) but continue
log.Printf("warning: could not check formula %s: %v", entry.Name(), err)
continue
}
content, err := formulasFS.ReadFile("formulas/" + entry.Name())
if err != nil {
return count, fmt.Errorf("reading %s: %w", entry.Name(), err)
}
if err := os.WriteFile(destPath, content, 0644); err != nil {
return count, fmt.Errorf("writing %s: %w", entry.Name(), err)
}
count++
}
return count, nil
}