diff --git a/.opencode/tool/foo.ts b/.opencode/tool/foo.ts new file mode 100644 index 00000000..3d350de1 --- /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 18bab702..48c8ea5b 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 7d86845e..387dbd65 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 272d66a7..b82faf4a 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, "auth" | "event">, + Name extends Exclude, "auth" | "event" | "tool">, Input = Parameters[Name]>[0], Output = Parameters[Name]>[1], >(name: Name, input: Input, output: Output): Promise { @@ -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 3a5d794c..95cfa6b6 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 379ca542..9f2ce223 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 - } - callbackUrl: string - headers?: Record - } - - function buildZodFromHttpSpec(spec: HttpToolRegistration["parameters"]) { - const shape: Record = {} - 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() + 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(mod)) { + custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) + } } - if (val.description) base = base.describe(val.description) - shape[key] = val.optional ? base.optional() : base } - return z.object(shape) + + const plugins = await Plugin.list() + for (const plugin of plugins) { + for (const [id, def] of Object.entries(plugin.tool ?? {})) { + custom.push(fromPlugin(id, def)) + } + } + + 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 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) + 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) } - 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 } - 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) + async function all(): Promise { + const custom = await state().then((x) => x.custom) + return [...BUILTIN, ...custom] } - function allTools(): Tool.Info[] { - return [...BUILTIN, ...EXTRA, ...HTTP] - } - - 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 6e2b9511..a372a69d 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 351eb91d..00000000 --- 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 = { "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 39355a1b..1fc45014 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 04c48c91..fd6a404d 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 f8b6d46f..9c2647c6 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 @@ -18,34 +22,16 @@ export type PluginInput = { directory: string worktree: string $: BunShell - Tool: { - define(id: string, init: any | (() => Promise)): any - } - z: any // Zod instance for creating schemas } -export type Plugin = (input: PluginInput) => Promise -// 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 - } - callbackUrl: string - headers?: Record -} +export type Plugin = (input: PluginInput) => Promise export interface Hooks { event?: (input: { event: Event }) => Promise config?: (input: Config) => Promise + tool?: { + [key: string]: ToolDefinition + } auth?: { provider: string loader?: (auth: () => Promise, provider: Provider) => Promise> @@ -121,16 +107,4 @@ export interface Hooks { metadata: any }, ) => Promise - /** - * 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 - register: (tool: any) => void | Promise // Tool.Info type from opencode - }, - ) => Promise } diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts new file mode 100644 index 00000000..7c1d3d7c --- /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( + input: (zod: typeof z) => { + description: string + args: Args + execute: (args: z.infer>, ctx: ToolContext) => Promise + }, +) { + return input(z) +} + +export type ToolDefinition = ReturnType