mini-coder 0.5.2 → 0.5.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/src/tools.ts CHANGED
@@ -57,6 +57,320 @@ function normalizeLineEndings(
57
57
  return content.replace(/\r\n/g, "\n");
58
58
  }
59
59
 
60
+ const MAX_EDIT_ERROR_MATCHES = 3;
61
+ const MAX_EDIT_ERROR_SNIPPET_LINES = 8;
62
+ const MAX_EDIT_ERROR_SNIPPET_LINE_CHARS = 160;
63
+ const MIN_EDIT_SIMILARITY_SCORE = 0.45;
64
+
65
+ interface EditSnippet {
66
+ startLine: number;
67
+ endLine: number;
68
+ lines: string[];
69
+ }
70
+
71
+ function splitDisplayLines(content: string): string[] {
72
+ const lines = content.replace(/\r\n/g, "\n").split("\n");
73
+ if (lines.at(-1) === "") {
74
+ lines.pop();
75
+ }
76
+ return lines;
77
+ }
78
+
79
+ function countDisplayLines(content: string): number {
80
+ return Math.max(splitDisplayLines(content).length, 1);
81
+ }
82
+
83
+ function formatLineRange(startLine: number, endLine: number): string {
84
+ return startLine === endLine
85
+ ? `line ${startLine}`
86
+ : `lines ${startLine}-${endLine}`;
87
+ }
88
+
89
+ function truncateSnippetLine(line: string): string {
90
+ if (line.length <= MAX_EDIT_ERROR_SNIPPET_LINE_CHARS) {
91
+ return line;
92
+ }
93
+ return `${line.slice(0, MAX_EDIT_ERROR_SNIPPET_LINE_CHARS - 1)}…`;
94
+ }
95
+
96
+ function formatSnippetLines(lines: readonly string[]): string {
97
+ const visibleLines = lines.slice(0, MAX_EDIT_ERROR_SNIPPET_LINES);
98
+ const formatted = visibleLines
99
+ .map((line) => ` ${truncateSnippetLine(line)}`)
100
+ .join("\n");
101
+ const hiddenLineCount = lines.length - visibleLines.length;
102
+ if (hiddenLineCount <= 0) {
103
+ return formatted;
104
+ }
105
+ return `${formatted}\n … ${hiddenLineCount} more lines`;
106
+ }
107
+
108
+ function commonPrefixLength(a: string, b: string): number {
109
+ let index = 0;
110
+ const maxLength = Math.min(a.length, b.length);
111
+ while (index < maxLength && a[index] === b[index]) {
112
+ index++;
113
+ }
114
+ return index;
115
+ }
116
+
117
+ function commonSuffixLength(
118
+ a: string,
119
+ b: string,
120
+ prefixLength: number,
121
+ ): number {
122
+ let index = 0;
123
+ const maxLength = Math.min(a.length, b.length) - prefixLength;
124
+ while (
125
+ index < maxLength &&
126
+ a[a.length - 1 - index] === b[b.length - 1 - index]
127
+ ) {
128
+ index++;
129
+ }
130
+ return index;
131
+ }
132
+
133
+ function scoreSimilarLine(oldLine: string, candidateLine: string): number {
134
+ if (oldLine === candidateLine) {
135
+ return 1;
136
+ }
137
+
138
+ const normalizedOldLine = oldLine.trim();
139
+ const normalizedCandidateLine = candidateLine.trim();
140
+ if (normalizedOldLine === normalizedCandidateLine) {
141
+ return normalizedOldLine === "" ? 1 : 0.98;
142
+ }
143
+ if (normalizedOldLine === "" || normalizedCandidateLine === "") {
144
+ return 0;
145
+ }
146
+
147
+ const prefixLength = commonPrefixLength(
148
+ normalizedOldLine,
149
+ normalizedCandidateLine,
150
+ );
151
+ const suffixLength = commonSuffixLength(
152
+ normalizedOldLine,
153
+ normalizedCandidateLine,
154
+ prefixLength,
155
+ );
156
+ const overlapLength = Math.min(
157
+ normalizedOldLine.length,
158
+ prefixLength + suffixLength,
159
+ );
160
+ const maxLength = Math.max(
161
+ normalizedOldLine.length,
162
+ normalizedCandidateLine.length,
163
+ );
164
+ const structuralScore = overlapLength / maxLength;
165
+
166
+ if (
167
+ normalizedOldLine.includes(normalizedCandidateLine) ||
168
+ normalizedCandidateLine.includes(normalizedOldLine)
169
+ ) {
170
+ const sharedLength = Math.min(
171
+ normalizedOldLine.length,
172
+ normalizedCandidateLine.length,
173
+ );
174
+ return Math.max(structuralScore, sharedLength / maxLength);
175
+ }
176
+
177
+ return structuralScore;
178
+ }
179
+
180
+ function scoreLineWindow(
181
+ oldLines: readonly string[],
182
+ candidateLines: readonly string[],
183
+ ): number {
184
+ const maxLineCount = Math.max(oldLines.length, candidateLines.length);
185
+ let weightedScore = 0;
186
+ let totalWeight = 0;
187
+
188
+ for (let index = 0; index < maxLineCount; index++) {
189
+ const oldLine = oldLines[index] ?? "";
190
+ const candidateLine = candidateLines[index] ?? "";
191
+ const weight = Math.max(
192
+ oldLine.trim().length,
193
+ candidateLine.trim().length,
194
+ 1,
195
+ );
196
+ weightedScore += scoreSimilarLine(oldLine, candidateLine) * weight;
197
+ totalWeight += weight;
198
+ }
199
+
200
+ return totalWeight === 0 ? 0 : weightedScore / totalWeight;
201
+ }
202
+
203
+ function findClosestEditSnippets(
204
+ oldText: string,
205
+ content: string,
206
+ ): EditSnippet[] {
207
+ const oldLines = splitDisplayLines(oldText);
208
+ const fileLines = splitDisplayLines(content);
209
+ if (fileLines.length === 0) {
210
+ return [];
211
+ }
212
+
213
+ const windowSizes = Array.from(
214
+ new Set([
215
+ Math.max(1, oldLines.length - 1),
216
+ Math.max(1, oldLines.length),
217
+ Math.min(fileLines.length, oldLines.length + 1),
218
+ ]),
219
+ );
220
+ const candidates: (EditSnippet & { score: number })[] = [];
221
+
222
+ for (const windowSize of windowSizes) {
223
+ if (windowSize > fileLines.length) {
224
+ continue;
225
+ }
226
+
227
+ for (
228
+ let startLineIndex = 0;
229
+ startLineIndex <= fileLines.length - windowSize;
230
+ startLineIndex++
231
+ ) {
232
+ const lines = fileLines.slice(
233
+ startLineIndex,
234
+ startLineIndex + windowSize,
235
+ );
236
+ candidates.push({
237
+ startLine: startLineIndex + 1,
238
+ endLine: startLineIndex + windowSize,
239
+ lines,
240
+ score: scoreLineWindow(oldLines, lines),
241
+ });
242
+ }
243
+ }
244
+
245
+ candidates.sort((a, b) => {
246
+ const scoreDelta = b.score - a.score;
247
+ if (scoreDelta !== 0) {
248
+ return scoreDelta;
249
+ }
250
+
251
+ const lineSpanDelta =
252
+ Math.abs(a.lines.length - oldLines.length) -
253
+ Math.abs(b.lines.length - oldLines.length);
254
+ if (lineSpanDelta !== 0) {
255
+ return lineSpanDelta;
256
+ }
257
+
258
+ return a.startLine - b.startLine;
259
+ });
260
+
261
+ const snippets: EditSnippet[] = [];
262
+ const seen = new Set<string>();
263
+ for (const candidate of candidates) {
264
+ if (candidate.score < MIN_EDIT_SIMILARITY_SCORE) {
265
+ break;
266
+ }
267
+
268
+ const key = `${candidate.startLine}:${candidate.endLine}`;
269
+ if (seen.has(key)) {
270
+ continue;
271
+ }
272
+
273
+ snippets.push({
274
+ startLine: candidate.startLine,
275
+ endLine: candidate.endLine,
276
+ lines: candidate.lines,
277
+ });
278
+ seen.add(key);
279
+
280
+ if (snippets.length === MAX_EDIT_ERROR_MATCHES) {
281
+ break;
282
+ }
283
+ }
284
+
285
+ return snippets;
286
+ }
287
+
288
+ function buildLineStarts(content: string): number[] {
289
+ const lineStarts = [0];
290
+ for (let index = 0; index < content.length; index++) {
291
+ if (content[index] === "\n") {
292
+ lineStarts.push(index + 1);
293
+ }
294
+ }
295
+ return lineStarts;
296
+ }
297
+
298
+ function findLineNumber(lineStarts: readonly number[], index: number): number {
299
+ let low = 0;
300
+ let high = lineStarts.length - 1;
301
+
302
+ while (low <= high) {
303
+ const mid = Math.floor((low + high) / 2);
304
+ const lineStart = lineStarts[mid];
305
+ if (lineStart === undefined) {
306
+ break;
307
+ }
308
+ if (lineStart <= index) {
309
+ low = mid + 1;
310
+ } else {
311
+ high = mid - 1;
312
+ }
313
+ }
314
+
315
+ return high + 1;
316
+ }
317
+
318
+ function formatEditNotFoundError(
319
+ path: string,
320
+ oldText: string,
321
+ content: string,
322
+ ): string {
323
+ const snippets = findClosestEditSnippets(oldText, content);
324
+ if (snippets.length === 0) {
325
+ return `Old text not found in ${path}`;
326
+ }
327
+
328
+ return [
329
+ `Old text not found in ${path}`,
330
+ "Closest matches:",
331
+ ...snippets.map(
332
+ (snippet) =>
333
+ `- ${formatLineRange(snippet.startLine, snippet.endLine)}\n${formatSnippetLines(snippet.lines)}`,
334
+ ),
335
+ ].join("\n");
336
+ }
337
+
338
+ function formatEditMultipleMatchesError(
339
+ path: string,
340
+ oldText: string,
341
+ content: string,
342
+ matchIndices: readonly number[],
343
+ totalMatches: number,
344
+ ): string {
345
+ const lineStarts = buildLineStarts(content);
346
+ const fileLines = splitDisplayLines(content);
347
+ const matchLineCount = countDisplayLines(oldText);
348
+ const snippets = matchIndices.map((matchIndex) => {
349
+ const startLine = findLineNumber(lineStarts, matchIndex);
350
+ const endLine = startLine + matchLineCount - 1;
351
+ return {
352
+ startLine,
353
+ endLine,
354
+ lines: fileLines.slice(startLine - 1, endLine),
355
+ };
356
+ });
357
+
358
+ const lines = [
359
+ `Old text matches multiple locations (${totalMatches}) in ${path}`,
360
+ "Matches:",
361
+ ...snippets.map(
362
+ (snippet) =>
363
+ `- ${formatLineRange(snippet.startLine, snippet.endLine)}\n${formatSnippetLines(snippet.lines)}`,
364
+ ),
365
+ ];
366
+ const hiddenMatchCount = totalMatches - matchIndices.length;
367
+ if (hiddenMatchCount > 0) {
368
+ lines.push(`- … ${hiddenMatchCount} more matches`);
369
+ }
370
+
371
+ return lines.join("\n");
372
+ }
373
+
60
374
  // ---------------------------------------------------------------------------
61
375
  // edit
62
376
  // ---------------------------------------------------------------------------
@@ -106,20 +420,33 @@ export function executeEdit(args: EditArgs, cwd: string): ToolExecResult {
106
420
 
107
421
  // Count occurrences
108
422
  let count = 0;
423
+ const matchIndices: number[] = [];
109
424
  let idx = 0;
110
425
  while (true) {
111
426
  idx = content.indexOf(args.oldText, idx);
112
427
  if (idx === -1) break;
113
428
  count++;
429
+ if (matchIndices.length < MAX_EDIT_ERROR_MATCHES) {
430
+ matchIndices.push(idx);
431
+ }
114
432
  idx += args.oldText.length;
115
433
  }
116
434
 
117
435
  if (count === 0) {
118
- return textResult(`Old text not found in ${args.path}`, true);
436
+ return textResult(
437
+ formatEditNotFoundError(args.path, args.oldText, content),
438
+ true,
439
+ );
119
440
  }
120
441
  if (count > 1) {
121
442
  return textResult(
122
- `Old text matches multiple locations (${count}) in ${args.path}`,
443
+ formatEditMultipleMatchesError(
444
+ args.path,
445
+ args.oldText,
446
+ content,
447
+ matchIndices,
448
+ count,
449
+ ),
123
450
  true,
124
451
  );
125
452
  }
@@ -129,7 +456,14 @@ export function executeEdit(args: EditArgs, cwd: string): ToolExecResult {
129
456
  const newText = lineEnding
130
457
  ? normalizeLineEndings(args.newText, lineEnding)
131
458
  : args.newText;
132
- const updated = content.replace(args.oldText, newText);
459
+ const matchIndex = matchIndices[0];
460
+ if (matchIndex === undefined) {
461
+ return textResult(`Old text not found in ${args.path}`, true);
462
+ }
463
+ const updated =
464
+ content.slice(0, matchIndex) +
465
+ newText +
466
+ content.slice(matchIndex + args.oldText.length);
133
467
  writeFileSync(filePath, updated, "utf-8");
134
468
  return textResult(`Edited ${args.path}`, false);
135
469
  }
@@ -174,6 +508,335 @@ function formatShellOutput(stdout: string, stderr: string): string {
174
508
  return "";
175
509
  }
176
510
 
511
+ interface ShellCommandLines {
512
+ lines: string[];
513
+ lineEnding: "\n" | "\r\n";
514
+ hasTrailingLineEnding: boolean;
515
+ }
516
+
517
+ interface PendingHeredoc {
518
+ startLineIndex: number;
519
+ delimiter: string;
520
+ stripLeadingTabs: boolean;
521
+ }
522
+
523
+ interface ShellQuoteState {
524
+ quote: "'" | '"' | null;
525
+ escaped: boolean;
526
+ }
527
+
528
+ function splitShellCommandLines(command: string): ShellCommandLines {
529
+ const lineEnding = detectLineEnding(command) ?? "\n";
530
+ const normalized = normalizeLineEndings(command, "\n");
531
+ const hasTrailingLineEnding = normalized.endsWith("\n");
532
+ const lines = normalized.split("\n");
533
+ if (hasTrailingLineEnding) {
534
+ lines.pop();
535
+ }
536
+ return { lines, lineEnding, hasTrailingLineEnding };
537
+ }
538
+
539
+ function joinShellCommandLines(parts: ShellCommandLines): string {
540
+ const joined = parts.lines.join(parts.lineEnding);
541
+ if (parts.hasTrailingLineEnding) {
542
+ return joined + parts.lineEnding;
543
+ }
544
+ return joined;
545
+ }
546
+
547
+ function advanceShellQuoteState(char: string, state: ShellQuoteState): boolean {
548
+ if (state.quote === "'") {
549
+ if (char === "'") {
550
+ state.quote = null;
551
+ }
552
+ return true;
553
+ }
554
+
555
+ if (state.quote === '"') {
556
+ if (state.escaped) {
557
+ state.escaped = false;
558
+ return true;
559
+ }
560
+ if (char === "\\") {
561
+ state.escaped = true;
562
+ return true;
563
+ }
564
+ if (char === '"') {
565
+ state.quote = null;
566
+ }
567
+ return true;
568
+ }
569
+
570
+ if (char === "'") {
571
+ state.quote = "'";
572
+ return true;
573
+ }
574
+ if (char === '"') {
575
+ state.quote = '"';
576
+ return true;
577
+ }
578
+
579
+ return false;
580
+ }
581
+
582
+ function isHeredocPrefixCharacter(char: string): boolean {
583
+ return (
584
+ char === "" ||
585
+ char === " " ||
586
+ char === "\t" ||
587
+ char === ";" ||
588
+ char === "(" ||
589
+ char === "&" ||
590
+ char === "|"
591
+ );
592
+ }
593
+
594
+ function getHeredocStartAt(
595
+ line: string,
596
+ index: number,
597
+ ): { index: number; stripLeadingTabs: boolean } | null {
598
+ if (line[index] !== "<" || line[index + 1] !== "<") {
599
+ return null;
600
+ }
601
+
602
+ const previousChar = index === 0 ? "" : (line[index - 1] ?? "");
603
+ if (!isHeredocPrefixCharacter(previousChar)) {
604
+ return null;
605
+ }
606
+
607
+ return {
608
+ index,
609
+ stripLeadingTabs: line[index + 2] === "-",
610
+ };
611
+ }
612
+
613
+ function findUnquotedHeredocStart(
614
+ line: string,
615
+ ): { index: number; stripLeadingTabs: boolean } | null {
616
+ const quoteState: ShellQuoteState = { quote: null, escaped: false };
617
+ let heredocStart: { index: number; stripLeadingTabs: boolean } | null = null;
618
+
619
+ for (let index = 0; index < line.length - 1; index++) {
620
+ const char = line[index];
621
+ if (char === undefined || advanceShellQuoteState(char, quoteState)) {
622
+ continue;
623
+ }
624
+
625
+ const nextHeredocStart = getHeredocStartAt(line, index);
626
+ if (!nextHeredocStart) {
627
+ continue;
628
+ }
629
+ if (heredocStart) {
630
+ return null;
631
+ }
632
+
633
+ heredocStart = nextHeredocStart;
634
+ index += heredocStart.stripLeadingTabs ? 2 : 1;
635
+ }
636
+
637
+ return heredocStart;
638
+ }
639
+
640
+ function skipHeredocDelimiterWhitespace(line: string, cursor: number): number {
641
+ let nextCursor = cursor;
642
+ while (line[nextCursor] === " " || line[nextCursor] === "\t") {
643
+ nextCursor++;
644
+ }
645
+ return nextCursor;
646
+ }
647
+
648
+ function readQuotedHeredocDelimiter(
649
+ line: string,
650
+ cursor: number,
651
+ ): string | null {
652
+ const quote = line[cursor];
653
+ if (quote !== "'" && quote !== '"') {
654
+ return null;
655
+ }
656
+
657
+ const endQuoteIndex = line.indexOf(quote, cursor + 1);
658
+ if (endQuoteIndex === -1) {
659
+ return null;
660
+ }
661
+ return line.slice(cursor + 1, endQuoteIndex);
662
+ }
663
+
664
+ function isHeredocDelimiterStopCharacter(char: string): boolean {
665
+ return (
666
+ char === " " ||
667
+ char === "\t" ||
668
+ char === "<" ||
669
+ char === ">" ||
670
+ char === "&" ||
671
+ char === "|" ||
672
+ char === ";" ||
673
+ char === "(" ||
674
+ char === ")"
675
+ );
676
+ }
677
+
678
+ function readBareHeredocDelimiter(line: string, cursor: number): string | null {
679
+ const startChar = line[cursor];
680
+ if (startChar === undefined || !/[A-Za-z_]/.test(startChar)) {
681
+ return null;
682
+ }
683
+
684
+ let endIndex = cursor;
685
+ while (endIndex < line.length) {
686
+ const currentChar = line[endIndex];
687
+ if (
688
+ currentChar === undefined ||
689
+ isHeredocDelimiterStopCharacter(currentChar)
690
+ ) {
691
+ break;
692
+ }
693
+ endIndex++;
694
+ }
695
+ return line.slice(cursor, endIndex);
696
+ }
697
+
698
+ function findUnquotedHeredoc(
699
+ line: string,
700
+ startLineIndex: number,
701
+ ): PendingHeredoc | null {
702
+ const heredocStart = findUnquotedHeredocStart(line);
703
+ if (!heredocStart) {
704
+ return null;
705
+ }
706
+
707
+ const cursor = skipHeredocDelimiterWhitespace(
708
+ line,
709
+ heredocStart.index + 2 + (heredocStart.stripLeadingTabs ? 1 : 0),
710
+ );
711
+ const delimiter =
712
+ readQuotedHeredocDelimiter(line, cursor) ??
713
+ readBareHeredocDelimiter(line, cursor);
714
+ if (!delimiter) {
715
+ return null;
716
+ }
717
+
718
+ return {
719
+ startLineIndex,
720
+ delimiter,
721
+ stripLeadingTabs: heredocStart.stripLeadingTabs,
722
+ };
723
+ }
724
+
725
+ function getHeredocLineBody(line: string, stripLeadingTabs: boolean): string {
726
+ if (!stripLeadingTabs) {
727
+ return line;
728
+ }
729
+ return line.replace(/^\t+/, "");
730
+ }
731
+
732
+ function getSupportedHeredocTrailer(rest: string): string | null {
733
+ const trimmedRest = rest.trimStart();
734
+ if (!trimmedRest) {
735
+ return null;
736
+ }
737
+ if (trimmedRest.startsWith("&&")) {
738
+ return trimmedRest.slice(2).trim() ? rest : null;
739
+ }
740
+ if (trimmedRest.startsWith("||")) {
741
+ return null;
742
+ }
743
+ if (trimmedRest.startsWith("|")) {
744
+ return trimmedRest.slice(1).trim() ? rest : null;
745
+ }
746
+ if (trimmedRest.startsWith(">")) {
747
+ return trimmedRest.slice(1).trim() ? rest : null;
748
+ }
749
+ return null;
750
+ }
751
+
752
+ function rewritePendingHeredocTrailer(
753
+ parts: ShellCommandLines,
754
+ line: string,
755
+ lineIndex: number,
756
+ pendingHeredoc: PendingHeredoc,
757
+ ): PendingHeredoc | null {
758
+ const body = getHeredocLineBody(line, pendingHeredoc.stripLeadingTabs);
759
+ if (body === pendingHeredoc.delimiter) {
760
+ return null;
761
+ }
762
+ if (!body.startsWith(pendingHeredoc.delimiter)) {
763
+ return pendingHeredoc;
764
+ }
765
+
766
+ const trailer = getSupportedHeredocTrailer(
767
+ body.slice(pendingHeredoc.delimiter.length),
768
+ );
769
+ if (!trailer) {
770
+ return pendingHeredoc;
771
+ }
772
+
773
+ const startLine = parts.lines[pendingHeredoc.startLineIndex];
774
+ if (startLine === undefined) {
775
+ return pendingHeredoc;
776
+ }
777
+
778
+ parts.lines[pendingHeredoc.startLineIndex] = startLine + trailer;
779
+ const leadingTabs = pendingHeredoc.stripLeadingTabs
780
+ ? (line.match(/^\t*/) ?? [""])[0]
781
+ : "";
782
+ parts.lines[lineIndex] = `${leadingTabs}${pendingHeredoc.delimiter}`;
783
+ return null;
784
+ }
785
+
786
+ function normalizeHeredocTrailingContinuations(command: string): string {
787
+ const parts = splitShellCommandLines(command);
788
+ let pendingHeredoc: PendingHeredoc | null = null;
789
+
790
+ for (const [index, line] of parts.lines.entries()) {
791
+ if (pendingHeredoc) {
792
+ pendingHeredoc = rewritePendingHeredocTrailer(
793
+ parts,
794
+ line,
795
+ index,
796
+ pendingHeredoc,
797
+ );
798
+ continue;
799
+ }
800
+
801
+ pendingHeredoc = findUnquotedHeredoc(line, index);
802
+ }
803
+
804
+ return joinShellCommandLines(parts);
805
+ }
806
+
807
+ function normalizeLeadingDashPrintf(command: string): string {
808
+ const parts = splitShellCommandLines(command);
809
+ let pendingHeredoc: PendingHeredoc | null = null;
810
+
811
+ for (const [index, line] of parts.lines.entries()) {
812
+ if (pendingHeredoc) {
813
+ const body = getHeredocLineBody(line, pendingHeredoc.stripLeadingTabs);
814
+ if (body === pendingHeredoc.delimiter) {
815
+ pendingHeredoc = null;
816
+ }
817
+ continue;
818
+ }
819
+
820
+ parts.lines[index] = line.replace(
821
+ /^(\s*)printf(\s+)(['"])-/,
822
+ "$1printf$2-- $3-",
823
+ );
824
+ pendingHeredoc = findUnquotedHeredoc(parts.lines[index] || "", index);
825
+ }
826
+
827
+ return joinShellCommandLines(parts);
828
+ }
829
+
830
+ function normalizeShellCommand(command: string): string {
831
+ try {
832
+ return normalizeLeadingDashPrintf(
833
+ normalizeHeredocTrailingContinuations(command),
834
+ );
835
+ } catch {
836
+ return command;
837
+ }
838
+ }
839
+
177
840
  /** Read a spawned shell stream into a string, reporting progressive updates. */
178
841
  async function consumeShellStream(
179
842
  stream: ReadableStream<Uint8Array>,
@@ -225,7 +888,8 @@ export async function executeShell(
225
888
  stderr: "pipe",
226
889
  };
227
890
  if (opts?.signal) spawnOpts.signal = opts.signal;
228
- const proc = Bun.spawn([shell, "-c", args.command], spawnOpts);
891
+ const command = normalizeShellCommand(args.command);
892
+ const proc = Bun.spawn([shell, "-c", command], spawnOpts);
229
893
 
230
894
  let stdoutBuf = "";
231
895
  let stderrBuf = "";