@oxecli/oxe 1.0.51 → 1.0.53

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
@@ -6,14 +6,22 @@ import { max_diff_source_chars, max_diff_lines, max_diff_context_lines, max_diff
6
6
  import { toolLoadSkill } from "./skills.js";
7
7
  export { toolLoadSkill };
8
8
  // ---------------------------------------------------------------------------
9
+ // Path sanitization helper
10
+ // ---------------------------------------------------------------------------
11
+ function sanitizePath(p) {
12
+ if (!p)
13
+ return "";
14
+ return String(p).trim().replace(/^["']|["']$/g, "");
15
+ }
16
+ function escapeRegExp(string) {
17
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18
+ }
19
+ // ---------------------------------------------------------------------------
9
20
  // Natural sort + diff helpers
10
21
  // ---------------------------------------------------------------------------
11
22
  export function naturalSortKey(s) {
12
23
  return s.replace(/\d+/g, (m) => m.padStart(12, "0")).toLowerCase();
13
24
  }
14
- function isGlob(s) {
15
- return /[*?[]/.test(s);
16
- }
17
25
  function truncateDiffLine(line) {
18
26
  const ending = line.endsWith("\n") ? "\n" : "";
19
27
  const body = ending ? line.slice(0, -1) : line;
@@ -24,8 +32,8 @@ function truncateDiffLine(line) {
24
32
  }
25
33
  function displayDiff(pathName, oldContent, newContent) {
26
34
  if (oldContent.length + newContent.length > max_diff_source_chars) {
27
- console.log(`\x1b[2m${pathName}: ${oldContent.length.toLocaleString()} chars -> ${newContent.length.toLocaleString()} chars ` +
28
- `(diff hidden: exceeds ${max_diff_source_chars.toLocaleString()} char limit)\x1b[0m`);
35
+ process.stdout.write(`\x1b[2m${pathName}: ${oldContent.length.toLocaleString()} chars -> ${newContent.length.toLocaleString()} chars ` +
36
+ `(diff hidden: exceeds ${max_diff_source_chars.toLocaleString()} char limit)\x1b[0m\n`);
29
37
  return;
30
38
  }
31
39
  const patch = structuredPatch(pathName, pathName, oldContent, newContent, "", "", { context: max_diff_context_lines });
@@ -44,8 +52,7 @@ function displayDiff(pathName, oldContent, newContent) {
44
52
  const removed = contentLines.filter((c) => c.kind === "-").length;
45
53
  if (!added && !removed)
46
54
  return;
47
- console.log(`\x1b[2m${pathName}\x1b[0m`);
48
- console.log(`\x1b[31m-${removed}\x1b[0m \x1b[32m+${added}\x1b[0m`);
55
+ process.stdout.write(`\x1b[90m┌─\x1b[0m \x1b[1m${pathName}\x1b[0m \x1b[32m+${added}\x1b[0m \x1b[31m-${removed}\x1b[0m\n`);
49
56
  const shown = [];
50
57
  let run = [];
51
58
  let lastKind = null;
@@ -70,17 +77,23 @@ function displayDiff(pathName, oldContent, newContent) {
70
77
  run.push(c);
71
78
  }
72
79
  flush();
73
- const diffText = shown.join("\n");
74
- const colored = diffText
75
- .split("\n")
76
- .map((l) => l.startsWith("+") ? `\x1b[32m${l}\x1b[0m` : l.startsWith("-") ? `\x1b[31m${l}\x1b[0m` : l)
77
- .join("\n");
78
- process.stdout.write(colored + "\n");
80
+ for (const l of shown) {
81
+ if (l.startsWith("+")) {
82
+ process.stdout.write(`\x1b[90m│\x1b[0m \x1b[32m${l}\x1b[0m\n`);
83
+ }
84
+ else if (l.startsWith("-")) {
85
+ process.stdout.write(`\x1b[90m│\x1b[0m \x1b[31m${l}\x1b[0m\n`);
86
+ }
87
+ else {
88
+ process.stdout.write(`\x1b[90m│\x1b[0m \x1b[2m${l}\x1b[0m\n`);
89
+ }
90
+ }
91
+ process.stdout.write(`\x1b[90m└────────────────────────────────────────\x1b[0m\n`);
79
92
  }
80
93
  export function truncateToolOutput(output, maxChars = max_output_chars) {
81
94
  if (output.length > maxChars) {
82
95
  return (output.slice(0, maxChars) +
83
- `\n\n[... Output truncated! Total characters: ${output.length} ...]`);
96
+ `\n\n[... Output truncated! Total characters: ${output.length.toLocaleString()} ...]`);
84
97
  }
85
98
  return output;
86
99
  }
@@ -129,11 +142,14 @@ function buildLineIndex(p) {
129
142
  return null;
130
143
  }
131
144
  finally {
132
- if (fd !== null)
145
+ if (fd !== null) {
133
146
  try {
134
147
  fs.closeSync(fd);
135
148
  }
136
- catch { /* ignore */ }
149
+ catch {
150
+ /* ignore */
151
+ }
152
+ }
137
153
  }
138
154
  if (base === 0)
139
155
  return [[0], 0];
@@ -210,7 +226,9 @@ function streamReadRange(p, start, end) {
210
226
  return [i, numbered];
211
227
  }
212
228
  export function toolReadFile(pathName, startLine = 1, endLine) {
213
- const p = pathName;
229
+ const p = sanitizePath(pathName);
230
+ if (!p)
231
+ return "Error: path must not be empty";
214
232
  if (!fs.existsSync(p))
215
233
  return `Error: file not found: ${pathName}`;
216
234
  if (!fs.statSync(p).isFile())
@@ -224,13 +242,30 @@ export function toolReadFile(pathName, startLine = 1, endLine) {
224
242
  }
225
243
  if (size > max_read_file_bytes) {
226
244
  return (`Error: file too large (${size.toLocaleString()} bytes > ${max_read_file_bytes.toLocaleString()} cap). ` +
227
- `Use grep or bash to inspect it instead of reading it whole.`);
245
+ "Use grep or bash to inspect it instead of reading it whole.");
228
246
  }
229
247
  if (size === 0)
230
248
  return "(empty file)";
231
- const start = Math.max(1, startLine);
232
- if (endLine != null && endLine < start) {
233
- return `Error: end_line ${endLine} is before start_line ${start}`;
249
+ try {
250
+ const head = fs.readFileSync(p, { flag: "r" }).subarray(0, 8192);
251
+ if (head.includes(0))
252
+ return `Error: refusing to read binary file: ${pathName}`;
253
+ }
254
+ catch {
255
+ /* ignore */
256
+ }
257
+ const sVal = typeof startLine === "number"
258
+ ? startLine
259
+ : parseInt(String(startLine || 1), 10) || 1;
260
+ const start = Math.max(1, sVal);
261
+ let eVal = null;
262
+ if (endLine != null) {
263
+ const parsed = typeof endLine === "number" ? endLine : parseInt(String(endLine), 10);
264
+ if (!Number.isNaN(parsed))
265
+ eVal = parsed;
266
+ }
267
+ if (eVal != null && eVal < start) {
268
+ return `Error: end_line ${eVal} is before start_line ${start}`;
234
269
  }
235
270
  let end = null;
236
271
  let total = 0;
@@ -240,9 +275,10 @@ export function toolReadFile(pathName, startLine = 1, endLine) {
240
275
  if (idx) {
241
276
  const [offsets, t] = idx;
242
277
  total = t;
243
- if (start > total)
278
+ if (start > total) {
244
279
  return `Error: start_line ${start} exceeds file length (${total} lines)`;
245
- end = endLine == null ? total : Math.min(total, endLine);
280
+ }
281
+ end = eVal == null ? total : Math.min(total, eVal);
246
282
  capped = end - start + 1 > max_read_lines;
247
283
  if (capped)
248
284
  end = start + max_read_lines - 1;
@@ -253,11 +289,12 @@ export function toolReadFile(pathName, startLine = 1, endLine) {
253
289
  }
254
290
  }
255
291
  else {
256
- const collectEnd = Math.min(Math.max(1, endLine != null ? endLine : start + max_read_lines - 1), start + max_read_lines - 1);
292
+ const collectEnd = Math.min(Math.max(1, eVal != null ? eVal : start + max_read_lines - 1), start + max_read_lines - 1);
257
293
  [total, numbered] = streamReadRange(p, start, collectEnd);
258
- if (start > total)
294
+ if (start > total) {
259
295
  return `Error: start_line ${start} exceeds file length (${total} lines)`;
260
- end = endLine == null ? total : Math.min(total, endLine);
296
+ }
297
+ end = eVal == null ? total : Math.min(total, eVal);
261
298
  capped = end - start + 1 > max_read_lines;
262
299
  if (capped) {
263
300
  end = start + max_read_lines - 1;
@@ -278,7 +315,9 @@ export function toolReadFile(pathName, startLine = 1, endLine) {
278
315
  // write_file
279
316
  // ---------------------------------------------------------------------------
280
317
  export function toolWriteFile(pathName, content) {
281
- const p = pathName;
318
+ const p = sanitizePath(pathName);
319
+ if (!p)
320
+ return "Error: path must not be empty";
282
321
  if (fs.existsSync(p) && fs.statSync(p).isDirectory()) {
283
322
  return `Error: ${pathName} is a directory`;
284
323
  }
@@ -286,8 +325,9 @@ export function toolWriteFile(pathName, content) {
286
325
  fs.mkdirSync(path.dirname(p), { recursive: true });
287
326
  if (fs.existsSync(p)) {
288
327
  const head = fs.readFileSync(p).subarray(0, 8192);
289
- if (head.includes(0))
328
+ if (head.includes(0)) {
290
329
  return `Error: refusing to overwrite binary file: ${pathName}`;
330
+ }
291
331
  const oldContent = fs.readFileSync(p, "utf-8");
292
332
  displayDiff(pathName, oldContent, content);
293
333
  }
@@ -299,7 +339,7 @@ export function toolWriteFile(pathName, content) {
299
339
  catch (err) {
300
340
  return `Error: ${err}`;
301
341
  }
302
- return `Wrote ${content.length} characters to ${pathName}`;
342
+ return `Wrote ${content.length.toLocaleString()} characters to ${pathName}`;
303
343
  }
304
344
  // ---------------------------------------------------------------------------
305
345
  // edit_file
@@ -322,9 +362,12 @@ function withNewlines(text, nl) {
322
362
  return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n/g, "\r\n");
323
363
  }
324
364
  function findWsBlocks(text, needle) {
325
- const needleLines = withNewlines(needle, "\n").split("\n").map((l) => l.trim());
326
- while (needleLines.length && needleLines[needleLines.length - 1] === "")
365
+ const needleLines = withNewlines(needle, "\n")
366
+ .split("\n")
367
+ .map((l) => l.trim());
368
+ while (needleLines.length && needleLines[needleLines.length - 1] === "") {
327
369
  needleLines.pop();
370
+ }
328
371
  if (!needleLines.length)
329
372
  return [];
330
373
  const textLines = text.split("\n");
@@ -350,7 +393,9 @@ function findWsBlocks(text, needle) {
350
393
  return blocks;
351
394
  }
352
395
  export function toolEditFile(pathName, oldString, newString, replaceAll = false) {
353
- const p = pathName;
396
+ const p = sanitizePath(pathName);
397
+ if (!p)
398
+ return "Error: path must not be empty";
354
399
  if (!fs.existsSync(p))
355
400
  return `Error: file not found: ${pathName}`;
356
401
  if (!fs.statSync(p).isFile())
@@ -391,7 +436,7 @@ export function toolEditFile(pathName, oldString, newString, replaceAll = false)
391
436
  }
392
437
  const count = text.split(match).length - 1;
393
438
  if (count > 1 && !replaceAll) {
394
- return (`Error: old_string matches ${count} locations; pass replace_all=true or make old_string more specific`);
439
+ return `Error: old_string matches ${count} locations; pass replace_all=true or make old_string more specific`;
395
440
  }
396
441
  const insert = withNewlines(newString, newline);
397
442
  const newContent = replaceAll
@@ -412,7 +457,7 @@ export function toolEditFile(pathName, oldString, newString, replaceAll = false)
412
457
  // ---------------------------------------------------------------------------
413
458
  function terminateProcessTree(proc) {
414
459
  try {
415
- if (isWindowsTool()) {
460
+ if (process.platform === "win32") {
416
461
  execFile("taskkill", ["/F", "/T", "/PID", String(proc.pid)], { timeout: 10000 });
417
462
  }
418
463
  else {
@@ -420,42 +465,43 @@ function terminateProcessTree(proc) {
420
465
  process.kill(-proc.pid, "SIGKILL");
421
466
  }
422
467
  catch {
423
- proc.kill();
468
+ proc.kill("SIGKILL");
424
469
  }
425
470
  }
426
471
  }
427
472
  catch {
428
473
  try {
429
- proc.kill();
474
+ proc.kill("SIGKILL");
430
475
  }
431
476
  catch {
432
477
  /* ignore */
433
478
  }
434
479
  }
435
480
  }
436
- function isWindowsTool() {
437
- return process.platform === "win32";
438
- }
439
481
  export function toolBash(command, timeout = 60, cwd) {
440
482
  try {
441
483
  let t = typeof timeout === "number" ? timeout : parseInt(String(timeout), 10);
442
484
  if (Number.isNaN(t))
443
485
  t = 60;
444
486
  t = t > 0 ? Math.min(t, max_bash_timeout_seconds) : 60;
487
+ const workingDir = cwd ? sanitizePath(cwd) : undefined;
488
+ if (workingDir && !fs.existsSync(workingDir)) {
489
+ return Promise.resolve(`Error: working directory not found: ${cwd}`);
490
+ }
445
491
  return new Promise((resolve) => {
446
492
  let child;
447
493
  try {
448
- if (isWindowsTool()) {
494
+ if (process.platform === "win32") {
449
495
  child = spawn(command, {
450
496
  shell: true,
451
- cwd: cwd || undefined,
452
- windowsHide: false,
497
+ cwd: workingDir,
498
+ windowsHide: true,
453
499
  });
454
500
  }
455
501
  else {
456
502
  child = spawn(command, {
457
503
  shell: true,
458
- cwd: cwd || undefined,
504
+ cwd: workingDir,
459
505
  detached: true,
460
506
  });
461
507
  }
@@ -470,7 +516,10 @@ export function toolBash(command, timeout = 60, cwd) {
470
516
  const timer = setTimeout(() => {
471
517
  if (finished)
472
518
  return;
473
- terminateProcessTree({ pid: child.pid, kill: () => child.kill("SIGKILL") });
519
+ terminateProcessTree({
520
+ pid: child.pid,
521
+ kill: (sig) => child.kill(sig),
522
+ });
474
523
  const output = (stdout + stderr).trim();
475
524
  let msg = `Error: command timed out after ${t}s`;
476
525
  if (output)
@@ -504,17 +553,6 @@ export function toolBash(command, timeout = 60, cwd) {
504
553
  // ---------------------------------------------------------------------------
505
554
  // glob / grep
506
555
  // ---------------------------------------------------------------------------
507
- function pathUnderIgnoredDir(base, f) {
508
- const rel = path.relative(base, f);
509
- if (rel.startsWith("..") || path.isAbsolute(rel))
510
- return false;
511
- const parts = rel.split(/[\\/]/);
512
- for (let i = 0; i < parts.length - 1; i++) {
513
- if (ignoredDirs.has(parts[i]))
514
- return true;
515
- }
516
- return false;
517
- }
518
556
  function matchParts(parts, relParts) {
519
557
  if (!parts.length)
520
558
  return relParts.length === 0;
@@ -529,13 +567,12 @@ function matchParts(parts, relParts) {
529
567
  globMatch(relParts[0], parts[0]) &&
530
568
  matchParts(parts.slice(1), relParts.slice(1)));
531
569
  }
532
- // minimal glob matcher (supports * ? [..])
533
570
  function globMatch(name, pattern) {
534
571
  const regex = pattern
535
572
  .replace(/[.+^${}()|[\]\\]/g, "\\$&")
536
573
  .replace(/\*/g, ".*")
537
574
  .replace(/\?/g, ".");
538
- return new RegExp(`^${regex}$`).test(name);
575
+ return new RegExp(`^${regex}$`, "i").test(name);
539
576
  }
540
577
  function walkFiles(root, current, rel, parts, out) {
541
578
  let entries;
@@ -557,7 +594,10 @@ function walkFiles(root, current, rel, parts, out) {
557
594
  }
558
595
  else if (entry.isFile()) {
559
596
  if (matchParts(parts, rel.concat(name))) {
560
- out.push([path.join(root, ...rel, name), fs.statSync(path.join(root, ...rel, name)).size]);
597
+ out.push([
598
+ path.join(root, ...rel, name),
599
+ fs.statSync(path.join(root, ...rel, name)).size,
600
+ ]);
561
601
  }
562
602
  }
563
603
  }
@@ -611,10 +651,10 @@ function rglobPruned(base, pattern) {
611
651
  return out;
612
652
  }
613
653
  export function toolGlob(pattern, pathName = ".", limit = 200) {
614
- const base = pathName;
654
+ const base = sanitizePath(pathName) || ".";
615
655
  if (!fs.existsSync(base))
616
656
  return `Error: path not found: ${pathName}`;
617
- limit = Math.max(1, limit);
657
+ const lim = typeof limit === "number" ? limit : parseInt(String(limit), 10) || 200;
618
658
  let matches;
619
659
  try {
620
660
  matches = rglobPruned(base, pattern);
@@ -626,24 +666,25 @@ export function toolGlob(pattern, pathName = ".", limit = 200) {
626
666
  if (!matches.length)
627
667
  return "No files matched";
628
668
  const total = matches.length;
629
- let result = matches.slice(0, limit).map(([p]) => p).join("\n");
630
- if (total > limit) {
631
- result += `\n\n[... ${total - limit} more matches not shown ...]`;
669
+ let result = matches.slice(0, lim).map(([p]) => p).join("\n");
670
+ if (total > lim) {
671
+ result += `\n\n[... ${total - lim} more matches not shown ...]`;
632
672
  }
633
673
  return result;
634
674
  }
635
675
  export function toolGrep(pattern, pathName = ".", glob = "*", limit = 200) {
636
- const base = pathName;
676
+ const base = sanitizePath(pathName) || ".";
637
677
  if (!fs.existsSync(base))
638
678
  return `Error: path not found: ${pathName}`;
639
679
  let regex;
640
680
  try {
641
- regex = new RegExp(pattern);
681
+ regex = new RegExp(pattern, "i");
642
682
  }
643
- catch (err) {
644
- return `Error: invalid regex pattern: ${err}`;
683
+ catch {
684
+ regex = new RegExp(escapeRegExp(pattern), "i");
645
685
  }
646
686
  glob = glob || "*";
687
+ const lim = typeof limit === "number" ? limit : parseInt(String(limit), 10) || 200;
647
688
  let targets;
648
689
  if (fs.statSync(base).isFile()) {
649
690
  targets =
@@ -670,7 +711,7 @@ export function toolGrep(pattern, pathName = ".", glob = "*", limit = 200) {
670
711
  const line = capLine(lines[i]);
671
712
  if (regex.test(line)) {
672
713
  hits.push(`${fpath}:${i + 1}:${line}`);
673
- if (hits.length >= limit)
714
+ if (hits.length >= lim)
674
715
  break;
675
716
  }
676
717
  }
@@ -678,7 +719,7 @@ export function toolGrep(pattern, pathName = ".", glob = "*", limit = 200) {
678
719
  catch {
679
720
  continue;
680
721
  }
681
- if (hits.length >= limit)
722
+ if (hits.length >= lim)
682
723
  break;
683
724
  }
684
725
  let result = hits.length ? hits.join("\n") : "No matches found";
@@ -710,7 +751,10 @@ export const RESPONSES_TOOLS = [
710
751
  type: "object",
711
752
  properties: {
712
753
  path: { type: "string", description: "Path to the file" },
713
- start_line: { type: "integer", description: "First line to read, 1-indexed (default 1)" },
754
+ start_line: {
755
+ type: "integer",
756
+ description: "First line to read, 1-indexed (default 1)",
757
+ },
714
758
  end_line: {
715
759
  type: "integer",
716
760
  description: `Last line to read, inclusive (default: end of file, capped at ${max_read_lines} lines from start_line)`,
@@ -784,8 +828,14 @@ export const RESPONSES_TOOLS = [
784
828
  type: "object",
785
829
  properties: {
786
830
  pattern: { type: "string", description: "Glob pattern, e.g. '*.py'" },
787
- path: { type: "string", description: "Directory to search from (default '.')" },
788
- limit: { type: "integer", description: "Max number of matches to return (default 200)" },
831
+ path: {
832
+ type: "string",
833
+ description: "Directory to search from (default '.')",
834
+ },
835
+ limit: {
836
+ type: "integer",
837
+ description: "Max number of matches to return (default 200)",
838
+ },
789
839
  },
790
840
  required: ["pattern"],
791
841
  },
@@ -798,13 +848,22 @@ export const RESPONSES_TOOLS = [
798
848
  parameters: {
799
849
  type: "object",
800
850
  properties: {
801
- pattern: { type: "string", description: "Regular expression to search for" },
802
- path: { type: "string", description: "Directory to search from (default '.')" },
851
+ pattern: {
852
+ type: "string",
853
+ description: "Regular expression to search for",
854
+ },
855
+ path: {
856
+ type: "string",
857
+ description: "Directory to search from (default '.')",
858
+ },
803
859
  glob: {
804
860
  type: "string",
805
861
  description: "Only search files matching this glob (default '*')",
806
862
  },
807
- limit: { type: "integer", description: "Max number of matches to return (default 200)" },
863
+ limit: {
864
+ type: "integer",
865
+ description: "Max number of matches to return (default 200)",
866
+ },
808
867
  },
809
868
  required: ["pattern"],
810
869
  },