Compare commits
15 Commits
master
...
feature/in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ff65e8137 | ||
|
|
365f0c01ec | ||
|
|
426288b356 | ||
|
|
2809b55912 | ||
|
|
bec85a69e0 | ||
|
|
79c28da120 | ||
|
|
3e2b8ee7bf | ||
|
|
d42b5278c1 | ||
|
|
a5fda5bbfd | ||
|
|
69c5d776e2 | ||
|
|
875e34669c | ||
|
|
b5e1733952 | ||
|
|
af7270967c | ||
|
|
6eeb919013 | ||
|
|
87851513f1 |
17
README.md
17
README.md
@ -72,6 +72,23 @@ grokkit chat --debug # Enable debug logging
|
|||||||
- History is saved automatically between sessions
|
- History is saved automatically between sessions
|
||||||
- Use `--debug` to see API request timing
|
- Use `--debug` to see API request timing
|
||||||
|
|
||||||
|
### 🧠 `grokkit chat --agent`
|
||||||
|
|
||||||
|
**Interactive Agent Mode** — the new primary way to work with Grok on code.
|
||||||
|
|
||||||
|
Grok can now call tools directly (`edit`, `scaffold`, `testgen`, `lint`, `commit`) while maintaining full conversation history and safety previews.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grokkit chat --agent
|
||||||
|
```
|
||||||
|
- Uses the fast non-reasoning model for tool reliability
|
||||||
|
- Every tool action shows a preview and requires explicit confirmation (y/n)
|
||||||
|
- Persistent session — you can iterate, reject changes, or give follow-up instructions
|
||||||
|
- Normal grokkit chat (without --agent) remains unchanged (full reasoning model for thoughtful conversation)
|
||||||
|
|
||||||
|
**Note**: The old `grokkit agent` command is deprecated and will be removed in v0.3.0. Please migrate to chat --agent.
|
||||||
|
|
||||||
|
|
||||||
### ✏️ `grokkit edit FILE "instruction"`
|
### ✏️ `grokkit edit FILE "instruction"`
|
||||||
AI-powered file editing with preview.
|
AI-powered file editing with preview.
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,10 @@ var agentCmd = &cobra.Command{
|
|||||||
Short: "Multi-file agent — Grok intelligently edits multiple files with preview",
|
Short: "Multi-file agent — Grok intelligently edits multiple files with preview",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
color.Red("⚠️ grokkit agent is deprecated!")
|
||||||
|
color.Red("Use `grokkit chat --agent` instead.")
|
||||||
|
color.Red("It provides the same (and better) multi-file editing with full conversation, safety previews, and tool control.")
|
||||||
|
color.Red("This command will be removed in v0.3.0.")
|
||||||
instruction := args[0]
|
instruction := args[0]
|
||||||
modelFlag, _ := cmd.Flags().GetString("model")
|
modelFlag, _ := cmd.Flags().GetString("model")
|
||||||
model := config.GetModel("agent", modelFlag)
|
model := config.GetModel("agent", modelFlag)
|
||||||
|
|||||||
144
cmd/chat.go
144
cmd/chat.go
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"gmgauthier.com/grokkit/config"
|
"gmgauthier.com/grokkit/config"
|
||||||
|
"gmgauthier.com/grokkit/internal/git"
|
||||||
"gmgauthier.com/grokkit/internal/grok"
|
"gmgauthier.com/grokkit/internal/grok"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -19,6 +20,15 @@ type ChatHistory struct {
|
|||||||
Messages []map[string]string `json:"messages"`
|
Messages []map[string]string `json:"messages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToolCall struct {
|
||||||
|
Tool string `json:"tool"`
|
||||||
|
File string `json:"file,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Instruction string `json:"instruction,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func loadChatHistory() []map[string]string {
|
func loadChatHistory() []map[string]string {
|
||||||
histFile := getChatHistoryFile()
|
histFile := getChatHistoryFile()
|
||||||
data, err := os.ReadFile(histFile)
|
data, err := os.ReadFile(histFile)
|
||||||
@ -48,51 +58,78 @@ func getChatHistoryFile() string {
|
|||||||
if configFile != "" {
|
if configFile != "" {
|
||||||
return configFile
|
return configFile
|
||||||
}
|
}
|
||||||
|
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
if home == "" {
|
if home == "" {
|
||||||
home = "."
|
home = "."
|
||||||
}
|
}
|
||||||
histDir := filepath.Join(home, ".config", "grokkit")
|
histDir := filepath.Join(home, ".config", "grokkit")
|
||||||
_ = os.MkdirAll(histDir, 0755) // Ignore error, WriteFile will catch it
|
_ = os.MkdirAll(histDir, 0755)
|
||||||
return filepath.Join(histDir, "chat_history.json")
|
return filepath.Join(histDir, "chat_history.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
var chatCmd = &cobra.Command{
|
var chatCmd = &cobra.Command{
|
||||||
Use: "chat",
|
Use: "chat",
|
||||||
Short: "Simple interactive CLI chat with Grok (full history + streaming)",
|
Short: "Interactive chat with Grok (use --agent for tool-enabled mode)",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Long: `Start a persistent conversation with Grok.
|
||||||
|
|
||||||
|
Normal mode: coherent, reasoning-focused chat (uses full model).
|
||||||
|
Agent mode (--agent): Grok can call tools with safety previews (uses fast model).`,
|
||||||
|
Run: runChat,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
chatCmd.Flags().Bool("agent", false, "Enable agent mode with tool calling (uses fast non-reasoning model)")
|
||||||
|
chatCmd.Flags().String("model", "", "Override model")
|
||||||
|
rootCmd.AddCommand(chatCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runChat(cmd *cobra.Command, args []string) {
|
||||||
|
agentMode, _ := cmd.Flags().GetBool("agent")
|
||||||
modelFlag, _ := cmd.Flags().GetString("model")
|
modelFlag, _ := cmd.Flags().GetString("model")
|
||||||
model := config.GetModel("chat", modelFlag)
|
|
||||||
|
var model string
|
||||||
|
if modelFlag != "" {
|
||||||
|
model = modelFlag
|
||||||
|
} else if agentMode {
|
||||||
|
model = config.GetModel("chat-agent", "")
|
||||||
|
} else {
|
||||||
|
model = config.GetModel("chat", "")
|
||||||
|
}
|
||||||
|
|
||||||
client := grok.NewClient()
|
client := grok.NewClient()
|
||||||
|
|
||||||
// Strong system prompt to lock in correct model identity
|
|
||||||
systemPrompt := map[string]string{
|
systemPrompt := map[string]string{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": fmt.Sprintf("You are Grok 4, the latest and most powerful model from xAI (2026). You are currently running as `%s`. Be helpful, truthful, and a little irreverent. Never claim to be an older model.", model),
|
"content": fmt.Sprintf("You are Grok 4, the latest and most powerful model from xAI (2026). You are currently running as `%s`. Be helpful, truthful, and a little irreverent.", model),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load history or start fresh
|
if agentMode {
|
||||||
history := loadChatHistory()
|
systemPrompt["content"] = "You are Grok in Agent Mode.\n" +
|
||||||
if history == nil {
|
"You can call tools using this exact JSON format inside ```tool blocks:\n\n" +
|
||||||
history = []map[string]string{systemPrompt}
|
"```tool\n" +
|
||||||
} else {
|
"{\"tool\": \"edit\", \"file\": \"main.go\", \"instruction\": \"Add error handling\"}\n" +
|
||||||
// Update system prompt in loaded history
|
"```\n\n" +
|
||||||
if len(history) > 0 && history[0]["role"] == "system" {
|
"Available tools: edit, scaffold, testgen, lint, commit.\n\n" +
|
||||||
history[0] = systemPrompt
|
"Always use tools when the user asks you to change code, generate tests, lint, or commit.\n" +
|
||||||
} else {
|
"After every tool call, wait for the result before continuing.\n" +
|
||||||
history = append([]map[string]string{systemPrompt}, history...)
|
"Be concise and action-oriented."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
history := loadChatHistory()
|
||||||
|
if len(history) == 0 {
|
||||||
|
history = []map[string]string{systemPrompt}
|
||||||
|
} else if history[0]["role"] != "system" {
|
||||||
|
history = append([]map[string]string{systemPrompt}, history...)
|
||||||
|
} else {
|
||||||
|
history[0] = systemPrompt
|
||||||
}
|
}
|
||||||
|
|
||||||
color.Cyan("┌──────────────────────────────────────────────────────────────┐")
|
color.Cyan("┌──────────────────────────────────────────────────────────────┐")
|
||||||
color.Cyan("│ Grokkit Chat — Model: %s │", model)
|
color.Cyan("│ Grokkit Chat %s — Model: %s │", map[bool]string{true: "(Agent Mode)", false: ""}[agentMode], model)
|
||||||
color.Cyan("│ Type /quit or Ctrl+C to exit │")
|
color.Cyan("│ Type /quit to exit │")
|
||||||
color.Cyan("└──────────────────────────────────────────────────────────────┘\n")
|
color.Cyan("└──────────────────────────────────────────────────────────────┘\n")
|
||||||
|
|
||||||
scanner := bufio.NewScanner(os.Stdin)
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
color.Yellow("You > ")
|
color.Yellow("You > ")
|
||||||
if !scanner.Scan() {
|
if !scanner.Scan() {
|
||||||
@ -113,12 +150,73 @@ var chatCmd = &cobra.Command{
|
|||||||
color.Green("Grok > ")
|
color.Green("Grok > ")
|
||||||
reply := client.Stream(history, model)
|
reply := client.Stream(history, model)
|
||||||
|
|
||||||
history = append(history, map[string]string{"role": "assistant", "content": reply})
|
if agentMode && strings.Contains(reply, "```tool") {
|
||||||
|
handleToolCall(reply, &history)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Save history after each exchange
|
history = append(history, map[string]string{"role": "assistant", "content": reply})
|
||||||
_ = saveChatHistory(history)
|
_ = saveChatHistory(history)
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleToolCall(reply string, history *[]map[string]string) {
|
||||||
|
start := strings.Index(reply, "```tool")
|
||||||
|
if start == -1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
end := strings.Index(reply[start+7:], "```")
|
||||||
|
if end == -1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
block := strings.TrimSpace(reply[start+7 : start+7+end])
|
||||||
|
|
||||||
|
var tc ToolCall
|
||||||
|
if err := json.Unmarshal([]byte(block), &tc); err != nil {
|
||||||
|
color.Red("Failed to parse tool call: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
color.Yellow("\n[Agent] Grok wants to call tool: %s", tc.Tool)
|
||||||
|
|
||||||
|
switch tc.Tool {
|
||||||
|
case "edit":
|
||||||
|
if tc.File != "" && tc.Instruction != "" {
|
||||||
|
editInstruction = tc.Instruction
|
||||||
|
editCmd.SetArgs([]string{tc.File})
|
||||||
|
_ = editCmd.Execute()
|
||||||
|
}
|
||||||
|
case "scaffold":
|
||||||
|
if tc.Path != "" && tc.Description != "" {
|
||||||
|
scaffoldDescription = tc.Description
|
||||||
|
scaffoldCmd.SetArgs([]string{tc.Path})
|
||||||
|
_ = scaffoldCmd.Execute()
|
||||||
|
}
|
||||||
|
case "testgen":
|
||||||
|
if tc.File != "" {
|
||||||
|
testgenCmd.SetArgs([]string{tc.File})
|
||||||
|
_ = testgenCmd.Execute()
|
||||||
|
}
|
||||||
|
case "lint":
|
||||||
|
if tc.File != "" {
|
||||||
|
lintCmd.SetArgs([]string{tc.File})
|
||||||
|
_ = lintCmd.Execute()
|
||||||
|
}
|
||||||
|
case "commit":
|
||||||
|
if tc.Message != "" {
|
||||||
|
_, _ = git.Run([]string{"commit", "-m", tc.Message})
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
color.Red("Unknown tool: %s", tc.Tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
*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
|
||||||
|
)
|
||||||
|
|||||||
126
cmd/edit.go
126
cmd/edit.go
@ -1,101 +1,91 @@
|
|||||||
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)
|
func init() {
|
||||||
if err != nil {
|
editCmd.Flags().String("instruction", "", "Edit instruction (used internally by agent mode)")
|
||||||
logger.Error("failed to read file", "file", filePath, "error", err)
|
rootCmd.AddCommand(editCmd)
|
||||||
color.Red("Failed to read file: %v", err)
|
}
|
||||||
os.Exit(1)
|
|
||||||
|
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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRemoveLastModifiedComments(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "removes last modified comment",
|
|
||||||
input: "// Last modified: 2024-01-01\npackage main\n\nfunc main() {}",
|
|
||||||
expected: "package main\n\nfunc main() {}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "removes multiple last modified comments",
|
|
||||||
input: "// Last modified: 2024-01-01\npackage main\n// Last modified by: user\nfunc main() {}",
|
|
||||||
expected: "package main\nfunc main() {}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "preserves code without last modified",
|
|
||||||
input: "package main\n\nfunc main() {}",
|
|
||||||
expected: "package main\n\nfunc main() {}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "handles empty string",
|
|
||||||
input: "",
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "preserves other comments",
|
|
||||||
input: "// This is a regular comment\npackage main\n// Last modified: 2024\n// Another comment\nfunc main() {}",
|
|
||||||
expected: "// This is a regular comment\npackage main\n// Another comment\nfunc main() {}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "handles line with only last modified",
|
|
||||||
input: "Last modified: 2024-01-01",
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := removeLastModifiedComments(tt.input)
|
|
||||||
if result != tt.expected {
|
|
||||||
t.Errorf("removeLastModifiedComments() = %q, want %q", result, tt.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
238
cmd/scaffold.go
238
cmd/scaffold.go
@ -1,186 +1,90 @@
|
|||||||
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)
|
|
||||||
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),
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,7 +36,8 @@ func Load() {
|
|||||||
viper.SetDefault("commands.prdescribe.model", "grok-4")
|
viper.SetDefault("commands.prdescribe.model", "grok-4")
|
||||||
viper.SetDefault("commands.review.model", "grok-4")
|
viper.SetDefault("commands.review.model", "grok-4")
|
||||||
viper.SetDefault("commands.docs.model", "grok-4")
|
viper.SetDefault("commands.docs.model", "grok-4")
|
||||||
|
viper.SetDefault("commands.chat.model", "grok-4-1")
|
||||||
|
viper.SetDefault("commands.chat-agent.model", "grok-4-1-fast-non-reasoning")
|
||||||
// Config file is optional, so we ignore read errors
|
// Config file is optional, so we ignore read errors
|
||||||
_ = viper.ReadInConfig()
|
_ = viper.ReadInConfig()
|
||||||
}
|
}
|
||||||
@ -48,8 +49,7 @@ func GetModel(commandName string, flagModel string) string {
|
|||||||
}
|
}
|
||||||
return flagModel
|
return flagModel
|
||||||
}
|
}
|
||||||
cmdModel := viper.GetString("commands." + commandName + ".model")
|
if cmdModel := viper.GetString("commands." + commandName + ".model"); cmdModel != "" {
|
||||||
if cmdModel != "" {
|
|
||||||
return cmdModel
|
return cmdModel
|
||||||
}
|
}
|
||||||
return viper.GetString("default_model")
|
return viper.GetString("default_model")
|
||||||
@ -62,7 +62,7 @@ func GetTemperature() float64 {
|
|||||||
func GetTimeout() int {
|
func GetTimeout() int {
|
||||||
timeout := viper.GetInt("timeout")
|
timeout := viper.GetInt("timeout")
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
return 60 // Default 60 seconds
|
return 60
|
||||||
}
|
}
|
||||||
return timeout
|
return timeout
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user