protoagent 0.0.3 ā 0.0.4
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/dist/agentic-loop.js +179 -121
- package/dist/config/client.js +11 -1
- package/dist/config/commands.js +15 -1
- package/dist/config/providers.js +59 -0
- package/dist/config/setup.js +35 -2
- package/dist/config/system-prompt.js +55 -21
- package/dist/tools/edit-file.js +43 -6
- package/dist/tools/index.js +14 -13
- package/dist/tools/run-shell-command.js +431 -26
- package/dist/tools/task-complete.js +26 -0
- package/dist/tools/write-file.js +15 -7
- package/dist/utils/enhanced-prompt.js +23 -0
- package/dist/utils/file-operations-approval.js +247 -25
- package/dist/utils/interrupt-handler.js +127 -0
- package/dist/utils/logger.js +1 -1
- package/dist/utils/user-cancellation.js +34 -0
- package/package.json +2 -2
|
@@ -1,8 +1,76 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
|
-
import inquirer from 'inquirer';
|
|
3
2
|
import { logger } from '../utils/logger.js';
|
|
3
|
+
import { UserCancellationError } from '../utils/user-cancellation.js';
|
|
4
|
+
import { enhancedPrompt } from '../utils/enhanced-prompt.js';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import fs from 'fs';
|
|
4
7
|
// Current working directory for file operations
|
|
5
8
|
const workingDirectory = process.cwd();
|
|
9
|
+
/**
|
|
10
|
+
* Parse compound shell commands and extract directory changes
|
|
11
|
+
* Handles patterns like: cd directory && command args
|
|
12
|
+
*/
|
|
13
|
+
function parseCompoundCommand(command, args) {
|
|
14
|
+
// Join command and args to analyze the full command string
|
|
15
|
+
const fullCommand = args.length > 0 ? `${command} ${args.join(' ')}` : command;
|
|
16
|
+
// Check for cd && pattern
|
|
17
|
+
const cdPattern = /^cd\s+([^&]+)\s*&&\s*(.+)$/;
|
|
18
|
+
const match = fullCommand.match(cdPattern);
|
|
19
|
+
if (match) {
|
|
20
|
+
const directory = match[1].trim();
|
|
21
|
+
const remainingCommand = match[2].trim();
|
|
22
|
+
// Parse the remaining command into command and args
|
|
23
|
+
const parts = remainingCommand.split(/\s+/);
|
|
24
|
+
const actualCommand = parts[0];
|
|
25
|
+
const actualArgs = parts.slice(1);
|
|
26
|
+
logger.debug('š Parsed compound command', {
|
|
27
|
+
component: 'ShellCommand',
|
|
28
|
+
original: fullCommand,
|
|
29
|
+
extractedDirectory: directory,
|
|
30
|
+
extractedCommand: actualCommand,
|
|
31
|
+
extractedArgs: actualArgs
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
directory,
|
|
35
|
+
actualCommand,
|
|
36
|
+
actualArgs
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// No compound command detected, return as-is
|
|
40
|
+
return {
|
|
41
|
+
actualCommand: command,
|
|
42
|
+
actualArgs: args
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Validate and resolve a subdirectory path safely
|
|
47
|
+
* Ensures the directory is within the working directory
|
|
48
|
+
*/
|
|
49
|
+
async function validateAndResolveDirectory(requestedDir) {
|
|
50
|
+
if (!requestedDir) {
|
|
51
|
+
return workingDirectory;
|
|
52
|
+
}
|
|
53
|
+
// Resolve the path relative to working directory
|
|
54
|
+
const resolvedPath = path.resolve(workingDirectory, requestedDir);
|
|
55
|
+
// Ensure the resolved path is within the working directory
|
|
56
|
+
if (!resolvedPath.startsWith(workingDirectory)) {
|
|
57
|
+
throw new Error(`Directory access denied - path outside working directory: ${requestedDir}`);
|
|
58
|
+
}
|
|
59
|
+
// Check if directory exists
|
|
60
|
+
try {
|
|
61
|
+
const stats = await fs.promises.stat(resolvedPath);
|
|
62
|
+
if (!stats.isDirectory()) {
|
|
63
|
+
throw new Error(`Path is not a directory: ${requestedDir}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code === 'ENOENT') {
|
|
68
|
+
throw new Error(`Directory does not exist: ${requestedDir}`);
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
return resolvedPath;
|
|
73
|
+
}
|
|
6
74
|
// Global flags and session state
|
|
7
75
|
let globalConfig = null;
|
|
8
76
|
let dangerouslyAcceptAll = false;
|
|
@@ -15,18 +83,346 @@ const SAFE_COMMANDS = [
|
|
|
15
83
|
'npm list', 'npm ls', 'yarn list', 'node --version', 'npm --version',
|
|
16
84
|
'python --version', 'python3 --version', 'which', 'type', 'file'
|
|
17
85
|
];
|
|
86
|
+
/**
|
|
87
|
+
* Detect if a command is likely to be interactive
|
|
88
|
+
*/
|
|
89
|
+
function isInteractiveCommand(commandString) {
|
|
90
|
+
const cmd = commandString.toLowerCase();
|
|
91
|
+
// Commands that are commonly interactive
|
|
92
|
+
const interactivePatterns = [
|
|
93
|
+
/npm create/,
|
|
94
|
+
/npm init(?!\s+\-y)/, // npm init without -y flag
|
|
95
|
+
/yarn create/,
|
|
96
|
+
/yarn init(?!\s+\-y)/,
|
|
97
|
+
/git commit(?!.*\-m)/, // git commit without -m flag
|
|
98
|
+
/git rebase\s+\-i/,
|
|
99
|
+
/git add\s+\-p/,
|
|
100
|
+
/npx create\-/,
|
|
101
|
+
/vue create/,
|
|
102
|
+
/ng new/,
|
|
103
|
+
/rails new/,
|
|
104
|
+
/django\-admin startproject/,
|
|
105
|
+
/composer create\-project/,
|
|
106
|
+
/cargo new/,
|
|
107
|
+
/dotnet new/
|
|
108
|
+
];
|
|
109
|
+
return interactivePatterns.some(pattern => pattern.test(cmd));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Run an interactive command with direct terminal access
|
|
113
|
+
*/
|
|
114
|
+
async function runInteractiveCommand(command, args = [], executionDirectory) {
|
|
115
|
+
logger.consoleLog(`\nšÆ Running interactive command...`);
|
|
116
|
+
logger.consoleLog(`š Directory: ${executionDirectory}`);
|
|
117
|
+
logger.consoleLog(`š„ļø Command: ${command} ${args.join(' ')}`);
|
|
118
|
+
logger.consoleLog(`\nš” You can interact with this command directly. Press Ctrl+C if needed.\n`);
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const child = spawn(command, args, {
|
|
121
|
+
cwd: executionDirectory,
|
|
122
|
+
stdio: 'inherit', // Allow direct interaction with terminal
|
|
123
|
+
shell: true,
|
|
124
|
+
env: { ...process.env, PATH: process.env.PATH }
|
|
125
|
+
});
|
|
126
|
+
child.on('close', (code) => {
|
|
127
|
+
if (code === 0) {
|
|
128
|
+
logger.consoleLog(`\nā
Interactive command completed successfully (exit code: ${code})`);
|
|
129
|
+
resolve(`Interactive command executed successfully (exit code: ${code})\n\nCommand: ${command} ${args.join(' ')}\nDirectory: ${executionDirectory}`);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
logger.consoleLog(`\nā ļø Interactive command exited with code ${code}`);
|
|
133
|
+
resolve(`Interactive command exited with code ${code}\n\nCommand: ${command} ${args.join(' ')}\nDirectory: ${executionDirectory}\n\nNote: Non-zero exit codes don't always indicate errors for interactive commands.`);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
child.on('error', (error) => {
|
|
137
|
+
logger.consoleLog(`\nā Interactive command failed: ${error.message}`);
|
|
138
|
+
reject(new Error(`Failed to execute interactive command: ${error.message}`));
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Show detailed preview of what a shell command will do
|
|
144
|
+
*/
|
|
145
|
+
async function showShellCommandPreview(commandString, directory) {
|
|
146
|
+
logger.consoleLog(`\nš Shell Command Preview`);
|
|
147
|
+
logger.consoleLog(`āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā`);
|
|
148
|
+
logger.consoleLog(`š„ļø Command: ${commandString}`);
|
|
149
|
+
logger.consoleLog(`š Working Directory: ${directory}`);
|
|
150
|
+
// Show relative path for better readability
|
|
151
|
+
const relativePath = path.relative(workingDirectory, directory);
|
|
152
|
+
if (relativePath && relativePath !== '.') {
|
|
153
|
+
logger.consoleLog(`š Subdirectory: ${relativePath}`);
|
|
154
|
+
}
|
|
155
|
+
// Analyze the command and show what it will do
|
|
156
|
+
const analysis = analyzeShellCommand(commandString);
|
|
157
|
+
// Show risk assessment
|
|
158
|
+
const riskColor = analysis.riskLevel === 'LOW' ? 'š¢' : analysis.riskLevel === 'MEDIUM' ? 'š”' : 'š“';
|
|
159
|
+
logger.consoleLog(`${riskColor} Risk Level: ${analysis.riskLevel} - ${analysis.riskReason}`);
|
|
160
|
+
logger.consoleLog(`\nš Command Analysis:`);
|
|
161
|
+
logger.consoleLog(` ⢠Purpose: ${analysis.purpose}`);
|
|
162
|
+
logger.consoleLog(` ⢠Expected output: ${analysis.expectedOutput}`);
|
|
163
|
+
logger.consoleLog(` ⢠Side effects: ${analysis.sideEffects}`);
|
|
164
|
+
if (analysis.fileSystemChanges.length > 0) {
|
|
165
|
+
logger.consoleLog(`\nš Potential file system changes:`);
|
|
166
|
+
analysis.fileSystemChanges.forEach(change => {
|
|
167
|
+
logger.consoleLog(` ⢠${change}`);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (analysis.networkActivity) {
|
|
171
|
+
logger.consoleLog(`\nš Network activity: ${analysis.networkActivity}`);
|
|
172
|
+
}
|
|
173
|
+
if (analysis.warnings.length > 0) {
|
|
174
|
+
logger.consoleLog(`\nā ļø Warnings:`);
|
|
175
|
+
analysis.warnings.forEach(warning => {
|
|
176
|
+
logger.consoleLog(` ⢠${warning}`);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (analysis.suggestions.length > 0) {
|
|
180
|
+
logger.consoleLog(`\nš” Suggestions:`);
|
|
181
|
+
analysis.suggestions.forEach(suggestion => {
|
|
182
|
+
logger.consoleLog(` ⢠${suggestion}`);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
logger.consoleLog(`āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā`);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Analyze a shell command to understand what it will do
|
|
189
|
+
*/
|
|
190
|
+
function analyzeShellCommand(commandString) {
|
|
191
|
+
const cmd = commandString.toLowerCase();
|
|
192
|
+
const parts = commandString.split(' ');
|
|
193
|
+
const baseCommand = parts[0].toLowerCase();
|
|
194
|
+
// Initialize analysis result
|
|
195
|
+
let analysis = {
|
|
196
|
+
riskLevel: 'LOW',
|
|
197
|
+
riskReason: 'Standard read-only operation',
|
|
198
|
+
purpose: 'Unknown command',
|
|
199
|
+
expectedOutput: 'Command output',
|
|
200
|
+
sideEffects: 'None',
|
|
201
|
+
fileSystemChanges: [],
|
|
202
|
+
networkActivity: undefined,
|
|
203
|
+
warnings: [],
|
|
204
|
+
suggestions: []
|
|
205
|
+
};
|
|
206
|
+
// Check if this is an interactive command
|
|
207
|
+
if (isInteractiveCommand(commandString)) {
|
|
208
|
+
analysis.warnings.push('This is an interactive command - you will be able to interact directly with it');
|
|
209
|
+
analysis.suggestions.push('The command will run with direct terminal access for interaction');
|
|
210
|
+
analysis.expectedOutput = 'Interactive session with prompts';
|
|
211
|
+
}
|
|
212
|
+
// Analyze based on command type
|
|
213
|
+
switch (baseCommand) {
|
|
214
|
+
case 'ls':
|
|
215
|
+
case 'dir':
|
|
216
|
+
analysis.purpose = 'List directory contents';
|
|
217
|
+
analysis.expectedOutput = 'File and folder names with details';
|
|
218
|
+
break;
|
|
219
|
+
case 'find':
|
|
220
|
+
analysis.purpose = 'Search for files and directories';
|
|
221
|
+
analysis.expectedOutput = 'Paths matching search criteria';
|
|
222
|
+
if (cmd.includes('-exec') || cmd.includes('-delete')) {
|
|
223
|
+
analysis.riskLevel = 'HIGH';
|
|
224
|
+
analysis.riskReason = 'Find command with execution or deletion flags';
|
|
225
|
+
analysis.sideEffects = 'May modify or delete files';
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
case 'grep':
|
|
229
|
+
analysis.purpose = 'Search text within files';
|
|
230
|
+
analysis.expectedOutput = 'Matching lines with context';
|
|
231
|
+
break;
|
|
232
|
+
case 'cat':
|
|
233
|
+
case 'head':
|
|
234
|
+
case 'tail':
|
|
235
|
+
analysis.purpose = 'Display file contents';
|
|
236
|
+
analysis.expectedOutput = 'File content';
|
|
237
|
+
break;
|
|
238
|
+
case 'git':
|
|
239
|
+
const gitSubcommand = parts[1]?.toLowerCase();
|
|
240
|
+
switch (gitSubcommand) {
|
|
241
|
+
case 'status':
|
|
242
|
+
case 'log':
|
|
243
|
+
case 'diff':
|
|
244
|
+
case 'show':
|
|
245
|
+
case 'branch':
|
|
246
|
+
analysis.purpose = `Show git ${gitSubcommand} information`;
|
|
247
|
+
analysis.expectedOutput = `Git ${gitSubcommand} output`;
|
|
248
|
+
break;
|
|
249
|
+
case 'add':
|
|
250
|
+
analysis.purpose = 'Stage files for commit';
|
|
251
|
+
analysis.riskLevel = 'MEDIUM';
|
|
252
|
+
analysis.riskReason = 'Modifies git index';
|
|
253
|
+
analysis.sideEffects = 'Stages files for next commit';
|
|
254
|
+
break;
|
|
255
|
+
case 'commit':
|
|
256
|
+
analysis.purpose = 'Create a new commit';
|
|
257
|
+
analysis.riskLevel = 'MEDIUM';
|
|
258
|
+
analysis.riskReason = 'Creates permanent git history';
|
|
259
|
+
analysis.sideEffects = 'Adds commit to repository history';
|
|
260
|
+
if (!cmd.includes('-m')) {
|
|
261
|
+
analysis.warnings.push('Missing commit message - will open interactive editor');
|
|
262
|
+
analysis.suggestions.push('Add -m "message" to avoid interactive mode');
|
|
263
|
+
}
|
|
264
|
+
break;
|
|
265
|
+
case 'push':
|
|
266
|
+
case 'pull':
|
|
267
|
+
case 'fetch':
|
|
268
|
+
analysis.purpose = `Synchronize with remote repository (${gitSubcommand})`;
|
|
269
|
+
analysis.riskLevel = 'MEDIUM';
|
|
270
|
+
analysis.riskReason = 'Network operation affecting repository';
|
|
271
|
+
analysis.networkActivity = `Git ${gitSubcommand} to remote repository`;
|
|
272
|
+
analysis.sideEffects = 'May update local or remote repository';
|
|
273
|
+
break;
|
|
274
|
+
default:
|
|
275
|
+
analysis.purpose = `Execute git ${gitSubcommand} command`;
|
|
276
|
+
analysis.riskLevel = 'MEDIUM';
|
|
277
|
+
analysis.riskReason = 'Git operation with unknown effects';
|
|
278
|
+
}
|
|
279
|
+
break;
|
|
280
|
+
case 'npm':
|
|
281
|
+
case 'yarn':
|
|
282
|
+
const packageSubcommand = parts[1]?.toLowerCase();
|
|
283
|
+
switch (packageSubcommand) {
|
|
284
|
+
case 'install':
|
|
285
|
+
case 'i':
|
|
286
|
+
analysis.purpose = 'Install package dependencies';
|
|
287
|
+
analysis.riskLevel = 'MEDIUM';
|
|
288
|
+
analysis.riskReason = 'Downloads and installs packages from internet';
|
|
289
|
+
analysis.networkActivity = 'Downloads packages from npm registry';
|
|
290
|
+
analysis.fileSystemChanges = ['Creates/updates node_modules/', 'Updates package-lock.json'];
|
|
291
|
+
analysis.sideEffects = 'Installs dependencies and updates lock file';
|
|
292
|
+
break;
|
|
293
|
+
case 'create':
|
|
294
|
+
analysis.purpose = 'Create new project from template';
|
|
295
|
+
analysis.riskLevel = 'MEDIUM';
|
|
296
|
+
analysis.riskReason = 'Creates new project structure';
|
|
297
|
+
analysis.networkActivity = 'Downloads project template';
|
|
298
|
+
analysis.fileSystemChanges = ['Creates new project directory', 'Installs dependencies'];
|
|
299
|
+
analysis.expectedOutput = 'Interactive prompts for project configuration';
|
|
300
|
+
analysis.sideEffects = 'Creates project files and installs dependencies interactively';
|
|
301
|
+
if (!cmd.includes('--template')) {
|
|
302
|
+
analysis.warnings.push('Interactive command - you will be prompted to choose template and options');
|
|
303
|
+
analysis.suggestions.push('You can answer prompts directly in the terminal');
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
analysis.suggestions.push('Template specified - fewer interactive prompts expected');
|
|
307
|
+
}
|
|
308
|
+
break;
|
|
309
|
+
case 'run':
|
|
310
|
+
case 'start':
|
|
311
|
+
case 'build':
|
|
312
|
+
case 'test':
|
|
313
|
+
analysis.purpose = `Run ${packageSubcommand} script`;
|
|
314
|
+
analysis.riskLevel = 'MEDIUM';
|
|
315
|
+
analysis.riskReason = 'Executes custom scripts defined in package.json';
|
|
316
|
+
analysis.sideEffects = 'Depends on script contents - may build, test, or start server';
|
|
317
|
+
break;
|
|
318
|
+
default:
|
|
319
|
+
analysis.purpose = `Execute ${baseCommand} ${packageSubcommand} command`;
|
|
320
|
+
analysis.riskLevel = 'MEDIUM';
|
|
321
|
+
analysis.riskReason = 'Package manager operation';
|
|
322
|
+
}
|
|
323
|
+
break;
|
|
324
|
+
case 'mkdir':
|
|
325
|
+
analysis.purpose = 'Create directories';
|
|
326
|
+
analysis.riskLevel = 'LOW';
|
|
327
|
+
analysis.fileSystemChanges = [`Creates directory: ${parts.slice(1).join(', ')}`];
|
|
328
|
+
analysis.sideEffects = 'Creates new directories';
|
|
329
|
+
break;
|
|
330
|
+
case 'rm':
|
|
331
|
+
analysis.purpose = 'Remove files and directories';
|
|
332
|
+
analysis.riskLevel = 'HIGH';
|
|
333
|
+
analysis.riskReason = 'Permanently deletes files';
|
|
334
|
+
analysis.sideEffects = 'PERMANENTLY DELETES FILES';
|
|
335
|
+
analysis.fileSystemChanges = [`Deletes: ${parts.slice(1).join(', ')}`];
|
|
336
|
+
analysis.warnings.push('DESTRUCTIVE OPERATION - files will be permanently deleted');
|
|
337
|
+
if (cmd.includes('-rf')) {
|
|
338
|
+
analysis.warnings.push('Recursive force delete - EXTREMELY DANGEROUS');
|
|
339
|
+
}
|
|
340
|
+
break;
|
|
341
|
+
case 'cp':
|
|
342
|
+
case 'copy':
|
|
343
|
+
analysis.purpose = 'Copy files or directories';
|
|
344
|
+
analysis.riskLevel = 'LOW';
|
|
345
|
+
analysis.sideEffects = 'Creates file copies';
|
|
346
|
+
analysis.fileSystemChanges = ['Creates copies of specified files'];
|
|
347
|
+
break;
|
|
348
|
+
case 'mv':
|
|
349
|
+
case 'move':
|
|
350
|
+
analysis.purpose = 'Move or rename files';
|
|
351
|
+
analysis.riskLevel = 'MEDIUM';
|
|
352
|
+
analysis.riskReason = 'Modifies file locations';
|
|
353
|
+
analysis.sideEffects = 'Moves or renames files';
|
|
354
|
+
analysis.fileSystemChanges = ['Moves/renames specified files'];
|
|
355
|
+
break;
|
|
356
|
+
case 'chmod':
|
|
357
|
+
analysis.purpose = 'Change file permissions';
|
|
358
|
+
analysis.riskLevel = 'MEDIUM';
|
|
359
|
+
analysis.riskReason = 'Modifies file security permissions';
|
|
360
|
+
analysis.sideEffects = 'Changes file permissions';
|
|
361
|
+
if (cmd.includes('777')) {
|
|
362
|
+
analysis.riskLevel = 'HIGH';
|
|
363
|
+
analysis.riskReason = 'Setting permissions to 777 (world-writable) is dangerous';
|
|
364
|
+
analysis.warnings.push('chmod 777 makes files writable by everyone - security risk');
|
|
365
|
+
}
|
|
366
|
+
break;
|
|
367
|
+
case 'curl':
|
|
368
|
+
case 'wget':
|
|
369
|
+
analysis.purpose = 'Download content from internet';
|
|
370
|
+
analysis.riskLevel = 'MEDIUM';
|
|
371
|
+
analysis.riskReason = 'Network operation downloading external content';
|
|
372
|
+
analysis.networkActivity = 'Downloads content from specified URL';
|
|
373
|
+
if (cmd.includes('-o') || cmd.includes('--output')) {
|
|
374
|
+
analysis.fileSystemChanges = ['Creates downloaded file'];
|
|
375
|
+
}
|
|
376
|
+
break;
|
|
377
|
+
case 'python':
|
|
378
|
+
case 'python3':
|
|
379
|
+
case 'node':
|
|
380
|
+
analysis.purpose = 'Execute script';
|
|
381
|
+
analysis.riskLevel = 'MEDIUM';
|
|
382
|
+
analysis.riskReason = 'Executes code with unknown effects';
|
|
383
|
+
analysis.sideEffects = 'Depends on script contents';
|
|
384
|
+
break;
|
|
385
|
+
default:
|
|
386
|
+
analysis.purpose = 'Execute command';
|
|
387
|
+
analysis.riskLevel = 'MEDIUM';
|
|
388
|
+
analysis.riskReason = 'Unknown command with unpredictable effects';
|
|
389
|
+
analysis.warnings.push('Unknown command - effects cannot be predicted');
|
|
390
|
+
}
|
|
391
|
+
// Check for dangerous patterns
|
|
392
|
+
if (cmd.includes('sudo')) {
|
|
393
|
+
analysis.riskLevel = 'HIGH';
|
|
394
|
+
analysis.riskReason = 'Requires elevated privileges';
|
|
395
|
+
analysis.warnings.push('Sudo command requires administrator privileges');
|
|
396
|
+
}
|
|
397
|
+
if (cmd.includes('rm -rf')) {
|
|
398
|
+
analysis.riskLevel = 'HIGH';
|
|
399
|
+
analysis.riskReason = 'Recursive force deletion';
|
|
400
|
+
analysis.warnings.push('DANGER: rm -rf can delete entire directory trees');
|
|
401
|
+
}
|
|
402
|
+
if (cmd.includes('format') || cmd.includes('mkfs') || cmd.includes('fdisk')) {
|
|
403
|
+
analysis.riskLevel = 'HIGH';
|
|
404
|
+
analysis.riskReason = 'Disk formatting/partitioning commands';
|
|
405
|
+
analysis.warnings.push('DANGER: Disk formatting commands can destroy data');
|
|
406
|
+
}
|
|
407
|
+
return analysis;
|
|
408
|
+
}
|
|
18
409
|
export function setShellConfig(config) {
|
|
19
410
|
globalConfig = config;
|
|
20
411
|
}
|
|
21
412
|
export function setDangerouslyAcceptAll(accept) {
|
|
22
413
|
dangerouslyAcceptAll = accept;
|
|
23
414
|
}
|
|
24
|
-
export async function runShellCommand(command, args = [], timeoutMs = 30000) {
|
|
25
|
-
|
|
415
|
+
export async function runShellCommand(command, args = [], timeoutMs = 30000, directory) {
|
|
416
|
+
// Parse compound commands like "cd directory && command"
|
|
417
|
+
const parsed = parseCompoundCommand(command, args);
|
|
418
|
+
const finalDirectory = parsed.directory || directory; // Parsed directory takes precedence
|
|
419
|
+
return await runShellCommandWithRetry(parsed.actualCommand, parsed.actualArgs, timeoutMs, 0, finalDirectory);
|
|
26
420
|
}
|
|
27
|
-
async function runShellCommandWithRetry(command, args = [], timeoutMs = 30000, retryCount = 0) {
|
|
421
|
+
async function runShellCommandWithRetry(command, args = [], timeoutMs = 30000, retryCount = 0, directory) {
|
|
28
422
|
const maxRetries = 2;
|
|
29
423
|
try {
|
|
424
|
+
// Validate and resolve the directory parameter
|
|
425
|
+
const executionDirectory = await validateAndResolveDirectory(directory);
|
|
30
426
|
// Security: Basic validation to prevent obviously dangerous commands
|
|
31
427
|
const dangerousCommands = ['rm -rf', 'sudo', 'su', 'chmod 777', 'dd if=', 'mkfs', 'fdisk', 'format'];
|
|
32
428
|
const fullCommand = `${command} ${args.join(' ')}`.toLowerCase();
|
|
@@ -59,21 +455,12 @@ async function runShellCommandWithRetry(command, args = [], timeoutMs = 30000, r
|
|
|
59
455
|
}
|
|
60
456
|
else {
|
|
61
457
|
// Require user confirmation with enhanced options
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
{
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
message: 'Choose your action:',
|
|
69
|
-
choices: [
|
|
70
|
-
{ name: '1. ā
Execute this command now', value: 'execute' },
|
|
71
|
-
{ name: `2. ā
Execute and approve all "${baseCommand}" commands for this session`, value: 'approve_session' },
|
|
72
|
-
{ name: '3. ā Cancel and suggest alternative', value: 'cancel' }
|
|
73
|
-
],
|
|
74
|
-
default: 'execute'
|
|
75
|
-
}
|
|
76
|
-
]);
|
|
458
|
+
await showShellCommandPreview(commandString, executionDirectory);
|
|
459
|
+
const choice = await enhancedPrompt('Choose your action:', [
|
|
460
|
+
{ name: '1. ā
Execute this command now', value: 'execute' },
|
|
461
|
+
{ name: `2. ā
Execute and approve all "${baseCommand}" commands for this session`, value: 'approve_session' },
|
|
462
|
+
{ name: '3. ā Cancel and suggest alternative', value: 'cancel' }
|
|
463
|
+
], 'execute');
|
|
77
464
|
switch (choice) {
|
|
78
465
|
case 'execute':
|
|
79
466
|
logger.consoleLog(`š Executing: ${commandString}`);
|
|
@@ -84,9 +471,9 @@ async function runShellCommandWithRetry(command, args = [], timeoutMs = 30000, r
|
|
|
84
471
|
logger.consoleLog(`š Executing: ${commandString}`);
|
|
85
472
|
break;
|
|
86
473
|
case 'cancel':
|
|
87
|
-
|
|
474
|
+
throw new UserCancellationError(`shell command: ${commandString}`, 'User chose to cancel and suggest alternative');
|
|
88
475
|
default:
|
|
89
|
-
|
|
476
|
+
throw new UserCancellationError(`shell command: ${commandString}`, 'User did not approve the command');
|
|
90
477
|
}
|
|
91
478
|
}
|
|
92
479
|
}
|
|
@@ -94,9 +481,14 @@ async function runShellCommandWithRetry(command, args = [], timeoutMs = 30000, r
|
|
|
94
481
|
else {
|
|
95
482
|
logger.consoleLog(`š Retry attempt ${retryCount}/${maxRetries}: ${commandString}`);
|
|
96
483
|
}
|
|
484
|
+
// Check if this is an interactive command and handle it differently
|
|
485
|
+
if (isInteractiveCommand(commandString)) {
|
|
486
|
+
logger.consoleLog(`šÆ Detected interactive command: ${commandString}`);
|
|
487
|
+
return await runInteractiveCommand(command, args, executionDirectory);
|
|
488
|
+
}
|
|
97
489
|
return new Promise((resolve, reject) => {
|
|
98
490
|
const child = spawn(command, args, {
|
|
99
|
-
cwd:
|
|
491
|
+
cwd: executionDirectory,
|
|
100
492
|
stdio: ['pipe', 'pipe', 'pipe'], // Capture all input/output for AI processing
|
|
101
493
|
shell: true,
|
|
102
494
|
env: { ...process.env, PATH: process.env.PATH }
|
|
@@ -178,6 +570,15 @@ async function analyzeTimeoutAndRetry(command, args = [], originalTimeout, retry
|
|
|
178
570
|
function analyzeCommandForTimeout(command, args) {
|
|
179
571
|
const fullCommand = `${command} ${args.join(' ')}`.toLowerCase();
|
|
180
572
|
const baseCommand = command.toLowerCase();
|
|
573
|
+
// First check if this is an interactive command that should use direct terminal access
|
|
574
|
+
if (isInteractiveCommand(fullCommand)) {
|
|
575
|
+
return {
|
|
576
|
+
reason: "This is an interactive command that requires user input",
|
|
577
|
+
suggestion: "Interactive commands should use direct terminal access, not captured I/O",
|
|
578
|
+
suggestedArgs: args,
|
|
579
|
+
suggestedTimeout: 30000
|
|
580
|
+
};
|
|
581
|
+
}
|
|
181
582
|
// Interactive command detection
|
|
182
583
|
if (baseCommand === 'npm' && args.length > 0) {
|
|
183
584
|
const npmSubcommand = args[0].toLowerCase();
|
|
@@ -186,7 +587,7 @@ function analyzeCommandForTimeout(command, args) {
|
|
|
186
587
|
if (npmSubcommand === 'create' && !args.some(arg => arg.includes('--template'))) {
|
|
187
588
|
return {
|
|
188
589
|
reason: "npm create command likely waiting for interactive template selection",
|
|
189
|
-
suggestion: "Add --template flag to avoid interactive prompts",
|
|
590
|
+
suggestion: "Add --template flag to avoid interactive prompts, or use interactive mode",
|
|
190
591
|
suggestedArgs: [...args, '--template', 'vanilla'],
|
|
191
592
|
suggestedTimeout: 60000
|
|
192
593
|
};
|
|
@@ -195,7 +596,7 @@ function analyzeCommandForTimeout(command, args) {
|
|
|
195
596
|
if (!args.some(arg => arg.includes('-y') || arg.includes('--yes'))) {
|
|
196
597
|
return {
|
|
197
598
|
reason: "npm command likely waiting for interactive confirmation",
|
|
198
|
-
suggestion: "Add -y flag to auto-confirm prompts",
|
|
599
|
+
suggestion: "Add -y flag to auto-confirm prompts, or use interactive mode",
|
|
199
600
|
suggestedArgs: [...args, '-y'],
|
|
200
601
|
suggestedTimeout: 60000
|
|
201
602
|
};
|
|
@@ -315,13 +716,13 @@ export const runShellCommandTool = {
|
|
|
315
716
|
type: 'function',
|
|
316
717
|
function: {
|
|
317
718
|
name: 'run_shell_command',
|
|
318
|
-
description: 'Execute a shell command in the current working directory. Commands run non-interactively and output is captured for analysis. For tools that normally prompt for input (like npm create), provide all necessary flags to avoid interactive prompts. Safe commands (ls, find, grep, git status, etc.) run automatically. Other commands may require user confirmation unless running with --dangerously-accept-all flag. Examples: find . -name "*.js", grep -r "TODO" ., npm create vite@latest my-app --template react --no-interactive',
|
|
719
|
+
description: 'Execute a shell command in the current working directory or a specific subdirectory. Commands run non-interactively and output is captured for analysis. Supports compound commands like "cd subdirectory && npm install" which will automatically change to the subdirectory and run the command there. For tools that normally prompt for input (like npm create), provide all necessary flags to avoid interactive prompts. Safe commands (ls, find, grep, git status, etc.) run automatically. Other commands may require user confirmation unless running with --dangerously-accept-all flag. Examples: find . -name "*.js", grep -r "TODO" ., npm create vite@latest my-app --template react --no-interactive, cd frontend && npm install',
|
|
319
720
|
parameters: {
|
|
320
721
|
type: 'object',
|
|
321
722
|
properties: {
|
|
322
723
|
command: {
|
|
323
724
|
type: 'string',
|
|
324
|
-
description: 'The command to execute. Examples: "find", "grep", "ls", "git", "npm", "python", "node", "yarn"'
|
|
725
|
+
description: 'The command to execute. Can be a simple command like "find" or a compound command like "cd directory && npm install". Examples: "find", "grep", "ls", "git", "npm", "python", "node", "yarn", "cd frontend && npm install"'
|
|
325
726
|
},
|
|
326
727
|
args: {
|
|
327
728
|
type: 'array',
|
|
@@ -333,6 +734,10 @@ export const runShellCommandTool = {
|
|
|
333
734
|
timeout_ms: {
|
|
334
735
|
type: 'integer',
|
|
335
736
|
description: 'Timeout in milliseconds for the command execution. Default is 30000 (30 seconds). Use higher values for long-running operations.'
|
|
737
|
+
},
|
|
738
|
+
directory: {
|
|
739
|
+
type: 'string',
|
|
740
|
+
description: 'Optional: Subdirectory path relative to the working directory where the command should be executed. Must be within the current working directory for security. Examples: "src", "src/components", "tests". Leave empty to execute in the current directory.'
|
|
336
741
|
}
|
|
337
742
|
},
|
|
338
743
|
required: ['command']
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { logger } from '../utils/logger.js';
|
|
3
|
+
/**
|
|
4
|
+
* Tool for the AI to explicitly signal that a task is complete
|
|
5
|
+
* This gives the AI control over when to stop the agentic loop
|
|
6
|
+
*/
|
|
7
|
+
const taskCompleteSchema = z.object({
|
|
8
|
+
summary: z.string().describe("Brief summary of what was accomplished"),
|
|
9
|
+
nextSteps: z.string().optional().describe("Optional suggestions for next steps or follow-up actions")
|
|
10
|
+
});
|
|
11
|
+
async function taskComplete(args) {
|
|
12
|
+
logger.debug('š AI signaled task completion', { component: 'TaskComplete', summary: args.summary });
|
|
13
|
+
logger.consoleLog(`\nš Task Complete: ${args.summary}`);
|
|
14
|
+
if (args.nextSteps) {
|
|
15
|
+
logger.consoleLog(`š” Next Steps: ${args.nextSteps}`);
|
|
16
|
+
}
|
|
17
|
+
// Return a special marker that the agentic loop can detect
|
|
18
|
+
return "TASK_COMPLETE";
|
|
19
|
+
}
|
|
20
|
+
export const taskCompleteTool = {
|
|
21
|
+
name: 'task_complete',
|
|
22
|
+
description: 'Signal that the current task has been completed successfully. Use this when you have finished all requested work and want to return control to the user.',
|
|
23
|
+
inputSchema: taskCompleteSchema,
|
|
24
|
+
handler: taskComplete
|
|
25
|
+
};
|
|
26
|
+
export { taskComplete };
|
package/dist/tools/write-file.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { randomBytes } from 'crypto';
|
|
4
4
|
import { requestFileOperationApproval } from '../utils/file-operations-approval.js';
|
|
5
|
+
import { isUserCancellation } from '../utils/user-cancellation.js';
|
|
5
6
|
import { logger } from '../utils/logger.js';
|
|
6
7
|
// Current working directory for file operations
|
|
7
8
|
const workingDirectory = process.cwd();
|
|
@@ -65,14 +66,17 @@ export async function writeFile(filePath, content) {
|
|
|
65
66
|
description: fileExists
|
|
66
67
|
? `Overwrite existing file with ${content.length} characters of new content`
|
|
67
68
|
: `Create new file with ${content.length} characters of content`,
|
|
68
|
-
contentPreview:
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
contentPreview: undefined, // Will be shown in the enhanced preview
|
|
70
|
+
newContent: content,
|
|
71
|
+
changeContext: {
|
|
72
|
+
linesAdded: content.split('\n').length,
|
|
73
|
+
linesRemoved: fileExists ? 0 : 0, // We don't know the old content yet
|
|
74
|
+
totalLines: content.split('\n').length,
|
|
75
|
+
affectedLineNumbers: []
|
|
76
|
+
}
|
|
71
77
|
};
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return `Write operation cancelled by user: ${filePath}`;
|
|
75
|
-
}
|
|
78
|
+
// Request user approval for write operation (throws UserCancellationError if cancelled)
|
|
79
|
+
await requestFileOperationApproval(approvalContext);
|
|
76
80
|
logger.debug(`š Writing file: ${filePath} (${content.length} chars)`, { component: 'WriteFile', operation: 'writeFile' });
|
|
77
81
|
try {
|
|
78
82
|
// Security: 'wx' flag ensures exclusive creation - fails if file/symlink exists,
|
|
@@ -106,6 +110,10 @@ export async function writeFile(filePath, content) {
|
|
|
106
110
|
return `Successfully wrote to ${filePath}`;
|
|
107
111
|
}
|
|
108
112
|
catch (error) {
|
|
113
|
+
// Re-throw UserCancellationError without modification
|
|
114
|
+
if (isUserCancellation(error)) {
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
109
117
|
if (error instanceof Error) {
|
|
110
118
|
throw new Error(`Failed to write file: ${error.message}`);
|
|
111
119
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enhanced prompt utility that supports both arrow key navigation + enter
|
|
3
|
+
* AND displays clear number shortcuts for faster interaction
|
|
4
|
+
*/
|
|
5
|
+
import inquirer from 'inquirer';
|
|
6
|
+
/**
|
|
7
|
+
* Enhanced prompt that uses standard inquirer with clear number indicators
|
|
8
|
+
* Users can still use arrow keys + enter as normal, but numbers make it clear what to select
|
|
9
|
+
*/
|
|
10
|
+
export async function enhancedPrompt(message, choices, defaultValue) {
|
|
11
|
+
// Add instruction about keyboard shortcuts
|
|
12
|
+
const enhancedMessage = `${message}\nš” Use arrow keys + Enter, or press the number key + Enter`;
|
|
13
|
+
const result = await inquirer.prompt([
|
|
14
|
+
{
|
|
15
|
+
type: 'list',
|
|
16
|
+
name: 'choice',
|
|
17
|
+
message: enhancedMessage,
|
|
18
|
+
choices: choices,
|
|
19
|
+
default: defaultValue
|
|
20
|
+
}
|
|
21
|
+
]);
|
|
22
|
+
return result.choice;
|
|
23
|
+
}
|