@freesyntax/notch-cli 0.5.17 → 0.5.19

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.
@@ -0,0 +1,246 @@
1
+ // src/tools/apply-patch.ts
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ import { z } from "zod";
5
+ var PatchParseError = class extends Error {
6
+ };
7
+ var PatchApplyError = class extends Error {
8
+ };
9
+ function parsePatch(raw) {
10
+ const lines = raw.split(/\r?\n/);
11
+ let i = 0;
12
+ while (i < lines.length && lines[i].trim() === "") i++;
13
+ if (i >= lines.length) throw new PatchParseError("Empty patch");
14
+ if (lines[i].trim() !== "*** Begin Patch") {
15
+ throw new PatchParseError(`Patch must start with "*** Begin Patch" (got: ${lines[i]})`);
16
+ }
17
+ i++;
18
+ const actions = [];
19
+ while (i < lines.length) {
20
+ const line = lines[i];
21
+ const trimmed = line.trim();
22
+ if (trimmed === "*** End Patch") {
23
+ i++;
24
+ return actions;
25
+ }
26
+ if (trimmed.startsWith("*** Add File: ")) {
27
+ const target = trimmed.slice("*** Add File: ".length).trim();
28
+ i++;
29
+ const body = [];
30
+ let sawPlusLine = false;
31
+ while (i < lines.length && !lines[i].startsWith("*** ")) {
32
+ const l = lines[i];
33
+ if (l.startsWith("+")) {
34
+ body.push(l.slice(1));
35
+ sawPlusLine = true;
36
+ } else if (l.trim() === "") body.push("");
37
+ else throw new PatchParseError(`Add File body line must start with '+': ${l}`);
38
+ i++;
39
+ }
40
+ const content = sawPlusLine ? body.join("\n") : "";
41
+ actions.push({ kind: "add", target, content });
42
+ continue;
43
+ }
44
+ if (trimmed.startsWith("*** Delete File: ")) {
45
+ const target = trimmed.slice("*** Delete File: ".length).trim();
46
+ actions.push({ kind: "delete", target });
47
+ i++;
48
+ continue;
49
+ }
50
+ if (trimmed.startsWith("*** Update File: ")) {
51
+ const target = trimmed.slice("*** Update File: ".length).trim();
52
+ i++;
53
+ let moveTo;
54
+ if (i < lines.length && lines[i].trim().startsWith("*** Move to: ")) {
55
+ moveTo = lines[i].trim().slice("*** Move to: ".length).trim();
56
+ i++;
57
+ }
58
+ const hunks = [];
59
+ while (i < lines.length && !lines[i].startsWith("*** ")) {
60
+ const l = lines[i];
61
+ if (l.trim() === "*** End of File") {
62
+ i++;
63
+ continue;
64
+ }
65
+ if (l.startsWith("@@")) {
66
+ const header = l.slice(2).trim() || void 0;
67
+ i++;
68
+ const ops = [];
69
+ while (i < lines.length && !lines[i].startsWith("*** ") && !lines[i].startsWith("@@") && lines[i].trim() !== "*** End of File") {
70
+ ops.push(parseOp(lines[i]));
71
+ i++;
72
+ }
73
+ hunks.push({ header, ops });
74
+ } else {
75
+ const ops = [];
76
+ while (i < lines.length && !lines[i].startsWith("*** ") && !lines[i].startsWith("@@") && lines[i].trim() !== "*** End of File") {
77
+ ops.push(parseOp(lines[i]));
78
+ i++;
79
+ }
80
+ if (ops.length > 0) hunks.push({ ops });
81
+ }
82
+ }
83
+ if (hunks.length === 0) {
84
+ throw new PatchParseError(`Update File "${target}" has no hunks`);
85
+ }
86
+ actions.push({ kind: "update", target, moveTo, hunks });
87
+ continue;
88
+ }
89
+ if (trimmed === "") {
90
+ i++;
91
+ continue;
92
+ }
93
+ throw new PatchParseError(`Unexpected patch line: ${line}`);
94
+ }
95
+ throw new PatchParseError('Patch missing "*** End Patch"');
96
+ }
97
+ function parseOp(line) {
98
+ if (line.startsWith("+")) return { op: "add", line: line.slice(1) };
99
+ if (line.startsWith("-")) return { op: "del", line: line.slice(1) };
100
+ if (line.startsWith(" ")) return { op: "keep", line: line.slice(1) };
101
+ if (line === "") return { op: "keep", line: "" };
102
+ throw new PatchParseError(`Hunk line must start with ' ', '+', or '-': ${line}`);
103
+ }
104
+ function applyHunk(source, hunk) {
105
+ const pattern = [];
106
+ const replacement = [];
107
+ for (const op of hunk.ops) {
108
+ if (op.op === "keep") {
109
+ pattern.push(op.line);
110
+ replacement.push(op.line);
111
+ } else if (op.op === "del") {
112
+ pattern.push(op.line);
113
+ } else if (op.op === "add") {
114
+ replacement.push(op.line);
115
+ }
116
+ }
117
+ if (pattern.length === 0) {
118
+ if (hunk.header) {
119
+ const idx = source.findIndex((l) => l.includes(hunk.header));
120
+ if (idx === -1) throw new PatchApplyError(`Insertion header not found: ${hunk.header}`);
121
+ return [...source.slice(0, idx + 1), ...replacement, ...source.slice(idx + 1)];
122
+ }
123
+ return [...source, ...replacement];
124
+ }
125
+ const matchIdx = findMatch(source, pattern, hunk.header);
126
+ if (matchIdx === -1) {
127
+ throw new PatchApplyError(
128
+ `Could not locate hunk context${hunk.header ? ` near "${hunk.header}"` : ""}. First context line: ${JSON.stringify(pattern[0])}`
129
+ );
130
+ }
131
+ return [
132
+ ...source.slice(0, matchIdx),
133
+ ...replacement,
134
+ ...source.slice(matchIdx + pattern.length)
135
+ ];
136
+ }
137
+ function findMatch(source, pattern, header) {
138
+ const start = header ? source.findIndex((l) => l.includes(header)) + 1 : 0;
139
+ const scanFrom = Math.max(0, start);
140
+ const strategies = [
141
+ (s) => s,
142
+ (s) => s.replace(/[ \t]+$/, ""),
143
+ (s) => s.trim()
144
+ ];
145
+ for (const norm of strategies) {
146
+ const normPattern = pattern.map(norm);
147
+ for (let i = scanFrom; i + pattern.length <= source.length; i++) {
148
+ let ok = true;
149
+ for (let j = 0; j < pattern.length; j++) {
150
+ if (norm(source[i + j]) !== normPattern[j]) {
151
+ ok = false;
152
+ break;
153
+ }
154
+ }
155
+ if (ok) return i;
156
+ }
157
+ if (header) {
158
+ for (let i = 0; i + pattern.length <= source.length; i++) {
159
+ let ok = true;
160
+ for (let j = 0; j < pattern.length; j++) {
161
+ if (norm(source[i + j]) !== normPattern[j]) {
162
+ ok = false;
163
+ break;
164
+ }
165
+ }
166
+ if (ok) return i;
167
+ }
168
+ }
169
+ }
170
+ return -1;
171
+ }
172
+ var parameters = z.object({
173
+ input: z.string().describe(
174
+ 'A patch in the apply_patch format. Must begin with "*** Begin Patch" and end with "*** End Patch". Use "*** Add File: path", "*** Update File: path" (with @@ context + +/-/space lines), or "*** Delete File: path" sections. Optionally "*** Move to: path" right after Update File.'
175
+ )
176
+ });
177
+ var applyPatchTool = {
178
+ name: "apply_patch",
179
+ description: 'Apply a multi-file patch in the apply_patch format. Prefer this over the edit tool when making coordinated changes across multiple files, adding/deleting/renaming files, or when context-anchored hunks are safer than exact string match. Format: *** Begin Patch / *** Add|Update|Delete File: path / (for Update: @@ header, then lines prefixed with " ", "+", "-") / *** End Patch.',
180
+ parameters,
181
+ async execute(params, ctx) {
182
+ let actions;
183
+ try {
184
+ actions = parsePatch(params.input);
185
+ } catch (err) {
186
+ return { content: `Patch parse error: ${err.message}`, isError: true };
187
+ }
188
+ const summary = [];
189
+ try {
190
+ for (const action of actions) {
191
+ const abs = path.isAbsolute(action.target) ? action.target : path.resolve(ctx.cwd, action.target);
192
+ if (action.kind === "add") {
193
+ try {
194
+ await fs.access(abs);
195
+ return { content: `Add File failed: ${action.target} already exists`, isError: true };
196
+ } catch {
197
+ }
198
+ await fs.mkdir(path.dirname(abs), { recursive: true });
199
+ await fs.writeFile(abs, action.content, "utf-8");
200
+ summary.push(`A ${action.target}`);
201
+ } else if (action.kind === "delete") {
202
+ await fs.unlink(abs);
203
+ summary.push(`D ${action.target}`);
204
+ } else {
205
+ const original = await fs.readFile(abs, "utf-8");
206
+ const hasCrlf = original.includes("\r\n");
207
+ const eol = hasCrlf ? "\r\n" : "\n";
208
+ const hadTrailingNewline = original.endsWith("\n") || original.endsWith("\r\n");
209
+ const stripped = hasCrlf ? original.replace(/\r\n/g, "\n") : original;
210
+ let lines = (hadTrailingNewline ? stripped.slice(0, -1) : stripped).split("\n");
211
+ for (const hunk of action.hunks) {
212
+ lines = applyHunk(lines, hunk);
213
+ }
214
+ const joined = lines.join(eol);
215
+ const next = hadTrailingNewline ? joined + eol : joined;
216
+ const destAbs = action.moveTo ? path.isAbsolute(action.moveTo) ? action.moveTo : path.resolve(ctx.cwd, action.moveTo) : abs;
217
+ if (action.moveTo) {
218
+ await fs.mkdir(path.dirname(destAbs), { recursive: true });
219
+ await fs.writeFile(destAbs, next, "utf-8");
220
+ await fs.unlink(abs);
221
+ summary.push(`R ${action.target} -> ${action.moveTo}`);
222
+ } else {
223
+ await fs.writeFile(abs, next, "utf-8");
224
+ summary.push(`M ${action.target}`);
225
+ }
226
+ }
227
+ }
228
+ } catch (err) {
229
+ return {
230
+ content: `Patch apply failed: ${err.message}
231
+ Partial changes may have been written. Applied so far: ${summary.join(", ") || "(none)"}`,
232
+ isError: true
233
+ };
234
+ }
235
+ return { content: `Applied patch (${actions.length} file op${actions.length === 1 ? "" : "s"}):
236
+ ${summary.join("\n")}` };
237
+ }
238
+ };
239
+
240
+ export {
241
+ PatchParseError,
242
+ PatchApplyError,
243
+ parsePatch,
244
+ applyHunk,
245
+ applyPatchTool
246
+ };
@@ -0,0 +1,134 @@
1
+ // src/tools/read.ts
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+ import { z } from "zod";
5
+ var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico"]);
6
+ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([".pdf", ".zip", ".tar", ".gz", ".wasm", ".exe", ".dll", ".so", ".dylib"]);
7
+ var MAX_FILE_SIZE = 5 * 1024 * 1024;
8
+ var MAX_LINES = 2e3;
9
+ var parameters = z.object({
10
+ path: z.string().describe("Relative or absolute path to the file to read"),
11
+ offset: z.number().optional().describe("Line number to start reading from (1-based)"),
12
+ limit: z.number().optional().describe("Maximum number of lines to read")
13
+ });
14
+ async function readImage(filePath, ext) {
15
+ try {
16
+ const stat = await fs.stat(filePath);
17
+ const buf = await fs.readFile(filePath);
18
+ const mimeType = ext === ".svg" ? "image/svg+xml" : ext === ".png" ? "image/png" : ext === ".gif" ? "image/gif" : ext === ".webp" ? "image/webp" : ext === ".bmp" ? "image/bmp" : ext === ".ico" ? "image/x-icon" : "image/jpeg";
19
+ if (ext === ".svg") {
20
+ const svgText = buf.toString("utf-8");
21
+ return {
22
+ content: `Image: ${filePath} (${(stat.size / 1024).toFixed(1)} KB, SVG)
23
+
24
+ ${svgText.slice(0, 1e4)}`
25
+ };
26
+ }
27
+ const base64 = buf.toString("base64");
28
+ return {
29
+ content: `Image: ${filePath} (${(stat.size / 1024).toFixed(1)} KB, ${ext})
30
+ data:${mimeType};base64,${base64.slice(0, 200)}... [${base64.length} chars total]
31
+
32
+ (Image data is available. Describe what changes to make if needed.)`
33
+ };
34
+ } catch (err) {
35
+ return { content: `Error reading image ${filePath}: ${err.message}`, isError: true };
36
+ }
37
+ }
38
+ async function readPDF(filePath) {
39
+ try {
40
+ const stat = await fs.stat(filePath);
41
+ const buf = await fs.readFile(filePath);
42
+ const text = buf.toString("latin1");
43
+ const textChunks = [];
44
+ const streamRegex = /stream\r?\n([\s\S]*?)\r?\nendstream/g;
45
+ let match;
46
+ while ((match = streamRegex.exec(text)) !== null) {
47
+ const chunk = match[1].replace(/[^\x20-\x7E\n\r\t]/g, " ").replace(/\s+/g, " ").trim();
48
+ if (chunk.length > 10) {
49
+ textChunks.push(chunk);
50
+ }
51
+ }
52
+ const tjRegex = /\(((?:[^)\\]|\\.)*)\)\s*Tj/g;
53
+ while ((match = tjRegex.exec(text)) !== null) {
54
+ const decoded = match[1].replace(/\\(.)/g, "$1");
55
+ if (decoded.trim().length > 0) {
56
+ textChunks.push(decoded.trim());
57
+ }
58
+ }
59
+ if (textChunks.length === 0) {
60
+ return {
61
+ content: `PDF: ${filePath} (${(stat.size / 1024).toFixed(1)} KB)
62
+
63
+ (Could not extract text \u2014 the PDF may be image-based or encrypted. Consider using an external tool for OCR.)`
64
+ };
65
+ }
66
+ const extractedText = textChunks.join("\n").slice(0, 5e4);
67
+ return {
68
+ content: `PDF: ${filePath} (${(stat.size / 1024).toFixed(1)} KB)
69
+
70
+ Extracted text:
71
+ ${extractedText}`
72
+ };
73
+ } catch (err) {
74
+ return { content: `Error reading PDF ${filePath}: ${err.message}`, isError: true };
75
+ }
76
+ }
77
+ var readTool = {
78
+ name: "read",
79
+ description: 'Read file contents with line numbers. Use offset/limit for large files. Supports text files, images (returns metadata + base64), and PDFs (basic text extraction). Returns numbered lines like " 1\\tline content".',
80
+ parameters,
81
+ async execute(params, ctx) {
82
+ const filePath = path.isAbsolute(params.path) ? params.path : path.resolve(ctx.cwd, params.path);
83
+ const ext = path.extname(filePath).toLowerCase();
84
+ try {
85
+ await fs.access(filePath);
86
+ if (IMAGE_EXTENSIONS.has(ext)) {
87
+ return await readImage(filePath, ext);
88
+ }
89
+ if (ext === ".pdf") {
90
+ return await readPDF(filePath);
91
+ }
92
+ if (BINARY_EXTENSIONS.has(ext)) {
93
+ const stat2 = await fs.stat(filePath);
94
+ return {
95
+ content: `Binary file: ${filePath} (${(stat2.size / 1024).toFixed(1)} KB, ${ext})`
96
+ };
97
+ }
98
+ const stat = await fs.stat(filePath);
99
+ if (stat.size > MAX_FILE_SIZE) {
100
+ return {
101
+ content: `File too large: ${filePath} (${(stat.size / 1024 / 1024).toFixed(1)} MB). Use offset/limit to read portions.`,
102
+ isError: true
103
+ };
104
+ }
105
+ const cacheKey = `${filePath}:${params.offset ?? 0}:${params.limit ?? 0}`;
106
+ if (ctx._readCache?.has(cacheKey)) {
107
+ return { content: ctx._readCache.get(cacheKey) };
108
+ }
109
+ const raw = await fs.readFile(filePath, "utf-8");
110
+ const allLines = raw.split("\n");
111
+ const offset = Math.max(0, (params.offset ?? 1) - 1);
112
+ const limit = params.limit ?? MAX_LINES;
113
+ const lines = allLines.slice(offset, offset + limit);
114
+ const numbered = lines.map((line, i) => {
115
+ const lineNum = String(offset + i + 1).padStart(5);
116
+ return `${lineNum} ${line}`;
117
+ }).join("\n");
118
+ const result = allLines.length > offset + limit ? `${numbered}
119
+
120
+ (${allLines.length - offset - limit} more lines. Use offset=${offset + limit + 1} to continue.)` : numbered;
121
+ ctx._readCache?.set(cacheKey, result);
122
+ return { content: result };
123
+ } catch (err) {
124
+ if (err.code === "ENOENT") {
125
+ return { content: `File not found: ${filePath}`, isError: true };
126
+ }
127
+ return { content: `Error reading ${filePath}: ${err.message}`, isError: true };
128
+ }
129
+ }
130
+ };
131
+
132
+ export {
133
+ readTool
134
+ };
@@ -0,0 +1,139 @@
1
+ // src/tools/git.ts
2
+ import { simpleGit } from "simple-git";
3
+ import { z } from "zod";
4
+ var PROTECTED_BRANCHES = ["main", "master", "production", "release"];
5
+ var parameters = z.object({
6
+ operation: z.enum([
7
+ "status",
8
+ "diff",
9
+ "log",
10
+ "commit",
11
+ "branch",
12
+ "checkout",
13
+ "push",
14
+ "pull",
15
+ "stash",
16
+ "add",
17
+ "show"
18
+ ]).describe("Git operation to perform"),
19
+ args: z.string().optional().describe("Additional arguments (e.g., branch name, commit message, file paths)")
20
+ });
21
+ var gitTool = {
22
+ name: "git",
23
+ description: "Perform git operations with built-in safety. Always prefer this over shell for git commands. Blocks force-push to protected branches. Operations: status, diff, log, commit, branch, checkout, push, pull, stash, add, show.",
24
+ parameters,
25
+ async execute(params, ctx) {
26
+ const git = simpleGit(ctx.cwd);
27
+ try {
28
+ switch (params.operation) {
29
+ case "status": {
30
+ const status = await git.status();
31
+ const lines = [
32
+ `Branch: ${status.current}`,
33
+ `Ahead: ${status.ahead} Behind: ${status.behind}`,
34
+ status.staged.length ? `Staged: ${status.staged.join(", ")}` : null,
35
+ status.modified.length ? `Modified: ${status.modified.join(", ")}` : null,
36
+ status.not_added.length ? `Untracked: ${status.not_added.join(", ")}` : null,
37
+ status.deleted.length ? `Deleted: ${status.deleted.join(", ")}` : null
38
+ ].filter(Boolean);
39
+ return { content: lines.join("\n") };
40
+ }
41
+ case "diff": {
42
+ const diff = await git.diff(params.args?.split(" ") ?? []);
43
+ return { content: diff || "(no changes)" };
44
+ }
45
+ case "log": {
46
+ const log = await git.log({ maxCount: 10, ...params.args ? {} : {} });
47
+ const entries = log.all.map(
48
+ (c) => `${c.hash.slice(0, 8)} ${c.date} ${c.message}`
49
+ );
50
+ return { content: entries.join("\n") || "(no commits)" };
51
+ }
52
+ case "commit": {
53
+ if (!params.args) {
54
+ return { content: 'Commit requires a message. Use args: "your commit message"', isError: true };
55
+ }
56
+ const result = await git.commit(params.args);
57
+ return {
58
+ content: `Committed: ${result.commit} (${result.summary.changes} changes, ${result.summary.insertions} insertions, ${result.summary.deletions} deletions)`
59
+ };
60
+ }
61
+ case "add": {
62
+ const files = params.args?.split(" ") ?? ["."];
63
+ const sensitive = files.filter(
64
+ (f) => /\.(env|pem|key|credentials|secret)/i.test(f)
65
+ );
66
+ if (sensitive.length > 0) {
67
+ const confirmed = await ctx.confirm(
68
+ `\u26A0 Staging potentially sensitive files: ${sensitive.join(", ")}
69
+ Proceed?`
70
+ );
71
+ if (!confirmed) {
72
+ return { content: "Staging cancelled.", isError: true };
73
+ }
74
+ }
75
+ await git.add(files);
76
+ return { content: `Staged: ${files.join(", ")}` };
77
+ }
78
+ case "branch": {
79
+ if (params.args) {
80
+ await git.branch(params.args.split(" "));
81
+ return { content: `Branch operation completed: ${params.args}` };
82
+ }
83
+ const branches = await git.branch();
84
+ return { content: branches.all.map((b) => `${b === branches.current ? "* " : " "}${b}`).join("\n") };
85
+ }
86
+ case "checkout": {
87
+ if (!params.args) {
88
+ return { content: "Checkout requires a branch or file path.", isError: true };
89
+ }
90
+ await git.checkout(params.args.split(" "));
91
+ return { content: `Checked out: ${params.args}` };
92
+ }
93
+ case "push": {
94
+ const currentBranch = (await git.status()).current ?? "";
95
+ const args = params.args?.split(" ") ?? [];
96
+ const isForce = args.includes("--force") || args.includes("-f");
97
+ if (isForce && PROTECTED_BRANCHES.includes(currentBranch)) {
98
+ return {
99
+ content: `Blocked: cannot force-push to protected branch "${currentBranch}". Use --force-with-lease instead.`,
100
+ isError: true
101
+ };
102
+ }
103
+ await git.push(args);
104
+ return { content: `Pushed ${currentBranch} successfully` };
105
+ }
106
+ case "pull": {
107
+ const pullResult = await git.pull(params.args?.split(" "));
108
+ return {
109
+ content: `Pulled: ${pullResult.summary.changes} changes, ${pullResult.summary.insertions} insertions, ${pullResult.summary.deletions} deletions`
110
+ };
111
+ }
112
+ case "stash": {
113
+ if (params.args === "pop") {
114
+ await git.stash(["pop"]);
115
+ return { content: "Stash popped" };
116
+ }
117
+ if (params.args === "list") {
118
+ const list = await git.stashList();
119
+ return { content: list.all.map((s) => `${s.hash} ${s.message}`).join("\n") || "(no stashes)" };
120
+ }
121
+ await git.stash(params.args?.split(" "));
122
+ return { content: "Changes stashed" };
123
+ }
124
+ case "show": {
125
+ const raw = await git.show(params.args?.split(" ") ?? []);
126
+ return { content: raw };
127
+ }
128
+ default:
129
+ return { content: `Unknown git operation: ${params.operation}`, isError: true };
130
+ }
131
+ } catch (err) {
132
+ return { content: `Git error: ${err.message}`, isError: true };
133
+ }
134
+ }
135
+ };
136
+
137
+ export {
138
+ gitTool
139
+ };
@@ -0,0 +1,72 @@
1
+ // src/tools/web-fetch.ts
2
+ import { z } from "zod";
3
+ var MAX_CONTENT_LENGTH = 5e4;
4
+ var FETCH_TIMEOUT = 15e3;
5
+ var parameters = z.object({
6
+ url: z.string().describe("URL to fetch"),
7
+ max_length: z.number().optional().default(MAX_CONTENT_LENGTH).describe("Max characters to return")
8
+ });
9
+ function htmlToText(html) {
10
+ return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<nav[\s\S]*?<\/nav>/gi, "").replace(/<footer[\s\S]*?<\/footer>/gi, "").replace(/<header[\s\S]*?<\/header>/gi, "").replace(/<\/?(p|div|br|h[1-6]|li|tr|blockquote|pre|hr)[^>]*>/gi, "\n").replace(/<li[^>]*>/gi, "\n- ").replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ").replace(/\n{3,}/g, "\n\n").replace(/[ \t]+/g, " ").trim();
11
+ }
12
+ var webFetchTool = {
13
+ name: "web_fetch",
14
+ description: "Fetch a web page and return its text content. Useful for reading documentation, API references, error lookups, and other web resources. Strips HTML to plain text.",
15
+ parameters,
16
+ async execute(params, _ctx) {
17
+ const { url } = params;
18
+ const maxLen = params.max_length ?? MAX_CONTENT_LENGTH;
19
+ try {
20
+ new URL(url);
21
+ } catch {
22
+ return { content: `Invalid URL: ${url}`, isError: true };
23
+ }
24
+ try {
25
+ const response = await fetch(url, {
26
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
27
+ headers: {
28
+ "User-Agent": "Notch-CLI/0.1 (AI coding assistant)",
29
+ "Accept": "text/html,application/json,text/plain"
30
+ },
31
+ redirect: "follow"
32
+ });
33
+ if (!response.ok) {
34
+ return {
35
+ content: `HTTP ${response.status} ${response.statusText} for ${url}`,
36
+ isError: true
37
+ };
38
+ }
39
+ const contentType = response.headers.get("content-type") ?? "";
40
+ const raw = await response.text();
41
+ let text;
42
+ if (contentType.includes("application/json")) {
43
+ try {
44
+ text = JSON.stringify(JSON.parse(raw), null, 2);
45
+ } catch {
46
+ text = raw;
47
+ }
48
+ } else if (contentType.includes("text/html")) {
49
+ text = htmlToText(raw);
50
+ } else {
51
+ text = raw;
52
+ }
53
+ if (text.length > maxLen) {
54
+ text = text.slice(0, maxLen) + `
55
+
56
+ ... (truncated, ${text.length} chars total)`;
57
+ }
58
+ return { content: `Fetched ${url} (${response.status}):
59
+
60
+ ${text}` };
61
+ } catch (err) {
62
+ if (err.name === "AbortError" || err.name === "TimeoutError") {
63
+ return { content: `Timeout fetching ${url} (${FETCH_TIMEOUT / 1e3}s limit)`, isError: true };
64
+ }
65
+ return { content: `Fetch error for ${url}: ${err.message}`, isError: true };
66
+ }
67
+ }
68
+ };
69
+
70
+ export {
71
+ webFetchTool
72
+ };