feat: delete file tool

Carlos Alexandro Becker created

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

Change summary

internal/agent/coordinator.go                                                      |   1 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/bash_tool.yaml             |   0 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/download_tool.yaml         |   0 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/fetch_tool.yaml            |   0 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/glob_tool.yaml             |   0 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/grep_tool.yaml             |   0 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/ls_tool.yaml               |   0 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/multiedit_tool.yaml        |   0 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/parallel_tool_calls.yaml   |   0 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/read_a_file.yaml           |   0 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/simple_test.yaml           |   0 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/sourcegraph_tool.yaml      |   0 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/update_a_file.yaml         |   0 
internal/agent/testdata/TestCoderAgent/anthropic-sonnet/write_tool.yaml            |   0 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/bash_tool.yaml                 |   0 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/download_tool.yaml             |   0 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/fetch_tool.yaml                |   0 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/glob_tool.yaml                 |   0 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/grep_tool.yaml                 |   0 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/ls_tool.yaml                   |   0 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/multiedit_tool.yaml            |   0 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/parallel_tool_calls.yaml       |   0 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/read_a_file.yaml               |   0 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/simple_test.yaml               |   0 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/sourcegraph_tool.yaml          |   0 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/update_a_file.yaml             |   0 
internal/agent/testdata/TestCoderAgent/openai-gpt-5/write_tool.yaml                |   0 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/bash_tool.yaml           |   0 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/download_tool.yaml       |   0 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/fetch_tool.yaml          |   0 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/glob_tool.yaml           |   0 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/grep_tool.yaml           |   0 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/ls_tool.yaml             |   0 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/multiedit_tool.yaml      |   0 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/parallel_tool_calls.yaml |   0 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/read_a_file.yaml         |   0 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/simple_test.yaml         |   0 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/sourcegraph_tool.yaml    |   0 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/update_a_file.yaml       |   0 
internal/agent/testdata/TestCoderAgent/openrouter-kimi-k2/write_tool.yaml          |   0 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/bash_tool.yaml                   |   0 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/download_tool.yaml               |   0 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/fetch_tool.yaml                  |   0 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/glob_tool.yaml                   |   0 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/grep_tool.yaml                   |   0 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/ls_tool.yaml                     |   0 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/multiedit_tool.yaml              |   0 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/parallel_tool_calls.yaml         |   0 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/read_a_file.yaml                 |   0 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/simple_test.yaml                 |   0 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/sourcegraph_tool.yaml            |   0 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/update_a_file.yaml               |   0 
internal/agent/testdata/TestCoderAgent/zai-glm4.6/write_tool.yaml                  |   0 
internal/agent/tools/bash.go                                                       |   3 
internal/agent/tools/delete.go                                                     | 130 
internal/agent/tools/delete.md                                                     |  14 
internal/config/config.go                                                          |   1 
internal/config/load_test.go                                                       |   4 
internal/lsp/client.go                                                             |  30 
59 files changed, 181 insertions(+), 2 deletions(-)

Detailed changes

internal/agent/coordinator.go 🔗

@@ -390,6 +390,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
 		tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
 		tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()),
+		tools.NewDeleteTool(c.lspClients, c.permissions, c.cfg.WorkingDir()),
 		tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
 		tools.NewGlobTool(c.cfg.WorkingDir()),
 		tools.NewGrepTool(c.cfg.WorkingDir()),

internal/agent/tools/bash.go 🔗

@@ -137,6 +137,9 @@ var bannedCommands = []string{
 	"pfctl",
 	"route",
 	"ufw",
+
+	// File deletion (use delete tool instead for proper LSP integration)
+	"rm",
 }
 
 func bashDescription(attribution *config.Attribution, modelName string) string {

internal/agent/tools/delete.go 🔗

@@ -0,0 +1,130 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/filepathext"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
+)
+
+//go:embed delete.md
+var deleteDescription []byte
+
+// DeleteParams defines the parameters for the delete tool.
+type DeleteParams struct {
+	FilePath  string `json:"file_path" description:"The path to the file or directory to delete"`
+	Recursive bool   `json:"recursive,omitempty" description:"If true, recursively delete directory contents (default false)"`
+}
+
+// DeletePermissionsParams defines the parameters shown in permission requests.
+type DeletePermissionsParams struct {
+	FilePath  string `json:"file_path"`
+	Recursive bool   `json:"recursive,omitempty"`
+	IsDir     bool   `json:"is_dir,omitempty"`
+}
+
+// DeleteToolName is the name of the delete tool.
+const DeleteToolName = "delete"
+
+// NewDeleteTool creates a new delete tool.
+func NewDeleteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		DeleteToolName,
+		string(deleteDescription),
+		func(ctx context.Context, params DeleteParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.FilePath == "" {
+				return fantasy.NewTextErrorResponse("file_path is required"), nil
+			}
+
+			filePath := filepathext.SmartJoin(workingDir, params.FilePath)
+
+			fileInfo, err := os.Stat(filePath)
+			if os.IsNotExist(err) {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("Path does not exist: %s", filePath)), nil
+			}
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error checking path: %w", err)
+			}
+
+			isDir := fileInfo.IsDir()
+
+			if isDir && !params.Recursive {
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("Cannot delete directory %s. Set recursive=true to delete directory and its contents.", filePath)), nil
+			}
+
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
+			}
+
+			p, err := permissions.Request(ctx,
+				permission.CreatePermissionRequest{
+					SessionID:   sessionID,
+					Path:        fsext.PathOrPrefix(filePath, workingDir),
+					ToolCallID:  call.ID,
+					ToolName:    DeleteToolName,
+					Action:      "delete",
+					Description: buildDeleteDescription(filePath, isDir),
+					Params: DeletePermissionsParams{
+						FilePath:  filePath,
+						Recursive: params.Recursive,
+						IsDir:     isDir,
+					},
+				},
+			)
+			if err != nil {
+				return fantasy.ToolResponse{}, err
+			}
+			if !p {
+				return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+			}
+
+			if err := os.RemoveAll(filePath); err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error deleting path: %w", err)
+			}
+
+			lspCloseAndDeleteFiles(ctx, lspClients, filePath)
+			return fantasy.NewTextResponse(fmt.Sprintf("Successfully deleted: %s", filePath)), nil
+		})
+}
+
+func buildDeleteDescription(filePath string, isDir bool) string {
+	if !isDir {
+		return fmt.Sprintf("Delete file %s", filePath)
+	}
+	return fmt.Sprintf("Delete directory %s and all its contents", filePath)
+}
+
+func lspCloseAndDeleteFiles(ctx context.Context, lsps *csync.Map[string, *lsp.Client], filePath string) {
+	cleanPath := filepath.Clean(filePath)
+	for client := range lsps.Seq() {
+		for uri := range client.OpenFiles() {
+			path, err := protocol.DocumentURI(uri).Path()
+			if err != nil {
+				continue
+			}
+			if path != cleanPath && !strings.HasPrefix(path, cleanPath+string(filepath.Separator)) {
+				continue
+			}
+			_ = client.CloseFile(ctx, path)
+			_ = client.DidChangeWatchedFiles(ctx, protocol.DidChangeWatchedFilesParams{
+				Changes: []protocol.FileEvent{
+					{
+						URI:  protocol.URIFromPath(path),
+						Type: protocol.Deleted,
+					},
+				},
+			})
+		}
+	}
+}

internal/agent/tools/delete.md 🔗

@@ -0,0 +1,14 @@
+Deletes files or directories from the filesystem.
+
+<usage>
+- Provide file path to delete
+- For directories, always set recursive=true to delete the directory
+- Tool handles LSP cleanup automatically (closes open files, clears diagnostics)
+</usage>
+
+<limitations>
+- Cannot delete files outside the working directory
+- Deleting directories requires recursive=true (even if empty)
+- Deleted files cannot be recovered (no trash/recycle bin)
+</limitations>
+

internal/config/config.go 🔗

@@ -685,6 +685,7 @@ func allToolNames() []string {
 		"bash",
 		"job_output",
 		"job_kill",
+		"delete",
 		"download",
 		"edit",
 		"multiedit",

internal/config/load_test.go 🔗

@@ -486,7 +486,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
 	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
 
-	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "delete", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write"}, coderAgent.AllowedTools)
 
 	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)
@@ -509,7 +509,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
 	cfg.SetupAgents()
 	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
-	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "todos", "write"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "delete", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "fetch", "agentic_fetch", "todos", "write"}, coderAgent.AllowedTools)
 
 	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)

internal/lsp/client.go 🔗

@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"iter"
 	"log/slog"
 	"maps"
 	"os"
@@ -336,6 +337,35 @@ func (c *Client) IsFileOpen(filepath string) bool {
 	return exists
 }
 
+// CloseFile closes a single file in the LSP server and clears its diagnostics.
+func (c *Client) CloseFile(ctx context.Context, filepath string) error {
+	uri := string(protocol.URIFromPath(filepath))
+
+	if _, exists := c.openFiles.Get(uri); !exists {
+		return nil // Not open, nothing to do.
+	}
+
+	if err := c.client.NotifyDidCloseTextDocument(ctx, uri); err != nil {
+		return fmt.Errorf("error closing file: %w", err)
+	}
+
+	c.openFiles.Del(uri)
+	c.ClearDiagnosticsForURI(protocol.DocumentURI(uri))
+
+	return nil
+}
+
+// OpenFiles returns an iterator over all currently open file URIs.
+func (c *Client) OpenFiles() iter.Seq[string] {
+	return func(yield func(string) bool) {
+		for uri := range c.openFiles.Seq2() {
+			if !yield(uri) {
+				return
+			}
+		}
+	}
+}
+
 // CloseAllFiles closes all currently open files.
 func (c *Client) CloseAllFiles(ctx context.Context) {
 	cfg := config.Get()