protoagent 0.0.5 → 0.1.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.
- package/README.md +99 -19
- package/dist/App.js +602 -0
- package/dist/agentic-loop.js +492 -525
- package/dist/cli.js +39 -0
- package/dist/components/CollapsibleBox.js +26 -0
- package/dist/components/ConfigDialog.js +40 -0
- package/dist/components/ConsolidatedToolMessage.js +41 -0
- package/dist/components/FormattedMessage.js +93 -0
- package/dist/components/Table.js +275 -0
- package/dist/config.js +171 -0
- package/dist/mcp.js +170 -0
- package/dist/providers.js +137 -0
- package/dist/sessions.js +161 -0
- package/dist/skills.js +229 -0
- package/dist/sub-agent.js +103 -0
- package/dist/system-prompt.js +131 -0
- package/dist/tools/bash.js +178 -0
- package/dist/tools/edit-file.js +65 -171
- package/dist/tools/index.js +79 -134
- package/dist/tools/list-directory.js +20 -73
- package/dist/tools/read-file.js +57 -101
- package/dist/tools/search-files.js +74 -162
- package/dist/tools/todo.js +57 -140
- package/dist/tools/webfetch.js +310 -0
- package/dist/tools/write-file.js +44 -135
- package/dist/utils/approval.js +69 -0
- package/dist/utils/compactor.js +87 -0
- package/dist/utils/cost-tracker.js +26 -81
- package/dist/utils/format-message.js +26 -0
- package/dist/utils/logger.js +101 -307
- package/dist/utils/path-validation.js +74 -0
- package/package.json +45 -51
- package/LICENSE +0 -21
- package/dist/config/client.js +0 -315
- package/dist/config/commands.js +0 -223
- package/dist/config/manager.js +0 -117
- package/dist/config/mcp-commands.js +0 -266
- package/dist/config/mcp-manager.js +0 -240
- package/dist/config/mcp-types.js +0 -28
- package/dist/config/providers.js +0 -229
- package/dist/config/setup.js +0 -209
- package/dist/config/system-prompt.js +0 -397
- package/dist/config/types.js +0 -4
- package/dist/index.js +0 -229
- package/dist/tools/create-directory.js +0 -76
- package/dist/tools/directory-operations.js +0 -195
- package/dist/tools/file-operations.js +0 -211
- package/dist/tools/run-shell-command.js +0 -746
- package/dist/tools/search-operations.js +0 -179
- package/dist/tools/shell-operations.js +0 -342
- package/dist/tools/task-complete.js +0 -26
- package/dist/tools/view-directory-tree.js +0 -125
- package/dist/tools.js +0 -2
- package/dist/utils/conversation-compactor.js +0 -140
- package/dist/utils/enhanced-prompt.js +0 -23
- package/dist/utils/file-operations-approval.js +0 -373
- package/dist/utils/interrupt-handler.js +0 -127
- package/dist/utils/user-cancellation.js +0 -34
package/dist/tools/edit-file.js
CHANGED
|
@@ -1,181 +1,75 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
const workingDirectory = process.cwd();
|
|
9
|
-
// Security utilities
|
|
10
|
-
function normalizePath(p) {
|
|
11
|
-
return path.normalize(p);
|
|
12
|
-
}
|
|
13
|
-
async function validatePath(requestedPath) {
|
|
14
|
-
const absolute = path.isAbsolute(requestedPath)
|
|
15
|
-
? path.resolve(requestedPath)
|
|
16
|
-
: path.resolve(workingDirectory, requestedPath);
|
|
17
|
-
const normalizedRequested = normalizePath(absolute);
|
|
18
|
-
// Check if path is within working directory
|
|
19
|
-
if (!normalizedRequested.startsWith(workingDirectory)) {
|
|
20
|
-
throw new Error(`Access denied - path outside working directory: ${absolute}`);
|
|
21
|
-
}
|
|
22
|
-
// Handle symlinks by checking their real path
|
|
23
|
-
try {
|
|
24
|
-
const realPath = await fs.realpath(absolute);
|
|
25
|
-
const normalizedReal = normalizePath(realPath);
|
|
26
|
-
if (!normalizedReal.startsWith(workingDirectory)) {
|
|
27
|
-
throw new Error(`Access denied - symlink target outside working directory: ${realPath}`);
|
|
28
|
-
}
|
|
29
|
-
return realPath;
|
|
30
|
-
}
|
|
31
|
-
catch (error) {
|
|
32
|
-
// For new files that don't exist yet, verify parent directory
|
|
33
|
-
if (error.code === 'ENOENT') {
|
|
34
|
-
const parentDir = path.dirname(absolute);
|
|
35
|
-
try {
|
|
36
|
-
const realParentPath = await fs.realpath(parentDir);
|
|
37
|
-
const normalizedParent = normalizePath(realParentPath);
|
|
38
|
-
if (!normalizedParent.startsWith(workingDirectory)) {
|
|
39
|
-
throw new Error(`Access denied - parent directory outside working directory: ${realParentPath}`);
|
|
40
|
-
}
|
|
41
|
-
return absolute;
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
throw new Error(`Parent directory does not exist: ${parentDir}`);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
throw error;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
export async function editFile(filePath, oldString, newString, expectedReplacements) {
|
|
51
|
-
try {
|
|
52
|
-
const validPath = await validatePath(filePath);
|
|
53
|
-
// Read current file content
|
|
54
|
-
const currentContent = await fs.readFile(validPath, 'utf-8');
|
|
55
|
-
// Check if the old string exists in the file
|
|
56
|
-
if (!currentContent.includes(oldString)) {
|
|
57
|
-
throw new Error(`Old string not found in file. Make sure the text matches exactly, including whitespace and line breaks.`);
|
|
58
|
-
}
|
|
59
|
-
// Count occurrences of the old string
|
|
60
|
-
const occurrences = (currentContent.match(new RegExp(escapeRegExp(oldString), 'g')) || []).length;
|
|
61
|
-
// If expected replacements is specified, validate it
|
|
62
|
-
if (expectedReplacements !== undefined) {
|
|
63
|
-
if (occurrences !== expectedReplacements) {
|
|
64
|
-
throw new Error(`Expected ${expectedReplacements} occurrences of the old string, but found ${occurrences}. This prevents accidental multiple replacements.`);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
else if (occurrences > 1) {
|
|
68
|
-
throw new Error(`Found ${occurrences} occurrences of the old string. Use expected_replacements parameter to confirm you want to replace all ${occurrences} occurrences, or make your old_string more specific to match only the intended text.`);
|
|
69
|
-
}
|
|
70
|
-
// Perform the replacement to show preview
|
|
71
|
-
const updatedContent = currentContent.replace(new RegExp(escapeRegExp(oldString), 'g'), newString);
|
|
72
|
-
// Verify that the replacement actually changed something
|
|
73
|
-
if (updatedContent === currentContent) {
|
|
74
|
-
throw new Error(`No changes were made to the file. The old_string and new_string appear to be identical.`);
|
|
75
|
-
}
|
|
76
|
-
// Calculate change context for better preview
|
|
77
|
-
const oldLines = currentContent.split('\n');
|
|
78
|
-
const newLines = updatedContent.split('\n');
|
|
79
|
-
const changeContext = {
|
|
80
|
-
linesAdded: newLines.length - oldLines.length,
|
|
81
|
-
linesRemoved: oldLines.length > newLines.length ? oldLines.length - newLines.length : 0,
|
|
82
|
-
totalLines: newLines.length,
|
|
83
|
-
affectedLineNumbers: findAffectedLineNumbers(oldString, currentContent)
|
|
84
|
-
};
|
|
85
|
-
// Request user approval for edit operation
|
|
86
|
-
const replacementCount = expectedReplacements || 1;
|
|
87
|
-
const approvalContext = {
|
|
88
|
-
operation: 'edit',
|
|
89
|
-
filePath: filePath,
|
|
90
|
-
description: `Replace ${replacementCount} occurrence${replacementCount === 1 ? '' : 's'} of text (${oldString.length} → ${newString.length} chars)`,
|
|
91
|
-
contentPreview: createEditPreview(oldString, newString),
|
|
92
|
-
oldContent: currentContent,
|
|
93
|
-
newContent: updatedContent,
|
|
94
|
-
changeContext
|
|
95
|
-
};
|
|
96
|
-
// Request user approval for edit operation (throws UserCancellationError if cancelled)
|
|
97
|
-
await requestFileOperationApproval(approvalContext);
|
|
98
|
-
logger.debug(`✏️ Editing file: ${filePath} (${replacementCount} replacement${replacementCount === 1 ? '' : 's'})`, { component: 'EditFile', operation: 'editFile' });
|
|
99
|
-
// Write the updated content using atomic operation
|
|
100
|
-
const tempPath = `${validPath}.${randomBytes(16).toString('hex')}.tmp`;
|
|
101
|
-
try {
|
|
102
|
-
await fs.writeFile(tempPath, updatedContent, 'utf-8');
|
|
103
|
-
await fs.rename(tempPath, validPath);
|
|
104
|
-
logger.info(`✅ Successfully edited: ${filePath} (${replacementCount} replacement${replacementCount === 1 ? '' : 's'})`, { component: 'EditFile', operation: 'editFile' });
|
|
105
|
-
}
|
|
106
|
-
catch (renameError) {
|
|
107
|
-
try {
|
|
108
|
-
await fs.unlink(tempPath);
|
|
109
|
-
}
|
|
110
|
-
catch { }
|
|
111
|
-
throw renameError;
|
|
112
|
-
}
|
|
113
|
-
return `Successfully edited ${filePath} - replaced ${replacementCount} occurrence${replacementCount === 1 ? '' : 's'} of the specified text`;
|
|
114
|
-
}
|
|
115
|
-
catch (error) {
|
|
116
|
-
// Re-throw UserCancellationError without modification
|
|
117
|
-
if (isUserCancellation(error)) {
|
|
118
|
-
throw error;
|
|
119
|
-
}
|
|
120
|
-
if (error instanceof Error) {
|
|
121
|
-
throw new Error(`Failed to edit file: ${error.message}`);
|
|
122
|
-
}
|
|
123
|
-
throw new Error('Failed to edit file: Unknown error');
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
// Helper function to escape special regex characters
|
|
127
|
-
function escapeRegExp(string) {
|
|
128
|
-
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
129
|
-
}
|
|
130
|
-
// Helper function to find affected line numbers
|
|
131
|
-
function findAffectedLineNumbers(searchString, content) {
|
|
132
|
-
const lines = content.split('\n');
|
|
133
|
-
const affectedLines = [];
|
|
134
|
-
lines.forEach((line, index) => {
|
|
135
|
-
if (line.includes(searchString)) {
|
|
136
|
-
affectedLines.push(index + 1); // Line numbers are 1-based
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
return affectedLines;
|
|
140
|
-
}
|
|
141
|
-
// Helper function to create a concise edit preview
|
|
142
|
-
function createEditPreview(oldText, newText) {
|
|
143
|
-
const maxLength = 150;
|
|
144
|
-
const oldPreview = oldText.length > maxLength
|
|
145
|
-
? `${oldText.substring(0, maxLength)}...`
|
|
146
|
-
: oldText;
|
|
147
|
-
const newPreview = newText.length > maxLength
|
|
148
|
-
? `${newText.substring(0, maxLength)}...`
|
|
149
|
-
: newText;
|
|
150
|
-
return `REMOVE: "${oldPreview}"\nADD: "${newPreview}"`;
|
|
151
|
-
}
|
|
152
|
-
// Tool definition
|
|
1
|
+
/**
|
|
2
|
+
* edit_file tool — Find-and-replace in an existing file. Requires approval.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { validatePath } from '../utils/path-validation.js';
|
|
7
|
+
import { requestApproval } from '../utils/approval.js';
|
|
153
8
|
export const editFileTool = {
|
|
154
9
|
type: 'function',
|
|
155
10
|
function: {
|
|
156
11
|
name: 'edit_file',
|
|
157
|
-
description: 'Edit an existing file by replacing
|
|
12
|
+
description: 'Edit an existing file by replacing an exact string match with new content. ' +
|
|
13
|
+
'The old_string must match exactly (including whitespace and indentation). ' +
|
|
14
|
+
'Always read the file first to get the exact content to replace.',
|
|
158
15
|
parameters: {
|
|
159
16
|
type: 'object',
|
|
160
17
|
properties: {
|
|
161
|
-
file_path: {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
},
|
|
165
|
-
old_string: {
|
|
166
|
-
type: 'string',
|
|
167
|
-
description: 'The exact text to find and replace. Must match exactly, including whitespace, indentation, and line breaks. For better precision, include some surrounding context.'
|
|
168
|
-
},
|
|
169
|
-
new_string: {
|
|
170
|
-
type: 'string',
|
|
171
|
-
description: 'The new text to replace the old_string with. Can be empty string to delete text.'
|
|
172
|
-
},
|
|
18
|
+
file_path: { type: 'string', description: 'Path to the file to edit.' },
|
|
19
|
+
old_string: { type: 'string', description: 'The exact text to find and replace.' },
|
|
20
|
+
new_string: { type: 'string', description: 'The text to replace it with.' },
|
|
173
21
|
expected_replacements: {
|
|
174
|
-
type: '
|
|
175
|
-
description: '
|
|
176
|
-
}
|
|
22
|
+
type: 'number',
|
|
23
|
+
description: 'Expected number of replacements (default 1). Fails if actual count differs.',
|
|
24
|
+
},
|
|
177
25
|
},
|
|
178
|
-
required: ['file_path', 'old_string', 'new_string']
|
|
179
|
-
}
|
|
180
|
-
}
|
|
26
|
+
required: ['file_path', 'old_string', 'new_string'],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
181
29
|
};
|
|
30
|
+
export async function editFile(filePath, oldString, newString, expectedReplacements = 1, sessionId) {
|
|
31
|
+
if (oldString.length === 0) {
|
|
32
|
+
return 'Error: old_string cannot be empty.';
|
|
33
|
+
}
|
|
34
|
+
const validated = await validatePath(filePath);
|
|
35
|
+
const content = await fs.readFile(validated, 'utf8');
|
|
36
|
+
// Count occurrences
|
|
37
|
+
let count = 0;
|
|
38
|
+
let idx = 0;
|
|
39
|
+
while ((idx = content.indexOf(oldString, idx)) !== -1) {
|
|
40
|
+
count++;
|
|
41
|
+
idx += oldString.length;
|
|
42
|
+
}
|
|
43
|
+
if (count === 0) {
|
|
44
|
+
return `Error: old_string not found in ${filePath}. Make sure you read the file first and use the exact text.`;
|
|
45
|
+
}
|
|
46
|
+
if (count !== expectedReplacements) {
|
|
47
|
+
return `Error: found ${count} occurrence(s) of old_string, but expected ${expectedReplacements}. Be more specific or set expected_replacements=${count}.`;
|
|
48
|
+
}
|
|
49
|
+
// Request approval
|
|
50
|
+
const oldPreview = oldString.length > 200 ? oldString.slice(0, 200) + '...' : oldString;
|
|
51
|
+
const newPreview = newString.length > 200 ? newString.slice(0, 200) + '...' : newString;
|
|
52
|
+
const approved = await requestApproval({
|
|
53
|
+
id: `edit-${Date.now()}`,
|
|
54
|
+
type: 'file_edit',
|
|
55
|
+
description: `Edit file: ${filePath} (${count} replacement${count > 1 ? 's' : ''})`,
|
|
56
|
+
detail: `Replace:\n${oldPreview}\n\nWith:\n${newPreview}`,
|
|
57
|
+
sessionId,
|
|
58
|
+
sessionScopeKey: `file_edit:${validated}`,
|
|
59
|
+
});
|
|
60
|
+
if (!approved) {
|
|
61
|
+
return `Operation cancelled: edit to ${filePath} was rejected by user.`;
|
|
62
|
+
}
|
|
63
|
+
// Perform replacement
|
|
64
|
+
const newContent = content.split(oldString).join(newString);
|
|
65
|
+
const directory = path.dirname(validated);
|
|
66
|
+
const tempPath = path.join(directory, `.protoagent-edit-${process.pid}-${Date.now()}-${path.basename(validated)}`);
|
|
67
|
+
try {
|
|
68
|
+
await fs.writeFile(tempPath, newContent, 'utf8');
|
|
69
|
+
await fs.rename(tempPath, validated);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
73
|
+
}
|
|
74
|
+
return `Successfully edited ${filePath}: ${count} replacement(s) made.`;
|
|
75
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -1,152 +1,97 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
import { readFile } from './read-file.js';
|
|
22
|
-
import { writeFile } from './write-file.js';
|
|
23
|
-
import { editFile } from './edit-file.js';
|
|
24
|
-
import { listDirectory } from './list-directory.js';
|
|
25
|
-
import { createDirectory } from './create-directory.js';
|
|
26
|
-
import { viewDirectoryTree } from './view-directory-tree.js';
|
|
27
|
-
import { searchFiles } from './search-files.js';
|
|
28
|
-
import { runShellCommand } from './run-shell-command.js';
|
|
29
|
-
import { logger } from '../utils/logger.js';
|
|
30
|
-
import { isUserCancellation } from '../utils/user-cancellation.js';
|
|
31
|
-
// Consolidated tool definitions
|
|
1
|
+
/**
|
|
2
|
+
* Tool registry — collects all tool definitions and provides a dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* Each tool file exports:
|
|
5
|
+
* - A tool definition (OpenAI function-calling JSON schema)
|
|
6
|
+
* - A handler function (args) => Promise<string>
|
|
7
|
+
*
|
|
8
|
+
* This file wires them together into a single `tools` array and
|
|
9
|
+
* a `handleToolCall(name, args)` dispatcher.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileTool, readFile } from './read-file.js';
|
|
12
|
+
import { writeFileTool, writeFile } from './write-file.js';
|
|
13
|
+
import { editFileTool, editFile } from './edit-file.js';
|
|
14
|
+
import { listDirectoryTool, listDirectory } from './list-directory.js';
|
|
15
|
+
import { searchFilesTool, searchFiles } from './search-files.js';
|
|
16
|
+
import { bashTool, runBash } from './bash.js';
|
|
17
|
+
import { todoReadTool, todoWriteTool, readTodos, writeTodos } from './todo.js';
|
|
18
|
+
import { webfetchTool, webfetch } from './webfetch.js';
|
|
19
|
+
export { setDangerouslyAcceptAll, setApprovalHandler, clearApprovalHandler } from '../utils/approval.js';
|
|
20
|
+
// All tool definitions — passed to the LLM
|
|
32
21
|
export const tools = [
|
|
33
22
|
readFileTool,
|
|
34
23
|
writeFileTool,
|
|
35
24
|
editFileTool,
|
|
36
25
|
listDirectoryTool,
|
|
37
|
-
createDirectoryTool,
|
|
38
|
-
viewDirectoryTreeTool,
|
|
39
26
|
searchFilesTool,
|
|
40
|
-
|
|
27
|
+
bashTool,
|
|
28
|
+
todoReadTool,
|
|
29
|
+
todoWriteTool,
|
|
30
|
+
webfetchTool,
|
|
41
31
|
];
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
32
|
+
// Mutable tools list — MCP and sub-agent tools get appended at runtime
|
|
33
|
+
let dynamicTools = [];
|
|
34
|
+
export function registerDynamicTool(tool) {
|
|
35
|
+
const toolName = tool.function.name;
|
|
36
|
+
dynamicTools = dynamicTools.filter((existing) => existing.function.name !== toolName);
|
|
37
|
+
dynamicTools.push(tool);
|
|
38
|
+
}
|
|
39
|
+
export function unregisterDynamicTool(toolName) {
|
|
40
|
+
dynamicTools = dynamicTools.filter((tool) => tool.function.name !== toolName);
|
|
41
|
+
}
|
|
42
|
+
export function clearDynamicTools() {
|
|
43
|
+
dynamicTools = [];
|
|
44
|
+
}
|
|
45
|
+
export function getAllTools() {
|
|
46
|
+
return [...tools, ...dynamicTools];
|
|
47
|
+
}
|
|
48
|
+
// Dynamic tool handlers (for MCP tools, etc.)
|
|
49
|
+
const dynamicHandlers = new Map();
|
|
50
|
+
export function registerDynamicHandler(name, handler) {
|
|
51
|
+
dynamicHandlers.set(name, handler);
|
|
52
|
+
}
|
|
53
|
+
export function unregisterDynamicHandler(name) {
|
|
54
|
+
dynamicHandlers.delete(name);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Dispatch a tool call to the appropriate handler.
|
|
58
|
+
* Returns the tool result as a string.
|
|
59
|
+
*/
|
|
60
|
+
export async function handleToolCall(toolName, args, context = {}) {
|
|
49
61
|
try {
|
|
50
|
-
const startTime = Date.now();
|
|
51
|
-
let result;
|
|
52
62
|
switch (toolName) {
|
|
53
63
|
case 'read_file':
|
|
54
|
-
|
|
55
|
-
const content = await readFile(args.file_path, args.offset, args.limit);
|
|
56
|
-
result = `File content of ${args.file_path}:\n\n${content}`;
|
|
57
|
-
break;
|
|
64
|
+
return await readFile(args.file_path, args.offset, args.limit);
|
|
58
65
|
case 'write_file':
|
|
59
|
-
|
|
60
|
-
result = await writeFile(args.file_path, args.content);
|
|
61
|
-
break;
|
|
66
|
+
return await writeFile(args.file_path, args.content, context.sessionId);
|
|
62
67
|
case 'edit_file':
|
|
63
|
-
|
|
64
|
-
component: 'ToolHandler',
|
|
65
|
-
filePath: args.file_path,
|
|
66
|
-
oldStringLength: args.old_string?.length,
|
|
67
|
-
newStringLength: args.new_string?.length,
|
|
68
|
-
expectedReplacements: args.expected_replacements
|
|
69
|
-
});
|
|
70
|
-
result = await editFile(args.file_path, args.old_string, args.new_string, args.expected_replacements);
|
|
71
|
-
break;
|
|
68
|
+
return await editFile(args.file_path, args.old_string, args.new_string, args.expected_replacements, context.sessionId);
|
|
72
69
|
case 'list_directory':
|
|
73
|
-
|
|
74
|
-
const listResult = await listDirectory(args.directory_path);
|
|
75
|
-
result = `Contents of ${args.directory_path}:\n\n${listResult}`;
|
|
76
|
-
break;
|
|
77
|
-
case 'create_directory':
|
|
78
|
-
logger.debug('📂 Executing create_directory', { component: 'ToolHandler', directoryPath: args.directory_path });
|
|
79
|
-
result = await createDirectory(args.directory_path);
|
|
80
|
-
break;
|
|
81
|
-
case 'view_directory_tree':
|
|
82
|
-
logger.debug('🌳 Executing view_directory_tree', {
|
|
83
|
-
component: 'ToolHandler',
|
|
84
|
-
directoryPath: args.directory_path,
|
|
85
|
-
maxDepth: args.max_depth
|
|
86
|
-
});
|
|
87
|
-
result = await viewDirectoryTree(args.directory_path, args.max_depth);
|
|
88
|
-
break;
|
|
70
|
+
return await listDirectory(args.directory_path);
|
|
89
71
|
case 'search_files':
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
default:
|
|
110
|
-
logger.error('❌ Unknown tool requested', { component: 'ToolHandler', toolName });
|
|
111
|
-
result = `Error: Unknown tool ${toolName}`;
|
|
72
|
+
return await searchFiles(args.search_term, args.directory_path, args.case_sensitive, args.file_extensions);
|
|
73
|
+
case 'bash':
|
|
74
|
+
return await runBash(args.command, args.timeout_ms, context.sessionId);
|
|
75
|
+
case 'todo_read':
|
|
76
|
+
return readTodos(context.sessionId);
|
|
77
|
+
case 'todo_write':
|
|
78
|
+
return writeTodos(args.todos, context.sessionId);
|
|
79
|
+
case 'webfetch': {
|
|
80
|
+
const result = await webfetch(args.url, args.format, args.timeout);
|
|
81
|
+
return JSON.stringify(result);
|
|
82
|
+
}
|
|
83
|
+
default: {
|
|
84
|
+
// Check dynamic handlers (MCP tools, sub-agent tools)
|
|
85
|
+
const handler = dynamicHandlers.get(toolName);
|
|
86
|
+
if (handler) {
|
|
87
|
+
return await handler(args);
|
|
88
|
+
}
|
|
89
|
+
return `Error: Unknown tool "${toolName}"`;
|
|
90
|
+
}
|
|
112
91
|
}
|
|
113
|
-
const executionTime = Date.now() - startTime;
|
|
114
|
-
logger.debug('✅ Tool execution completed', {
|
|
115
|
-
component: 'ToolHandler',
|
|
116
|
-
toolName,
|
|
117
|
-
executionTime,
|
|
118
|
-
resultLength: result.length,
|
|
119
|
-
success: !result.startsWith('Error:')
|
|
120
|
-
});
|
|
121
|
-
return result;
|
|
122
92
|
}
|
|
123
|
-
catch (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
logger.debug('🚪 Re-throwing UserCancellationError for proper handling', {
|
|
127
|
-
component: 'ToolHandler',
|
|
128
|
-
toolName,
|
|
129
|
-
cancellationReason: error.message
|
|
130
|
-
});
|
|
131
|
-
throw error; // Re-throw to let the agentic loop handle it
|
|
132
|
-
}
|
|
133
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
134
|
-
logger.error('❌ Tool execution failed with exception', {
|
|
135
|
-
component: 'ToolHandler',
|
|
136
|
-
toolName,
|
|
137
|
-
error: errorMessage
|
|
138
|
-
});
|
|
139
|
-
return `Error executing ${toolName}: ${errorMessage}`;
|
|
93
|
+
catch (err) {
|
|
94
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
95
|
+
return `Error executing ${toolName}: ${msg}`;
|
|
140
96
|
}
|
|
141
97
|
}
|
|
142
|
-
// Convenience functions for config setup
|
|
143
|
-
import { setShellConfig, setDangerouslyAcceptAll } from './run-shell-command.js';
|
|
144
|
-
import { setFileOperationConfig, setDangerouslyAcceptAllFileOps } from '../utils/file-operations-approval.js';
|
|
145
|
-
export function setToolsConfig(config) {
|
|
146
|
-
setShellConfig(config);
|
|
147
|
-
setFileOperationConfig(config);
|
|
148
|
-
}
|
|
149
|
-
export function setToolsDangerouslyAcceptAll(accept) {
|
|
150
|
-
setDangerouslyAcceptAll(accept);
|
|
151
|
-
setDangerouslyAcceptAllFileOps(accept);
|
|
152
|
-
}
|
|
@@ -1,84 +1,31 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
function normalizePath(p) {
|
|
7
|
-
return path.normalize(p);
|
|
8
|
-
}
|
|
9
|
-
async function validatePath(requestedPath) {
|
|
10
|
-
const absolute = path.isAbsolute(requestedPath)
|
|
11
|
-
? path.resolve(requestedPath)
|
|
12
|
-
: path.resolve(workingDirectory, requestedPath);
|
|
13
|
-
const normalizedRequested = normalizePath(absolute);
|
|
14
|
-
// Check if path is within working directory
|
|
15
|
-
if (!normalizedRequested.startsWith(workingDirectory)) {
|
|
16
|
-
throw new Error(`Access denied - path outside working directory: ${absolute}`);
|
|
17
|
-
}
|
|
18
|
-
// Handle symlinks by checking their real path
|
|
19
|
-
try {
|
|
20
|
-
const realPath = await fs.realpath(absolute);
|
|
21
|
-
const normalizedReal = normalizePath(realPath);
|
|
22
|
-
if (!normalizedReal.startsWith(workingDirectory)) {
|
|
23
|
-
throw new Error(`Access denied - symlink target outside working directory: ${realPath}`);
|
|
24
|
-
}
|
|
25
|
-
return realPath;
|
|
26
|
-
}
|
|
27
|
-
catch (error) {
|
|
28
|
-
// For new files that don't exist yet, verify parent directory
|
|
29
|
-
if (error.code === 'ENOENT') {
|
|
30
|
-
const parentDir = path.dirname(absolute);
|
|
31
|
-
try {
|
|
32
|
-
const realParentPath = await fs.realpath(parentDir);
|
|
33
|
-
const normalizedParent = normalizePath(realParentPath);
|
|
34
|
-
if (!normalizedParent.startsWith(workingDirectory)) {
|
|
35
|
-
throw new Error(`Access denied - parent directory outside working directory: ${realParentPath}`);
|
|
36
|
-
}
|
|
37
|
-
return absolute;
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
throw new Error(`Parent directory does not exist: ${parentDir}`);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
throw error;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
export async function listDirectory(dirPath) {
|
|
47
|
-
try {
|
|
48
|
-
const validPath = await validatePath(dirPath);
|
|
49
|
-
// Check if path exists and is a directory
|
|
50
|
-
const stats = await fs.stat(validPath);
|
|
51
|
-
if (!stats.isDirectory()) {
|
|
52
|
-
throw new Error('Path is not a directory');
|
|
53
|
-
}
|
|
54
|
-
const entries = await fs.readdir(validPath, { withFileTypes: true });
|
|
55
|
-
const formatted = entries
|
|
56
|
-
.map((entry) => `${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}`)
|
|
57
|
-
.join("\n");
|
|
58
|
-
return formatted || "Directory is empty";
|
|
59
|
-
}
|
|
60
|
-
catch (error) {
|
|
61
|
-
if (error instanceof Error) {
|
|
62
|
-
throw new Error(`Failed to list directory: ${error.message}`);
|
|
63
|
-
}
|
|
64
|
-
throw new Error('Failed to list directory: Unknown error');
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
// Tool definition
|
|
1
|
+
/**
|
|
2
|
+
* list_directory tool — List contents of a directory.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import { validatePath } from '../utils/path-validation.js';
|
|
68
6
|
export const listDirectoryTool = {
|
|
69
7
|
type: 'function',
|
|
70
8
|
function: {
|
|
71
9
|
name: 'list_directory',
|
|
72
|
-
description: 'List
|
|
10
|
+
description: 'List the contents of a directory. Returns entries with [FILE] or [DIR] prefixes.',
|
|
73
11
|
parameters: {
|
|
74
12
|
type: 'object',
|
|
75
13
|
properties: {
|
|
76
14
|
directory_path: {
|
|
77
15
|
type: 'string',
|
|
78
|
-
description: '
|
|
79
|
-
}
|
|
16
|
+
description: 'Path to the directory to list (relative to working directory). Defaults to ".".',
|
|
17
|
+
},
|
|
80
18
|
},
|
|
81
|
-
required: [
|
|
82
|
-
}
|
|
83
|
-
}
|
|
19
|
+
required: [],
|
|
20
|
+
},
|
|
21
|
+
},
|
|
84
22
|
};
|
|
23
|
+
export async function listDirectory(directoryPath = '.') {
|
|
24
|
+
const validated = await validatePath(directoryPath);
|
|
25
|
+
const entries = await fs.readdir(validated, { withFileTypes: true });
|
|
26
|
+
const lines = entries.map((entry) => {
|
|
27
|
+
const prefix = entry.isDirectory() ? '[DIR] ' : '[FILE]';
|
|
28
|
+
return `${prefix} ${entry.name}`;
|
|
29
|
+
});
|
|
30
|
+
return `Contents of ${directoryPath} (${entries.length} entries):\n${lines.join('\n')}`;
|
|
31
|
+
}
|