From 8e414faa10c78996c03d8acad51b584dc6889607 Mon Sep 17 00:00:00 2001 From: Greg Gauthier Date: Fri, 6 Mar 2026 18:45:03 +0000 Subject: [PATCH] feat(cmd): add recipe run command Implement the `recipe` command with `run` subcommand for executing transactional recipes. - Resolve recipe paths: explicit, project-local (.grokkit/recipes), or global (~/.local/share/grokkit/recipes) with user prompt. - Load and parse recipes using internal/recipe package. - Integrate with root command and project root detection. --- cmd/recipe.go | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 cmd/recipe.go diff --git a/cmd/recipe.go b/cmd/recipe.go new file mode 100644 index 0000000..c46d7f6 --- /dev/null +++ b/cmd/recipe.go @@ -0,0 +1,120 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "repos.gmgauthier.com/gmgauthier/grokkit/internal/recipe" // adjust if your module name differs +) + +var recipeCmd = &cobra.Command{ + Use: "recipe", + Short: "Run a recipe (transactional sous-chef mode)", +} + +var runCmd = &cobra.Command{ + Use: "run [recipe-name|recipe.md]", + Short: "Execute a recipe", + Args: cobra.MinimumNArgs(1), + RunE: runRecipe, +} + +func init() { + recipeCmd.AddCommand(runCmd) + rootCmd.AddCommand(recipeCmd) // this is how every other command is wired in your project +} + +func runRecipe(cmd *cobra.Command, args []string) error { + nameOrPath := args[0] + + // 1. Resolve recipe path (your exact rules) + recipePath, err := resolveRecipePath(nameOrPath) + if err != nil { + return err + } + + // 2. For now we pass empty params (we'll add --param later) + params := make(map[string]any) + + // 3. Load & parse + r, err := recipe.Load(recipePath, params) + if err != nil { + return fmt.Errorf("failed to load recipe: %w", err) + } + + // 4. Run it + runner := recipe.NewRunner(r) + if err := runner.Run(); err != nil { + return err + } + + return nil +} + +// resolveRecipePath implements exactly what you asked for +func resolveRecipePath(nameOrPath string) (string, error) { + // explicit path? + if strings.Contains(nameOrPath, "/") || strings.HasSuffix(nameOrPath, ".md") { + if _, err := os.Stat(nameOrPath); err == nil { + return nameOrPath, nil + } + return "", fmt.Errorf("recipe file not found: %s", nameOrPath) + } + + // normalise name + if !strings.HasSuffix(nameOrPath, ".md") { + nameOrPath += ".md" + } + + // 1. Project-local first (primary source of truth) + projectRoot, err := findProjectRoot() + if err == nil { + local := filepath.Join(projectRoot, ".grokkit", "recipes", nameOrPath) + if _, err := os.Stat(local); err == nil { + return local, nil + } + } + + // 2. Global XDG fallback + global := filepath.Join(os.Getenv("HOME"), ".local", "share", "grokkit", "recipes", nameOrPath) + if _, err := os.Stat(global); err == nil { + fmt.Printf("Recipe %q not found in project.\nFound globally at %s\nUse this one? [y/N] ", nameOrPath, global) + var answer string + fmt.Scanln(&answer) + if strings.HasPrefix(strings.ToLower(answer), "y") { + return global, nil + } + return "", fmt.Errorf("user declined global recipe") + } + + return "", fmt.Errorf("recipe %q not found in project or global store", nameOrPath) +} + +// tiny helper used by almost every command already +func findProjectRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + return dir, nil + } + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + if _, err := os.Stat(filepath.Join(dir, ".grokkit")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", fmt.Errorf("not in a project") +}