1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10
11 "github.com/bnema/zut/packages/agent/lsp"
12 "github.com/bnema/zut/packages/core"
13 "github.com/bnema/zut/packages/provider"
14)
15
16// WriteTool writes content to a file, creating parent directories.
17type WriteTool struct {
18 CWD string
19 Sandbox *Sandbox
20 LSP *lsp.Manager
21 LSPDiagnostics bool
22}
23
24type writeArgs struct {
25 Path string `json:"path"`
26 Content string `json:"content"`
27}
28
29const writeSchema = `{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}`
30
31func (t *WriteTool) Name() string { return "write" }
32func (t *WriteTool) Description() string {
33 return "Write a file. Creates parent dirs. Overwrites."
34}
35func (t *WriteTool) Schema() json.RawMessage { return json.RawMessage(writeSchema) }
36
37func (t *WriteTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
38 var a writeArgs
39 if err := json.Unmarshal(raw, &a); err != nil {
40 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
41 }
42 if a.Path == "" {
43 return core.ToolResult{}, fmt.Errorf("path is required")
44 }
45 path := resolvePath(t.CWD, a.Path)
46 if err := t.Sandbox.CheckWritePath(path); err != nil {
47 return core.ToolResult{}, err
48 }
49
50 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
51 return core.ToolResult{}, err
52 }
53 if err := os.WriteFile(path, []byte(a.Content), 0o644); err != nil {
54 return core.ToolResult{}, err
55 }
56
57 // Return the file content as the result body, just like `read`
58 // does. The TUI renders it with a syntax-highlighted gutter so
59 // the on-screen view after a `write` matches the pre-write
60 // streaming preview seamlessly. The model also sees the written
61 // content in its tool_result, which is useful on follow-up turns
62 // where it wants to reference what it just wrote without a
63 // second `read` call.
64 totalLines := strings.Count(a.Content, "\n")
65 if len(a.Content) > 0 && !strings.HasSuffix(a.Content, "\n") {
66 totalLines++ // count the last unterminated line
67 }
68 result := core.ToolResult{
69 Content: []provider.Content{provider.TextBlock{Text: a.Content}},
70 Details: map[string]any{
71 "path": path,
72 "bytes": len(a.Content),
73 "total_lines": totalLines,
74 "start_line": 1,
75 },
76 }
77 if t.LSPDiagnostics {
78 attachWriteDiagnostics(ctx, t.CWD, path, t.LSP, &result)
79 }
80 return result, nil
81}