@theokit/sdk-tools 0.1.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/CHANGELOG.md +32 -0
- package/LICENSE +201 -0
- package/README.md +80 -0
- package/dist/index.cjs +1394 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +430 -0
- package/dist/index.d.ts +430 -0
- package/dist/index.js +1373 -0
- package/dist/index.js.map +1 -0
- package/package.json +71 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1394 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var promises = require('fs/promises');
|
|
4
|
+
var path = require('path');
|
|
5
|
+
var sdk = require('@theokit/sdk');
|
|
6
|
+
var zod = require('zod');
|
|
7
|
+
var fs = require('fs');
|
|
8
|
+
var child_process = require('child_process');
|
|
9
|
+
|
|
10
|
+
// src/apply-patch.ts
|
|
11
|
+
var PathTraversalError = class extends sdk.ConfigurationError {
|
|
12
|
+
name = "PathTraversalError";
|
|
13
|
+
constructor(input, resolvedPath) {
|
|
14
|
+
super(`Path traversal attempt: ${input} \u2192 ${resolvedPath}`, {
|
|
15
|
+
code: "path_traversal"
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var ForbiddenPathError = class extends sdk.ConfigurationError {
|
|
20
|
+
name = "ForbiddenPathError";
|
|
21
|
+
constructor(path) {
|
|
22
|
+
super(
|
|
23
|
+
`Path '${path}' is in the sensitive-file blocklist (.env, .git/, node_modules/, .theo/, lock files)`,
|
|
24
|
+
{
|
|
25
|
+
code: "forbidden_path"
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
function safePathJoin(base, ...parts) {
|
|
31
|
+
if (base === "") {
|
|
32
|
+
throw new Error("safePathJoin: base must be non-empty");
|
|
33
|
+
}
|
|
34
|
+
const baseResolved = path.resolve(base);
|
|
35
|
+
const target = path.resolve(base, ...parts);
|
|
36
|
+
if (target !== baseResolved && !target.startsWith(baseResolved + path.sep)) {
|
|
37
|
+
throw new PathTraversalError(parts.join("/"), target);
|
|
38
|
+
}
|
|
39
|
+
return target;
|
|
40
|
+
}
|
|
41
|
+
function assertNoSymlinkEscape(path$1, base) {
|
|
42
|
+
let baseResolved;
|
|
43
|
+
try {
|
|
44
|
+
baseResolved = fs.realpathSync(base);
|
|
45
|
+
} catch {
|
|
46
|
+
baseResolved = path.resolve(base);
|
|
47
|
+
}
|
|
48
|
+
const resolved = realpathOfDeepestExisting(path$1);
|
|
49
|
+
if (resolved === void 0) return;
|
|
50
|
+
if (resolved !== baseResolved && !resolved.startsWith(baseResolved + path.sep)) {
|
|
51
|
+
throw new PathTraversalError(`symlink ${path$1}`, resolved);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function realpathOfDeepestExisting(path$1) {
|
|
55
|
+
try {
|
|
56
|
+
return fs.realpathSync(path$1);
|
|
57
|
+
} catch {
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const stat = fs.lstatSync(path$1);
|
|
61
|
+
if (stat.isSymbolicLink()) {
|
|
62
|
+
const target = fs.readlinkSync(path$1);
|
|
63
|
+
const parentReal = realpathOfDeepestExisting(path.dirname(path$1));
|
|
64
|
+
const parentBase = parentReal ?? path.dirname(path$1);
|
|
65
|
+
return path.resolve(parentBase, target);
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
}
|
|
69
|
+
let cursor = path.dirname(path$1);
|
|
70
|
+
let suffix = path$1.slice(cursor.length);
|
|
71
|
+
while (cursor !== path.dirname(cursor)) {
|
|
72
|
+
try {
|
|
73
|
+
const real = fs.realpathSync(cursor);
|
|
74
|
+
return path.resolve(real, `.${suffix}`);
|
|
75
|
+
} catch {
|
|
76
|
+
suffix = path$1.slice(path.dirname(cursor).length);
|
|
77
|
+
cursor = path.dirname(cursor);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return void 0;
|
|
81
|
+
}
|
|
82
|
+
var LOCK_FILES = /* @__PURE__ */ new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
|
|
83
|
+
function isForbiddenPath(input) {
|
|
84
|
+
const normalized = input.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
85
|
+
if (normalized.length === 0) return false;
|
|
86
|
+
const segments = normalized.split("/").filter((s) => s.length > 0);
|
|
87
|
+
if (segments.length === 0) return false;
|
|
88
|
+
const first = segments[0];
|
|
89
|
+
if (first === ".env.example") return false;
|
|
90
|
+
if (first === ".env") return true;
|
|
91
|
+
if (/^\.env\./.test(first)) return true;
|
|
92
|
+
if (first === ".git") return true;
|
|
93
|
+
if (first === "node_modules") return true;
|
|
94
|
+
if (first === ".theo") return true;
|
|
95
|
+
const basename = segments[segments.length - 1];
|
|
96
|
+
if (LOCK_FILES.has(basename)) return true;
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/apply-patch.ts
|
|
101
|
+
function createApplyPatchTool(opts) {
|
|
102
|
+
const { projectRoot } = opts;
|
|
103
|
+
return sdk.defineTool({
|
|
104
|
+
name: "apply_patch",
|
|
105
|
+
description: "Apply a unified diff patch to project files. Each file in the diff is security-checked against the project root. Creates .bak backups before modifying. Returns { ok, files_patched } or { ok: false, error }.",
|
|
106
|
+
inputSchema: zod.z.object({
|
|
107
|
+
patch: zod.z.string().min(1).describe("Unified diff content.")
|
|
108
|
+
}),
|
|
109
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: unified diff parsing is inherently complex
|
|
110
|
+
handler: async ({ patch }) => {
|
|
111
|
+
const hunks = parsePatch(patch);
|
|
112
|
+
if (hunks.length === 0) {
|
|
113
|
+
return JSON.stringify({ ok: false, error: "parse_error", detail: "no file hunks found" });
|
|
114
|
+
}
|
|
115
|
+
for (const hunk of hunks) {
|
|
116
|
+
if (isForbiddenPath(hunk.file)) {
|
|
117
|
+
return JSON.stringify({ ok: false, error: "forbidden_path", path: hunk.file });
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const abs = safePathJoin(projectRoot, hunk.file);
|
|
121
|
+
assertNoSymlinkEscape(abs, projectRoot);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
|
|
124
|
+
return JSON.stringify({ ok: false, error: "path_traversal", path: hunk.file });
|
|
125
|
+
}
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const patched = [];
|
|
130
|
+
for (const hunk of hunks) {
|
|
131
|
+
const absolutePath = safePathJoin(projectRoot, hunk.file);
|
|
132
|
+
let content;
|
|
133
|
+
try {
|
|
134
|
+
content = await promises.readFile(absolutePath, "utf-8");
|
|
135
|
+
} catch (err) {
|
|
136
|
+
const e = err;
|
|
137
|
+
if (e.code === "ENOENT") {
|
|
138
|
+
content = "";
|
|
139
|
+
} else {
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const result = applyHunks(content, hunk.changes);
|
|
144
|
+
if (result === null) {
|
|
145
|
+
return JSON.stringify({
|
|
146
|
+
ok: false,
|
|
147
|
+
error: "patch_failed",
|
|
148
|
+
path: hunk.file,
|
|
149
|
+
detail: "hunk context mismatch"
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (content !== "") {
|
|
153
|
+
await promises.copyFile(absolutePath, `${absolutePath}.bak`);
|
|
154
|
+
}
|
|
155
|
+
await promises.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
156
|
+
await promises.writeFile(absolutePath, result, "utf-8");
|
|
157
|
+
patched.push(hunk.file);
|
|
158
|
+
}
|
|
159
|
+
return JSON.stringify({ ok: true, files_patched: patched });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
function parsePatch(patch) {
|
|
164
|
+
const lines = patch.split("\n");
|
|
165
|
+
const hunks = [];
|
|
166
|
+
let current = null;
|
|
167
|
+
for (const line of lines) {
|
|
168
|
+
if (line.startsWith("+++ ")) {
|
|
169
|
+
const filePath = line.slice(4).replace(/^b\//, "").trim();
|
|
170
|
+
if (filePath && filePath !== "/dev/null") {
|
|
171
|
+
current = { file: filePath, changes: [] };
|
|
172
|
+
hunks.push(current);
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (line.startsWith("--- ")) continue;
|
|
177
|
+
if (line.startsWith("@@ ")) continue;
|
|
178
|
+
if (current === null) continue;
|
|
179
|
+
if (line.startsWith("+")) {
|
|
180
|
+
current.changes.push({ type: "add", content: line.slice(1) });
|
|
181
|
+
} else if (line.startsWith("-")) {
|
|
182
|
+
current.changes.push({ type: "remove", content: line.slice(1) });
|
|
183
|
+
} else if (line.startsWith(" ")) {
|
|
184
|
+
current.changes.push({ type: "context", content: line.slice(1) });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return hunks;
|
|
188
|
+
}
|
|
189
|
+
function applyHunks(content, changes) {
|
|
190
|
+
const originalLines = content.split("\n");
|
|
191
|
+
const result = [];
|
|
192
|
+
let origIdx = 0;
|
|
193
|
+
const firstContext = changes.find((c) => c.type === "context" || c.type === "remove");
|
|
194
|
+
if (firstContext) {
|
|
195
|
+
const startIdx = originalLines.indexOf(firstContext.content, origIdx);
|
|
196
|
+
if (startIdx === -1) return null;
|
|
197
|
+
for (let i = 0; i < startIdx; i++) {
|
|
198
|
+
result.push(originalLines[i]);
|
|
199
|
+
}
|
|
200
|
+
origIdx = startIdx;
|
|
201
|
+
}
|
|
202
|
+
for (const change of changes) {
|
|
203
|
+
if (change.type === "context") {
|
|
204
|
+
if (origIdx >= originalLines.length || originalLines[origIdx] !== change.content) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
result.push(change.content);
|
|
208
|
+
origIdx++;
|
|
209
|
+
} else if (change.type === "remove") {
|
|
210
|
+
if (origIdx >= originalLines.length || originalLines[origIdx] !== change.content) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
origIdx++;
|
|
214
|
+
} else if (change.type === "add") {
|
|
215
|
+
result.push(change.content);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
while (origIdx < originalLines.length) {
|
|
219
|
+
result.push(originalLines[origIdx]);
|
|
220
|
+
origIdx++;
|
|
221
|
+
}
|
|
222
|
+
return result.join("\n");
|
|
223
|
+
}
|
|
224
|
+
function createEditFileTool(opts) {
|
|
225
|
+
const { projectRoot } = opts;
|
|
226
|
+
return sdk.defineTool({
|
|
227
|
+
name: "edit_file",
|
|
228
|
+
description: "Replace the first occurrence of old_string with new_string in a project-relative file. Falls back to whitespace-normalized matching when the exact match fails. Creates a .bak backup before editing. Returns { ok, replacements } or { ok: false, error }.",
|
|
229
|
+
inputSchema: zod.z.object({
|
|
230
|
+
path: zod.z.string().min(1).describe("Project-relative file path."),
|
|
231
|
+
old_string: zod.z.string().min(1).describe("String to find in the file."),
|
|
232
|
+
new_string: zod.z.string().describe("Replacement string.")
|
|
233
|
+
}),
|
|
234
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: unified diff parsing is inherently complex
|
|
235
|
+
handler: async ({ path, old_string, new_string }) => {
|
|
236
|
+
if (isForbiddenPath(path)) {
|
|
237
|
+
return JSON.stringify({ ok: false, error: "forbidden_path", path });
|
|
238
|
+
}
|
|
239
|
+
let absolutePath;
|
|
240
|
+
try {
|
|
241
|
+
absolutePath = safePathJoin(projectRoot, path);
|
|
242
|
+
assertNoSymlinkEscape(absolutePath, projectRoot);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
|
|
245
|
+
return JSON.stringify({ ok: false, error: "path_traversal", path });
|
|
246
|
+
}
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
let content;
|
|
250
|
+
try {
|
|
251
|
+
content = await promises.readFile(absolutePath, "utf-8");
|
|
252
|
+
} catch (err) {
|
|
253
|
+
const e = err;
|
|
254
|
+
if (e.code === "ENOENT") {
|
|
255
|
+
return JSON.stringify({ ok: false, error: "not_found", path });
|
|
256
|
+
}
|
|
257
|
+
throw err;
|
|
258
|
+
}
|
|
259
|
+
const exactIdx = content.indexOf(old_string);
|
|
260
|
+
if (exactIdx !== -1) {
|
|
261
|
+
await promises.copyFile(absolutePath, `${absolutePath}.bak`);
|
|
262
|
+
const result2 = content.slice(0, exactIdx) + new_string + content.slice(exactIdx + old_string.length);
|
|
263
|
+
await promises.writeFile(absolutePath, result2, "utf-8");
|
|
264
|
+
return JSON.stringify({ ok: true, replacements: 1 });
|
|
265
|
+
}
|
|
266
|
+
const normalizedContent = normalizeWhitespace(content);
|
|
267
|
+
const normalizedOld = normalizeWhitespace(old_string);
|
|
268
|
+
const normalizedIdx = normalizedContent.indexOf(normalizedOld);
|
|
269
|
+
if (normalizedIdx === -1) {
|
|
270
|
+
return JSON.stringify({ ok: false, error: "no_match", path });
|
|
271
|
+
}
|
|
272
|
+
const span = findOriginalSpan(
|
|
273
|
+
content,
|
|
274
|
+
normalizedContent,
|
|
275
|
+
normalizedIdx,
|
|
276
|
+
normalizedOld.length
|
|
277
|
+
);
|
|
278
|
+
await promises.copyFile(absolutePath, `${absolutePath}.bak`);
|
|
279
|
+
const result = content.slice(0, span.start) + new_string + content.slice(span.end);
|
|
280
|
+
await promises.writeFile(absolutePath, result, "utf-8");
|
|
281
|
+
return JSON.stringify({ ok: true, replacements: 1 });
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
function normalizeWhitespace(s) {
|
|
286
|
+
return s.replace(/\s+/g, " ").trim();
|
|
287
|
+
}
|
|
288
|
+
function findOriginalSpan(original, _normalized, normStart, normLen) {
|
|
289
|
+
let origIdx = 0;
|
|
290
|
+
let normIdx = 0;
|
|
291
|
+
while (origIdx < original.length && /\s/.test(original[origIdx])) {
|
|
292
|
+
origIdx++;
|
|
293
|
+
}
|
|
294
|
+
while (normIdx < normStart && origIdx < original.length) {
|
|
295
|
+
if (/\s/.test(original[origIdx])) {
|
|
296
|
+
while (origIdx < original.length && /\s/.test(original[origIdx])) {
|
|
297
|
+
origIdx++;
|
|
298
|
+
}
|
|
299
|
+
normIdx++;
|
|
300
|
+
} else {
|
|
301
|
+
origIdx++;
|
|
302
|
+
normIdx++;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const start = origIdx;
|
|
306
|
+
let walked = 0;
|
|
307
|
+
while (walked < normLen && origIdx < original.length) {
|
|
308
|
+
if (/\s/.test(original[origIdx])) {
|
|
309
|
+
while (origIdx < original.length && /\s/.test(original[origIdx])) {
|
|
310
|
+
origIdx++;
|
|
311
|
+
}
|
|
312
|
+
walked++;
|
|
313
|
+
} else {
|
|
314
|
+
origIdx++;
|
|
315
|
+
walked++;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return { start, end: origIdx };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/formatter.ts
|
|
322
|
+
function formatCode(language, code) {
|
|
323
|
+
return `\`\`\`${language}
|
|
324
|
+
${code}
|
|
325
|
+
\`\`\``;
|
|
326
|
+
}
|
|
327
|
+
function formatDiff(diff) {
|
|
328
|
+
return `\`\`\`diff
|
|
329
|
+
${diff}
|
|
330
|
+
\`\`\``;
|
|
331
|
+
}
|
|
332
|
+
function formatFileList(files) {
|
|
333
|
+
if (files.length === 0) return "(no files)";
|
|
334
|
+
return files.map((f) => `- ${f}`).join("\n");
|
|
335
|
+
}
|
|
336
|
+
function formatError(message, code) {
|
|
337
|
+
const prefix = code ? `[${code}] ` : "";
|
|
338
|
+
return `> **Error:** ${prefix}${message}`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/path-scope.ts
|
|
342
|
+
function checkPathScope(path, projectRoot) {
|
|
343
|
+
if (path === void 0 || path === "") return null;
|
|
344
|
+
try {
|
|
345
|
+
const abs = safePathJoin(projectRoot, path);
|
|
346
|
+
assertNoSymlinkEscape(abs, projectRoot);
|
|
347
|
+
return null;
|
|
348
|
+
} catch (err) {
|
|
349
|
+
if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
|
|
350
|
+
return JSON.stringify({ ok: false, error: "path_traversal", path });
|
|
351
|
+
}
|
|
352
|
+
throw err;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// src/subprocess.ts
|
|
357
|
+
function createSettleGate(timer) {
|
|
358
|
+
let done = false;
|
|
359
|
+
return {
|
|
360
|
+
settled: () => done,
|
|
361
|
+
fire: (onSettle) => {
|
|
362
|
+
if (done) return;
|
|
363
|
+
done = true;
|
|
364
|
+
clearTimeout(timer);
|
|
365
|
+
onSettle();
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function armTimeoutKill(child, timeoutMs, onTimeout, resolve2) {
|
|
370
|
+
const timer = setTimeout(() => {
|
|
371
|
+
gate.fire(() => {
|
|
372
|
+
try {
|
|
373
|
+
process.kill(-(child.pid ?? 0), "SIGKILL");
|
|
374
|
+
} catch {
|
|
375
|
+
}
|
|
376
|
+
resolve2(onTimeout());
|
|
377
|
+
});
|
|
378
|
+
}, timeoutMs);
|
|
379
|
+
const gate = createSettleGate(timer);
|
|
380
|
+
return gate;
|
|
381
|
+
}
|
|
382
|
+
function attachChildSettlers(child, gate, onClose, onError, resolve2) {
|
|
383
|
+
child.on("close", (code) => {
|
|
384
|
+
gate.fire(() => resolve2(onClose(code)));
|
|
385
|
+
});
|
|
386
|
+
child.on("error", (err) => {
|
|
387
|
+
gate.fire(() => resolve2(onError(err)));
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// src/git-diff.ts
|
|
392
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
393
|
+
var DEFAULT_MAX_STDOUT_BYTES = 5 * 1024 * 1024;
|
|
394
|
+
function createGitDiffTool(opts) {
|
|
395
|
+
const {
|
|
396
|
+
projectRoot,
|
|
397
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
398
|
+
maxStdoutBytes = DEFAULT_MAX_STDOUT_BYTES
|
|
399
|
+
} = opts;
|
|
400
|
+
return sdk.defineTool({
|
|
401
|
+
name: "git_diff",
|
|
402
|
+
description: "Return the unified diff of the project's working tree (or staged changes when cached=true). Scoped to a single file when 'path' is provided. Requires the project to be a git repository. Returns { ok, diff, truncated? } or { ok: false, error }.",
|
|
403
|
+
inputSchema: zod.z.object({
|
|
404
|
+
path: zod.z.string().optional().describe("Optional project-relative file or dir scope."),
|
|
405
|
+
cached: zod.z.boolean().optional().describe("If true, show staged changes (git diff --cached). Default false.")
|
|
406
|
+
}),
|
|
407
|
+
handler: async ({ path: path$1, cached }) => {
|
|
408
|
+
if (!fs.existsSync(path.join(projectRoot, ".git"))) {
|
|
409
|
+
return JSON.stringify({ ok: false, error: "not_a_repo" });
|
|
410
|
+
}
|
|
411
|
+
const scopeCheck = checkPathScope(path$1, projectRoot);
|
|
412
|
+
if (scopeCheck !== null) return scopeCheck;
|
|
413
|
+
const args = buildDiffArgs(cached, path$1);
|
|
414
|
+
const result = await runGitProcess(projectRoot, args, timeoutMs, maxStdoutBytes);
|
|
415
|
+
return formatGitResult(result, timeoutMs);
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
function buildDiffArgs(cached, path) {
|
|
420
|
+
const args = ["diff", "--no-color"];
|
|
421
|
+
if (cached === true) args.push("--cached");
|
|
422
|
+
if (path !== void 0 && path !== "") args.push("--", path);
|
|
423
|
+
return args;
|
|
424
|
+
}
|
|
425
|
+
function formatGitResult(result, timeoutMs) {
|
|
426
|
+
if (result.kind === "timeout") {
|
|
427
|
+
return JSON.stringify({ ok: false, error: "timeout", timeoutMs });
|
|
428
|
+
}
|
|
429
|
+
if (result.kind === "error") {
|
|
430
|
+
return JSON.stringify({ ok: false, error: "git_failed", stderr: result.stderr });
|
|
431
|
+
}
|
|
432
|
+
return JSON.stringify({ ok: true, diff: result.stdout, truncated: result.truncated });
|
|
433
|
+
}
|
|
434
|
+
function runGitProcess(cwd, args, timeoutMs, maxStdoutBytes) {
|
|
435
|
+
return new Promise((resolve2) => {
|
|
436
|
+
const child = child_process.spawn("git", args, { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
437
|
+
const stdoutChunks = [];
|
|
438
|
+
const stderrChunks = [];
|
|
439
|
+
let stdoutBytes = 0;
|
|
440
|
+
let truncated = false;
|
|
441
|
+
const gate = armTimeoutKill(
|
|
442
|
+
child,
|
|
443
|
+
timeoutMs,
|
|
444
|
+
() => ({ kind: "timeout" }),
|
|
445
|
+
resolve2
|
|
446
|
+
);
|
|
447
|
+
child.stdout.on("data", (chunk) => {
|
|
448
|
+
if (gate.settled()) return;
|
|
449
|
+
if (stdoutBytes >= maxStdoutBytes) {
|
|
450
|
+
truncated = true;
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const remaining = maxStdoutBytes - stdoutBytes;
|
|
454
|
+
if (chunk.length > remaining) {
|
|
455
|
+
stdoutChunks.push(chunk.subarray(0, remaining));
|
|
456
|
+
stdoutBytes = maxStdoutBytes;
|
|
457
|
+
truncated = true;
|
|
458
|
+
} else {
|
|
459
|
+
stdoutChunks.push(chunk);
|
|
460
|
+
stdoutBytes += chunk.length;
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
child.stderr.on("data", (chunk) => {
|
|
464
|
+
stderrChunks.push(chunk);
|
|
465
|
+
});
|
|
466
|
+
attachChildSettlers(
|
|
467
|
+
child,
|
|
468
|
+
gate,
|
|
469
|
+
(code) => {
|
|
470
|
+
const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
|
|
471
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf-8");
|
|
472
|
+
return code === 0 ? { kind: "ok", stdout, truncated } : { kind: "error", stderr };
|
|
473
|
+
},
|
|
474
|
+
(err) => ({ kind: "error", stderr: err.message }),
|
|
475
|
+
resolve2
|
|
476
|
+
);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
var DEFAULT_EXCLUDES = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", ".theo"]);
|
|
480
|
+
function createGlobTool(opts) {
|
|
481
|
+
const { projectRoot } = opts;
|
|
482
|
+
return sdk.defineTool({
|
|
483
|
+
name: "glob_files",
|
|
484
|
+
description: "List project files matching a glob-like pattern. Excludes node_modules, .git, dist, .theo by default. Returns relative paths. Pattern supports * and ** wildcards. Returns { ok, files } or { ok: false, error }.",
|
|
485
|
+
inputSchema: zod.z.object({
|
|
486
|
+
pattern: zod.z.string().min(1).describe("Glob pattern (e.g. '**/*.ts', 'src/**/*.json')."),
|
|
487
|
+
cwd: zod.z.string().optional().describe("Project-relative subdirectory to search from.")
|
|
488
|
+
}),
|
|
489
|
+
handler: async ({ pattern, cwd }) => {
|
|
490
|
+
let searchRoot = projectRoot;
|
|
491
|
+
if (cwd) {
|
|
492
|
+
try {
|
|
493
|
+
searchRoot = safePathJoin(projectRoot, cwd);
|
|
494
|
+
assertNoSymlinkEscape(searchRoot, projectRoot);
|
|
495
|
+
} catch (err) {
|
|
496
|
+
if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
|
|
497
|
+
return JSON.stringify({ ok: false, error: "path_traversal", path: cwd });
|
|
498
|
+
}
|
|
499
|
+
throw err;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const regex = globToRegex(pattern);
|
|
503
|
+
const files = [];
|
|
504
|
+
await walkDir(searchRoot, searchRoot, regex, files);
|
|
505
|
+
const relativePaths = files.map((f) => path.relative(projectRoot, f)).sort();
|
|
506
|
+
return JSON.stringify({ ok: true, files: relativePaths, count: relativePaths.length });
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
async function walkDir(base, dir, pattern, results) {
|
|
511
|
+
let entries;
|
|
512
|
+
try {
|
|
513
|
+
entries = await promises.readdir(dir, { withFileTypes: true });
|
|
514
|
+
} catch {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
for (const entry of entries) {
|
|
518
|
+
if (DEFAULT_EXCLUDES.has(entry.name)) continue;
|
|
519
|
+
const fullPath = path.join(dir, entry.name);
|
|
520
|
+
const relPath = path.relative(base, fullPath);
|
|
521
|
+
if (entry.isDirectory()) {
|
|
522
|
+
await walkDir(base, fullPath, pattern, results);
|
|
523
|
+
} else if (entry.isFile() && pattern.test(relPath)) {
|
|
524
|
+
results.push(fullPath);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
function globToRegex(pattern) {
|
|
529
|
+
let regexStr = "";
|
|
530
|
+
let i = 0;
|
|
531
|
+
while (i < pattern.length) {
|
|
532
|
+
const ch = pattern[i];
|
|
533
|
+
if (ch === "*" && pattern[i + 1] === "*") {
|
|
534
|
+
regexStr += ".*";
|
|
535
|
+
i += 2;
|
|
536
|
+
if (pattern[i] === "/") i++;
|
|
537
|
+
} else if (ch === "*") {
|
|
538
|
+
regexStr += "[^/]*";
|
|
539
|
+
i++;
|
|
540
|
+
} else if (ch === "?") {
|
|
541
|
+
regexStr += "[^/]";
|
|
542
|
+
i++;
|
|
543
|
+
} else if (".+^${}()|[]\\".includes(ch)) {
|
|
544
|
+
regexStr += `\\${ch}`;
|
|
545
|
+
i++;
|
|
546
|
+
} else {
|
|
547
|
+
regexStr += ch;
|
|
548
|
+
i++;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return new RegExp(`^${regexStr}$`);
|
|
552
|
+
}
|
|
553
|
+
var DEFAULT_MAX_ENTRIES = 500;
|
|
554
|
+
function createListDirTool(opts) {
|
|
555
|
+
const { projectRoot, max = DEFAULT_MAX_ENTRIES } = opts;
|
|
556
|
+
return sdk.defineTool({
|
|
557
|
+
name: "list_dir",
|
|
558
|
+
description: `Return the direct entries of a project-relative directory. Refuses paths outside the project root or in the sensitive-file blocklist (.env, .git/, node_modules/, .theo/, lock files). Caps at ${String(max)} entries by default; result carries truncated + totalCount.`,
|
|
559
|
+
inputSchema: zod.z.object({
|
|
560
|
+
path: zod.z.string().min(1).describe("Project-relative directory path. Use '.' for root.")
|
|
561
|
+
}),
|
|
562
|
+
handler: async ({ path }) => {
|
|
563
|
+
const relative2 = path === "" || path === "." ? "." : path;
|
|
564
|
+
if (relative2 !== "." && isForbiddenPath(relative2)) {
|
|
565
|
+
return JSON.stringify({ ok: false, error: "forbidden_path", path });
|
|
566
|
+
}
|
|
567
|
+
const boundary = resolveDirBoundary(relative2, projectRoot, path);
|
|
568
|
+
if ("error" in boundary) return boundary.error;
|
|
569
|
+
const readResult = await readDirSafe(boundary.absolutePath, path);
|
|
570
|
+
if ("error" in readResult) return readResult.error;
|
|
571
|
+
return formatListing(readResult.dirents, max);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
function resolveDirBoundary(relative2, projectRoot, originalPath) {
|
|
576
|
+
try {
|
|
577
|
+
const absolutePath = relative2 === "." ? projectRoot : safePathJoin(projectRoot, relative2);
|
|
578
|
+
assertNoSymlinkEscape(absolutePath, projectRoot);
|
|
579
|
+
return { absolutePath };
|
|
580
|
+
} catch (err) {
|
|
581
|
+
if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
|
|
582
|
+
return { error: JSON.stringify({ ok: false, error: "path_traversal", path: originalPath }) };
|
|
583
|
+
}
|
|
584
|
+
throw err;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
async function readDirSafe(absolutePath, originalPath) {
|
|
588
|
+
try {
|
|
589
|
+
const dirents = await promises.readdir(absolutePath, { withFileTypes: true });
|
|
590
|
+
return { dirents };
|
|
591
|
+
} catch (err) {
|
|
592
|
+
const e = err;
|
|
593
|
+
if (e.code === "ENOENT" || e.code === "ENOTDIR") {
|
|
594
|
+
return { error: JSON.stringify({ ok: false, error: "not_found", path: originalPath }) };
|
|
595
|
+
}
|
|
596
|
+
throw err;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function formatListing(dirents, max) {
|
|
600
|
+
const totalCount = dirents.length;
|
|
601
|
+
const truncated = totalCount > max;
|
|
602
|
+
const entries = dirents.slice(0, max).map((d) => ({
|
|
603
|
+
name: d.name,
|
|
604
|
+
type: d.isDirectory() ? "directory" : "file"
|
|
605
|
+
}));
|
|
606
|
+
return JSON.stringify({ ok: true, entries, truncated, totalCount });
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// src/plan-mode.ts
|
|
610
|
+
var PLAN_INSTRUCTIONS = [
|
|
611
|
+
"You are now in PLAN MODE.",
|
|
612
|
+
"Outline the steps you will take before executing any code changes.",
|
|
613
|
+
"Number each step. Include file paths and what will change.",
|
|
614
|
+
"Do NOT make any edits or tool calls other than reading files.",
|
|
615
|
+
"When ready, use plan_mode with action 'exit' to return to normal mode."
|
|
616
|
+
].join("\n");
|
|
617
|
+
var NORMAL_INSTRUCTIONS = "Returned to NORMAL MODE. You may now execute changes.";
|
|
618
|
+
function createPlanModeTool() {
|
|
619
|
+
let mode = "normal";
|
|
620
|
+
return {
|
|
621
|
+
name: "plan_mode",
|
|
622
|
+
description: "Toggle between normal and plan mode. Actions: 'enter' (switch to plan mode), 'exit' (return to normal), 'status' (check current mode). Returns { ok, mode, message }.",
|
|
623
|
+
inputSchema: {
|
|
624
|
+
type: "object",
|
|
625
|
+
properties: {
|
|
626
|
+
action: {
|
|
627
|
+
type: "string",
|
|
628
|
+
enum: ["enter", "exit", "status"],
|
|
629
|
+
description: "The action to perform."
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
required: ["action"]
|
|
633
|
+
},
|
|
634
|
+
handler: (input) => {
|
|
635
|
+
switch (input.action) {
|
|
636
|
+
case "enter":
|
|
637
|
+
mode = "plan";
|
|
638
|
+
return JSON.stringify({ ok: true, mode, message: PLAN_INSTRUCTIONS });
|
|
639
|
+
case "exit":
|
|
640
|
+
mode = "normal";
|
|
641
|
+
return JSON.stringify({ ok: true, mode, message: NORMAL_INSTRUCTIONS });
|
|
642
|
+
case "status":
|
|
643
|
+
return JSON.stringify({ ok: true, mode, message: `Current mode: ${mode}` });
|
|
644
|
+
default:
|
|
645
|
+
return JSON.stringify({
|
|
646
|
+
ok: false,
|
|
647
|
+
error: "invalid_action",
|
|
648
|
+
message: `Unknown action '${input.action}'. Valid: enter, exit, status.`
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
currentMode: () => mode
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// src/question.ts
|
|
657
|
+
function createQuestionTool(opts) {
|
|
658
|
+
const timeoutMs = opts.timeoutMs ?? 3e5;
|
|
659
|
+
return {
|
|
660
|
+
name: "question",
|
|
661
|
+
description: "Ask the user a question and wait for their response. Use when you need clarification or confirmation before proceeding. Returns { ok, answer } or { ok: false, error: 'timeout' }.",
|
|
662
|
+
inputSchema: {
|
|
663
|
+
type: "object",
|
|
664
|
+
properties: {
|
|
665
|
+
question: { type: "string", description: "The question to ask the user." }
|
|
666
|
+
},
|
|
667
|
+
required: ["question"]
|
|
668
|
+
},
|
|
669
|
+
handler: async (input) => {
|
|
670
|
+
const timeout = new Promise((_, reject) => {
|
|
671
|
+
setTimeout(() => reject(new Error("timeout")), timeoutMs);
|
|
672
|
+
});
|
|
673
|
+
try {
|
|
674
|
+
const answer = await Promise.race([opts.askUser(input.question), timeout]);
|
|
675
|
+
return JSON.stringify({ ok: true, answer });
|
|
676
|
+
} catch (err) {
|
|
677
|
+
if (err instanceof Error && err.message === "timeout") {
|
|
678
|
+
return JSON.stringify({
|
|
679
|
+
ok: false,
|
|
680
|
+
error: "timeout",
|
|
681
|
+
message: "User did not respond within timeout."
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
throw err;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
var MAX_FILE_SIZE = 5 * 1024 * 1024;
|
|
690
|
+
var BINARY_PROBE_BYTES = 8 * 1024;
|
|
691
|
+
function createReadFileTool(opts) {
|
|
692
|
+
const { projectRoot } = opts;
|
|
693
|
+
return sdk.defineTool({
|
|
694
|
+
name: "read_file",
|
|
695
|
+
description: "Read a single project-relative text file as UTF-8. Refuses paths that escape the project root, are in the sensitive-file blocklist (.env, .git/, node_modules/, .theo/, lock files), or contain a null byte in the first 8 KB (binary file). Returns { ok, content } or { ok: false, error }.",
|
|
696
|
+
inputSchema: zod.z.object({
|
|
697
|
+
path: zod.z.string().min(1).describe("Project-relative file path.")
|
|
698
|
+
}),
|
|
699
|
+
handler: async ({ path }) => {
|
|
700
|
+
if (isForbiddenPath(path)) {
|
|
701
|
+
return JSON.stringify({ ok: false, error: "forbidden_path", path });
|
|
702
|
+
}
|
|
703
|
+
const boundary = resolveBoundary(path, projectRoot);
|
|
704
|
+
if ("error" in boundary) return boundary.error;
|
|
705
|
+
const opened = await openHandleSafe(boundary.absolutePath, path);
|
|
706
|
+
if ("error" in opened) return opened.error;
|
|
707
|
+
try {
|
|
708
|
+
return await readContent(opened.handle, path);
|
|
709
|
+
} finally {
|
|
710
|
+
await opened.handle.close();
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
function resolveBoundary(path, projectRoot) {
|
|
716
|
+
try {
|
|
717
|
+
const absolutePath = safePathJoin(projectRoot, path);
|
|
718
|
+
assertNoSymlinkEscape(absolutePath, projectRoot);
|
|
719
|
+
return { absolutePath };
|
|
720
|
+
} catch (err) {
|
|
721
|
+
if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
|
|
722
|
+
return { error: JSON.stringify({ ok: false, error: "path_traversal", path }) };
|
|
723
|
+
}
|
|
724
|
+
throw err;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
async function openHandleSafe(absolutePath, path) {
|
|
728
|
+
try {
|
|
729
|
+
const handle = await promises.open(absolutePath, "r");
|
|
730
|
+
return { handle };
|
|
731
|
+
} catch (err) {
|
|
732
|
+
const e = err;
|
|
733
|
+
if (e.code === "ENOENT") {
|
|
734
|
+
return { error: JSON.stringify({ ok: false, error: "not_found", path }) };
|
|
735
|
+
}
|
|
736
|
+
throw err;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
async function readContent(handle, path) {
|
|
740
|
+
const stat = await handle.stat();
|
|
741
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
742
|
+
return JSON.stringify({
|
|
743
|
+
ok: false,
|
|
744
|
+
error: "too_large",
|
|
745
|
+
path,
|
|
746
|
+
size: stat.size,
|
|
747
|
+
limit: MAX_FILE_SIZE
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
if (await isBinaryProbe(handle, Number(stat.size))) {
|
|
751
|
+
return JSON.stringify({ ok: false, error: "binary_file", path, size: stat.size });
|
|
752
|
+
}
|
|
753
|
+
const content = await handle.readFile({ encoding: "utf-8" });
|
|
754
|
+
return JSON.stringify({ ok: true, content, size: stat.size });
|
|
755
|
+
}
|
|
756
|
+
async function isBinaryProbe(handle, size) {
|
|
757
|
+
const probeLen = Math.min(BINARY_PROBE_BYTES, size);
|
|
758
|
+
if (probeLen <= 0) return false;
|
|
759
|
+
const probe = Buffer.alloc(probeLen);
|
|
760
|
+
const { bytesRead } = await handle.read(probe, 0, probeLen, 0);
|
|
761
|
+
for (let i = 0; i < bytesRead; i += 1) {
|
|
762
|
+
if (probe[i] === 0) return true;
|
|
763
|
+
}
|
|
764
|
+
return false;
|
|
765
|
+
}
|
|
766
|
+
var DEFAULT_TIMEOUT_MS2 = 12e4;
|
|
767
|
+
var DEFAULT_MAX_STDOUT_BYTES2 = 10 * 1024 * 1024;
|
|
768
|
+
function createRunVitestTool(opts) {
|
|
769
|
+
const {
|
|
770
|
+
projectRoot,
|
|
771
|
+
timeoutMs = DEFAULT_TIMEOUT_MS2,
|
|
772
|
+
maxStdoutBytes = DEFAULT_MAX_STDOUT_BYTES2
|
|
773
|
+
} = opts;
|
|
774
|
+
return sdk.defineTool({
|
|
775
|
+
name: "run_vitest",
|
|
776
|
+
description: "Run the project's vitest suite, optionally scoped to a file or pattern via 'path'. Returns parsed { ok, summary } or { ok: false, error }. Vitest stdout warnings are stripped \u2014 the parser extracts the trailing JSON report.",
|
|
777
|
+
inputSchema: zod.z.object({
|
|
778
|
+
path: zod.z.string().optional().describe("Optional vitest pattern or file path (project-relative).")
|
|
779
|
+
}),
|
|
780
|
+
handler: async ({ path }) => {
|
|
781
|
+
const scopeError = validateVitestScope(path, projectRoot);
|
|
782
|
+
if (scopeError !== null) return scopeError;
|
|
783
|
+
const args = ["--no-install", "vitest", "run", "--reporter=json"];
|
|
784
|
+
if (path !== void 0 && path !== "") args.push(path);
|
|
785
|
+
const result = await runProcess(projectRoot, "npx", args, timeoutMs, maxStdoutBytes);
|
|
786
|
+
return formatVitestResult(result, timeoutMs);
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
function validateVitestScope(path, projectRoot) {
|
|
791
|
+
if (path !== void 0 && path !== "" && isForbiddenPath(path)) {
|
|
792
|
+
return JSON.stringify({ ok: false, error: "forbidden_path", path });
|
|
793
|
+
}
|
|
794
|
+
return checkPathScope(path, projectRoot);
|
|
795
|
+
}
|
|
796
|
+
function formatVitestResult(result, timeoutMs) {
|
|
797
|
+
if (result.kind === "timeout") {
|
|
798
|
+
return JSON.stringify({ ok: false, error: "timeout", timeoutMs });
|
|
799
|
+
}
|
|
800
|
+
if (result.kind === "spawn_error") {
|
|
801
|
+
return JSON.stringify({ ok: false, error: "no_vitest", detail: result.message });
|
|
802
|
+
}
|
|
803
|
+
const summary = extractTrailingJson(result.stdout);
|
|
804
|
+
if (summary === null) {
|
|
805
|
+
return JSON.stringify({
|
|
806
|
+
ok: false,
|
|
807
|
+
error: "unparseable_output",
|
|
808
|
+
stderrPreview: result.stderr.slice(0, 500)
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
return JSON.stringify({ ok: true, summary });
|
|
812
|
+
}
|
|
813
|
+
function extractTrailingJson(stdout) {
|
|
814
|
+
const lines = stdout.split(/\r?\n/);
|
|
815
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
816
|
+
const line = lines[i].trim();
|
|
817
|
+
if (line.length === 0) continue;
|
|
818
|
+
if (line[0] !== "{" && line[0] !== "[") continue;
|
|
819
|
+
try {
|
|
820
|
+
return JSON.parse(line);
|
|
821
|
+
} catch {
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
function appendCapped(chunks, chunk, current, cap) {
|
|
827
|
+
if (current >= cap) return current;
|
|
828
|
+
const remaining = cap - current;
|
|
829
|
+
if (chunk.length > remaining) {
|
|
830
|
+
chunks.push(chunk.subarray(0, remaining));
|
|
831
|
+
return cap;
|
|
832
|
+
}
|
|
833
|
+
chunks.push(chunk);
|
|
834
|
+
return current + chunk.length;
|
|
835
|
+
}
|
|
836
|
+
function runProcess(cwd, command, args, timeoutMs, maxStdoutBytes) {
|
|
837
|
+
return new Promise((resolve2) => {
|
|
838
|
+
const child = child_process.spawn(command, args, {
|
|
839
|
+
cwd,
|
|
840
|
+
detached: true,
|
|
841
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
842
|
+
});
|
|
843
|
+
const stdoutChunks = [];
|
|
844
|
+
const stderrChunks = [];
|
|
845
|
+
let stdoutBytes = 0;
|
|
846
|
+
const gate = armTimeoutKill(
|
|
847
|
+
child,
|
|
848
|
+
timeoutMs,
|
|
849
|
+
() => ({ kind: "timeout" }),
|
|
850
|
+
resolve2
|
|
851
|
+
);
|
|
852
|
+
child.stdout.on("data", (chunk) => {
|
|
853
|
+
if (gate.settled()) return;
|
|
854
|
+
stdoutBytes = appendCapped(stdoutChunks, chunk, stdoutBytes, maxStdoutBytes);
|
|
855
|
+
});
|
|
856
|
+
child.stderr.on("data", (chunk) => {
|
|
857
|
+
stderrChunks.push(chunk);
|
|
858
|
+
});
|
|
859
|
+
attachChildSettlers(
|
|
860
|
+
child,
|
|
861
|
+
gate,
|
|
862
|
+
(code) => ({
|
|
863
|
+
kind: "ok",
|
|
864
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
|
|
865
|
+
stderr: Buffer.concat(stderrChunks).toString("utf-8"),
|
|
866
|
+
exitCode: code ?? 0
|
|
867
|
+
}),
|
|
868
|
+
(err) => ({ kind: "spawn_error", message: err.message }),
|
|
869
|
+
resolve2
|
|
870
|
+
);
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
var DEFAULT_MAX_MATCHES = 100;
|
|
874
|
+
var DEFAULT_MAX_FILE_SIZE = 1024 * 1024;
|
|
875
|
+
var BINARY_PROBE_BYTES2 = 8 * 1024;
|
|
876
|
+
var PREVIEW_MAX = 200;
|
|
877
|
+
function createSearchTextTool(opts) {
|
|
878
|
+
const {
|
|
879
|
+
projectRoot,
|
|
880
|
+
maxMatches = DEFAULT_MAX_MATCHES,
|
|
881
|
+
maxFileSize = DEFAULT_MAX_FILE_SIZE
|
|
882
|
+
} = opts;
|
|
883
|
+
return sdk.defineTool({
|
|
884
|
+
name: "search_text",
|
|
885
|
+
description: `Search the project tree for a literal text query. Skips sensitive dirs (.env/.git/node_modules/.theo), binary files, and files over 1 MB. Returns up to ${String(maxMatches)} matches as { file, line, preview }. Use 'path' to scope the search to a subdirectory.`,
|
|
886
|
+
inputSchema: zod.z.object({
|
|
887
|
+
query: zod.z.string().min(1).describe("Literal text to search for. Case-sensitive."),
|
|
888
|
+
path: zod.z.string().optional().describe("Optional project-relative directory to scope the search.")
|
|
889
|
+
}),
|
|
890
|
+
handler: async ({ query, path }) => {
|
|
891
|
+
const scope = resolveSearchScope(path, projectRoot);
|
|
892
|
+
if ("error" in scope) return scope.error;
|
|
893
|
+
const state = {
|
|
894
|
+
matches: [],
|
|
895
|
+
totalMatches: 0,
|
|
896
|
+
truncated: false,
|
|
897
|
+
query,
|
|
898
|
+
maxMatches,
|
|
899
|
+
maxFileSize,
|
|
900
|
+
projectRoot
|
|
901
|
+
};
|
|
902
|
+
await walk(scope.scopeAbs, state);
|
|
903
|
+
return JSON.stringify({
|
|
904
|
+
ok: true,
|
|
905
|
+
matches: state.matches,
|
|
906
|
+
truncated: state.truncated,
|
|
907
|
+
totalMatches: state.totalMatches
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
function resolveSearchScope(path, projectRoot) {
|
|
913
|
+
const scopeRel = path === void 0 || path === "" || path === "." ? "." : path;
|
|
914
|
+
try {
|
|
915
|
+
const scopeAbs = scopeRel === "." ? projectRoot : safePathJoin(projectRoot, scopeRel);
|
|
916
|
+
assertNoSymlinkEscape(scopeAbs, projectRoot);
|
|
917
|
+
return { scopeAbs };
|
|
918
|
+
} catch (err) {
|
|
919
|
+
if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
|
|
920
|
+
return { error: JSON.stringify({ ok: false, error: "path_traversal", path }) };
|
|
921
|
+
}
|
|
922
|
+
throw err;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
async function handleEntry(entry, absDir, state) {
|
|
926
|
+
const entryAbs = path.join(absDir, entry.name);
|
|
927
|
+
const entryRel = path.relative(state.projectRoot, entryAbs);
|
|
928
|
+
if (isForbiddenPath(entryRel)) return;
|
|
929
|
+
if (entry.isDirectory()) {
|
|
930
|
+
await walk(entryAbs, state);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if (entry.isFile()) await scanFile(entryAbs, entryRel, state);
|
|
934
|
+
}
|
|
935
|
+
async function walk(absDir, state) {
|
|
936
|
+
if (state.truncated) return;
|
|
937
|
+
const entries = await readEntriesQuiet(absDir);
|
|
938
|
+
if (entries === null) return;
|
|
939
|
+
for (const entry of entries) {
|
|
940
|
+
if (state.truncated) return;
|
|
941
|
+
await handleEntry(entry, absDir, state);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
async function readEntriesQuiet(absDir) {
|
|
945
|
+
try {
|
|
946
|
+
return await promises.readdir(absDir, { withFileTypes: true });
|
|
947
|
+
} catch {
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
async function readBufferQuiet(absPath) {
|
|
952
|
+
try {
|
|
953
|
+
return await promises.readFile(absPath);
|
|
954
|
+
} catch {
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
function isBinaryBuffer(buffer) {
|
|
959
|
+
const probeEnd = Math.min(buffer.length, BINARY_PROBE_BYTES2);
|
|
960
|
+
for (let i = 0; i < probeEnd; i += 1) {
|
|
961
|
+
if (buffer[i] === 0) return true;
|
|
962
|
+
}
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
function recordMatch(state, file, line, lineText) {
|
|
966
|
+
state.totalMatches += 1;
|
|
967
|
+
if (state.matches.length < state.maxMatches) {
|
|
968
|
+
state.matches.push({
|
|
969
|
+
file,
|
|
970
|
+
line,
|
|
971
|
+
preview: lineText.length > PREVIEW_MAX ? `${lineText.slice(0, PREVIEW_MAX)}\u2026` : lineText
|
|
972
|
+
});
|
|
973
|
+
return true;
|
|
974
|
+
}
|
|
975
|
+
state.truncated = true;
|
|
976
|
+
return false;
|
|
977
|
+
}
|
|
978
|
+
async function scanFile(absPath, relPath, state) {
|
|
979
|
+
const buffer = await readBufferQuiet(absPath);
|
|
980
|
+
if (buffer === null || buffer.length > state.maxFileSize) return;
|
|
981
|
+
if (isBinaryBuffer(buffer)) return;
|
|
982
|
+
const lines = buffer.toString("utf-8").split("\n");
|
|
983
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
984
|
+
const line = lines[i];
|
|
985
|
+
if (!line.includes(state.query)) continue;
|
|
986
|
+
if (!recordMatch(state, relPath, i + 1, line)) return;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
var DEFAULT_TIMEOUT_MS3 = 3e4;
|
|
990
|
+
var MAX_TIMEOUT_MS = 3e5;
|
|
991
|
+
var MAX_OUTPUT_BYTES = 5 * 1024 * 1024;
|
|
992
|
+
function createShellTool(opts) {
|
|
993
|
+
const { projectRoot, defaultTimeoutMs = DEFAULT_TIMEOUT_MS3 } = opts;
|
|
994
|
+
return sdk.defineTool({
|
|
995
|
+
name: "shell_exec",
|
|
996
|
+
description: "Execute a shell command in the project directory. Returns stdout, stderr, and exit code. Default timeout 30s, max 5 minutes. Output capped at 5 MB. Returns { ok, stdout, stderr, exit_code } or { ok: false, error }.",
|
|
997
|
+
inputSchema: zod.z.object({
|
|
998
|
+
command: zod.z.string().min(1).describe("Shell command to execute."),
|
|
999
|
+
timeout_ms: zod.z.number().int().positive().optional().describe("Timeout in milliseconds (default 30000, max 300000).")
|
|
1000
|
+
}),
|
|
1001
|
+
handler: async ({ command, timeout_ms }) => {
|
|
1002
|
+
const timeoutMs = Math.min(timeout_ms ?? defaultTimeoutMs, MAX_TIMEOUT_MS);
|
|
1003
|
+
const result = await runShell(projectRoot, command, timeoutMs);
|
|
1004
|
+
return result;
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
function runShell(cwd, command, timeoutMs) {
|
|
1009
|
+
return new Promise((resolve2) => {
|
|
1010
|
+
const child = child_process.spawn("/bin/sh", ["-c", command], {
|
|
1011
|
+
cwd,
|
|
1012
|
+
detached: true,
|
|
1013
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1014
|
+
});
|
|
1015
|
+
const stdoutChunks = [];
|
|
1016
|
+
const stderrChunks = [];
|
|
1017
|
+
let stdoutBytes = 0;
|
|
1018
|
+
let stderrBytes = 0;
|
|
1019
|
+
const gate = armTimeoutKill(
|
|
1020
|
+
child,
|
|
1021
|
+
timeoutMs,
|
|
1022
|
+
() => ({ kind: "timeout" }),
|
|
1023
|
+
(result) => resolve2(formatResult(result, timeoutMs))
|
|
1024
|
+
);
|
|
1025
|
+
child.stdout.on("data", (chunk) => {
|
|
1026
|
+
if (gate.settled()) return;
|
|
1027
|
+
if (stdoutBytes >= MAX_OUTPUT_BYTES) {
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const remaining = MAX_OUTPUT_BYTES - stdoutBytes;
|
|
1031
|
+
if (chunk.length > remaining) {
|
|
1032
|
+
stdoutChunks.push(chunk.subarray(0, remaining));
|
|
1033
|
+
stdoutBytes = MAX_OUTPUT_BYTES;
|
|
1034
|
+
} else {
|
|
1035
|
+
stdoutChunks.push(chunk);
|
|
1036
|
+
stdoutBytes += chunk.length;
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
child.stderr.on("data", (chunk) => {
|
|
1040
|
+
if (gate.settled()) return;
|
|
1041
|
+
if (stderrBytes >= MAX_OUTPUT_BYTES) return;
|
|
1042
|
+
const remaining = MAX_OUTPUT_BYTES - stderrBytes;
|
|
1043
|
+
if (chunk.length > remaining) {
|
|
1044
|
+
stderrChunks.push(chunk.subarray(0, remaining));
|
|
1045
|
+
stderrBytes = MAX_OUTPUT_BYTES;
|
|
1046
|
+
} else {
|
|
1047
|
+
stderrChunks.push(chunk);
|
|
1048
|
+
stderrBytes += chunk.length;
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
attachChildSettlers(
|
|
1052
|
+
child,
|
|
1053
|
+
gate,
|
|
1054
|
+
(code) => ({
|
|
1055
|
+
kind: "ok",
|
|
1056
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
|
|
1057
|
+
stderr: Buffer.concat(stderrChunks).toString("utf-8"),
|
|
1058
|
+
exitCode: code
|
|
1059
|
+
}),
|
|
1060
|
+
(err) => ({ kind: "error", message: err.message }),
|
|
1061
|
+
(result) => resolve2(formatResult(result, timeoutMs))
|
|
1062
|
+
);
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
function formatResult(result, timeoutMs) {
|
|
1066
|
+
if (result.kind === "timeout") {
|
|
1067
|
+
return JSON.stringify({ ok: false, error: "timeout", timeout_ms: timeoutMs });
|
|
1068
|
+
}
|
|
1069
|
+
if (result.kind === "error") {
|
|
1070
|
+
return JSON.stringify({ ok: false, error: "exec_failed", message: result.message });
|
|
1071
|
+
}
|
|
1072
|
+
return JSON.stringify({
|
|
1073
|
+
ok: true,
|
|
1074
|
+
stdout: result.stdout,
|
|
1075
|
+
stderr: result.stderr,
|
|
1076
|
+
exit_code: result.exitCode
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// src/todolist.ts
|
|
1081
|
+
function ok(data) {
|
|
1082
|
+
return JSON.stringify({ ok: true, ...data });
|
|
1083
|
+
}
|
|
1084
|
+
function fail(data) {
|
|
1085
|
+
return JSON.stringify({ ok: false, ...data });
|
|
1086
|
+
}
|
|
1087
|
+
function requireId(input) {
|
|
1088
|
+
if (!("id" in input) || !input.id) return null;
|
|
1089
|
+
return input.id;
|
|
1090
|
+
}
|
|
1091
|
+
function createTodolistTool() {
|
|
1092
|
+
const items = [];
|
|
1093
|
+
let nextId = 1;
|
|
1094
|
+
function genId() {
|
|
1095
|
+
return `todo-${nextId++}`;
|
|
1096
|
+
}
|
|
1097
|
+
function findById(id) {
|
|
1098
|
+
return items.find((i) => i.id === id);
|
|
1099
|
+
}
|
|
1100
|
+
function formatList() {
|
|
1101
|
+
if (items.length === 0) return "No tasks. Use action 'add' to create one.";
|
|
1102
|
+
const lines = items.map((item) => {
|
|
1103
|
+
const icon = item.status === "done" ? "[x]" : item.status === "in_progress" ? "[>]" : "[ ]";
|
|
1104
|
+
return `${icon} ${item.id}: ${item.title}`;
|
|
1105
|
+
});
|
|
1106
|
+
const pending = items.filter((i) => i.status === "pending").length;
|
|
1107
|
+
const inProg = items.filter((i) => i.status === "in_progress").length;
|
|
1108
|
+
const done = items.filter((i) => i.status === "done").length;
|
|
1109
|
+
lines.push(`
|
|
1110
|
+
${done}/${items.length} done | ${inProg} in progress | ${pending} pending`);
|
|
1111
|
+
return lines.join("\n");
|
|
1112
|
+
}
|
|
1113
|
+
function handleAdd(input) {
|
|
1114
|
+
if (!("title" in input) || !input.title) return fail({ error: "missing_title" });
|
|
1115
|
+
const item = {
|
|
1116
|
+
id: genId(),
|
|
1117
|
+
title: input.title,
|
|
1118
|
+
status: "pending",
|
|
1119
|
+
createdAt: Date.now()
|
|
1120
|
+
};
|
|
1121
|
+
items.push(item);
|
|
1122
|
+
return ok({ id: item.id, message: `Added: ${item.title}`, items_summary: formatList() });
|
|
1123
|
+
}
|
|
1124
|
+
function handleSetStatus(input, status) {
|
|
1125
|
+
const id = requireId(input);
|
|
1126
|
+
if (!id) return fail({ error: "missing_id" });
|
|
1127
|
+
const item = findById(id);
|
|
1128
|
+
if (!item) return fail({ error: "not_found", id });
|
|
1129
|
+
item.status = status;
|
|
1130
|
+
if (status === "done") item.completedAt = Date.now();
|
|
1131
|
+
const verb = status === "done" ? "Completed" : "Started";
|
|
1132
|
+
return ok({ message: `${verb}: ${item.title}`, items_summary: formatList() });
|
|
1133
|
+
}
|
|
1134
|
+
function handleRemove(input) {
|
|
1135
|
+
const id = requireId(input);
|
|
1136
|
+
if (!id) return fail({ error: "missing_id" });
|
|
1137
|
+
const idx = items.findIndex((i) => i.id === id);
|
|
1138
|
+
if (idx === -1) return fail({ error: "not_found", id });
|
|
1139
|
+
const removed = items.splice(idx, 1)[0];
|
|
1140
|
+
return ok({ message: `Removed: ${removed.title}`, items_summary: formatList() });
|
|
1141
|
+
}
|
|
1142
|
+
function handleClearCompleted() {
|
|
1143
|
+
const before = items.length;
|
|
1144
|
+
const kept = items.filter((i) => i.status !== "done");
|
|
1145
|
+
items.length = 0;
|
|
1146
|
+
items.push(...kept);
|
|
1147
|
+
return ok({
|
|
1148
|
+
message: `Cleared ${before - items.length} completed items`,
|
|
1149
|
+
items_summary: formatList()
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
const actions = {
|
|
1153
|
+
add: handleAdd,
|
|
1154
|
+
in_progress: (input) => handleSetStatus(input, "in_progress"),
|
|
1155
|
+
complete: (input) => handleSetStatus(input, "done"),
|
|
1156
|
+
remove: handleRemove,
|
|
1157
|
+
list: () => ok({ items_summary: formatList() }),
|
|
1158
|
+
clear_completed: handleClearCompleted
|
|
1159
|
+
};
|
|
1160
|
+
return {
|
|
1161
|
+
name: "todolist",
|
|
1162
|
+
description: "Track multi-step task progress. Actions: 'add' (create task with title), 'complete' (mark done by id), 'in_progress' (mark started by id), 'remove' (delete by id), 'list' (show all), 'clear_completed' (remove done items). Returns { ok, items_summary }.",
|
|
1163
|
+
inputSchema: {
|
|
1164
|
+
type: "object",
|
|
1165
|
+
properties: {
|
|
1166
|
+
action: {
|
|
1167
|
+
type: "string",
|
|
1168
|
+
enum: ["add", "complete", "in_progress", "remove", "list", "clear_completed"],
|
|
1169
|
+
description: "The action to perform."
|
|
1170
|
+
},
|
|
1171
|
+
title: {
|
|
1172
|
+
type: "string",
|
|
1173
|
+
description: "Title for a new todo item (required for 'add')."
|
|
1174
|
+
},
|
|
1175
|
+
id: {
|
|
1176
|
+
type: "string",
|
|
1177
|
+
description: "ID of the todo item (required for 'complete', 'in_progress', 'remove')."
|
|
1178
|
+
}
|
|
1179
|
+
},
|
|
1180
|
+
required: ["action"]
|
|
1181
|
+
},
|
|
1182
|
+
handler: (input) => {
|
|
1183
|
+
const action = actions[input.action];
|
|
1184
|
+
if (!action) return fail({ error: "invalid_action" });
|
|
1185
|
+
return action(input);
|
|
1186
|
+
},
|
|
1187
|
+
getItems: () => [...items]
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
function truncateOutput(output, opts) {
|
|
1191
|
+
const maxBytes = opts?.maxBytes ?? 3e4;
|
|
1192
|
+
const outputDir = opts?.outputDir ?? ".theocode/tool-output";
|
|
1193
|
+
const byteLength = Buffer.byteLength(output, "utf-8");
|
|
1194
|
+
if (byteLength <= maxBytes) {
|
|
1195
|
+
return { content: output, truncated: false };
|
|
1196
|
+
}
|
|
1197
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
1198
|
+
const filename = `overflow-${Date.now()}.txt`;
|
|
1199
|
+
const overflowPath = path.join(outputDir, filename);
|
|
1200
|
+
fs.writeFileSync(overflowPath, output, "utf-8");
|
|
1201
|
+
const truncated = Buffer.from(output, "utf-8").subarray(0, maxBytes).toString("utf-8");
|
|
1202
|
+
const trailer = `
|
|
1203
|
+
|
|
1204
|
+
[Output truncated. Full output: ${overflowPath}]`;
|
|
1205
|
+
return {
|
|
1206
|
+
content: truncated + trailer,
|
|
1207
|
+
truncated: true,
|
|
1208
|
+
overflowPath
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
var DEFAULT_TIMEOUT_MS4 = 3e4;
|
|
1212
|
+
var MAX_BODY_BYTES = 1 * 1024 * 1024;
|
|
1213
|
+
function createWebFetchTool(opts) {
|
|
1214
|
+
const defaultTimeoutMs = opts?.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS4;
|
|
1215
|
+
return sdk.defineTool({
|
|
1216
|
+
name: "web_fetch",
|
|
1217
|
+
description: "Fetch content from a URL via HTTP/HTTPS. Rejects non-http(s) URLs. Response body capped at 1 MB. Returns { ok, content, status_code } or { ok: false, error }.",
|
|
1218
|
+
inputSchema: zod.z.object({
|
|
1219
|
+
url: zod.z.string().min(1).describe("URL to fetch (http or https only)."),
|
|
1220
|
+
timeout_ms: zod.z.number().int().positive().optional().describe("Timeout in milliseconds (default 30000).")
|
|
1221
|
+
}),
|
|
1222
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: fetch with guards
|
|
1223
|
+
handler: async ({ url, timeout_ms }) => {
|
|
1224
|
+
let parsed;
|
|
1225
|
+
try {
|
|
1226
|
+
parsed = new URL(url);
|
|
1227
|
+
} catch {
|
|
1228
|
+
return JSON.stringify({ ok: false, error: "invalid_url", url });
|
|
1229
|
+
}
|
|
1230
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1231
|
+
return JSON.stringify({
|
|
1232
|
+
ok: false,
|
|
1233
|
+
error: "invalid_url",
|
|
1234
|
+
url,
|
|
1235
|
+
detail: "only http and https protocols allowed"
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
const timeoutMs = timeout_ms ?? defaultTimeoutMs;
|
|
1239
|
+
const controller = new AbortController();
|
|
1240
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1241
|
+
try {
|
|
1242
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
1243
|
+
clearTimeout(timer);
|
|
1244
|
+
const contentLength = response.headers.get("content-length");
|
|
1245
|
+
if (contentLength && Number(contentLength) > MAX_BODY_BYTES) {
|
|
1246
|
+
return JSON.stringify({
|
|
1247
|
+
ok: false,
|
|
1248
|
+
error: "too_large",
|
|
1249
|
+
url,
|
|
1250
|
+
size: Number(contentLength),
|
|
1251
|
+
limit: MAX_BODY_BYTES
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
const buffer = await response.arrayBuffer();
|
|
1255
|
+
if (buffer.byteLength > MAX_BODY_BYTES) {
|
|
1256
|
+
return JSON.stringify({
|
|
1257
|
+
ok: false,
|
|
1258
|
+
error: "too_large",
|
|
1259
|
+
url,
|
|
1260
|
+
size: buffer.byteLength,
|
|
1261
|
+
limit: MAX_BODY_BYTES
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
const content = new TextDecoder("utf-8").decode(buffer);
|
|
1265
|
+
const contentType = response.headers.get("content-type") ?? void 0;
|
|
1266
|
+
return JSON.stringify({
|
|
1267
|
+
ok: true,
|
|
1268
|
+
content,
|
|
1269
|
+
status_code: response.status,
|
|
1270
|
+
content_type: contentType
|
|
1271
|
+
});
|
|
1272
|
+
} catch (err) {
|
|
1273
|
+
clearTimeout(timer);
|
|
1274
|
+
const e = err;
|
|
1275
|
+
if (e.name === "AbortError") {
|
|
1276
|
+
return JSON.stringify({ ok: false, error: "timeout", url, timeout_ms: timeoutMs });
|
|
1277
|
+
}
|
|
1278
|
+
return JSON.stringify({
|
|
1279
|
+
ok: false,
|
|
1280
|
+
error: "fetch_failed",
|
|
1281
|
+
url,
|
|
1282
|
+
message: e.message ?? "unknown"
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
function createWebSearchTool(opts) {
|
|
1289
|
+
const { search, defaultMaxResults = 5 } = opts;
|
|
1290
|
+
return sdk.defineTool({
|
|
1291
|
+
name: "web_search",
|
|
1292
|
+
description: "Search the web for a query. Returns a list of results with title, URL, and snippet. The search provider is injected by the consumer. Returns { ok, results } or { ok: false, error }.",
|
|
1293
|
+
inputSchema: zod.z.object({
|
|
1294
|
+
query: zod.z.string().min(1).describe("Search query."),
|
|
1295
|
+
max_results: zod.z.number().int().positive().max(20).optional().describe("Maximum results to return (default 5, max 20).")
|
|
1296
|
+
}),
|
|
1297
|
+
handler: async ({ query, max_results }) => {
|
|
1298
|
+
const maxResults = max_results ?? defaultMaxResults;
|
|
1299
|
+
try {
|
|
1300
|
+
const results = await search(query, maxResults);
|
|
1301
|
+
return JSON.stringify({
|
|
1302
|
+
ok: true,
|
|
1303
|
+
results: results.slice(0, maxResults),
|
|
1304
|
+
count: Math.min(results.length, maxResults)
|
|
1305
|
+
});
|
|
1306
|
+
} catch (err) {
|
|
1307
|
+
const e = err;
|
|
1308
|
+
return JSON.stringify({
|
|
1309
|
+
ok: false,
|
|
1310
|
+
error: "search_failed",
|
|
1311
|
+
message: e.message ?? "unknown"
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
var BINARY_PROBE_BYTES3 = 8 * 1024;
|
|
1318
|
+
function createWriteFileTool(opts) {
|
|
1319
|
+
const { projectRoot } = opts;
|
|
1320
|
+
return sdk.defineTool({
|
|
1321
|
+
name: "write_file",
|
|
1322
|
+
description: "Write UTF-8 content to a project-relative file. Creates parent directories recursively. Refuses paths that escape the project root, sensitive files (.env, .git/, node_modules/, .theo/, lock files), and binary-file overwrites. Returns { ok, path, bytes } or { ok: false, error }.",
|
|
1323
|
+
inputSchema: zod.z.object({
|
|
1324
|
+
path: zod.z.string().min(1).describe("Project-relative file path."),
|
|
1325
|
+
content: zod.z.string().describe("UTF-8 content to write.")
|
|
1326
|
+
}),
|
|
1327
|
+
handler: async ({ path: path$1, content }) => {
|
|
1328
|
+
if (isForbiddenPath(path$1)) {
|
|
1329
|
+
return JSON.stringify({ ok: false, error: "forbidden_path", path: path$1 });
|
|
1330
|
+
}
|
|
1331
|
+
let absolutePath;
|
|
1332
|
+
try {
|
|
1333
|
+
absolutePath = safePathJoin(projectRoot, path$1);
|
|
1334
|
+
assertNoSymlinkEscape(absolutePath, projectRoot);
|
|
1335
|
+
} catch (err) {
|
|
1336
|
+
if (err instanceof PathTraversalError || err instanceof ForbiddenPathError) {
|
|
1337
|
+
return JSON.stringify({ ok: false, error: "path_traversal", path: path$1 });
|
|
1338
|
+
}
|
|
1339
|
+
throw err;
|
|
1340
|
+
}
|
|
1341
|
+
if (await isBinaryFile(absolutePath)) {
|
|
1342
|
+
return JSON.stringify({ ok: false, error: "binary_file", path: path$1 });
|
|
1343
|
+
}
|
|
1344
|
+
await promises.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
1345
|
+
await promises.writeFile(absolutePath, content, "utf-8");
|
|
1346
|
+
const bytes = Buffer.byteLength(content, "utf-8");
|
|
1347
|
+
return JSON.stringify({ ok: true, path: path$1, bytes });
|
|
1348
|
+
}
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
async function isBinaryFile(absolutePath) {
|
|
1352
|
+
let handle;
|
|
1353
|
+
try {
|
|
1354
|
+
handle = await promises.open(absolutePath, "r");
|
|
1355
|
+
} catch {
|
|
1356
|
+
return false;
|
|
1357
|
+
}
|
|
1358
|
+
try {
|
|
1359
|
+
const stat = await handle.stat();
|
|
1360
|
+
const probeLen = Math.min(BINARY_PROBE_BYTES3, Number(stat.size));
|
|
1361
|
+
if (probeLen <= 0) return false;
|
|
1362
|
+
const probe = Buffer.alloc(probeLen);
|
|
1363
|
+
const { bytesRead } = await handle.read(probe, 0, probeLen, 0);
|
|
1364
|
+
for (let i = 0; i < bytesRead; i += 1) {
|
|
1365
|
+
if (probe[i] === 0) return true;
|
|
1366
|
+
}
|
|
1367
|
+
return false;
|
|
1368
|
+
} finally {
|
|
1369
|
+
await handle.close();
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
exports.createApplyPatchTool = createApplyPatchTool;
|
|
1374
|
+
exports.createEditFileTool = createEditFileTool;
|
|
1375
|
+
exports.createGitDiffTool = createGitDiffTool;
|
|
1376
|
+
exports.createGlobTool = createGlobTool;
|
|
1377
|
+
exports.createListDirTool = createListDirTool;
|
|
1378
|
+
exports.createPlanModeTool = createPlanModeTool;
|
|
1379
|
+
exports.createQuestionTool = createQuestionTool;
|
|
1380
|
+
exports.createReadFileTool = createReadFileTool;
|
|
1381
|
+
exports.createRunVitestTool = createRunVitestTool;
|
|
1382
|
+
exports.createSearchTextTool = createSearchTextTool;
|
|
1383
|
+
exports.createShellTool = createShellTool;
|
|
1384
|
+
exports.createTodolistTool = createTodolistTool;
|
|
1385
|
+
exports.createWebFetchTool = createWebFetchTool;
|
|
1386
|
+
exports.createWebSearchTool = createWebSearchTool;
|
|
1387
|
+
exports.createWriteFileTool = createWriteFileTool;
|
|
1388
|
+
exports.formatCode = formatCode;
|
|
1389
|
+
exports.formatDiff = formatDiff;
|
|
1390
|
+
exports.formatError = formatError;
|
|
1391
|
+
exports.formatFileList = formatFileList;
|
|
1392
|
+
exports.truncateOutput = truncateOutput;
|
|
1393
|
+
//# sourceMappingURL=index.cjs.map
|
|
1394
|
+
//# sourceMappingURL=index.cjs.map
|