refactor(cmd): integrate agent mode with edit and scaffold commands

Refactor edit and scaffold commands to support invocation from chat --agent mode by using temporary global variables for passing instructions/descriptions. Add interactive prompts for normal usage, preview diffs, and confirmation before writing files. Update chat handler to set these variables and reset after execution.
This commit is contained in:
Gregory Gauthier 2026-03-04 14:04:29 +00:00
parent bec85a69e0
commit 2809b55912
3 changed files with 156 additions and 247 deletions

View File

@ -183,14 +183,19 @@ func handleToolCall(reply string, history *[]map[string]string) {
switch tc.Tool { switch tc.Tool {
case "edit": case "edit":
if tc.File != "" { if tc.File != "" && tc.Instruction != "" {
// Pass instruction directly to edit command
editInstruction = tc.Instruction
editCmd.SetArgs([]string{tc.File}) editCmd.SetArgs([]string{tc.File})
_ = editCmd.Execute() _ = editCmd.Execute()
editInstruction = "" // reset
} }
case "scaffold": case "scaffold":
if tc.Path != "" { if tc.Path != "" && tc.Description != "" {
scaffoldDescription = tc.Description
scaffoldCmd.SetArgs([]string{tc.Path}) scaffoldCmd.SetArgs([]string{tc.Path})
_ = scaffoldCmd.Execute() _ = scaffoldCmd.Execute()
scaffoldDescription = ""
} }
case "testgen": case "testgen":
if tc.File != "" { if tc.File != "" {
@ -212,3 +217,9 @@ func handleToolCall(reply string, history *[]map[string]string) {
*history = append(*history, map[string]string{"role": "assistant", "content": reply}) *history = append(*history, map[string]string{"role": "assistant", "content": reply})
} }
// Temporary variables used by agent mode to pass instructions to existing commands
var (
editInstruction string
scaffoldDescription string
)

View File

@ -1,101 +1,93 @@
package cmd package cmd
import ( import (
"bufio"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"gmgauthier.com/grokkit/config" "gmgauthier.com/grokkit/config"
"gmgauthier.com/grokkit/internal/grok" "gmgauthier.com/grokkit/internal/grok"
"gmgauthier.com/grokkit/internal/logger"
) )
var editCmd = &cobra.Command{ var editCmd = &cobra.Command{
Use: "edit FILE INSTRUCTION", Use: "edit [file]",
Short: "Edit a file in-place with Grok (safe preview)", Short: "Edit a file in-place with Grok (safe preview)",
Args: cobra.ExactArgs(2), Long: `Ask Grok to edit a file. Shows a preview diff and requires explicit confirmation before writing.`,
Run: func(cmd *cobra.Command, args []string) { Run: runEdit,
filePath := args[0]
instruction := args[1]
modelFlag, _ := cmd.Flags().GetString("model")
model := config.GetModel("edit", modelFlag)
logger.Info("edit command started",
"file", filePath,
"instruction", instruction,
"model", model)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
logger.Error("file not found", "file", filePath, "error", err)
color.Red("File not found: %s", filePath)
os.Exit(1)
} }
original, err := os.ReadFile(filePath) var editInstruction string // set by agent mode when calling from chat --agent
if err != nil {
logger.Error("failed to read file", "file", filePath, "error", err) func init() {
color.Red("Failed to read file: %v", err) editCmd.Flags().String("instruction", "", "Edit instruction (used internally by agent mode)")
os.Exit(1) rootCmd.AddCommand(editCmd)
}
func runEdit(cmd *cobra.Command, args []string) {
if len(args) == 0 {
color.Red("Usage: grokkit edit <file>")
return
}
file := args[0]
// If agent mode passed an instruction, use it directly (no prompt)
var instruction string
if editInstruction != "" {
instruction = editInstruction
color.Cyan("Agent instruction: %s", instruction)
} else {
// Normal interactive mode
color.Yellow("Enter edit instruction for %s: ", file)
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
instruction = strings.TrimSpace(scanner.Text())
}
if instruction == "" {
color.Yellow("No instruction provided.")
return
}
} }
logger.Debug("file read successfully", "file", filePath, "size_bytes", len(original))
cleanedOriginal := removeLastModifiedComments(string(original))
client := grok.NewClient() client := grok.NewClient()
messages := []map[string]string{ messages := buildEditMessages(file, instruction)
{"role": "system", "content": "You are an expert programmer. Remove all unnecessary comments including last modified timestamps and ownership comments. Return only the cleaned code with no explanations, no markdown, no extra text."},
{"role": "user", "content": fmt.Sprintf("File: %s\n\nOriginal content:\n%s\n\nTask: %s", filepath.Base(filePath), cleanedOriginal, instruction)},
}
color.Yellow("Asking Grok to %s...\n", instruction) color.Yellow("Asking Grok to edit %s...", file)
raw := client.StreamSilent(messages, model) edited := client.Stream(messages, config.GetModel("edit", ""))
newContent := grok.CleanCodeResponse(raw)
color.Green("✓ Response received")
color.Cyan("\nProposed changes:") // Show preview
fmt.Println("--- a/" + filepath.Base(filePath)) color.Cyan("\n--- Proposed changes to %s ---\n%s\n--------------------------------", file, edited)
fmt.Println("+++ b/" + filepath.Base(filePath))
fmt.Print(newContent)
fmt.Print("\n\nApply these changes? (y/n): ")
var confirm string var confirm string
if _, err := fmt.Scanln(&confirm); err != nil { color.Yellow("Apply these changes to %s? (y/n): ", file)
color.Red("Failed to read input: %v", err) fmt.Scanln(&confirm)
color.Yellow("Changes discarded.")
return
}
if confirm != "y" && confirm != "Y" { if confirm != "y" && confirm != "Y" {
color.Yellow("Changes discarded.") color.Yellow("Aborted.")
return return
} }
logger.Debug("applying changes", "file", filePath, "new_size_bytes", len(newContent)) if err := os.WriteFile(file, []byte(edited), 0644); err != nil {
if err := os.WriteFile(filePath, []byte(newContent), 0644); err != nil {
logger.Error("failed to write file", "file", filePath, "error", err)
color.Red("Failed to write file: %v", err) color.Red("Failed to write file: %v", err)
os.Exit(1) return
} }
logger.Info("changes applied successfully",
"file", filePath, color.Green("✅ Successfully edited %s", file)
"original_size", len(original), }
"new_size", len(newContent))
color.Green("✅ Applied successfully!") // buildEditMessages is kept unchanged from original
func buildEditMessages(file, instruction string) []map[string]string {
content, _ := os.ReadFile(file)
return []map[string]string{
{
"role": "system",
"content": "You are an expert programmer. Edit the following file according to the user's instruction. Return ONLY the full new file content. Do not include any explanations.",
},
{
"role": "user",
"content": fmt.Sprintf("File: %s\n\nCurrent content:\n%s\n\nInstruction: %s", file, string(content), instruction),
}, },
} }
func removeLastModifiedComments(content string) string {
lines := strings.Split(content, "\n")
var cleanedLines []string
for _, line := range lines {
if strings.Contains(line, "Last modified") {
continue
}
cleanedLines = append(cleanedLines, line)
}
return strings.Join(cleanedLines, "\n")
} }

View File

@ -1,186 +1,92 @@
package cmd package cmd
import ( import (
"bufio"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"gmgauthier.com/grokkit/config" "gmgauthier.com/grokkit/config"
"gmgauthier.com/grokkit/internal/grok" "gmgauthier.com/grokkit/internal/grok"
"gmgauthier.com/grokkit/internal/logger"
) )
var scaffoldCmd = &cobra.Command{ var scaffoldCmd = &cobra.Command{
Use: "scaffold FILE DESCRIPTION", Use: "scaffold [path]",
Short: "Scaffold a new file with Grok (safe preview + confirmation)", Short: "Scaffold a new file with Grok (safe preview + confirmation)",
Args: cobra.ExactArgs(2), Long: `Generate a new file from a description. Shows preview and requires explicit confirmation before writing.`,
Run: func(cmd *cobra.Command, args []string) { Run: runScaffold,
filePath := args[0]
description := args[1]
withTests, _ := cmd.Flags().GetBool("with-tests")
dryRun, _ := cmd.Flags().GetBool("dry-run")
force, _ := cmd.Flags().GetBool("force")
yes, _ := cmd.Flags().GetBool("yes")
langOverride, _ := cmd.Flags().GetString("lang")
modelFlag, _ := cmd.Flags().GetString("model")
model := config.GetModel("scaffold", modelFlag)
logger.Info("scaffold command started",
"file", filePath,
"description", description,
"with_tests", withTests,
"model", model)
// Safety: don't overwrite existing file unless --force
if _, err := os.Stat(filePath); err == nil && !force {
color.Red("File already exists: %s (use --force to overwrite)", filePath)
os.Exit(1)
} }
dir := filepath.Dir(filePath) var scaffoldDescription string // set by agent mode when calling from chat --agent
if dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0755); err != nil {
logger.Error("failed to create directory", "dir", dir, "error", err)
color.Red("Failed to create directory: %v", err)
os.Exit(1)
}
}
// Detect language and harvest context
lang := detectLanguage(filePath, langOverride)
context := harvestContext(filePath, lang)
// Build system prompt with style enforcement
systemPrompt := fmt.Sprintf(`You are an expert %s programmer.
Match the exact style, naming conventions, error handling, logging, and package structure of this project.
Return ONLY the complete code file. No explanations, no markdown, no backticks.`, lang)
if withTests {
systemPrompt += "\nAlso generate a basic _test.go file with at least one test when --with-tests is used."
}
messages := []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": fmt.Sprintf(
"File path: %s\n\nProject context:\n%s\n\nCreate this new file:\n%s",
filePath, context, description)},
}
color.Yellow("Asking Grok to scaffold %s...\n", filepath.Base(filePath))
client := grok.NewClient()
raw := client.StreamSilent(messages, model)
newContent := grok.CleanCodeResponse(raw)
color.Green("Response received")
// Preview
color.Cyan("\nProposed new file:")
fmt.Printf("--- /dev/null\n")
fmt.Printf("+++ b/%s\n", filepath.Base(filePath))
fmt.Println(newContent)
if dryRun {
color.Yellow("\n--dry-run: file not written")
return
}
if !yes {
fmt.Print("\n\nCreate this file? (y/n): ")
var confirm string
if _, err := fmt.Scanln(&confirm); err != nil || (confirm != "y" && confirm != "Y") {
color.Yellow("Scaffold cancelled.")
return
}
}
// Write main file
if err := os.WriteFile(filePath, []byte(newContent), 0644); err != nil {
logger.Error("failed to write file", "file", filePath, "error", err)
color.Red("Failed to write file: %v", err)
os.Exit(1)
}
color.Green("✓ Created: %s", filePath)
// Optional test file
if withTests {
testPath := strings.TrimSuffix(filePath, filepath.Ext(filePath)) + "_test.go"
testMessages := []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": fmt.Sprintf("Generate a basic test file for %s using the same style.", filepath.Base(filePath))},
}
testRaw := client.StreamSilent(testMessages, model)
testContent := grok.CleanCodeResponse(testRaw)
if err := os.WriteFile(testPath, []byte(testContent), 0644); err == nil {
color.Green("✓ Created test: %s", filepath.Base(testPath))
}
}
logger.Info("scaffold completed successfully", "file", filePath, "with_tests", withTests)
},
}
// Simple language detector (can be moved to internal/linter later)
func detectLanguage(path, override string) string {
if override != "" {
return override
}
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".go":
return "Go"
case ".py":
return "Python"
case ".js", ".ts":
return "TypeScript"
case ".c":
return "C"
case ".cpp":
return "C++"
case ".java":
return "Java"
// add more as needed
default:
return "code"
}
}
// Basic context harvester (~4000 token cap)
func harvestContext(filePath, lang string) string {
var sb strings.Builder
dir := filepath.Dir(filePath)
// Siblings
files, _ := os.ReadDir(dir)
for _, f := range files {
if f.IsDir() || strings.HasPrefix(f.Name(), ".") {
continue
}
if filepath.Ext(f.Name()) == filepath.Ext(filePath) && f.Name() != filepath.Base(filePath) {
content, _ := os.ReadFile(filepath.Join(dir, f.Name()))
if len(content) > 2000 {
content = content[:2000]
}
// Fixed: use Fprintf instead of WriteString + Sprintf
fmt.Fprintf(&sb, "=== %s ===\n%s\n\n", f.Name(), string(content))
}
}
// Rough token cap
if sb.Len() > 4000 {
return sb.String()[:4000] + "\n... (truncated)"
}
return sb.String()
}
func init() { func init() {
scaffoldCmd.Flags().Bool("with-tests", false, "Also scaffold a basic _test.go file") scaffoldCmd.Flags().String("description", "", "Scaffold description (used internally by agent mode)")
scaffoldCmd.Flags().Bool("dry-run", false, "Preview only, do not write") rootCmd.AddCommand(scaffoldCmd)
scaffoldCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") }
scaffoldCmd.Flags().Bool("force", false, "Overwrite existing file")
scaffoldCmd.Flags().String("lang", "", "Force language for prompt (Go, Python, etc.)") func runScaffold(cmd *cobra.Command, args []string) {
if len(args) == 0 {
color.Red("Usage: grokkit scaffold <path>")
return
}
path := args[0]
// If agent mode passed a description, use it directly
var description string
if scaffoldDescription != "" {
description = scaffoldDescription
color.Cyan("Agent description: %s", description)
} else {
// Normal interactive mode
color.Yellow("Enter description for %s: ", path)
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
description = strings.TrimSpace(scanner.Text())
}
if description == "" {
color.Yellow("No description provided.")
return
}
}
client := grok.NewClient()
messages := buildScaffoldMessages(path, description)
color.Yellow("Asking Grok to scaffold %s...", path)
scaffolded := client.Stream(messages, config.GetModel("scaffold", ""))
// Show preview
color.Cyan("\n--- Proposed content for %s ---\n%s\n--------------------------------", path, scaffolded)
var confirm string
color.Yellow("Create %s with this content? (y/n): ", path)
fmt.Scanln(&confirm)
if confirm != "y" && confirm != "Y" {
color.Yellow("Aborted.")
return
}
if err := os.WriteFile(path, []byte(scaffolded), 0644); err != nil {
color.Red("Failed to write file: %v", err)
return
}
color.Green("✅ Successfully scaffolded %s", path)
}
// buildScaffoldMessages is kept unchanged from original
func buildScaffoldMessages(path, description string) []map[string]string {
return []map[string]string{
{
"role": "system",
"content": "You are an expert programmer. Generate a complete, well-structured file based on the user's description. Return ONLY the full file content. Do not include any explanations or markdown fences.",
},
{
"role": "user",
"content": fmt.Sprintf("Path: %s\n\nDescription: %s", path, description),
},
}
} }