touch.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/filepathext"
 13	"github.com/charmbracelet/crush/internal/filetracker"
 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)
 19
 20//go:embed touch.md
 21var touchDescription []byte
 22
 23type TouchParams struct {
 24	FilePath string `json:"file_path" description:"The path to the empty file to create"`
 25}
 26
 27type TouchPermissionsParams struct {
 28	FilePath   string `json:"file_path"`
 29	OldContent string `json:"old_content,omitempty"`
 30	NewContent string `json:"new_content,omitempty"`
 31}
 32
 33type TouchResponseMetadata struct {
 34	FilePath string `json:"file_path"`
 35}
 36
 37const TouchToolName = "touch"
 38
 39func NewTouchTool(
 40	lspManager *lsp.Manager,
 41	permissions permission.Service,
 42	files history.Service,
 43	filetracker filetracker.Service,
 44	workingDir string,
 45) fantasy.AgentTool {
 46	return fantasy.NewAgentTool(
 47		TouchToolName,
 48		FirstLineDescription(touchDescription),
 49		func(ctx context.Context, params TouchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 50			if params.FilePath == "" {
 51				return fantasy.NewTextErrorResponse("file_path is required"), nil
 52			}
 53
 54			sessionID := GetSessionFromContext(ctx)
 55			if sessionID == "" {
 56				return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
 57			}
 58
 59			filePath := filepathext.SmartJoin(workingDir, params.FilePath)
 60
 61			absWorkingDir, err := filepath.Abs(workingDir)
 62			if err != nil {
 63				return fantasy.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
 64			}
 65			absFilePath, err := filepath.Abs(filePath)
 66			if err != nil {
 67				return fantasy.ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
 68			}
 69			relPath, relErr := filepath.Rel(absWorkingDir, absFilePath)
 70			isOutsideWorkDir := relErr != nil || strings.HasPrefix(relPath, "..")
 71
 72			if isOutsideWorkDir {
 73				granted, permReqErr := permissions.Request(ctx,
 74					permission.CreatePermissionRequest{
 75						SessionID:   sessionID,
 76						Path:        absFilePath,
 77						ToolCallID:  call.ID,
 78						ToolName:    TouchToolName,
 79						Action:      "write",
 80						Description: fmt.Sprintf("Create empty file outside working directory: %s", absFilePath),
 81						Params: TouchPermissionsParams{
 82							FilePath: absFilePath,
 83						},
 84					},
 85				)
 86				if permReqErr != nil {
 87					return fantasy.ToolResponse{}, permReqErr
 88				}
 89				if !granted {
 90					return NewPermissionDeniedResponse(), nil
 91				}
 92			}
 93
 94			fileInfo, err := os.Stat(absFilePath)
 95			if err == nil {
 96				if fileInfo.IsDir() {
 97					return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", absFilePath)), nil
 98				}
 99				return fantasy.NewTextErrorResponse(fmt.Sprintf("File already exists: %s", absFilePath)), nil
100			} else if !os.IsNotExist(err) {
101				return fantasy.ToolResponse{}, fmt.Errorf("error checking file: %w", err)
102			}
103
104			dir := filepath.Dir(absFilePath)
105			if err = os.MkdirAll(dir, 0o755); err != nil {
106				return fantasy.ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
107			}
108
109			p, err := permissions.Request(ctx,
110				permission.CreatePermissionRequest{
111					SessionID:   sessionID,
112					Path:        fsext.PathOrPrefix(absFilePath, absWorkingDir),
113					ToolCallID:  call.ID,
114					ToolName:    TouchToolName,
115					Action:      "write",
116					Description: fmt.Sprintf("Create empty file %s", absFilePath),
117					Params: TouchPermissionsParams{
118						FilePath:   absFilePath,
119						OldContent: "",
120						NewContent: "",
121					},
122				},
123			)
124			if err != nil {
125				return fantasy.ToolResponse{}, err
126			}
127			if !p {
128				return NewPermissionDeniedResponse(), nil
129			}
130
131			file, err := os.OpenFile(absFilePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o644)
132			if err != nil {
133				if os.IsExist(err) {
134					return fantasy.NewTextErrorResponse(fmt.Sprintf("File already exists: %s", absFilePath)), nil
135				}
136				return fantasy.ToolResponse{}, fmt.Errorf("error creating file: %w", err)
137			}
138			if err = file.Close(); err != nil {
139				return fantasy.ToolResponse{}, fmt.Errorf("error closing file: %w", err)
140			}
141
142			_, err = files.Create(ctx, sessionID, absFilePath, "")
143			if err != nil {
144				return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
145			}
146
147			filetracker.RecordRead(ctx, sessionID, absFilePath)
148
149			notifyLSPs(ctx, lspManager, absFilePath)
150
151			result := fmt.Sprintf("Empty file successfully created: %s", absFilePath)
152			result = fmt.Sprintf("<result>\n%s\n</result>", result)
153			result += getDiagnostics(absFilePath, lspManager)
154			return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result),
155				TouchResponseMetadata{
156					FilePath: absFilePath,
157				},
158			), nil
159		})
160}