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.
Files changed (58) hide show
  1. package/README.md +99 -19
  2. package/dist/App.js +602 -0
  3. package/dist/agentic-loop.js +492 -525
  4. package/dist/cli.js +39 -0
  5. package/dist/components/CollapsibleBox.js +26 -0
  6. package/dist/components/ConfigDialog.js +40 -0
  7. package/dist/components/ConsolidatedToolMessage.js +41 -0
  8. package/dist/components/FormattedMessage.js +93 -0
  9. package/dist/components/Table.js +275 -0
  10. package/dist/config.js +171 -0
  11. package/dist/mcp.js +170 -0
  12. package/dist/providers.js +137 -0
  13. package/dist/sessions.js +161 -0
  14. package/dist/skills.js +229 -0
  15. package/dist/sub-agent.js +103 -0
  16. package/dist/system-prompt.js +131 -0
  17. package/dist/tools/bash.js +178 -0
  18. package/dist/tools/edit-file.js +65 -171
  19. package/dist/tools/index.js +79 -134
  20. package/dist/tools/list-directory.js +20 -73
  21. package/dist/tools/read-file.js +57 -101
  22. package/dist/tools/search-files.js +74 -162
  23. package/dist/tools/todo.js +57 -140
  24. package/dist/tools/webfetch.js +310 -0
  25. package/dist/tools/write-file.js +44 -135
  26. package/dist/utils/approval.js +69 -0
  27. package/dist/utils/compactor.js +87 -0
  28. package/dist/utils/cost-tracker.js +26 -81
  29. package/dist/utils/format-message.js +26 -0
  30. package/dist/utils/logger.js +101 -307
  31. package/dist/utils/path-validation.js +74 -0
  32. package/package.json +45 -51
  33. package/LICENSE +0 -21
  34. package/dist/config/client.js +0 -315
  35. package/dist/config/commands.js +0 -223
  36. package/dist/config/manager.js +0 -117
  37. package/dist/config/mcp-commands.js +0 -266
  38. package/dist/config/mcp-manager.js +0 -240
  39. package/dist/config/mcp-types.js +0 -28
  40. package/dist/config/providers.js +0 -229
  41. package/dist/config/setup.js +0 -209
  42. package/dist/config/system-prompt.js +0 -397
  43. package/dist/config/types.js +0 -4
  44. package/dist/index.js +0 -229
  45. package/dist/tools/create-directory.js +0 -76
  46. package/dist/tools/directory-operations.js +0 -195
  47. package/dist/tools/file-operations.js +0 -211
  48. package/dist/tools/run-shell-command.js +0 -746
  49. package/dist/tools/search-operations.js +0 -179
  50. package/dist/tools/shell-operations.js +0 -342
  51. package/dist/tools/task-complete.js +0 -26
  52. package/dist/tools/view-directory-tree.js +0 -125
  53. package/dist/tools.js +0 -2
  54. package/dist/utils/conversation-compactor.js +0 -140
  55. package/dist/utils/enhanced-prompt.js +0 -23
  56. package/dist/utils/file-operations-approval.js +0 -373
  57. package/dist/utils/interrupt-handler.js +0 -127
  58. package/dist/utils/user-cancellation.js +0 -34
@@ -1,181 +1,75 @@
1
- import fs from 'fs/promises';
2
- import path from 'path';
3
- import { randomBytes } from 'crypto';
4
- import { requestFileOperationApproval } from '../utils/file-operations-approval.js';
5
- import { isUserCancellation } from '../utils/user-cancellation.js';
6
- import { logger } from '../utils/logger.js';
7
- // Current working directory for file operations
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 specific text with new text. This operation is atomic and ensures file integrity. Use this to modify configuration files, scripts, or any text files where you need to update specific sections. The old_string must match exactly, including whitespace and line breaks.',
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
- type: 'string',
163
- description: 'The path to the file to edit, relative to the current working directory. Examples: "src/config.json", "README.md"'
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: 'integer',
175
- description: 'Optional: The expected number of replacements. If not specified, only allows 1 replacement. Use this to confirm when you want to replace multiple occurrences of the same text.'
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
+ }
@@ -1,152 +1,97 @@
1
- // Export all tools and their implementations
2
- export { readFile, readFileTool } from './read-file.js';
3
- export { writeFile, writeFileTool } from './write-file.js';
4
- export { editFile, editFileTool } from './edit-file.js';
5
- export { listDirectory, listDirectoryTool } from './list-directory.js';
6
- export { createDirectory, createDirectoryTool } from './create-directory.js';
7
- export { viewDirectoryTree, viewDirectoryTreeTool } from './view-directory-tree.js';
8
- export { searchFiles, searchFilesTool } from './search-files.js';
9
- export { runShellCommand, setShellConfig, setDangerouslyAcceptAll, runShellCommandTool } from './run-shell-command.js';
10
- export { setFileOperationConfig, setDangerouslyAcceptAllFileOps, getSessionApprovalStatus, clearSessionApprovals, showApprovalStatus } from '../utils/file-operations-approval.js';
11
- // Import all tool definitions
12
- import { readFileTool } from './read-file.js';
13
- import { writeFileTool } from './write-file.js';
14
- import { editFileTool } from './edit-file.js';
15
- import { listDirectoryTool } from './list-directory.js';
16
- import { createDirectoryTool } from './create-directory.js';
17
- import { viewDirectoryTreeTool } from './view-directory-tree.js';
18
- import { searchFilesTool } from './search-files.js';
19
- import { runShellCommandTool } from './run-shell-command.js';
20
- // Import all tool implementations
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
- runShellCommandTool
27
+ bashTool,
28
+ todoReadTool,
29
+ todoWriteTool,
30
+ webfetchTool,
41
31
  ];
42
- // Tool handler function
43
- export async function handleToolCall(toolName, args) {
44
- logger.debug('🛠️ handleToolCall invoked', {
45
- component: 'ToolHandler',
46
- toolName,
47
- argsKeys: Object.keys(args || {})
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
- logger.debug('📖 Executing read_file', { component: 'ToolHandler', filePath: args.file_path });
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
- logger.debug('✏️ Executing write_file', { component: 'ToolHandler', filePath: args.file_path, contentLength: args.content?.length });
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
- logger.debug('✂️ Executing edit_file', {
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
- logger.debug('📁 Executing list_directory', { component: 'ToolHandler', directoryPath: args.directory_path });
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
- logger.debug('🔍 Executing search_files', {
91
- component: 'ToolHandler',
92
- searchTerm: args.search_term,
93
- directoryPath: args.directory_path,
94
- caseSensitive: args.case_sensitive,
95
- fileExtensions: args.file_extensions
96
- });
97
- result = await searchFiles(args.search_term, args.directory_path, args.case_sensitive, args.file_extensions);
98
- break;
99
- case 'run_shell_command':
100
- logger.debug('🐚 Executing run_shell_command', {
101
- component: 'ToolHandler',
102
- command: args.command,
103
- argsCount: args.args?.length || 0,
104
- timeout: args.timeout_ms || 30000,
105
- directory: args.directory || 'current'
106
- });
107
- result = await runShellCommand(args.command, args.args || [], args.timeout_ms || 30000, args.directory);
108
- break;
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 (error) {
124
- // Re-throw UserCancellationError so it can be handled properly by the agentic loop
125
- if (isUserCancellation(error)) {
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
- import fs from 'fs/promises';
2
- import path from 'path';
3
- // Current working directory for file operations
4
- const workingDirectory = process.cwd();
5
- // Security utilities
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 all files and directories in a specified directory. Use this to explore the project structure or find files. Returns a formatted list showing [FILE] and [DIR] prefixes.',
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: 'The path to the directory to list, relative to the current working directory. Use "." for current directory. Examples: ".", "src", "src/components"'
79
- }
16
+ description: 'Path to the directory to list (relative to working directory). Defaults to ".".',
17
+ },
80
18
  },
81
- required: ['directory_path']
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
+ }