diff --git a/.gitignore b/.gitignore index 0ddc6d0e..1c2aebf0 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Makefile b/Makefile index 4810dc3f..22613fdf 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/internal/cmd/install.go b/internal/cmd/install.go index 85e9ec7c..16bae4f1 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -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, diff --git a/internal/cmd/install_integration_test.go b/internal/cmd/install_integration_test.go index 2b534339..778dadfd 100644 --- a/internal/cmd/install_integration_test.go +++ b/internal/cmd/install_integration_test.go @@ -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() diff --git a/internal/formula/embed.go b/internal/formula/embed.go new file mode 100644 index 00000000..050d2ae4 --- /dev/null +++ b/internal/formula/embed.go @@ -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 +}