From e5ce3406d2485c2632a62beb7532a5eae9667756 Mon Sep 17 00:00:00 2001 From: Erik Engervall Date: Tue, 15 Sep 2026 23:13:16 +0900 Subject: [PATCH] feat(core): add firecrawl devsearch tool Adds a connection-gated `devsearch` tool backed by Firecrawl's `firecrawl_developer_search` MCP tool. It searches an index of repositories, GitHub issues, merged pull requests, READMEs, and curated documentation and returns the matched passages. It is a separate tool rather than a web search provider, so it never replaces `websearch` or joins the provider pool. Sessions only see it once the Firecrawl integration has an active connection (saved key or FIRECRAWL_API_KEY). The Firecrawl MCP request code is shared with the existing web search provider through `WebSearchFirecrawl.call`. Co-Authored-By: Claude Fable 5.1 --- packages/cli/src/acp/permission.ts | 1 + packages/core/src/plugin/agent.ts | 1 + packages/core/src/plugin/internal.ts | 2 + .../core/src/plugin/websearch/firecrawl.ts | 44 ++-- packages/core/src/tool/plugin/devsearch.ts | 103 ++++++++ packages/core/src/v1/config/permission.ts | 1 + packages/core/test/location-layer.test.ts | 2 + packages/core/test/tool-devsearch.test.ts | 223 ++++++++++++++++++ .../src/components/tool-error-card.tsx | 1 + .../session-ui/src/tools/tool-renderer.tsx | 33 ++- packages/tui/src/mini/tool.ts | 27 +++ packages/tui/src/routes/session/index.tsx | 17 ++ packages/tui/src/util/permission.ts | 9 + packages/tui/test/util/permission.test.ts | 6 + packages/ui/src/i18n/en.ts | 1 + packages/web/src/content/docs/permissions.mdx | 1 + packages/web/src/content/docs/tools.mdx | 25 ++ 17 files changed, 477 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/tool/plugin/devsearch.ts create mode 100644 packages/core/test/tool-devsearch.test.ts diff --git a/packages/cli/src/acp/permission.ts b/packages/cli/src/acp/permission.ts index ab62a8770779..a514c154d540 100644 --- a/packages/cli/src/acp/permission.ts +++ b/packages/cli/src/acp/permission.ts @@ -156,6 +156,7 @@ function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyA case "webfetch": return stringValue(input.url) case "websearch": + case "devsearch": return stringValue(input.query) case "grep": case "glob": diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index d0d12713ea41..affe565823e1 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -119,6 +119,7 @@ export const Plugin = define({ { action: "glob", resource: "*", effect: "allow" }, { action: "webfetch", resource: "*", effect: "allow" }, { action: "websearch", resource: "*", effect: "allow" }, + { action: "devsearch", resource: "*", effect: "allow" }, { action: "read", resource: "*", effect: "allow" }, { action: "read", resource: "*.env", effect: "ask" }, { action: "read", resource: "*.env.*", effect: "ask" }, diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index e450b82b77c5..f7eeccbc914e 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -65,6 +65,7 @@ import { Skill } from "../skill.js" import { SkillDiscovery } from "../skill/discovery.js" import { Watcher } from "../filesystem/watcher.js" import { PatchTool } from "../tool/plugin/patch.js" +import { DevSearchTool } from "../tool/plugin/devsearch.js" import { EditTool } from "../tool/plugin/edit.js" import { GlobTool } from "../tool/plugin/glob.js" import { GrepTool } from "../tool/plugin/grep.js" @@ -218,6 +219,7 @@ const pre = [ // Render model prompts after the patch plugin selects the available editing tools. ...OptimizePlugin.Plugins, IdentityPlugin.Plugin, + DevSearchTool.Plugin, EditTool.Plugin, GlobTool.Plugin, GrepTool.Plugin, diff --git a/packages/core/src/plugin/websearch/firecrawl.ts b/packages/core/src/plugin/websearch/firecrawl.ts index 1ea260e0c5da..94c196e5db1f 100644 --- a/packages/core/src/plugin/websearch/firecrawl.ts +++ b/packages/core/src/plugin/websearch/firecrawl.ts @@ -1,12 +1,13 @@ export * as WebSearchFirecrawl from "./firecrawl.js" -import { define } from "@opencode/plugin/effect/plugin" +import { define, type Context } from "@opencode/plugin/effect/plugin" import { Effect, Option, Schema, Scope } from "effect" import { HttpClient } from "effect/unstable/http" import { App } from "../../app.js" import { WebSearchMcp } from "./mcp.js" export const endpoint = "https://mcp.firecrawl.dev/v2/mcp" +export const integrationID = "firecrawl" const McpInput = Schema.Struct({ query: Schema.String, @@ -33,18 +34,36 @@ const SearchResponse = Schema.fromJsonString( ) const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse) +/** Calls a Firecrawl MCP tool with the active connection's credential and returns its text content. */ +export const call = ( + ctx: Context, + http: HttpClient.HttpClient, + tool: string, + input: Schema.Struct, + value: Schema.Struct.Type, +) => + Effect.gen(function* () { + const connection = yield* ctx.integration.connection.active(integrationID) + const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined + const result = yield* WebSearchMcp.call(http, endpoint, tool, { input, output: McpOutput }, value, { + "User-Agent": App.useragent(ctx.app), + ...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}), + }) + return result?.content.find((item) => item.text)?.text + }) + export const Plugin = define({ id: "opencode.websearch.firecrawl", effect: Effect.fn("WebSearchFirecrawl.Plugin")(function* (ctx) { const http = yield* HttpClient.HttpClient yield* ctx.integration.transform((editor) => { - editor.update("firecrawl", (integration) => (integration.name = "Firecrawl")) + editor.update(integrationID, (integration) => (integration.name = "Firecrawl")) editor.method.update({ - integrationID: "firecrawl", + integrationID, method: { type: "key" }, }) editor.method.update({ - integrationID: "firecrawl", + integrationID, method: { type: "env", names: ["FIRECRAWL_API_KEY"] }, }) }) @@ -54,21 +73,8 @@ export const Plugin = define({ name: "Firecrawl", execute: (input) => Effect.gen(function* () { - const connection = yield* ctx.integration.connection.active("firecrawl") - const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined - const result = yield* WebSearchMcp.call( - http, - endpoint, - "firecrawl_search", - { input: McpInput, output: McpOutput }, - { query: input.query, limit: 8 }, - { - "User-Agent": App.useragent(ctx.app), - ...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}), - }, - ) - const content = result?.content.find((item) => item.text) - const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined + const text = yield* call(ctx, http, "firecrawl_search", McpInput, { query: input.query, limit: 8 }) + const response = text ? Option.getOrUndefined(decodeSearchResponse(text)) : undefined return ( response?.data.web.map((item) => ({ url: item.url, diff --git a/packages/core/src/tool/plugin/devsearch.ts b/packages/core/src/tool/plugin/devsearch.ts new file mode 100644 index 000000000000..7aaa031e58fb --- /dev/null +++ b/packages/core/src/tool/plugin/devsearch.ts @@ -0,0 +1,103 @@ +export * as DevSearchTool from "./devsearch.js" + +import type { Context } from "@opencode/plugin/effect/plugin" +import type { SessionHooks } from "@opencode/plugin/effect/session" +import { ToolFailure } from "@opencode/ai" +import { Effect, Schema } from "effect" +import { HttpClient, HttpClientError } from "effect/unstable/http" +import { Permission } from "../../permission.js" +import { WebSearchFirecrawl } from "../../plugin/websearch/firecrawl.js" + +export const name = "devsearch" +export const NO_RESULTS = "No developer search results found. Please try a different query." +export const NOT_CONNECTED = + "Developer search needs a Firecrawl connection. Connect the Firecrawl integration to enable it." +const httpErrors = new Map([ + [429, "Developer search rate limited (HTTP 429)"], + [401, "Developer search authentication failed (HTTP 401)"], +]) + +export const description = `Search an index built for coding agents: repositories, GitHub issues, merged pull requests, READMEs, and curated documentation sites. Use this for how a library or API behaves, what an error message means, or whether a bug was fixed. Results include the matched passages. + +Prefer this over websearch for programming questions. Use websearch for current events and anything outside software.` + +export const Input = Schema.Struct({ + query: Schema.String.annotate({ + description: "Developer question or search phrase, including the library, error message, or API involved", + }), +}) + +const McpInput = Schema.Struct({ + query: Schema.String, + k: Schema.Number.pipe(Schema.optional), +}) + +const Output = Schema.Struct({ + output: Schema.String, +}) + +export const Plugin = { + id: "opencode.tool.devsearch", + effect: Effect.fn("DevSearchTool.Plugin")(function* (ctx: Context) { + const http = yield* HttpClient.HttpClient + const permission = yield* Permission.Service + // An exported FIRECRAWL_API_KEY resolves as an active connection, so it counts as connected too. + const connected = ctx.integration.connection.active(WebSearchFirecrawl.integrationID).pipe( + Effect.map((connection) => connection !== undefined), + Effect.orElseSucceed(() => false), + ) + + yield* ctx.tool + .transform((editor) => + editor.add({ + name, + options: { codemode: false }, + description, + input: Input, + output: Output, + execute: (input, context) => + Effect.gen(function* () { + if (!(yield* connected)) return yield* new ToolFailure({ message: NOT_CONNECTED }) + yield* permission.assert({ + action: name, + resources: [input.query], + save: ["*"], + metadata: input, + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.messageID, id: context.id }, + }) + const text = yield* WebSearchFirecrawl.call(ctx, http, "firecrawl_developer_search", McpInput, { + query: input.query, + k: 8, + }) + const output = text?.trim() || NO_RESULTS + return { output: { output }, content: output } + }).pipe( + Effect.mapError((error) => { + if (error instanceof ToolFailure) return error + const status = HttpClientError.isHttpClientError(error) ? error.response?.status : undefined + return new ToolFailure({ + message: + status === undefined + ? `Unable to search developer sources for ${input.query}` + : (httpErrors.get(status) ?? `Developer search request failed (HTTP ${status})`), + error, + }) + }), + ), + }), + ) + .pipe(Effect.orDie) + + // Sessions only see the tool once Firecrawl is connected; a stale tool list still fails clearly above. + const hook = (event: SessionHooks["context"]) => + Effect.gen(function* () { + if (yield* connected) return + delete event.tools[name] + }) + yield* ctx.session.hook("context", hook) + yield* ctx.session.hook("compaction", hook) + yield* ctx.session.hook("generate", hook) + }), +} diff --git a/packages/core/src/v1/config/permission.ts b/packages/core/src/v1/config/permission.ts index 450ae5daa40f..d488f242d5a2 100644 --- a/packages/core/src/v1/config/permission.ts +++ b/packages/core/src/v1/config/permission.ts @@ -27,6 +27,7 @@ const InputObject = Schema.StructWithRest( question: Schema.optional(Action), webfetch: Schema.optional(Rule), websearch: Schema.optional(Action), + devsearch: Schema.optional(Action), lsp: Schema.optional(Rule), doom_loop: Schema.optional(Action), skill: Schema.optional(Rule), diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 718385c2ac33..a31e1d1ebd38 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -483,6 +483,7 @@ describe("LocationServiceMap", () => { expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false) const blockedTools = blockedState.tools.map((tool) => tool.name) expect(blockedTools.filter((name) => name !== "execute").sort()).toEqual([ + "devsearch", "edit", "glob", "grep", @@ -502,6 +503,7 @@ describe("LocationServiceMap", () => { const allowedTools = allowedState.tools.map((tool) => tool.name) expect(blockedTools.includes("execute")).toBe(allowedTools.includes("execute")) expect(allowedTools.filter((name) => name !== "execute").sort()).toEqual([ + "devsearch", "edit", "glob", "grep", diff --git a/packages/core/test/tool-devsearch.test.ts b/packages/core/test/tool-devsearch.test.ts new file mode 100644 index 000000000000..1b0bb7cdce24 --- /dev/null +++ b/packages/core/test/tool-devsearch.test.ts @@ -0,0 +1,223 @@ +import { beforeEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" +import { LayerNode } from "@opencode/util/effect/layer-node" +import { LayerNodePlatform } from "@opencode/util/effect/app-node-platform" +import { Bus } from "@opencode/core/bus" +import { Config } from "@opencode/core/config" +import { Credential } from "@opencode/core/credential" +import { Form } from "@opencode/core/form" +import { Image } from "@opencode/core/image" +import { Integration } from "@opencode/core/integration" +import { Model } from "@opencode/core/model" +import { Permission } from "@opencode/core/permission" +import { WebSearchFirecrawl } from "@opencode/core/plugin/websearch/firecrawl" +import { Provider } from "@opencode/core/provider" +import { Session } from "@opencode/core/session" +import { Tool } from "@opencode/core/tool" +import { DevSearchTool } from "@opencode/core/tool/plugin/devsearch" +import { WebSearch } from "@opencode/core/websearch" +import type { SessionHooks } from "@opencode/plugin/effect/session" +import { makeLocationNode } from "@opencode/util/effect/app-node" +import { testEffect } from "./lib/effect" +import { imagePassthrough } from "./lib/image" +import { permissionLayer } from "./lib/permission" +import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" +import { host, integrationHost, webSearchHost } from "./plugin/host" + +const sessionID = Session.ID.make("ses_devsearch_test") +const integrationID = Integration.ID.make(WebSearchFirecrawl.integrationID) +const passages = + "## [issue:effect-ts/effect#1234] (issue) Retrying HttpClient requests\nhttps://github.com/effect-ts/effect/issues/1234\nUse Effect.retry with a Schedule." +const requests: Array<{ readonly url: string; readonly headers: Record; readonly body: unknown }> = [] +const assertions: Permission.AssertInput[] = [] +const hooks = new Map Effect.Effect>() +let response = { body: "", status: 200 } + +const mcp = (text: string) => + `event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: 1, result: { content: [{ type: "text", text }] } })}\n\n` + +const http = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`) + requests.push({ + url: request.url, + headers: request.headers, + body: JSON.parse(new TextDecoder().decode(request.body.body)), + }) + return HttpClientResponse.fromWeb(request, new Response(response.body, { status: response.status })) + }), + ), +) + +const devSearchToolNode = makeLocationNode({ + name: "test/devsearch-tool-plugin", + layer: Layer.effectDiscard( + Effect.gen(function* () { + const integrations = yield* Integration.Service + const websearch = yield* WebSearch.Service + // The web search provider registers the Firecrawl integration and its key method; devsearch only reads the connection. + yield* WebSearchFirecrawl.Plugin.effect( + host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }), + ) + yield* registerToolPlugin(DevSearchTool.Plugin, { + integration: integrationHost(integrations), + session: { + hook: (name, callback) => + Effect.sync(() => { + hooks.set(name, callback as (event: SessionHooks["context"]) => Effect.Effect) + return { dispose: Effect.void } + }), + }, + }) + }), + ), + deps: [ + Tool.node, + Permission.node, + Integration.node, + Credential.node, + Bus.node, + Form.node, + WebSearch.node, + LayerNodePlatform.httpClient, + ], +}) + +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Tool.node, Integration.node, devSearchToolNode]), [ + Permission.node.replace(permissionLayer({ assert: (input) => Effect.sync(() => assertions.push(input)) })), + Image.node.replace(imagePassthrough), + Config.node.replace(Config.testLayer()), + LayerNodePlatform.httpClient.replace(http), + ]), +) + +beforeEach(() => { + requests.length = 0 + assertions.length = 0 + hooks.clear() + response = { body: mcp(passages), status: 200 } +}) + +const call = (query: string, id = "call-devsearch") => ({ + sessionID, + ...toolIdentity, + call: { type: "tool-call" as const, id, name: DevSearchTool.name, input: { query } }, +}) + +const context = (): SessionHooks["context"] => ({ + sessionID, + agent: toolIdentity.agent, + model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("test") }), + system: [], + messages: [], + tools: Object.fromEntries( + [DevSearchTool.name, "websearch"].map((name) => [name, { description: name, input: { type: "object" } }]), + ), + options: {}, +}) + +const connect = Effect.gen(function* () { + const integrations = yield* Integration.Service + yield* integrations.connection.key({ integrationID, key: "fc-secret" }) +}) + +describe("DevSearchTool", () => { + it.effect("hides the tool from sessions until Firecrawl is connected", () => + Effect.gen(function* () { + const registry = yield* Tool.Service + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toContain(DevSearchTool.name) + expect([...hooks.keys()].sort()).toEqual(["compaction", "context", "generate"]) + + const hidden = context() + yield* hooks.get("context")!(hidden) + expect(Object.keys(hidden.tools)).toEqual(["websearch"]) + + yield* connect + const shown = context() + yield* hooks.get("generate")!(shown) + expect(Object.keys(shown.tools).sort()).toEqual([DevSearchTool.name, "websearch"]) + }), + ) + + it.effect("treats FIRECRAWL_API_KEY as a connection", () => + Effect.gen(function* () { + const previous = process.env.FIRECRAWL_API_KEY + process.env.FIRECRAWL_API_KEY = "fc-env" + const event = context() + yield* hooks.get("compaction")!(event).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env.FIRECRAWL_API_KEY + else process.env.FIRECRAWL_API_KEY = previous + }), + ), + ) + expect(Object.keys(event.tools)).toContain(DevSearchTool.name) + }), + ) + + it.effect("fails without a request when Firecrawl is not connected", () => + Effect.gen(function* () { + const registry = yield* Tool.Service + expect(yield* executeTool(registry, call("effect retry"))).toMatchObject({ + status: "error", + error: { type: "tool.execution", message: DevSearchTool.NOT_CONNECTED }, + }) + expect(requests).toHaveLength(0) + expect(assertions).toHaveLength(0) + }), + ) + + it.effect("asserts permission and returns the developer index passages", () => + Effect.gen(function* () { + yield* connect + const registry = yield* Tool.Service + expect(yield* executeTool(registry, call("effect retry"))).toMatchObject({ + status: "completed", + output: { output: passages }, + content: [{ type: "text", text: passages }], + }) + expect(assertions).toMatchObject([{ action: DevSearchTool.name, resources: ["effect retry"] }]) + expect(requests).toMatchObject([ + { + url: WebSearchFirecrawl.endpoint, + headers: { authorization: "Bearer fc-secret" }, + body: { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "firecrawl_developer_search", arguments: { query: "effect retry", k: 8 } }, + }, + }, + ]) + }), + ) + + it.effect("reports an empty index response as no results", () => + Effect.gen(function* () { + yield* connect + response = { body: mcp(""), status: 200 } + const registry = yield* Tool.Service + expect(yield* executeTool(registry, call("nothing here"))).toMatchObject({ + status: "completed", + output: { output: DevSearchTool.NO_RESULTS }, + content: [{ type: "text", text: DevSearchTool.NO_RESULTS }], + }) + }), + ) + + it.effect("surfaces HTTP failures as tool errors", () => + Effect.gen(function* () { + yield* connect + response = { body: "Rate limited", status: 429 } + const registry = yield* Tool.Service + expect(yield* executeTool(registry, call("effect retry"))).toMatchObject({ status: "error" }) + expect(requests).toHaveLength(1) + }), + ) +}) diff --git a/packages/session-ui/src/components/tool-error-card.tsx b/packages/session-ui/src/components/tool-error-card.tsx index 80bf8ab16c51..bf384f3f4121 100644 --- a/packages/session-ui/src/components/tool-error-card.tsx +++ b/packages/session-ui/src/components/tool-error-card.tsx @@ -52,6 +52,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) { subagent: "ui.tool.agent.default", webfetch: "ui.tool.webfetch", websearch: "ui.tool.websearch", + devsearch: "ui.tool.devsearch", shell: "ui.tool.shell", execute: "ui.tool.execute", patch: "ui.tool.patch", diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index 3eb2bec70203..162ace602cf0 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -339,6 +339,12 @@ export function getToolInfo( title: webSearchProviderLabel(metadata?.provider, i18n), subtitle: typeof input.query === "string" ? input.query : undefined, } + case "devsearch": + return { + icon: "window-cursor", + title: i18n.t("ui.tool.devsearch"), + subtitle: typeof input.query === "string" ? input.query : undefined, + } case "subagent": { const raw = input.agent const type = typeof raw === "string" && raw ? raw[0].toUpperCase() + raw.slice(1) : undefined @@ -1271,7 +1277,7 @@ function toolErrorSubtitle(props: ToolProps, i18n: UiI18n) { if (props.tool === "list" || props.tool === "glob" || props.tool === "grep") return displayDirectory(text(props.input.path) ?? "/") if (props.tool === "webfetch") return text(props.input.url) - if (props.tool === "websearch") return text(props.input.query) + if (props.tool === "websearch" || props.tool === "devsearch") return text(props.input.query) if (props.tool === "skill") return skillToolName(props.input, props.metadata) if (props.tool === "patch") { const count = new Set( @@ -1552,6 +1558,31 @@ ToolRegistry.register({ ) }, }) +ToolRegistry.register({ + name: "devsearch", + render(props) { + const i18n = useI18n() + const query = createMemo(() => { + const value = props.input.query + if (typeof value !== "string") return "" + return value + }) + + return ( + + + + ) + }, +}) ToolRegistry.register({ name: "subagent", render(props) { diff --git a/packages/tui/src/mini/tool.ts b/packages/tui/src/mini/tool.ts index c2afca933686..4132dfe134bf 100644 --- a/packages/tui/src/mini/tool.ts +++ b/packages/tui/src/mini/tool.ts @@ -125,6 +125,7 @@ type ToolName = | "lsp" | "webfetch" | "websearch" + | "devsearch" | "skill" type ToolRule = { @@ -423,6 +424,13 @@ function runWebSearch(p: ToolProps): ToolInline { } } +function runDevSearch(p: ToolProps): ToolInline { + return { + icon: "◈", + title: p.input.query ? `Developer Search "${p.input.query}"` : "Developer Search", + } +} + function runTask(p: ToolProps): ToolInline { const kind = Locale.titlecase(p.input.agent || "unknown") const desc = p.input.description @@ -887,6 +895,15 @@ function scrollWebSearchStart(p: ToolProps): string { return `◈ ${title} "${query}"` } +function scrollDevSearchStart(p: ToolProps): string { + const query = p.input.query ?? "" + if (!query) { + return "◈ Developer Search" + } + + return `◈ Developer Search "${query}"` +} + const TOOL_RULES = { invalid: { view: { @@ -1054,6 +1071,16 @@ const TOOL_RULES = { start: scrollWebSearchStart, }, }, + devsearch: { + view: { + output: false, + final: false, + }, + run: runDevSearch, + scroll: { + start: scrollDevSearchStart, + }, + }, skill: { view: { output: false, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index c833c9fea2dc..a061c1c31ae3 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2488,6 +2488,9 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean } + + + @@ -3163,6 +3166,19 @@ function WebSearch(props: ToolProps) { ) } +function DevSearch(props: ToolProps) { + return ( + + Developer Search "{stringValue(props.input.query)}" + + ) +} + function Subagent(props: ToolProps) { const { navigate } = useRoute() const data = useData() @@ -3580,6 +3596,7 @@ const toolDisplays = new Set([ "grep", "webfetch", "websearch", + "devsearch", "write", "edit", "subagent", diff --git a/packages/tui/src/util/permission.ts b/packages/tui/src/util/permission.ts index 09d607ff373e..7cd1582536d6 100644 --- a/packages/tui/src/util/permission.ts +++ b/packages/tui/src/util/permission.ts @@ -101,6 +101,15 @@ export function permissionPresentation( } } + if (action === "devsearch") { + const query = text(input.query) || text(metadata.query) + return { + icon: "◈", + title: query ? `Developer Search "${query}"` : "Developer Search", + lines: query ? [`Query: ${query}`] : [], + } + } + if (action === "lsp") { const file = text(input.path) const operation = text(input.operation) || "request" diff --git a/packages/tui/test/util/permission.test.ts b/packages/tui/test/util/permission.test.ts index 4bd7c7cae42a..ae18044a1b9f 100644 --- a/packages/tui/test/util/permission.test.ts +++ b/packages/tui/test/util/permission.test.ts @@ -20,4 +20,10 @@ test("preserves permission roots and self-contained metadata", () => { lines: ["Query: releases"], }, ) + expect( + permissionPresentation({ action: "devsearch", resources: [], metadata: { query: "effect retry" } }), + ).toMatchObject({ + title: 'Developer Search "effect retry"', + lines: ["Query: effect retry"], + }) }) diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index b40e18bc5659..1091967e1f9f 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -190,6 +190,7 @@ const source = { "ui.tool.webfetch": "Webfetch", "ui.tool.websearch": "Web Search", "ui.tool.websearch.provider": "{{provider}} Web Search", + "ui.tool.devsearch": "Developer Search", "ui.tool.shell": "Shell", "ui.tool.shell.writingCommand": "Writing command…", "ui.tool.shell.exit": "Command exited with code {{code}}", diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index eb8975a882c7..35ae7da62a0a 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -164,6 +164,7 @@ OpenCode permissions are keyed by tool name, plus a couple of safety guards: - `question` — asking the user questions during execution - `webfetch` — fetching a URL (matches the URL) - `websearch` — web search +- `devsearch` — developer search (requires a Firecrawl connection) - `external_directory` — triggered when a tool touches paths outside the project working directory - `doom_loop` — triggered when the same tool call repeats 3 times with identical input diff --git a/packages/web/src/content/docs/tools.mdx b/packages/web/src/content/docs/tools.mdx index e2ea6c258ce1..a4b2e5f808d3 100644 --- a/packages/web/src/content/docs/tools.mdx +++ b/packages/web/src/content/docs/tools.mdx @@ -265,6 +265,31 @@ Use `websearch` when you need to find information (discovery), and `webfetch` wh --- +### devsearch + +Search developer sources. + +:::note +This tool is only available when the Firecrawl integration is connected, either through the `/connect` command or the `FIRECRAWL_API_KEY` environment variable. It is separate from `websearch` and does not take part in web search provider selection. +::: + +```json title="opencode.json" {4} +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "devsearch": "allow" + } +} +``` + +Searches an index built for coding agents: repositories, GitHub issues, merged pull requests, READMEs, and curated documentation sites. Results include the matched passages, so the model gets the relevant documentation excerpt or issue thread rather than a page summary. + +:::tip +Use `devsearch` for how a library or API behaves, what an error message means, or whether a bug was fixed. Use `websearch` for everything else. +::: + +--- + ### question Ask the user questions during execution.