Introduce a new mechanism in the recipe runner to execute whitelisted shell commands for steps like initialization, creation, or running tools (e.g., poetry, git). Commands are generated via AI prompt, parsed from JSON, validated against an allowed list, and executed within a strict working directory to ensure safety.
355 lines
8.8 KiB
Go
355 lines
8.8 KiB
Go
package recipe
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"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
|
|
|
|
// === NEW: Safe shell command execution ===
|
|
case strings.Contains(titleLower, "initialize") || strings.Contains(titleLower, "create") ||
|
|
strings.Contains(titleLower, "run") || strings.Contains(titleLower, "shell") ||
|
|
strings.Contains(titleLower, "poetry") || strings.Contains(titleLower, "git") ||
|
|
strings.Contains(titleLower, "tea"):
|
|
r.executeShellCommands(step, previousResults)
|
|
continue
|
|
|
|
default:
|
|
// fallback for any other step
|
|
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 — fully generic using recipe metadata
|
|
func (r *Runner) discoverFiles() []string {
|
|
var files []string
|
|
|
|
// 1. Use explicit --param package_path if provided
|
|
root := "."
|
|
if v, ok := r.Recipe.ResolvedParams["package_path"]; ok {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
root = s
|
|
}
|
|
}
|
|
|
|
// 2. Smart defaults if no param was given
|
|
if root == "." {
|
|
if _, err := os.Stat("src"); err == nil {
|
|
root = "src"
|
|
}
|
|
}
|
|
|
|
// 3. 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Use a configurable search pattern (defaults to Go-style)
|
|
searchFor := r.Recipe.SearchPattern
|
|
if searchFor == "" {
|
|
searchFor = "if err != nil"
|
|
}
|
|
|
|
_ = 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), searchFor) {
|
|
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
|
|
}
|
|
|
|
// NEW: Safe shell command execution
|
|
func (r *Runner) executeShellCommands(step Step, previousResults []string) {
|
|
prompt := fmt.Sprintf(`You are executing shell commands for this step.
|
|
|
|
Recipe Overview:
|
|
%s
|
|
|
|
Previous step results:
|
|
%s
|
|
|
|
=== CURRENT STEP ===
|
|
Objective: %s
|
|
Instructions: %s
|
|
|
|
Return ONLY a JSON array of commands to run. Each command must be in this exact format:
|
|
|
|
[
|
|
{
|
|
"command": "poetry",
|
|
"args": ["new", "{{.project_name}}"]
|
|
}
|
|
]
|
|
|
|
Only use commands from the allowed list. Never use cd, rm -rf, or anything that could escape the project directory.`,
|
|
r.Recipe.Overview,
|
|
strings.Join(previousResults, "\n\n---\n\n"),
|
|
step.Objective,
|
|
step.Instructions)
|
|
|
|
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()
|
|
|
|
// Parse JSON command list
|
|
type ShellCommand struct {
|
|
Command string `json:"command"`
|
|
Args []string `json:"args"`
|
|
}
|
|
|
|
var cmds []ShellCommand
|
|
start := strings.Index(response, "[")
|
|
end := strings.LastIndex(response, "]") + 1
|
|
if start == -1 {
|
|
fmt.Println(" ⚠️ No valid shell commands returned — skipping.")
|
|
return
|
|
}
|
|
|
|
if err := json.Unmarshal([]byte(response[start:end]), &cmds); err != nil {
|
|
fmt.Printf(" ⚠️ Could not parse shell commands: %v\n", err)
|
|
return
|
|
}
|
|
|
|
// Resolve boundary
|
|
cwd := "."
|
|
if v, ok := r.Recipe.ResolvedParams["package_path"]; ok {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
cwd = s
|
|
}
|
|
}
|
|
|
|
for _, cmd := range cmds {
|
|
fullCmd := cmd.Command
|
|
if len(cmd.Args) > 0 {
|
|
fullCmd += " " + strings.Join(cmd.Args, " ")
|
|
}
|
|
|
|
fmt.Printf(" Running: %s\n", fullCmd)
|
|
|
|
// Whitelist check
|
|
allowed := false
|
|
for _, allowedCmd := range r.Recipe.AllowedShellCommands {
|
|
if strings.HasPrefix(cmd.Command, allowedCmd) {
|
|
allowed = true
|
|
break
|
|
}
|
|
}
|
|
if !allowed {
|
|
fmt.Printf(" ❌ Command not allowed: %s\n", cmd.Command)
|
|
continue
|
|
}
|
|
|
|
// Execute with strict cwd
|
|
execCmd := exec.Command(cmd.Command, cmd.Args...)
|
|
execCmd.Dir = cwd
|
|
output, err := execCmd.CombinedOutput()
|
|
|
|
if err != nil {
|
|
fmt.Printf(" ❌ Failed: %v\n%s\n", err, string(output))
|
|
} else {
|
|
fmt.Printf(" ✅ Success\n%s\n", string(output))
|
|
}
|
|
}
|
|
}
|