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:
dave
2026-01-04 21:57:09 -08:00
committed by Steve Yegge
parent 053d005956
commit 6730fce9b1
5 changed files with 854 additions and 137 deletions

View File

@@ -1,8 +1,15 @@
package main package main
import ( import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/cmd/bd/setup" "github.com/steveyegge/beads/cmd/bd/setup"
"github.com/steveyegge/beads/internal/recipes"
) )
var ( var (
@@ -10,162 +17,310 @@ var (
setupCheck bool setupCheck bool
setupRemove bool setupRemove bool
setupStealth bool setupStealth bool
setupPrint bool
setupOutput string
setupList bool
setupAdd string
) )
var setupCmd = &cobra.Command{ var setupCmd = &cobra.Command{
Use: "setup", Use: "setup [recipe]",
GroupID: "setup", GroupID: "setup",
Short: "Setup integration with AI editors", Short: "Setup integration with AI editors",
Long: `Setup integration files for AI editors like Claude Code, Cursor, Aider, and Factory.ai Droid.`, Long: `Setup integration files for AI editors and coding assistants.
Recipes define where beads workflow instructions are written. Built-in recipes
include cursor, claude, gemini, aider, factory, windsurf, cody, and kilocode.
Examples:
bd setup cursor # Install Cursor IDE integration
bd setup --list # Show all available recipes
bd setup --print # Print the template to stdout
bd setup -o rules.md # Write template to custom path
bd setup --add myeditor .myeditor/rules.md # Add custom recipe
Use 'bd setup <recipe> --check' to verify installation status.
Use 'bd setup <recipe> --remove' to uninstall.`,
Args: cobra.MaximumNArgs(1),
Run: runSetup,
} }
var setupCursorCmd = &cobra.Command{ func runSetup(cmd *cobra.Command, args []string) {
Use: "cursor", // Handle --list flag
Short: "Setup Cursor IDE integration", if setupList {
Long: `Install Beads workflow rules for Cursor IDE. listRecipes()
return
}
Creates .cursor/rules/beads.mdc with bd workflow context. // Handle --print flag (no recipe needed)
Uses BEGIN/END markers for safe idempotent updates.`, if setupPrint {
Run: func(cmd *cobra.Command, args []string) { fmt.Print(recipes.Template)
return
}
// Handle -o flag (write to arbitrary path)
if setupOutput != "" {
if err := writeToPath(setupOutput); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("✓ Wrote template to %s\n", setupOutput)
return
}
// Handle --add flag (save custom recipe)
if setupAdd != "" {
if len(args) != 1 {
fmt.Fprintln(os.Stderr, "Error: --add requires a path argument")
fmt.Fprintln(os.Stderr, "Usage: bd setup --add <name> <path>")
os.Exit(1)
}
if err := addRecipe(setupAdd, args[0]); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return
}
// Require a recipe name for install/check/remove
if len(args) == 0 {
_ = cmd.Help()
return
}
recipeName := strings.ToLower(args[0])
runRecipe(recipeName)
}
func listRecipes() {
beadsDir := findBeadsDir()
allRecipes, err := recipes.GetAllRecipes(beadsDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading recipes: %v\n", err)
os.Exit(1)
}
// Sort recipe names
names := make([]string, 0, len(allRecipes))
for name := range allRecipes {
names = append(names, name)
}
sort.Strings(names)
fmt.Println("Available recipes:")
fmt.Println()
for _, name := range names {
r := allRecipes[name]
source := "built-in"
if !recipes.IsBuiltin(name) {
source = "user"
}
fmt.Printf(" %-12s %-25s (%s)\n", name, r.Description, source)
}
fmt.Println()
fmt.Println("Use 'bd setup <recipe>' to install.")
fmt.Println("Use 'bd setup --add <name> <path>' to add a custom recipe.")
}
func writeToPath(path string) error {
// Ensure parent directory exists
dir := filepath.Dir(path)
if dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create directory: %w", err)
}
}
if err := os.WriteFile(path, []byte(recipes.Template), 0o644); err != nil {
return fmt.Errorf("write file: %w", err)
}
return nil
}
func addRecipe(name, path string) error {
beadsDir := findBeadsDir()
if beadsDir == "" {
beadsDir = ".beads"
}
if err := recipes.SaveUserRecipe(beadsDir, name, path); err != nil {
return err
}
fmt.Printf("✓ Added recipe '%s' → %s\n", name, path)
fmt.Printf(" Config: %s/recipes.toml\n", beadsDir)
fmt.Println()
fmt.Printf("Install with: bd setup %s\n", name)
return nil
}
func runRecipe(name string) {
// Check for legacy recipes that need special handling
switch name {
case "claude":
runClaudeRecipe()
return
case "gemini":
runGeminiRecipe()
return
case "factory":
runFactoryRecipe()
return
case "aider":
runAiderRecipe()
return
case "cursor":
runCursorRecipe()
return
}
// For all other recipes (built-in or user), use generic file-based install
beadsDir := findBeadsDir()
recipe, err := recipes.GetRecipe(name, beadsDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
fmt.Fprintln(os.Stderr, "Use 'bd setup --list' to see available recipes.")
os.Exit(1)
}
if recipe.Type != recipes.TypeFile {
fmt.Fprintf(os.Stderr, "Error: recipe '%s' has type '%s' which requires special handling\n", name, recipe.Type)
os.Exit(1)
}
// Handle --check
if setupCheck {
if _, err := os.Stat(recipe.Path); os.IsNotExist(err) {
fmt.Printf("✗ %s integration not installed\n", recipe.Name)
fmt.Printf(" Run: bd setup %s\n", name)
os.Exit(1)
}
fmt.Printf("✓ %s integration installed: %s\n", recipe.Name, recipe.Path)
return
}
// Handle --remove
if setupRemove {
if err := os.Remove(recipe.Path); err != nil {
if os.IsNotExist(err) {
fmt.Println("No integration file found")
return
}
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("✓ Removed %s integration\n", recipe.Name)
return
}
// Install
fmt.Printf("Installing %s integration...\n", recipe.Name)
// Ensure parent directory exists
dir := filepath.Dir(recipe.Path)
if dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "Error: create directory: %v\n", err)
os.Exit(1)
}
}
if err := os.WriteFile(recipe.Path, []byte(recipes.Template), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "Error: write file: %v\n", err)
os.Exit(1)
}
fmt.Printf("\n✓ %s integration installed\n", recipe.Name)
fmt.Printf(" File: %s\n", recipe.Path)
}
// Legacy recipe handlers that delegate to existing implementations
func runCursorRecipe() {
if setupCheck { if setupCheck {
setup.CheckCursor() setup.CheckCursor()
return return
} }
if setupRemove { if setupRemove {
setup.RemoveCursor() setup.RemoveCursor()
return return
} }
setup.InstallCursor() setup.InstallCursor()
},
} }
var setupAiderCmd = &cobra.Command{ func runClaudeRecipe() {
Use: "aider",
Short: "Setup Aider integration",
Long: `Install Beads workflow configuration for Aider.
Creates .aider.conf.yml with bd workflow instructions.
The AI will suggest bd commands for you to run via /run.
Note: Aider requires explicit command execution - the AI cannot
run commands autonomously. It will suggest bd commands which you
must confirm using Aider's /run command.`,
Run: func(cmd *cobra.Command, args []string) {
if setupCheck {
setup.CheckAider()
return
}
if setupRemove {
setup.RemoveAider()
return
}
setup.InstallAider()
},
}
var setupFactoryCmd = &cobra.Command{
Use: "factory",
Short: "Setup Factory.ai (Droid) integration",
Long: `Install Beads workflow configuration for Factory.ai Droid.
Creates or updates AGENTS.md with bd workflow instructions.
Factory Droids automatically read AGENTS.md on session start.
AGENTS.md is the standard format used across AI coding assistants
(Factory, Cursor, Aider, Gemini CLI, Jules, and more).`,
Run: func(cmd *cobra.Command, args []string) {
if setupCheck {
setup.CheckFactory()
return
}
if setupRemove {
setup.RemoveFactory()
return
}
setup.InstallFactory()
},
}
var setupClaudeCmd = &cobra.Command{
Use: "claude",
Short: "Setup Claude Code integration",
Long: `Install Claude Code hooks that auto-inject bd workflow context.
By default, installs hooks globally (~/.claude/settings.json).
Use --project flag to install only for this project.
Hooks call 'bd prime' on SessionStart and PreCompact events to prevent
agents from forgetting bd workflow after context compaction.`,
Run: func(cmd *cobra.Command, args []string) {
if setupCheck { if setupCheck {
setup.CheckClaude() setup.CheckClaude()
return return
} }
if setupRemove { if setupRemove {
setup.RemoveClaude(setupProject) setup.RemoveClaude(setupProject)
return return
} }
setup.InstallClaude(setupProject, setupStealth) setup.InstallClaude(setupProject, setupStealth)
},
} }
var setupGeminiCmd = &cobra.Command{ func runGeminiRecipe() {
Use: "gemini",
Short: "Setup Gemini CLI integration",
Long: `Install Gemini CLI hooks that auto-inject bd workflow context.
By default, installs hooks globally (~/.gemini/settings.json).
Use --project flag to install only for this project.
Hooks call 'bd prime' on SessionStart and PreCompress events to prevent
agents from forgetting bd workflow after context compaction.`,
Run: func(cmd *cobra.Command, args []string) {
if setupCheck { if setupCheck {
setup.CheckGemini() setup.CheckGemini()
return return
} }
if setupRemove { if setupRemove {
setup.RemoveGemini(setupProject) setup.RemoveGemini(setupProject)
return return
} }
setup.InstallGemini(setupProject, setupStealth) setup.InstallGemini(setupProject, setupStealth)
}, }
func runFactoryRecipe() {
if setupCheck {
setup.CheckFactory()
return
}
if setupRemove {
setup.RemoveFactory()
return
}
setup.InstallFactory()
}
func runAiderRecipe() {
if setupCheck {
setup.CheckAider()
return
}
if setupRemove {
setup.RemoveAider()
return
}
setup.InstallAider()
}
func findBeadsDir() string {
// Check for .beads in current directory
if info, err := os.Stat(".beads"); err == nil && info.IsDir() {
return ".beads"
}
// Check for redirected beads directory
redirectPath := ".beads/.redirect"
if data, err := os.ReadFile(redirectPath); err == nil {
return strings.TrimSpace(string(data))
}
return ".beads"
} }
func init() { func init() {
setupFactoryCmd.Flags().BoolVar(&setupCheck, "check", false, "Check if Factory.ai integration is installed") // Global flags for the setup command
setupFactoryCmd.Flags().BoolVar(&setupRemove, "remove", false, "Remove bd section from AGENTS.md") setupCmd.Flags().BoolVar(&setupList, "list", false, "List all available recipes")
setupCmd.Flags().BoolVar(&setupPrint, "print", false, "Print the template to stdout")
setupCmd.Flags().StringVarP(&setupOutput, "output", "o", "", "Write template to custom path")
setupCmd.Flags().StringVar(&setupAdd, "add", "", "Add a custom recipe with given name")
setupClaudeCmd.Flags().BoolVar(&setupProject, "project", false, "Install for this project only (not globally)") // Per-recipe flags
setupClaudeCmd.Flags().BoolVar(&setupCheck, "check", false, "Check if Claude integration is installed") setupCmd.Flags().BoolVar(&setupCheck, "check", false, "Check if integration is installed")
setupClaudeCmd.Flags().BoolVar(&setupRemove, "remove", false, "Remove bd hooks from Claude settings") setupCmd.Flags().BoolVar(&setupRemove, "remove", false, "Remove the integration")
setupClaudeCmd.Flags().BoolVar(&setupStealth, "stealth", false, "Use 'bd prime --stealth' (flush only, no git operations)") setupCmd.Flags().BoolVar(&setupProject, "project", false, "Install for this project only (claude/gemini)")
setupCmd.Flags().BoolVar(&setupStealth, "stealth", false, "Use stealth mode (claude/gemini)")
setupCursorCmd.Flags().BoolVar(&setupCheck, "check", false, "Check if Cursor integration is installed")
setupCursorCmd.Flags().BoolVar(&setupRemove, "remove", false, "Remove bd rules from Cursor")
setupAiderCmd.Flags().BoolVar(&setupCheck, "check", false, "Check if Aider integration is installed")
setupAiderCmd.Flags().BoolVar(&setupRemove, "remove", false, "Remove bd config from Aider")
setupGeminiCmd.Flags().BoolVar(&setupProject, "project", false, "Install for this project only (not globally)")
setupGeminiCmd.Flags().BoolVar(&setupCheck, "check", false, "Check if Gemini CLI integration is installed")
setupGeminiCmd.Flags().BoolVar(&setupRemove, "remove", false, "Remove bd hooks from Gemini CLI settings")
setupGeminiCmd.Flags().BoolVar(&setupStealth, "stealth", false, "Use 'bd prime --stealth' (flush only, no git operations)")
setupCmd.AddCommand(setupFactoryCmd)
setupCmd.AddCommand(setupClaudeCmd)
setupCmd.AddCommand(setupCursorCmd)
setupCmd.AddCommand(setupAiderCmd)
setupCmd.AddCommand(setupGeminiCmd)
rootCmd.AddCommand(setupCmd) rootCmd.AddCommand(setupCmd)
} }

View File

@@ -1,36 +1,53 @@
# Setup Command Reference # Setup Command Reference
**For:** Setting up beads integration with AI coding tools **For:** Setting up beads integration with AI coding tools
**Version:** 0.29.0+ **Version:** 0.30.0+
## Overview ## Overview
The `bd setup` command configures beads integration with AI coding tools. It supports five integrations: The `bd setup` command uses a **recipe-based architecture** to configure beads integration with AI coding tools. Recipes define where workflow instructions are written—built-in recipes handle popular tools, and you can add custom recipes for any tool.
| Tool | Command | Integration Type | ### Built-in Recipes
|------|---------|-----------------|
| [Factory.ai (Droid)](#factoryai-droid) | `bd setup factory` | AGENTS.md file (universal standard) | | Recipe | Path | Integration Type |
| [Claude Code](#claude-code) | `bd setup claude` | SessionStart/PreCompact hooks | |--------|------|-----------------|
| [Gemini CLI](#gemini-cli) | `bd setup gemini` | SessionStart/PreCompress hooks | | `cursor` | `.cursor/rules/beads.mdc` | Rules file |
| [Cursor IDE](#cursor-ide) | `bd setup cursor` | Rules file (.cursor/rules/beads.mdc) | | `windsurf` | `.windsurf/rules/beads.md` | Rules file |
| [Aider](#aider) | `bd setup aider` | Config file (.aider.conf.yml) | | `cody` | `.cody/rules/beads.md` | Rules file |
| `kilocode` | `.kilocode/rules/beads.md` | Rules file |
| `claude` | `~/.claude/settings.json` | SessionStart/PreCompact hooks |
| `gemini` | `~/.gemini/settings.json` | SessionStart/PreCompress hooks |
| `factory` | `AGENTS.md` | Marked section |
| `aider` | `.aider.conf.yml` + `.aider/` | Multi-file config |
## Quick Start ## Quick Start
```bash ```bash
# List all available recipes
bd setup --list
# Install integration for your tool # Install integration for your tool
bd setup factory # For Factory.ai Droid (and other AGENTS.md-compatible tools) bd setup cursor # Cursor IDE
bd setup claude # For Claude Code bd setup windsurf # Windsurf
bd setup gemini # For Gemini CLI bd setup kilocode # Kilo Code
bd setup cursor # For Cursor IDE bd setup claude # Claude Code
bd setup aider # For Aider bd setup gemini # Gemini CLI
bd setup factory # Factory.ai Droid
bd setup aider # Aider
# Verify installation # Verify installation
bd setup factory --check
bd setup claude --check
bd setup gemini --check
bd setup cursor --check bd setup cursor --check
bd setup aider --check bd setup claude --check
# Print template to stdout (for inspection)
bd setup --print
# Write template to custom path
bd setup -o .my-editor/rules.md
# Add a custom recipe
bd setup --add myeditor .myeditor/rules.md
bd setup myeditor # Now you can use it
``` ```
## Factory.ai (Droid) ## Factory.ai (Droid)
@@ -396,6 +413,60 @@ bd setup claude --remove
bd setup claude --project bd setup claude --project
``` ```
## Custom Recipes
You can add custom recipes for editors/tools not included in the built-in list.
### Adding a Custom Recipe
```bash
# Add a recipe that writes to a specific path
bd setup --add myeditor .myeditor/rules.md
# Install it
bd setup myeditor
# Check it
bd setup myeditor --check
# Remove it
bd setup myeditor --remove
```
### User Recipes File
Custom recipes are stored in `.beads/recipes.toml`:
```toml
[recipes.myeditor]
name = "myeditor"
path = ".myeditor/rules.md"
type = "file"
```
### Using Arbitrary Paths
For one-off installs without saving a recipe:
```bash
# Write template to any path
bd setup -o .my-custom-location/beads.md
# Inspect the template first
bd setup --print
```
### Recipe Types
| Type | Description | Example |
|------|-------------|---------|
| `file` | Write template to a single file | cursor, windsurf, cody, kilocode |
| `hooks` | Modify JSON settings to add hooks | claude, gemini |
| `section` | Inject marked section into existing file | factory |
| `multifile` | Write multiple files | aider |
Custom recipes added via `--add` are always type `file`.
## Related Documentation ## Related Documentation
- [CLAUDE_INTEGRATION.md](CLAUDE_INTEGRATION.md) - Design decisions for Claude Code integration - [CLAUDE_INTEGRATION.md](CLAUDE_INTEGRATION.md) - Design decisions for Claude Code integration

244
internal/recipes/recipes.go Normal file
View 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
}

View 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
}

View 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`" + `
`