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