lcagent-cli 0.1.6 → 0.1.8

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.
@@ -1,61 +1,351 @@
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
7
  path: z.string().min(1).optional(),
6
8
  filePath: z.string().min(1).optional(),
7
9
  file_path: z.string().min(1).optional(),
10
+ content: z.string().optional(),
11
+ fileContent: z.string().optional(),
12
+ file_content: z.string().optional(),
13
+ replaceEntireFile: z.boolean().optional(),
14
+ replace_entire_file: z.boolean().optional(),
15
+ patch: z.string().optional(),
16
+ diff: z.string().optional(),
17
+ unifiedDiff: z.string().optional(),
18
+ unified_diff: z.string().optional(),
8
19
  oldText: z.string().optional(),
9
20
  old_text: z.string().optional(),
10
21
  newText: z.string().optional(),
11
22
  new_text: z.string().optional(),
23
+ replaceAll: z.boolean().optional(),
24
+ replace_all: z.boolean().optional(),
25
+ occurrence: z.number().int().positive().optional(),
26
+ occurrence_index: z.number().int().positive().optional(),
12
27
  });
13
28
  function resolvePath(cwd, filePath) {
14
29
  return isAbsolute(filePath) ? filePath : resolve(cwd, filePath);
15
30
  }
31
+ function normalizePatchPath(filePath) {
32
+ if (!filePath) {
33
+ return null;
34
+ }
35
+ return filePath.replace(/^(a|b)\//, '');
36
+ }
37
+ function getPathBasename(filePath) {
38
+ const segments = filePath.replace(/\\/g, '/').split('/').filter(Boolean);
39
+ return segments[segments.length - 1] ?? filePath;
40
+ }
41
+ function extractSinglePatch(patchText) {
42
+ const parsed = parsePatch(patchText);
43
+ if (parsed.length === 0) {
44
+ return { error: 'Patch could not be parsed. Provide a valid unified diff.' };
45
+ }
46
+ if (parsed.length > 1) {
47
+ return {
48
+ error: 'Patch mode currently supports exactly one file diff. Split multi-file patches into separate edit_file calls.',
49
+ };
50
+ }
51
+ const patch = parsed[0];
52
+ const targetPath = normalizePatchPath(patch.newFileName) ?? normalizePatchPath(patch.oldFileName);
53
+ return { patch, targetPath };
54
+ }
55
+ function patchTouchedLines(patch) {
56
+ const lines = new Set();
57
+ for (const hunk of patch.hunks) {
58
+ const removedOnly = hunk.lines.every(line => line.startsWith('-'));
59
+ const start = removedOnly ? hunk.oldStart : hunk.newStart;
60
+ const length = removedOnly ? hunk.oldLines : hunk.newLines;
61
+ const safeLength = Math.max(length, 1);
62
+ for (let offset = 0; offset < safeLength; offset += 1) {
63
+ lines.add(start + offset);
64
+ }
65
+ }
66
+ return [...lines].sort((left, right) => left - right);
67
+ }
68
+ function buildPatchMismatchDiagnostic(inputPath, patchPath) {
69
+ return [
70
+ 'Patch target path does not match the requested file path.',
71
+ `Input path: ${inputPath}`,
72
+ `Patch path: ${patchPath}`,
73
+ 'Either omit path and let the patch header decide, or make them match exactly.',
74
+ ].join('\n');
75
+ }
76
+ function applySingleFilePatch(current, patchText) {
77
+ const parsed = extractSinglePatch(patchText);
78
+ if ('error' in parsed) {
79
+ return parsed;
80
+ }
81
+ const updated = applyUnifiedPatch(current, parsed.patch, {
82
+ fuzzFactor: 0,
83
+ });
84
+ if (updated === false) {
85
+ return {
86
+ error: [
87
+ 'Patch could not be applied cleanly.',
88
+ 'The file content may have changed, or the patch context does not match exactly.',
89
+ 'Re-read the file and generate a fresh unified diff.',
90
+ ].join('\n'),
91
+ };
92
+ }
93
+ return {
94
+ updated,
95
+ touchedLines: patchTouchedLines(parsed.patch),
96
+ patchPath: parsed.targetPath,
97
+ };
98
+ }
99
+ function getLineNumberAtIndex(source, index) {
100
+ return source.slice(0, index).split(/\r?\n/).length;
101
+ }
102
+ function buildLinePreview(source, lineNumber) {
103
+ const lines = source.split(/\r?\n/);
104
+ const start = Math.max(lineNumber - 2, 0);
105
+ const end = Math.min(lineNumber + 1, lines.length);
106
+ return lines
107
+ .slice(start, end)
108
+ .map((line, offset) => `${start + offset + 1}: ${line}`)
109
+ .join('\n');
110
+ }
111
+ function buildNotFoundDiagnostic(source, oldText) {
112
+ const firstAnchor = oldText
113
+ .split(/\r?\n/)
114
+ .map(line => line.trim())
115
+ .find(Boolean);
116
+ if (!firstAnchor) {
117
+ return 'oldText was not found in the target file.';
118
+ }
119
+ const lines = source.split(/\r?\n/);
120
+ const anchorLower = firstAnchor.toLowerCase();
121
+ const matchingLines = lines
122
+ .map((line, index) => ({ line, lineNumber: index + 1 }))
123
+ .filter(item => item.line.toLowerCase().includes(anchorLower))
124
+ .slice(0, 3);
125
+ if (matchingLines.length === 0) {
126
+ const whitespaceNormalized = stripTrailingWhitespace(oldText);
127
+ const sourceWithoutTrailingWhitespace = stripTrailingWhitespace(source);
128
+ if (whitespaceNormalized !== oldText &&
129
+ sourceWithoutTrailingWhitespace.includes(whitespaceNormalized)) {
130
+ return [
131
+ 'oldText was not found exactly in the target file.',
132
+ 'A trailing-whitespace-normalized version appears to exist.',
133
+ 'Try reading the file again and resend oldText without trailing spaces.',
134
+ ].join('\n');
135
+ }
136
+ return 'oldText was not found in the target file.';
137
+ }
138
+ return [
139
+ 'oldText was not found exactly in the target file.',
140
+ 'Closest anchor matches:',
141
+ ...matchingLines.map(item => `${item.lineNumber}: ${item.line}`),
142
+ ].join('\n');
143
+ }
16
144
  export const editFileTool = {
17
145
  name: 'edit_file',
18
- description: 'Replace the first occurrence of oldText with newText in a text file.',
146
+ description: 'Edit a file by replacing text, applying a single-file unified diff patch, or creating/replacing a whole file from content.',
19
147
  inputSchema,
20
148
  inputSchemaJson: {
21
149
  type: 'object',
22
150
  properties: {
23
151
  path: { type: 'string' },
152
+ content: {
153
+ type: 'string',
154
+ description: 'Full file contents to write. Use for creating a new file, or set replaceEntireFile=true to overwrite an existing file.',
155
+ },
156
+ replaceEntireFile: {
157
+ type: 'boolean',
158
+ description: 'Required with content mode when overwriting an existing file.',
159
+ },
160
+ patch: {
161
+ type: 'string',
162
+ description: 'Unified diff patch for a single file. When provided, oldText/newText are not required.',
163
+ },
24
164
  oldText: { type: 'string' },
25
165
  newText: { type: 'string' },
166
+ replaceAll: { type: 'boolean' },
167
+ occurrence: {
168
+ type: 'integer',
169
+ description: '1-based occurrence index to replace when multiple exact matches exist.',
170
+ },
26
171
  },
27
- required: ['path', 'oldText', 'newText'],
28
172
  additionalProperties: false,
29
173
  },
30
174
  isReadOnly: false,
31
175
  async execute(input, context) {
32
- if (context.approvalMode === 'manual') {
176
+ const normalizedPath = input.path ?? input.filePath ?? input.file_path;
177
+ const fullContent = input.content ?? input.fileContent ?? input.file_content;
178
+ const replaceEntireFile = input.replaceEntireFile ?? input.replace_entire_file ?? false;
179
+ const patchText = input.patch ?? input.diff ?? input.unifiedDiff ?? input.unified_diff;
180
+ const oldText = input.oldText ?? input.old_text;
181
+ const newText = input.newText ?? input.new_text;
182
+ const replaceAll = input.replaceAll ?? input.replace_all ?? false;
183
+ const occurrence = input.occurrence ?? input.occurrence_index;
184
+ if (patchText !== undefined &&
185
+ (oldText !== undefined || newText !== undefined || fullContent !== undefined)) {
33
186
  return {
34
187
  isError: true,
35
- content: 'edit_file is blocked in manual approval mode. Switch config approvalMode to auto to enable file edits.',
188
+ content: 'Use exactly one edit mode: patch, oldText/newText, or content.',
36
189
  };
37
190
  }
38
- const normalizedPath = input.path ?? input.filePath ?? input.file_path;
39
- const oldText = input.oldText ?? input.old_text;
40
- const newText = input.newText ?? input.new_text;
41
- if (!normalizedPath || oldText === undefined || newText === undefined) {
191
+ if (fullContent !== undefined &&
192
+ (oldText !== undefined || newText !== undefined)) {
42
193
  return {
43
194
  isError: true,
44
- content: 'Missing required fields: path, oldText, or newText',
195
+ content: 'Use either content mode or oldText/newText mode, not both in the same call.',
45
196
  };
46
197
  }
47
- const absolutePath = resolvePath(context.cwd, normalizedPath);
48
- const current = await readFile(absolutePath, 'utf8');
49
- if (!current.includes(oldText)) {
198
+ if (patchText === undefined &&
199
+ fullContent === undefined &&
200
+ (!normalizedPath || oldText === undefined || newText === undefined)) {
50
201
  return {
51
202
  isError: true,
52
- content: 'oldText was not found in the target file.',
203
+ content: 'Missing required fields. Use path + oldText + newText, or path + content, or provide patch (and optionally path).',
53
204
  };
54
205
  }
55
- const next = current.replace(oldText, newText);
206
+ if (patchText === undefined && oldText !== undefined && oldText.length === 0) {
207
+ return {
208
+ isError: true,
209
+ content: 'oldText must not be empty.',
210
+ };
211
+ }
212
+ if (patchText !== undefined && (replaceAll || occurrence !== undefined)) {
213
+ return {
214
+ isError: true,
215
+ content: 'replaceAll and occurrence are only supported in oldText/newText mode.',
216
+ };
217
+ }
218
+ if (patchText === undefined && replaceAll && occurrence !== undefined) {
219
+ return {
220
+ isError: true,
221
+ content: 'Use either replaceAll or occurrence, not both.',
222
+ };
223
+ }
224
+ let resolvedPath = normalizedPath;
225
+ if (patchText !== undefined) {
226
+ const parsed = extractSinglePatch(patchText);
227
+ if ('error' in parsed) {
228
+ return {
229
+ isError: true,
230
+ content: parsed.error,
231
+ };
232
+ }
233
+ if (!resolvedPath && parsed.targetPath) {
234
+ resolvedPath = parsed.targetPath;
235
+ }
236
+ if (!resolvedPath) {
237
+ return {
238
+ isError: true,
239
+ content: 'Patch mode requires a path field or a patch header with a target file path.',
240
+ };
241
+ }
242
+ if (parsed.targetPath && normalizePatchPath(resolvedPath) !== normalizePatchPath(parsed.targetPath)) {
243
+ return {
244
+ isError: true,
245
+ content: buildPatchMismatchDiagnostic(resolvedPath, parsed.targetPath),
246
+ };
247
+ }
248
+ }
249
+ const absolutePath = resolvePath(context.cwd, resolvedPath);
250
+ let current;
251
+ try {
252
+ current = await readFile(absolutePath, 'utf8');
253
+ }
254
+ catch (error) {
255
+ if (fullContent !== undefined) {
256
+ current = '';
257
+ }
258
+ else {
259
+ return {
260
+ isError: true,
261
+ content: error instanceof Error ? error.message : String(error),
262
+ };
263
+ }
264
+ }
265
+ if (fullContent !== undefined) {
266
+ const fileExists = current.length > 0;
267
+ if (fileExists && !replaceEntireFile) {
268
+ return {
269
+ isError: true,
270
+ content: 'Content mode can create a new file, but overwriting an existing file requires replaceEntireFile=true.',
271
+ };
272
+ }
273
+ await writeFile(absolutePath, fullContent, 'utf8');
274
+ return {
275
+ content: `${fileExists ? 'Replaced' : 'Created'} ${resolvedPath}.`,
276
+ };
277
+ }
278
+ if (patchText !== undefined) {
279
+ const patchResult = applySingleFilePatch(current, patchText);
280
+ if ('error' in patchResult) {
281
+ return {
282
+ isError: true,
283
+ content: patchResult.error,
284
+ };
285
+ }
286
+ await writeFile(absolutePath, patchResult.updated, 'utf8');
287
+ return {
288
+ content: `Applied patch to ${resolvedPath} touching line${patchResult.touchedLines.length === 1 ? '' : 's'} ${patchResult.touchedLines.join(', ')}.`,
289
+ };
290
+ }
291
+ const requiredOldText = oldText;
292
+ const requiredNewText = newText;
293
+ const matches = findEditMatches(current, requiredOldText);
294
+ if (matches.length === 0) {
295
+ return {
296
+ isError: true,
297
+ content: buildNotFoundDiagnostic(current, requiredOldText),
298
+ };
299
+ }
300
+ let next;
301
+ let replacedCount;
302
+ let replacedLines;
303
+ let matchTypeLabel = matches[0]?.matchType === 'normalized_quotes'
304
+ ? 'normalized quote match'
305
+ : 'exact match';
306
+ if (replaceAll) {
307
+ next = applyMatches(current, requiredOldText, requiredNewText, matches);
308
+ replacedCount = matches.length;
309
+ replacedLines = matches.map(match => getLineNumberAtIndex(current, match.index));
310
+ }
311
+ else if (occurrence !== undefined) {
312
+ if (occurrence > matches.length) {
313
+ return {
314
+ isError: true,
315
+ content: `Requested occurrence ${occurrence}, but only found ${matches.length} exact match(es).`,
316
+ };
317
+ }
318
+ const selected = matches[occurrence - 1];
319
+ next = applyMatches(current, requiredOldText, requiredNewText, [selected]);
320
+ replacedCount = 1;
321
+ replacedLines = [getLineNumberAtIndex(current, selected.index)];
322
+ matchTypeLabel = selected.matchType === 'normalized_quotes'
323
+ ? 'normalized quote match'
324
+ : 'exact match';
325
+ }
326
+ else if (matches.length > 1) {
327
+ const previews = matches.slice(0, 3).map(match => {
328
+ const lineNumber = getLineNumberAtIndex(current, match.index);
329
+ return `${match.matchType === 'normalized_quotes' ? '[normalized-quotes]' : '[exact]'}\n${buildLinePreview(current, lineNumber)}`;
330
+ });
331
+ return {
332
+ isError: true,
333
+ content: [
334
+ `Found ${matches.length} ${matches[0]?.matchType === 'normalized_quotes' ? 'normalized quote' : 'exact'} matches for oldText in ${resolvedPath}.`,
335
+ 'Specify occurrence (1-based) or set replaceAll=true.',
336
+ 'Example matching locations:',
337
+ ...previews,
338
+ ].join('\n---\n'),
339
+ };
340
+ }
341
+ else {
342
+ next = applyMatches(current, requiredOldText, requiredNewText, [matches[0]]);
343
+ replacedCount = 1;
344
+ replacedLines = [getLineNumberAtIndex(current, matches[0].index)];
345
+ }
56
346
  await writeFile(absolutePath, next, 'utf8');
57
347
  return {
58
- content: `Updated ${normalizedPath}`,
348
+ content: `Updated ${resolvedPath} (${replacedCount} replacement${replacedCount === 1 ? '' : 's'}) using ${matchTypeLabel} at line${replacedLines.length === 1 ? '' : 's'} ${replacedLines.join(', ')}.`,
59
349
  };
60
350
  },
61
351
  };
@@ -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
+ }
@@ -1,25 +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;
15
63
  }
16
64
  try {
17
- return await tool.execute(parsed.data, context);
65
+ const result = await tool.execute(parsed.data, context);
66
+ return finalizeResult(result, context, startedAt, approvalMeta);
18
67
  }
19
68
  catch (error) {
20
- return {
69
+ return finalizeResult({
21
70
  isError: true,
22
71
  content: error instanceof Error ? error.message : String(error),
23
- };
72
+ }, context, startedAt, approvalMeta, 'execution');
24
73
  }
25
74
  }
@@ -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;