lcagent-cli 0.1.5 → 0.1.7
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/README.md +140 -1
- package/dist/app/bootstrap.d.ts +4 -1
- package/dist/app/bootstrap.js +2 -2
- package/dist/bin/cli.js +109 -38
- package/dist/config/schema.d.ts +2 -0
- package/dist/config/schema.js +2 -0
- package/dist/core/engine.d.ts +3 -2
- package/dist/core/engine.js +100 -10
- package/dist/core/loop.d.ts +7 -2
- package/dist/core/loop.js +9 -2
- package/dist/core/message.d.ts +9 -0
- package/dist/core/message.js +9 -0
- package/dist/tools/editFile.d.ts +15 -3
- package/dist/tools/editFile.js +273 -13
- package/dist/tools/editUtils.d.ts +15 -0
- package/dist/tools/editUtils.js +142 -0
- package/dist/tools/execute.js +62 -5
- package/dist/tools/grep.d.ts +5 -1
- package/dist/tools/grep.js +22 -9
- package/dist/tools/permissions.d.ts +12 -0
- package/dist/tools/permissions.js +105 -0
- package/dist/tools/readFile.d.ts +5 -1
- package/dist/tools/readFile.js +17 -4
- package/dist/tools/runShell.d.ts +2 -1
- package/dist/tools/runShell.js +11 -9
- package/dist/tools/types.d.ts +28 -0
- package/package.json +3 -1
package/dist/tools/editFile.js
CHANGED
|
@@ -1,48 +1,308 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { applyPatch as applyUnifiedPatch, parsePatch } from 'diff';
|
|
3
4
|
import { z } from 'zod';
|
|
5
|
+
import { applyMatches, findEditMatches, stripTrailingWhitespace } from './editUtils.js';
|
|
4
6
|
const inputSchema = z.object({
|
|
5
|
-
path: z.string().min(1),
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
path: z.string().min(1).optional(),
|
|
8
|
+
filePath: z.string().min(1).optional(),
|
|
9
|
+
file_path: z.string().min(1).optional(),
|
|
10
|
+
patch: z.string().optional(),
|
|
11
|
+
diff: z.string().optional(),
|
|
12
|
+
unifiedDiff: z.string().optional(),
|
|
13
|
+
unified_diff: z.string().optional(),
|
|
14
|
+
oldText: z.string().optional(),
|
|
15
|
+
old_text: z.string().optional(),
|
|
16
|
+
newText: z.string().optional(),
|
|
17
|
+
new_text: z.string().optional(),
|
|
18
|
+
replaceAll: z.boolean().optional(),
|
|
19
|
+
replace_all: z.boolean().optional(),
|
|
20
|
+
occurrence: z.number().int().positive().optional(),
|
|
21
|
+
occurrence_index: z.number().int().positive().optional(),
|
|
8
22
|
});
|
|
9
23
|
function resolvePath(cwd, filePath) {
|
|
10
24
|
return isAbsolute(filePath) ? filePath : resolve(cwd, filePath);
|
|
11
25
|
}
|
|
26
|
+
function normalizePatchPath(filePath) {
|
|
27
|
+
if (!filePath) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return filePath.replace(/^(a|b)\//, '');
|
|
31
|
+
}
|
|
32
|
+
function getPathBasename(filePath) {
|
|
33
|
+
const segments = filePath.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
34
|
+
return segments[segments.length - 1] ?? filePath;
|
|
35
|
+
}
|
|
36
|
+
function extractSinglePatch(patchText) {
|
|
37
|
+
const parsed = parsePatch(patchText);
|
|
38
|
+
if (parsed.length === 0) {
|
|
39
|
+
return { error: 'Patch could not be parsed. Provide a valid unified diff.' };
|
|
40
|
+
}
|
|
41
|
+
if (parsed.length > 1) {
|
|
42
|
+
return {
|
|
43
|
+
error: 'Patch mode currently supports exactly one file diff. Split multi-file patches into separate edit_file calls.',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const patch = parsed[0];
|
|
47
|
+
const targetPath = normalizePatchPath(patch.newFileName) ?? normalizePatchPath(patch.oldFileName);
|
|
48
|
+
return { patch, targetPath };
|
|
49
|
+
}
|
|
50
|
+
function patchTouchedLines(patch) {
|
|
51
|
+
const lines = new Set();
|
|
52
|
+
for (const hunk of patch.hunks) {
|
|
53
|
+
const removedOnly = hunk.lines.every(line => line.startsWith('-'));
|
|
54
|
+
const start = removedOnly ? hunk.oldStart : hunk.newStart;
|
|
55
|
+
const length = removedOnly ? hunk.oldLines : hunk.newLines;
|
|
56
|
+
const safeLength = Math.max(length, 1);
|
|
57
|
+
for (let offset = 0; offset < safeLength; offset += 1) {
|
|
58
|
+
lines.add(start + offset);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [...lines].sort((left, right) => left - right);
|
|
62
|
+
}
|
|
63
|
+
function buildPatchMismatchDiagnostic(inputPath, patchPath) {
|
|
64
|
+
return [
|
|
65
|
+
'Patch target path does not match the requested file path.',
|
|
66
|
+
`Input path: ${inputPath}`,
|
|
67
|
+
`Patch path: ${patchPath}`,
|
|
68
|
+
'Either omit path and let the patch header decide, or make them match exactly.',
|
|
69
|
+
].join('\n');
|
|
70
|
+
}
|
|
71
|
+
function applySingleFilePatch(current, patchText) {
|
|
72
|
+
const parsed = extractSinglePatch(patchText);
|
|
73
|
+
if ('error' in parsed) {
|
|
74
|
+
return parsed;
|
|
75
|
+
}
|
|
76
|
+
const updated = applyUnifiedPatch(current, parsed.patch, {
|
|
77
|
+
fuzzFactor: 0,
|
|
78
|
+
});
|
|
79
|
+
if (updated === false) {
|
|
80
|
+
return {
|
|
81
|
+
error: [
|
|
82
|
+
'Patch could not be applied cleanly.',
|
|
83
|
+
'The file content may have changed, or the patch context does not match exactly.',
|
|
84
|
+
'Re-read the file and generate a fresh unified diff.',
|
|
85
|
+
].join('\n'),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
updated,
|
|
90
|
+
touchedLines: patchTouchedLines(parsed.patch),
|
|
91
|
+
patchPath: parsed.targetPath,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function getLineNumberAtIndex(source, index) {
|
|
95
|
+
return source.slice(0, index).split(/\r?\n/).length;
|
|
96
|
+
}
|
|
97
|
+
function buildLinePreview(source, lineNumber) {
|
|
98
|
+
const lines = source.split(/\r?\n/);
|
|
99
|
+
const start = Math.max(lineNumber - 2, 0);
|
|
100
|
+
const end = Math.min(lineNumber + 1, lines.length);
|
|
101
|
+
return lines
|
|
102
|
+
.slice(start, end)
|
|
103
|
+
.map((line, offset) => `${start + offset + 1}: ${line}`)
|
|
104
|
+
.join('\n');
|
|
105
|
+
}
|
|
106
|
+
function buildNotFoundDiagnostic(source, oldText) {
|
|
107
|
+
const firstAnchor = oldText
|
|
108
|
+
.split(/\r?\n/)
|
|
109
|
+
.map(line => line.trim())
|
|
110
|
+
.find(Boolean);
|
|
111
|
+
if (!firstAnchor) {
|
|
112
|
+
return 'oldText was not found in the target file.';
|
|
113
|
+
}
|
|
114
|
+
const lines = source.split(/\r?\n/);
|
|
115
|
+
const anchorLower = firstAnchor.toLowerCase();
|
|
116
|
+
const matchingLines = lines
|
|
117
|
+
.map((line, index) => ({ line, lineNumber: index + 1 }))
|
|
118
|
+
.filter(item => item.line.toLowerCase().includes(anchorLower))
|
|
119
|
+
.slice(0, 3);
|
|
120
|
+
if (matchingLines.length === 0) {
|
|
121
|
+
const whitespaceNormalized = stripTrailingWhitespace(oldText);
|
|
122
|
+
const sourceWithoutTrailingWhitespace = stripTrailingWhitespace(source);
|
|
123
|
+
if (whitespaceNormalized !== oldText &&
|
|
124
|
+
sourceWithoutTrailingWhitespace.includes(whitespaceNormalized)) {
|
|
125
|
+
return [
|
|
126
|
+
'oldText was not found exactly in the target file.',
|
|
127
|
+
'A trailing-whitespace-normalized version appears to exist.',
|
|
128
|
+
'Try reading the file again and resend oldText without trailing spaces.',
|
|
129
|
+
].join('\n');
|
|
130
|
+
}
|
|
131
|
+
return 'oldText was not found in the target file.';
|
|
132
|
+
}
|
|
133
|
+
return [
|
|
134
|
+
'oldText was not found exactly in the target file.',
|
|
135
|
+
'Closest anchor matches:',
|
|
136
|
+
...matchingLines.map(item => `${item.lineNumber}: ${item.line}`),
|
|
137
|
+
].join('\n');
|
|
138
|
+
}
|
|
12
139
|
export const editFileTool = {
|
|
13
140
|
name: 'edit_file',
|
|
14
|
-
description: '
|
|
141
|
+
description: 'Edit a file either by replacing text or by applying a single-file unified diff patch.',
|
|
15
142
|
inputSchema,
|
|
16
143
|
inputSchemaJson: {
|
|
17
144
|
type: 'object',
|
|
18
145
|
properties: {
|
|
19
146
|
path: { type: 'string' },
|
|
147
|
+
patch: {
|
|
148
|
+
type: 'string',
|
|
149
|
+
description: 'Unified diff patch for a single file. When provided, oldText/newText are not required.',
|
|
150
|
+
},
|
|
20
151
|
oldText: { type: 'string' },
|
|
21
152
|
newText: { type: 'string' },
|
|
153
|
+
replaceAll: { type: 'boolean' },
|
|
154
|
+
occurrence: {
|
|
155
|
+
type: 'integer',
|
|
156
|
+
description: '1-based occurrence index to replace when multiple exact matches exist.',
|
|
157
|
+
},
|
|
22
158
|
},
|
|
23
|
-
required: ['path', 'oldText', 'newText'],
|
|
24
159
|
additionalProperties: false,
|
|
25
160
|
},
|
|
26
161
|
isReadOnly: false,
|
|
27
162
|
async execute(input, context) {
|
|
28
|
-
|
|
163
|
+
const normalizedPath = input.path ?? input.filePath ?? input.file_path;
|
|
164
|
+
const patchText = input.patch ?? input.diff ?? input.unifiedDiff ?? input.unified_diff;
|
|
165
|
+
const oldText = input.oldText ?? input.old_text;
|
|
166
|
+
const newText = input.newText ?? input.new_text;
|
|
167
|
+
const replaceAll = input.replaceAll ?? input.replace_all ?? false;
|
|
168
|
+
const occurrence = input.occurrence ?? input.occurrence_index;
|
|
169
|
+
if (patchText !== undefined && (oldText !== undefined || newText !== undefined)) {
|
|
170
|
+
return {
|
|
171
|
+
isError: true,
|
|
172
|
+
content: 'Use either patch mode or oldText/newText mode, not both in the same call.',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (patchText === undefined && (!normalizedPath || oldText === undefined || newText === undefined)) {
|
|
176
|
+
return {
|
|
177
|
+
isError: true,
|
|
178
|
+
content: 'Missing required fields. Use path + oldText + newText, or provide patch (and optionally path).',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (patchText === undefined && oldText !== undefined && oldText.length === 0) {
|
|
29
182
|
return {
|
|
30
183
|
isError: true,
|
|
31
|
-
content: '
|
|
184
|
+
content: 'oldText must not be empty.',
|
|
32
185
|
};
|
|
33
186
|
}
|
|
34
|
-
|
|
35
|
-
const current = await readFile(absolutePath, 'utf8');
|
|
36
|
-
if (!current.includes(input.oldText)) {
|
|
187
|
+
if (patchText !== undefined && (replaceAll || occurrence !== undefined)) {
|
|
37
188
|
return {
|
|
38
189
|
isError: true,
|
|
39
|
-
content: '
|
|
190
|
+
content: 'replaceAll and occurrence are only supported in oldText/newText mode.',
|
|
40
191
|
};
|
|
41
192
|
}
|
|
42
|
-
|
|
193
|
+
if (patchText === undefined && replaceAll && occurrence !== undefined) {
|
|
194
|
+
return {
|
|
195
|
+
isError: true,
|
|
196
|
+
content: 'Use either replaceAll or occurrence, not both.',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
let resolvedPath = normalizedPath;
|
|
200
|
+
if (patchText !== undefined) {
|
|
201
|
+
const parsed = extractSinglePatch(patchText);
|
|
202
|
+
if ('error' in parsed) {
|
|
203
|
+
return {
|
|
204
|
+
isError: true,
|
|
205
|
+
content: parsed.error,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (!resolvedPath && parsed.targetPath) {
|
|
209
|
+
resolvedPath = parsed.targetPath;
|
|
210
|
+
}
|
|
211
|
+
if (!resolvedPath) {
|
|
212
|
+
return {
|
|
213
|
+
isError: true,
|
|
214
|
+
content: 'Patch mode requires a path field or a patch header with a target file path.',
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
if (parsed.targetPath && normalizePatchPath(resolvedPath) !== normalizePatchPath(parsed.targetPath)) {
|
|
218
|
+
return {
|
|
219
|
+
isError: true,
|
|
220
|
+
content: buildPatchMismatchDiagnostic(resolvedPath, parsed.targetPath),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const absolutePath = resolvePath(context.cwd, resolvedPath);
|
|
225
|
+
let current;
|
|
226
|
+
try {
|
|
227
|
+
current = await readFile(absolutePath, 'utf8');
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
return {
|
|
231
|
+
isError: true,
|
|
232
|
+
content: error instanceof Error ? error.message : String(error),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (patchText !== undefined) {
|
|
236
|
+
const patchResult = applySingleFilePatch(current, patchText);
|
|
237
|
+
if ('error' in patchResult) {
|
|
238
|
+
return {
|
|
239
|
+
isError: true,
|
|
240
|
+
content: patchResult.error,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
await writeFile(absolutePath, patchResult.updated, 'utf8');
|
|
244
|
+
return {
|
|
245
|
+
content: `Applied patch to ${resolvedPath} touching line${patchResult.touchedLines.length === 1 ? '' : 's'} ${patchResult.touchedLines.join(', ')}.`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const requiredOldText = oldText;
|
|
249
|
+
const requiredNewText = newText;
|
|
250
|
+
const matches = findEditMatches(current, requiredOldText);
|
|
251
|
+
if (matches.length === 0) {
|
|
252
|
+
return {
|
|
253
|
+
isError: true,
|
|
254
|
+
content: buildNotFoundDiagnostic(current, requiredOldText),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
let next;
|
|
258
|
+
let replacedCount;
|
|
259
|
+
let replacedLines;
|
|
260
|
+
let matchTypeLabel = matches[0]?.matchType === 'normalized_quotes'
|
|
261
|
+
? 'normalized quote match'
|
|
262
|
+
: 'exact match';
|
|
263
|
+
if (replaceAll) {
|
|
264
|
+
next = applyMatches(current, requiredOldText, requiredNewText, matches);
|
|
265
|
+
replacedCount = matches.length;
|
|
266
|
+
replacedLines = matches.map(match => getLineNumberAtIndex(current, match.index));
|
|
267
|
+
}
|
|
268
|
+
else if (occurrence !== undefined) {
|
|
269
|
+
if (occurrence > matches.length) {
|
|
270
|
+
return {
|
|
271
|
+
isError: true,
|
|
272
|
+
content: `Requested occurrence ${occurrence}, but only found ${matches.length} exact match(es).`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const selected = matches[occurrence - 1];
|
|
276
|
+
next = applyMatches(current, requiredOldText, requiredNewText, [selected]);
|
|
277
|
+
replacedCount = 1;
|
|
278
|
+
replacedLines = [getLineNumberAtIndex(current, selected.index)];
|
|
279
|
+
matchTypeLabel = selected.matchType === 'normalized_quotes'
|
|
280
|
+
? 'normalized quote match'
|
|
281
|
+
: 'exact match';
|
|
282
|
+
}
|
|
283
|
+
else if (matches.length > 1) {
|
|
284
|
+
const previews = matches.slice(0, 3).map(match => {
|
|
285
|
+
const lineNumber = getLineNumberAtIndex(current, match.index);
|
|
286
|
+
return `${match.matchType === 'normalized_quotes' ? '[normalized-quotes]' : '[exact]'}\n${buildLinePreview(current, lineNumber)}`;
|
|
287
|
+
});
|
|
288
|
+
return {
|
|
289
|
+
isError: true,
|
|
290
|
+
content: [
|
|
291
|
+
`Found ${matches.length} ${matches[0]?.matchType === 'normalized_quotes' ? 'normalized quote' : 'exact'} matches for oldText in ${resolvedPath}.`,
|
|
292
|
+
'Specify occurrence (1-based) or set replaceAll=true.',
|
|
293
|
+
'Example matching locations:',
|
|
294
|
+
...previews,
|
|
295
|
+
].join('\n---\n'),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
next = applyMatches(current, requiredOldText, requiredNewText, [matches[0]]);
|
|
300
|
+
replacedCount = 1;
|
|
301
|
+
replacedLines = [getLineNumberAtIndex(current, matches[0].index)];
|
|
302
|
+
}
|
|
43
303
|
await writeFile(absolutePath, next, 'utf8');
|
|
44
304
|
return {
|
|
45
|
-
content: `Updated ${
|
|
305
|
+
content: `Updated ${resolvedPath} (${replacedCount} replacement${replacedCount === 1 ? '' : 's'}) using ${matchTypeLabel} at line${replacedLines.length === 1 ? '' : 's'} ${replacedLines.join(', ')}.`,
|
|
46
306
|
};
|
|
47
307
|
},
|
|
48
308
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const LEFT_SINGLE_CURLY_QUOTE = "\u2018";
|
|
2
|
+
export declare const RIGHT_SINGLE_CURLY_QUOTE = "\u2019";
|
|
3
|
+
export declare const LEFT_DOUBLE_CURLY_QUOTE = "\u201C";
|
|
4
|
+
export declare const RIGHT_DOUBLE_CURLY_QUOTE = "\u201D";
|
|
5
|
+
export type EditMatch = {
|
|
6
|
+
index: number;
|
|
7
|
+
actual: string;
|
|
8
|
+
matchType: 'exact' | 'normalized_quotes';
|
|
9
|
+
};
|
|
10
|
+
export declare function normalizeQuotes(value: string): string;
|
|
11
|
+
export declare function stripTrailingWhitespace(value: string): string;
|
|
12
|
+
export declare function findExactMatchIndices(source: string, target: string): number[];
|
|
13
|
+
export declare function preserveQuoteStyle(requestedOldText: string, actualOldText: string, requestedNewText: string): string;
|
|
14
|
+
export declare function findEditMatches(source: string, requestedOldText: string): EditMatch[];
|
|
15
|
+
export declare function applyMatches(source: string, requestedOldText: string, requestedNewText: string, matches: EditMatch[]): string;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
export const LEFT_SINGLE_CURLY_QUOTE = '‘';
|
|
2
|
+
export const RIGHT_SINGLE_CURLY_QUOTE = '’';
|
|
3
|
+
export const LEFT_DOUBLE_CURLY_QUOTE = '“';
|
|
4
|
+
export const RIGHT_DOUBLE_CURLY_QUOTE = '”';
|
|
5
|
+
export function normalizeQuotes(value) {
|
|
6
|
+
return value
|
|
7
|
+
.replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'")
|
|
8
|
+
.replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'")
|
|
9
|
+
.replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"')
|
|
10
|
+
.replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"');
|
|
11
|
+
}
|
|
12
|
+
export function stripTrailingWhitespace(value) {
|
|
13
|
+
const parts = value.split(/(\r\n|\n|\r)/);
|
|
14
|
+
let result = '';
|
|
15
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
16
|
+
const part = parts[index];
|
|
17
|
+
if (part === undefined) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
result += index % 2 === 0 ? part.replace(/\s+$/, '') : part;
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
export function findExactMatchIndices(source, target) {
|
|
25
|
+
const indices = [];
|
|
26
|
+
if (!target) {
|
|
27
|
+
return indices;
|
|
28
|
+
}
|
|
29
|
+
let searchStart = 0;
|
|
30
|
+
while (searchStart <= source.length) {
|
|
31
|
+
const index = source.indexOf(target, searchStart);
|
|
32
|
+
if (index === -1) {
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
indices.push(index);
|
|
36
|
+
searchStart = index + Math.max(target.length, 1);
|
|
37
|
+
}
|
|
38
|
+
return indices;
|
|
39
|
+
}
|
|
40
|
+
function isOpeningContext(characters, index) {
|
|
41
|
+
if (index === 0) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
const previous = characters[index - 1];
|
|
45
|
+
return (previous === ' ' ||
|
|
46
|
+
previous === '\t' ||
|
|
47
|
+
previous === '\n' ||
|
|
48
|
+
previous === '\r' ||
|
|
49
|
+
previous === '(' ||
|
|
50
|
+
previous === '[' ||
|
|
51
|
+
previous === '{' ||
|
|
52
|
+
previous === '\u2014' ||
|
|
53
|
+
previous === '\u2013');
|
|
54
|
+
}
|
|
55
|
+
function applyCurlyDoubleQuotes(value) {
|
|
56
|
+
const characters = [...value];
|
|
57
|
+
const result = [];
|
|
58
|
+
for (let index = 0; index < characters.length; index += 1) {
|
|
59
|
+
const character = characters[index];
|
|
60
|
+
if (character === '"') {
|
|
61
|
+
result.push(isOpeningContext(characters, index)
|
|
62
|
+
? LEFT_DOUBLE_CURLY_QUOTE
|
|
63
|
+
: RIGHT_DOUBLE_CURLY_QUOTE);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
result.push(character ?? '');
|
|
67
|
+
}
|
|
68
|
+
return result.join('');
|
|
69
|
+
}
|
|
70
|
+
function applyCurlySingleQuotes(value) {
|
|
71
|
+
const characters = [...value];
|
|
72
|
+
const result = [];
|
|
73
|
+
for (let index = 0; index < characters.length; index += 1) {
|
|
74
|
+
const character = characters[index];
|
|
75
|
+
if (character === "'") {
|
|
76
|
+
const previous = index > 0 ? characters[index - 1] : undefined;
|
|
77
|
+
const next = index < characters.length - 1 ? characters[index + 1] : undefined;
|
|
78
|
+
const previousIsLetter = previous !== undefined && /\p{L}/u.test(previous);
|
|
79
|
+
const nextIsLetter = next !== undefined && /\p{L}/u.test(next);
|
|
80
|
+
if (previousIsLetter && nextIsLetter) {
|
|
81
|
+
result.push(RIGHT_SINGLE_CURLY_QUOTE);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
result.push(isOpeningContext(characters, index)
|
|
85
|
+
? LEFT_SINGLE_CURLY_QUOTE
|
|
86
|
+
: RIGHT_SINGLE_CURLY_QUOTE);
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
result.push(character ?? '');
|
|
91
|
+
}
|
|
92
|
+
return result.join('');
|
|
93
|
+
}
|
|
94
|
+
export function preserveQuoteStyle(requestedOldText, actualOldText, requestedNewText) {
|
|
95
|
+
if (requestedOldText === actualOldText) {
|
|
96
|
+
return requestedNewText;
|
|
97
|
+
}
|
|
98
|
+
const hasDoubleCurlyQuotes = actualOldText.includes(LEFT_DOUBLE_CURLY_QUOTE) ||
|
|
99
|
+
actualOldText.includes(RIGHT_DOUBLE_CURLY_QUOTE);
|
|
100
|
+
const hasSingleCurlyQuotes = actualOldText.includes(LEFT_SINGLE_CURLY_QUOTE) ||
|
|
101
|
+
actualOldText.includes(RIGHT_SINGLE_CURLY_QUOTE);
|
|
102
|
+
if (!hasDoubleCurlyQuotes && !hasSingleCurlyQuotes) {
|
|
103
|
+
return requestedNewText;
|
|
104
|
+
}
|
|
105
|
+
let result = requestedNewText;
|
|
106
|
+
if (hasDoubleCurlyQuotes) {
|
|
107
|
+
result = applyCurlyDoubleQuotes(result);
|
|
108
|
+
}
|
|
109
|
+
if (hasSingleCurlyQuotes) {
|
|
110
|
+
result = applyCurlySingleQuotes(result);
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
export function findEditMatches(source, requestedOldText) {
|
|
115
|
+
const exactMatches = findExactMatchIndices(source, requestedOldText);
|
|
116
|
+
if (exactMatches.length > 0) {
|
|
117
|
+
return exactMatches.map(index => ({
|
|
118
|
+
index,
|
|
119
|
+
actual: requestedOldText,
|
|
120
|
+
matchType: 'exact',
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
const normalizedOldText = normalizeQuotes(requestedOldText);
|
|
124
|
+
const normalizedSource = normalizeQuotes(source);
|
|
125
|
+
const normalizedIndices = findExactMatchIndices(normalizedSource, normalizedOldText);
|
|
126
|
+
return normalizedIndices.map(index => ({
|
|
127
|
+
index,
|
|
128
|
+
actual: source.slice(index, index + requestedOldText.length),
|
|
129
|
+
matchType: 'normalized_quotes',
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
export function applyMatches(source, requestedOldText, requestedNewText, matches) {
|
|
133
|
+
let next = source;
|
|
134
|
+
for (const match of [...matches].sort((left, right) => right.index - left.index)) {
|
|
135
|
+
const replacement = preserveQuoteStyle(requestedOldText, match.actual, requestedNewText);
|
|
136
|
+
next =
|
|
137
|
+
next.slice(0, match.index) +
|
|
138
|
+
replacement +
|
|
139
|
+
next.slice(match.index + match.actual.length);
|
|
140
|
+
}
|
|
141
|
+
return next;
|
|
142
|
+
}
|
package/dist/tools/execute.js
CHANGED
|
@@ -1,17 +1,74 @@
|
|
|
1
|
+
import { buildApprovalRequest } from './permissions.js';
|
|
2
|
+
function finalizeResult(result, context, startedAt, approval, failureStage) {
|
|
3
|
+
return {
|
|
4
|
+
...result,
|
|
5
|
+
meta: {
|
|
6
|
+
cwd: context.cwd,
|
|
7
|
+
durationMs: Date.now() - startedAt,
|
|
8
|
+
approval,
|
|
9
|
+
failureStage,
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
1
13
|
export async function executeToolCall(toolUse, tools, context) {
|
|
14
|
+
const startedAt = Date.now();
|
|
2
15
|
const tool = tools.find(item => item.name === toolUse.name);
|
|
3
16
|
if (!tool) {
|
|
4
|
-
return {
|
|
17
|
+
return finalizeResult({
|
|
5
18
|
isError: true,
|
|
6
19
|
content: `Unknown tool: ${toolUse.name}`,
|
|
7
|
-
}
|
|
20
|
+
}, context, startedAt, {
|
|
21
|
+
mode: context.approvalMode,
|
|
22
|
+
required: false,
|
|
23
|
+
}, 'lookup');
|
|
8
24
|
}
|
|
9
25
|
const parsed = tool.inputSchema.safeParse(toolUse.input);
|
|
10
26
|
if (!parsed.success) {
|
|
11
|
-
return {
|
|
27
|
+
return finalizeResult({
|
|
12
28
|
isError: true,
|
|
13
29
|
content: parsed.error.message,
|
|
14
|
-
}
|
|
30
|
+
}, context, startedAt, {
|
|
31
|
+
mode: context.approvalMode,
|
|
32
|
+
required: false,
|
|
33
|
+
}, 'validation');
|
|
34
|
+
}
|
|
35
|
+
const approvalRequest = buildApprovalRequest(tool, parsed.data, context);
|
|
36
|
+
const approvalMeta = {
|
|
37
|
+
mode: context.approvalMode,
|
|
38
|
+
required: Boolean(approvalRequest),
|
|
39
|
+
risk: approvalRequest?.risk,
|
|
40
|
+
targets: approvalRequest?.targets,
|
|
41
|
+
};
|
|
42
|
+
if (approvalRequest) {
|
|
43
|
+
if (!context.requestApproval) {
|
|
44
|
+
return finalizeResult({
|
|
45
|
+
isError: true,
|
|
46
|
+
content: 'This tool requires manual approval, but no approval handler is available in the current session.',
|
|
47
|
+
}, context, startedAt, {
|
|
48
|
+
...approvalMeta,
|
|
49
|
+
approved: false,
|
|
50
|
+
}, 'approval');
|
|
51
|
+
}
|
|
52
|
+
const decision = await context.requestApproval(approvalRequest);
|
|
53
|
+
if (!decision.approved) {
|
|
54
|
+
return finalizeResult({
|
|
55
|
+
isError: true,
|
|
56
|
+
content: decision.reason ?? `User denied ${tool.name}.`,
|
|
57
|
+
}, context, startedAt, {
|
|
58
|
+
...approvalMeta,
|
|
59
|
+
approved: false,
|
|
60
|
+
}, 'approval');
|
|
61
|
+
}
|
|
62
|
+
approvalMeta.approved = true;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const result = await tool.execute(parsed.data, context);
|
|
66
|
+
return finalizeResult(result, context, startedAt, approvalMeta);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return finalizeResult({
|
|
70
|
+
isError: true,
|
|
71
|
+
content: error instanceof Error ? error.message : String(error),
|
|
72
|
+
}, context, startedAt, approvalMeta, 'execution');
|
|
15
73
|
}
|
|
16
|
-
return tool.execute(parsed.data, context);
|
|
17
74
|
}
|
package/dist/tools/grep.d.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ToolDefinition } from './types.js';
|
|
3
3
|
declare const inputSchema: z.ZodObject<{
|
|
4
|
-
pattern: z.ZodString
|
|
4
|
+
pattern: z.ZodOptional<z.ZodString>;
|
|
5
|
+
query: z.ZodOptional<z.ZodString>;
|
|
5
6
|
path: z.ZodOptional<z.ZodString>;
|
|
7
|
+
directory: z.ZodOptional<z.ZodString>;
|
|
6
8
|
maxResults: z.ZodOptional<z.ZodNumber>;
|
|
9
|
+
max_results: z.ZodOptional<z.ZodNumber>;
|
|
7
10
|
isRegex: z.ZodOptional<z.ZodBoolean>;
|
|
11
|
+
is_regex: z.ZodOptional<z.ZodBoolean>;
|
|
8
12
|
}, z.core.$strip>;
|
|
9
13
|
export declare const grepTool: ToolDefinition<z.infer<typeof inputSchema>>;
|
|
10
14
|
export {};
|
package/dist/tools/grep.js
CHANGED
|
@@ -2,10 +2,14 @@ import { readdir, readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { extname, isAbsolute, join, resolve } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
const inputSchema = z.object({
|
|
5
|
-
pattern: z.string().min(1),
|
|
5
|
+
pattern: z.string().min(1).optional(),
|
|
6
|
+
query: z.string().min(1).optional(),
|
|
6
7
|
path: z.string().optional(),
|
|
8
|
+
directory: z.string().optional(),
|
|
7
9
|
maxResults: z.number().int().positive().max(200).optional(),
|
|
10
|
+
max_results: z.number().int().positive().max(200).optional(),
|
|
8
11
|
isRegex: z.boolean().optional(),
|
|
12
|
+
is_regex: z.boolean().optional(),
|
|
9
13
|
});
|
|
10
14
|
const textExtensions = new Set([
|
|
11
15
|
'.ts', '.tsx', '.js', '.jsx', '.json', '.md', '.txt', '.yml', '.yaml', '.css', '.html', '.mjs', '.cjs', '.sh', '.py', '.java', '.go', '.rs'
|
|
@@ -44,17 +48,26 @@ export const grepTool = {
|
|
|
44
48
|
},
|
|
45
49
|
isReadOnly: true,
|
|
46
50
|
async execute(input, context) {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
:
|
|
51
|
+
const pattern = input.pattern ?? input.query;
|
|
52
|
+
if (!pattern) {
|
|
53
|
+
return {
|
|
54
|
+
isError: true,
|
|
55
|
+
content: 'Missing required field: pattern',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const searchPath = input.path ?? input.directory;
|
|
59
|
+
const root = searchPath
|
|
60
|
+
? isAbsolute(searchPath)
|
|
61
|
+
? searchPath
|
|
62
|
+
: resolve(context.cwd, searchPath)
|
|
51
63
|
: context.cwd;
|
|
52
|
-
const
|
|
53
|
-
|
|
64
|
+
const isRegex = input.isRegex ?? input.is_regex;
|
|
65
|
+
const matcher = isRegex
|
|
66
|
+
? new RegExp(pattern, 'i')
|
|
54
67
|
: null;
|
|
55
68
|
const files = await walk(root);
|
|
56
69
|
const results = [];
|
|
57
|
-
const maxResults = input.maxResults ?? 50;
|
|
70
|
+
const maxResults = input.maxResults ?? input.max_results ?? 50;
|
|
58
71
|
for (const filePath of files) {
|
|
59
72
|
if (!textExtensions.has(extname(filePath))) {
|
|
60
73
|
continue;
|
|
@@ -68,7 +81,7 @@ export const grepTool = {
|
|
|
68
81
|
const line = lines[index] ?? '';
|
|
69
82
|
const matched = matcher
|
|
70
83
|
? matcher.test(line)
|
|
71
|
-
: line.toLowerCase().includes(
|
|
84
|
+
: line.toLowerCase().includes(pattern.toLowerCase());
|
|
72
85
|
if (!matched) {
|
|
73
86
|
continue;
|
|
74
87
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ToolContext, ToolDefinition } from './types.js';
|
|
2
|
+
export declare const DANGEROUS_FILES: readonly [".gitconfig", ".gitmodules", ".bashrc", ".bash_profile", ".zshrc", ".zprofile", ".profile", ".mcp.json", ".claude.json"];
|
|
3
|
+
export declare const DANGEROUS_DIRECTORIES: readonly [".git", ".vscode", ".idea", ".claude"];
|
|
4
|
+
export type ToolApprovalRequest = {
|
|
5
|
+
toolName: string;
|
|
6
|
+
summary: string;
|
|
7
|
+
reason: string;
|
|
8
|
+
targets: string[];
|
|
9
|
+
risk: 'low' | 'medium' | 'high';
|
|
10
|
+
input: unknown;
|
|
11
|
+
};
|
|
12
|
+
export declare function buildApprovalRequest(tool: ToolDefinition, input: unknown, context: ToolContext): ToolApprovalRequest | null;
|