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 {