Skip to content

Commit 6837cca

Browse files
feat: implement Task 2.7 & 2.8 - File I/O and Web tools
Task 2.7 - File I/O Tools: - Add FileReadTool with offset/limit support for partial file reading - Add FileWriteTool with automatic parent directory creation - Add EditFileTool with exact string replacement (prevents multiple matches) - All tools enforce security policy via ISecurityPolicy - 18 unit tests, all passing Task 2.8 - Web Tools: - Add WebSearchTool using Brave Search API - Add WebFetchTool for extracting readable content from web pages - WebFetchTool strips scripts, styles, navigation elements - Supports truncation with configurable max_chars - 10 unit tests, all passing TDD Process: - RED: Wrote failing tests first - GREEN: Implemented minimal code to pass tests - REFACTOR: Cleaned up and optimized Total: 38 tests passing (28 new + 8 existing ShellTool tests + 2 existing)
1 parent 310ccea commit 6837cca

8 files changed

Lines changed: 1144 additions & 0 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using System.Text.Json;
2+
using ClawSharp.Core.Security;
3+
using ClawSharp.Core.Tools;
4+
5+
namespace ClawSharp.Tools;
6+
7+
/// <summary>
8+
/// Tool for editing a file by replacing exact text strings.
9+
/// </summary>
10+
public class EditFileTool : ITool
11+
{
12+
private readonly ISecurityPolicy _securityPolicy;
13+
14+
public string Name => "edit_file";
15+
16+
public string Description => "Edit a file by replacing an exact text string. Returns an error if the old_string appears multiple times or is not found.";
17+
18+
public ToolSpec Specification => new(
19+
Name: Name,
20+
Description: Description,
21+
ParametersSchema: JsonSerializer.Deserialize<JsonElement>(@"
22+
{
23+
""type"": ""object"",
24+
""properties"": {
25+
""path"": {
26+
""type"": ""string"",
27+
""description"": ""The path to the file to edit""
28+
},
29+
""old_string"": {
30+
""type"": ""string"",
31+
""description"": ""The exact text to find and replace""
32+
},
33+
""new_string"": {
34+
""type"": ""string"",
35+
""description"": ""The text to replace old_string with""
36+
}
37+
},
38+
""required"": [""path"", ""old_string"", ""new_string""]
39+
}")
40+
);
41+
42+
public EditFileTool(ISecurityPolicy securityPolicy)
43+
{
44+
_securityPolicy = securityPolicy ?? throw new ArgumentNullException(nameof(securityPolicy));
45+
}
46+
47+
public async Task<ToolResult> ExecuteAsync(JsonElement arguments, CancellationToken ct = default)
48+
{
49+
// Extract path
50+
if (!arguments.TryGetProperty("path", out var pathElement))
51+
{
52+
return new ToolResult(false, "", "Missing required parameter: path");
53+
}
54+
55+
var path = pathElement.GetString();
56+
if (string.IsNullOrWhiteSpace(path))
57+
{
58+
return new ToolResult(false, "", "Path cannot be empty");
59+
}
60+
61+
// Extract old_string
62+
if (!arguments.TryGetProperty("old_string", out var oldStringElement))
63+
{
64+
return new ToolResult(false, "", "Missing required parameter: old_string");
65+
}
66+
67+
var oldString = oldStringElement.GetString();
68+
if (string.IsNullOrEmpty(oldString))
69+
{
70+
return new ToolResult(false, "", "old_string cannot be empty");
71+
}
72+
73+
// Extract new_string
74+
string newString = "";
75+
if (arguments.TryGetProperty("new_string", out var newStringElement))
76+
{
77+
newString = newStringElement.GetString() ?? "";
78+
}
79+
80+
// Check security policy
81+
if (!_securityPolicy.IsPathAllowed(path))
82+
{
83+
return new ToolResult(false, "", $"Path not allowed by security policy: {path}");
84+
}
85+
86+
// Check file exists
87+
if (!File.Exists(path))
88+
{
89+
return new ToolResult(false, "", $"File not found: {path}");
90+
}
91+
92+
try
93+
{
94+
// Read file content
95+
var content = await File.ReadAllTextAsync(path, ct);
96+
97+
// Find occurrences
98+
var index = content.IndexOf(oldString, StringComparison.Ordinal);
99+
if (index < 0)
100+
{
101+
return new ToolResult(false, "", $"old_string not found in file: {oldString}");
102+
}
103+
104+
// Check for multiple occurrences
105+
var nextIndex = content.IndexOf(oldString, index + oldString.Length, StringComparison.Ordinal);
106+
if (nextIndex >= 0)
107+
{
108+
var count = 1;
109+
while ((index = content.IndexOf(oldString, index + oldString.Length, StringComparison.Ordinal)) >= 0)
110+
{
111+
count++;
112+
}
113+
return new ToolResult(false, "", $"old_string appears {count} times in file. Use a more specific string.");
114+
}
115+
116+
// Replace
117+
var newContent = content.Substring(0, index) + newString + content.Substring(index + oldString.Length);
118+
119+
// Write file
120+
await File.WriteAllTextAsync(path, newContent, ct);
121+
122+
return new ToolResult(true, $"Successfully replaced text in {path}");
123+
}
124+
catch (Exception ex)
125+
{
126+
return new ToolResult(false, "", $"Error editing file: {ex.Message}");
127+
}
128+
}
129+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using System.Text.Json;
2+
using ClawSharp.Core.Security;
3+
using ClawSharp.Core.Tools;
4+
5+
namespace ClawSharp.Tools;
6+
7+
/// <summary>
8+
/// Tool for reading file contents with optional offset and limit.
9+
/// </summary>
10+
public class FileReadTool : ITool
11+
{
12+
private readonly ISecurityPolicy _securityPolicy;
13+
14+
public string Name => "read_file";
15+
16+
public string Description => "Read the contents of a file. Supports optional offset and limit parameters for reading partial content.";
17+
18+
public ToolSpec Specification => new(
19+
Name: Name,
20+
Description: Description,
21+
ParametersSchema: JsonSerializer.Deserialize<JsonElement>(@"
22+
{
23+
""type"": ""object"",
24+
""properties"": {
25+
""path"": {
26+
""type"": ""string"",
27+
""description"": ""The path to the file to read""
28+
},
29+
""offset"": {
30+
""type"": ""integer"",
31+
""description"": ""Line number to start reading from (1-indexed)""
32+
},
33+
""limit"": {
34+
""type"": ""integer"",
35+
""description"": ""Maximum number of lines to read""
36+
}
37+
},
38+
""required"": [""path""]
39+
}")
40+
);
41+
42+
public FileReadTool(ISecurityPolicy securityPolicy)
43+
{
44+
_securityPolicy = securityPolicy ?? throw new ArgumentNullException(nameof(securityPolicy));
45+
}
46+
47+
public async Task<ToolResult> ExecuteAsync(JsonElement arguments, CancellationToken ct = default)
48+
{
49+
// Extract path
50+
if (!arguments.TryGetProperty("path", out var pathElement))
51+
{
52+
return new ToolResult(false, "", "Missing required parameter: path");
53+
}
54+
55+
var path = pathElement.GetString();
56+
if (string.IsNullOrWhiteSpace(path))
57+
{
58+
return new ToolResult(false, "", "Path cannot be empty");
59+
}
60+
61+
// Check security policy
62+
if (!_securityPolicy.IsPathAllowed(path))
63+
{
64+
return new ToolResult(false, "", $"Path not allowed by security policy: {path}");
65+
}
66+
67+
// Check file exists
68+
if (!File.Exists(path))
69+
{
70+
return new ToolResult(false, "", $"File not found: {path}");
71+
}
72+
73+
try
74+
{
75+
// Read all lines
76+
var lines = await File.ReadAllLinesAsync(path, ct);
77+
78+
// Parse offset and limit
79+
var offset = 0;
80+
var limit = lines.Length;
81+
82+
if (arguments.TryGetProperty("offset", out var offsetElement))
83+
{
84+
offset = offsetElement.GetInt32() - 1; // Convert to 0-indexed
85+
if (offset < 0) offset = 0;
86+
}
87+
88+
if (arguments.TryGetProperty("limit", out var limitElement))
89+
{
90+
limit = limitElement.GetInt32();
91+
}
92+
93+
// Clamp values
94+
if (offset >= lines.Length)
95+
{
96+
return new ToolResult(true, "");
97+
}
98+
99+
var count = Math.Min(limit, lines.Length - offset);
100+
var selectedLines = lines.Skip(offset).Take(count);
101+
102+
return new ToolResult(true, string.Join(Environment.NewLine, selectedLines));
103+
}
104+
catch (Exception ex)
105+
{
106+
return new ToolResult(false, "", $"Error reading file: {ex.Message}");
107+
}
108+
}
109+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using System.Text.Json;
2+
using ClawSharp.Core.Security;
3+
using ClawSharp.Core.Tools;
4+
5+
namespace ClawSharp.Tools;
6+
7+
/// <summary>
8+
/// Tool for writing content to a file, creating parent directories if needed.
9+
/// </summary>
10+
public class FileWriteTool : ITool
11+
{
12+
private readonly ISecurityPolicy _securityPolicy;
13+
14+
public string Name => "write_file";
15+
16+
public string Description => "Write content to a file. Creates the file and parent directories if they don't exist.";
17+
18+
public ToolSpec Specification => new(
19+
Name: Name,
20+
Description: Description,
21+
ParametersSchema: JsonSerializer.Deserialize<JsonElement>(@"
22+
{
23+
""type"": ""object"",
24+
""properties"": {
25+
""path"": {
26+
""type"": ""string"",
27+
""description"": ""The path to the file to write""
28+
},
29+
""content"": {
30+
""type"": ""string"",
31+
""description"": ""The content to write to the file""
32+
}
33+
},
34+
""required"": [""path"", ""content""]
35+
}")
36+
);
37+
38+
public FileWriteTool(ISecurityPolicy securityPolicy)
39+
{
40+
_securityPolicy = securityPolicy ?? throw new ArgumentNullException(nameof(securityPolicy));
41+
}
42+
43+
public async Task<ToolResult> ExecuteAsync(JsonElement arguments, CancellationToken ct = default)
44+
{
45+
// Extract path
46+
if (!arguments.TryGetProperty("path", out var pathElement))
47+
{
48+
return new ToolResult(false, "", "Missing required parameter: path");
49+
}
50+
51+
var path = pathElement.GetString();
52+
if (string.IsNullOrWhiteSpace(path))
53+
{
54+
return new ToolResult(false, "", "Path cannot be empty");
55+
}
56+
57+
// Extract content
58+
if (!arguments.TryGetProperty("content", out var contentElement))
59+
{
60+
return new ToolResult(false, "", "Missing required parameter: content");
61+
}
62+
63+
var content = contentElement.GetString() ?? "";
64+
65+
// Check security policy
66+
if (!_securityPolicy.IsPathAllowed(path))
67+
{
68+
return new ToolResult(false, "", $"Path not allowed by security policy: {path}");
69+
}
70+
71+
try
72+
{
73+
// Create parent directories if needed
74+
var directory = Path.GetDirectoryName(path);
75+
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
76+
{
77+
Directory.CreateDirectory(directory);
78+
}
79+
80+
// Write file
81+
await File.WriteAllTextAsync(path, content, ct);
82+
83+
return new ToolResult(true, $"Successfully wrote {content.Length} bytes to {path}");
84+
}
85+
catch (Exception ex)
86+
{
87+
return new ToolResult(false, "", $"Error writing file: {ex.Message}");
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)