tools/file: port Read, Write, Edit, Grep, Glob from goq

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-12 20:18:59 +02:00
parent 345ec744d0
commit c1e5fceda6
6 changed files with 850 additions and 0 deletions

188
pkg/tools/file/edit.go Normal file
View File

@ -0,0 +1,188 @@
package file
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"ollie/pkg/tools"
)
var ToolEdit = tools.ToolInfo{
Name: "Edit",
Description: `Performs exact string replacements in files.
Usage:
- You must use your ` + "`" + `Read` + "`" + ` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + "| ". Everything after that is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
- The edit will FAIL if ` + "`" + `old_string` + "`" + ` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use ` + "`" + `replace_all` + "`" + ` to change every instance of ` + "`" + `old_string` + "`" + `.
- Use ` + "`" + `replace_all` + "`" + ` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`,
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["file_path", "old_string", "new_string"],
"properties": {
"file_path": {"type": "string", "description": "The absolute path to the file to modify"},
"old_string": {"type": "string", "description": "The text to replace"},
"new_string": {"type": "string", "description": "The text to replace it with (must be different from old_string)"},
"replace_all": {"type": "boolean", "description": "Replace all occurences of old_string (default false)"}
}
}`),
}
func (s *Server) dispatchEdit(_ context.Context, raw json.RawMessage) (string, error) {
var a struct {
FilePath string `json:"file_path"`
OldString string `json:"old_string"`
NewString string `json:"new_string"`
ReplaceAll bool `json:"replace_all"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return "", fmt.Errorf("bad args: %w", err)
}
if a.FilePath == "" {
return "", fmt.Errorf("file_path is required")
}
if a.OldString == a.NewString {
return errText("old_string and new_string must be different"), nil
}
if !s.wasRead(a.FilePath) {
return errText("file must be read first. Use the Read tool to examine the file contents."), nil
}
original, err := readFileChecked(a.FilePath)
if err != nil {
return err.Error(), nil
}
newContent, err := robustReplace(original, a.OldString, a.NewString, a.ReplaceAll)
if err != nil {
return errText("%v", err), nil
}
if err := os.WriteFile(a.FilePath, []byte(newContent), 0644); err != nil { //nolint:gosec
return "", fmt.Errorf("writing file: %w", err)
}
return "File edited successfully", nil
}
// robustReplace tries exact → whitespace-normalized → indentation-flexible → trimmed-boundary.
func robustReplace(content, oldString, newString string, replaceAll bool) (string, error) {
if m := findExact(content, oldString); m != "" {
return doReplace(content, m, newString, replaceAll)
}
if m := findWSNormalized(content, oldString); m != "" {
return doReplace(content, m, newString, replaceAll)
}
if m := findIndentFlexible(content, oldString); m != "" {
return doReplace(content, m, newString, replaceAll)
}
if m := findTrimmedBoundary(content, oldString); m != "" {
return doReplace(content, m, newString, replaceAll)
}
return "", fmt.Errorf("old_string not found in file")
}
func doReplace(content, match, newString string, replaceAll bool) (string, error) {
if replaceAll {
return strings.ReplaceAll(content, match, newString), nil
}
n := strings.Count(content, match)
if n == 0 {
return "", fmt.Errorf("match disappeared from content")
}
if n > 1 {
return "", fmt.Errorf("old_string is not unique (%d occurrences). Use replace_all=true or provide more context", n)
}
return strings.Replace(content, match, newString, 1), nil
}
func findExact(content, find string) string {
if strings.Contains(content, find) {
return find
}
return ""
}
func findWSNormalized(content, find string) string {
nf := strings.Join(strings.Fields(find), " ")
lines := strings.Split(content, "\n")
findLines := strings.Split(find, "\n")
n := len(findLines)
for i := 0; i <= len(lines)-n; i++ {
block := strings.Join(lines[i:i+n], "\n")
if strings.Join(strings.Fields(block), " ") == nf {
return block
}
}
return ""
}
func findIndentFlexible(content, find string) string {
deindent := func(text string) string {
ls := strings.Split(text, "\n")
min := 10000
for _, l := range ls {
if strings.TrimSpace(l) == "" {
continue
}
for i, r := range l {
if r != ' ' && r != '\t' {
if i < min {
min = i
}
break
}
}
}
if min == 10000 {
return text
}
out := make([]string, len(ls))
for i, l := range ls {
if strings.TrimSpace(l) == "" {
out[i] = l
} else if len(l) > min {
out[i] = l[min:]
} else {
out[i] = strings.TrimLeft(l, " \t")
}
}
return strings.Join(out, "\n")
}
nf := deindent(find)
lines := strings.Split(content, "\n")
findLines := strings.Split(find, "\n")
n := len(findLines)
for i := 0; i <= len(lines)-n; i++ {
block := strings.Join(lines[i:i+n], "\n")
if deindent(block) == nf {
return block
}
}
return ""
}
func findTrimmedBoundary(content, find string) string {
trimmed := strings.TrimSpace(find)
if trimmed == find {
return ""
}
if strings.Contains(content, trimmed) {
return trimmed
}
lines := strings.Split(content, "\n")
findLines := strings.Split(find, "\n")
n := len(findLines)
for i := 0; i <= len(lines)-n; i++ {
block := strings.Join(lines[i:i+n], "\n")
if strings.TrimSpace(block) == trimmed {
return block
}
}
return ""
}

111
pkg/tools/file/glob.go Normal file
View File

@ -0,0 +1,111 @@
package file
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"ollie/pkg/tools"
)
var ToolGlob = tools.ToolInfo{
Name: "Glob",
Description: `Fast file pattern matching. Find files by name/path patterns.
Usage:
- Works with any codebase size
- Results sorted by modification time
- ripgrep respects .gitignore and .ignore files by default
- Use ** for recursive path matches (for example, **/*.js)
- Use instead of find or ls in Bash
- Can speculatively launch multiple Glob calls in parallel`,
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["pattern"],
"properties": {
"pattern": {"type": "string", "description": "Glob pattern, e.g. '**/*.js', 'src/**/*.ts'"},
"path": {"type": "string", "description": "Directory to search in. Omit for current working directory. Do NOT enter 'undefined' or 'null'."}
}
}`),
}
func (s *Server) dispatchGlob(ctx context.Context, raw json.RawMessage) (string, error) {
var a struct {
Pattern string `json:"pattern"`
Path string `json:"path"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return "", fmt.Errorf("bad args: %w", err)
}
if strings.TrimSpace(a.Pattern) == "" {
return "", fmt.Errorf("pattern is required")
}
if s.rgPath == "" {
return "", fmt.Errorf("ripgrep (rg) not found in PATH")
}
searchPath := a.Path
if searchPath == "" {
searchPath = s.projectDir
}
searchPath = filepath.Clean(searchPath)
if err := ensureSearchPath(searchPath); err != nil {
return "", err
}
cmd := exec.CommandContext(ctx, s.rgPath, "--no-config", "--files", "-g", a.Pattern, searchPath)
cmd.Dir = s.projectDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return "", nil
}
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("%s", msg)
}
type entry struct {
path string
modTime time.Time
}
lines := splitLines(stdout.String())
entries := make([]entry, 0, len(lines))
for _, p := range lines {
info, err := os.Stat(p)
if err != nil {
continue
}
entries = append(entries, entry{p, info.ModTime()})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].modTime.Equal(entries[j].modTime) {
return entries[i].path < entries[j].path
}
return entries[i].modTime.After(entries[j].modTime)
})
paths := make([]string, len(entries))
for i, e := range entries {
paths[i] = e.path
}
return strings.Join(paths, "\n"), nil
}
// statPath wraps os.Stat for use by grep.go and glob.go.
func statPath(path string) (os.FileInfo, error) {
return os.Stat(path)
}

192
pkg/tools/file/grep.go Normal file
View File

@ -0,0 +1,192 @@
package file
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"strings"
"ollie/pkg/tools"
)
var ToolGrep = tools.ToolInfo{
Name: "Grep",
Description: `Search file contents using ripgrep. The primary content search tool.
Usage:
- Always use Grep instead of grep or rg in Bash
- Uses ripgrep syntax (not GNU grep); literal braces need escaping as \{ and \}
- ripgrep respects .gitignore and .ignore files by default
- files_with_matches is the default mode (just file paths)
- multiline: true is needed for patterns spanning lines
- Supports pagination via offset + head_limit`,
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["pattern"],
"properties": {
"pattern": {"type": "string", "description": "Regex pattern to search for (ripgrep syntax)"},
"path": {"type": "string", "description": "File or directory to search in. Defaults to cwd."},
"output_mode": {"type": "string", "description": "Default: 'files_with_matches'. 'content' shows matching lines, 'count' shows match counts.", "enum": ["content", "files_with_matches", "count"]},
"glob": {"type": "string", "description": "Glob filter, e.g. '*.js', '*.{ts,tsx}'"},
"type": {"type": "string", "description": "File type filter, e.g. 'js', 'py', 'rust'. More efficient than glob for standard types."},
"-i": {"type": "boolean", "description": "Case insensitive search"},
"-n": {"type": "boolean", "description": "Show line numbers (default true). Requires output_mode: 'content'."},
"-A": {"type": "number", "description": "Lines after match"},
"-B": {"type": "number", "description": "Lines before match"},
"-C": {"type": "number", "description": "Lines before and after match (short flag alias)"},
"context": {"type": "number", "description": "Lines before and after match"},
"multiline": {"type": "boolean", "description": "Enable multiline mode where . matches newlines. Default: false."},
"head_limit": {"type": "number", "description": "Limit output to first N lines/entries (like | head -N). Default: 0 (unlimited)."},
"offset": {"type": "number", "description": "Skip first N entries before applying head_limit (like | tail -n +N | head -N). Default: 0."}
}
}`),
}
func (s *Server) dispatchGrep(ctx context.Context, raw json.RawMessage) (string, error) {
var a struct {
Pattern string `json:"pattern"`
Path string `json:"path"`
OutputMode string `json:"output_mode"`
Glob string `json:"glob"`
Type string `json:"type"`
IgnoreCase bool `json:"-i"`
LineNumber *bool `json:"-n"`
After *int `json:"-A"`
Before *int `json:"-B"`
ContextC *int `json:"-C"`
Context *int `json:"context"`
Multiline bool `json:"multiline"`
HeadLimit *int `json:"head_limit"`
Offset *int `json:"offset"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return "", fmt.Errorf("bad args: %w", err)
}
if strings.TrimSpace(a.Pattern) == "" {
return "", fmt.Errorf("pattern is required")
}
if s.rgPath == "" {
return "", fmt.Errorf("ripgrep (rg) not found in PATH")
}
outputMode := a.OutputMode
if outputMode == "" {
outputMode = "files_with_matches"
}
searchPath := a.Path
if searchPath == "" {
searchPath = s.projectDir
}
searchPath = filepath.Clean(searchPath)
if err := ensureSearchPath(searchPath); err != nil {
return "", err
}
cmdArgs := []string{"--no-config", "--color=never"}
switch outputMode {
case "files_with_matches":
cmdArgs = append(cmdArgs, "-l")
case "count":
cmdArgs = append(cmdArgs, "--count")
case "content":
showLineNums := true
if a.LineNumber != nil {
showLineNums = *a.LineNumber
}
if showLineNums {
cmdArgs = append(cmdArgs, "-n")
}
}
if a.IgnoreCase {
cmdArgs = append(cmdArgs, "-i")
}
if a.Multiline {
cmdArgs = append(cmdArgs, "-U", "--multiline-dotall")
}
if a.Glob != "" {
cmdArgs = append(cmdArgs, "-g", a.Glob)
}
if a.Type != "" {
cmdArgs = append(cmdArgs, "-t", a.Type)
}
ctx_ := a.Context
if ctx_ == nil {
ctx_ = a.ContextC
}
if ctx_ != nil {
cmdArgs = append(cmdArgs, "-C", fmt.Sprintf("%d", *ctx_))
}
if a.Before != nil {
cmdArgs = append(cmdArgs, "-B", fmt.Sprintf("%d", *a.Before))
}
if a.After != nil {
cmdArgs = append(cmdArgs, "-A", fmt.Sprintf("%d", *a.After))
}
cmdArgs = append(cmdArgs, "-e", a.Pattern, searchPath)
cmd := exec.CommandContext(ctx, s.rgPath, cmdArgs...)
cmd.Dir = s.projectDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return "", nil // no matches
}
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("%s", msg)
}
lines := splitLines(stdout.String())
lines = paginate(lines, intOrZero(a.Offset), intOrZero(a.HeadLimit))
if outputMode == "content" {
lines = formatContentLines(lines)
}
return strings.Join(lines, "\n"), nil
}
func formatContentLines(lines []string) []string {
out := make([]string, 0, len(lines))
for _, line := range lines {
if line == "--" {
out = append(out, line)
continue
}
if p, rest, ok := strings.Cut(line, ":"); ok {
if ln, content, ok2 := strings.Cut(rest, ":"); ok2 && isDigits(ln) {
out = append(out, fmt.Sprintf("%s:%05s| %s", p, ln, content))
continue
}
}
if p, rest, ok := strings.Cut(line, "-"); ok {
if ln, content, ok2 := strings.Cut(rest, "-"); ok2 && isDigits(ln) {
out = append(out, fmt.Sprintf("%s:%05s| %s", p, ln, content))
continue
}
}
out = append(out, line)
}
return out
}
func ensureSearchPath(path string) error {
info, err := statPath(path)
if err != nil {
return fmt.Errorf("error accessing path: %w", err)
}
if !info.IsDir() && !info.Mode().IsRegular() {
return fmt.Errorf("not a searchable path: %s", path)
}
return nil
}

139
pkg/tools/file/read.go Normal file
View File

@ -0,0 +1,139 @@
package file
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"ollie/pkg/tools"
)
var ToolRead = tools.ToolInfo{
Name: "Read",
Description: `Reads a file from the local filesystem. You can access any file directly by using this tool.
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
Usage:
- The file_path parameter must be an absolute path, not a relative path
- By default, it reads up to 2000 lines starting from the beginning of the file
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
- Any lines longer than 2000 characters will be truncated
- Results are returned with line numbers in the format: line number + "| " + content, starting at 1
- This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.
- You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel.
- If you read a file that exists but has empty contents "(empty file)" will be returned.`,
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["file_path"],
"properties": {
"file_path": {"type": "string", "description": "The absolute path to the file to read"},
"offset": {"type": "number", "description": "The line number to start reading from. Only provide if the file is too large to read at once"},
"limit": {"type": "number", "description": "The number of lines to read. Only provide if the file is too large to read at once."}
}
}`),
}
const (
maxFileSize = 8 * 1024 * 1024 // 8 MB
defaultLimit = 2000
maxLineLen = 2000
)
func (s *Server) dispatchRead(_ context.Context, raw json.RawMessage) (string, error) {
var a struct {
FilePath string `json:"file_path"`
Offset *int `json:"offset"`
Limit *int `json:"limit"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return "", fmt.Errorf("bad args: %w", err)
}
if a.FilePath == "" {
return "", fmt.Errorf("file_path is required")
}
file, err := openChecked(a.FilePath)
if err != nil {
return err.Error(), nil // surface as text
}
defer file.Close()
off := 1
if a.Offset != nil && *a.Offset > 1 {
off = *a.Offset
}
lim := defaultLimit
if a.Limit != nil && *a.Limit > 0 {
lim = *a.Limit
}
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var lines []string
lineNum := 1
for scanner.Scan() {
if lineNum >= off && len(lines) < lim {
line := scanner.Text()
if len(line) > maxLineLen {
line = line[:maxLineLen] + "..."
}
lines = append(lines, fmt.Sprintf("%05d| %s", lineNum, line))
}
lineNum++
if lineNum > off+lim {
break
}
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("reading file: %w", err)
}
if len(lines) == 0 {
return "(empty file)", nil
}
s.markRead(a.FilePath)
return strings.Join(lines, "\n"), nil
}
// openChecked validates and opens a regular file within size limits.
func openChecked(path string) (*os.File, error) {
if !filepath.IsAbs(path) {
return nil, fmt.Errorf("error: path must be absolute, got: %s", path)
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("error accessing file: %w", err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("error: not a regular file: %s", path)
}
if info.Size() > maxFileSize {
return nil, fmt.Errorf("error: file too large (%d bytes, max %d)", info.Size(), maxFileSize)
}
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("error opening file: %w", err)
}
return f, nil
}
// readFileChecked reads the full content of a regular file within size limits.
func readFileChecked(path string) (string, error) {
f, err := openChecked(path)
if err != nil {
return "", err
}
defer f.Close()
info, _ := f.Stat()
buf := make([]byte, info.Size())
n, err := f.Read(buf)
if err != nil && n == 0 {
return "", fmt.Errorf("reading file: %w", err)
}
return string(buf[:n]), nil
}

164
pkg/tools/file/server.go Normal file
View File

@ -0,0 +1,164 @@
// Package file implements the Read, Write, Edit, Grep, and Glob tools.
package file
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"sync"
"ollie/pkg/tools"
)
// Server implements tools.Server for file operations.
type Server struct {
projectDir string
rgPath string
mu sync.Mutex
readFiles map[string]struct{}
}
// New creates a Server for the given project directory.
func New(projectDir string) *Server {
rg, _ := exec.LookPath("rg")
return &Server{
projectDir: projectDir,
rgPath: rg,
readFiles: make(map[string]struct{}),
}
}
// Decl returns a factory for a file Server with the given project directory.
func Decl(projectDir string) func() tools.Server {
return func() tools.Server { return New(projectDir) }
}
// SetWorkDir implements tools.WorkDirSetter.
func (s *Server) SetWorkDir(dir string) {
s.mu.Lock()
s.projectDir = dir
s.mu.Unlock()
}
// ListTools implements tools.Server.
func (s *Server) ListTools() ([]tools.ToolInfo, error) {
return []tools.ToolInfo{ToolRead, ToolWrite, ToolEdit, ToolGrep, ToolGlob}, nil
}
// CallTool implements tools.Server.
func (s *Server) CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
var (
text string
err error
)
switch tool {
case "Read":
text, err = s.dispatchRead(ctx, args)
case "Write":
text, err = s.dispatchWrite(ctx, args)
case "Edit":
text, err = s.dispatchEdit(ctx, args)
case "Grep":
text, err = s.dispatchGrep(ctx, args)
case "Glob":
text, err = s.dispatchGlob(ctx, args)
default:
text = "unknown tool: " + tool
}
if err != nil {
text = "error: " + err.Error()
}
return json.Marshal(map[string]any{
"content": []map[string]string{{"type": "text", "text": text}},
})
}
// Close implements tools.Server (no-op).
func (s *Server) Close() {}
func (s *Server) markRead(path string) {
s.mu.Lock()
s.readFiles[path] = struct{}{}
s.mu.Unlock()
}
func (s *Server) wasRead(path string) bool {
s.mu.Lock()
_, ok := s.readFiles[path]
s.mu.Unlock()
return ok
}
// Compile-time interface checks.
var _ tools.Server = (*Server)(nil)
var _ tools.WorkDirSetter = (*Server)(nil)
// Shared argument helpers.
func intOrZero(p *int) int {
if p == nil {
return 0
}
return *p
}
func paginate(items []string, offset, headLimit int) []string {
if offset < 0 {
offset = 0
}
if offset >= len(items) {
return nil
}
items = items[offset:]
if headLimit > 0 && headLimit < len(items) {
items = items[:headLimit]
}
return items
}
func isDigits(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
func splitLines(s string) []string {
if s == "" {
return nil
}
s = s[:len(s)-countTrailingNewlines(s)]
if s == "" {
return nil
}
lines := make([]string, 0, 32)
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
lines = append(lines, s[start:])
return lines
}
func countTrailingNewlines(s string) int {
n := 0
for i := len(s) - 1; i >= 0 && s[i] == '\n'; i-- {
n++
}
return n
}
// errText wraps an error message as a non-error tool result (model sees error text).
func errText(format string, args ...any) string {
return "error: " + fmt.Sprintf(format, args...)
}

56
pkg/tools/file/write.go Normal file
View File

@ -0,0 +1,56 @@
package file
import (
"context"
"encoding/json"
"fmt"
"os"
"ollie/pkg/tools"
)
var ToolWrite = tools.ToolInfo{
Name: "Write",
Description: `Writes a file to the local filesystem.
Usage:
- This tool will overwrite the existing file if there is one at the provided path.
- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.`,
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["file_path", "content"],
"properties": {
"file_path": {"type": "string", "description": "The absolute path to the file to write (must be absolute, not relative)"},
"content": {"type": "string", "description": "The content to write to the file"}
}
}`),
}
func (s *Server) dispatchWrite(_ context.Context, raw json.RawMessage) (string, error) {
var a struct {
FilePath string `json:"file_path"`
Content string `json:"content"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return "", fmt.Errorf("bad args: %w", err)
}
if a.FilePath == "" {
return "", fmt.Errorf("file_path is required")
}
_, statErr := os.Stat(a.FilePath)
exists := statErr == nil
if exists && !s.wasRead(a.FilePath) {
return errText("existing file must be read first. Use the Read tool to examine the file contents."), nil
}
if err := os.WriteFile(a.FilePath, []byte(a.Content), 0644); err != nil { //nolint:gosec
return "", fmt.Errorf("writing file: %w", err)
}
s.markRead(a.FilePath)
return "File written successfully", nil
}