@calliopelabs/cli 2.0.6 → 2.1.1

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/tools.js CHANGED
@@ -14,6 +14,7 @@ import { getPluginTools, isPluginTool, executePluginTool } from './plugins.js';
14
14
  import config from './config.js';
15
15
  import { applySkin, applyPalette, listSkins, listPalettes } from './hud/api.js';
16
16
  import { listCompanions } from './companions.js';
17
+ import { generateDiff as generateFileDiff } from './diff.js';
17
18
  /**
18
19
  * Available tools for the agent
19
20
  */
@@ -197,6 +198,76 @@ export const TOOLS = [
197
198
  required: ['operation'],
198
199
  },
199
200
  },
201
+ {
202
+ name: 'edit_file',
203
+ description: 'Edit a file by replacing an exact string. Prefer this over write_file for modifications. Fails if old_string is not found or appears multiple times (use replace_all for intentional multi-replace).',
204
+ parameters: {
205
+ type: 'object',
206
+ properties: {
207
+ path: {
208
+ type: 'string',
209
+ description: 'The path to the file to edit',
210
+ },
211
+ old_string: {
212
+ type: 'string',
213
+ description: 'The exact string to find and replace',
214
+ },
215
+ new_string: {
216
+ type: 'string',
217
+ description: 'The string to replace old_string with',
218
+ },
219
+ replace_all: {
220
+ type: 'boolean',
221
+ description: 'If true, replace all occurrences. If false (default), fails when multiple matches exist.',
222
+ },
223
+ },
224
+ required: ['path', 'old_string', 'new_string'],
225
+ },
226
+ },
227
+ {
228
+ name: 'glob',
229
+ description: 'Find files matching a glob pattern (e.g. **/*.ts, src/**/*.json). Returns paths relative to cwd, sorted.',
230
+ parameters: {
231
+ type: 'object',
232
+ properties: {
233
+ pattern: {
234
+ type: 'string',
235
+ description: 'Glob pattern to match files against (e.g. **/*.ts, src/**/*.json)',
236
+ },
237
+ cwd: {
238
+ type: 'string',
239
+ description: 'Directory to search in (default: current working directory)',
240
+ },
241
+ },
242
+ required: ['pattern'],
243
+ },
244
+ },
245
+ {
246
+ name: 'grep',
247
+ description: 'Search file contents for a pattern. Returns matching lines with file path and line number.',
248
+ parameters: {
249
+ type: 'object',
250
+ properties: {
251
+ pattern: {
252
+ type: 'string',
253
+ description: 'The regex or literal string pattern to search for',
254
+ },
255
+ path: {
256
+ type: 'string',
257
+ description: 'Directory or file to search in (default: ".")',
258
+ },
259
+ glob: {
260
+ type: 'string',
261
+ description: 'Filter files by glob pattern (e.g. "*.ts")',
262
+ },
263
+ case_insensitive: {
264
+ type: 'boolean',
265
+ description: 'If true, perform case-insensitive matching',
266
+ },
267
+ },
268
+ required: ['pattern'],
269
+ },
270
+ },
200
271
  {
201
272
  name: 'configure',
202
273
  description: `Read, set, or list Calliope configuration options. Use this when the user asks to change settings, switch themes, providers, models, companions, or any preference through natural conversation. Always use action "list" first if you need to show available options.
@@ -560,6 +631,38 @@ export async function executeTool(toolCall, cwd, timeout = 60000, onOutput) {
560
631
  result = generateMermaidDiagram(diagramType, args.content, title);
561
632
  break;
562
633
  }
634
+ case 'edit_file': {
635
+ if (typeof args.path !== 'string') {
636
+ return { toolCallId: id, result: 'Error: path must be a string', isError: true };
637
+ }
638
+ if (typeof args.old_string !== 'string') {
639
+ return { toolCallId: id, result: 'Error: old_string must be a string', isError: true };
640
+ }
641
+ if (typeof args.new_string !== 'string') {
642
+ return { toolCallId: id, result: 'Error: new_string must be a string', isError: true };
643
+ }
644
+ const replaceAll = args.replace_all === true;
645
+ result = await editFile(args.path, args.old_string, args.new_string, replaceAll, cwd);
646
+ break;
647
+ }
648
+ case 'glob': {
649
+ if (typeof args.pattern !== 'string') {
650
+ return { toolCallId: id, result: 'Error: pattern must be a string', isError: true };
651
+ }
652
+ const globCwd = typeof args.cwd === 'string' ? args.cwd : cwd;
653
+ result = await globFiles(args.pattern, globCwd);
654
+ break;
655
+ }
656
+ case 'grep': {
657
+ if (typeof args.pattern !== 'string') {
658
+ return { toolCallId: id, result: 'Error: pattern must be a string', isError: true };
659
+ }
660
+ const grepPath = typeof args.path === 'string' ? args.path : '.';
661
+ const grepGlob = typeof args.glob === 'string' ? args.glob : undefined;
662
+ const caseInsensitive = args.case_insensitive === true;
663
+ result = await grepFiles(args.pattern, grepPath, cwd, grepGlob, caseInsensitive);
664
+ break;
665
+ }
563
666
  default:
564
667
  return { toolCallId: id, result: `Unknown tool: ${name}`, isError: true };
565
668
  }
@@ -836,7 +939,16 @@ async function readFile(filePath, cwd) {
836
939
  if (stats.size > 1024 * 1024) {
837
940
  throw new Error(`File too large (${Math.round(stats.size / 1024)}KB). Max 1MB.`);
838
941
  }
839
- return fs.readFileSync(absPath, 'utf-8');
942
+ const content = fs.readFileSync(absPath, 'utf-8');
943
+ // Inline file preview header (#119)
944
+ const PREVIEW_CAP = 20;
945
+ const allLines = content.split('\n');
946
+ const totalLines = allLines.length;
947
+ const previewLines = allLines.slice(0, PREVIEW_CAP);
948
+ const header = `[file: ${filePath} \u2014 ${totalLines} line${totalLines !== 1 ? 's' : ''}]\n${'─'.repeat(40)}`;
949
+ const previewBody = previewLines.join('\n');
950
+ const footer = totalLines > PREVIEW_CAP ? `\n... (${totalLines - PREVIEW_CAP} more lines)` : '';
951
+ return `${header}\n${previewBody}${footer}\n\n${content}`;
840
952
  }
841
953
  /**
842
954
  * Generate a simple line-diff between old and new content
@@ -934,24 +1046,42 @@ async function writeFile(filePath, content, cwd) {
934
1046
  }
935
1047
  }
936
1048
  fs.writeFileSync(absPath, content);
937
- // Generate diff output
1049
+ // Generate diff output (#119)
1050
+ const DIFF_CAP = 50;
1051
+ const header = `[wrote: ${filePath}]\n${'─'.repeat(Math.min(filePath.length + 9, 60))}`;
938
1052
  if (isNewFile) {
939
1053
  const allLines = content.split('\n');
940
- const lines = allLines.slice(0, 10);
941
- const lineNumWidth = Math.max(4, allLines.length.toString().length);
942
- const padNum = (n) => String(n).padStart(lineNumWidth, ' ');
943
- const preview = lines.map((l, i) => `${padNum(i + 1)} + ${l}`).join('\n');
944
- const more = allLines.length > 10 ? '\n ... (new file truncated)' : '';
945
- return `DIFF:NEW_FILE:${absPath}\n⎿ Added ${allLines.length} lines\n${preview}${more}`;
1054
+ const previewLines = allLines.slice(0, DIFF_CAP);
1055
+ const diffLines = previewLines.map(l => `+${l}`);
1056
+ const more = allLines.length > DIFF_CAP ? `\n... (${allLines.length - DIFF_CAP} more lines)` : '';
1057
+ return `${header}\n[new file: ${filePath}]\n--- /dev/null\n+++ b/${filePath}\n${diffLines.join('\n')}${more}`;
946
1058
  }
947
1059
  else {
948
- const diff = generateDiff(oldContent, content);
949
- if (diff.trim()) {
950
- return `DIFF:${absPath}\n${diff}`;
1060
+ const fileDiff = generateFileDiff(oldContent, content, filePath);
1061
+ const diffParts = [
1062
+ `--- a/${filePath}`,
1063
+ `+++ b/${filePath}`,
1064
+ ];
1065
+ const diffLines = fileDiff.lines.filter(l => l.type !== 'header');
1066
+ let lineCount = 0;
1067
+ let truncated = false;
1068
+ for (const line of diffLines) {
1069
+ if (line.type === 'context')
1070
+ continue; // skip context for compact output
1071
+ if (lineCount >= DIFF_CAP) {
1072
+ truncated = true;
1073
+ break;
1074
+ }
1075
+ const prefix = line.type === 'add' ? '+' : '-';
1076
+ diffParts.push(`${prefix}${line.content}`);
1077
+ lineCount++;
951
1078
  }
952
- else {
953
- return `File unchanged: ${absPath}`;
1079
+ if (truncated)
1080
+ diffParts.push(`... (diff truncated at ${DIFF_CAP} lines)`);
1081
+ if (lineCount === 0) {
1082
+ return `File unchanged: ${filePath}`;
954
1083
  }
1084
+ return `${header}\n${diffParts.join('\n')}`;
955
1085
  }
956
1086
  }
957
1087
  /**
@@ -1213,4 +1343,258 @@ ${content}
1213
1343
  \`\`\``;
1214
1344
  return `MERMAID_DIAGRAM:\n${diagram}\n\nTo view this diagram, paste the mermaid code into https://mermaid.live or a markdown viewer that supports Mermaid.`;
1215
1345
  }
1346
+ /**
1347
+ * Edit a file by replacing an exact string (in-place edit).
1348
+ * Supports single-occurrence enforcement or replace_all mode.
1349
+ */
1350
+ async function editFile(filePath, oldString, newString, replaceAll, cwd) {
1351
+ const absPath = validatePath(filePath, cwd);
1352
+ if (!fs.existsSync(absPath)) {
1353
+ throw new Error(`File not found: ${absPath}`);
1354
+ }
1355
+ const stats = fs.statSync(absPath);
1356
+ if (stats.isDirectory()) {
1357
+ throw new Error(`Path is a directory: ${absPath}`);
1358
+ }
1359
+ const content = fs.readFileSync(absPath, 'utf-8');
1360
+ // Helper to build a compact edit diff from old_string/new_string (#119)
1361
+ const buildEditDiff = (oldStr, newStr, fPath, count) => {
1362
+ const DIFF_CAP = 50;
1363
+ const label = count === 1 ? '1 occurrence' : `${count} occurrences`;
1364
+ const header = `[edited: ${fPath} — replaced ${label}]\n${'─'.repeat(Math.min(fPath.length + 10, 60))}`;
1365
+ const oldLines = oldStr.split('\n');
1366
+ const newLines = newStr.split('\n');
1367
+ const diffParts = [
1368
+ `--- a/${fPath}`,
1369
+ `+++ b/${fPath}`,
1370
+ ];
1371
+ let lineCount = 0;
1372
+ let truncated = false;
1373
+ for (const line of oldLines) {
1374
+ if (lineCount >= DIFF_CAP) {
1375
+ truncated = true;
1376
+ break;
1377
+ }
1378
+ diffParts.push(`-${line}`);
1379
+ lineCount++;
1380
+ }
1381
+ if (!truncated) {
1382
+ for (const line of newLines) {
1383
+ if (lineCount >= DIFF_CAP) {
1384
+ truncated = true;
1385
+ break;
1386
+ }
1387
+ diffParts.push(`+${line}`);
1388
+ lineCount++;
1389
+ }
1390
+ }
1391
+ if (truncated)
1392
+ diffParts.push(`... (diff truncated at ${DIFF_CAP} lines)`);
1393
+ return `${header}\n${diffParts.join('\n')}`;
1394
+ };
1395
+ if (replaceAll) {
1396
+ const updated = content.replaceAll(oldString, newString);
1397
+ const count = (content.split(oldString).length - 1);
1398
+ if (count === 0) {
1399
+ throw new Error(`old_string not found in file: ${absPath}`);
1400
+ }
1401
+ fs.writeFileSync(absPath, updated);
1402
+ return buildEditDiff(oldString, newString, filePath, count);
1403
+ }
1404
+ // Count occurrences
1405
+ const occurrences = content.split(oldString).length - 1;
1406
+ if (occurrences === 0) {
1407
+ throw new Error(`old_string not found in file: ${absPath}`);
1408
+ }
1409
+ if (occurrences > 1) {
1410
+ throw new Error(`old_string matches ${occurrences} occurrences — use replace_all: true or make it more specific`);
1411
+ }
1412
+ const updated = content.replace(oldString, newString);
1413
+ fs.writeFileSync(absPath, updated);
1414
+ return buildEditDiff(oldString, newString, filePath, 1);
1415
+ }
1416
+ /**
1417
+ * Convert a glob pattern to a RegExp.
1418
+ * Supports: *, **, ?, {a,b} syntax.
1419
+ */
1420
+ function globToRegex(pattern) {
1421
+ let regexStr = '';
1422
+ let i = 0;
1423
+ while (i < pattern.length) {
1424
+ const ch = pattern[i];
1425
+ if (ch === '*') {
1426
+ if (pattern[i + 1] === '*') {
1427
+ // ** matches any path segment including slashes
1428
+ regexStr += '.*';
1429
+ i += 2;
1430
+ // Consume optional trailing slash
1431
+ if (pattern[i] === '/')
1432
+ i++;
1433
+ }
1434
+ else {
1435
+ // * matches anything except /
1436
+ regexStr += '[^/]*';
1437
+ i++;
1438
+ }
1439
+ }
1440
+ else if (ch === '?') {
1441
+ regexStr += '[^/]';
1442
+ i++;
1443
+ }
1444
+ else if (ch === '{') {
1445
+ // {a,b,c} → (a|b|c)
1446
+ const end = pattern.indexOf('}', i);
1447
+ if (end === -1) {
1448
+ regexStr += '\\{';
1449
+ i++;
1450
+ }
1451
+ else {
1452
+ const options = pattern.slice(i + 1, end).split(',').map(s => s.replace(/[.+^$[\]\\(){}|]/g, '\\$&'));
1453
+ regexStr += `(?:${options.join('|')})`;
1454
+ i = end + 1;
1455
+ }
1456
+ }
1457
+ else if ('.+^$[]\\(){}|'.includes(ch)) {
1458
+ regexStr += '\\' + ch;
1459
+ i++;
1460
+ }
1461
+ else {
1462
+ regexStr += ch;
1463
+ i++;
1464
+ }
1465
+ }
1466
+ return new RegExp(`^${regexStr}$`);
1467
+ }
1468
+ /**
1469
+ * Recursively walk a directory and collect all file paths relative to the base.
1470
+ */
1471
+ function walkDir(dir, base, results) {
1472
+ let entries;
1473
+ try {
1474
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1475
+ }
1476
+ catch {
1477
+ return;
1478
+ }
1479
+ for (const entry of entries) {
1480
+ const fullPath = path.join(dir, entry.name);
1481
+ const relPath = path.relative(base, fullPath);
1482
+ if (entry.isDirectory()) {
1483
+ walkDir(fullPath, base, results);
1484
+ }
1485
+ else {
1486
+ results.push(relPath);
1487
+ }
1488
+ }
1489
+ }
1490
+ /**
1491
+ * Find files matching a glob pattern.
1492
+ */
1493
+ async function globFiles(pattern, searchCwd) {
1494
+ const absCwd = path.isAbsolute(searchCwd)
1495
+ ? path.resolve(searchCwd)
1496
+ : path.resolve(searchCwd);
1497
+ let exists = false;
1498
+ try {
1499
+ exists = fs.existsSync(absCwd) && fs.statSync(absCwd).isDirectory();
1500
+ }
1501
+ catch {
1502
+ exists = false;
1503
+ }
1504
+ if (!exists) {
1505
+ throw new Error(`Directory not found: ${absCwd}`);
1506
+ }
1507
+ const regex = globToRegex(pattern);
1508
+ const allFiles = [];
1509
+ walkDir(absCwd, absCwd, allFiles);
1510
+ // Normalize to forward slashes for matching (glob convention)
1511
+ const matched = allFiles
1512
+ .filter(f => regex.test(f.replace(/\\/g, '/')))
1513
+ .sort();
1514
+ if (matched.length === 0) {
1515
+ return `No files matched pattern: ${pattern}`;
1516
+ }
1517
+ return matched.join('\n');
1518
+ }
1519
+ /**
1520
+ * Search file contents using a regex pattern (or literal string fallback).
1521
+ */
1522
+ async function grepFiles(pattern, searchPath, cwd, globPattern, caseInsensitive) {
1523
+ // Resolve search path relative to cwd
1524
+ const absSearchPath = path.isAbsolute(searchPath)
1525
+ ? path.resolve(searchPath)
1526
+ : path.resolve(cwd, searchPath);
1527
+ // Validate access
1528
+ try {
1529
+ validatePath(absSearchPath, cwd);
1530
+ }
1531
+ catch {
1532
+ throw new Error(`Access denied: ${searchPath} is outside allowed scope`);
1533
+ }
1534
+ if (!fs.existsSync(absSearchPath)) {
1535
+ throw new Error(`Path not found: ${absSearchPath}`);
1536
+ }
1537
+ // Build the regex, fallback to literal if invalid
1538
+ let regex;
1539
+ try {
1540
+ regex = new RegExp(pattern, caseInsensitive ? 'i' : '');
1541
+ }
1542
+ catch {
1543
+ // Treat as literal string
1544
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1545
+ regex = new RegExp(escaped, caseInsensitive ? 'i' : '');
1546
+ }
1547
+ // Collect files to search
1548
+ let filesToSearch;
1549
+ const stat = fs.statSync(absSearchPath);
1550
+ if (stat.isFile()) {
1551
+ filesToSearch = [absSearchPath];
1552
+ }
1553
+ else {
1554
+ const all = [];
1555
+ walkDir(absSearchPath, absSearchPath, all);
1556
+ filesToSearch = all.map(f => path.join(absSearchPath, f));
1557
+ }
1558
+ // Apply glob filter if provided
1559
+ if (globPattern) {
1560
+ const globRegex = globToRegex(globPattern);
1561
+ filesToSearch = filesToSearch.filter(f => {
1562
+ const basename = path.basename(f);
1563
+ return globRegex.test(basename) || globRegex.test(f.replace(/\\/g, '/'));
1564
+ });
1565
+ }
1566
+ const results = [];
1567
+ const MAX_RESULTS = 200;
1568
+ for (const filePath of filesToSearch) {
1569
+ if (results.length >= MAX_RESULTS)
1570
+ break;
1571
+ let content;
1572
+ try {
1573
+ const fileStat = fs.statSync(filePath);
1574
+ if (fileStat.size > 5 * 1024 * 1024)
1575
+ continue; // skip files > 5MB
1576
+ content = fs.readFileSync(filePath, 'utf-8');
1577
+ }
1578
+ catch {
1579
+ continue;
1580
+ }
1581
+ const lines = content.split('\n');
1582
+ const relPath = path.relative(cwd, filePath);
1583
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
1584
+ if (results.length >= MAX_RESULTS)
1585
+ break;
1586
+ if (regex.test(lines[lineIdx])) {
1587
+ results.push(`${relPath}:${lineIdx + 1}: ${lines[lineIdx]}`);
1588
+ }
1589
+ }
1590
+ }
1591
+ if (results.length === 0) {
1592
+ return 'No matches found';
1593
+ }
1594
+ let output = results.join('\n');
1595
+ if (results.length >= MAX_RESULTS) {
1596
+ output += `\n(results truncated at ${MAX_RESULTS} matches)`;
1597
+ }
1598
+ return output;
1599
+ }
1216
1600
  //# sourceMappingURL=tools.js.map