mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-22 18:24:21 +01:00
support custom tools (#2668)
This commit is contained in:
11
.opencode/tool/foo.ts
Normal file
11
.opencode/tool/foo.ts
Normal file
@@ -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
bun.lock
1
bun.lock
@@ -183,6 +183,7 @@
|
||||
"version": "0.9.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "catalog:",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
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))
|
||||
}
|
||||
export type HttpToolRegistration = {
|
||||
id: string
|
||||
description: string
|
||||
parameters: {
|
||||
type: "object"
|
||||
properties: Record<string, HttpParamSpec>
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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 }),
|
||||
return { custom }
|
||||
})
|
||||
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> }
|
||||
|
||||
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
|
||||
return {
|
||||
title: json.title ?? input.id,
|
||||
output: json.output ?? "",
|
||||
metadata: (json.metadata ?? {}) as any,
|
||||
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: {},
|
||||
}
|
||||
},
|
||||
})
|
||||
const idx = HTTP.findIndex((t) => t.id === info.id)
|
||||
if (idx >= 0) HTTP.splice(idx, 1, info)
|
||||
else HTTP.push(info)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function allTools(): Tool.Info[] {
|
||||
return [...BUILTIN, ...EXTRA, ...HTTP]
|
||||
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 ids() {
|
||||
return allTools().map((t) => t.id)
|
||||
async function all(): Promise<Tool.Info[]> {
|
||||
const custom = await state().then((x) => x.custom)
|
||||
return [...BUILTIN, ...custom]
|
||||
}
|
||||
|
||||
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()),
|
||||
})),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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:",
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
packages/plugin/src/tool.ts
Normal file
20
packages/plugin/src/tool.ts
Normal file
@@ -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>
|
||||
Reference in New Issue
Block a user