Dax 6 месяцев назад
Родитель
Сommit
3b6c0ec0b3

+ 11 - 0
.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!"
+  },
+}

+ 1 - 0
bun.lock

@@ -183,6 +183,7 @@
       "version": "0.9.11",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
+        "zod": "catalog:",
       },
       "devDependencies": {
         "@tsconfig/node22": "catalog:",

+ 18 - 3
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)
   }
 }

+ 3 - 14
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)

+ 1 - 47
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(

+ 55 - 80
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()),
       })),

+ 1 - 1
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
   }

+ 0 - 298
packages/opencode/test/tool/register.test.ts

@@ -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")
-  })
-})

+ 6 - 1
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:",

+ 13 - 7
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
     },

+ 8 - 34
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>
 }

+ 20 - 0
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>