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.
@@ -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
- return await runShellCommandWithRetry(command, args, timeoutMs, 0);
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
- logger.consoleLog(`\nšŸ” Shell Command Requested: ${commandString}`);
63
- logger.consoleLog(`šŸ“ Working Directory: ${workingDirectory}`);
64
- const { choice } = await inquirer.prompt([
65
- {
66
- type: 'list',
67
- name: 'choice',
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
- return getSuggestion(commandString);
474
+ throw new UserCancellationError(`shell command: ${commandString}`, 'User chose to cancel and suggest alternative');
88
475
  default:
89
- return `Command execution cancelled by user: ${commandString}`;
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: workingDirectory,
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 };
@@ -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: content.length > 500
69
- ? `${content.substring(0, 250)}...\n\n...${content.substring(content.length - 250)}`
70
- : content
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
- const approved = await requestFileOperationApproval(approvalContext);
73
- if (!approved) {
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
+ }