vigthoria-cli 1.5.0 → 1.5.2

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.
@@ -78,7 +78,7 @@ export enum ToolErrorType {
78
78
  export class AgenticTools {
79
79
  private logger: Logger;
80
80
  private cwd: string;
81
- private permissionCallback: (action: string) => Promise<boolean>;
81
+ private permissionCallback: (action: string, options?: { batchApproval?: boolean }) => Promise<boolean | 'batch'>;
82
82
  private autoApprove: boolean;
83
83
  private undoStack: UndoOperation[] = [];
84
84
  private maxUndoStack: number = 50;
@@ -88,11 +88,14 @@ export class AgenticTools {
88
88
  maxDelayMs: 10000,
89
89
  retryableErrors: ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ECONNREFUSED', 'EAI_AGAIN'],
90
90
  };
91
+
92
+ // Session-based tool approvals - remembers which tools user approved for this turn
93
+ private sessionApprovedTools: Set<string> = new Set();
91
94
 
92
95
  constructor(
93
96
  logger: Logger,
94
97
  cwd: string,
95
- permissionCallback: (action: string) => Promise<boolean>,
98
+ permissionCallback: (action: string, options?: { batchApproval?: boolean }) => Promise<boolean | 'batch'>,
96
99
  autoApprove: boolean = false
97
100
  ) {
98
101
  this.logger = logger;
@@ -101,6 +104,20 @@ export class AgenticTools {
101
104
  this.autoApprove = autoApprove;
102
105
  }
103
106
 
107
+ /**
108
+ * Clear session-approved tools (call this at the start of each new AI turn)
109
+ */
110
+ clearSessionApprovals(): void {
111
+ this.sessionApprovedTools.clear();
112
+ }
113
+
114
+ /**
115
+ * Get currently approved tools for this session
116
+ */
117
+ getSessionApprovedTools(): string[] {
118
+ return Array.from(this.sessionApprovedTools);
119
+ }
120
+
104
121
  /**
105
122
  * Get the undo stack for inspection
106
123
  */
@@ -278,6 +295,34 @@ export class AgenticTools {
278
295
  riskLevel: 'medium',
279
296
  category: 'execute',
280
297
  },
298
+ {
299
+ name: 'fetch_url',
300
+ description: 'Fetch content from a URL (web page, API endpoint). Works cross-platform on Windows, Mac, and Linux.',
301
+ parameters: [
302
+ { name: 'url', description: 'URL to fetch (must be http or https)', required: true },
303
+ { name: 'method', description: 'HTTP method (GET, POST, etc.)', required: false },
304
+ { name: 'headers', description: 'JSON string of headers to send', required: false },
305
+ { name: 'body', description: 'Request body for POST/PUT requests', required: false },
306
+ { name: 'selector', description: 'CSS selector to extract specific content (for HTML)', required: false },
307
+ ],
308
+ requiresPermission: true,
309
+ dangerous: false,
310
+ riskLevel: 'low',
311
+ category: 'read',
312
+ },
313
+ {
314
+ name: 'ssh_exec',
315
+ description: 'Execute a command on a remote server via SSH (useful for Unix commands from Windows)',
316
+ parameters: [
317
+ { name: 'command', description: 'Command to execute on remote server', required: true },
318
+ { name: 'host', description: 'SSH host (default: vigthoria-server)', required: false },
319
+ { name: 'timeout', description: 'Timeout in seconds', required: false },
320
+ ],
321
+ requiresPermission: true,
322
+ dangerous: true,
323
+ riskLevel: 'high',
324
+ category: 'execute',
325
+ },
281
326
  ];
282
327
  }
283
328
 
@@ -303,16 +348,28 @@ export class AgenticTools {
303
348
 
304
349
  // Check permission for dangerous/modifying actions
305
350
  if (tool.requiresPermission && !this.autoApprove) {
306
- const approved = await this.permissionCallback(
307
- this.formatPermissionRequest(call, tool)
308
- );
309
-
310
- if (!approved) {
311
- return {
312
- success: false,
313
- error: 'Permission denied by user',
314
- canRetry: true,
315
- };
351
+ // Check if this tool was already approved for this session/turn
352
+ if (this.sessionApprovedTools.has(call.tool)) {
353
+ // Already approved - skip permission prompt
354
+ this.logger.info(`${call.tool}: Auto-approved (batch)`);
355
+ } else {
356
+ const approved = await this.permissionCallback(
357
+ this.formatPermissionRequest(call, tool),
358
+ { batchApproval: true }
359
+ );
360
+
361
+ if (approved === false) {
362
+ return {
363
+ success: false,
364
+ error: 'Permission denied by user',
365
+ canRetry: true,
366
+ };
367
+ }
368
+
369
+ // If user approved with 'batch' or just 'yes', remember for this session
370
+ if (approved === 'batch' || approved === true) {
371
+ this.sessionApprovedTools.add(call.tool);
372
+ }
316
373
  }
317
374
  }
318
375
 
@@ -394,6 +451,10 @@ export class AgenticTools {
394
451
  return this.git(call.args);
395
452
  case 'repo':
396
453
  return this.repo(call.args);
454
+ case 'fetch_url':
455
+ return this.fetchUrl(call.args);
456
+ case 'ssh_exec':
457
+ return this.sshExec(call.args);
397
458
  default:
398
459
  return this.createErrorResult(
399
460
  ToolErrorType.INVALID_ARGS,
@@ -507,6 +568,9 @@ export class AgenticTools {
507
568
  msg += chalk.red(' The AI is requesting to execute commands on your system.\n');
508
569
  }
509
570
 
571
+ // Add batch approval hint
572
+ msg += `\n${chalk.gray('Respond:')} ${chalk.white('[y]es')} ${chalk.gray('/')} ${chalk.white('[n]o')} ${chalk.gray('/')} ${chalk.cyan('[a]pprove all')} ${chalk.cyan(call.tool)} ${chalk.gray('for this turn')}\n`;
573
+
510
574
  return msg;
511
575
  }
512
576
 
@@ -727,10 +791,51 @@ export class AgenticTools {
727
791
  /**
728
792
  * Execute bash command with enhanced error handling
729
793
  * SECURITY: Commands are sandboxed to workspace directory
794
+ * WINDOWS: Detects Unix-specific commands and suggests alternatives
730
795
  */
731
796
  private bash(args: Record<string, string>): ToolResult {
732
797
  const cwd = args.cwd ? this.resolvePath(args.cwd) : this.cwd;
733
798
  const timeout = args.timeout ? parseInt(args.timeout) * 1000 : 30000;
799
+ const os = require('os');
800
+ const platform = os.platform();
801
+
802
+ // Unix-specific commands that don't exist on Windows
803
+ const unixOnlyCommands = [
804
+ 'head', 'tail', 'grep', 'awk', 'sed', 'wc', 'cut', 'sort', 'uniq',
805
+ 'xargs', 'find', 'chmod', 'chown', 'ln', 'df', 'du', 'ps', 'kill',
806
+ 'top', 'htop', 'which', 'whereis', 'man', 'less', 'more', 'cat',
807
+ ];
808
+
809
+ // Check if command uses Unix-only tools on Windows
810
+ if (platform === 'win32') {
811
+ const cmdParts = args.command.split(/[|&;]/);
812
+ for (const part of cmdParts) {
813
+ const firstWord = part.trim().split(/\s+/)[0].toLowerCase();
814
+ if (unixOnlyCommands.includes(firstWord)) {
815
+ return this.createErrorResult(
816
+ ToolErrorType.EXECUTION_FAILED,
817
+ `Command '${firstWord}' is not available on Windows`,
818
+ `Use the 'ssh_exec' tool to run Unix commands on the server, ` +
819
+ `or use 'fetch_url' for web requests. ` +
820
+ `PowerShell alternatives: dir (ls), type (cat), findstr (grep), select-string (grep)`
821
+ );
822
+ }
823
+ }
824
+
825
+ // Check for pipe to Unix command
826
+ if (args.command.includes('|')) {
827
+ const pipedCommands = args.command.split('|').map(c => c.trim().split(/\s+/)[0].toLowerCase());
828
+ for (const cmd of pipedCommands) {
829
+ if (unixOnlyCommands.includes(cmd)) {
830
+ return this.createErrorResult(
831
+ ToolErrorType.EXECUTION_FAILED,
832
+ `Piped command '${cmd}' is not available on Windows`,
833
+ `Windows doesn't have '${cmd}'. Use 'ssh_exec' tool to run this command on the Vigthoria server instead.`
834
+ );
835
+ }
836
+ }
837
+ }
838
+ }
734
839
 
735
840
  // SECURITY: Block dangerous commands that could access outside workspace
736
841
  const blockedPatterns = [
@@ -771,8 +876,6 @@ export class AgenticTools {
771
876
 
772
877
  try {
773
878
  const startTime = Date.now();
774
- const os = require('os');
775
- const platform = os.platform();
776
879
 
777
880
  // Build exec options based on platform
778
881
  const execOptions: any = {
@@ -1051,6 +1154,217 @@ export class AgenticTools {
1051
1154
  }
1052
1155
  }
1053
1156
 
1157
+ /**
1158
+ * Fetch URL content - Cross-platform web fetching
1159
+ * Uses Node.js native fetch (available in Node 18+)
1160
+ */
1161
+ private async fetchUrl(args: Record<string, string>): Promise<ToolResult> {
1162
+ const url = args.url;
1163
+ const method = (args.method || 'GET').toUpperCase();
1164
+
1165
+ // Validate URL
1166
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
1167
+ return this.createErrorResult(
1168
+ ToolErrorType.INVALID_ARGS,
1169
+ 'URL must start with http:// or https://',
1170
+ 'Provide a valid URL, e.g., https://example.com'
1171
+ );
1172
+ }
1173
+
1174
+ try {
1175
+ const fetchOptions: RequestInit = {
1176
+ method,
1177
+ headers: {
1178
+ 'User-Agent': 'Vigthoria-CLI/1.5.1',
1179
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
1180
+ },
1181
+ };
1182
+
1183
+ // Parse custom headers if provided
1184
+ if (args.headers) {
1185
+ try {
1186
+ const customHeaders = JSON.parse(args.headers);
1187
+ fetchOptions.headers = { ...fetchOptions.headers, ...customHeaders };
1188
+ } catch {
1189
+ this.logger.warn('Invalid headers JSON, using defaults');
1190
+ }
1191
+ }
1192
+
1193
+ // Add body for POST/PUT requests
1194
+ if (args.body && ['POST', 'PUT', 'PATCH'].includes(method)) {
1195
+ fetchOptions.body = args.body;
1196
+ (fetchOptions.headers as Record<string, string>)['Content-Type'] = 'application/json';
1197
+ }
1198
+
1199
+ // Use AbortController for timeout
1200
+ const controller = new AbortController();
1201
+ const timeout = setTimeout(() => controller.abort(), 30000);
1202
+ fetchOptions.signal = controller.signal;
1203
+
1204
+ const response = await fetch(url, fetchOptions);
1205
+ clearTimeout(timeout);
1206
+
1207
+ if (!response.ok) {
1208
+ return this.createErrorResult(
1209
+ ToolErrorType.NETWORK_ERROR,
1210
+ `HTTP ${response.status}: ${response.statusText}`,
1211
+ `The server returned an error. Status: ${response.status}`
1212
+ );
1213
+ }
1214
+
1215
+ let content = await response.text();
1216
+
1217
+ // Extract content based on selector if provided (basic HTML extraction)
1218
+ if (args.selector && content.includes('<')) {
1219
+ // Basic extraction - for title, meta, etc.
1220
+ const selector = args.selector.toLowerCase();
1221
+
1222
+ if (selector === 'title') {
1223
+ const match = content.match(/<title[^>]*>([^<]*)<\/title>/i);
1224
+ content = match ? match[1].trim() : 'No title found';
1225
+ } else if (selector === 'meta') {
1226
+ const matches = content.match(/<meta[^>]+>/gi) || [];
1227
+ content = matches.join('\n');
1228
+ } else if (selector === 'body') {
1229
+ const match = content.match(/<body[^>]*>([\s\S]*)<\/body>/i);
1230
+ content = match ? match[1].replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() : content;
1231
+ } else if (selector.startsWith('h')) {
1232
+ const regex = new RegExp(`<${selector}[^>]*>([^<]*)</${selector}>`, 'gi');
1233
+ const matches: string[] = [];
1234
+ let match;
1235
+ while ((match = regex.exec(content)) !== null) {
1236
+ matches.push(match[1].trim());
1237
+ }
1238
+ content = matches.length > 0 ? matches.join('\n') : `No ${selector} tags found`;
1239
+ }
1240
+ }
1241
+
1242
+ // Truncate if too long
1243
+ if (content.length > 50000) {
1244
+ content = content.substring(0, 50000) + '\n... (truncated, content too long)';
1245
+ }
1246
+
1247
+ return {
1248
+ success: true,
1249
+ output: content,
1250
+ metadata: {
1251
+ url,
1252
+ status: response.status,
1253
+ contentType: response.headers.get('content-type') || 'unknown',
1254
+ contentLength: content.length,
1255
+ },
1256
+ };
1257
+ } catch (error: any) {
1258
+ if (error.name === 'AbortError') {
1259
+ return this.createErrorResult(
1260
+ ToolErrorType.TIMEOUT,
1261
+ 'Request timed out after 30 seconds',
1262
+ 'The server took too long to respond. Try again or use a different URL.'
1263
+ );
1264
+ }
1265
+
1266
+ return this.createErrorResult(
1267
+ ToolErrorType.NETWORK_ERROR,
1268
+ error.message,
1269
+ 'Check your internet connection and ensure the URL is correct.'
1270
+ );
1271
+ }
1272
+ }
1273
+
1274
+ /**
1275
+ * Execute command via SSH on remote server
1276
+ * Useful for running Unix commands from Windows
1277
+ */
1278
+ private async sshExec(args: Record<string, string>): Promise<ToolResult> {
1279
+ const command = args.command;
1280
+ const host = args.host || 'vigthoria-server';
1281
+ const timeout = args.timeout ? parseInt(args.timeout) * 1000 : 60000;
1282
+
1283
+ // Security checks for SSH commands
1284
+ const blockedPatterns = [
1285
+ /\brm\s+-rf?\s+\//i, // Dangerous rm commands
1286
+ /\bsudo\s+rm/i, // Sudo rm
1287
+ />\s*\/dev\/sd/i, // Writing to disk devices
1288
+ /\bdd\s+.*of=\/dev/i, // dd to devices
1289
+ /\bmkfs/i, // Format filesystems
1290
+ /shutdown|reboot|halt|poweroff/i, // System control
1291
+ ];
1292
+
1293
+ for (const pattern of blockedPatterns) {
1294
+ if (pattern.test(command)) {
1295
+ return this.createErrorResult(
1296
+ ToolErrorType.PERMISSION_DENIED,
1297
+ 'This command is blocked for security reasons',
1298
+ 'Dangerous system commands cannot be executed via SSH.'
1299
+ );
1300
+ }
1301
+ }
1302
+
1303
+ try {
1304
+ const os = require('os');
1305
+ const platform = os.platform();
1306
+
1307
+ let sshCommand: string;
1308
+ let execOptions: any = {
1309
+ encoding: 'utf-8',
1310
+ timeout,
1311
+ maxBuffer: 10 * 1024 * 1024,
1312
+ };
1313
+
1314
+ if (platform === 'win32') {
1315
+ // On Windows, use the ssh command from OpenSSH
1316
+ sshCommand = `ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${host} "${command.replace(/"/g, '\\"')}"`;
1317
+ execOptions.shell = true;
1318
+ } else {
1319
+ // On Unix-like systems
1320
+ sshCommand = `ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${host} '${command.replace(/'/g, "'\\''")}'`;
1321
+ execOptions.shell = '/bin/sh';
1322
+ }
1323
+
1324
+ const output = execSync(sshCommand, execOptions) as string;
1325
+
1326
+ return {
1327
+ success: true,
1328
+ output: output.trim(),
1329
+ metadata: { host, command },
1330
+ };
1331
+ } catch (error: any) {
1332
+ // Check for common SSH errors
1333
+ const errorMsg = error.stderr || error.message || '';
1334
+
1335
+ if (errorMsg.includes('Connection refused') || errorMsg.includes('No route to host')) {
1336
+ return this.createErrorResult(
1337
+ ToolErrorType.NETWORK_ERROR,
1338
+ `Cannot connect to SSH host: ${host}`,
1339
+ 'Check that the SSH host is correct and the server is running.'
1340
+ );
1341
+ }
1342
+
1343
+ if (errorMsg.includes('Permission denied') || errorMsg.includes('authentication')) {
1344
+ return this.createErrorResult(
1345
+ ToolErrorType.PERMISSION_DENIED,
1346
+ 'SSH authentication failed',
1347
+ 'Make sure you have SSH key authentication set up for this host.'
1348
+ );
1349
+ }
1350
+
1351
+ if (error.killed) {
1352
+ return this.createErrorResult(
1353
+ ToolErrorType.TIMEOUT,
1354
+ `SSH command timed out after ${timeout/1000}s`,
1355
+ 'Try a simpler command or increase the timeout.'
1356
+ );
1357
+ }
1358
+
1359
+ return {
1360
+ success: false,
1361
+ output: error.stdout || '',
1362
+ error: errorMsg,
1363
+ suggestion: 'Check the command syntax and ensure SSH access is configured.',
1364
+ };
1365
+ }
1366
+ }
1367
+
1054
1368
  /**
1055
1369
  * Resolve and SANITIZE path - prevent path traversal outside workspace
1056
1370
  * SECURITY: All paths MUST stay within the workspace (cwd)
@@ -1274,21 +1588,44 @@ To use a tool, output a JSON block in a code fence with "tool" language:
1274
1588
  {"tool": "read_file", "args": {"path": "src/index.js"}}
1275
1589
  \`\`\`
1276
1590
 
1277
- 3. Run a shell command:
1591
+ 3. Fetch a web page:
1592
+ \`\`\`tool
1593
+ {"tool": "fetch_url", "args": {"url": "https://example.com", "selector": "title"}}
1594
+ \`\`\`
1595
+
1596
+ 4. Run command on remote server (for Unix commands from Windows):
1278
1597
  \`\`\`tool
1279
- {"tool": "bash", "args": {"command": "ls -la"}}
1598
+ {"tool": "ssh_exec", "args": {"command": "curl -s https://example.com | head -20"}}
1280
1599
  \`\`\`
1281
1600
 
1282
- 4. Write a file:
1601
+ 5. Write a file:
1283
1602
  \`\`\`tool
1284
1603
  {"tool": "write_file", "args": {"path": "hello.py", "content": "print('Hello World')"}}
1285
1604
  \`\`\`
1286
1605
 
1287
- IMPORTANT:
1606
+ ## CRITICAL RULES:
1607
+
1608
+ ### Cross-Platform Compatibility:
1609
+ - On Windows, Unix commands (head, tail, grep, awk, sed, wc) are NOT available
1610
+ - Use \`fetch_url\` for web requests instead of curl|grep
1611
+ - Use \`ssh_exec\` to run Unix commands on the Vigthoria server if needed
1612
+ - Use \`read_file\` instead of cat
1613
+ - Use \`list_dir\` instead of ls
1614
+
1615
+ ### Handling Tool Failures:
1616
+ - If a tool fails, REPORT the failure honestly to the user
1617
+ - NEVER make up or hallucinate content when a tool fails
1618
+ - If you cannot access a URL, say "I was unable to fetch the URL because..."
1619
+ - If a command fails, explain what happened and suggest alternatives
1620
+ - Do NOT write analysis or comparison reports if you couldn't gather the actual data
1621
+
1622
+ ### File Access:
1288
1623
  - You can ONLY access files within the current project workspace
1289
1624
  - Use relative paths (e.g., "src/file.js", "app.py", "./config.json")
1290
1625
  - Never try to access system files or directories outside the workspace
1291
- - Use ONLY the exact tool names: list_dir, read_file, write_file, edit_file, bash, grep, glob, git
1626
+
1627
+ ### Tool Names:
1628
+ - Use ONLY these exact tool names: list_dir, read_file, write_file, edit_file, bash, grep, glob, git, repo, fetch_url, ssh_exec
1292
1629
  - The JSON must be valid with double quotes for all keys and string values
1293
1630
  - After tool execution, you will receive results and can continue with the next step
1294
1631
  - Explain what you're doing before using tools