@giovannijecha/jecode 0.1.5-rc.2

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.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +264 -0
  3. package/bin/jecode.js +11 -0
  4. package/dist/atomic.js +78 -0
  5. package/dist/batch-view.js +17 -0
  6. package/dist/batch.js +100 -0
  7. package/dist/cli-info.js +40 -0
  8. package/dist/commands.js +171 -0
  9. package/dist/config.js +97 -0
  10. package/dist/controller.js +90 -0
  11. package/dist/credential-commands.js +134 -0
  12. package/dist/credential-safety.js +86 -0
  13. package/dist/credentials.js +124 -0
  14. package/dist/main.js +7 -0
  15. package/dist/ollama-settings-command.js +75 -0
  16. package/dist/prompt.js +29 -0
  17. package/dist/provider-commands.js +236 -0
  18. package/dist/provider-errors.js +10 -0
  19. package/dist/providers/anthropic-stream.js +111 -0
  20. package/dist/providers/anthropic-wire.js +79 -0
  21. package/dist/providers/anthropic.js +77 -0
  22. package/dist/providers/catalog.js +37 -0
  23. package/dist/providers/http.js +219 -0
  24. package/dist/providers/index.js +18 -0
  25. package/dist/providers/ollama-endpoint.js +40 -0
  26. package/dist/providers/ollama-stream.js +60 -0
  27. package/dist/providers/ollama-wire.js +95 -0
  28. package/dist/providers/ollama.js +87 -0
  29. package/dist/providers/openai-stream.js +48 -0
  30. package/dist/providers/openai-wire.js +112 -0
  31. package/dist/providers/openai.js +70 -0
  32. package/dist/providers/sse.js +81 -0
  33. package/dist/providers/stream-limits.js +11 -0
  34. package/dist/session.js +3 -0
  35. package/dist/settings-command.js +202 -0
  36. package/dist/settings.js +98 -0
  37. package/dist/start.js +47 -0
  38. package/dist/tools/args.js +38 -0
  39. package/dist/tools/fs.js +277 -0
  40. package/dist/tools/index.js +39 -0
  41. package/dist/tools/paths.js +122 -0
  42. package/dist/tools/search.js +213 -0
  43. package/dist/tools/shell.js +139 -0
  44. package/dist/tools/text-boundary.js +102 -0
  45. package/dist/tools/types.js +1 -0
  46. package/dist/transcript-export.js +11 -0
  47. package/dist/transcript.js +49 -0
  48. package/dist/tui/activity.js +11 -0
  49. package/dist/tui/app-input.js +155 -0
  50. package/dist/tui/app-state.js +17 -0
  51. package/dist/tui/app-workflows.js +105 -0
  52. package/dist/tui/app.js +215 -0
  53. package/dist/tui/approve.js +67 -0
  54. package/dist/tui/blocks.js +23 -0
  55. package/dist/tui/complete.js +54 -0
  56. package/dist/tui/components/command-menu.js +17 -0
  57. package/dist/tui/components/composer.js +47 -0
  58. package/dist/tui/components/dock.js +10 -0
  59. package/dist/tui/components/footer.js +21 -0
  60. package/dist/tui/components/menu.js +38 -0
  61. package/dist/tui/components/messages.js +43 -0
  62. package/dist/tui/components/misc.js +22 -0
  63. package/dist/tui/components/status.js +45 -0
  64. package/dist/tui/components/tool.js +85 -0
  65. package/dist/tui/components/types.js +1 -0
  66. package/dist/tui/editor.js +105 -0
  67. package/dist/tui/feedback.js +74 -0
  68. package/dist/tui/field.js +60 -0
  69. package/dist/tui/frame.js +33 -0
  70. package/dist/tui/input.js +52 -0
  71. package/dist/tui/keys.js +177 -0
  72. package/dist/tui/modal.js +24 -0
  73. package/dist/tui/overlay.js +93 -0
  74. package/dist/tui/picker.js +99 -0
  75. package/dist/tui/screen.js +94 -0
  76. package/dist/tui/scroll.js +7 -0
  77. package/dist/tui/session-view.js +49 -0
  78. package/dist/tui/transcript-view.js +134 -0
  79. package/dist/tui/turn.js +255 -0
  80. package/dist/tui/view.js +88 -0
  81. package/dist/tui/workspace.js +59 -0
  82. package/dist/types.js +9 -0
  83. package/dist/ui/diff.js +109 -0
  84. package/dist/ui/highlight.js +158 -0
  85. package/dist/ui/inline.js +39 -0
  86. package/dist/ui/markdown.js +147 -0
  87. package/dist/ui/render.js +232 -0
  88. package/dist/ui/table.js +127 -0
  89. package/dist/ui/terminal-text.js +42 -0
  90. package/dist/ui/theme.js +25 -0
  91. package/dist/ui/width.js +196 -0
  92. package/dist/usage.js +30 -0
  93. package/dist/user-data.js +29 -0
  94. package/package.json +56 -0
@@ -0,0 +1,38 @@
1
+ // Hand-written argument checks. A schema validator would be a dependency; a
2
+ // tool takes three arguments and this is the whole job.
3
+ //
4
+ // Every throw here becomes an is_error tool result the model can read and
5
+ // correct on the next step, so the messages are written for that reader.
6
+ export function requireString(args, name) {
7
+ const value = args[name];
8
+ if (typeof value !== "string" || value === "") {
9
+ throw new Error(`"${name}" is required and must be a non-empty string`);
10
+ }
11
+ return value;
12
+ }
13
+ export function optionalString(args, name) {
14
+ const value = args[name];
15
+ if (value === undefined || value === null)
16
+ return undefined;
17
+ if (typeof value !== "string")
18
+ throw new Error(`"${name}" must be a string`);
19
+ return value;
20
+ }
21
+ export function optionalInt(args, name) {
22
+ const value = args[name];
23
+ if (value === undefined || value === null)
24
+ return undefined;
25
+ const n = typeof value === "string" ? Number(value) : value;
26
+ if (typeof n !== "number" || !Number.isInteger(n)) {
27
+ throw new Error(`"${name}" must be an integer`);
28
+ }
29
+ return n;
30
+ }
31
+ export function optionalBool(args, name) {
32
+ const value = args[name];
33
+ if (value === undefined || value === null)
34
+ return undefined;
35
+ if (typeof value !== "boolean")
36
+ throw new Error(`"${name}" must be a boolean`);
37
+ return value;
38
+ }
@@ -0,0 +1,277 @@
1
+ // Filesystem tools: read, list, write, edit.
2
+ import { createReadStream } from "node:fs";
3
+ import * as fs from "node:fs/promises";
4
+ import * as path from "node:path";
5
+ import { optionalBool, optionalInt, requireString } from "./args.js";
6
+ import { assertDirectWritableInRoot, displayPath, resolveDirectWritableInRoot, resolveExistingInRoot, } from "./paths.js";
7
+ import { assertEditableText, assertReplacementFits, MAX_EDITABLE_CHARS, MAX_EDITABLE_LINES, readEditableText, } from "./text-boundary.js";
8
+ import { atomicWrite } from "../atomic.js";
9
+ const MAX_READ_CHARS = 60_000;
10
+ const MAX_LIST_CHARS = 60_000;
11
+ const MAX_LIST_ENTRIES = 2_000;
12
+ export const readFile = {
13
+ name: "read_file",
14
+ description: "Read a UTF-8 text file inside the workspace. Optionally start at a line " +
15
+ "(1-based) and cap how many lines come back. Large files are truncated.",
16
+ dangerous: false,
17
+ input: {
18
+ type: "object",
19
+ properties: {
20
+ path: { type: "string", description: "Path relative to the workspace root." },
21
+ offset: { type: "integer", description: "First line to return, 1-based." },
22
+ limit: { type: "integer", description: "How many lines to return." },
23
+ },
24
+ required: ["path"],
25
+ },
26
+ async run(args, ctx) {
27
+ const root = await resolveExistingInRoot(ctx.root, ".");
28
+ const target = await resolveExistingInRoot(root, requireString(args, "path"));
29
+ const offset = optionalInt(args, "offset");
30
+ const limit = optionalInt(args, "limit");
31
+ const { text, truncated } = await readRange(target, offset, limit);
32
+ if (truncated) {
33
+ return {
34
+ output: `${text}\n\n[truncated at ${MAX_READ_CHARS} characters — read a narrower range]`,
35
+ summary: `truncated at ${MAX_READ_CHARS} characters`,
36
+ };
37
+ }
38
+ if (text === "")
39
+ return { output: "[file is empty]", summary: "empty" };
40
+ return { output: text, summary: `${count(text, "line")}` };
41
+ },
42
+ };
43
+ export const listDir = {
44
+ name: "list_dir",
45
+ description: "List the entries of a directory inside the workspace. Directories end with a slash.",
46
+ dangerous: false,
47
+ input: {
48
+ type: "object",
49
+ properties: {
50
+ path: { type: "string", description: "Directory relative to the workspace root. Defaults to the root." },
51
+ },
52
+ required: [],
53
+ },
54
+ async run(args, ctx) {
55
+ const root = await resolveExistingInRoot(ctx.root, ".");
56
+ const target = await resolveExistingInRoot(root, args.path === undefined ? "." : requireString(args, "path"));
57
+ const entries = [];
58
+ let chars = 0;
59
+ let truncated = false;
60
+ const directory = await fs.opendir(target);
61
+ for await (const entry of directory) {
62
+ const label = entry.isDirectory() ? `${entry.name}/` : entry.name;
63
+ const separator = entries.length === 0 ? 0 : 1;
64
+ if (entries.length >= MAX_LIST_ENTRIES ||
65
+ chars + separator + label.length > MAX_LIST_CHARS) {
66
+ truncated = true;
67
+ break;
68
+ }
69
+ entries.push(label);
70
+ chars += separator + label.length;
71
+ }
72
+ if (entries.length === 0)
73
+ return { output: "[empty directory]", summary: "empty" };
74
+ const listing = entries
75
+ .sort((a, b) => a.localeCompare(b))
76
+ .join("\n");
77
+ if (truncated) {
78
+ return {
79
+ output: `${listing}\n\n[truncated after ${entries.length} entries]`,
80
+ summary: `${entries.length}+ entries`,
81
+ };
82
+ }
83
+ return { output: listing, summary: plural(entries.length, "entry", "entries") };
84
+ },
85
+ };
86
+ export const writeFile = {
87
+ name: "write_file",
88
+ description: "Create a file, or replace its entire contents. Parent directories are " +
89
+ "created as needed. Whole-file changes are limited to " +
90
+ `${MAX_EDITABLE_CHARS} characters and ${MAX_EDITABLE_LINES} lines. ` +
91
+ "To change part of an existing file, prefer edit_file.",
92
+ dangerous: true,
93
+ input: {
94
+ type: "object",
95
+ properties: {
96
+ path: { type: "string", description: "Path relative to the workspace root." },
97
+ content: { type: "string", description: "The full new contents of the file." },
98
+ },
99
+ required: ["path", "content"],
100
+ },
101
+ async preview(args, ctx) {
102
+ const root = await resolveExistingInRoot(ctx.root, ".");
103
+ const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
104
+ const content = requireString(args, "content");
105
+ assertEditableText(content);
106
+ // A write against a file that is already there is a replacement, and the
107
+ // user is owed the difference rather than a wall of green.
108
+ return { before: await current(target), after: content };
109
+ },
110
+ async run(args, ctx) {
111
+ const root = await resolveExistingInRoot(ctx.root, ".");
112
+ const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
113
+ const content = requireString(args, "content");
114
+ assertEditableText(content);
115
+ await fs.mkdir(path.dirname(target), { recursive: true });
116
+ const validate = () => assertDirectWritableInRoot(root, target);
117
+ await validate();
118
+ await unchangedSinceApproval(target, ctx.preview?.before);
119
+ await atomicWrite(target, content, { validate });
120
+ return {
121
+ output: `wrote ${displayPath(root, target)} (${content.length} characters)`,
122
+ summary: count(content, "line"),
123
+ };
124
+ },
125
+ };
126
+ export const editFile = {
127
+ name: "edit_file",
128
+ description: "Replace an exact string in a file. The old text must appear exactly once " +
129
+ "unless replace_all is true — include enough surrounding context to make it unique. " +
130
+ `Whole-file changes are limited to ${MAX_EDITABLE_CHARS} characters and ` +
131
+ `${MAX_EDITABLE_LINES} lines.`,
132
+ dangerous: true,
133
+ input: {
134
+ type: "object",
135
+ properties: {
136
+ path: { type: "string", description: "Path relative to the workspace root." },
137
+ old_text: { type: "string", description: "Exact text to replace, including indentation." },
138
+ new_text: { type: "string", description: "Text to put in its place." },
139
+ replace_all: { type: "boolean", description: "Replace every occurrence instead of requiring one." },
140
+ },
141
+ required: ["path", "old_text", "new_text"],
142
+ },
143
+ async preview(args, ctx) {
144
+ const root = await resolveExistingInRoot(ctx.root, ".");
145
+ const target = await resolveDirectWritableInRoot(root, requireString(args, "path"), true);
146
+ const before = await current(target);
147
+ // An edit that will not apply gets no preview: the run is about to say so
148
+ // properly, and a diff of a match that does not exist would be a lie.
149
+ try {
150
+ return { before, after: applied(before, args).after };
151
+ }
152
+ catch {
153
+ return undefined;
154
+ }
155
+ },
156
+ async run(args, ctx) {
157
+ const root = await resolveExistingInRoot(ctx.root, ".");
158
+ const target = await resolveDirectWritableInRoot(root, requireString(args, "path"), true);
159
+ const before = await readEditableText(target);
160
+ if (ctx.preview !== undefined && before !== ctx.preview.before) {
161
+ throw new Error("file changed after the preview — inspect it and retry the edit");
162
+ }
163
+ const { after, made } = applied(before, args);
164
+ const validate = () => assertDirectWritableInRoot(root, target, true);
165
+ await atomicWrite(target, after, { validate });
166
+ return {
167
+ output: `edited ${displayPath(root, target)} (${made} replacement${made === 1 ? "" : "s"})`,
168
+ summary: plural(made, "replacement", "replacements"),
169
+ };
170
+ },
171
+ };
172
+ async function readRange(target, offset, limit) {
173
+ const firstLine = Math.max(1, offset ?? 1);
174
+ const lineCount = limit === undefined ? undefined : Math.max(0, limit);
175
+ const endLine = lineCount === undefined ? Number.POSITIVE_INFINITY : firstLine + lineCount;
176
+ if (lineCount === 0)
177
+ return { text: "", truncated: false };
178
+ const source = createReadStream(target);
179
+ const decoder = new TextDecoder();
180
+ let text = "";
181
+ let line = 1;
182
+ let stopped = false;
183
+ let truncated = false;
184
+ const selected = (at) => at >= firstLine && at < endLine;
185
+ const append = (fragment) => {
186
+ if (fragment === "")
187
+ return;
188
+ const room = MAX_READ_CHARS - text.length;
189
+ if (fragment.length > room) {
190
+ if (room > 0)
191
+ text += fragment.slice(0, room);
192
+ truncated = true;
193
+ stopped = true;
194
+ return;
195
+ }
196
+ text += fragment;
197
+ };
198
+ const consume = (chunk) => {
199
+ let start = 0;
200
+ while (!stopped && start < chunk.length) {
201
+ const newline = chunk.indexOf("\n", start);
202
+ const end = newline === -1 ? chunk.length : newline;
203
+ if (selected(line))
204
+ append(chunk.slice(start, end));
205
+ if (stopped || newline === -1)
206
+ return;
207
+ const nextLine = line + 1;
208
+ if (selected(line) && selected(nextLine))
209
+ append("\n");
210
+ line = nextLine;
211
+ if (line >= endLine) {
212
+ stopped = true;
213
+ return;
214
+ }
215
+ start = newline + 1;
216
+ }
217
+ };
218
+ try {
219
+ for await (const chunk of source) {
220
+ consume(decoder.decode(chunk, { stream: true }));
221
+ if (stopped)
222
+ break;
223
+ }
224
+ if (!stopped)
225
+ consume(decoder.decode());
226
+ }
227
+ finally {
228
+ source.destroy();
229
+ }
230
+ return { text, truncated };
231
+ }
232
+ /**
233
+ * The edit worked out against a given text — the one place its rules live.
234
+ *
235
+ * Shared by `run` and `preview` on purpose: a preview computed by a second
236
+ * implementation of the same rules is a preview that can disagree with what
237
+ * the call then does, which is worse than showing nothing at all.
238
+ */
239
+ function applied(before, args) {
240
+ const oldText = requireString(args, "old_text");
241
+ const newText = args.new_text === "" ? "" : requireString(args, "new_text");
242
+ const replaceAll = optionalBool(args, "replace_all") ?? false;
243
+ const occurrences = countOccurrences(before, oldText);
244
+ if (occurrences === 0)
245
+ throw new Error("old_text was not found in the file");
246
+ if (occurrences > 1 && !replaceAll) {
247
+ throw new Error(`old_text appears ${occurrences} times — add surrounding context, or pass replace_all`);
248
+ }
249
+ const made = replaceAll ? occurrences : 1;
250
+ assertReplacementFits(before, oldText, newText, made);
251
+ const after = replaceAll ? before.replaceAll(oldText, newText) : before.replace(oldText, newText);
252
+ assertEditableText(after, "edited content");
253
+ return { after, made };
254
+ }
255
+ /** What is on disk now, or nothing at all — a file that is not there yet. */
256
+ async function current(target) {
257
+ return readEditableText(target, { missingAsEmpty: true });
258
+ }
259
+ function countOccurrences(haystack, needle) {
260
+ let occurrences = 0;
261
+ for (let index = haystack.indexOf(needle); index !== -1; index = haystack.indexOf(needle, index + needle.length)) {
262
+ occurrences += 1;
263
+ }
264
+ return occurrences;
265
+ }
266
+ async function unchangedSinceApproval(target, approved) {
267
+ const onDisk = await current(target);
268
+ if (approved !== undefined && onDisk !== approved) {
269
+ throw new Error("file changed after the preview — inspect it and retry the write");
270
+ }
271
+ }
272
+ function count(text, noun) {
273
+ return plural(text.split("\n").length, noun, `${noun}s`);
274
+ }
275
+ function plural(n, one, many) {
276
+ return `${n} ${n === 1 ? one : many}`;
277
+ }
@@ -0,0 +1,39 @@
1
+ // The tool registry, and the one place a tool actually gets run.
2
+ import { editFile, listDir, readFile, writeFile } from "./fs.js";
3
+ import { runCommand } from "./shell.js";
4
+ import { findFiles, searchText } from "./search.js";
5
+ export function builtinTools() {
6
+ return [readFile, listDir, findFiles, searchText, editFile, writeFile, runCommand];
7
+ }
8
+ export function findTool(tools, name) {
9
+ return tools.find((tool) => tool.name === name);
10
+ }
11
+ /** Strip the executable half — providers only ever see the declaration. */
12
+ export function toolSpecs(tools) {
13
+ return tools.map((tool) => ({
14
+ name: tool.name,
15
+ description: tool.description,
16
+ input: tool.input,
17
+ }));
18
+ }
19
+ // A tool failure is a result, not an exception: the model reads the message
20
+ // and gets another turn to fix its call. Only an aborted turn propagates.
21
+ export async function runTool(tool, call, ctx) {
22
+ try {
23
+ const { output, summary } = await tool.run(call.input, ctx);
24
+ return { result: { kind: "tool_result", id: call.id, output, isError: false }, summary };
25
+ }
26
+ catch (error) {
27
+ if (ctx.signal?.aborted === true)
28
+ throw error;
29
+ return {
30
+ result: {
31
+ kind: "tool_result",
32
+ id: call.id,
33
+ output: `${tool.name} failed: ${error.message}`,
34
+ isError: true,
35
+ },
36
+ summary: "failed",
37
+ };
38
+ }
39
+ }
@@ -0,0 +1,122 @@
1
+ import * as path from "node:path";
2
+ import { lstat, realpath } from "node:fs/promises";
3
+ // Every filesystem tool resolves through here. The agent works inside one
4
+ // root and cannot be talked into reaching outside it — including via "..",
5
+ // an absolute path, or (on Windows) a different drive letter.
6
+ export function resolveInRoot(root, candidate) {
7
+ const absoluteRoot = path.resolve(root);
8
+ const absolute = path.resolve(absoluteRoot, candidate);
9
+ if (!inside(absoluteRoot, absolute)) {
10
+ throw new Error(`path escapes the workspace root: ${candidate}`);
11
+ }
12
+ return absolute;
13
+ }
14
+ /** Resolve an existing path after following symlinks and Windows junctions. */
15
+ export async function resolveExistingInRoot(root, candidate) {
16
+ const lexical = resolveInRoot(root, candidate);
17
+ const canonicalRoot = await realpath(root);
18
+ const canonical = await realpath(lexical);
19
+ if (!inside(canonicalRoot, canonical))
20
+ throw new Error(`path escapes the workspace root: ${candidate}`);
21
+ return canonical;
22
+ }
23
+ /** Resolve a path that may not exist, canonicalizing its nearest existing parent. */
24
+ export async function resolveWritableInRoot(root, candidate) {
25
+ const lexical = resolveInRoot(root, candidate);
26
+ const canonicalRoot = await realpath(root);
27
+ try {
28
+ const canonical = await realpath(lexical);
29
+ if (!inside(canonicalRoot, canonical))
30
+ throw new Error(`path escapes the workspace root: ${candidate}`);
31
+ return canonical;
32
+ }
33
+ catch (error) {
34
+ if (!missing(error))
35
+ throw error;
36
+ }
37
+ const rest = [path.basename(lexical)];
38
+ let parent = path.dirname(lexical);
39
+ while (true) {
40
+ try {
41
+ const canonicalParent = await realpath(parent);
42
+ const target = path.join(canonicalParent, ...rest);
43
+ if (!inside(canonicalRoot, target))
44
+ throw new Error(`path escapes the workspace root: ${candidate}`);
45
+ return target;
46
+ }
47
+ catch (error) {
48
+ if (!missing(error))
49
+ throw error;
50
+ const next = path.dirname(parent);
51
+ if (next === parent)
52
+ throw error;
53
+ rest.unshift(path.basename(parent));
54
+ parent = next;
55
+ }
56
+ }
57
+ }
58
+ /**
59
+ * Resolve a write target without permitting a symlink or junction component.
60
+ *
61
+ * Reads may follow an in-workspace alias. Writes stay on the direct path so a
62
+ * later boundary check can detect a component replaced during the operation.
63
+ */
64
+ export async function resolveDirectWritableInRoot(root, candidate, mustExist = false) {
65
+ const { canonicalRoot, target } = await directPath(root, candidate);
66
+ await assertDirectWritableInRoot(canonicalRoot, target, mustExist);
67
+ return target;
68
+ }
69
+ /** Revalidate a previously resolved direct write target. */
70
+ export async function assertDirectWritableInRoot(root, target, mustExist = false) {
71
+ const { canonicalRoot, target: direct } = await directPath(root, target);
72
+ const relative = path.relative(canonicalRoot, direct);
73
+ let current = canonicalRoot;
74
+ for (const part of relative.split(path.sep).filter((value) => value !== "")) {
75
+ current = path.join(current, part);
76
+ try {
77
+ const details = await lstat(current);
78
+ if (details.isSymbolicLink())
79
+ throw writeLinkError();
80
+ }
81
+ catch (error) {
82
+ if (!missing(error))
83
+ throw error;
84
+ if (mustExist)
85
+ throw error;
86
+ break;
87
+ }
88
+ }
89
+ const resolved = mustExist
90
+ ? await realpath(direct)
91
+ : await resolveWritableInRoot(canonicalRoot, direct);
92
+ if (!samePath(direct, resolved))
93
+ throw writeLinkError();
94
+ }
95
+ async function directPath(root, candidate) {
96
+ const lexicalRoot = path.resolve(root);
97
+ const lexicalTarget = resolveInRoot(lexicalRoot, candidate);
98
+ const relative = path.relative(lexicalRoot, lexicalTarget);
99
+ const canonicalRoot = await realpath(lexicalRoot);
100
+ const target = path.resolve(canonicalRoot, relative);
101
+ if (!inside(canonicalRoot, target)) {
102
+ throw new Error(`path escapes the workspace root: ${candidate}`);
103
+ }
104
+ return { canonicalRoot, target };
105
+ }
106
+ export function displayPath(root, absolute) {
107
+ const relative = path.relative(root, absolute);
108
+ return relative === "" ? "." : relative.split(path.sep).join("/");
109
+ }
110
+ function inside(root, target) {
111
+ const relative = path.relative(root, target);
112
+ return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
113
+ }
114
+ function samePath(left, right) {
115
+ return path.relative(left, right) === "";
116
+ }
117
+ function writeLinkError() {
118
+ return new Error("write path contains a symbolic link or junction — use a direct workspace path");
119
+ }
120
+ function missing(error) {
121
+ return error.code === "ENOENT";
122
+ }
@@ -0,0 +1,213 @@
1
+ // Bounded, read-only workspace discovery without borrowing a shell.
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { optionalBool, optionalInt, optionalString, requireString } from "./args.js";
5
+ import { displayPath, resolveExistingInRoot } from "./paths.js";
6
+ const DEFAULT_RESULTS = 100;
7
+ const MAX_RESULTS = 500;
8
+ const MAX_VISITED = 20_000;
9
+ const MAX_FILE_BYTES = 1_000_000;
10
+ const MAX_MATCH_LINE = 500;
11
+ const SKIP = new Set([".git", ".hg", ".svn", "node_modules"]);
12
+ export const findFiles = {
13
+ name: "find_files",
14
+ description: "Find files inside the workspace by glob (for example **/*.ts). Skips dependency and VCS " +
15
+ "directories, never follows symlinks, and returns a bounded list.",
16
+ dangerous: false,
17
+ input: {
18
+ type: "object",
19
+ properties: {
20
+ pattern: { type: "string", description: "Glob matched against workspace-relative paths." },
21
+ path: { type: "string", description: "Directory to search, relative to the workspace root." },
22
+ max_results: { type: "integer", description: "Maximum paths returned. Defaults to 100, caps at 500." },
23
+ },
24
+ required: ["pattern"],
25
+ },
26
+ async run(args, ctx) {
27
+ const scoped = await canonicalContext(ctx);
28
+ const start = await startAt(args, scoped);
29
+ const match = glob(requireString(args, "pattern"));
30
+ const limit = resultLimit(args);
31
+ const found = [];
32
+ const walked = await walk(start, scoped, async (file) => {
33
+ const relative = displayPath(scoped.root, file);
34
+ if (match(relative))
35
+ found.push(relative);
36
+ return found.length >= limit;
37
+ });
38
+ found.sort((a, b) => a.localeCompare(b));
39
+ return {
40
+ output: found.length === 0 ? "[no matching files]" : found.join("\n"),
41
+ summary: summary(found.length, limit, walked.capped, "file", "files"),
42
+ };
43
+ },
44
+ };
45
+ export const searchText = {
46
+ name: "search_text",
47
+ description: "Search UTF-8 text files inside the workspace for a literal string. Skips dependencies, VCS " +
48
+ "directories, symlinks, binary files, and files over 1 MB; results are bounded.",
49
+ dangerous: false,
50
+ input: {
51
+ type: "object",
52
+ properties: {
53
+ query: { type: "string", description: "Literal text to find." },
54
+ path: { type: "string", description: "Directory to search, relative to the workspace root." },
55
+ pattern: { type: "string", description: "Optional file glob, for example **/*.ts." },
56
+ case_sensitive: { type: "boolean", description: "Defaults to false." },
57
+ max_results: { type: "integer", description: "Maximum matching lines. Defaults to 100, caps at 500." },
58
+ },
59
+ required: ["query"],
60
+ },
61
+ async run(args, ctx) {
62
+ const scoped = await canonicalContext(ctx);
63
+ const start = await startAt(args, scoped);
64
+ const query = requireString(args, "query");
65
+ const sensitive = optionalBool(args, "case_sensitive") ?? false;
66
+ const needle = sensitive ? query : query.toLocaleLowerCase();
67
+ const pattern = optionalString(args, "pattern");
68
+ const match = pattern === undefined || pattern === "" ? () => true : glob(pattern);
69
+ const limit = resultLimit(args);
70
+ const found = [];
71
+ let skipped = 0;
72
+ const walked = await walk(start, scoped, async (lexical) => {
73
+ const relative = displayPath(scoped.root, lexical);
74
+ if (!match(relative))
75
+ return false;
76
+ const file = await resolveExistingInRoot(scoped.root, lexical);
77
+ const info = await fs.stat(file);
78
+ if (info.size > MAX_FILE_BYTES) {
79
+ skipped++;
80
+ return false;
81
+ }
82
+ let text;
83
+ try {
84
+ const data = await fs.readFile(file);
85
+ if (data.includes(0)) {
86
+ skipped++;
87
+ return false;
88
+ }
89
+ text = data.toString("utf8");
90
+ }
91
+ catch (error) {
92
+ if (skippable(error)) {
93
+ skipped++;
94
+ return false;
95
+ }
96
+ throw error;
97
+ }
98
+ for (const [index, line] of text.replace(/\r\n?/g, "\n").split("\n").entries()) {
99
+ checkAbort(ctx.signal);
100
+ const haystack = sensitive ? line : line.toLocaleLowerCase();
101
+ if (!haystack.includes(needle))
102
+ continue;
103
+ found.push(`${relative}:${index + 1}:${clip(line)}`);
104
+ if (found.length >= limit)
105
+ return true;
106
+ }
107
+ return false;
108
+ });
109
+ const extra = skipped === 0 ? "" : ` · skipped ${skipped} binary/large/unreadable`;
110
+ return {
111
+ output: found.length === 0 ? "[no matches]" : found.join("\n"),
112
+ summary: `${summary(found.length, limit, walked.capped, "match", "matches")}${extra}`,
113
+ };
114
+ },
115
+ };
116
+ async function walk(start, ctx, visit) {
117
+ const pending = [start];
118
+ let seen = 0;
119
+ while (pending.length > 0) {
120
+ checkAbort(ctx.signal);
121
+ const lexical = pending.pop();
122
+ const directory = await resolveExistingInRoot(ctx.root, lexical);
123
+ let entries;
124
+ try {
125
+ entries = await fs.readdir(directory, { withFileTypes: true, encoding: "utf8" });
126
+ }
127
+ catch (error) {
128
+ if (skippable(error))
129
+ continue;
130
+ throw error;
131
+ }
132
+ entries.sort((a, b) => a.name.localeCompare(b.name));
133
+ for (const entry of entries) {
134
+ checkAbort(ctx.signal);
135
+ if (++seen > MAX_VISITED)
136
+ return { capped: true };
137
+ if (entry.isSymbolicLink())
138
+ continue;
139
+ const target = path.join(directory, entry.name);
140
+ if (entry.isDirectory()) {
141
+ if (!SKIP.has(entry.name))
142
+ pending.push(target);
143
+ }
144
+ else if (entry.isFile() && (await visit(target))) {
145
+ return { capped: false };
146
+ }
147
+ }
148
+ }
149
+ return { capped: false };
150
+ }
151
+ async function startAt(args, ctx) {
152
+ const candidate = optionalString(args, "path") ?? ".";
153
+ const target = await resolveExistingInRoot(ctx.root, candidate);
154
+ if (!(await fs.stat(target)).isDirectory())
155
+ throw new Error(`"path" is not a directory: ${candidate}`);
156
+ return target;
157
+ }
158
+ async function canonicalContext(ctx) {
159
+ return { ...ctx, root: await resolveExistingInRoot(ctx.root, ".") };
160
+ }
161
+ function resultLimit(args) {
162
+ const requested = optionalInt(args, "max_results") ?? DEFAULT_RESULTS;
163
+ if (requested <= 0)
164
+ throw new Error('"max_results" must be a positive integer');
165
+ return Math.min(requested, MAX_RESULTS);
166
+ }
167
+ function glob(pattern) {
168
+ const normalized = pattern.replace(/\\/g, "/");
169
+ let source = "";
170
+ for (let index = 0; index < normalized.length; index++) {
171
+ const char = normalized[index];
172
+ if (char === "*" && normalized[index + 1] === "*") {
173
+ if (normalized[index + 2] === "/") {
174
+ source += "(?:.*/)?";
175
+ index += 2;
176
+ }
177
+ else {
178
+ source += ".*";
179
+ index++;
180
+ }
181
+ }
182
+ else if (char === "*")
183
+ source += "[^/]*";
184
+ else if (char === "?")
185
+ source += "[^/]";
186
+ else
187
+ source += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
188
+ }
189
+ const expression = new RegExp(`^${source}$`, "i");
190
+ const basenameOnly = !normalized.includes("/");
191
+ return (relative) => expression.test(basenameOnly ? path.posix.basename(relative) : relative);
192
+ }
193
+ function summary(count, limit, capped, one, many) {
194
+ const noun = count === 1 ? one : many;
195
+ if (count >= limit)
196
+ return `${count} ${noun} · result limit`;
197
+ if (capped)
198
+ return `${count} ${noun} · scan limit`;
199
+ return `${count} ${noun}`;
200
+ }
201
+ function clip(line) {
202
+ if (line.length <= MAX_MATCH_LINE)
203
+ return line;
204
+ return `${line.slice(0, MAX_MATCH_LINE - 1)}…`;
205
+ }
206
+ function checkAbort(signal) {
207
+ if (signal?.aborted !== true)
208
+ return;
209
+ throw signal.reason instanceof Error ? signal.reason : new Error("aborted");
210
+ }
211
+ function skippable(error) {
212
+ return ["EACCES", "EPERM", "ENOENT"].includes(error.code ?? "");
213
+ }