001/*
002 * Copyright 2025 - 2025 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package com.agentsflex.tool.commons;
017
018
019import com.agentsflex.core.model.chat.tool.annotation.ToolDef;
020import com.agentsflex.core.model.chat.tool.annotation.ToolParam;
021import com.agentsflex.core.util.IOUtil;
022
023import java.io.*;
024import java.nio.file.Files;
025import java.nio.file.Path;
026import java.nio.file.Paths;
027import java.util.ArrayList;
028import java.util.List;
029
030/**
031 * @author Christian Tzolov
032 * @author Michael Yang
033 */
034public class FileSystemTools {
035
036    @ToolDef(name = "Read", description = "Reads a file from the local filesystem. You can access any file directly by using this tool.\n" +
037        "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.\n" +
038        "\n" +
039        "Usage:\n" +
040        "- The file_path parameter must be an absolute path, not a relative path\n" +
041        "- By default, it reads up to 2000 lines starting from the beginning of the file\n" +
042        "- 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\n" +
043        "- Any lines longer than 2000 characters will be truncated\n" +
044        "- Results are returned using cat -n format, with line numbers starting at 1\n" +
045        "- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.\n" +
046        "- This tool can read PDF files (.pdf). PDFs are processed page by page, extracting both text and visual content for analysis.\n" +
047        "- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.\n" +
048        "- This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.\n" +
049        "- You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel.\n" +
050        "- You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths.\n" +
051        "- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.")
052    public String read(
053        @ToolParam(name = "filePath", description = "The absolute path to the file to read") String filePath,
054        @ToolParam(name = "offset", description = "The line number to start reading from. Only provide if the file is too large to read at once", required = false) Integer offset,
055        @ToolParam(name = "limit", description = "The number of lines to read. Only provide if the file is too large to read at once.", required = false) Integer limit) {
056
057        try {
058            File file = new File(filePath);
059
060            if (!file.exists()) {
061                return "Error: File does not exist: " + filePath;
062            }
063
064            if (file.isDirectory()) {
065                return "Error: Path is a directory, not a file: " + filePath;
066            }
067
068            // Default values
069            int startLine = offset != null ? offset : 1;
070            int maxLines = limit != null ? limit : 2000;
071
072            if (startLine < 1) {
073                startLine = 1;
074            }
075
076            List<String> lines = new ArrayList<>();
077            int currentLine = 0;
078            int linesRead = 0;
079
080            try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
081                String line;
082                while ((line = reader.readLine()) != null) {
083                    currentLine++;
084
085                    // Skip lines before the offset
086                    if (currentLine < startLine) {
087                        continue;
088                    }
089
090                    // Stop if we've read enough lines
091                    if (linesRead >= maxLines) {
092                        break;
093                    }
094
095                    // Truncate long lines to 2000 characters
096                    if (line.length() > 2000) {
097                        line = line.substring(0, 2000) + "... (line truncated)";
098                    }
099
100                    lines.add(String.format("%6d%s", currentLine, line));
101                    linesRead++;
102                }
103            }
104
105            if (lines.isEmpty()) {
106                if (currentLine == 0) {
107                    return "File is empty: " + filePath;
108                } else {
109                    return String.format("No lines to read. File has %d lines, but offset was %d", currentLine,
110                        startLine);
111                }
112            }
113
114            StringBuilder result = new StringBuilder();
115            result.append(String.format("File: %s\n", filePath));
116            result.append(
117                String.format("Showing lines %d-%d of %d\n\n", startLine, startLine + linesRead - 1, currentLine));
118
119            for (String line : lines) {
120                result.append(line).append("\n");
121            }
122
123            return result.toString();
124
125        } catch (IOException e) {
126            return "Error reading file: " + e.getMessage();
127        }
128    }
129
130    // @formatter:off
131        @ToolDef(name = "Write", description = "Writes a file to the local filesystem.\n" +
132        "\n" +
133        "Usage:\n" +
134        "- This tool will overwrite the existing file if there is one at the provided path.\n" +
135        "- 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.\n" +
136        "- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.\n" +
137        "- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.\n" +
138        "- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.")
139        public String write(
140                @ToolParam(name = "filePath", description = "The absolute path to the file to write (must be absolute, not relative)") String filePath,
141                @ToolParam(name = "content", description = "The content to write to the file") String content) { // @formatter:on
142
143        try {
144            content = content != null ? content : "";
145
146            Path path = Paths.get(filePath);
147            File file = path.toFile();
148
149            // Create parent directories if they don't exist
150            File parentDir = file.getParentFile();
151            if (parentDir != null && !parentDir.exists()) {
152                if (!parentDir.mkdirs()) {
153                    return "Error: Failed to create parent directories for: " + filePath;
154                }
155            }
156
157            // Check if file already exists
158            boolean fileExists = file.exists();
159
160            // Write content to file
161            try (BufferedWriter writer = new BufferedWriter(new FileWriter(file, false))) {
162                writer.write(content);
163            }
164
165            if (fileExists) {
166                return String.format("Successfully overwrote file: %s (%d bytes)", filePath, content.length());
167            } else {
168                return String.format("Successfully created file: %s (%d bytes)", filePath, content.length());
169            }
170
171        } catch (IOException e) {
172            return "Error writing file: " + e.getMessage();
173        } catch (Exception e) {
174            return "Error: " + e.getMessage();
175        }
176    }
177
178    // @formatter:off
179        @ToolDef(name = "Edit", description = "Performs exact string replacements in files.\n" +
180        "\n" +
181        "Usage:\n" +
182        "- 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.\n" +
183        "- 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: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.\n" +
184        "- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.\n" +
185        "- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.\n" +
186        "- 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`.\n" +
187        "- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.")
188        public String edit(
189                @ToolParam(name = "filePath", description = "The absolute path to the file to modify") String filePath,
190                @ToolParam(name = "old_string", description = "The text to replace") String old_string,
191                @ToolParam(name = "new_string", description = "The text to replace it with (must be different from old_string)") String new_string,
192                @ToolParam(name = "replace_all", description = "Replace all occurences of old_string (default false)", required = false) Boolean replace_all) { // @formatter:on
193
194        try {
195            File file = new File(filePath);
196
197            if (!file.exists()) {
198                return "Error: File does not exist: " + filePath;
199            }
200
201            if (file.isDirectory()) {
202                return "Error: Path is a directory, not a file: " + filePath;
203            }
204
205            // Validate that old_string and new_string are different
206            if (old_string.equals(new_string)) {
207                return "Error: old_string and new_string must be different";
208            }
209
210            // Read the entire file content preserving exact line endings
211            String originalContent;
212            try {
213                originalContent = IOUtil.readUtf8(Files.newInputStream(file.toPath()));
214            } catch (IOException e) {
215                return "Error reading file content: " + e.getMessage();
216            }
217
218            // Count occurrences
219            int occurrences = countOccurrences(originalContent, old_string);
220
221            if (occurrences == 0) {
222                return "Error: old_string not found in file: " + filePath;
223            }
224
225            boolean replaceAll = Boolean.TRUE.equals(replace_all);
226
227            if (!replaceAll && occurrences > 1) {
228                return String.format(
229                    "Error: old_string appears %d times in the file. Either provide a larger string with more surrounding context to make it unique or use replace_all=true to change all instances.",
230                    occurrences);
231            }
232
233            // Perform replacement
234            String newContent;
235            if (replaceAll) {
236                // Replace all occurrences using literal string replacement
237                newContent = replaceAll(originalContent, old_string, new_string);
238            } else {
239                // Replace first occurrence only
240                newContent = replaceFirst(originalContent, old_string, new_string);
241            }
242
243            // Write the modified content back to the file
244            try (BufferedWriter writer = new BufferedWriter(new FileWriter(file, false))) {
245                writer.write(newContent);
246            }
247
248            // Generate a snippet showing the context around the edit
249            String snippet = generateEditSnippet(newContent, new_string);
250
251            // Return formatted response matching Claude Code's Edit tool format
252            return String.format(
253                "The file %s has been updated. Here's the result of running `cat -n` on a snippet of the edited file:\n%s",
254                filePath, snippet);
255
256        } catch (IOException e) {
257            return "Error editing file: " + e.getMessage();
258        }
259    }
260
261    // Helper method to count occurrences of a substring
262    private int countOccurrences(String text, String substring) {
263        int count = 0;
264        int index = 0;
265        while ((index = text.indexOf(substring, index)) != -1) {
266            count++;
267            index += substring.length();
268        }
269        return count;
270    }
271
272    // Helper method to replace first occurrence
273    private String replaceFirst(String text, String old_string, String new_string) {
274        int index = text.indexOf(old_string);
275        if (index == -1) {
276            return text;
277        }
278        return text.substring(0, index) + new_string + text.substring(index + old_string.length());
279    }
280
281    // Helper method to replace all occurrences (literal, not regex)
282    private String replaceAll(String text, String old_string, String new_string) {
283        StringBuilder result = new StringBuilder();
284        int index = 0;
285        int lastIndex = 0;
286
287        while ((index = text.indexOf(old_string, lastIndex)) != -1) {
288            result.append(text, lastIndex, index);
289            result.append(new_string);
290            lastIndex = index + old_string.length();
291        }
292        result.append(text.substring(lastIndex));
293
294        return result.toString();
295    }
296
297    /**
298     * Generates a formatted snippet of the file showing context around the edited
299     * section. Matches Claude Code's Edit tool output format with line numbers and arrow
300     * separator.
301     *
302     * @param fileContent the complete file content after editing
303     * @param newString   the new string that was inserted (used to find the edit location)
304     * @return formatted snippet with line numbers
305     */
306    private String generateEditSnippet(String fileContent, String newString) {
307        String[] lines = fileContent.split("\n", -1);
308
309        // Find the line where the new content appears
310        int editStartLine = -1;
311        int editEndLine = -1;
312
313        // Split new_string into lines to find where it appears in the file
314        String[] newLines = newString.split("\n", -1);
315
316        // Search for the first line of the new content
317        for (int i = 0; i < lines.length; i++) {
318            if (newLines.length > 0 && lines[i].contains(newLines[0])) {
319                // Check if subsequent lines match (for multi-line edits)
320                boolean matches = true;
321                for (int j = 1; j < newLines.length && i + j < lines.length; j++) {
322                    if (!lines[i + j].contains(newLines[j])) {
323                        matches = false;
324                        break;
325                    }
326                }
327                if (matches) {
328                    editStartLine = i;
329                    editEndLine = i + newLines.length - 1;
330                    break;
331                }
332            }
333        }
334
335        // If we didn't find the edit location, show the beginning of the file
336        if (editStartLine == -1) {
337            editStartLine = 0;
338            editEndLine = Math.min(10, lines.length - 1);
339        }
340
341        // Show context: ~5 lines before and ~5 lines after the edit
342        int contextBefore = 5;
343        int contextAfter = 5;
344        int startLine = Math.max(0, editStartLine - contextBefore);
345        int endLine = Math.min(lines.length - 1, editEndLine + contextAfter);
346
347        // Build the snippet with line numbers (1-indexed, right-aligned with arrow)
348        StringBuilder snippet = new StringBuilder();
349        for (int i = startLine; i <= endLine; i++) {
350            // Line numbers are 1-indexed and right-aligned to 6 characters
351            snippet.append(String.format("%6d→%s", i + 1, lines[i]));
352            if (i < endLine) {
353                snippet.append("\n");
354            }
355        }
356
357        return snippet.toString();
358    }
359
360    public static Builder builder() {
361        return new Builder();
362    }
363
364    public static class Builder {
365
366        public FileSystemTools build() {
367            return new FileSystemTools();
368        }
369
370    }
371
372}