From 010ca2f5bf4f0d039b6fe086f195838374582bd6 Mon Sep 17 00:00:00 2001 From: vorticalbox Date: Wed, 29 Apr 2026 14:52:11 +0100 Subject: [PATCH] feat: add touch tool for empty files --- internal/agent/coordinator.go | 1 + internal/agent/tools/touch.go | 126 +++++++++++++++++++++++++++++ internal/agent/tools/touch.md | 27 +++++++ internal/agent/tools/touch_test.go | 94 +++++++++++++++++++++ internal/agent/tools/write.go | 2 +- internal/config/config.go | 1 + internal/config/load_test.go | 4 +- internal/proto/permission.go | 6 ++ internal/proto/tools.go | 19 +++++ internal/ui/chat/file.go | 49 +++++++++++ internal/ui/chat/tools.go | 25 ++++++ internal/ui/dialog/permissions.go | 16 +++- 12 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 internal/agent/tools/touch.go create mode 100644 internal/agent/tools/touch.md create mode 100644 internal/agent/tools/touch_test.go diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 468397c07336fc1b52288462d53a71772ea566d7..562216b5035b03f424782aa6630e545c62899a35 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -488,6 +488,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent, isSubA tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Tools.Ls), tools.NewSourcegraphTool(nil), tools.NewTodosTool(c.sessions), + tools.NewTouchTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.skillTracker, c.cfg.WorkingDir(), c.cfg.Config().Options.SkillsPaths...), tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), ) diff --git a/internal/agent/tools/touch.go b/internal/agent/tools/touch.go new file mode 100644 index 0000000000000000000000000000000000000000..fb3934b2cca31b361389dc6073a0bd13a59af02e --- /dev/null +++ b/internal/agent/tools/touch.go @@ -0,0 +1,126 @@ +package tools + +import ( + "context" + _ "embed" + "fmt" + "os" + "path/filepath" + + "charm.land/fantasy" + "github.com/charmbracelet/crush/internal/filepathext" + "github.com/charmbracelet/crush/internal/filetracker" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/permission" +) + +//go:embed touch.md +var touchDescription []byte + +type TouchParams struct { + FilePath string `json:"file_path" description:"The path to the empty file to create"` +} + +type TouchPermissionsParams struct { + FilePath string `json:"file_path"` + OldContent string `json:"old_content,omitempty"` + NewContent string `json:"new_content,omitempty"` +} + +type TouchResponseMetadata struct { + FilePath string `json:"file_path"` +} + +const TouchToolName = "touch" + +func NewTouchTool( + lspManager *lsp.Manager, + permissions permission.Service, + files history.Service, + filetracker filetracker.Service, + workingDir string, +) fantasy.AgentTool { + return fantasy.NewAgentTool( + TouchToolName, + FirstLineDescription(touchDescription), + func(ctx context.Context, params TouchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + if params.FilePath == "" { + return fantasy.NewTextErrorResponse("file_path is required"), nil + } + + sessionID := GetSessionFromContext(ctx) + if sessionID == "" { + return fantasy.ToolResponse{}, fmt.Errorf("session_id is required") + } + + filePath := filepathext.SmartJoin(workingDir, params.FilePath) + + fileInfo, err := os.Stat(filePath) + if err == nil { + if fileInfo.IsDir() { + return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil + } + return fantasy.NewTextErrorResponse(fmt.Sprintf("File already exists: %s", filePath)), nil + } else if !os.IsNotExist(err) { + return fantasy.ToolResponse{}, fmt.Errorf("error checking file: %w", err) + } + + dir := filepath.Dir(filePath) + if err = os.MkdirAll(dir, 0o755); err != nil { + return fantasy.ToolResponse{}, fmt.Errorf("error creating directory: %w", err) + } + + p, err := permissions.Request(ctx, + permission.CreatePermissionRequest{ + SessionID: sessionID, + Path: fsext.PathOrPrefix(filePath, workingDir), + ToolCallID: call.ID, + ToolName: TouchToolName, + Action: "write", + Description: fmt.Sprintf("Create empty file %s", filePath), + Params: TouchPermissionsParams{ + FilePath: filePath, + OldContent: "", + NewContent: "", + }, + }, + ) + if err != nil { + return fantasy.ToolResponse{}, err + } + if !p { + return NewPermissionDeniedResponse(), nil + } + + file, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + if os.IsExist(err) { + return fantasy.NewTextErrorResponse(fmt.Sprintf("File already exists: %s", filePath)), nil + } + return fantasy.ToolResponse{}, fmt.Errorf("error creating file: %w", err) + } + if err = file.Close(); err != nil { + return fantasy.ToolResponse{}, fmt.Errorf("error closing file: %w", err) + } + + _, err = files.Create(ctx, sessionID, filePath, "") + if err != nil { + return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err) + } + + filetracker.RecordRead(ctx, sessionID, filePath) + + notifyLSPs(ctx, lspManager, filePath) + + result := fmt.Sprintf("Empty file successfully created: %s", filePath) + result = fmt.Sprintf("\n%s\n", result) + result += getDiagnostics(filePath, lspManager) + return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result), + TouchResponseMetadata{ + FilePath: filePath, + }, + ), nil + }) +} diff --git a/internal/agent/tools/touch.md b/internal/agent/tools/touch.md new file mode 100644 index 0000000000000000000000000000000000000000..7e0d89ad893c5591b8d911842ae6a6687177ef47 --- /dev/null +++ b/internal/agent/tools/touch.md @@ -0,0 +1,27 @@ +Create an empty file; auto-creates parent dirs. Fails if the file already exists. + + +- Provide file path to create +- Tool creates necessary parent directories automatically + + + +- Creates new empty files +- Auto-creates parent directories if missing +- Refuses to overwrite existing files + + + +- Cannot write content +- Cannot update modification times for existing files +- Cannot create directories + + + +- Use forward slashes (/) for compatibility + + + +- Use Write tool when the file should contain content +- Use LS tool to verify location when creating new files + diff --git a/internal/agent/tools/touch_test.go b/internal/agent/tools/touch_test.go new file mode 100644 index 0000000000000000000000000000000000000000..81cb1569498729a7f575388b594e3a3cd3407086 --- /dev/null +++ b/internal/agent/tools/touch_test.go @@ -0,0 +1,94 @@ +package tools + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "charm.land/fantasy" + "github.com/stretchr/testify/require" +) + +type mockFileTrackerService struct{} + +func (m mockFileTrackerService) RecordRead(ctx context.Context, sessionID, path string) {} + +func (m mockFileTrackerService) LastReadTime(ctx context.Context, sessionID, path string) time.Time { + return time.Now() +} + +func (m mockFileTrackerService) ListReadFiles(ctx context.Context, sessionID string) ([]string, error) { + return nil, nil +} + +func TestTouchToolCreatesEmptyFile(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + tool := NewTouchTool(nil, &mockPermissionService{}, &mockHistoryService{}, mockFileTrackerService{}, workingDir) + ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session") + + resp := runTouchTool(t, tool, ctx, TouchParams{FilePath: "nested/empty.txt"}) + require.False(t, resp.IsError) + + filePath := filepath.Join(workingDir, "nested", "empty.txt") + info, err := os.Stat(filePath) + require.NoError(t, err) + require.False(t, info.IsDir()) + require.Zero(t, info.Size()) +} + +func TestTouchToolRefusesExistingFile(t *testing.T) { + t.Parallel() + + workingDir := t.TempDir() + filePath := filepath.Join(workingDir, "existing.txt") + require.NoError(t, os.WriteFile(filePath, []byte("content"), 0o644)) + + tool := NewTouchTool(nil, &mockPermissionService{}, &mockHistoryService{}, mockFileTrackerService{}, workingDir) + ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session") + + resp := runTouchTool(t, tool, ctx, TouchParams{FilePath: "existing.txt"}) + require.True(t, resp.IsError) + require.Contains(t, resp.Content, "File already exists") + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + require.Equal(t, "content", string(content)) +} + +func TestWriteToolEmptyContentPointsToTouch(t *testing.T) { + t.Parallel() + + tool := NewWriteTool(nil, nil, nil, nil, t.TempDir()) + + input, err := json.Marshal(WriteParams{FilePath: "empty.txt"}) + require.NoError(t, err) + + resp, err := tool.Run(context.Background(), fantasy.ToolCall{ + ID: "test-call", + Name: WriteToolName, + Input: string(input), + }) + require.NoError(t, err) + require.True(t, resp.IsError) + require.Equal(t, `content is required. use the "touch" tool to create an empty file`, resp.Content) +} + +func runTouchTool(t *testing.T, tool fantasy.AgentTool, ctx context.Context, params TouchParams) fantasy.ToolResponse { + t.Helper() + + input, err := json.Marshal(params) + require.NoError(t, err) + + resp, err := tool.Run(ctx, fantasy.ToolCall{ + ID: "test-call", + Name: TouchToolName, + Input: string(input), + }) + require.NoError(t, err) + return resp +} diff --git a/internal/agent/tools/write.go b/internal/agent/tools/write.go index 8fedb8ea09def36ef70086b13ca1d32aa44d8230..2d936d75bc12c643fd721e74bb4008d445c6aa13 100644 --- a/internal/agent/tools/write.go +++ b/internal/agent/tools/write.go @@ -59,7 +59,7 @@ func NewWriteTool( } if params.Content == "" { - return fantasy.NewTextErrorResponse("content is required"), nil + return fantasy.NewTextErrorResponse(`content is required. use the "touch" tool to create an empty file`), nil } sessionID := GetSessionFromContext(ctx) diff --git a/internal/config/config.go b/internal/config/config.go index b7f612a9f28e88e8ff9c6e430c3f471b401ebe57..d93b9c95ebd399fedddcc9158db77427fcf28da2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -674,6 +674,7 @@ func allToolNames() []string { "ls", "sourcegraph", "todos", + "touch", "view", "write", "list_mcp_resources", diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 61054e5ccbc9031702f3d1d778634bf53b625bcb..5ade7d8e4cd244263bcf64f9018a619e755dedd7 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -490,7 +490,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) { coderAgent, ok := cfg.Agents[AgentCoder] require.True(t, ok) - assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools) + assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "touch", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools) taskAgent, ok := cfg.Agents[AgentTask] require.True(t, ok) @@ -513,7 +513,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) { cfg.SetupAgents() coderAgent, ok := cfg.Agents[AgentCoder] require.True(t, ok) - assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools) + assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "touch", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools) taskAgent, ok := cfg.Agents[AgentTask] require.True(t, ok) diff --git a/internal/proto/permission.go b/internal/proto/permission.go index 5834de628e41a290d0bc391fbe3ead2505eb742a..981a2c22c812c322adb44ad5a11e8392af69994b 100644 --- a/internal/proto/permission.go +++ b/internal/proto/permission.go @@ -100,6 +100,12 @@ func unmarshalToolParams(toolName string, raw json.RawMessage) (any, error) { return nil, err } return params, nil + case TouchToolName: + var params TouchPermissionsParams + if err := json.Unmarshal(raw, ¶ms); err != nil { + return nil, err + } + return params, nil case WriteToolName: var params WritePermissionsParams if err := json.Unmarshal(raw, ¶ms); err != nil { diff --git a/internal/proto/tools.go b/internal/proto/tools.go index 09774ac0a22b672ff7df81d968db21ef35517c02..cee9e70f11032d5b9d620c43214adcd31125480d 100644 --- a/internal/proto/tools.go +++ b/internal/proto/tools.go @@ -227,6 +227,25 @@ type ViewResponseMetadata struct { Content string `json:"content"` } +const TouchToolName = "touch" + +// TouchParams represents the parameters for the touch tool. +type TouchParams struct { + FilePath string `json:"file_path"` +} + +// TouchPermissionsParams represents the permission parameters for the touch tool. +type TouchPermissionsParams struct { + FilePath string `json:"file_path"` + OldContent string `json:"old_content,omitempty"` + NewContent string `json:"new_content,omitempty"` +} + +// TouchResponseMetadata represents the metadata for the touch tool response. +type TouchResponseMetadata struct { + FilePath string `json:"file_path"` +} + const WriteToolName = "write" // WriteParams represents the parameters for the write tool. diff --git a/internal/ui/chat/file.go b/internal/ui/chat/file.go index 14fc5169aec1b0a238cad177a0be5bf4d6db27b0..c47c7c2fc955122299e82f6adc2167996d11d27f 100644 --- a/internal/ui/chat/file.go +++ b/internal/ui/chat/file.go @@ -97,6 +97,55 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts * return joinToolParts(header, body) } +// ----------------------------------------------------------------------------- +// Touch Tool +// ----------------------------------------------------------------------------- + +// TouchToolMessageItem is a message item that represents a touch tool call. +type TouchToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*TouchToolMessageItem)(nil) + +// NewTouchToolMessageItem creates a new [TouchToolMessageItem]. +func NewTouchToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &TouchToolRenderContext{}, canceled) +} + +// TouchToolRenderContext renders touch tool messages. +type TouchToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (t *TouchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Touch", opts.Anim, opts.Compact) + } + + var params tools.TouchParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + file := fsext.PrettyPath(params.FilePath) + header := toolHeader(sty, opts.Status, "Touch", cappedWidth, opts.Compact, file) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + return header +} + // ----------------------------------------------------------------------------- // Write Tool // ----------------------------------------------------------------------------- diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 4715cfafa6a25f01b93ee56caf41aa55aee3196e..80e4e16954f4d83c22bbcc60583ebdb3eb02cee2 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -221,6 +221,8 @@ func NewToolMessageItem( item = NewJobKillToolMessageItem(sty, toolCall, result, canceled) case tools.ViewToolName: item = NewViewToolMessageItem(sty, toolCall, result, canceled) + case tools.TouchToolName: + item = NewTouchToolMessageItem(sty, toolCall, result, canceled) case tools.WriteToolName: item = NewWriteToolMessageItem(sty, toolCall, result, canceled) case tools.EditToolName: @@ -1076,6 +1078,11 @@ func (t *baseToolMessageItem) formatParametersForCopy() string { parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits))) return strings.Join(parts, "\n") } + case tools.TouchToolName: + var params tools.TouchParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil { + return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)) + } case tools.WriteToolName: var params tools.WriteParams if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) == nil { @@ -1217,6 +1224,8 @@ func (t *baseToolMessageItem) formatResultForCopy() string { return t.formatEditResultForCopy() case tools.MultiEditToolName: return t.formatMultiEditResultForCopy() + case tools.TouchToolName: + return t.formatTouchResultForCopy() case tools.WriteToolName: return t.formatWriteResultForCopy() case tools.FetchToolName: @@ -1390,6 +1399,20 @@ func (t *baseToolMessageItem) formatMultiEditResultForCopy() string { return result.String() } +// formatTouchResultForCopy formats touch tool results for clipboard. +func (t *baseToolMessageItem) formatTouchResultForCopy() string { + if t.result == nil { + return "" + } + + var params tools.TouchParams + if json.Unmarshal([]byte(t.toolCall.Input), ¶ms) != nil { + return t.result.Content + } + + return fmt.Sprintf("File: %s\n```\n```", fsext.PrettyPath(params.FilePath)) +} + // formatWriteResultForCopy formats write tool results for clipboard. func (t *baseToolMessageItem) formatWriteResultForCopy() string { if t.result == nil { @@ -1575,6 +1598,8 @@ func prettifyToolName(name string) string { return "Sourcegraph" case tools.TodosToolName: return "To-Do" + case tools.TouchToolName: + return "Touch" case tools.ViewToolName: return "View" case tools.WriteToolName: diff --git a/internal/ui/dialog/permissions.go b/internal/ui/dialog/permissions.go index c130f14b4b3c0a42929c1bbbf5447dc8e14aa303..01097fcc6e6145b26ffa711a91e617aef39f3816 100644 --- a/internal/ui/dialog/permissions.go +++ b/internal/ui/dialog/permissions.go @@ -316,7 +316,7 @@ func (p *Permissions) respond(action PermissionAction) tea.Msg { func (p *Permissions) hasDiffView() bool { switch p.permission.ToolName { - case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName: + case tools.EditToolName, tools.TouchToolName, tools.WriteToolName, tools.MultiEditToolName: return true } return false @@ -463,11 +463,13 @@ func (p *Permissions) renderHeader(contentWidth int) string { lines = append(lines, p.renderKeyValue("URL", params.URL, contentWidth)) lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(params.FilePath), contentWidth)) } - case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName: + case tools.EditToolName, tools.TouchToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName: var filePath string switch params := p.permission.Params.(type) { case tools.EditPermissionsParams: filePath = params.FilePath + case tools.TouchPermissionsParams: + filePath = params.FilePath case tools.WritePermissionsParams: filePath = params.FilePath case tools.MultiEditPermissionsParams: @@ -527,6 +529,8 @@ func (p *Permissions) renderContent(width int) string { return p.renderBashContent(width) case tools.EditToolName: return p.renderEditContent(width) + case tools.TouchToolName: + return p.renderTouchContent(width) case tools.WriteToolName: return p.renderWriteContent(width) case tools.MultiEditToolName: @@ -563,6 +567,14 @@ func (p *Permissions) renderEditContent(contentWidth int) string { return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth) } +func (p *Permissions) renderTouchContent(contentWidth int) string { + params, ok := p.permission.Params.(tools.TouchPermissionsParams) + if !ok { + return "" + } + return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth) +} + func (p *Permissions) renderWriteContent(contentWidth int) string { params, ok := p.permission.Params.(tools.WritePermissionsParams) if !ok {