vscode.go

  1package tools
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"os"
  8	"os/exec"
  9	"path/filepath"
 10	"runtime"
 11	"strings"
 12	"time"
 13
 14	"github.com/charmbracelet/crush/internal/config"
 15	"github.com/charmbracelet/crush/internal/permission"
 16)
 17
 18type VSCodeDiffParams struct {
 19	LeftContent  string `json:"left_content"`
 20	RightContent string `json:"right_content"`
 21	LeftTitle    string `json:"left_title,omitempty"`
 22	RightTitle   string `json:"right_title,omitempty"`
 23	Language     string `json:"language,omitempty"`
 24}
 25
 26type VSCodeDiffPermissionsParams struct {
 27	LeftContent  string `json:"left_content"`
 28	RightContent string `json:"right_content"`
 29	LeftTitle    string `json:"left_title,omitempty"`
 30	RightTitle   string `json:"right_title,omitempty"`
 31	Language     string `json:"language,omitempty"`
 32}
 33
 34type vscodeDiffTool struct {
 35	permissions permission.Service
 36}
 37
 38const (
 39	VSCodeDiffToolName = "vscode_diff"
 40)
 41
 42func NewVSCodeDiffTool(permissions permission.Service) BaseTool {
 43	return &vscodeDiffTool{
 44		permissions: permissions,
 45	}
 46}
 47
 48func (t *vscodeDiffTool) Name() string {
 49	return VSCodeDiffToolName
 50}
 51
 52func (t *vscodeDiffTool) Info() ToolInfo {
 53	return ToolInfo{
 54		Name:        VSCodeDiffToolName,
 55		Description: "Opens VS Code with a diff view comparing two pieces of content. Useful for visualizing code changes, comparing files, or reviewing modifications.",
 56		Parameters: map[string]any{
 57			"left_content": map[string]any{
 58				"type":        "string",
 59				"description": "The content for the left side of the diff (typically the 'before' or original content)",
 60			},
 61			"right_content": map[string]any{
 62				"type":        "string",
 63				"description": "The content for the right side of the diff (typically the 'after' or modified content)",
 64			},
 65			"left_title": map[string]any{
 66				"type":        "string",
 67				"description": "Optional title for the left side (e.g., 'Original', 'Before', or a filename)",
 68			},
 69			"right_title": map[string]any{
 70				"type":        "string",
 71				"description": "Optional title for the right side (e.g., 'Modified', 'After', or a filename)",
 72			},
 73			"language": map[string]any{
 74				"type":        "string",
 75				"description": "Optional language identifier for syntax highlighting (e.g., 'javascript', 'python', 'go')",
 76			},
 77		},
 78		Required: []string{"left_content", "right_content"},
 79	}
 80}
 81
 82func (t *vscodeDiffTool) Run(ctx context.Context, params ToolCall) (ToolResponse, error) {
 83	var diffParams VSCodeDiffParams
 84	if err := json.Unmarshal([]byte(params.Input), &diffParams); err != nil {
 85		return NewTextErrorResponse(fmt.Sprintf("Failed to parse parameters: %v", err)), nil
 86	}
 87
 88	// Check if VS Code is available
 89	vscodeCmd := getVSCodeCommand()
 90	if vscodeCmd == "" {
 91		return NewTextErrorResponse("VS Code is not available. Please install VS Code and ensure 'code' command is in PATH."), nil
 92	}
 93
 94	// Check permissions
 95	sessionID, _ := GetContextValues(ctx)
 96	permissionParams := VSCodeDiffPermissionsParams(diffParams)
 97
 98	p := t.permissions.Request(
 99		permission.CreatePermissionRequest{
100			SessionID:   sessionID,
101			Path:        config.WorkingDirectory(),
102			ToolName:    VSCodeDiffToolName,
103			Action:      "open_diff",
104			Description: fmt.Sprintf("Open VS Code diff view comparing '%s' and '%s'", diffParams.LeftTitle, diffParams.RightTitle),
105			Params:      permissionParams,
106		},
107	)
108	if !p {
109		return NewTextErrorResponse("Permission denied to open VS Code diff"), nil
110	}
111
112	// Create temporary directory for diff files
113	tempDir, err := os.MkdirTemp("", "crush-vscode-diff-*")
114	if err != nil {
115		return NewTextErrorResponse(fmt.Sprintf("Failed to create temporary directory: %v", err)), nil
116	}
117
118	// Determine file extension based on language
119	ext := getFileExtension(diffParams.Language)
120
121	// Create temporary files
122	leftTitle := diffParams.LeftTitle
123	if leftTitle == "" {
124		leftTitle = "before"
125	}
126	rightTitle := diffParams.RightTitle
127	if rightTitle == "" {
128		rightTitle = "after"
129	}
130
131	leftFile := filepath.Join(tempDir, sanitizeFilename(leftTitle)+ext)
132	rightFile := filepath.Join(tempDir, sanitizeFilename(rightTitle)+ext)
133
134	// Write content to temporary files
135	if err := os.WriteFile(leftFile, []byte(diffParams.LeftContent), 0644); err != nil {
136		os.RemoveAll(tempDir)
137		return NewTextErrorResponse(fmt.Sprintf("Failed to write left file: %v", err)), nil
138	}
139
140	if err := os.WriteFile(rightFile, []byte(diffParams.RightContent), 0644); err != nil {
141		os.RemoveAll(tempDir)
142		return NewTextErrorResponse(fmt.Sprintf("Failed to write right file: %v", err)), nil
143	}
144
145	// Open VS Code with diff view
146	cmd := exec.Command(vscodeCmd, "--diff", leftFile, rightFile)
147
148	// Set working directory to current directory
149	cwd := config.WorkingDirectory()
150	if cwd != "" {
151		cmd.Dir = cwd
152	}
153
154	if err := cmd.Start(); err != nil {
155		os.RemoveAll(tempDir)
156		return NewTextErrorResponse(fmt.Sprintf("Failed to open VS Code: %v", err)), nil
157	}
158
159	// Clean up temporary files after a delay (VS Code should have opened them by then)
160	go func() {
161		time.Sleep(5 * time.Second)
162		os.RemoveAll(tempDir)
163	}()
164
165	response := fmt.Sprintf("Opened VS Code diff view comparing '%s' and '%s'", leftTitle, rightTitle)
166	if diffParams.Language != "" {
167		response += fmt.Sprintf(" with %s syntax highlighting", diffParams.Language)
168	}
169
170	return NewTextResponse(response), nil
171}
172
173// getVSCodeCommand returns the appropriate VS Code command for the current platform
174func getVSCodeCommand() string {
175	// Try common VS Code command names
176	commands := []string{"code", "code-insiders"}
177
178	// On macOS, also try the full path
179	if runtime.GOOS == "darwin" {
180		commands = append(commands,
181			"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
182			"/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code",
183		)
184	}
185
186	for _, cmd := range commands {
187		if _, err := exec.LookPath(cmd); err == nil {
188			return cmd
189		}
190	}
191
192	return ""
193}
194
195// getFileExtension returns the appropriate file extension for syntax highlighting
196func getFileExtension(language string) string {
197	if language == "" {
198		return ".txt"
199	}
200
201	extensions := map[string]string{
202		"javascript": ".js",
203		"typescript": ".ts",
204		"python":     ".py",
205		"go":         ".go",
206		"java":       ".java",
207		"c":          ".c",
208		"cpp":        ".cpp",
209		"csharp":     ".cs",
210		"php":        ".php",
211		"ruby":       ".rb",
212		"rust":       ".rs",
213		"swift":      ".swift",
214		"kotlin":     ".kt",
215		"scala":      ".scala",
216		"html":       ".html",
217		"css":        ".css",
218		"scss":       ".scss",
219		"less":       ".less",
220		"json":       ".json",
221		"xml":        ".xml",
222		"yaml":       ".yaml",
223		"yml":        ".yml",
224		"toml":       ".toml",
225		"markdown":   ".md",
226		"sql":        ".sql",
227		"shell":      ".sh",
228		"bash":       ".sh",
229		"zsh":        ".sh",
230		"fish":       ".fish",
231		"powershell": ".ps1",
232		"dockerfile": ".dockerfile",
233		"makefile":   ".mk",
234	}
235
236	if ext, ok := extensions[strings.ToLower(language)]; ok {
237		return ext
238	}
239
240	return ".txt"
241}
242
243// sanitizeFilename removes or replaces characters that are not safe for filenames
244func sanitizeFilename(filename string) string {
245	// Replace common unsafe characters
246	replacements := map[string]string{
247		"/":  "_",
248		"\\": "_",
249		":":  "_",
250		"*":  "_",
251		"?":  "_",
252		"\"": "_",
253		"<":  "_",
254		">":  "_",
255		"|":  "_",
256	}
257
258	result := filename
259	for old, new := range replacements {
260		result = strings.ReplaceAll(result, old, new)
261	}
262
263	// Trim spaces and dots from the beginning and end
264	result = strings.Trim(result, " .")
265
266	// If the result is empty, use a default name
267	if result == "" {
268		result = "file"
269	}
270
271	return result
272}