@dexto/tools-filesystem 1.7.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/directory-approval.cjs +5 -4
  2. package/dist/directory-approval.d.ts +2 -1
  3. package/dist/directory-approval.d.ts.map +1 -1
  4. package/dist/directory-approval.integration.test.cjs +73 -33
  5. package/dist/directory-approval.integration.test.js +73 -33
  6. package/dist/directory-approval.js +2 -1
  7. package/dist/edit-file-tool.cjs +52 -37
  8. package/dist/edit-file-tool.d.ts +1 -1
  9. package/dist/edit-file-tool.d.ts.map +1 -1
  10. package/dist/edit-file-tool.js +43 -29
  11. package/dist/edit-file-tool.test.cjs +159 -2
  12. package/dist/edit-file-tool.test.js +159 -2
  13. package/dist/errors.cjs +53 -53
  14. package/dist/errors.d.ts +1 -1
  15. package/dist/errors.d.ts.map +1 -1
  16. package/dist/errors.js +1 -1
  17. package/dist/file-tool-types.d.ts +1 -1
  18. package/dist/file-tool-types.d.ts.map +1 -1
  19. package/dist/filesystem-service.cjs +60 -60
  20. package/dist/filesystem-service.d.ts +1 -1
  21. package/dist/filesystem-service.d.ts.map +1 -1
  22. package/dist/filesystem-service.js +5 -5
  23. package/dist/filesystem-service.test.cjs +1 -3
  24. package/dist/filesystem-service.test.js +1 -3
  25. package/dist/glob-files-tool.cjs +27 -24
  26. package/dist/glob-files-tool.d.ts +1 -1
  27. package/dist/glob-files-tool.d.ts.map +1 -1
  28. package/dist/glob-files-tool.js +24 -21
  29. package/dist/glob-files-tool.test.cjs +100 -88
  30. package/dist/glob-files-tool.test.js +101 -67
  31. package/dist/grep-content-tool.cjs +129 -44
  32. package/dist/grep-content-tool.d.ts +1 -1
  33. package/dist/grep-content-tool.d.ts.map +1 -1
  34. package/dist/grep-content-tool.js +120 -41
  35. package/dist/grep-content-tool.test.cjs +122 -87
  36. package/dist/grep-content-tool.test.js +123 -66
  37. package/dist/index.d.cts +3 -4
  38. package/dist/path-validator.d.ts +1 -1
  39. package/dist/path-validator.d.ts.map +1 -1
  40. package/dist/read-file-tool.cjs +43 -14
  41. package/dist/read-file-tool.d.ts +1 -1
  42. package/dist/read-file-tool.d.ts.map +1 -1
  43. package/dist/read-file-tool.js +40 -11
  44. package/dist/read-file-tool.test.cjs +119 -0
  45. package/dist/read-file-tool.test.d.ts +2 -0
  46. package/dist/read-file-tool.test.d.ts.map +1 -0
  47. package/dist/read-file-tool.test.js +96 -0
  48. package/dist/read-media-file-tool.cjs +4 -4
  49. package/dist/read-media-file-tool.d.ts +1 -1
  50. package/dist/read-media-file-tool.d.ts.map +1 -1
  51. package/dist/read-media-file-tool.js +1 -1
  52. package/dist/tool-factory.cjs +2 -2
  53. package/dist/tool-factory.js +1 -1
  54. package/dist/types.d.ts +0 -2
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/workspace-paths.cjs +87 -0
  57. package/dist/workspace-paths.d.ts +4 -0
  58. package/dist/workspace-paths.d.ts.map +1 -0
  59. package/dist/workspace-paths.js +51 -0
  60. package/dist/write-file-tool.cjs +74 -34
  61. package/dist/write-file-tool.d.ts +1 -2
  62. package/dist/write-file-tool.d.ts.map +1 -1
  63. package/dist/write-file-tool.js +68 -29
  64. package/dist/write-file-tool.test.cjs +262 -11
  65. package/dist/write-file-tool.test.js +262 -11
  66. package/package.json +3 -3
@@ -3,14 +3,15 @@ import { z } from "zod";
3
3
  import { createPatch } from "diff";
4
4
  import {
5
5
  createLocalToolCallHeader,
6
- DextoRuntimeError,
7
6
  ToolError,
8
7
  ToolErrorCode,
9
8
  defineTool,
10
9
  truncateForHeader
11
- } from "@dexto/core";
10
+ } from "@dexto/core/tools";
11
+ import { DextoRuntimeError } from "@dexto/core/errors";
12
12
  import { FileSystemErrorCode } from "./error-codes.js";
13
13
  import { createDirectoryAccessApprovalHandlers, resolveFilePath } from "./directory-approval.js";
14
+ import { toWorkspaceRelativePath } from "./workspace-paths.js";
14
15
  const previewContentHashCache = /* @__PURE__ */ new Map();
15
16
  function computeContentHash(content) {
16
17
  return createHash("sha256").update(content, "utf8").digest("hex");
@@ -51,7 +52,7 @@ function createEditFileTool(getFileSystemService) {
51
52
  }),
52
53
  presentation: {
53
54
  describeHeader: (input) => createLocalToolCallHeader({
54
- title: "Update",
55
+ title: "Edit",
55
56
  argsText: truncateForHeader(input.file_path, 140)
56
57
  }),
57
58
  /**
@@ -124,54 +125,67 @@ function createEditFileTool(getFileSystemService) {
124
125
  }
125
126
  },
126
127
  async execute(input, context) {
127
- const resolvedFileSystemService = await getFileSystemService(context);
128
128
  const { file_path, old_string, new_string, replace_all } = input;
129
- const { path: resolvedPath } = resolveFilePath(
130
- resolvedFileSystemService.getWorkingDirectory(),
129
+ const handle = await openWorkspace(context, "edit_file");
130
+ const workspacePath = toWorkspaceRelativePath(
131
+ "edit_file",
132
+ handle.context.path,
131
133
  file_path
132
134
  );
135
+ let currentContent = await handle.files.readText(workspacePath);
133
136
  const toolCallId = context.toolCallId;
134
137
  if (toolCallId) {
135
138
  const expectedHash = previewContentHashCache.get(toolCallId);
136
139
  if (expectedHash === void 0) {
137
140
  } else {
138
141
  previewContentHashCache.delete(toolCallId);
139
- let currentContent;
140
142
  try {
141
- const currentFile = await resolvedFileSystemService.readFile(resolvedPath);
142
- currentContent = currentFile.content;
143
- } catch (error) {
144
- if (error instanceof DextoRuntimeError && error.code === FileSystemErrorCode.FILE_NOT_FOUND) {
145
- throw ToolError.fileModifiedSincePreview("edit_file", resolvedPath);
146
- }
147
- throw error;
143
+ currentContent = await handle.files.readText(workspacePath);
144
+ } catch {
145
+ throw ToolError.fileModifiedSincePreview("edit_file", file_path);
148
146
  }
149
147
  const currentHash = computeContentHash(currentContent);
150
148
  if (expectedHash !== currentHash) {
151
- throw ToolError.fileModifiedSincePreview("edit_file", resolvedPath);
149
+ throw ToolError.fileModifiedSincePreview("edit_file", file_path);
152
150
  }
153
151
  }
154
152
  }
155
- const result = await resolvedFileSystemService.editFile(resolvedPath, {
156
- oldString: old_string,
157
- newString: new_string,
158
- replaceAll: replace_all
159
- });
160
- const _display = generateDiffPreview(
161
- resolvedPath,
162
- result.originalContent,
163
- result.newContent
164
- );
153
+ const occurrences = currentContent.split(old_string).length - 1;
154
+ if (occurrences === 0) {
155
+ throw ToolError.validationFailed(
156
+ "edit_file",
157
+ `String not found in file: "${old_string.slice(0, 50)}${old_string.length > 50 ? "..." : ""}"`,
158
+ {
159
+ file_path,
160
+ old_string_preview: old_string.slice(0, 100)
161
+ }
162
+ );
163
+ }
164
+ if (!replace_all && occurrences > 1) {
165
+ throw ToolError.validationFailed(
166
+ "edit_file",
167
+ `String found ${occurrences} times in file. Set replace_all=true to replace all, or provide more context to make old_string unique.`,
168
+ { file_path, occurrences }
169
+ );
170
+ }
171
+ const newContent = replace_all ? currentContent.split(old_string).join(new_string) : currentContent.replace(old_string, new_string);
172
+ await handle.files.writeFile(workspacePath, newContent);
173
+ const _display = generateDiffPreview(file_path, currentContent, newContent);
165
174
  return {
166
- success: result.success,
167
- path: result.path,
168
- changes_count: result.changesCount,
169
- ...result.backupPath && { backup_path: result.backupPath },
175
+ success: true,
176
+ path: file_path,
177
+ changes_count: occurrences,
170
178
  _display
171
179
  };
172
180
  }
173
181
  });
174
182
  }
183
+ async function openWorkspace(context, toolName) {
184
+ if (!context.services) {
185
+ throw new Error(`${toolName} requires ToolExecutionContext.services`);
186
+ }
187
+ return context.services.workspaceManager.open({ intent: "write" });
188
+ }
175
189
  export {
176
190
  createEditFileTool
177
191
  };
@@ -46,8 +46,49 @@ const createMockLogger = () => {
46
46
  };
47
47
  return logger;
48
48
  };
49
- function createToolContext(logger, overrides = {}) {
50
- return { logger, ...overrides };
49
+ function createToolContext(logger, overrides = {}, workspaceRoot = currentWorkspaceRoot) {
50
+ return {
51
+ logger,
52
+ services: createWorkspaceServices(workspaceRoot),
53
+ ...overrides
54
+ };
55
+ }
56
+ let currentWorkspaceRoot = process.cwd();
57
+ function createWorkspaceServices(workspaceRoot) {
58
+ const workspaceManager = {
59
+ open: import_vitest.vi.fn(async () => ({
60
+ context: {
61
+ id: "test-workspace",
62
+ path: workspaceRoot,
63
+ createdAt: Date.now(),
64
+ lastActiveAt: Date.now()
65
+ },
66
+ capabilities: ["files"],
67
+ files: {
68
+ readFile: async (filePath) => fs.readFile(resolveWorkspacePath(workspaceRoot, filePath), "utf-8"),
69
+ readText: async (filePath) => fs.readFile(resolveWorkspacePath(workspaceRoot, filePath), "utf-8"),
70
+ glob: import_vitest.vi.fn(async () => []),
71
+ writeFile: async (filePath, content) => {
72
+ const resolvedPath = resolveWorkspacePath(workspaceRoot, filePath);
73
+ await fs.writeFile(resolvedPath, content, "utf-8");
74
+ },
75
+ listFiles: import_vitest.vi.fn(async () => [])
76
+ }
77
+ }))
78
+ };
79
+ return {
80
+ approval: {},
81
+ search: {},
82
+ resources: {},
83
+ prompts: {},
84
+ skills: {},
85
+ mcp: {},
86
+ taskForker: null,
87
+ workspaceManager
88
+ };
89
+ }
90
+ function resolveWorkspacePath(workspaceRoot, filePath) {
91
+ return path.isAbsolute(filePath) ? filePath : path.join(workspaceRoot, filePath);
51
92
  }
52
93
  (0, import_vitest.describe)("edit_file tool", () => {
53
94
  let mockLogger;
@@ -70,6 +111,7 @@ function createToolContext(logger, overrides = {}) {
70
111
  mockLogger
71
112
  );
72
113
  await fileSystemService.initialize();
114
+ currentWorkspaceRoot = tempDir;
73
115
  import_vitest.vi.clearAllMocks();
74
116
  });
75
117
  (0, import_vitest.afterEach)(async () => {
@@ -78,6 +120,121 @@ function createToolContext(logger, overrides = {}) {
78
120
  } catch {
79
121
  }
80
122
  });
123
+ (0, import_vitest.it)("edits through WorkspaceManager.open without FileSystemService execution", async () => {
124
+ await fs.writeFile(path.join(tempDir, "workspace.txt"), "hello world");
125
+ const getFileSystemService = import_vitest.vi.fn(async () => {
126
+ throw new Error("edit_file execute must not use FileSystemService");
127
+ });
128
+ const tool = (0, import_edit_file_tool.createEditFileTool)(getFileSystemService);
129
+ const parsedInput = tool.inputSchema.parse({
130
+ file_path: "workspace.txt",
131
+ old_string: "world",
132
+ new_string: "workspace"
133
+ });
134
+ const result = await tool.execute(parsedInput, createToolContext(mockLogger));
135
+ (0, import_vitest.expect)(getFileSystemService).not.toHaveBeenCalled();
136
+ (0, import_vitest.expect)(result).toMatchObject({
137
+ success: true,
138
+ path: "workspace.txt",
139
+ changes_count: 1
140
+ });
141
+ await (0, import_vitest.expect)(fs.readFile(path.join(tempDir, "workspace.txt"), "utf-8")).resolves.toBe(
142
+ "hello workspace"
143
+ );
144
+ });
145
+ (0, import_vitest.it)("normalizes workspace-contained absolute paths before editing", async () => {
146
+ const readText = import_vitest.vi.fn(async () => "hello world");
147
+ const writeFile = import_vitest.vi.fn(async () => void 0);
148
+ const workspaceManager = {
149
+ open: import_vitest.vi.fn(async () => ({
150
+ context: {
151
+ id: "test-workspace",
152
+ path: tempDir,
153
+ createdAt: Date.now(),
154
+ lastActiveAt: Date.now()
155
+ },
156
+ capabilities: ["files"],
157
+ files: {
158
+ readFile: readText,
159
+ readText,
160
+ glob: import_vitest.vi.fn(async () => []),
161
+ writeFile,
162
+ listFiles: import_vitest.vi.fn(async () => [])
163
+ }
164
+ }))
165
+ };
166
+ const tool = (0, import_edit_file_tool.createEditFileTool)(import_vitest.vi.fn());
167
+ await tool.execute(
168
+ tool.inputSchema.parse({
169
+ file_path: path.join(tempDir, "workspace.txt"),
170
+ old_string: "world",
171
+ new_string: "workspace"
172
+ }),
173
+ createToolContext(mockLogger, {
174
+ services: {
175
+ ...createWorkspaceServices(tempDir),
176
+ workspaceManager
177
+ }
178
+ })
179
+ );
180
+ (0, import_vitest.expect)(readText).toHaveBeenCalledWith("workspace.txt");
181
+ (0, import_vitest.expect)(writeFile).toHaveBeenCalledWith("workspace.txt", "hello workspace");
182
+ });
183
+ (0, import_vitest.it)("rejects external absolute paths before file provider calls", async () => {
184
+ const readText = import_vitest.vi.fn(async () => "hello world");
185
+ const writeFile = import_vitest.vi.fn(async () => void 0);
186
+ const workspaceManager = {
187
+ open: import_vitest.vi.fn(async () => ({
188
+ context: {
189
+ id: "test-workspace",
190
+ path: tempDir,
191
+ createdAt: Date.now(),
192
+ lastActiveAt: Date.now()
193
+ },
194
+ capabilities: ["files"],
195
+ files: {
196
+ readFile: readText,
197
+ readText,
198
+ glob: import_vitest.vi.fn(async () => []),
199
+ writeFile,
200
+ listFiles: import_vitest.vi.fn(async () => [])
201
+ }
202
+ }))
203
+ };
204
+ const tool = (0, import_edit_file_tool.createEditFileTool)(import_vitest.vi.fn());
205
+ await (0, import_vitest.expect)(
206
+ tool.execute(
207
+ tool.inputSchema.parse({
208
+ file_path: "/outside/workspace.txt",
209
+ old_string: "world",
210
+ new_string: "workspace"
211
+ }),
212
+ createToolContext(mockLogger, {
213
+ services: {
214
+ ...createWorkspaceServices(tempDir),
215
+ workspaceManager
216
+ }
217
+ })
218
+ )
219
+ ).rejects.toMatchObject({ code: import_core.ToolErrorCode.VALIDATION_FAILED });
220
+ (0, import_vitest.expect)(readText).not.toHaveBeenCalled();
221
+ (0, import_vitest.expect)(writeFile).not.toHaveBeenCalled();
222
+ });
223
+ (0, import_vitest.it)("should describe edit calls with an Edit header", () => {
224
+ const tool = (0, import_edit_file_tool.createEditFileTool)(async () => fileSystemService);
225
+ const header = tool.presentation?.describeHeader?.(
226
+ tool.inputSchema.parse({
227
+ file_path: "snake.py",
228
+ old_string: "old",
229
+ new_string: "new"
230
+ }),
231
+ createToolContext(mockLogger)
232
+ );
233
+ (0, import_vitest.expect)(header).toEqual({
234
+ argsText: "snake.py",
235
+ title: "Edit"
236
+ });
237
+ });
81
238
  (0, import_vitest.describe)("File Modification Detection", () => {
82
239
  (0, import_vitest.it)("should generate preview for files outside config-allowed roots (preview read only)", async () => {
83
240
  const tool = (0, import_edit_file_tool.createEditFileTool)(async () => fileSystemService);
@@ -23,8 +23,49 @@ const createMockLogger = () => {
23
23
  };
24
24
  return logger;
25
25
  };
26
- function createToolContext(logger, overrides = {}) {
27
- return { logger, ...overrides };
26
+ function createToolContext(logger, overrides = {}, workspaceRoot = currentWorkspaceRoot) {
27
+ return {
28
+ logger,
29
+ services: createWorkspaceServices(workspaceRoot),
30
+ ...overrides
31
+ };
32
+ }
33
+ let currentWorkspaceRoot = process.cwd();
34
+ function createWorkspaceServices(workspaceRoot) {
35
+ const workspaceManager = {
36
+ open: vi.fn(async () => ({
37
+ context: {
38
+ id: "test-workspace",
39
+ path: workspaceRoot,
40
+ createdAt: Date.now(),
41
+ lastActiveAt: Date.now()
42
+ },
43
+ capabilities: ["files"],
44
+ files: {
45
+ readFile: async (filePath) => fs.readFile(resolveWorkspacePath(workspaceRoot, filePath), "utf-8"),
46
+ readText: async (filePath) => fs.readFile(resolveWorkspacePath(workspaceRoot, filePath), "utf-8"),
47
+ glob: vi.fn(async () => []),
48
+ writeFile: async (filePath, content) => {
49
+ const resolvedPath = resolveWorkspacePath(workspaceRoot, filePath);
50
+ await fs.writeFile(resolvedPath, content, "utf-8");
51
+ },
52
+ listFiles: vi.fn(async () => [])
53
+ }
54
+ }))
55
+ };
56
+ return {
57
+ approval: {},
58
+ search: {},
59
+ resources: {},
60
+ prompts: {},
61
+ skills: {},
62
+ mcp: {},
63
+ taskForker: null,
64
+ workspaceManager
65
+ };
66
+ }
67
+ function resolveWorkspacePath(workspaceRoot, filePath) {
68
+ return path.isAbsolute(filePath) ? filePath : path.join(workspaceRoot, filePath);
28
69
  }
29
70
  describe("edit_file tool", () => {
30
71
  let mockLogger;
@@ -47,6 +88,7 @@ describe("edit_file tool", () => {
47
88
  mockLogger
48
89
  );
49
90
  await fileSystemService.initialize();
91
+ currentWorkspaceRoot = tempDir;
50
92
  vi.clearAllMocks();
51
93
  });
52
94
  afterEach(async () => {
@@ -55,6 +97,121 @@ describe("edit_file tool", () => {
55
97
  } catch {
56
98
  }
57
99
  });
100
+ it("edits through WorkspaceManager.open without FileSystemService execution", async () => {
101
+ await fs.writeFile(path.join(tempDir, "workspace.txt"), "hello world");
102
+ const getFileSystemService = vi.fn(async () => {
103
+ throw new Error("edit_file execute must not use FileSystemService");
104
+ });
105
+ const tool = createEditFileTool(getFileSystemService);
106
+ const parsedInput = tool.inputSchema.parse({
107
+ file_path: "workspace.txt",
108
+ old_string: "world",
109
+ new_string: "workspace"
110
+ });
111
+ const result = await tool.execute(parsedInput, createToolContext(mockLogger));
112
+ expect(getFileSystemService).not.toHaveBeenCalled();
113
+ expect(result).toMatchObject({
114
+ success: true,
115
+ path: "workspace.txt",
116
+ changes_count: 1
117
+ });
118
+ await expect(fs.readFile(path.join(tempDir, "workspace.txt"), "utf-8")).resolves.toBe(
119
+ "hello workspace"
120
+ );
121
+ });
122
+ it("normalizes workspace-contained absolute paths before editing", async () => {
123
+ const readText = vi.fn(async () => "hello world");
124
+ const writeFile = vi.fn(async () => void 0);
125
+ const workspaceManager = {
126
+ open: vi.fn(async () => ({
127
+ context: {
128
+ id: "test-workspace",
129
+ path: tempDir,
130
+ createdAt: Date.now(),
131
+ lastActiveAt: Date.now()
132
+ },
133
+ capabilities: ["files"],
134
+ files: {
135
+ readFile: readText,
136
+ readText,
137
+ glob: vi.fn(async () => []),
138
+ writeFile,
139
+ listFiles: vi.fn(async () => [])
140
+ }
141
+ }))
142
+ };
143
+ const tool = createEditFileTool(vi.fn());
144
+ await tool.execute(
145
+ tool.inputSchema.parse({
146
+ file_path: path.join(tempDir, "workspace.txt"),
147
+ old_string: "world",
148
+ new_string: "workspace"
149
+ }),
150
+ createToolContext(mockLogger, {
151
+ services: {
152
+ ...createWorkspaceServices(tempDir),
153
+ workspaceManager
154
+ }
155
+ })
156
+ );
157
+ expect(readText).toHaveBeenCalledWith("workspace.txt");
158
+ expect(writeFile).toHaveBeenCalledWith("workspace.txt", "hello workspace");
159
+ });
160
+ it("rejects external absolute paths before file provider calls", async () => {
161
+ const readText = vi.fn(async () => "hello world");
162
+ const writeFile = vi.fn(async () => void 0);
163
+ const workspaceManager = {
164
+ open: vi.fn(async () => ({
165
+ context: {
166
+ id: "test-workspace",
167
+ path: tempDir,
168
+ createdAt: Date.now(),
169
+ lastActiveAt: Date.now()
170
+ },
171
+ capabilities: ["files"],
172
+ files: {
173
+ readFile: readText,
174
+ readText,
175
+ glob: vi.fn(async () => []),
176
+ writeFile,
177
+ listFiles: vi.fn(async () => [])
178
+ }
179
+ }))
180
+ };
181
+ const tool = createEditFileTool(vi.fn());
182
+ await expect(
183
+ tool.execute(
184
+ tool.inputSchema.parse({
185
+ file_path: "/outside/workspace.txt",
186
+ old_string: "world",
187
+ new_string: "workspace"
188
+ }),
189
+ createToolContext(mockLogger, {
190
+ services: {
191
+ ...createWorkspaceServices(tempDir),
192
+ workspaceManager
193
+ }
194
+ })
195
+ )
196
+ ).rejects.toMatchObject({ code: ToolErrorCode.VALIDATION_FAILED });
197
+ expect(readText).not.toHaveBeenCalled();
198
+ expect(writeFile).not.toHaveBeenCalled();
199
+ });
200
+ it("should describe edit calls with an Edit header", () => {
201
+ const tool = createEditFileTool(async () => fileSystemService);
202
+ const header = tool.presentation?.describeHeader?.(
203
+ tool.inputSchema.parse({
204
+ file_path: "snake.py",
205
+ old_string: "old",
206
+ new_string: "new"
207
+ }),
208
+ createToolContext(mockLogger)
209
+ );
210
+ expect(header).toEqual({
211
+ argsText: "snake.py",
212
+ title: "Edit"
213
+ });
214
+ });
58
215
  describe("File Modification Detection", () => {
59
216
  it("should generate preview for files outside config-allowed roots (preview read only)", async () => {
60
217
  const tool = createEditFileTool(async () => fileSystemService);