grokkit/internal/recipe/runner.go
Greg Gauthier f5c73ab291 refactor(recipe): add ResolvedParams for better param handling
Introduce ResolvedParams field to Recipe struct for storing resolved
parameter values from defaults and user overrides. Update loader to
populate and use it for template rendering. Adjust runner to use
ResolvedParams for root path and generalize file discovery.
2026-03-07 00:07:21 +00:00

234 lines
5.8 KiB
Go

package recipe
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"gmgauthier.com/grokkit/internal/grok"
)
type Runner struct {
Recipe *Recipe
Client *grok.Client
Model string
}
func NewRunner(r *Recipe, client *grok.Client, model string) *Runner {
return &Runner{Recipe: r, Client: client, Model: model}
}
func (r *Runner) Run() error {
fmt.Printf("🍳 Starting recipe: %s v%s\n\n", r.Recipe.Name, r.Recipe.Version)
var previousResults []string
var refactorJSONs []string
for _, step := range r.Recipe.Steps {
fmt.Printf("Step %d/%d: %s\n", step.Number, len(r.Recipe.Steps), step.Title)
titleLower := strings.ToLower(step.Title)
switch {
case strings.Contains(titleLower, "discover") || strings.Contains(titleLower, "find"):
files := r.discoverFiles()
result := strings.Join(files, "\n")
previousResults = append(previousResults, "Discovered files:\n"+result)
fmt.Println(result)
case strings.Contains(titleLower, "refactor"):
r.refactorFiles(previousResults, &refactorJSONs)
continue
case strings.Contains(titleLower, "apply") || strings.Contains(titleLower, "patch"):
r.handleApplyStep(refactorJSONs)
continue
default:
prompt := fmt.Sprintf(`Recipe Overview:
%s
Previous step results (for context):
%s
=== CURRENT STEP ===
Objective: %s
Instructions: %s
Expected output format: %s
Execute this step now. Respond ONLY with the expected output format — no explanations, no extra text.`,
r.Recipe.Overview,
strings.Join(previousResults, "\n\n---\n\n"),
step.Objective,
step.Instructions,
step.Expected)
messages := []map[string]string{
{"role": "system", "content": "You are Grok, built by xAI. Precise expert programmer and refactoring assistant."},
{"role": "user", "content": prompt},
}
response := r.Client.Stream(messages, r.Model)
fmt.Println()
previousResults = append(previousResults, fmt.Sprintf("Step %d result:\n%s", step.Number, response))
}
}
fmt.Println("\n✅ Recipe complete.")
return nil
}
// discoverFiles — now fully generic using recipe metadata
func (r *Runner) discoverFiles() []string {
var files []string
// Get root from --param or default
root := "."
if v, ok := r.Recipe.ResolvedParams["package_path"]; ok {
if s, ok := v.(string); ok && s != "" {
root = s
}
}
// Build allowed extensions from recipe frontmatter
allowedExt := make(map[string]bool)
for _, lang := range r.Recipe.ProjectLanguages {
if exts, ok := r.Recipe.Extensions[lang]; ok {
for _, ext := range exts {
allowedExt[ext] = true
}
}
}
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if allowedExt[filepath.Ext(path)] {
b, _ := os.ReadFile(path)
if strings.Contains(string(b), "if err != nil") {
files = append(files, path)
}
}
return nil
})
if len(files) == 0 {
files = append(files, "No files found matching the criteria.")
}
return files
}
// refactorFiles — one file at a time
func (r *Runner) refactorFiles(previousResults []string, refactorJSONs *[]string) {
discoveredLine := previousResults[len(previousResults)-1]
lines := strings.Split(discoveredLine, "\n")
for _, line := range lines {
filePath := strings.TrimSpace(line)
if filePath == "" || strings.HasPrefix(filePath, "Discovered") || filePath == "No files found matching the criteria." {
continue
}
fmt.Printf(" Refactoring %s...\n", filePath)
content, err := os.ReadFile(filePath)
if err != nil {
fmt.Printf(" ❌ Could not read %s\n", filePath)
continue
}
prompt := fmt.Sprintf(`Refactor the following file to use Result[T] instead of naked errors.
Follow existing style and preserve all comments.
Return ONLY this exact JSON (no extra text, no markdown):
{
"file": "%s",
"content": "the complete refactored file here"
}
Original file:
%s`, filePath, string(content))
messages := []map[string]string{
{"role": "system", "content": "You are Grok, built by xAI. Precise expert programmer and refactoring assistant."},
{"role": "user", "content": prompt},
}
response := r.Client.Stream(messages, r.Model)
fmt.Println()
*refactorJSONs = append(*refactorJSONs, response)
}
}
type FileChange struct {
File string `json:"file"`
Content string `json:"content"`
}
func (r *Runner) handleApplyStep(refactorJSONs []string) {
if len(refactorJSONs) == 0 {
fmt.Println(" ⚠️ No refactored files to apply — skipping.")
return
}
var allChanges []FileChange
for _, jsonStr := range refactorJSONs {
start := strings.Index(jsonStr, "{")
end := strings.LastIndex(jsonStr, "}") + 1
if start == -1 {
continue
}
var ch FileChange
if err := json.Unmarshal([]byte(jsonStr[start:end]), &ch); err == nil && ch.File != "" {
allChanges = append(allChanges, ch)
}
}
if len(allChanges) == 0 {
fmt.Println(" ⚠️ No valid file changes found — skipping.")
return
}
fmt.Println(" 📄 Dry-run mode: creating patch file...")
patchPath := filepath.Join(".", "recipe-refactor.patch")
if err := createUnifiedPatch(allChanges, patchPath); err != nil {
fmt.Printf(" ❌ Failed to create patch: %v\n", err)
return
}
fmt.Printf(" ✅ Patch created: %s\n", patchPath)
fmt.Println(" Review it, then run with dry_run=false to apply.")
}
func createUnifiedPatch(changes []FileChange, patchPath string) error {
f, err := os.Create(patchPath)
if err != nil {
return err
}
defer func(f *os.File) {
err := f.Close()
if err != nil {
return
}
}(f)
for _, ch := range changes {
_, err := fmt.Fprintf(f, "--- %s\n+++ %s\n@@ -0,0 +1,%d @@\n", ch.File, ch.File, strings.Count(ch.Content, "\n")+1)
if err != nil {
return err
}
for _, line := range strings.Split(ch.Content, "\n") {
_, err := fmt.Fprintf(f, "+%s\n", line)
if err != nil {
return err
}
}
}
return nil
}