feat(cmd): add scaffold command for AI-assisted file creation
Introduce a new `scaffold` command that uses Grok to generate new files based on a description. Includes options for generating tests, dry runs, force overwrite, and language override. Detects language from file extension and harvests project context for better generation.
This commit is contained in:
parent
b884f32758
commit
3ea26c403a
@ -4,6 +4,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"gmgauthier.com/grokkit/config"
|
"gmgauthier.com/grokkit/config"
|
||||||
"gmgauthier.com/grokkit/internal/logger"
|
"gmgauthier.com/grokkit/internal/logger"
|
||||||
@ -58,6 +59,7 @@ func init() {
|
|||||||
rootCmd.AddCommand(versionCmd)
|
rootCmd.AddCommand(versionCmd)
|
||||||
rootCmd.AddCommand(docsCmd)
|
rootCmd.AddCommand(docsCmd)
|
||||||
rootCmd.AddCommand(testgenCmd)
|
rootCmd.AddCommand(testgenCmd)
|
||||||
|
rootCmd.AddCommand(scaffoldCmd)
|
||||||
|
|
||||||
// Add model flag to all commands
|
// Add model flag to all commands
|
||||||
rootCmd.PersistentFlags().StringP("model", "m", "", "Grok model to use (overrides config)")
|
rootCmd.PersistentFlags().StringP("model", "m", "", "Grok model to use (overrides config)")
|
||||||
|
|||||||
185
cmd/scaffold.go
Normal file
185
cmd/scaffold.go
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"gmgauthier.com/grokkit/config"
|
||||||
|
"gmgauthier.com/grokkit/internal/grok"
|
||||||
|
"gmgauthier.com/grokkit/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var scaffoldCmd = &cobra.Command{
|
||||||
|
Use: "scaffold FILE DESCRIPTION",
|
||||||
|
Short: "Scaffold a new file with Grok (safe preview + confirmation)",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
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)
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("=== %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() {
|
||||||
|
scaffoldCmd.Flags().Bool("with-tests", false, "Also scaffold a basic _test.go file")
|
||||||
|
scaffoldCmd.Flags().Bool("dry-run", false, "Preview only, do not write")
|
||||||
|
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.)")
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user