@llmist/cli 13.0.0 → 15.0.0

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/index.js CHANGED
@@ -1,64 +1,257 @@
1
1
  // src/builtins/filesystem/edit-file.ts
2
- import { z } from "zod";
2
+ import { readFileSync, writeFileSync } from "fs";
3
3
  import { createGadget } from "llmist";
4
+ import { z } from "zod";
4
5
 
5
- // src/spawn.ts
6
- import { spawn as nodeSpawn } from "child_process";
7
- function nodeStreamToReadableStream(nodeStream) {
8
- if (!nodeStream) return null;
9
- return new ReadableStream({
10
- start(controller) {
11
- nodeStream.on("data", (chunk) => {
12
- controller.enqueue(new Uint8Array(chunk));
13
- });
14
- nodeStream.on("end", () => {
15
- controller.close();
16
- });
17
- nodeStream.on("error", (err) => {
18
- controller.error(err);
19
- });
20
- },
21
- cancel() {
22
- nodeStream.destroy();
6
+ // src/builtins/filesystem/editfile/matcher.ts
7
+ var DEFAULT_OPTIONS = {
8
+ fuzzyThreshold: 0.8,
9
+ maxSuggestions: 3,
10
+ contextLines: 5
11
+ };
12
+ function findMatch(content, search, options = {}) {
13
+ const opts = { ...DEFAULT_OPTIONS, ...options };
14
+ const strategies = [
15
+ { name: "exact", fn: exactMatch },
16
+ { name: "whitespace", fn: whitespaceMatch },
17
+ { name: "indentation", fn: indentationMatch },
18
+ { name: "fuzzy", fn: (c, s) => fuzzyMatch(c, s, opts.fuzzyThreshold) }
19
+ ];
20
+ for (const { name, fn } of strategies) {
21
+ const result = fn(content, search);
22
+ if (result) {
23
+ return { ...result, strategy: name };
23
24
  }
24
- });
25
+ }
26
+ return null;
25
27
  }
26
- function spawn(argv, options = {}) {
27
- const [command, ...args] = argv;
28
- const proc = nodeSpawn(command, args, {
29
- cwd: options.cwd,
30
- stdio: [
31
- options.stdin === "pipe" ? "pipe" : options.stdin ?? "ignore",
32
- options.stdout === "pipe" ? "pipe" : options.stdout ?? "ignore",
33
- options.stderr === "pipe" ? "pipe" : options.stderr ?? "ignore"
34
- ]
35
- });
36
- const exited = new Promise((resolve, reject) => {
37
- proc.on("exit", (code) => {
38
- resolve(code ?? 1);
39
- });
40
- proc.on("error", (err) => {
41
- reject(err);
42
- });
43
- });
44
- const stdin = proc.stdin ? {
45
- write(data) {
46
- proc.stdin?.write(data);
47
- },
48
- end() {
49
- proc.stdin?.end();
50
- }
51
- } : null;
28
+ function applyReplacement(content, match, replacement) {
29
+ return content.slice(0, match.startIndex) + replacement + content.slice(match.endIndex);
30
+ }
31
+ function getMatchFailure(content, search, options = {}) {
32
+ const opts = { ...DEFAULT_OPTIONS, ...options };
33
+ const suggestions = findSuggestions(content, search, opts.maxSuggestions, opts.fuzzyThreshold);
34
+ const nearbyContext = suggestions.length > 0 ? getContext(content, suggestions[0].lineNumber, opts.contextLines) : "";
52
35
  return {
53
- exited,
54
- stdout: nodeStreamToReadableStream(proc.stdout),
55
- stderr: nodeStreamToReadableStream(proc.stderr),
56
- stdin,
57
- kill() {
58
- proc.kill();
36
+ reason: "Search content not found in file",
37
+ suggestions,
38
+ nearbyContext
39
+ };
40
+ }
41
+ function exactMatch(content, search) {
42
+ const index = content.indexOf(search);
43
+ if (index === -1) return null;
44
+ const { startLine, endLine } = getLineNumbers(content, index, index + search.length);
45
+ return {
46
+ found: true,
47
+ strategy: "exact",
48
+ confidence: 1,
49
+ matchedContent: search,
50
+ startIndex: index,
51
+ endIndex: index + search.length,
52
+ startLine,
53
+ endLine
54
+ };
55
+ }
56
+ function whitespaceMatch(content, search) {
57
+ const normalizeWs = (s) => s.replace(/[ \t]+/g, " ");
58
+ const normalizedContent = normalizeWs(content);
59
+ const normalizedSearch = normalizeWs(search);
60
+ const normalizedIndex = normalizedContent.indexOf(normalizedSearch);
61
+ if (normalizedIndex === -1) return null;
62
+ const { originalStart, originalEnd } = mapNormalizedToOriginal(
63
+ content,
64
+ normalizedIndex,
65
+ normalizedSearch.length
66
+ );
67
+ const matchedContent = content.slice(originalStart, originalEnd);
68
+ const { startLine, endLine } = getLineNumbers(content, originalStart, originalEnd);
69
+ return {
70
+ found: true,
71
+ strategy: "whitespace",
72
+ confidence: 0.95,
73
+ matchedContent,
74
+ startIndex: originalStart,
75
+ endIndex: originalEnd,
76
+ startLine,
77
+ endLine
78
+ };
79
+ }
80
+ function indentationMatch(content, search) {
81
+ const stripIndent = (s) => s.split("\n").map((line) => line.trimStart()).join("\n");
82
+ const strippedSearch = stripIndent(search);
83
+ const contentLines = content.split("\n");
84
+ const searchLineCount = search.split("\n").length;
85
+ for (let i = 0; i <= contentLines.length - searchLineCount; i++) {
86
+ const windowLines = contentLines.slice(i, i + searchLineCount);
87
+ const strippedWindow = stripIndent(windowLines.join("\n"));
88
+ if (strippedWindow === strippedSearch) {
89
+ const startIndex = contentLines.slice(0, i).join("\n").length + (i > 0 ? 1 : 0);
90
+ const matchedContent = windowLines.join("\n");
91
+ const endIndex = startIndex + matchedContent.length;
92
+ const { startLine, endLine } = getLineNumbers(content, startIndex, endIndex);
93
+ return {
94
+ found: true,
95
+ strategy: "indentation",
96
+ confidence: 0.9,
97
+ matchedContent,
98
+ startIndex,
99
+ endIndex,
100
+ startLine,
101
+ endLine
102
+ };
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+ function fuzzyMatch(content, search, threshold) {
108
+ const searchLines = search.split("\n");
109
+ const contentLines = content.split("\n");
110
+ if (searchLines.length > contentLines.length) return null;
111
+ let bestMatch = null;
112
+ for (let i = 0; i <= contentLines.length - searchLines.length; i++) {
113
+ const windowLines = contentLines.slice(i, i + searchLines.length);
114
+ const similarity = calculateLineSimilarity(searchLines, windowLines);
115
+ if (similarity >= threshold && (!bestMatch || similarity > bestMatch.similarity)) {
116
+ bestMatch = {
117
+ startLineIndex: i,
118
+ endLineIndex: i + searchLines.length,
119
+ similarity
120
+ };
59
121
  }
122
+ }
123
+ if (!bestMatch) return null;
124
+ const startIndex = contentLines.slice(0, bestMatch.startLineIndex).join("\n").length + (bestMatch.startLineIndex > 0 ? 1 : 0);
125
+ const matchedContent = contentLines.slice(bestMatch.startLineIndex, bestMatch.endLineIndex).join("\n");
126
+ const endIndex = startIndex + matchedContent.length;
127
+ const { startLine, endLine } = getLineNumbers(content, startIndex, endIndex);
128
+ return {
129
+ found: true,
130
+ strategy: "fuzzy",
131
+ confidence: bestMatch.similarity,
132
+ matchedContent,
133
+ startIndex,
134
+ endIndex,
135
+ startLine,
136
+ endLine
60
137
  };
61
138
  }
139
+ function findSuggestions(content, search, maxSuggestions, minSimilarity) {
140
+ const searchLines = search.split("\n");
141
+ const contentLines = content.split("\n");
142
+ const suggestions = [];
143
+ const suggestionThreshold = Math.max(0.5, minSimilarity - 0.2);
144
+ for (let i = 0; i <= contentLines.length - searchLines.length; i++) {
145
+ const windowLines = contentLines.slice(i, i + searchLines.length);
146
+ const similarity = calculateLineSimilarity(searchLines, windowLines);
147
+ if (similarity >= suggestionThreshold) {
148
+ suggestions.push({
149
+ lineIndex: i,
150
+ similarity,
151
+ content: windowLines.join("\n")
152
+ });
153
+ }
154
+ }
155
+ suggestions.sort((a, b) => b.similarity - a.similarity);
156
+ return suggestions.slice(0, maxSuggestions).map((s) => ({
157
+ content: s.content,
158
+ lineNumber: s.lineIndex + 1,
159
+ // 1-based
160
+ similarity: s.similarity
161
+ }));
162
+ }
163
+ function calculateLineSimilarity(a, b) {
164
+ if (a.length !== b.length) return 0;
165
+ if (a.length === 0) return 1;
166
+ let totalSimilarity = 0;
167
+ let totalWeight = 0;
168
+ for (let i = 0; i < a.length; i++) {
169
+ const lineA = a[i];
170
+ const lineB = b[i];
171
+ const weight = Math.max(lineA.length, lineB.length, 1);
172
+ const similarity = stringSimilarity(lineA, lineB);
173
+ totalSimilarity += similarity * weight;
174
+ totalWeight += weight;
175
+ }
176
+ return totalWeight > 0 ? totalSimilarity / totalWeight : 0;
177
+ }
178
+ function stringSimilarity(a, b) {
179
+ if (a === b) return 1;
180
+ if (a.length === 0 || b.length === 0) return 0;
181
+ const distance = levenshteinDistance(a, b);
182
+ const maxLen = Math.max(a.length, b.length);
183
+ return 1 - distance / maxLen;
184
+ }
185
+ function levenshteinDistance(a, b) {
186
+ const matrix = [];
187
+ for (let i = 0; i <= b.length; i++) {
188
+ matrix[i] = [i];
189
+ }
190
+ for (let j = 0; j <= a.length; j++) {
191
+ matrix[0][j] = j;
192
+ }
193
+ for (let i = 1; i <= b.length; i++) {
194
+ for (let j = 1; j <= a.length; j++) {
195
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
196
+ matrix[i][j] = matrix[i - 1][j - 1];
197
+ } else {
198
+ matrix[i][j] = Math.min(
199
+ matrix[i - 1][j - 1] + 1,
200
+ // substitution
201
+ matrix[i][j - 1] + 1,
202
+ // insertion
203
+ matrix[i - 1][j] + 1
204
+ // deletion
205
+ );
206
+ }
207
+ }
208
+ }
209
+ return matrix[b.length][a.length];
210
+ }
211
+ function getLineNumbers(content, startIndex, endIndex) {
212
+ const beforeStart = content.slice(0, startIndex);
213
+ const beforeEnd = content.slice(0, endIndex);
214
+ const startLine = (beforeStart.match(/\n/g) || []).length + 1;
215
+ const endLine = (beforeEnd.match(/\n/g) || []).length + 1;
216
+ return { startLine, endLine };
217
+ }
218
+ function isHorizontalWhitespace(char) {
219
+ return char === " " || char === " ";
220
+ }
221
+ function mapNormalizedToOriginal(original, normalizedStart, normalizedLength) {
222
+ const originalStart = findOriginalIndex(original, normalizedStart);
223
+ const originalEnd = findOriginalIndex(original, normalizedStart + normalizedLength);
224
+ return { originalStart, originalEnd: originalEnd === -1 ? original.length : originalEnd };
225
+ }
226
+ function findOriginalIndex(original, targetNormalizedPos) {
227
+ let normalizedPos = 0;
228
+ let inWhitespace = false;
229
+ for (let i = 0; i < original.length; i++) {
230
+ if (normalizedPos === targetNormalizedPos) {
231
+ return i;
232
+ }
233
+ const isWs = isHorizontalWhitespace(original[i]);
234
+ if (isWs && !inWhitespace) {
235
+ normalizedPos++;
236
+ inWhitespace = true;
237
+ } else if (!isWs) {
238
+ normalizedPos++;
239
+ inWhitespace = false;
240
+ }
241
+ }
242
+ return normalizedPos === targetNormalizedPos ? original.length : -1;
243
+ }
244
+ function getContext(content, lineNumber, contextLines) {
245
+ const lines = content.split("\n");
246
+ const start = Math.max(0, lineNumber - 1 - contextLines);
247
+ const end = Math.min(lines.length, lineNumber + contextLines);
248
+ const contextWithNumbers = lines.slice(start, end).map((line, i) => {
249
+ const num = start + i + 1;
250
+ const marker = num === lineNumber ? ">" : " ";
251
+ return `${marker}${String(num).padStart(4)} | ${line}`;
252
+ });
253
+ return contextWithNumbers.join("\n");
254
+ }
62
255
 
63
256
  // src/builtins/filesystem/utils.ts
64
257
  import fs from "fs";
@@ -91,110 +284,139 @@ function validatePathIsWithinCwd(inputPath) {
91
284
  }
92
285
 
93
286
  // src/builtins/filesystem/edit-file.ts
94
- function filterDangerousCommands(commands) {
95
- return commands.split("\n").filter((line) => !line.trimStart().startsWith("!")).join("\n");
287
+ function formatFailure(filePath, search, failure, fileContent) {
288
+ const lines = [
289
+ `path=${filePath} status=failed`,
290
+ "",
291
+ `Error: ${failure.reason}`,
292
+ "",
293
+ "SEARCH CONTENT:",
294
+ "```",
295
+ search,
296
+ "```"
297
+ ];
298
+ if (failure.suggestions.length > 0) {
299
+ lines.push("", "SUGGESTIONS (similar content found):");
300
+ for (const suggestion of failure.suggestions) {
301
+ const percent = Math.round(suggestion.similarity * 100);
302
+ lines.push(
303
+ "",
304
+ `Line ${suggestion.lineNumber} (${percent}% similar):`,
305
+ "```",
306
+ suggestion.content,
307
+ "```"
308
+ );
309
+ }
310
+ if (failure.nearbyContext) {
311
+ lines.push("", "CONTEXT:", failure.nearbyContext);
312
+ }
313
+ }
314
+ lines.push("", "CURRENT FILE CONTENT:", "```", fileContent, "```");
315
+ return lines.join("\n");
96
316
  }
97
317
  var editFile = createGadget({
98
318
  name: "EditFile",
99
- description: "Edit a file using ed commands. Ed is a line-oriented text editor - pipe commands to it for precise file modifications. Commands are executed in sequence. Remember to end with 'w' (write) and 'q' (quit). Shell escape commands (!) are filtered for security.",
319
+ description: `Edit a file by searching for content and replacing it.
320
+
321
+ Uses layered matching strategies (in order):
322
+ 1. Exact match - byte-for-byte comparison
323
+ 2. Whitespace-insensitive - ignores differences in spaces/tabs
324
+ 3. Indentation-preserving - matches structure ignoring leading whitespace
325
+ 4. Fuzzy match - similarity-based matching (80% threshold)
326
+
327
+ For multiple edits to the same file, call this gadget multiple times.
328
+ Each call provides immediate feedback, allowing you to adjust subsequent edits.`,
100
329
  schema: z.object({
101
330
  filePath: z.string().describe("Path to the file to edit (relative or absolute)"),
102
- commands: z.string().describe("Ed commands to execute, one per line")
331
+ search: z.string().describe("The content to search for in the file"),
332
+ replace: z.string().describe("The content to replace it with (empty string to delete)")
103
333
  }),
104
334
  examples: [
105
335
  {
106
336
  params: {
107
- filePath: "config.txt",
108
- commands: `1,$p
109
- q`
337
+ filePath: "src/config.ts",
338
+ search: "const DEBUG = false;",
339
+ replace: "const DEBUG = true;"
110
340
  },
111
- output: "path=config.txt\n\n32\nkey=value\noption=true",
112
- comment: "Print entire file contents (ed shows byte count, then content)"
341
+ output: "path=src/config.ts status=success strategy=exact lines=5-5\n\nReplaced content successfully.\n\nUPDATED FILE CONTENT:\n```\n// config.ts\nconst DEBUG = true;\nexport default { DEBUG };\n```",
342
+ comment: "Simple single-line edit"
113
343
  },
114
344
  {
115
345
  params: {
116
- filePath: "data.txt",
117
- commands: `1,$s/foo/bar/g
118
- w
119
- q`
346
+ filePath: "src/utils.ts",
347
+ search: `function oldHelper() {
348
+ return 1;
349
+ }`,
350
+ replace: `function newHelper() {
351
+ return 2;
352
+ }`
120
353
  },
121
- output: "path=data.txt\n\n42\n42",
122
- comment: "Replace all 'foo' with 'bar' (ed shows bytes read, then bytes written)"
354
+ output: "path=src/utils.ts status=success strategy=exact lines=10-12\n\nReplaced content successfully.\n\nUPDATED FILE CONTENT:\n```\n// utils.ts\nfunction newHelper() {\n return 2;\n}\n```",
355
+ comment: "Multi-line replacement"
123
356
  },
124
357
  {
125
358
  params: {
126
- filePath: "list.txt",
127
- commands: `3d
128
- w
129
- q`
359
+ filePath: "src/app.ts",
360
+ search: "unusedImport",
361
+ replace: ""
130
362
  },
131
- output: "path=list.txt\n\n45\n28",
132
- comment: "Delete line 3, save and quit"
133
- },
134
- {
135
- params: {
136
- filePath: "readme.txt",
137
- commands: `$a
138
- New last line
139
- .
140
- w
141
- q`
142
- },
143
- output: "path=readme.txt\n\n40\n56",
144
- comment: "Append text after last line ($ = last line, . = end input mode)"
363
+ output: 'path=src/app.ts status=success strategy=exact lines=3-3\n\nReplaced content successfully.\n\nUPDATED FILE CONTENT:\n```\n// app.ts\nimport { usedImport } from "./lib";\n```',
364
+ comment: "Delete content by replacing with empty string"
145
365
  }
146
366
  ],
147
367
  timeoutMs: 3e4,
148
- execute: async ({ filePath, commands }) => {
149
- const validatedPath = validatePathIsWithinCwd(filePath);
150
- const safeCommands = filterDangerousCommands(commands);
368
+ execute: ({ filePath, search, replace }) => {
369
+ if (search.trim() === "") {
370
+ return `path=${filePath} status=error
371
+
372
+ Error: Search content cannot be empty.`;
373
+ }
374
+ let validatedPath;
151
375
  try {
152
- const proc = spawn(["ed", validatedPath], {
153
- stdin: "pipe",
154
- stdout: "pipe",
155
- stderr: "pipe"
156
- });
157
- if (!proc.stdin) {
158
- return `path=${filePath}
376
+ validatedPath = validatePathIsWithinCwd(filePath);
377
+ } catch (error) {
378
+ const message = error instanceof Error ? error.message : String(error);
379
+ return `path=${filePath} status=error
159
380
 
160
- error: Failed to open stdin for ed process`;
161
- }
162
- proc.stdin.write(`${safeCommands}
163
- `);
164
- proc.stdin.end();
165
- let timeoutId;
166
- const timeoutPromise = new Promise((_, reject) => {
167
- timeoutId = setTimeout(() => {
168
- proc.kill();
169
- reject(new Error("ed command timed out after 30000ms"));
170
- }, 3e4);
171
- });
172
- const [exitCode, stdout, stderr] = await Promise.race([
173
- Promise.all([
174
- proc.exited,
175
- new Response(proc.stdout).text(),
176
- new Response(proc.stderr).text()
177
- ]),
178
- timeoutPromise
179
- ]);
180
- if (timeoutId) {
181
- clearTimeout(timeoutId);
182
- }
183
- const output = [stdout, stderr].filter(Boolean).join("\n").trim();
184
- if (exitCode !== 0) {
185
- return `path=${filePath}
381
+ Error: ${message}`;
382
+ }
383
+ let content;
384
+ try {
385
+ content = readFileSync(validatedPath, "utf-8");
386
+ } catch (error) {
387
+ const nodeError = error;
388
+ if (nodeError.code === "ENOENT") {
389
+ return `path=${filePath} status=error
186
390
 
187
- ${output || "ed exited with non-zero status"}`;
391
+ Error: File not found: ${filePath}`;
188
392
  }
189
- return `path=${filePath}
393
+ const message = error instanceof Error ? error.message : String(error);
394
+ return `path=${filePath} status=error
190
395
 
191
- ${output || "(no output)"}`;
396
+ Error reading file: ${message}`;
397
+ }
398
+ const match = findMatch(content, search);
399
+ if (!match) {
400
+ const failure = getMatchFailure(content, search);
401
+ return formatFailure(filePath, search, failure, content);
402
+ }
403
+ const newContent = applyReplacement(content, match, replace);
404
+ try {
405
+ writeFileSync(validatedPath, newContent, "utf-8");
192
406
  } catch (error) {
193
407
  const message = error instanceof Error ? error.message : String(error);
194
- return `path=${filePath}
408
+ return `path=${filePath} status=error
195
409
 
196
- error: ${message}`;
410
+ Error writing file: ${message}`;
197
411
  }
412
+ return `path=${filePath} status=success strategy=${match.strategy} lines=${match.startLine}-${match.endLine}
413
+
414
+ Replaced content successfully.
415
+
416
+ UPDATED FILE CONTENT:
417
+ \`\`\`
418
+ ${newContent}
419
+ \`\`\``;
198
420
  }
199
421
  });
200
422
 
@@ -409,6 +631,66 @@ Wrote ${bytesWritten} bytes${dirNote}`;
409
631
  // src/builtins/run-command.ts
410
632
  import { z as z5 } from "zod";
411
633
  import { createGadget as createGadget5 } from "llmist";
634
+
635
+ // src/spawn.ts
636
+ import { spawn as nodeSpawn } from "child_process";
637
+ function nodeStreamToReadableStream(nodeStream) {
638
+ if (!nodeStream) return null;
639
+ return new ReadableStream({
640
+ start(controller) {
641
+ nodeStream.on("data", (chunk) => {
642
+ controller.enqueue(new Uint8Array(chunk));
643
+ });
644
+ nodeStream.on("end", () => {
645
+ controller.close();
646
+ });
647
+ nodeStream.on("error", (err) => {
648
+ controller.error(err);
649
+ });
650
+ },
651
+ cancel() {
652
+ nodeStream.destroy();
653
+ }
654
+ });
655
+ }
656
+ function spawn(argv, options = {}) {
657
+ const [command, ...args] = argv;
658
+ const proc = nodeSpawn(command, args, {
659
+ cwd: options.cwd,
660
+ stdio: [
661
+ options.stdin === "pipe" ? "pipe" : options.stdin ?? "ignore",
662
+ options.stdout === "pipe" ? "pipe" : options.stdout ?? "ignore",
663
+ options.stderr === "pipe" ? "pipe" : options.stderr ?? "ignore"
664
+ ]
665
+ });
666
+ const exited = new Promise((resolve, reject) => {
667
+ proc.on("exit", (code) => {
668
+ resolve(code ?? 1);
669
+ });
670
+ proc.on("error", (err) => {
671
+ reject(err);
672
+ });
673
+ });
674
+ const stdin = proc.stdin ? {
675
+ write(data) {
676
+ proc.stdin?.write(data);
677
+ },
678
+ end() {
679
+ proc.stdin?.end();
680
+ }
681
+ } : null;
682
+ return {
683
+ exited,
684
+ stdout: nodeStreamToReadableStream(proc.stdout),
685
+ stderr: nodeStreamToReadableStream(proc.stderr),
686
+ stdin,
687
+ kill() {
688
+ proc.kill();
689
+ }
690
+ };
691
+ }
692
+
693
+ // src/builtins/run-command.ts
412
694
  var runCommand = createGadget5({
413
695
  name: "RunCommand",
414
696
  description: "Execute a command with arguments and return its output. Uses argv array to bypass shell - arguments are passed directly without interpretation. Returns stdout/stderr combined with exit status.",