From 6730fce9b1ac61b1b37fbb656f877af51b2fa841 Mon Sep 17 00:00:00 2001 From: dave Date: Sun, 4 Jan 2026 21:57:09 -0800 Subject: [PATCH] feat(setup): refactor to recipe-based architecture (bd-i3ed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Executed-By: beads/crew/dave Rig: beads Role: crew --- cmd/bd/setup.go | 393 +++++++++++++++++++++---------- docs/SETUP.md | 107 +++++++-- internal/recipes/recipes.go | 244 +++++++++++++++++++ internal/recipes/recipes_test.go | 188 +++++++++++++++ internal/recipes/template.go | 59 +++++ 5 files changed, 854 insertions(+), 137 deletions(-) create mode 100644 internal/recipes/recipes.go create mode 100644 internal/recipes/recipes_test.go create mode 100644 internal/recipes/template.go diff --git a/cmd/bd/setup.go b/cmd/bd/setup.go index d93dcf24..f00f492f 100644 --- a/cmd/bd/setup.go +++ b/cmd/bd/setup.go @@ -1,8 +1,15 @@ package main import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "github.com/spf13/cobra" "github.com/steveyegge/beads/cmd/bd/setup" + "github.com/steveyegge/beads/internal/recipes" ) var ( @@ -10,162 +17,310 @@ var ( setupCheck bool setupRemove bool setupStealth bool + setupPrint bool + setupOutput string + setupList bool + setupAdd string ) var setupCmd = &cobra.Command{ - Use: "setup", + Use: "setup [recipe]", GroupID: "setup", 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 --check' to verify installation status. +Use 'bd setup --remove' to uninstall.`, + Args: cobra.MaximumNArgs(1), + Run: runSetup, } -var setupCursorCmd = &cobra.Command{ - Use: "cursor", - Short: "Setup Cursor IDE integration", - Long: `Install Beads workflow rules for Cursor IDE. +func runSetup(cmd *cobra.Command, args []string) { + // Handle --list flag + if setupList { + listRecipes() + return + } -Creates .cursor/rules/beads.mdc with bd workflow context. -Uses BEGIN/END markers for safe idempotent updates.`, - Run: func(cmd *cobra.Command, args []string) { - if setupCheck { - setup.CheckCursor() - return + // Handle --print flag (no recipe needed) + if setupPrint { + 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 + } - if setupRemove { - setup.RemoveCursor() - 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 ") + os.Exit(1) } + if err := addRecipe(setupAdd, args[0]); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return + } - setup.InstallCursor() - }, + // Require a recipe name for install/check/remove + if len(args) == 0 { + _ = cmd.Help() + return + } + + recipeName := strings.ToLower(args[0]) + runRecipe(recipeName) } -var setupAiderCmd = &cobra.Command{ - Use: "aider", - Short: "Setup Aider integration", - Long: `Install Beads workflow configuration for Aider. +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) + } -Creates .aider.conf.yml with bd workflow instructions. -The AI will suggest bd commands for you to run via /run. + // Sort recipe names + names := make([]string, 0, len(allRecipes)) + for name := range allRecipes { + names = append(names, name) + } + sort.Strings(names) -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 + fmt.Println("Available recipes:") + fmt.Println() + for _, name := range names { + r := allRecipes[name] + source := "built-in" + if !recipes.IsBuiltin(name) { + source = "user" } - - if setupRemove { - setup.RemoveAider() - return - } - - setup.InstallAider() - }, + fmt.Printf(" %-12s %-25s (%s)\n", name, r.Description, source) + } + fmt.Println() + fmt.Println("Use 'bd setup ' to install.") + fmt.Println("Use 'bd setup --add ' to add a custom recipe.") } -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 +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 setupRemove { - setup.RemoveFactory() - return - } - - setup.InstallFactory() - }, + if err := os.WriteFile(path, []byte(recipes.Template), 0o644); err != nil { + return fmt.Errorf("write file: %w", err) + } + return nil } -var setupClaudeCmd = &cobra.Command{ - Use: "claude", - Short: "Setup Claude Code integration", - Long: `Install Claude Code hooks that auto-inject bd workflow context. +func addRecipe(name, path string) error { + beadsDir := findBeadsDir() + if beadsDir == "" { + beadsDir = ".beads" + } -By default, installs hooks globally (~/.claude/settings.json). -Use --project flag to install only for this project. + if err := recipes.SaveUserRecipe(beadsDir, name, path); err != nil { + return err + } -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 { - setup.CheckClaude() - return - } - - if setupRemove { - setup.RemoveClaude(setupProject) - return - } - - setup.InstallClaude(setupProject, setupStealth) - }, + 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 } -var setupGeminiCmd = &cobra.Command{ - Use: "gemini", - Short: "Setup Gemini CLI integration", - Long: `Install Gemini CLI hooks that auto-inject bd workflow context. +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 + } -By default, installs hooks globally (~/.gemini/settings.json). -Use --project flag to install only for this project. + // 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) + } -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 { - setup.CheckGemini() - return + 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 + } - if setupRemove { - setup.RemoveGemini(setupProject) - 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 + } - setup.InstallGemini(setupProject, setupStealth) - }, + // 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 { + setup.CheckCursor() + return + } + if setupRemove { + setup.RemoveCursor() + return + } + setup.InstallCursor() +} + +func runClaudeRecipe() { + if setupCheck { + setup.CheckClaude() + return + } + if setupRemove { + setup.RemoveClaude(setupProject) + return + } + setup.InstallClaude(setupProject, setupStealth) +} + +func runGeminiRecipe() { + if setupCheck { + setup.CheckGemini() + return + } + if setupRemove { + setup.RemoveGemini(setupProject) + return + } + 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() { - setupFactoryCmd.Flags().BoolVar(&setupCheck, "check", false, "Check if Factory.ai integration is installed") - setupFactoryCmd.Flags().BoolVar(&setupRemove, "remove", false, "Remove bd section from AGENTS.md") + // Global flags for the setup command + 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)") - setupClaudeCmd.Flags().BoolVar(&setupCheck, "check", false, "Check if Claude integration is installed") - setupClaudeCmd.Flags().BoolVar(&setupRemove, "remove", false, "Remove bd hooks from Claude settings") - setupClaudeCmd.Flags().BoolVar(&setupStealth, "stealth", false, "Use 'bd prime --stealth' (flush only, no git operations)") + // Per-recipe flags + setupCmd.Flags().BoolVar(&setupCheck, "check", false, "Check if integration is installed") + setupCmd.Flags().BoolVar(&setupRemove, "remove", false, "Remove the integration") + 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) } diff --git a/docs/SETUP.md b/docs/SETUP.md index 4c9a3154..644fdefe 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -1,36 +1,53 @@ # Setup Command Reference **For:** Setting up beads integration with AI coding tools -**Version:** 0.29.0+ +**Version:** 0.30.0+ ## 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 | -|------|---------|-----------------| -| [Factory.ai (Droid)](#factoryai-droid) | `bd setup factory` | AGENTS.md file (universal standard) | -| [Claude Code](#claude-code) | `bd setup claude` | SessionStart/PreCompact hooks | -| [Gemini CLI](#gemini-cli) | `bd setup gemini` | SessionStart/PreCompress hooks | -| [Cursor IDE](#cursor-ide) | `bd setup cursor` | Rules file (.cursor/rules/beads.mdc) | -| [Aider](#aider) | `bd setup aider` | Config file (.aider.conf.yml) | +### Built-in Recipes + +| Recipe | Path | Integration Type | +|--------|------|-----------------| +| `cursor` | `.cursor/rules/beads.mdc` | Rules file | +| `windsurf` | `.windsurf/rules/beads.md` | Rules file | +| `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 ```bash +# List all available recipes +bd setup --list + # Install integration for your tool -bd setup factory # For Factory.ai Droid (and other AGENTS.md-compatible tools) -bd setup claude # For Claude Code -bd setup gemini # For Gemini CLI -bd setup cursor # For Cursor IDE -bd setup aider # For Aider +bd setup cursor # Cursor IDE +bd setup windsurf # Windsurf +bd setup kilocode # Kilo Code +bd setup claude # Claude Code +bd setup gemini # Gemini CLI +bd setup factory # Factory.ai Droid +bd setup aider # Aider # Verify installation -bd setup factory --check -bd setup claude --check -bd setup gemini --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) @@ -396,6 +413,60 @@ bd setup claude --remove 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 - [CLAUDE_INTEGRATION.md](CLAUDE_INTEGRATION.md) - Design decisions for Claude Code integration diff --git a/internal/recipes/recipes.go b/internal/recipes/recipes.go new file mode 100644 index 00000000..ec671bb1 --- /dev/null +++ b/internal/recipes/recipes.go @@ -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 +} diff --git a/internal/recipes/recipes_test.go b/internal/recipes/recipes_test.go new file mode 100644 index 00000000..a07097b7 --- /dev/null +++ b/internal/recipes/recipes_test.go @@ -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 +} diff --git a/internal/recipes/template.go b/internal/recipes/template.go new file mode 100644 index 00000000..54514be0 --- /dev/null +++ b/internal/recipes/template.go @@ -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 --status=in_progress # Claim work +bd close # Mark complete +bd dep add # Add dependency +bd sync # Sync with git remote +` + "```" + ` + +## Workflow + +1. Check for ready work: ` + "`bd ready`" + ` +2. Claim an issue: ` + "`bd update --status=in_progress`" + ` +3. Do the work +4. Mark complete: ` + "`bd close `" + ` +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`" + ` +`