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