- gate.go: fix "cancelled" → "canceled" misspelling, add #nosec for validated GitHub IDs in exec.Command, mark checkTimer escalated as intentionally false, rename unused ctx param - sync_divergence.go: add #nosec for git commands with validated paths, mark unused path param - sync_branch.go: add #nosec for .git/info/exclude permissions - setup.go: add #nosec for config file permissions - recipes.go: add #nosec for validated config file paths - external_deps.go: add #nosec for SQL with generated placeholders 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
245 lines
6.6 KiB
Go
245 lines
6.6 KiB
Go
// 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) // #nosec G304 -- path is constructed from validated beadsDir
|
|
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) // #nosec G304 -- path is constructed from validated beadsDir
|
|
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) // #nosec G304 -- path is constructed from validated beadsDir
|
|
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
|
|
}
|