delete.go

  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}