diff --git a/pkg/tools/file/edit.go b/pkg/tools/file/edit.go new file mode 100644 index 0000000..dad98da --- /dev/null +++ b/pkg/tools/file/edit.go @@ -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 "" +} diff --git a/pkg/tools/file/glob.go b/pkg/tools/file/glob.go new file mode 100644 index 0000000..271724e --- /dev/null +++ b/pkg/tools/file/glob.go @@ -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) +} diff --git a/pkg/tools/file/grep.go b/pkg/tools/file/grep.go new file mode 100644 index 0000000..38a00ec --- /dev/null +++ b/pkg/tools/file/grep.go @@ -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 +} diff --git a/pkg/tools/file/read.go b/pkg/tools/file/read.go new file mode 100644 index 0000000..765a561 --- /dev/null +++ b/pkg/tools/file/read.go @@ -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 +} diff --git a/pkg/tools/file/server.go b/pkg/tools/file/server.go new file mode 100644 index 0000000..3bfb9d2 --- /dev/null +++ b/pkg/tools/file/server.go @@ -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...) +} diff --git a/pkg/tools/file/write.go b/pkg/tools/file/write.go new file mode 100644 index 0000000..6ef5ce6 --- /dev/null +++ b/pkg/tools/file/write.go @@ -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 +}