feat(setup): refactor to recipe-based architecture (bd-i3ed)
Replace tool-specific setup commands with a generic recipe-based system. New tools become config entries, not code changes. Changes: - Add internal/recipes/ package with Recipe type and built-in recipes - Add --list flag to show available recipes - Add --print flag to output template to stdout - Add -o flag to write template to arbitrary path - Add --add flag to save custom recipes to .beads/recipes.toml - Add built-in recipes: windsurf, cody, kilocode (new) - Legacy recipes (cursor, claude, gemini, aider, factory) continue to work The recipe system enables: - Adding new tool support without code changes - User-defined recipes in .beads/recipes.toml - Shared template across all file-based integrations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Executed-By: beads/crew/dave Rig: beads Role: crew
This commit is contained in:
244
internal/recipes/recipes.go
Normal file
244
internal/recipes/recipes.go
Normal file
@@ -0,0 +1,244 @@
|
||||
// Package recipes provides recipe-based configuration for bd setup.
|
||||
// Recipes define where beads workflow instructions are written for different AI tools.
|
||||
package recipes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// RecipeType indicates how the recipe is installed.
|
||||
type RecipeType string
|
||||
|
||||
const (
|
||||
// TypeFile writes template to a file path (simple case)
|
||||
TypeFile RecipeType = "file"
|
||||
// TypeHooks modifies JSON settings to add hooks (claude, gemini)
|
||||
TypeHooks RecipeType = "hooks"
|
||||
// TypeSection injects a marked section into existing file (factory)
|
||||
TypeSection RecipeType = "section"
|
||||
// TypeMultiFile writes multiple files (aider)
|
||||
TypeMultiFile RecipeType = "multifile"
|
||||
)
|
||||
|
||||
// Recipe defines an AI tool integration.
|
||||
type Recipe struct {
|
||||
Name string `toml:"name"` // Display name (e.g., "Cursor IDE")
|
||||
Path string `toml:"path"` // Primary file path (for TypeFile)
|
||||
Type RecipeType `toml:"type"` // How to install
|
||||
Description string `toml:"description"` // Brief description
|
||||
// Optional fields for complex recipes
|
||||
GlobalPath string `toml:"global_path"` // Global settings path (for hooks)
|
||||
ProjectPath string `toml:"project_path"` // Project settings path (for hooks)
|
||||
Paths []string `toml:"paths"` // Multiple paths (for multifile)
|
||||
}
|
||||
|
||||
// BuiltinRecipes contains the default recipe definitions.
|
||||
// These are compiled into the binary.
|
||||
var BuiltinRecipes = map[string]Recipe{
|
||||
"cursor": {
|
||||
Name: "Cursor IDE",
|
||||
Path: ".cursor/rules/beads.mdc",
|
||||
Type: TypeFile,
|
||||
Description: "Cursor IDE rules file",
|
||||
},
|
||||
"windsurf": {
|
||||
Name: "Windsurf",
|
||||
Path: ".windsurf/rules/beads.md",
|
||||
Type: TypeFile,
|
||||
Description: "Windsurf editor rules file",
|
||||
},
|
||||
"cody": {
|
||||
Name: "Sourcegraph Cody",
|
||||
Path: ".cody/rules/beads.md",
|
||||
Type: TypeFile,
|
||||
Description: "Cody AI rules file",
|
||||
},
|
||||
"kilocode": {
|
||||
Name: "Kilo Code",
|
||||
Path: ".kilocode/rules/beads.md",
|
||||
Type: TypeFile,
|
||||
Description: "Kilo Code rules file",
|
||||
},
|
||||
"claude": {
|
||||
Name: "Claude Code",
|
||||
Type: TypeHooks,
|
||||
Description: "Claude Code hooks (SessionStart, PreCompact)",
|
||||
GlobalPath: "~/.claude/settings.json",
|
||||
ProjectPath: ".claude/settings.local.json",
|
||||
},
|
||||
"gemini": {
|
||||
Name: "Gemini CLI",
|
||||
Type: TypeHooks,
|
||||
Description: "Gemini CLI hooks (SessionStart, PreCompress)",
|
||||
GlobalPath: "~/.gemini/settings.json",
|
||||
ProjectPath: ".gemini/settings.json",
|
||||
},
|
||||
"factory": {
|
||||
Name: "Factory.ai (Droid)",
|
||||
Path: "AGENTS.md",
|
||||
Type: TypeSection,
|
||||
Description: "Factory Droid AGENTS.md section",
|
||||
},
|
||||
"aider": {
|
||||
Name: "Aider",
|
||||
Type: TypeMultiFile,
|
||||
Description: "Aider config and instruction files",
|
||||
Paths: []string{".aider.conf.yml", ".aider/BEADS.md", ".aider/README.md"},
|
||||
},
|
||||
}
|
||||
|
||||
// UserRecipes holds recipes loaded from user config file.
|
||||
type UserRecipes struct {
|
||||
Recipes map[string]Recipe `toml:"recipes"`
|
||||
}
|
||||
|
||||
// LoadUserRecipes loads recipes from .beads/recipes.toml if it exists.
|
||||
func LoadUserRecipes(beadsDir string) (map[string]Recipe, error) {
|
||||
path := filepath.Join(beadsDir, "recipes.toml")
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil // No user recipes, that's fine
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read recipes.toml: %w", err)
|
||||
}
|
||||
|
||||
var userRecipes UserRecipes
|
||||
if err := toml.Unmarshal(data, &userRecipes); err != nil {
|
||||
return nil, fmt.Errorf("parse recipes.toml: %w", err)
|
||||
}
|
||||
|
||||
// Set defaults for user recipes
|
||||
for name, recipe := range userRecipes.Recipes {
|
||||
if recipe.Type == "" {
|
||||
recipe.Type = TypeFile
|
||||
}
|
||||
if recipe.Name == "" {
|
||||
recipe.Name = name
|
||||
}
|
||||
userRecipes.Recipes[name] = recipe
|
||||
}
|
||||
|
||||
return userRecipes.Recipes, nil
|
||||
}
|
||||
|
||||
// GetAllRecipes returns merged built-in and user recipes.
|
||||
// User recipes override built-in recipes with the same name.
|
||||
func GetAllRecipes(beadsDir string) (map[string]Recipe, error) {
|
||||
result := make(map[string]Recipe)
|
||||
|
||||
// Start with built-in recipes
|
||||
for name, recipe := range BuiltinRecipes {
|
||||
result[name] = recipe
|
||||
}
|
||||
|
||||
// Load and merge user recipes
|
||||
userRecipes, err := LoadUserRecipes(beadsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for name, recipe := range userRecipes {
|
||||
result[name] = recipe
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetRecipe looks up a recipe by name, checking user recipes first.
|
||||
func GetRecipe(name string, beadsDir string) (*Recipe, error) {
|
||||
// Normalize name (lowercase, strip leading/trailing hyphens)
|
||||
name = strings.ToLower(strings.Trim(name, "-"))
|
||||
|
||||
recipes, err := GetAllRecipes(beadsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
recipe, ok := recipes[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown recipe: %s", name)
|
||||
}
|
||||
|
||||
return &recipe, nil
|
||||
}
|
||||
|
||||
// SaveUserRecipe adds or updates a recipe in .beads/recipes.toml.
|
||||
func SaveUserRecipe(beadsDir, name, path string) error {
|
||||
recipesPath := filepath.Join(beadsDir, "recipes.toml")
|
||||
|
||||
// Load existing user recipes
|
||||
var userRecipes UserRecipes
|
||||
data, err := os.ReadFile(recipesPath)
|
||||
if err == nil {
|
||||
if err := toml.Unmarshal(data, &userRecipes); err != nil {
|
||||
return fmt.Errorf("parse recipes.toml: %w", err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read recipes.toml: %w", err)
|
||||
}
|
||||
|
||||
if userRecipes.Recipes == nil {
|
||||
userRecipes.Recipes = make(map[string]Recipe)
|
||||
}
|
||||
|
||||
// Add/update the recipe
|
||||
userRecipes.Recipes[name] = Recipe{
|
||||
Name: name,
|
||||
Path: path,
|
||||
Type: TypeFile,
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
if err := os.MkdirAll(beadsDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create beads dir: %w", err)
|
||||
}
|
||||
|
||||
// Write back
|
||||
f, err := os.Create(recipesPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create recipes.toml: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
encoder := toml.NewEncoder(f)
|
||||
if err := encoder.Encode(userRecipes); err != nil {
|
||||
return fmt.Errorf("encode recipes.toml: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRecipeNames returns sorted list of all recipe names.
|
||||
func ListRecipeNames(beadsDir string) ([]string, error) {
|
||||
recipes, err := GetAllRecipes(beadsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(recipes))
|
||||
for name := range recipes {
|
||||
names = append(names, name)
|
||||
}
|
||||
|
||||
// Sort alphabetically
|
||||
for i := 0; i < len(names); i++ {
|
||||
for j := i + 1; j < len(names); j++ {
|
||||
if names[i] > names[j] {
|
||||
names[i], names[j] = names[j], names[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// IsBuiltin returns true if the recipe is a built-in (not user-defined).
|
||||
func IsBuiltin(name string) bool {
|
||||
_, ok := BuiltinRecipes[name]
|
||||
return ok
|
||||
}
|
||||
188
internal/recipes/recipes_test.go
Normal file
188
internal/recipes/recipes_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package recipes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuiltinRecipes(t *testing.T) {
|
||||
// Ensure all expected built-in recipes exist
|
||||
expected := []string{"cursor", "windsurf", "cody", "kilocode", "claude", "gemini", "factory", "aider"}
|
||||
|
||||
for _, name := range expected {
|
||||
recipe, ok := BuiltinRecipes[name]
|
||||
if !ok {
|
||||
t.Errorf("missing built-in recipe: %s", name)
|
||||
continue
|
||||
}
|
||||
if recipe.Name == "" {
|
||||
t.Errorf("recipe %s has empty Name", name)
|
||||
}
|
||||
if recipe.Type == "" {
|
||||
t.Errorf("recipe %s has empty Type", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecipe(t *testing.T) {
|
||||
// Test getting a built-in recipe
|
||||
recipe, err := GetRecipe("cursor", "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecipe(cursor): %v", err)
|
||||
}
|
||||
if recipe.Name != "Cursor IDE" {
|
||||
t.Errorf("got Name=%q, want 'Cursor IDE'", recipe.Name)
|
||||
}
|
||||
if recipe.Path != ".cursor/rules/beads.mdc" {
|
||||
t.Errorf("got Path=%q, want '.cursor/rules/beads.mdc'", recipe.Path)
|
||||
}
|
||||
|
||||
// Test unknown recipe
|
||||
_, err = GetRecipe("nonexistent", "")
|
||||
if err == nil {
|
||||
t.Error("GetRecipe(nonexistent) should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBuiltin(t *testing.T) {
|
||||
if !IsBuiltin("cursor") {
|
||||
t.Error("cursor should be builtin")
|
||||
}
|
||||
if IsBuiltin("myeditor") {
|
||||
t.Error("myeditor should not be builtin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRecipeNames(t *testing.T) {
|
||||
names, err := ListRecipeNames("")
|
||||
if err != nil {
|
||||
t.Fatalf("ListRecipeNames: %v", err)
|
||||
}
|
||||
|
||||
// Check that it's sorted
|
||||
for i := 1; i < len(names); i++ {
|
||||
if names[i-1] > names[i] {
|
||||
t.Errorf("names not sorted: %v", names)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check expected recipes present
|
||||
found := make(map[string]bool)
|
||||
for _, name := range names {
|
||||
found[name] = true
|
||||
}
|
||||
for _, expected := range []string{"cursor", "claude", "aider"} {
|
||||
if !found[expected] {
|
||||
t.Errorf("expected recipe %s not in list", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRecipes(t *testing.T) {
|
||||
// Create temp directory
|
||||
tmpDir, err := os.MkdirTemp("", "recipes-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Save a user recipe
|
||||
if err := SaveUserRecipe(tmpDir, "myeditor", ".myeditor/rules.md"); err != nil {
|
||||
t.Fatalf("SaveUserRecipe: %v", err)
|
||||
}
|
||||
|
||||
// Verify file was created
|
||||
recipesPath := filepath.Join(tmpDir, "recipes.toml")
|
||||
if _, err := os.Stat(recipesPath); os.IsNotExist(err) {
|
||||
t.Error("recipes.toml was not created")
|
||||
}
|
||||
|
||||
// Load user recipes
|
||||
userRecipes, err := LoadUserRecipes(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadUserRecipes: %v", err)
|
||||
}
|
||||
|
||||
recipe, ok := userRecipes["myeditor"]
|
||||
if !ok {
|
||||
t.Fatal("myeditor recipe not found")
|
||||
}
|
||||
if recipe.Path != ".myeditor/rules.md" {
|
||||
t.Errorf("got Path=%q, want '.myeditor/rules.md'", recipe.Path)
|
||||
}
|
||||
if recipe.Type != TypeFile {
|
||||
t.Errorf("got Type=%q, want 'file'", recipe.Type)
|
||||
}
|
||||
|
||||
// GetRecipe should find user recipe
|
||||
r, err := GetRecipe("myeditor", tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecipe(myeditor): %v", err)
|
||||
}
|
||||
if r.Path != ".myeditor/rules.md" {
|
||||
t.Errorf("got Path=%q, want '.myeditor/rules.md'", r.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRecipeOverride(t *testing.T) {
|
||||
// Create temp directory
|
||||
tmpDir, err := os.MkdirTemp("", "recipes-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Save a user recipe that overrides cursor
|
||||
if err := SaveUserRecipe(tmpDir, "cursor", ".my-cursor/rules.md"); err != nil {
|
||||
t.Fatalf("SaveUserRecipe: %v", err)
|
||||
}
|
||||
|
||||
// GetRecipe should return user's version
|
||||
r, err := GetRecipe("cursor", tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecipe(cursor): %v", err)
|
||||
}
|
||||
if r.Path != ".my-cursor/rules.md" {
|
||||
t.Errorf("user override failed: got Path=%q, want '.my-cursor/rules.md'", r.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUserRecipesNoFile(t *testing.T) {
|
||||
// Should return nil, nil when no file exists
|
||||
recipes, err := LoadUserRecipes("/nonexistent/path")
|
||||
if err != nil {
|
||||
t.Errorf("LoadUserRecipes should not error on missing file: %v", err)
|
||||
}
|
||||
if recipes != nil {
|
||||
t.Error("LoadUserRecipes should return nil for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplate(t *testing.T) {
|
||||
// Basic sanity check that template is not empty
|
||||
if len(Template) < 100 {
|
||||
t.Error("Template is suspiciously short")
|
||||
}
|
||||
// Check for key content
|
||||
if !containsAll(Template, "Beads", "bd ready", "bd create", "bd close") {
|
||||
t.Error("Template missing expected content")
|
||||
}
|
||||
}
|
||||
|
||||
func containsAll(s string, substrs ...string) bool {
|
||||
for _, sub := range substrs {
|
||||
found := false
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
59
internal/recipes/template.go
Normal file
59
internal/recipes/template.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package recipes
|
||||
|
||||
// Template is the universal beads workflow template.
|
||||
// This content is written to all file-based recipes.
|
||||
const Template = `# Beads Issue Tracking
|
||||
|
||||
This project uses [Beads (bd)](https://github.com/steveyegge/beads) for issue tracking.
|
||||
|
||||
## Core Rules
|
||||
|
||||
- Track ALL work in bd (never use markdown TODOs or comment-based task lists)
|
||||
- Use ` + "`bd ready`" + ` to find available work
|
||||
- Use ` + "`bd create`" + ` to track new issues/tasks/bugs
|
||||
- Use ` + "`bd sync`" + ` at end of session to sync with git remote
|
||||
- Git hooks auto-sync on commit/merge
|
||||
|
||||
## Quick Reference
|
||||
|
||||
` + "```bash" + `
|
||||
bd prime # Load complete workflow context
|
||||
bd ready # Show issues ready to work (no blockers)
|
||||
bd list --status=open # List all open issues
|
||||
bd create --title="..." --type=task # Create new issue
|
||||
bd update <id> --status=in_progress # Claim work
|
||||
bd close <id> # Mark complete
|
||||
bd dep add <issue> <depends-on> # Add dependency
|
||||
bd sync # Sync with git remote
|
||||
` + "```" + `
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Check for ready work: ` + "`bd ready`" + `
|
||||
2. Claim an issue: ` + "`bd update <id> --status=in_progress`" + `
|
||||
3. Do the work
|
||||
4. Mark complete: ` + "`bd close <id>`" + `
|
||||
5. Sync: ` + "`bd sync`" + ` (or let git hooks handle it)
|
||||
|
||||
## Issue Types
|
||||
|
||||
- ` + "`bug`" + ` - Something broken
|
||||
- ` + "`feature`" + ` - New functionality
|
||||
- ` + "`task`" + ` - Work item (tests, docs, refactoring)
|
||||
- ` + "`epic`" + ` - Large feature with subtasks
|
||||
- ` + "`chore`" + ` - Maintenance (dependencies, tooling)
|
||||
|
||||
## Priorities
|
||||
|
||||
- ` + "`0`" + ` - Critical (security, data loss, broken builds)
|
||||
- ` + "`1`" + ` - High (major features, important bugs)
|
||||
- ` + "`2`" + ` - Medium (default, nice-to-have)
|
||||
- ` + "`3`" + ` - Low (polish, optimization)
|
||||
- ` + "`4`" + ` - Backlog (future ideas)
|
||||
|
||||
## Context Loading
|
||||
|
||||
Run ` + "`bd prime`" + ` to get complete workflow documentation in AI-optimized format.
|
||||
|
||||
For detailed docs: see AGENTS.md, QUICKSTART.md, or run ` + "`bd --help`" + `
|
||||
`
|
||||
Reference in New Issue
Block a user