Detailed changes
@@ -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()),
)
@@ -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("<result>\n%s\n</result>", result)
+ result += getDiagnostics(filePath, lspManager)
+ return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result),
+ TouchResponseMetadata{
+ FilePath: filePath,
+ },
+ ), nil
+ })
+}
@@ -0,0 +1,27 @@
+Create an empty file; auto-creates parent dirs. Fails if the file already exists.
+
+<usage>
+- Provide file path to create
+- Tool creates necessary parent directories automatically
+</usage>
+
+<features>
+- Creates new empty files
+- Auto-creates parent directories if missing
+- Refuses to overwrite existing files
+</features>
+
+<limitations>
+- Cannot write content
+- Cannot update modification times for existing files
+- Cannot create directories
+</limitations>
+
+<cross_platform>
+- Use forward slashes (/) for compatibility
+</cross_platform>
+
+<tips>
+- Use Write tool when the file should contain content
+- Use LS tool to verify location when creating new files
+</tips>
@@ -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
+}
@@ -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)
@@ -674,6 +674,7 @@ func allToolNames() []string {
"ls",
"sourcegraph",
"todos",
+ "touch",
"view",
"write",
"list_mcp_resources",
@@ -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)
@@ -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 {
@@ -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.
@@ -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
// -----------------------------------------------------------------------------
@@ -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:
@@ -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 {