summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax <[email protected]>2025-09-18 03:58:21 -0400
committerGitHub <[email protected]>2025-09-18 03:58:21 -0400
commit3b6c0ec0b3b4f2224311888d8f7be178ed2335da (patch)
tree33d2de1a0f3860add12f1211d23b75565eba3e9e
parente9d902d84471faff3daf62455d6bc97750e4dc15 (diff)
downloadopencode-3b6c0ec0b3b4f2224311888d8f7be178ed2335da.tar.gz
opencode-3b6c0ec0b3b4f2224311888d8f7be178ed2335da.zip
support custom tools (#2668)
-rw-r--r--.opencode/tool/foo.ts11
-rw-r--r--bun.lock1
-rw-r--r--packages/opencode/src/config/config.ts21
-rw-r--r--packages/opencode/src/plugin/index.ts17
-rw-r--r--packages/opencode/src/server/server.ts48
-rw-r--r--packages/opencode/src/tool/registry.ts135
-rw-r--r--packages/opencode/src/tool/tool.ts2
-rw-r--r--packages/opencode/test/tool/register.test.ts298
-rw-r--r--packages/plugin/package.json7
-rw-r--r--packages/plugin/src/example.ts20
-rw-r--r--packages/plugin/src/index.ts42
-rw-r--r--packages/plugin/src/tool.ts20
12 files changed, 137 insertions, 485 deletions
diff --git a/.opencode/tool/foo.ts b/.opencode/tool/foo.ts
new file mode 100644
index 000000000..3d350de10
--- /dev/null
+++ b/.opencode/tool/foo.ts
@@ -0,0 +1,11 @@
+import z from "zod/v4"
+
+export default {
+ description: "foo tool for fooing",
+ args: {
+ foo: z.string().describe("foo"),
+ },
+ async execute() {
+ return "Hey fuck you!"
+ },
+}
diff --git a/bun.lock b/bun.lock
index 18bab7024..48c8ea5b6 100644
--- a/bun.lock
+++ b/bun.lock
@@ -183,6 +183,7 @@
"version": "0.9.11",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
+ "zod": "catalog:",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 7d86845e3..387dbd65d 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -48,6 +48,14 @@ export namespace Config {
}
result.agent = result.agent || {}
+
+ const directories = [
+ Global.Path.config,
+ ...(await Array.fromAsync(
+ Filesystem.up({ targets: [".opencode"], start: Instance.directory, stop: Instance.worktree }),
+ )),
+ ]
+
const markdownAgents = [
...(await Filesystem.globUp("agent/**/*.md", Global.Path.config, Global.Path.config)),
...(await Filesystem.globUp(".opencode/agent/**/*.md", Instance.directory, Instance.worktree)),
@@ -203,7 +211,10 @@ export namespace Config {
result.keybinds.agent_cycle_reverse = result.keybinds.switch_agent_reverse
}
- return result
+ return {
+ config: result,
+ directories,
+ }
})
export const McpLocal = z
@@ -655,7 +666,11 @@ export namespace Config {
}),
)
- export function get() {
- return state()
+ export async function get() {
+ return state().then((x) => x.config)
+ }
+
+ export async function directories() {
+ return state().then((x) => x.directories)
}
}
diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts
index 272d66a7e..b82faf4ab 100644
--- a/packages/opencode/src/plugin/index.ts
+++ b/packages/opencode/src/plugin/index.ts
@@ -1,4 +1,4 @@
-import type { Hooks, Plugin as PluginInstance } from "@opencode-ai/plugin"
+import type { Hooks, PluginInput, Plugin as PluginInstance } from "@opencode-ai/plugin"
import { Config } from "../config/config"
import { Bus } from "../bus"
import { Log } from "../util/log"
@@ -7,7 +7,6 @@ import { Server } from "../server/server"
import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
-import { ToolRegistry } from "../tool/registry"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
@@ -19,14 +18,12 @@ export namespace Plugin {
})
const config = await Config.get()
const hooks = []
- const input = {
+ const input: PluginInput = {
client,
project: Instance.project,
worktree: Instance.worktree,
directory: Instance.directory,
$: Bun.$,
- Tool: await import("../tool/tool").then((m) => m.Tool),
- z: await import("zod").then((m) => m.z),
}
const plugins = [...(config.plugin ?? [])]
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
@@ -53,7 +50,7 @@ export namespace Plugin {
})
export async function trigger<
- Name extends Exclude<keyof Required<Hooks>, "auth" | "event">,
+ Name extends Exclude<keyof Required<Hooks>, "auth" | "event" | "tool">,
Input = Parameters<Required<Hooks>[Name]>[0],
Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output): Promise<Output> {
@@ -78,14 +75,6 @@ export namespace Plugin {
const config = await Config.get()
for (const hook of hooks) {
await hook.config?.(config)
- // Let plugins register tools at startup
- await hook["tool.register"]?.(
- {},
- {
- registerHTTP: ToolRegistry.registerHTTP,
- register: ToolRegistry.register,
- },
- )
}
Bus.subscribeAll(async (input) => {
const hooks = await state().then((x) => x.hooks)
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 3a5d794c5..95cfa6b66 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -52,29 +52,6 @@ const ERRORS = {
export namespace Server {
const log = Log.create({ service: "server" })
- // Schemas for HTTP tool registration
- const HttpParamSpec = z
- .object({
- type: z.enum(["string", "number", "boolean", "array"]),
- description: z.string().optional(),
- optional: z.boolean().optional(),
- items: z.enum(["string", "number", "boolean"]).optional(),
- })
- .meta({ ref: "HttpParamSpec" })
-
- const HttpToolRegistration = z
- .object({
- id: z.string(),
- description: z.string(),
- parameters: z.object({
- type: z.literal("object"),
- properties: z.record(z.string(), HttpParamSpec),
- }),
- callbackUrl: z.string(),
- headers: z.record(z.string(), z.string()).optional(),
- })
- .meta({ ref: "HttpToolRegistration" })
-
export const Event = {
Connected: Bus.event("server.connected", z.object({})),
}
@@ -153,29 +130,6 @@ export namespace Server {
return c.json(await Config.get())
},
)
- .post(
- "/experimental/tool/register",
- describeRoute({
- description: "Register a new HTTP callback tool",
- operationId: "tool.register",
- responses: {
- 200: {
- description: "Tool registered successfully",
- content: {
- "application/json": {
- schema: resolver(z.boolean()),
- },
- },
- },
- ...ERRORS,
- },
- }),
- validator("json", HttpToolRegistration),
- async (c) => {
- ToolRegistry.registerHTTP(c.req.valid("json"))
- return c.json(true)
- },
- )
.get(
"/experimental/tool/ids",
describeRoute({
@@ -194,7 +148,7 @@ export namespace Server {
},
}),
async (c) => {
- return c.json(ToolRegistry.ids())
+ return c.json(await ToolRegistry.ids())
},
)
.get(
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index 379ca542b..9f2ce223e 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -1,4 +1,3 @@
-import z from "zod/v4"
import { BashTool } from "./bash"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
@@ -13,6 +12,12 @@ import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
import type { Agent } from "../agent/agent"
import { Tool } from "./tool"
+import { Instance } from "../project/instance"
+import { Config } from "../config/config"
+import path from "path"
+import { type ToolDefinition } from "@opencode-ai/plugin"
+import z from "zod/v4"
+import { Plugin } from "../plugin"
export namespace ToolRegistry {
// Built-in tools that ship with opencode
@@ -32,101 +37,71 @@ export namespace ToolRegistry {
TaskTool,
]
- // Extra tools registered at runtime (via plugins)
- const EXTRA: Tool.Info[] = []
+ export const state = Instance.state(async () => {
+ const custom = [] as Tool.Info[]
+ const glob = new Bun.Glob("tool/*.{js,ts}")
- // Tools registered via HTTP callback (via SDK/API)
- const HTTP: Tool.Info[] = []
-
- export type HttpParamSpec = {
- type: "string" | "number" | "boolean" | "array"
- description?: string
- optional?: boolean
- items?: "string" | "number" | "boolean"
- }
- export type HttpToolRegistration = {
- id: string
- description: string
- parameters: {
- type: "object"
- properties: Record<string, HttpParamSpec>
+ for (const dir of await Config.directories()) {
+ for await (const match of glob.scan({ cwd: dir, absolute: true })) {
+ const namespace = path.basename(match, path.extname(match))
+ const mod = await import(match)
+ for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
+ custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
+ }
+ }
}
- callbackUrl: string
- headers?: Record<string, string>
- }
- function buildZodFromHttpSpec(spec: HttpToolRegistration["parameters"]) {
- const shape: Record<string, z.ZodTypeAny> = {}
- for (const [key, val] of Object.entries(spec.properties)) {
- let base: z.ZodTypeAny
- switch (val.type) {
- case "string":
- base = z.string()
- break
- case "number":
- base = z.number()
- break
- case "boolean":
- base = z.boolean()
- break
- case "array":
- if (!val.items) throw new Error(`array spec for ${key} requires 'items'`)
- base = z.array(val.items === "string" ? z.string() : val.items === "number" ? z.number() : z.boolean())
- break
- default:
- base = z.any()
+ const plugins = await Plugin.list()
+ for (const plugin of plugins) {
+ for (const [id, def] of Object.entries(plugin.tool ?? {})) {
+ custom.push(fromPlugin(id, def))
}
- if (val.description) base = base.describe(val.description)
- shape[key] = val.optional ? base.optional() : base
}
- return z.object(shape)
- }
- export function register(tool: Tool.Info) {
- // Prevent duplicates by id (replace existing)
- const idx = EXTRA.findIndex((t) => t.id === tool.id)
- if (idx >= 0) EXTRA.splice(idx, 1, tool)
- else EXTRA.push(tool)
+ return { custom }
+ })
+
+ function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
+ return {
+ id,
+ init: async () => ({
+ parameters: z.object(def.args),
+ description: def.description,
+ execute: async (args, ctx) => {
+ const result = await def.execute(args as any, ctx)
+ return {
+ title: "",
+ output: result,
+ metadata: {},
+ }
+ },
+ }),
+ }
}
- export function registerHTTP(input: HttpToolRegistration) {
- const parameters = buildZodFromHttpSpec(input.parameters)
- const info = Tool.define(input.id, {
- description: input.description,
- parameters,
- async execute(args) {
- const res = await fetch(input.callbackUrl, {
- method: "POST",
- headers: { "content-type": "application/json", ...(input.headers ?? {}) },
- body: JSON.stringify({ args }),
- })
- if (!res.ok) {
- throw new Error(`HTTP tool callback failed: ${res.status} ${await res.text()}`)
- }
- const json = (await res.json()) as { title?: string; output: string; metadata?: Record<string, any> }
- return {
- title: json.title ?? input.id,
- output: json.output ?? "",
- metadata: (json.metadata ?? {}) as any,
- }
- },
- })
- const idx = HTTP.findIndex((t) => t.id === info.id)
- if (idx >= 0) HTTP.splice(idx, 1, info)
- else HTTP.push(info)
+ export async function register(tool: Tool.Info) {
+ const { custom } = await state()
+ const idx = custom.findIndex((t) => t.id === tool.id)
+ if (idx >= 0) {
+ custom.splice(idx, 1, tool)
+ return
+ }
+ custom.push(tool)
}
- function allTools(): Tool.Info[] {
- return [...BUILTIN, ...EXTRA, ...HTTP]
+ async function all(): Promise<Tool.Info[]> {
+ const custom = await state().then((x) => x.custom)
+ return [...BUILTIN, ...custom]
}
- export function ids() {
- return allTools().map((t) => t.id)
+ export async function ids() {
+ return all().then((x) => x.map((t) => t.id))
}
export async function tools(_providerID: string, _modelID: string) {
+ const tools = await all()
const result = await Promise.all(
- allTools().map(async (t) => ({
+ tools.map(async (t) => ({
id: t.id,
...(await t.init()),
})),
diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts
index 6e2b95112..a372a69d7 100644
--- a/packages/opencode/src/tool/tool.ts
+++ b/packages/opencode/src/tool/tool.ts
@@ -8,8 +8,8 @@ export namespace Tool {
sessionID: string
messageID: string
agent: string
- callID?: string
abort: AbortSignal
+ callID?: string
extra?: { [key: string]: any }
metadata(input: { title?: string; metadata?: M }): void
}
diff --git a/packages/opencode/test/tool/register.test.ts b/packages/opencode/test/tool/register.test.ts
deleted file mode 100644
index 351eb91d1..000000000
--- a/packages/opencode/test/tool/register.test.ts
+++ /dev/null
@@ -1,298 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import path from "path"
-import os from "os"
-import { Instance } from "../../src/project/instance"
-
-// Helper to create a Request targeting the in-memory Hono app
-function makeRequest(method: string, url: string, body?: any) {
- const headers: Record<string, string> = { "content-type": "application/json" }
- const init: RequestInit = { method, headers }
- if (body !== undefined) init.body = JSON.stringify(body)
- return new Request(url, init)
-}
-
-describe("HTTP tool registration API", () => {
- test("POST /tool/register then list via /tool/ids and /tool", async () => {
- const projectRoot = path.join(__dirname, "../..")
- await Instance.provide(projectRoot, async () => {
- const { Server } = await import("../../src/server/server")
-
- const toolSpec = {
- id: "http-echo",
- description: "Simple echo tool (test-only)",
- parameters: {
- type: "object" as const,
- properties: {
- foo: { type: "string" as const, optional: true },
- bar: { type: "number" as const },
- },
- },
- callbackUrl: "http://localhost:9999/echo",
- }
-
- // Register
- const registerRes = await Server.App().fetch(
- makeRequest("POST", "http://localhost:4096/experimental/tool/register", toolSpec),
- )
- expect(registerRes.status).toBe(200)
- const ok = await registerRes.json()
- expect(ok).toBe(true)
-
- // IDs should include the new tool
- const idsRes = await Server.App().fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids"))
- expect(idsRes.status).toBe(200)
- const ids = (await idsRes.json()) as string[]
- expect(ids).toContain("http-echo")
-
- // List tools for a provider/model and check JSON Schema shape
- const listRes = await Server.App().fetch(
- makeRequest("GET", "http://localhost:4096/experimental/tool?provider=openai&model=gpt-4o"),
- )
- expect(listRes.status).toBe(200)
- const list = (await listRes.json()) as Array<{ id: string; description: string; parameters: any }>
- const found = list.find((t) => t.id === "http-echo")
- expect(found).toBeTruthy()
- expect(found!.description).toBe("Simple echo tool (test-only)")
-
- // Basic JSON Schema checks
- expect(found!.parameters?.type).toBe("object")
- expect(found!.parameters?.properties?.bar?.type).toBe("number")
-
- const foo = found!.parameters?.properties?.foo
- // optional -> nullable for OpenAI/Azure providers; accept either type array including null or nullable: true
- const fooIsNullable = Array.isArray(foo?.type) ? foo.type.includes("null") : foo?.nullable === true
- expect(fooIsNullable).toBe(true)
- })
- })
-})
-
-describe("Plugin tool.register hook", () => {
- test("Plugin registers tool during Plugin.init()", async () => {
- // Create a temporary project directory with opencode.json that points to our plugin
- const tmpDir = path.join(os.tmpdir(), `opencode-test-project-${Date.now()}`)
- await Bun.$`mkdir -p ${tmpDir}`
-
- const tmpPluginPath = path.join(tmpDir, `test-plugin-${Date.now()}.ts`)
- const pluginCode = `
- export async function TestPlugin() {
- return {
- async ["tool.register"](_input, { registerHTTP }) {
- registerHTTP({
- id: "from-plugin",
- description: "Registered from test plugin",
- parameters: { type: "object", properties: { name: { type: "string", optional: true } } },
- callbackUrl: "http://localhost:9999/echo"
- })
- }
- }
- }
- `
- await Bun.write(tmpPluginPath, pluginCode)
-
- const configPath = path.join(tmpDir, "opencode.json")
- await Bun.write(configPath, JSON.stringify({ plugin: ["file://" + tmpPluginPath] }, null, 2))
-
- await Instance.provide(tmpDir, async () => {
- const { Plugin } = await import("../../src/plugin")
- const { ToolRegistry } = await import("../../src/tool/registry")
- const { Server } = await import("../../src/server/server")
-
- // Initialize plugins (will invoke our tool.register hook)
- await Plugin.init()
-
- // Confirm the tool is registered
- const allIDs = ToolRegistry.ids()
- expect(allIDs).toContain("from-plugin")
-
- // Also verify via the HTTP surface
- const idsRes = await Server.App().fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids"))
- expect(idsRes.status).toBe(200)
- const ids = (await idsRes.json()) as string[]
- expect(ids).toContain("from-plugin")
- })
- })
-})
-
-test("Multiple plugins can each register tools", async () => {
- const tmpDir = path.join(os.tmpdir(), `opencode-test-project-multi-${Date.now()}`)
- await Bun.$`mkdir -p ${tmpDir}`
-
- // Create two plugin files
- const pluginAPath = path.join(tmpDir, `plugin-a-${Date.now()}.ts`)
- const pluginBPath = path.join(tmpDir, `plugin-b-${Date.now()}.ts`)
- const pluginA = `
- export async function PluginA() {
- return {
- async ["tool.register"](_input, { registerHTTP }) {
- registerHTTP({
- id: "alpha-tool",
- description: "Alpha tool",
- parameters: { type: "object", properties: { a: { type: "string", optional: true } } },
- callbackUrl: "http://localhost:9999/echo"
- })
- }
- }
- }
- `
- const pluginB = `
- export async function PluginB() {
- return {
- async ["tool.register"](_input, { registerHTTP }) {
- registerHTTP({
- id: "beta-tool",
- description: "Beta tool",
- parameters: { type: "object", properties: { b: { type: "number", optional: true } } },
- callbackUrl: "http://localhost:9999/echo"
- })
- }
- }
- }
- `
- await Bun.write(pluginAPath, pluginA)
- await Bun.write(pluginBPath, pluginB)
-
- // Config with both plugins
- await Bun.write(
- path.join(tmpDir, "opencode.json"),
- JSON.stringify({ plugin: ["file://" + pluginAPath, "file://" + pluginBPath] }, null, 2),
- )
-
- await Instance.provide(tmpDir, async () => {
- const { Plugin } = await import("../../src/plugin")
- const { ToolRegistry } = await import("../../src/tool/registry")
- const { Server } = await import("../../src/server/server")
-
- await Plugin.init()
-
- const ids = ToolRegistry.ids()
- expect(ids).toContain("alpha-tool")
- expect(ids).toContain("beta-tool")
-
- const res = await Server.App().fetch(new Request("http://localhost:4096/experimental/tool/ids"))
- expect(res.status).toBe(200)
- const httpIds = (await res.json()) as string[]
- expect(httpIds).toContain("alpha-tool")
- expect(httpIds).toContain("beta-tool")
- })
-})
-
-test("Plugin registers native/local tool with function execution", async () => {
- const tmpDir = path.join(os.tmpdir(), `opencode-test-project-native-${Date.now()}`)
- await Bun.$`mkdir -p ${tmpDir}`
-
- const pluginPath = path.join(tmpDir, `plugin-native-${Date.now()}.ts`)
- const pluginCode = `
- export async function NativeToolPlugin({ $, Tool, z }) {
- // Use z (zod) provided by the plugin system
-
- // Define a native tool using Tool.define from plugin input
- const MyNativeTool = Tool.define("my-native-tool", {
- description: "A native tool that runs local code",
- parameters: z.object({
- message: z.string().describe("Message to process"),
- count: z.number().optional().describe("Repeat count").default(1)
- }),
- async execute(args, ctx) {
- // This runs locally in the plugin process, not via HTTP!
- const result = args.message.repeat(args.count)
- const output = \`Processed: \${result}\`
-
- // Can also run shell commands directly
- const hostname = await $\`hostname\`.text()
-
- return {
- title: "Native Tool Result",
- output: output + " on " + hostname.trim(),
- metadata: { processedAt: new Date().toISOString() }
- }
- }
- })
-
- return {
- async ["tool.register"](_input, { register, registerHTTP }) {
- // Register our native tool
- register(MyNativeTool)
-
- // Can also register HTTP tools in the same plugin
- registerHTTP({
- id: "http-tool-from-same-plugin",
- description: "HTTP tool alongside native tool",
- parameters: { type: "object", properties: {} },
- callbackUrl: "http://localhost:9999/echo"
- })
- }
- }
- }
- `
- await Bun.write(pluginPath, pluginCode)
-
- await Bun.write(path.join(tmpDir, "opencode.json"), JSON.stringify({ plugin: ["file://" + pluginPath] }, null, 2))
-
- await Instance.provide(tmpDir, async () => {
- const { Plugin } = await import("../../src/plugin")
- const { ToolRegistry } = await import("../../src/tool/registry")
- const { Server } = await import("../../src/server/server")
-
- await Plugin.init()
-
- // Both tools should be registered
- const ids = ToolRegistry.ids()
- expect(ids).toContain("my-native-tool")
- expect(ids).toContain("http-tool-from-same-plugin")
-
- // Verify via HTTP endpoint
- const res = await Server.App().fetch(new Request("http://localhost:4096/experimental/tool/ids"))
- expect(res.status).toBe(200)
- const httpIds = (await res.json()) as string[]
- expect(httpIds).toContain("my-native-tool")
- expect(httpIds).toContain("http-tool-from-same-plugin")
-
- // Get tool details to verify native tool has proper structure
- const toolsRes = await Server.App().fetch(
- new Request("http://localhost:4096/experimental/tool?provider=anthropic&model=claude"),
- )
- expect(toolsRes.status).toBe(200)
- const tools = (await toolsRes.json()) as any[]
- const nativeTool = tools.find((t) => t.id === "my-native-tool")
- expect(nativeTool).toBeTruthy()
- expect(nativeTool.description).toBe("A native tool that runs local code")
- expect(nativeTool.parameters.properties.message).toBeTruthy()
- expect(nativeTool.parameters.properties.count).toBeTruthy()
- })
-})
-
-// Malformed plugin (no tool.register) should not throw and should not register anything
-test("Plugin without tool.register is handled gracefully", async () => {
- const tmpDir = path.join(os.tmpdir(), `opencode-test-project-noreg-${Date.now()}`)
- await Bun.$`mkdir -p ${tmpDir}`
-
- const pluginPath = path.join(tmpDir, `plugin-noreg-${Date.now()}.ts`)
- const pluginSrc = `
- export async function NoRegisterPlugin() {
- return {
- // no tool.register hook provided
- async config(_cfg) { /* noop */ }
- }
- }
- `
- await Bun.write(pluginPath, pluginSrc)
-
- await Bun.write(path.join(tmpDir, "opencode.json"), JSON.stringify({ plugin: ["file://" + pluginPath] }, null, 2))
-
- await Instance.provide(tmpDir, async () => {
- const { Plugin } = await import("../../src/plugin")
- const { ToolRegistry } = await import("../../src/tool/registry")
- const { Server } = await import("../../src/server/server")
-
- await Plugin.init()
-
- // Ensure our specific id isn't present
- const ids = ToolRegistry.ids()
- expect(ids).not.toContain("malformed-tool")
-
- const res = await Server.App().fetch(new Request("http://localhost:4096/experimental/tool/ids"))
- expect(res.status).toBe(200)
- const httpIds = (await res.json()) as string[]
- expect(httpIds).not.toContain("malformed-tool")
- })
-})
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index 39355a1b1..1fc45014f 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -10,13 +10,18 @@
".": {
"development": "./src/index.ts",
"import": "./dist/index.js"
+ },
+ "./tool": {
+ "development": "./src/tool.ts",
+ "import": "./dist/tool.js"
}
},
"files": [
"dist"
],
"dependencies": {
- "@opencode-ai/sdk": "workspace:*"
+ "@opencode-ai/sdk": "workspace:*",
+ "zod": "catalog:"
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
diff --git a/packages/plugin/src/example.ts b/packages/plugin/src/example.ts
index 04c48c918..fd6a404d9 100644
--- a/packages/plugin/src/example.ts
+++ b/packages/plugin/src/example.ts
@@ -1,14 +1,20 @@
import { Plugin } from "./index"
+import { tool } from "./tool"
-export const ExamplePlugin: Plugin = async ({
- client: _client,
- $: _shell,
- project: _project,
- directory: _directory,
- worktree: _worktree,
-}) => {
+export const ExamplePlugin: Plugin = async (ctx) => {
return {
permission: {},
+ tool: {
+ mytool: tool((zod) => ({
+ description: "This is a custom tool tool",
+ args: {
+ foo: zod.string(),
+ },
+ async execute(args, ctx) {
+ return `Hello ${args.foo}!`
+ },
+ })),
+ },
async "chat.params"(_input, output) {
output.topP = 1
},
diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts
index f8b6d46f7..9c2647c60 100644
--- a/packages/plugin/src/index.ts
+++ b/packages/plugin/src/index.ts
@@ -10,7 +10,11 @@ import type {
Auth,
Config,
} from "@opencode-ai/sdk"
+
import type { BunShell } from "./shell"
+import { type ToolDefinition } from "./tool"
+
+export * from "./tool"
export type PluginInput = {
client: ReturnType<typeof createOpencodeClient>
@@ -18,34 +22,16 @@ export type PluginInput = {
directory: string
worktree: string
$: BunShell
- Tool: {
- define(id: string, init: any | (() => Promise<any>)): any
- }
- z: any // Zod instance for creating schemas
}
-export type Plugin = (input: PluginInput) => Promise<Hooks>
-// Lightweight schema spec for HTTP-registered tools
-export type HttpParamSpec = {
- type: "string" | "number" | "boolean" | "array"
- description?: string
- optional?: boolean
- items?: "string" | "number" | "boolean"
-}
-export type HttpToolRegistration = {
- id: string
- description: string
- parameters: {
- type: "object"
- properties: Record<string, HttpParamSpec>
- }
- callbackUrl: string
- headers?: Record<string, string>
-}
+export type Plugin = (input: PluginInput) => Promise<Hooks>
export interface Hooks {
event?: (input: { event: Event }) => Promise<void>
config?: (input: Config) => Promise<void>
+ tool?: {
+ [key: string]: ToolDefinition
+ }
auth?: {
provider: string
loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>>
@@ -121,16 +107,4 @@ export interface Hooks {
metadata: any
},
) => Promise<void>
- /**
- * Allow plugins to register additional tools with the server.
- * Use registerHTTP to add a tool that calls back to your plugin/service.
- * Use register to add a native/local tool with direct function execution.
- */
- "tool.register"?: (
- input: {},
- output: {
- registerHTTP: (tool: HttpToolRegistration) => void | Promise<void>
- register: (tool: any) => void | Promise<void> // Tool.Info type from opencode
- },
- ) => Promise<void>
}
diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts
new file mode 100644
index 000000000..7c1d3d7c5
--- /dev/null
+++ b/packages/plugin/src/tool.ts
@@ -0,0 +1,20 @@
+import { z } from "zod/v4"
+
+export type ToolContext = {
+ sessionID: string
+ messageID: string
+ agent: string
+ abort: AbortSignal
+}
+
+export function tool<Args extends z.ZodRawShape>(
+ input: (zod: typeof z) => {
+ description: string
+ args: Args
+ execute: (args: z.infer<z.ZodObject<Args>>, ctx: ToolContext) => Promise<string>
+ },
+) {
+ return input(z)
+}
+
+export type ToolDefinition = ReturnType<typeof tool>