1// Package tools implements zut's built-in tools: read, write, edit, bash,
2// create_worktree, grep, lsp, web search, and autonomous goal updates.
3package tools
4
5import (
6 "bytes"
7 "context"
8 "encoding/json"
9 "fmt"
10 "io"
11 "os"
12 "path/filepath"
13 "strings"
14
15 "github.com/bnema/zut/packages/core"
16 "github.com/bnema/zut/packages/provider"
17)
18
19const (
20 maxReadLines = 2000
21 maxReadBytes = 50 * 1024
22)
23
24// ReadTool reads file contents from disk.
25type ReadTool struct {
26 CWD string
27 Sandbox *Sandbox // when jailed, confines reads to the sandbox root
28}
29
30type readArgs struct {
31 Path string `json:"path"`
32 Offset int `json:"offset,omitempty"`
33 Limit int `json:"limit,omitempty"`
34}
35
36const readSchema = `{"type":"object","properties":{"path":{"type":"string"},"offset":{"type":"integer"},"limit":{"type":"integer"}},"required":["path"]}`
37
38func (t *ReadTool) Name() string { return "read" }
39func (t *ReadTool) Description() string {
40 return "Read a file. Images (png/jpg/gif/webp) return inline."
41}
42func (t *ReadTool) Schema() json.RawMessage { return json.RawMessage(readSchema) }
43
44func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
45 var a readArgs
46 if err := json.Unmarshal(raw, &a); err != nil {
47 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
48 }
49 if a.Path == "" {
50 return core.ToolResult{}, fmt.Errorf("path is required")
51 }
52 path := resolvePath(t.CWD, a.Path)
53 if err := t.Sandbox.CheckReadPath(path); err != nil {
54 return core.ToolResult{}, err
55 }
56 // What the model sees in errors: relative to the sandbox root when
57 // jailed, so absolute paths stay out of the context window.
58 shown := t.Sandbox.DisplayPath(path, a.Path)
59
60 info, err := os.Stat(path)
61 if err != nil {
62 return core.ToolResult{}, err
63 }
64 if info.IsDir() {
65 return core.ToolResult{}, fmt.Errorf("%s is a directory", shown)
66 }
67
68 // Image handling.
69 if mime := imageMIME(path); mime != "" {
70 data, err := os.ReadFile(path)
71 if err != nil {
72 return core.ToolResult{}, err
73 }
74 // The extension is only a hint. Files are routinely mislabeled
75 // (a .png that is actually JPEG bytes, a renamed download, an
76 // editor that re-encoded on save). Anthropic and other providers
77 // sniff the real bytes and reject the whole request when the
78 // declared media type disagrees, which would break the session.
79 // Always derive the MIME from the actual content; fall back to
80 // the extension-based guess only when the bytes are unrecognized.
81 if sniffed := sniffImageMIME(data); sniffed != "" {
82 mime = sniffed
83 }
84 return core.ToolResult{
85 Content: []provider.Content{provider.ImageBlock{MimeType: mime, Data: data}},
86 Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: canonicalResourcePath(path)}}},
87 }, nil
88 }
89
90 f, err := os.Open(path)
91 if err != nil {
92 return core.ToolResult{}, err
93 }
94 defer f.Close()
95
96 // Read up to maxReadBytes and also line-limit.
97 limited := io.LimitReader(f, int64(maxReadBytes)+1)
98 data, err := io.ReadAll(limited)
99 if err != nil {
100 return core.ToolResult{}, err
101 }
102 truncBytes := len(data) > maxReadBytes
103 if truncBytes {
104 data = data[:maxReadBytes]
105 }
106
107 if looksBinary(data) {
108 return core.ToolResult{}, fmt.Errorf("%s looks binary; refusing to read as text", a.Path)
109 }
110
111 lines := strings.Split(string(data), "\n")
112 // Trim trailing empty line from final \n.
113 if n := len(lines); n > 0 && lines[n-1] == "" {
114 lines = lines[:n-1]
115 }
116
117 start := 0
118 if a.Offset > 0 {
119 start = a.Offset - 1
120 if start > len(lines) {
121 start = len(lines)
122 }
123 }
124 end := len(lines)
125 if a.Limit > 0 && start+a.Limit < end {
126 end = start + a.Limit
127 }
128 selected := lines[start:end]
129
130 truncLines := false
131 if len(selected) > maxReadLines {
132 selected = selected[:maxReadLines]
133 truncLines = true
134 }
135
136 // Raw file contents go to the model. We deliberately DON'T
137 // prepend line numbers here: they'd inflate the token count by
138 // ~15-20% on typical source files (7 bytes per line, every
139 // line, every time the file gets re-sent as context on later
140 // turns) and the model doesn't need them — edit goes through
141 // exact-match text replacement, not line ranges.
142 //
143 // The TUI renders its own gutter using the start offset stored
144 // in Details, so the on-screen view still looks like cat -n.
145 var sb strings.Builder
146 for _, line := range selected {
147 sb.WriteString(line)
148 sb.WriteByte('\n')
149 }
150 if truncLines || truncBytes {
151 sb.WriteString("\n")
152 }
153 if truncLines {
154 fmt.Fprintf(&sb, "... [truncated at %d lines]\n", maxReadLines)
155 }
156 if truncBytes {
157 fmt.Fprintf(&sb, "... [truncated at %d bytes]\n", maxReadBytes)
158 }
159
160 variant := fmt.Sprintf("%d:%d", a.Offset, a.Limit)
161 return core.ToolResult{
162 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
163 Context: provider.ToolContext{Reads: []provider.ResourceRef{{Key: canonicalResourcePath(path), Variant: variant}}},
164 Details: map[string]any{
165 "path": path,
166 "start_line": start + 1, // 1-indexed; TUI draws the gutter
167 "lines_truncated": truncLines,
168 "bytes_truncated": truncBytes,
169 "total_lines": len(lines),
170 },
171 }, nil
172}
173
174func canonicalResourcePath(path string) string {
175 absolute, err := filepath.Abs(path)
176 if err == nil {
177 return filepath.Clean(absolute)
178 }
179 return filepath.Clean(path)
180}
181
182func resolvePath(cwd, p string) string {
183 if filepath.IsAbs(p) {
184 return p
185 }
186 if cwd == "" {
187 cwd, _ = os.Getwd()
188 }
189 return filepath.Join(cwd, p)
190}
191
192func imageMIME(path string) string {
193 switch strings.ToLower(filepath.Ext(path)) {
194 case ".png":
195 return "image/png"
196 case ".jpg", ".jpeg":
197 return "image/jpeg"
198 case ".gif":
199 return "image/gif"
200 case ".webp":
201 return "image/webp"
202 }
203 return ""
204}
205
206// sniffImageMIME inspects the leading bytes of an image file and
207// returns the real media type, independent of the file's extension.
208// Providers validate the declared media type against the actual bytes
209// and 400 the whole request on a mismatch, so the extension can never
210// be trusted. Returns "" when the format is not one zut ships images
211// for, leaving the caller's extension-based guess in place.
212func sniffImageMIME(data []byte) string {
213 switch {
214 case len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}):
215 return "image/png"
216 case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
217 return "image/jpeg"
218 case len(data) >= 6 && (bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a"))):
219 return "image/gif"
220 case len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")):
221 return "image/webp"
222 }
223 return ""
224}
225
226// looksBinary returns true if the buffer contains a NUL byte in its first 8 KiB.
227func looksBinary(b []byte) bool {
228 n := len(b)
229 if n > 8192 {
230 n = 8192
231 }
232 for i := 0; i < n; i++ {
233 if b[i] == 0 {
234 return true
235 }
236 }
237 return false
238}