@juicesharp/rpiv-web-tools 1.5.1 → 1.6.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.
Files changed (4) hide show
  1. package/README.md +28 -0
  2. package/index.ts +19 -454
  3. package/package.json +2 -1
  4. package/web-tools.ts +645 -0
package/README.md CHANGED
@@ -103,6 +103,34 @@ First match wins:
103
103
  1. `BRAVE_SEARCH_API_KEY` environment variable
104
104
  2. `apiKey` field in `~/.config/rpiv-web-tools/config.json`
105
105
 
106
+ ## Executor guidance overrides
107
+
108
+ Override the `promptSnippet` / `promptGuidelines` the model sees for each tool by editing `~/.config/rpiv-web-tools/config.json`. Note the per-tool nesting under `guidance.web_search` / `guidance.web_fetch` — this differs from the flat `guidance` shape used by single-tool siblings (`rpiv-advisor`, `rpiv-todo`, `rpiv-ask-user-question`):
109
+
110
+ ```json
111
+ {
112
+ "apiKey": "sk-...",
113
+ "guidance": {
114
+ "web_search": {
115
+ "promptSnippet": "Search Brave for current docs and library versions",
116
+ "promptGuidelines": [
117
+ "Only call web_search when training-data answers may be stale.",
118
+ "Always include a Sources: section with markdown hyperlinks."
119
+ ]
120
+ },
121
+ "web_fetch": {
122
+ "promptSnippet": "Fetch a specific URL and read its content"
123
+ }
124
+ }
125
+ }
126
+ ```
127
+
128
+ Each field is independent: omit one and the built-in default is kept. Invalid values (empty string, wrong type, empty array) silently fall back to defaults. Changes take effect on the next Pi session start.
129
+
130
+ ## Security note: `web_fetch` reach
131
+
132
+ `web_fetch` accepts any http/https URL the model passes, including loopback (`127.0.0.1`, `::1`) and private-range addresses (RFC 1918, link-local, cloud metadata at `169.254.169.254`). This is intentional — local dev servers and intranet docs are common legitimate targets in a single-user CLI. If you run Pi in an untrusted automation context where the model could be steered toward internal services, restrict the tool at the network layer (firewall, egress proxy) or fork to add a host filter; the package ships without one.
133
+
106
134
  ## License
107
135
 
108
136
  MIT
package/index.ts CHANGED
@@ -1,463 +1,28 @@
1
1
  /**
2
- * rpiv-pi web-tools extension
2
+ * rpiv-web-tools — Pi extension
3
3
  *
4
- * Provides `web_search` and `web_fetch` tools backed by the Brave Search API.
5
- * Based on the user-local reference implementation at
6
- * ~/.pi/agent/extensions/web-search/index.ts (Tavily/Serper backends stripped,
7
- * Brave kept as default).
4
+ * Registers the `web_search` and `web_fetch` tools, plus the
5
+ * `/web-search-config` slash command. Body lives in `web-tools.ts`.
8
6
  *
9
- * API key resolution precedence (first wins):
10
- * 1. BRAVE_SEARCH_API_KEY environment variable
11
- * 2. apiKey field in ~/.config/rpiv-pi/web-tools.json
12
- *
13
- * Use the /web-search-config slash command to set the key interactively.
7
+ * Config persists at ~/.config/rpiv-web-tools/config.json. Env var
8
+ * BRAVE_SEARCH_API_KEY wins over the config file.
14
9
  */
15
10
 
16
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
- import { mkdtemp, writeFile } from "node:fs/promises";
18
- import { homedir, tmpdir } from "node:os";
19
- import { dirname, join } from "node:path";
20
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
- import {
22
- DEFAULT_MAX_BYTES,
23
- DEFAULT_MAX_LINES,
24
- formatSize,
25
- type TruncationResult,
26
- truncateHead,
27
- } from "@earendil-works/pi-coding-agent";
28
- import { Text } from "@earendil-works/pi-tui";
29
- import { Type } from "typebox";
30
-
31
- // ---------------------------------------------------------------------------
32
- // Config file persistence
33
- // ---------------------------------------------------------------------------
34
-
35
- interface WebToolsConfig {
36
- apiKey?: string;
37
- }
38
-
39
- const CONFIG_PATH = join(homedir(), ".config", "rpiv-web-tools", "config.json");
40
-
41
- function loadConfig(): WebToolsConfig {
42
- if (!existsSync(CONFIG_PATH)) return {};
43
- try {
44
- return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as WebToolsConfig;
45
- } catch {
46
- return {};
47
- }
48
- }
49
-
50
- function saveConfig(config: WebToolsConfig): void {
51
- mkdirSync(dirname(CONFIG_PATH), { recursive: true });
52
- writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
53
- try {
54
- chmodSync(CONFIG_PATH, 0o600);
55
- } catch {
56
- // chmod may fail on some filesystems — best effort only
57
- }
58
- }
59
-
60
- function resolveApiKey(): string | undefined {
61
- const envKey = process.env.BRAVE_SEARCH_API_KEY;
62
- if (envKey?.trim()) return envKey.trim();
63
- const config = loadConfig();
64
- if (config.apiKey?.trim()) return config.apiKey.trim();
65
- return undefined;
66
- }
67
-
68
- // ---------------------------------------------------------------------------
69
- // Brave Search API client
70
- // ---------------------------------------------------------------------------
71
-
72
- interface SearchResult {
73
- title: string;
74
- url: string;
75
- snippet: string;
76
- }
77
-
78
- interface SearchResponse {
79
- results: SearchResult[];
80
- query: string;
81
- }
82
-
83
- async function searchBrave(query: string, maxResults: number, signal?: AbortSignal): Promise<SearchResponse> {
84
- const apiKey = resolveApiKey();
85
- if (!apiKey) {
86
- throw new Error("BRAVE_SEARCH_API_KEY is not set. Run /web-search-config to configure, or export the env var.");
87
- }
88
-
89
- const url = new URL("https://api.search.brave.com/res/v1/web/search");
90
- url.searchParams.set("q", query);
91
- url.searchParams.set("count", String(maxResults));
92
-
93
- const res = await fetch(url.toString(), {
94
- method: "GET",
95
- headers: {
96
- Accept: "application/json",
97
- "Accept-Encoding": "gzip",
98
- "X-Subscription-Token": apiKey,
99
- },
100
- signal,
101
- });
102
-
103
- if (!res.ok) {
104
- const text = await res.text();
105
- throw new Error(`Brave Search API error (${res.status}): ${text}`);
106
- }
107
-
108
- const data = (await res.json()) as {
109
- web?: { results?: Array<{ title?: string; url?: string; description?: string }> };
110
- };
111
- const results: SearchResult[] = (data.web?.results ?? []).map((r) => ({
112
- title: r.title ?? "",
113
- url: r.url ?? "",
114
- snippet: r.description ?? "",
115
- }));
116
-
117
- return { results, query };
118
- }
119
-
120
- // ---------------------------------------------------------------------------
121
- // HTML-to-text for web_fetch
122
- // ---------------------------------------------------------------------------
123
-
124
- function htmlToText(html: string): string {
125
- let text = html;
126
- text = text.replace(/<script[\s\S]*?<\/script>/gi, "");
127
- text = text.replace(/<style[\s\S]*?<\/style>/gi, "");
128
- text = text.replace(/<noscript[\s\S]*?<\/noscript>/gi, "");
129
- text = text.replace(
130
- /<\/(p|div|h[1-6]|li|tr|br|blockquote|pre|section|article|header|footer|nav|details|summary)>/gi,
131
- "\n",
132
- );
133
- text = text.replace(/<br\s*\/?>/gi, "\n");
134
- text = text.replace(/<[^>]+>/g, " ");
135
- text = text.replace(/&amp;/g, "&");
136
- text = text.replace(/&lt;/g, "<");
137
- text = text.replace(/&gt;/g, ">");
138
- text = text.replace(/&quot;/g, '"');
139
- text = text.replace(/&#39;/g, "'");
140
- text = text.replace(/&nbsp;/g, " ");
141
- text = text.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)));
142
- text = text.replace(/[ \t]+/g, " ");
143
- text = text.replace(/\n{3,}/g, "\n\n");
144
- return text.trim();
145
- }
146
-
147
- function extractTitle(html: string): string | undefined {
148
- const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
149
- if (match) {
150
- return match[1].replace(/<[^>]+>/g, "").trim() || undefined;
151
- }
152
- return undefined;
153
- }
154
-
155
- // ---------------------------------------------------------------------------
156
- // Extension entry point
157
- // ---------------------------------------------------------------------------
12
+ import { registerWebFetchTool, registerWebSearchConfigCommand, registerWebSearchTool } from "./web-tools.js";
13
+
14
+ export {
15
+ DEFAULT_WEB_FETCH_GUIDELINES,
16
+ DEFAULT_WEB_FETCH_SNIPPET,
17
+ DEFAULT_WEB_SEARCH_GUIDELINES,
18
+ DEFAULT_WEB_SEARCH_SNIPPET,
19
+ registerWebFetchTool,
20
+ registerWebSearchConfigCommand,
21
+ registerWebSearchTool,
22
+ } from "./web-tools.js";
158
23
 
159
24
  export default function (pi: ExtensionAPI) {
160
- // =========================================================================
161
- // web_search tool
162
- // =========================================================================
163
-
164
- pi.registerTool({
165
- name: "web_search",
166
- label: "Web Search",
167
- description:
168
- "Search the web for information via the Brave Search API. Returns a list of results with titles, URLs, and snippets. Use when you need current information not in your training data.",
169
- promptSnippet: "Search the web for up-to-date information via Brave",
170
- promptGuidelines: [
171
- "Use web_search for information beyond your training data — recent events, current library versions, live API documentation.",
172
- 'Use the current year from "Current date:" in your context when searching for recent information or documentation.',
173
- 'After answering using search results, include a "Sources:" section listing relevant URLs as markdown hyperlinks: [Title](URL). Never skip this.',
174
- "Domain filtering is supported to include or block specific websites.",
175
- "If BRAVE_SEARCH_API_KEY is not set, ask the user to run /web-search-config before proceeding.",
176
- ],
177
- parameters: Type.Object({
178
- query: Type.String({
179
- description: "The search query. Be specific and use natural language.",
180
- }),
181
- max_results: Type.Optional(
182
- Type.Number({
183
- description: "Maximum number of results to return (1-10). Default: 5.",
184
- default: 5,
185
- minimum: 1,
186
- maximum: 10,
187
- }),
188
- ),
189
- }),
190
-
191
- async execute(_toolCallId, params, signal, onUpdate, _ctx) {
192
- const maxResults = Math.min(Math.max(params.max_results ?? 5, 1), 10);
193
-
194
- onUpdate?.({
195
- content: [{ type: "text", text: `Searching Brave for: "${params.query}"...` }],
196
- details: { query: params.query, backend: "brave", resultCount: 0 },
197
- });
198
-
199
- const response = await searchBrave(params.query, maxResults, signal);
200
-
201
- if (response.results.length === 0) {
202
- return {
203
- content: [
204
- {
205
- type: "text",
206
- text: `No results found for "${params.query}".`,
207
- },
208
- ],
209
- details: { query: params.query, backend: "brave", resultCount: 0 },
210
- };
211
- }
212
-
213
- let text = `**Search results for "${response.query}":**\n\n`;
214
- for (let i = 0; i < response.results.length; i++) {
215
- const r = response.results[i];
216
- text += `${i + 1}. **${r.title}**\n ${r.url}\n ${r.snippet}\n\n`;
217
- }
218
-
219
- return {
220
- content: [{ type: "text", text: text.trimEnd() }],
221
- details: {
222
- query: params.query,
223
- backend: "brave",
224
- resultCount: response.results.length,
225
- results: response.results,
226
- },
227
- };
228
- },
229
-
230
- renderCall(args, theme, _context) {
231
- let text = theme.fg("toolTitle", theme.bold("WebSearch "));
232
- text += theme.fg("accent", `"${args.query}"`);
233
- return new Text(text, 0, 0);
234
- },
235
-
236
- renderResult(result, { expanded, isPartial }, theme, _context) {
237
- if (isPartial) {
238
- return new Text(theme.fg("warning", "Searching..."), 0, 0);
239
- }
240
- const details = result.details as { resultCount?: number; results?: SearchResult[] };
241
- const count = details?.resultCount ?? 0;
242
- let text = theme.fg("success", `✓ ${count} result${count !== 1 ? "s" : ""}`);
243
- if (expanded && details?.results) {
244
- for (const r of details.results.slice(0, 5)) {
245
- text += `\n ${theme.fg("dim", `• ${r.title}`)}`;
246
- }
247
- if (details.results.length > 5) {
248
- text += `\n ${theme.fg("dim", `... and ${details.results.length - 5} more`)}`;
249
- }
250
- }
251
- return new Text(text, 0, 0);
252
- },
253
- });
254
-
255
- // =========================================================================
256
- // web_fetch tool
257
- // =========================================================================
258
-
259
- interface FetchDetails {
260
- url: string;
261
- title?: string;
262
- contentType?: string;
263
- contentLength?: number;
264
- truncation?: TruncationResult;
265
- fullOutputPath?: string;
266
- }
267
-
268
- pi.registerTool({
269
- name: "web_fetch",
270
- label: "Web Fetch",
271
- description:
272
- "Fetch the content of a specific URL. Returns text content for HTML pages (tags stripped), raw text for plain text or JSON. Supports http and https only. Content is truncated to avoid overwhelming the context window.",
273
- promptSnippet: "Fetch and read content from a specific URL",
274
- promptGuidelines: [
275
- "Use web_fetch to read the full content of a specific URL — documentation pages, blog posts, API references found via web_search.",
276
- "web_fetch is complementary to web_search: search finds URLs, fetch reads them.",
277
- 'After answering using fetched content, include a "Sources:" section with a markdown hyperlink to the fetched URL.',
278
- "Large responses are truncated and spilled to a temp file — the temp path is reported in the result details.",
279
- ],
280
- parameters: Type.Object({
281
- url: Type.String({
282
- description: "The URL to fetch. Must be http or https.",
283
- }),
284
- raw: Type.Optional(
285
- Type.Boolean({
286
- description: "If true, return the raw HTML instead of extracted text. Default: false.",
287
- default: false,
288
- }),
289
- ),
290
- }),
291
-
292
- async execute(_toolCallId, params, signal, onUpdate, _ctx) {
293
- const { url, raw = false } = params;
294
-
295
- let parsedUrl: URL;
296
- try {
297
- parsedUrl = new URL(url);
298
- } catch {
299
- throw new Error(`Invalid URL: ${url}`);
300
- }
301
- if (!["http:", "https:"].includes(parsedUrl.protocol)) {
302
- throw new Error(`Unsupported URL protocol: ${parsedUrl.protocol}. Only http and https are supported.`);
303
- }
304
-
305
- onUpdate?.({
306
- content: [{ type: "text", text: `Fetching: ${url}...` }],
307
- details: { url } as FetchDetails,
308
- });
309
-
310
- const res = await fetch(url, {
311
- signal,
312
- redirect: "follow",
313
- headers: {
314
- "User-Agent": "Mozilla/5.0 (compatible; rpiv-pi/1.0)",
315
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5",
316
- },
317
- });
318
-
319
- if (!res.ok) {
320
- throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
321
- }
322
-
323
- const contentType = res.headers.get("content-type") ?? "";
324
- const contentLength = res.headers.get("content-length");
325
-
326
- if (contentType.includes("image/") || contentType.includes("video/") || contentType.includes("audio/")) {
327
- throw new Error(`Unsupported content type: ${contentType}. web_fetch supports text pages only.`);
328
- }
329
-
330
- const body = await res.text();
331
-
332
- let resultText: string;
333
- let title: string | undefined;
334
-
335
- if (contentType.includes("text/html") && !raw) {
336
- title = extractTitle(body);
337
- resultText = htmlToText(body);
338
- } else {
339
- resultText = body;
340
- }
341
-
342
- const truncation = truncateHead(resultText, {
343
- maxLines: DEFAULT_MAX_LINES,
344
- maxBytes: DEFAULT_MAX_BYTES,
345
- });
346
-
347
- const details: FetchDetails = {
348
- url,
349
- title,
350
- contentType,
351
- contentLength: contentLength ? Number(contentLength) : undefined,
352
- };
353
-
354
- let output = truncation.content;
355
-
356
- if (truncation.truncated) {
357
- const tempDir = await mkdtemp(join(tmpdir(), "rpiv-fetch-"));
358
- const tempFile = join(tempDir, "content.txt");
359
- await writeFile(tempFile, resultText, "utf8");
360
- details.truncation = truncation;
361
- details.fullOutputPath = tempFile;
362
-
363
- const truncatedLines = truncation.totalLines - truncation.outputLines;
364
- const truncatedBytes = truncation.totalBytes - truncation.outputBytes;
365
- output += `\n\n[Content truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`;
366
- output += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
367
- output += ` ${truncatedLines} lines (${formatSize(truncatedBytes)}) omitted.`;
368
- output += ` Full content saved to: ${tempFile}]`;
369
- }
370
-
371
- let header = `**Fetched:** ${url}`;
372
- if (title) header += `\n**Title:** ${title}`;
373
- if (contentType) header += `\n**Content-Type:** ${contentType}`;
374
- header += "\n\n";
375
-
376
- return {
377
- content: [{ type: "text", text: header + output }],
378
- details,
379
- };
380
- },
381
-
382
- renderCall(args, theme, _context) {
383
- let text = theme.fg("toolTitle", theme.bold("WebFetch "));
384
- text += theme.fg("accent", args.url);
385
- return new Text(text, 0, 0);
386
- },
387
-
388
- renderResult(result, { expanded, isPartial }, theme, _context) {
389
- if (isPartial) {
390
- return new Text(theme.fg("warning", "Fetching..."), 0, 0);
391
- }
392
- const details = result.details as FetchDetails | undefined;
393
- let text = theme.fg("success", "✓ Fetched");
394
- if (details?.title) {
395
- text += theme.fg("muted", `: ${details.title}`);
396
- }
397
- if (details?.truncation?.truncated) {
398
- text += theme.fg("warning", " (truncated)");
399
- }
400
- if (expanded) {
401
- const content = result.content[0];
402
- if (content?.type === "text") {
403
- const lines = content.text.split("\n").slice(0, 15);
404
- for (const line of lines) {
405
- text += `\n ${theme.fg("dim", line)}`;
406
- }
407
- if (content.text.split("\n").length > 15) {
408
- text += `\n ${theme.fg("muted", "... (use read tool to see full content)")}`;
409
- }
410
- }
411
- }
412
- return new Text(text, 0, 0);
413
- },
414
- });
415
-
416
- // =========================================================================
417
- // /web-search-config slash command
418
- // =========================================================================
419
-
420
- pi.registerCommand("web-search-config", {
421
- description: "Configure the Brave Search API key used by web_search/web_fetch",
422
- handler: async (args, ctx) => {
423
- if (!ctx.hasUI) {
424
- ctx.ui?.notify?.("/web-search-config requires interactive mode", "error");
425
- return;
426
- }
427
-
428
- const current = loadConfig();
429
- const showMode = typeof args === "string" && args.includes("--show");
430
-
431
- if (showMode) {
432
- const masked = current.apiKey ? `${current.apiKey.slice(0, 4)}...${current.apiKey.slice(-4)}` : "(not set)";
433
- const envMasked = process.env.BRAVE_SEARCH_API_KEY
434
- ? `${process.env.BRAVE_SEARCH_API_KEY.slice(0, 4)}...${process.env.BRAVE_SEARCH_API_KEY.slice(-4)}`
435
- : "(not set)";
436
- ctx.ui.notify(
437
- `Web search config:\n config file: ${CONFIG_PATH}\n apiKey: ${masked}\n BRAVE_SEARCH_API_KEY env: ${envMasked}`,
438
- "info",
439
- );
440
- return;
441
- }
442
-
443
- const input = await ctx.ui.input(
444
- "Brave Search API key",
445
- current.apiKey ? "(leave empty to keep existing)" : "sk-...",
446
- );
447
-
448
- if (input === undefined || input === null) {
449
- ctx.ui.notify("Web search config unchanged", "info");
450
- return;
451
- }
452
-
453
- const trimmed = input.trim();
454
- if (!trimmed) {
455
- ctx.ui.notify("Web search config unchanged", "info");
456
- return;
457
- }
458
-
459
- saveConfig({ ...current, apiKey: trimmed });
460
- ctx.ui.notify(`Saved Brave API key to ${CONFIG_PATH}`, "info");
461
- },
462
- });
25
+ registerWebSearchTool(pi);
26
+ registerWebFetchTool(pi);
27
+ registerWebSearchConfigCommand(pi);
463
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juicesharp/rpiv-web-tools",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "Pi extension. Web search and fetch for the model, backed by the Brave Search API.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -29,6 +29,7 @@
29
29
  },
30
30
  "files": [
31
31
  "index.ts",
32
+ "web-tools.ts",
32
33
  "README.md",
33
34
  "LICENSE"
34
35
  ],
package/web-tools.ts ADDED
@@ -0,0 +1,645 @@
1
+ /**
2
+ * rpiv-web-tools — body
3
+ *
4
+ * Provides `web_search` and `web_fetch` tools backed by the Brave Search API,
5
+ * plus the `/web-search-config` slash command for API key entry.
6
+ *
7
+ * API key resolution precedence (first wins):
8
+ * 1. BRAVE_SEARCH_API_KEY environment variable
9
+ * 2. apiKey field in ~/.config/rpiv-web-tools/config.json
10
+ */
11
+
12
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { mkdtemp, writeFile } from "node:fs/promises";
14
+ import { homedir, tmpdir } from "node:os";
15
+ import { dirname, join } from "node:path";
16
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
17
+ import {
18
+ DEFAULT_MAX_BYTES,
19
+ DEFAULT_MAX_LINES,
20
+ formatSize,
21
+ type TruncationResult,
22
+ truncateHead,
23
+ } from "@earendil-works/pi-coding-agent";
24
+ import { Text } from "@earendil-works/pi-tui";
25
+ import { Type } from "typebox";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Tunables and external surface
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const BRAVE_SEARCH_API_URL = "https://api.search.brave.com/res/v1/web/search";
32
+ const BRAVE_API_KEY_ENV_VAR = "BRAVE_SEARCH_API_KEY";
33
+
34
+ const MIN_SEARCH_RESULTS = 1;
35
+ const MAX_SEARCH_RESULTS = 10;
36
+ const DEFAULT_SEARCH_RESULTS = 5;
37
+
38
+ const SEARCH_RESULT_PREVIEW_LIMIT = 5;
39
+ const FETCH_PREVIEW_LINE_LIMIT = 15;
40
+ const API_KEY_MASK_VISIBLE_CHARS = 4;
41
+
42
+ const USER_AGENT = "Mozilla/5.0 (compatible; rpiv-pi/1.0)";
43
+ const FETCH_ACCEPT_HEADER = "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5";
44
+
45
+ const FETCH_TEMP_DIR_PREFIX = "rpiv-fetch-";
46
+ const FETCH_TEMP_FILE_NAME = "content.txt";
47
+
48
+ const CONFIG_DIR = join(homedir(), ".config", "rpiv-web-tools");
49
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
50
+ const CONFIG_FILE_MODE = 0o600;
51
+
52
+ const SUPPORTED_HTTP_PROTOCOLS = new Set(["http:", "https:"]);
53
+ const BINARY_CONTENT_TYPE_PREFIXES = ["image/", "video/", "audio/"];
54
+ const HTML_CONTENT_TYPE_TOKEN = "text/html";
55
+
56
+ const SEARCH_BACKEND_NAME = "brave";
57
+ const WEB_SEARCH_CONFIG_COMMAND_NAME = "web-search-config";
58
+ const SHOW_FLAG = "--show";
59
+ const UNSET_LABEL = "(not set)";
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Config file persistence
63
+ // ---------------------------------------------------------------------------
64
+
65
+ interface GuidanceFields {
66
+ promptSnippet?: string;
67
+ promptGuidelines?: string[];
68
+ }
69
+
70
+ interface WebToolsGuidance {
71
+ web_search?: GuidanceFields;
72
+ web_fetch?: GuidanceFields;
73
+ }
74
+
75
+ interface WebToolsConfig {
76
+ apiKey?: string;
77
+ guidance?: WebToolsGuidance;
78
+ }
79
+
80
+ function loadConfig(): WebToolsConfig {
81
+ if (!existsSync(CONFIG_PATH)) return {};
82
+ try {
83
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as WebToolsConfig;
84
+ } catch {
85
+ return {};
86
+ }
87
+ }
88
+
89
+ function saveConfig(config: WebToolsConfig): void {
90
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
91
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
92
+ try {
93
+ chmodSync(CONFIG_PATH, CONFIG_FILE_MODE);
94
+ } catch {
95
+ // chmod may fail on some filesystems — best effort only
96
+ }
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Executor guidance — overrides + defaults
101
+ // ---------------------------------------------------------------------------
102
+
103
+ function validateGuidanceFields(fields: unknown): GuidanceFields {
104
+ if (!fields || typeof fields !== "object") return {};
105
+ const g = fields as Record<string, unknown>;
106
+ const result: GuidanceFields = {};
107
+ if (typeof g.promptSnippet === "string" && g.promptSnippet.length > 0) {
108
+ result.promptSnippet = g.promptSnippet;
109
+ }
110
+ if (
111
+ Array.isArray(g.promptGuidelines) &&
112
+ g.promptGuidelines.length > 0 &&
113
+ g.promptGuidelines.every((s) => typeof s === "string" && s.length > 0)
114
+ ) {
115
+ result.promptGuidelines = g.promptGuidelines;
116
+ }
117
+ return result;
118
+ }
119
+
120
+ export const DEFAULT_WEB_SEARCH_SNIPPET = "Search the web for up-to-date information via Brave";
121
+ export const DEFAULT_WEB_SEARCH_GUIDELINES: string[] = [
122
+ "Use web_search for information beyond your training data — recent events, current library versions, live API documentation.",
123
+ 'Use the current year from "Current date:" in your context when searching for recent information or documentation.',
124
+ 'After answering using search results, include a "Sources:" section listing relevant URLs as markdown hyperlinks: [Title](URL). Never skip this.',
125
+ "Domain filtering is supported to include or block specific websites.",
126
+ "If BRAVE_SEARCH_API_KEY is not set, ask the user to run /web-search-config before proceeding.",
127
+ ];
128
+
129
+ export const DEFAULT_WEB_FETCH_SNIPPET = "Fetch and read content from a specific URL";
130
+ export const DEFAULT_WEB_FETCH_GUIDELINES: string[] = [
131
+ "Use web_fetch to read the full content of a specific URL — documentation pages, blog posts, API references found via web_search.",
132
+ "web_fetch is complementary to web_search: search finds URLs, fetch reads them.",
133
+ 'After answering using fetched content, include a "Sources:" section with a markdown hyperlink to the fetched URL.',
134
+ "Large responses are truncated and spilled to a temp file — the temp path is reported in the result details.",
135
+ ];
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // API key resolution + masking
139
+ // ---------------------------------------------------------------------------
140
+
141
+ function readApiKeyFromEnv(): string | undefined {
142
+ const key = process.env[BRAVE_API_KEY_ENV_VAR];
143
+ return key?.trim() || undefined;
144
+ }
145
+
146
+ function readApiKeyFromConfig(): string | undefined {
147
+ return loadConfig().apiKey?.trim() || undefined;
148
+ }
149
+
150
+ function resolveApiKey(): string | undefined {
151
+ return readApiKeyFromEnv() ?? readApiKeyFromConfig();
152
+ }
153
+
154
+ function maskApiKey(key: string | undefined): string {
155
+ if (!key) return UNSET_LABEL;
156
+ const head = key.slice(0, API_KEY_MASK_VISIBLE_CHARS);
157
+ const tail = key.slice(-API_KEY_MASK_VISIBLE_CHARS);
158
+ return `${head}...${tail}`;
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Brave Search API client
163
+ // ---------------------------------------------------------------------------
164
+
165
+ interface SearchResult {
166
+ title: string;
167
+ url: string;
168
+ snippet: string;
169
+ }
170
+
171
+ interface SearchResponse {
172
+ query: string;
173
+ results: SearchResult[];
174
+ }
175
+
176
+ function buildBraveSearchUrl(query: string, count: number): string {
177
+ const url = new URL(BRAVE_SEARCH_API_URL);
178
+ url.searchParams.set("q", query);
179
+ url.searchParams.set("count", String(count));
180
+ return url.toString();
181
+ }
182
+
183
+ function buildBraveRequestHeaders(apiKey: string): Record<string, string> {
184
+ return {
185
+ Accept: "application/json",
186
+ "Accept-Encoding": "gzip",
187
+ "X-Subscription-Token": apiKey,
188
+ };
189
+ }
190
+
191
+ interface BraveRawResponse {
192
+ web?: { results?: Array<{ title?: string; url?: string; description?: string }> };
193
+ }
194
+
195
+ function normalizeBraveResults(raw: BraveRawResponse): SearchResult[] {
196
+ return (raw.web?.results ?? []).map((r) => ({
197
+ title: r.title ?? "",
198
+ url: r.url ?? "",
199
+ snippet: r.description ?? "",
200
+ }));
201
+ }
202
+
203
+ async function searchBrave(query: string, maxResults: number, signal?: AbortSignal): Promise<SearchResponse> {
204
+ const apiKey = resolveApiKey();
205
+ if (!apiKey) {
206
+ throw new Error(
207
+ `${BRAVE_API_KEY_ENV_VAR} is not set. Run /${WEB_SEARCH_CONFIG_COMMAND_NAME} to configure, or export the env var.`,
208
+ );
209
+ }
210
+
211
+ const res = await fetch(buildBraveSearchUrl(query, maxResults), {
212
+ method: "GET",
213
+ headers: buildBraveRequestHeaders(apiKey),
214
+ signal,
215
+ });
216
+
217
+ if (!res.ok) {
218
+ const text = await res.text();
219
+ throw new Error(`Brave Search API error (${res.status}): ${text}`);
220
+ }
221
+
222
+ const raw = (await res.json()) as BraveRawResponse;
223
+ return { query, results: normalizeBraveResults(raw) };
224
+ }
225
+
226
+ function clampSearchResultCount(requested: number | undefined): number {
227
+ const value = requested ?? DEFAULT_SEARCH_RESULTS;
228
+ return Math.min(Math.max(value, MIN_SEARCH_RESULTS), MAX_SEARCH_RESULTS);
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // HTML-to-text extraction
233
+ // ---------------------------------------------------------------------------
234
+
235
+ const SCRIPT_BLOCK_REGEX = /<script[\s\S]*?<\/script>/gi;
236
+ const STYLE_BLOCK_REGEX = /<style[\s\S]*?<\/style>/gi;
237
+ const NOSCRIPT_BLOCK_REGEX = /<noscript[\s\S]*?<\/noscript>/gi;
238
+ const BLOCK_CLOSER_REGEX =
239
+ /<\/(p|div|h[1-6]|li|tr|br|blockquote|pre|section|article|header|footer|nav|details|summary)>/gi;
240
+ const SELF_CLOSING_BR_REGEX = /<br\s*\/?>/gi;
241
+ const ANY_REMAINING_TAG_REGEX = /<[^>]+>/g;
242
+ const TITLE_TAG_REGEX = /<title[^>]*>([\s\S]*?)<\/title>/i;
243
+ const NUMERIC_HTML_ENTITY_REGEX = /&#(\d+);/g;
244
+ const HORIZONTAL_WHITESPACE_RUN = /[ \t]+/g;
245
+ const BLANK_LINE_RUN = /\n{3,}/g;
246
+
247
+ function stripNonContentBlocks(html: string): string {
248
+ return html.replace(SCRIPT_BLOCK_REGEX, "").replace(STYLE_BLOCK_REGEX, "").replace(NOSCRIPT_BLOCK_REGEX, "");
249
+ }
250
+
251
+ function convertBlockTagsToNewlines(text: string): string {
252
+ return text.replace(BLOCK_CLOSER_REGEX, "\n").replace(SELF_CLOSING_BR_REGEX, "\n");
253
+ }
254
+
255
+ function stripRemainingTags(text: string): string {
256
+ return text.replace(ANY_REMAINING_TAG_REGEX, " ");
257
+ }
258
+
259
+ function decodeHtmlEntities(text: string): string {
260
+ return text
261
+ .replace(/&amp;/g, "&")
262
+ .replace(/&lt;/g, "<")
263
+ .replace(/&gt;/g, ">")
264
+ .replace(/&quot;/g, '"')
265
+ .replace(/&#39;/g, "'")
266
+ .replace(/&nbsp;/g, " ")
267
+ .replace(NUMERIC_HTML_ENTITY_REGEX, (_, code) => String.fromCharCode(Number(code)));
268
+ }
269
+
270
+ function collapseWhitespace(text: string): string {
271
+ return text.replace(HORIZONTAL_WHITESPACE_RUN, " ").replace(BLANK_LINE_RUN, "\n\n");
272
+ }
273
+
274
+ function htmlToText(html: string): string {
275
+ let text = stripNonContentBlocks(html);
276
+ text = convertBlockTagsToNewlines(text);
277
+ text = stripRemainingTags(text);
278
+ text = decodeHtmlEntities(text);
279
+ text = collapseWhitespace(text);
280
+ return text.trim();
281
+ }
282
+
283
+ function extractTitle(html: string): string | undefined {
284
+ const match = html.match(TITLE_TAG_REGEX);
285
+ if (!match) return undefined;
286
+ return match[1].replace(ANY_REMAINING_TAG_REGEX, "").trim() || undefined;
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // URL + content-type guards
291
+ // ---------------------------------------------------------------------------
292
+
293
+ function parseAndAssertHttpUrl(raw: string): URL {
294
+ let parsed: URL;
295
+ try {
296
+ parsed = new URL(raw);
297
+ } catch {
298
+ throw new Error(`Invalid URL: ${raw}`);
299
+ }
300
+ if (!SUPPORTED_HTTP_PROTOCOLS.has(parsed.protocol)) {
301
+ throw new Error(`Unsupported URL protocol: ${parsed.protocol}. Only http and https are supported.`);
302
+ }
303
+ return parsed;
304
+ }
305
+
306
+ function isBinaryContentType(contentType: string): boolean {
307
+ return BINARY_CONTENT_TYPE_PREFIXES.some((prefix) => contentType.includes(prefix));
308
+ }
309
+
310
+ function isHtmlContentType(contentType: string): boolean {
311
+ return contentType.includes(HTML_CONTENT_TYPE_TOKEN);
312
+ }
313
+
314
+ function assertTextContentType(contentType: string): void {
315
+ if (isBinaryContentType(contentType)) {
316
+ throw new Error(`Unsupported content type: ${contentType}. web_fetch supports text pages only.`);
317
+ }
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // web_fetch helpers
322
+ // ---------------------------------------------------------------------------
323
+
324
+ interface FetchDetails {
325
+ url: string;
326
+ title?: string;
327
+ contentType?: string;
328
+ contentLength?: number;
329
+ truncation?: TruncationResult;
330
+ fullOutputPath?: string;
331
+ }
332
+
333
+ function buildFetchRequestInit(signal: AbortSignal | undefined): RequestInit {
334
+ return {
335
+ signal,
336
+ redirect: "follow",
337
+ headers: { "User-Agent": USER_AGENT, Accept: FETCH_ACCEPT_HEADER },
338
+ };
339
+ }
340
+
341
+ async function fetchUrlOrThrow(url: string, signal: AbortSignal | undefined): Promise<Response> {
342
+ const res = await fetch(url, buildFetchRequestInit(signal));
343
+ if (!res.ok) {
344
+ throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
345
+ }
346
+ return res;
347
+ }
348
+
349
+ function parseContentLength(value: string | null): number | undefined {
350
+ return value ? Number(value) : undefined;
351
+ }
352
+
353
+ interface ExtractedBody {
354
+ text: string;
355
+ title?: string;
356
+ }
357
+
358
+ async function extractBodyAsText(res: Response, contentType: string, raw: boolean): Promise<ExtractedBody> {
359
+ const body = await res.text();
360
+ if (!raw && isHtmlContentType(contentType)) {
361
+ return { text: htmlToText(body), title: extractTitle(body) };
362
+ }
363
+ return { text: body };
364
+ }
365
+
366
+ async function spillFullContentToTempFile(content: string): Promise<string> {
367
+ const tempDir = await mkdtemp(join(tmpdir(), FETCH_TEMP_DIR_PREFIX));
368
+ const tempFile = join(tempDir, FETCH_TEMP_FILE_NAME);
369
+ await writeFile(tempFile, content, "utf8");
370
+ return tempFile;
371
+ }
372
+
373
+ function formatTruncationFooter(truncation: TruncationResult, tempFile: string): string {
374
+ const truncatedLines = truncation.totalLines - truncation.outputLines;
375
+ const truncatedBytes = truncation.totalBytes - truncation.outputBytes;
376
+ return (
377
+ `\n\n[Content truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines` +
378
+ ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).` +
379
+ ` ${truncatedLines} lines (${formatSize(truncatedBytes)}) omitted.` +
380
+ ` Full content saved to: ${tempFile}]`
381
+ );
382
+ }
383
+
384
+ function formatFetchHeader(url: string, title: string | undefined, contentType: string): string {
385
+ const lines = [`**Fetched:** ${url}`];
386
+ if (title) lines.push(`**Title:** ${title}`);
387
+ if (contentType) lines.push(`**Content-Type:** ${contentType}`);
388
+ return `${lines.join("\n")}\n\n`;
389
+ }
390
+
391
+ // ---------------------------------------------------------------------------
392
+ // web_search result rendering
393
+ // ---------------------------------------------------------------------------
394
+
395
+ function formatSearchResultsBody(response: SearchResponse): string {
396
+ let text = `**Search results for "${response.query}":**\n\n`;
397
+ response.results.forEach((r, i) => {
398
+ text += `${i + 1}. **${r.title}**\n ${r.url}\n ${r.snippet}\n\n`;
399
+ });
400
+ return text.trimEnd();
401
+ }
402
+
403
+ function buildEmptyResultsEnvelope(query: string) {
404
+ return {
405
+ content: [{ type: "text" as const, text: `No results found for "${query}".` }],
406
+ details: { query, backend: SEARCH_BACKEND_NAME, resultCount: 0 },
407
+ };
408
+ }
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // Tool registrars
412
+ // ---------------------------------------------------------------------------
413
+
414
+ export function registerWebSearchTool(pi: ExtensionAPI): void {
415
+ const guidance = validateGuidanceFields(loadConfig().guidance?.web_search);
416
+
417
+ pi.registerTool({
418
+ name: "web_search",
419
+ label: "Web Search",
420
+ description:
421
+ "Search the web for information via the Brave Search API. Returns a list of results with titles, URLs, and snippets. Use when you need current information not in your training data.",
422
+ promptSnippet: guidance.promptSnippet ?? DEFAULT_WEB_SEARCH_SNIPPET,
423
+ promptGuidelines: guidance.promptGuidelines ?? DEFAULT_WEB_SEARCH_GUIDELINES,
424
+ parameters: Type.Object({
425
+ query: Type.String({
426
+ description: "The search query. Be specific and use natural language.",
427
+ }),
428
+ max_results: Type.Optional(
429
+ Type.Number({
430
+ description: `Maximum number of results to return (${MIN_SEARCH_RESULTS}-${MAX_SEARCH_RESULTS}). Default: ${DEFAULT_SEARCH_RESULTS}.`,
431
+ default: DEFAULT_SEARCH_RESULTS,
432
+ minimum: MIN_SEARCH_RESULTS,
433
+ maximum: MAX_SEARCH_RESULTS,
434
+ }),
435
+ ),
436
+ }),
437
+
438
+ async execute(_toolCallId, params, signal, onUpdate, _ctx) {
439
+ const maxResults = clampSearchResultCount(params.max_results);
440
+
441
+ onUpdate?.({
442
+ content: [{ type: "text", text: `Searching Brave for: "${params.query}"...` }],
443
+ details: { query: params.query, backend: SEARCH_BACKEND_NAME, resultCount: 0 },
444
+ });
445
+
446
+ const response = await searchBrave(params.query, maxResults, signal);
447
+
448
+ if (response.results.length === 0) {
449
+ return buildEmptyResultsEnvelope(params.query);
450
+ }
451
+
452
+ return {
453
+ content: [{ type: "text", text: formatSearchResultsBody(response) }],
454
+ details: {
455
+ query: params.query,
456
+ backend: SEARCH_BACKEND_NAME,
457
+ resultCount: response.results.length,
458
+ results: response.results,
459
+ },
460
+ };
461
+ },
462
+
463
+ renderCall(args, theme, _context) {
464
+ let text = theme.fg("toolTitle", theme.bold("WebSearch "));
465
+ text += theme.fg("accent", `"${args.query}"`);
466
+ return new Text(text, 0, 0);
467
+ },
468
+
469
+ renderResult(result, { expanded, isPartial }, theme, _context) {
470
+ if (isPartial) {
471
+ return new Text(theme.fg("warning", "Searching..."), 0, 0);
472
+ }
473
+ const details = result.details as { resultCount?: number; results?: SearchResult[] };
474
+ const count = details?.resultCount ?? 0;
475
+ let text = theme.fg("success", `✓ ${count} result${count !== 1 ? "s" : ""}`);
476
+ if (expanded && details?.results) {
477
+ text += renderSearchResultsPreview(details.results, theme);
478
+ }
479
+ return new Text(text, 0, 0);
480
+ },
481
+ });
482
+ }
483
+
484
+ function renderSearchResultsPreview(results: SearchResult[], theme: Theme): string {
485
+ let text = "";
486
+ for (const r of results.slice(0, SEARCH_RESULT_PREVIEW_LIMIT)) {
487
+ text += `\n ${theme.fg("dim", `• ${r.title}`)}`;
488
+ }
489
+ if (results.length > SEARCH_RESULT_PREVIEW_LIMIT) {
490
+ text += `\n ${theme.fg("dim", `... and ${results.length - SEARCH_RESULT_PREVIEW_LIMIT} more`)}`;
491
+ }
492
+ return text;
493
+ }
494
+
495
+ export function registerWebFetchTool(pi: ExtensionAPI): void {
496
+ const guidance = validateGuidanceFields(loadConfig().guidance?.web_fetch);
497
+
498
+ pi.registerTool({
499
+ name: "web_fetch",
500
+ label: "Web Fetch",
501
+ description:
502
+ "Fetch the content of a specific URL. Returns text content for HTML pages (tags stripped), raw text for plain text or JSON. Supports http and https only. Content is truncated to avoid overwhelming the context window.",
503
+ promptSnippet: guidance.promptSnippet ?? DEFAULT_WEB_FETCH_SNIPPET,
504
+ promptGuidelines: guidance.promptGuidelines ?? DEFAULT_WEB_FETCH_GUIDELINES,
505
+ parameters: Type.Object({
506
+ url: Type.String({
507
+ description: "The URL to fetch. Must be http or https.",
508
+ }),
509
+ raw: Type.Optional(
510
+ Type.Boolean({
511
+ description: "If true, return the raw HTML instead of extracted text. Default: false.",
512
+ default: false,
513
+ }),
514
+ ),
515
+ }),
516
+
517
+ async execute(_toolCallId, params, signal, onUpdate, _ctx) {
518
+ const { url, raw = false } = params;
519
+ parseAndAssertHttpUrl(url);
520
+
521
+ onUpdate?.({
522
+ content: [{ type: "text", text: `Fetching: ${url}...` }],
523
+ details: { url } as FetchDetails,
524
+ });
525
+
526
+ const res = await fetchUrlOrThrow(url, signal);
527
+ const contentType = res.headers.get("content-type") ?? "";
528
+ assertTextContentType(contentType);
529
+
530
+ const { text: bodyText, title } = await extractBodyAsText(res, contentType, raw);
531
+
532
+ const truncation = truncateHead(bodyText, {
533
+ maxLines: DEFAULT_MAX_LINES,
534
+ maxBytes: DEFAULT_MAX_BYTES,
535
+ });
536
+
537
+ const details: FetchDetails = {
538
+ url,
539
+ title,
540
+ contentType,
541
+ contentLength: parseContentLength(res.headers.get("content-length")),
542
+ };
543
+
544
+ let output = truncation.content;
545
+ if (truncation.truncated) {
546
+ const tempFile = await spillFullContentToTempFile(bodyText);
547
+ details.truncation = truncation;
548
+ details.fullOutputPath = tempFile;
549
+ output += formatTruncationFooter(truncation, tempFile);
550
+ }
551
+
552
+ return {
553
+ content: [{ type: "text", text: formatFetchHeader(url, title, contentType) + output }],
554
+ details,
555
+ };
556
+ },
557
+
558
+ renderCall(args, theme, _context) {
559
+ let text = theme.fg("toolTitle", theme.bold("WebFetch "));
560
+ text += theme.fg("accent", args.url);
561
+ return new Text(text, 0, 0);
562
+ },
563
+
564
+ renderResult(result, { expanded, isPartial }, theme, _context) {
565
+ if (isPartial) {
566
+ return new Text(theme.fg("warning", "Fetching..."), 0, 0);
567
+ }
568
+ const details = result.details as FetchDetails | undefined;
569
+ let text = theme.fg("success", "✓ Fetched");
570
+ if (details?.title) text += theme.fg("muted", `: ${details.title}`);
571
+ if (details?.truncation?.truncated) text += theme.fg("warning", " (truncated)");
572
+ if (expanded) {
573
+ const content = result.content[0];
574
+ if (content?.type === "text") {
575
+ text += renderFetchedContentPreview(content.text, theme);
576
+ }
577
+ }
578
+ return new Text(text, 0, 0);
579
+ },
580
+ });
581
+ }
582
+
583
+ function renderFetchedContentPreview(content: string, theme: Theme): string {
584
+ const lines = content.split("\n");
585
+ const visible = lines.slice(0, FETCH_PREVIEW_LINE_LIMIT);
586
+ let text = "";
587
+ for (const line of visible) {
588
+ text += `\n ${theme.fg("dim", line)}`;
589
+ }
590
+ if (lines.length > FETCH_PREVIEW_LINE_LIMIT) {
591
+ text += `\n ${theme.fg("muted", "... (use read tool to see full content)")}`;
592
+ }
593
+ return text;
594
+ }
595
+
596
+ // ---------------------------------------------------------------------------
597
+ // /web-search-config command
598
+ // ---------------------------------------------------------------------------
599
+
600
+ function formatShowConfigMessage(current: WebToolsConfig): string {
601
+ return (
602
+ `Web search config:\n` +
603
+ ` config file: ${CONFIG_PATH}\n` +
604
+ ` apiKey: ${maskApiKey(current.apiKey)}\n` +
605
+ ` ${BRAVE_API_KEY_ENV_VAR} env: ${maskApiKey(process.env[BRAVE_API_KEY_ENV_VAR])}`
606
+ );
607
+ }
608
+
609
+ export function registerWebSearchConfigCommand(pi: ExtensionAPI): void {
610
+ pi.registerCommand(WEB_SEARCH_CONFIG_COMMAND_NAME, {
611
+ description: "Configure the Brave Search API key used by web_search/web_fetch",
612
+ handler: async (args, ctx) => {
613
+ if (!ctx.hasUI) {
614
+ ctx.ui?.notify?.(`/${WEB_SEARCH_CONFIG_COMMAND_NAME} requires interactive mode`, "error");
615
+ return;
616
+ }
617
+
618
+ const current = loadConfig();
619
+
620
+ if (typeof args === "string" && args.includes(SHOW_FLAG)) {
621
+ ctx.ui.notify(formatShowConfigMessage(current), "info");
622
+ return;
623
+ }
624
+
625
+ const input = await ctx.ui.input(
626
+ "Brave Search API key",
627
+ current.apiKey ? "(leave empty to keep existing)" : "sk-...",
628
+ );
629
+
630
+ if (input === undefined || input === null) {
631
+ ctx.ui.notify("Web search config unchanged", "info");
632
+ return;
633
+ }
634
+
635
+ const trimmed = input.trim();
636
+ if (!trimmed) {
637
+ ctx.ui.notify("Web search config unchanged", "info");
638
+ return;
639
+ }
640
+
641
+ saveConfig({ ...current, apiKey: trimmed });
642
+ ctx.ui.notify(`Saved Brave API key to ${CONFIG_PATH}`, "info");
643
+ },
644
+ });
645
+ }