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}