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
189 lines
4.4 KiB
Go
189 lines
4.4 KiB
Go
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
|
|
}
|