opencode-websearch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # opencode-websearch
2
+
3
+ Web search plugin for [OpenCode](https://opencode.ai), powered by Anthropic's server-side `web_search` API. A port of the Claude Code WebSearch tool to OpenCode.
4
+
5
+ ## Install
6
+
7
+ Add the plugin to your `opencode.json`:
8
+
9
+ ```json
10
+ {
11
+ "plugin": ["opencode-websearch"]
12
+ }
13
+ ```
14
+
15
+ OpenCode will install it automatically at startup.
16
+
17
+ ## Configuration
18
+
19
+ The plugin needs an Anthropic API key. It resolves credentials in this order:
20
+
21
+ 1. **opencode.json** -- looks for an `@ai-sdk/anthropic` provider in your project or global config
22
+ 2. **Environment variable** -- falls back to `ANTHROPIC_API_KEY`
23
+
24
+ ### Option 1: opencode.json provider (recommended)
25
+
26
+ If you already have an Anthropic provider configured, the plugin will use it automatically:
27
+
28
+ ```json
29
+ {
30
+ "provider": {
31
+ "anthropic": {
32
+ "npm": "@ai-sdk/anthropic",
33
+ "options": {
34
+ "apiKey": "{env:ANTHROPIC_API_KEY}"
35
+ },
36
+ "models": {
37
+ "claude-sonnet-4-5": { "name": "Claude Sonnet 4.5" }
38
+ }
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ The model used for search is the first model listed in the provider config.
45
+
46
+ ### Option 2: environment variable
47
+
48
+ ```sh
49
+ export ANTHROPIC_API_KEY=sk-ant-...
50
+ ```
51
+
52
+ When using the env var fallback, `claude-sonnet-4-5` is used as the default model.
53
+
54
+ ## Usage
55
+
56
+ Once configured, the `web-search` tool is available to the AI agent. It accepts:
57
+
58
+ | Argument | Type | Required | Description |
59
+ |---|---|---|---|
60
+ | `query` | `string` | Yes | The search query |
61
+ | `allowed_domains` | `string[]` | No | Restrict results to these domains |
62
+ | `blocked_domains` | `string[]` | No | Exclude results from these domains |
63
+ | `max_uses` | `number` (1-10) | No | Max searches per invocation (default: 5) |
64
+
65
+ You cannot specify both `allowed_domains` and `blocked_domains` at the same time.
66
+
67
+ Results are returned as formatted markdown with source links.
68
+
69
+ ## Development
70
+
71
+ ```sh
72
+ # Install dependencies
73
+ bun install
74
+
75
+ # Type check
76
+ bun run typecheck
77
+
78
+ # Build
79
+ bun run build
80
+ ```
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,21 @@
1
+ declare const _default: () => Promise<{
2
+ tool: {
3
+ "web-search": {
4
+ description: string;
5
+ args: {
6
+ allowed_domains: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
7
+ blocked_domains: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
8
+ max_uses: import("zod").ZodOptional<import("zod").ZodNumber>;
9
+ query: import("zod").ZodString;
10
+ };
11
+ execute(args: {
12
+ query: string;
13
+ allowed_domains?: string[] | undefined;
14
+ blocked_domains?: string[] | undefined;
15
+ max_uses?: number | undefined;
16
+ }, context: import("@opencode-ai/plugin").ToolContext): Promise<string>;
17
+ };
18
+ };
19
+ }>;
20
+ export default _default;
21
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;AAobA,wBAoDqB"}
package/dist/index.js ADDED
@@ -0,0 +1,315 @@
1
+ // src/index.ts
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { tool } from "@opencode-ai/plugin";
6
+ import Anthropic, { APIError } from "@anthropic-ai/sdk";
7
+ var DEFAULT_MODEL = "claude-sonnet-4-5";
8
+ var MONTH_OFFSET = 1;
9
+ var PAD_LENGTH = 2;
10
+ var ENV_VAR_CAPTURE_GROUP = 1;
11
+ var FIRST_MODEL_INDEX = 0;
12
+ var EMPTY_LENGTH = 0;
13
+ var MIN_QUERY_LENGTH = 2;
14
+ var MIN_SEARCH_USES = 1;
15
+ var MAX_SEARCH_USES = 10;
16
+ var DEFAULT_SEARCH_USES = 5;
17
+ var MAX_RESPONSE_TOKENS = 16000;
18
+ var getTodayDate = () => {
19
+ const now = new Date;
20
+ const year = now.getFullYear();
21
+ const month = String(now.getMonth() + MONTH_OFFSET).padStart(PAD_LENGTH, "0");
22
+ const day = String(now.getDate()).padStart(PAD_LENGTH, "0");
23
+ return `${year}-${month}-${day}`;
24
+ };
25
+ var resolveEnvVar = (value) => {
26
+ const match = value.match(/^\{env:(\w+)\}$/);
27
+ if (match?.[ENV_VAR_CAPTURE_GROUP]) {
28
+ return process.env[match[ENV_VAR_CAPTURE_GROUP]] ?? "";
29
+ }
30
+ return value;
31
+ };
32
+ var normalizeBaseURL = (url) => url.replace(/\/v1\/?$/, "");
33
+ var CONFIG_PATHS = [
34
+ join(process.cwd(), "opencode.json"),
35
+ join(process.cwd(), ".opencode", "opencode.json"),
36
+ join(homedir(), ".config", "opencode", "opencode.json")
37
+ ];
38
+ var parseConfigFile = (configPath) => {
39
+ try {
40
+ return JSON.parse(readFileSync(configPath, "utf8"));
41
+ } catch (error) {
42
+ if (error instanceof SyntaxError) {
43
+ return `Failed to parse ${configPath}: ${error.message}`;
44
+ }
45
+ return `Failed to parse ${configPath}: ${String(error)}`;
46
+ }
47
+ };
48
+ var resolveProviderApiKey = (ctx, rawApiKey) => {
49
+ const apiKey = resolveEnvVar(rawApiKey);
50
+ if (apiKey) {
51
+ return apiKey;
52
+ }
53
+ const envMatch = rawApiKey.match(/^\{env:(\w+)\}$/);
54
+ if (envMatch) {
55
+ ctx.errors.push(`${ctx.configPath}: Environment variable ${envMatch[ENV_VAR_CAPTURE_GROUP]} is not set`);
56
+ } else {
57
+ ctx.errors.push(`${ctx.configPath}: Provider "${ctx.providerName}" has empty apiKey`);
58
+ }
59
+ return;
60
+ };
61
+ var resolveModelName = (provider) => {
62
+ if (!provider.models) {
63
+ return DEFAULT_MODEL;
64
+ }
65
+ const models = Object.keys(provider.models);
66
+ return models[FIRST_MODEL_INDEX] ?? DEFAULT_MODEL;
67
+ };
68
+ var resolveBaseURL = (provider) => {
69
+ if (!provider.options?.baseURL) {
70
+ return;
71
+ }
72
+ return normalizeBaseURL(resolveEnvVar(provider.options.baseURL));
73
+ };
74
+ var resolveProviderConfig = (ctx, provider) => {
75
+ if (provider.npm !== "@ai-sdk/anthropic") {
76
+ return;
77
+ }
78
+ if (!provider.options?.apiKey) {
79
+ ctx.errors.push(`${ctx.configPath}: Provider "${ctx.providerName}" has no apiKey configured`);
80
+ return;
81
+ }
82
+ const apiKey = resolveProviderApiKey(ctx, provider.options.apiKey);
83
+ if (!apiKey) {
84
+ return;
85
+ }
86
+ return {
87
+ apiKey,
88
+ baseURL: resolveBaseURL(provider),
89
+ model: resolveModelName(provider)
90
+ };
91
+ };
92
+ var scanProviders = (configPath, providers, errors) => {
93
+ for (const [providerName, provider] of Object.entries(providers)) {
94
+ const ctx = { configPath, errors, providerName };
95
+ const resolved = resolveProviderConfig(ctx, provider);
96
+ if (resolved) {
97
+ return resolved;
98
+ }
99
+ }
100
+ return;
101
+ };
102
+ var scanConfigFile = (configPath, errors) => {
103
+ if (!existsSync(configPath)) {
104
+ return;
105
+ }
106
+ const parsed = parseConfigFile(configPath);
107
+ if (typeof parsed === "string") {
108
+ errors.push(parsed);
109
+ return;
110
+ }
111
+ if (!parsed.provider) {
112
+ errors.push(`${configPath}: No "provider" field found`);
113
+ return;
114
+ }
115
+ return scanProviders(configPath, parsed.provider, errors);
116
+ };
117
+ var scanAllConfigFiles = (errors) => {
118
+ for (const configPath of CONFIG_PATHS) {
119
+ const resolved = scanConfigFile(configPath, errors);
120
+ if (resolved) {
121
+ return resolved;
122
+ }
123
+ }
124
+ return;
125
+ };
126
+ var getEnvFallback = () => {
127
+ const envApiKey = process.env.ANTHROPIC_API_KEY;
128
+ if (envApiKey) {
129
+ return {
130
+ config: {
131
+ apiKey: envApiKey,
132
+ model: DEFAULT_MODEL
133
+ }
134
+ };
135
+ }
136
+ return;
137
+ };
138
+ var getAnthropicConfig = () => {
139
+ const errors = [];
140
+ const fromConfig = scanAllConfigFiles(errors);
141
+ if (fromConfig) {
142
+ return { config: fromConfig };
143
+ }
144
+ const fromEnv = getEnvFallback();
145
+ if (fromEnv) {
146
+ return fromEnv;
147
+ }
148
+ if (errors.length > EMPTY_LENGTH) {
149
+ return { config: null, error: errors.join(`
150
+ `) };
151
+ }
152
+ return { config: null };
153
+ };
154
+ var formatSearchResult = (result) => {
155
+ if (result.page_age) {
156
+ return `- [${result.title}](${result.url}) (Updated: ${result.page_age})`;
157
+ }
158
+ return `- [${result.title}](${result.url})`;
159
+ };
160
+ var processSearchToolResult = (block, results) => {
161
+ if (Array.isArray(block.content)) {
162
+ const searchResults = block.content;
163
+ if (searchResults.length === EMPTY_LENGTH) {
164
+ results.push("No results found.");
165
+ } else {
166
+ results.push(`
167
+ Found ${searchResults.length} results:
168
+ `);
169
+ for (const result of searchResults) {
170
+ results.push(formatSearchResult(result));
171
+ }
172
+ }
173
+ } else if (block.content?.type === "web_search_tool_result_error") {
174
+ results.push(`Search error: ${block.content.error_code}`);
175
+ }
176
+ };
177
+ var processBlock = (block, results) => {
178
+ if (block.type === "server_tool_use" && block.name === "web_search") {
179
+ results.push(`Search query: "${block.input.query}"`);
180
+ return;
181
+ }
182
+ if (block.type === "web_search_tool_result") {
183
+ processSearchToolResult(block, results);
184
+ return;
185
+ }
186
+ if (block.type === "text" && block.text) {
187
+ results.push(`
188
+ ${block.text}`);
189
+ }
190
+ };
191
+ var buildWebSearchTool = (args) => {
192
+ const searchTool = {
193
+ max_uses: args.max_uses ?? DEFAULT_SEARCH_USES,
194
+ name: "web_search",
195
+ type: "web_search_20250305"
196
+ };
197
+ if (args.allowed_domains?.length) {
198
+ searchTool.allowed_domains = args.allowed_domains;
199
+ }
200
+ if (args.blocked_domains?.length) {
201
+ searchTool.blocked_domains = args.blocked_domains;
202
+ }
203
+ return searchTool;
204
+ };
205
+ var formatConfigError = (error) => {
206
+ let hint = "";
207
+ if (error) {
208
+ hint = `
209
+
210
+ ${error}`;
211
+ }
212
+ return `Error: web-search requires an Anthropic API key.
213
+
214
+ Set the ANTHROPIC_API_KEY environment variable, or add an Anthropic provider to your opencode.json:
215
+
216
+ {
217
+ "provider": {
218
+ "anthropic": {
219
+ "npm": "@ai-sdk/anthropic",
220
+ "options": {
221
+ "apiKey": "{env:ANTHROPIC_API_KEY}"
222
+ },
223
+ "models": {
224
+ "claude-sonnet-4-5": { "name": "Claude Sonnet 4.5" }
225
+ }
226
+ }
227
+ }
228
+ }${hint}`;
229
+ };
230
+ var formatErrorMessage = (error) => {
231
+ if (error instanceof APIError) {
232
+ return `Anthropic API error: ${error.message} (status: ${error.status})`;
233
+ }
234
+ if (error instanceof Error) {
235
+ return `Error performing web search: ${error.message}`;
236
+ }
237
+ return `Error performing web search: ${String(error)}`;
238
+ };
239
+ var createAnthropicClient = (config) => {
240
+ const options = {
241
+ apiKey: config.apiKey
242
+ };
243
+ if (config.baseURL) {
244
+ options.baseURL = config.baseURL;
245
+ }
246
+ return new Anthropic(options);
247
+ };
248
+ var appendUsageInfo = (usage, results) => {
249
+ if (usage.server_tool_use?.web_search_requests) {
250
+ results.push(`
251
+ ---
252
+ Searches performed: ${usage.server_tool_use.web_search_requests}`);
253
+ }
254
+ };
255
+ var executeSearch = async (config, args) => {
256
+ const client = createAnthropicClient(config);
257
+ const webSearchTool = buildWebSearchTool(args);
258
+ const response = await client.messages.create({
259
+ max_tokens: MAX_RESPONSE_TOKENS,
260
+ messages: [
261
+ {
262
+ content: `Perform a web search for: ${args.query}`,
263
+ role: "user"
264
+ }
265
+ ],
266
+ model: config.model,
267
+ tools: [webSearchTool]
268
+ });
269
+ const results = [];
270
+ const content = response.content;
271
+ for (const block of content) {
272
+ processBlock(block, results);
273
+ }
274
+ appendUsageInfo(response.usage, results);
275
+ return results.join(`
276
+ `) || "No results returned from web search.";
277
+ };
278
+ var src_default = async () => ({
279
+ tool: {
280
+ "web-search": tool({
281
+ args: {
282
+ allowed_domains: tool.schema.array(tool.schema.string()).optional().describe("Only include results from these domains"),
283
+ blocked_domains: tool.schema.array(tool.schema.string()).optional().describe("Exclude results from these domains"),
284
+ max_uses: tool.schema.number().min(MIN_SEARCH_USES).max(MAX_SEARCH_USES).optional().describe("Maximum number of searches to perform (default: 5)"),
285
+ query: tool.schema.string().min(MIN_QUERY_LENGTH).describe("The search query to execute")
286
+ },
287
+ description: `Search the web using Anthropic's server-side web_search API.
288
+
289
+ - Provides up-to-date information for current events and recent data
290
+ - Returns search results with links as markdown hyperlinks
291
+ - Use this for accessing information beyond the knowledge cutoff
292
+
293
+ CRITICAL: After answering, you MUST include a "Sources:" section with URLs as markdown hyperlinks.
294
+
295
+ Today's date: ${getTodayDate()}. Use the current year when searching for recent information.`,
296
+ async execute(args) {
297
+ const { config, error } = getAnthropicConfig();
298
+ if (!config) {
299
+ return formatConfigError(error);
300
+ }
301
+ if (args.allowed_domains && args.blocked_domains) {
302
+ return "Error: Cannot specify both allowed_domains and blocked_domains.";
303
+ }
304
+ try {
305
+ return await executeSearch(config, args);
306
+ } catch (error2) {
307
+ return formatErrorMessage(error2);
308
+ }
309
+ }
310
+ })
311
+ }
312
+ });
313
+ export {
314
+ src_default as default
315
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "opencode-websearch",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code's WebSearch tool ported to OpenCode",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "build": "bun build src/index.ts --outdir dist --target node --format esm --packages external && bun run build:types",
19
+ "build:types": "tsc --emitDeclarationOnly --declaration --outDir dist",
20
+ "dev": "bun run --watch src/index.ts",
21
+ "lint": "oxlint --tsconfig tsconfig.json src/",
22
+ "typecheck": "tsc --noEmit",
23
+ "check": "bun run lint && bun run typecheck",
24
+ "prepublishOnly": "bun run check && bun run build"
25
+ },
26
+ "keywords": [
27
+ "opencode",
28
+ "opencode-plugin",
29
+ "web-search",
30
+ "anthropic",
31
+ "ai"
32
+ ],
33
+ "license": "MIT",
34
+ "dependencies": {
35
+ "@anthropic-ai/sdk": "^0.52.0"
36
+ },
37
+ "devDependencies": {
38
+ "@opencode-ai/plugin": "^1.1.65",
39
+ "@types/bun": "latest",
40
+ "oxlint": "^1.47.0",
41
+ "typescript": "^5.8.0"
42
+ },
43
+ "peerDependencies": {
44
+ "@opencode-ai/plugin": ">=1.0.0"
45
+ }
46
+ }