1package tools
2
3import (
4 "context"
5 _ "embed"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10
11 "charm.land/fantasy"
12 "github.com/charmbracelet/crush/internal/csync"
13 "github.com/charmbracelet/crush/internal/filepathext"
14 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/history"
16 "github.com/charmbracelet/crush/internal/lsp"
17 "github.com/charmbracelet/crush/internal/permission"
18 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
19)
20
21//go:embed delete.md
22var deleteDescription []byte
23
24// DeleteParams defines the parameters for the delete tool.
25type DeleteParams struct {
26 FilePath string `json:"file_path" description:"The path to the file or directory to delete"`
27 Recursive bool `json:"recursive,omitempty" description:"If true, recursively delete directory contents (default false)"`
28}
29
30// DeletePermissionsParams defines the parameters shown in permission requests.
31type DeletePermissionsParams struct {
32 FilePath string `json:"file_path"`
33 Recursive bool `json:"recursive,omitempty"`
34}
35
36// DeleteToolName is the name of the delete tool.
37const DeleteToolName = "delete"
38
39// NewDeleteTool creates a new delete tool.
40func NewDeleteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
41 return fantasy.NewAgentTool(
42 DeleteToolName,
43 string(deleteDescription),
44 func(ctx context.Context, params DeleteParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
45 if params.FilePath == "" {
46 return fantasy.NewTextErrorResponse("file_path is required"), nil
47 }
48
49 filePath := filepathext.SmartJoin(workingDir, params.FilePath)
50
51 fileInfo, err := os.Stat(filePath)
52 if os.IsNotExist(err) {
53 return fantasy.NewTextErrorResponse(fmt.Sprintf("Path does not exist: %s", filePath)), nil
54 }
55 if err != nil {
56 return fantasy.ToolResponse{}, fmt.Errorf("error checking path: %w", err)
57 }
58
59 isDir := fileInfo.IsDir()
60
61 if isDir && !params.Recursive {
62 return fantasy.NewTextErrorResponse(fmt.Sprintf("Cannot delete directory %s. Set recursive=true to delete directory and its contents.", filePath)), nil
63 }
64
65 sessionID := GetSessionFromContext(ctx)
66 if sessionID == "" {
67 return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
68 }
69
70 p, err := permissions.Request(ctx,
71 permission.CreatePermissionRequest{
72 SessionID: sessionID,
73 Path: fsext.PathOrPrefix(filePath, workingDir),
74 ToolCallID: call.ID,
75 ToolName: DeleteToolName,
76 Action: "delete",
77 Description: buildDeleteDescription(filePath, isDir),
78 Params: DeletePermissionsParams{
79 FilePath: filePath,
80 Recursive: params.Recursive,
81 },
82 },
83 )
84 if err != nil {
85 return fantasy.ToolResponse{}, err
86 }
87 if !p {
88 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
89 }
90
91 // Save file content to history BEFORE deleting.
92 saveDeletedFileHistory(ctx, files, sessionID, filePath, isDir)
93
94 if err := os.RemoveAll(filePath); err != nil {
95 return fantasy.ToolResponse{}, fmt.Errorf("error deleting path: %w", err)
96 }
97
98 lspCloseAndDeleteFiles(ctx, lspClients, filePath, isDir)
99 return fantasy.NewTextResponse(fmt.Sprintf("Successfully deleted: %s", filePath)), nil
100 })
101}
102
103func buildDeleteDescription(filePath string, isDir bool) string {
104 if !isDir {
105 return fmt.Sprintf("Delete file %s", filePath)
106 }
107 return fmt.Sprintf("Delete directory %s and all its contents", filePath)
108}
109
110// shouldDeletePath checks if a path matches the deletion target.
111// For files, it matches exact paths. For directories, it matches the directory
112// and all paths within it.
113func shouldDeletePath(path, targetPath string, isDir bool) bool {
114 cleanPath := filepath.Clean(path)
115 cleanTarget := filepath.Clean(targetPath)
116
117 if cleanPath == cleanTarget {
118 return true
119 }
120
121 return isDir && strings.HasPrefix(cleanPath, cleanTarget+string(filepath.Separator))
122}
123
124func lspCloseAndDeleteFiles(ctx context.Context, lsps *csync.Map[string, *lsp.Client], filePath string, isDir bool) {
125 for client := range lsps.Seq() {
126 for uri := range client.OpenFiles() {
127 path, err := protocol.DocumentURI(uri).Path()
128 if err != nil {
129 continue
130 }
131 if !shouldDeletePath(path, filePath, isDir) {
132 continue
133 }
134 _ = client.DeleteFile(ctx, path)
135 }
136 }
137}
138
139func saveDeletedFileHistory(ctx context.Context, files history.Service, sessionID, filePath string, isDir bool) {
140 if isDir {
141 // For directories, walk through and save all files.
142 _ = filepath.Walk(filePath, func(path string, info os.FileInfo, err error) error {
143 if err != nil || info.IsDir() {
144 return nil
145 }
146 saveFileBeforeDeletion(ctx, files, sessionID, path)
147 return nil
148 })
149 } else {
150 // For single file.
151 saveFileBeforeDeletion(ctx, files, sessionID, filePath)
152 }
153}
154
155func saveFileBeforeDeletion(ctx context.Context, files history.Service, sessionID, filePath string) {
156 // Check if file already exists in history.
157 existing, err := files.GetByPathAndSession(ctx, filePath, sessionID)
158 if err == nil && existing.Path != "" {
159 // File exists in history, create empty version to show deletion.
160 _, _ = files.CreateVersion(ctx, sessionID, filePath, "")
161 return
162 }
163
164 // File not in history, read content and create initial + empty version.
165 content, err := os.ReadFile(filePath)
166 if err != nil {
167 return
168 }
169
170 // Create initial version with current content.
171 _, err = files.Create(ctx, sessionID, filePath, string(content))
172 if err != nil {
173 return
174 }
175
176 // Create empty version to show deletion.
177 _, _ = files.CreateVersion(ctx, sessionID, filePath, "")
178}