OpenCode 内置了十几种基础工具——read 读取文件、edit 编辑代码、bash 执行命令、websearch 搜索网络……这些工具覆盖了日常开发的大部分场景。但总有一些情况,你需要 AI 做的事情超出了内置工具的边界:查询内部数据库、调用私有 API、运行自定义脚本、或者对某些操作施加企业级的约束规则。
Custom Tools 正是为解决这类需求而生。你可以用 TypeScript 或 JavaScript 定义一个工具描述,让 LLM 在工作过程中自动调用它——就像调用内置工具一样自然。最妙的是,工具的执行逻辑完全由你掌控,底层可以是任何语言:Python 脚本、Go 二进制、cURL 请求,甚至是远程服务的 API 调用。
本文将带你从零开始掌握 OpenCode Custom Tools,涵盖基本结构、参数定义、上下文获取、跨语言实践和安全配置,并附上可直接运行的代码示例。
在开始之前,简要区分三种易混淆的扩展机制:
| 机制 | 定位 | 何时使用 |
|------|------|----------|
| Custom Tools | 让 LLM 调用的可执行函数 | 需要 LLM 自动决定何时触发某个操作(如"写日志前先检查磁盘空间") |
| Commands | TUI 中用户手动触发的快捷命令 | 你主动输入 /deploy 执行部署流程 |
| MCP Servers | 外部工具生态的标准协议集成 | 接入第三方服务(数据库、API、文件系统) |
简单来说:Command 是"用户主动发起的快捷操作",Custom Tool 是"LLM 自主决策调用的能力",MCP 是"标准化的外部工具接入协议"。
Custom Tools 定义在 .opencode/tools/ 目录下的 .ts 或 .js 文件中。文件名即工具名。
创建一个最简单的问候工具:
// .opencode/tools/greet.ts
import { tool } from "@opencode-ai/plugin"
export default tool({
description: "向指定的名字打招呼",
args: {
name: tool.schema.string().describe("要打招呼的名字"),
},
async execute(args) {
return `你好,${args.name}!欢迎使用 OpenCode Custom Tools。`
},
})
保存后,在 OpenCode 会话中对 AI 说:
用 greet 工具向 "张三" 打招呼
LLM 就会自动调用 greet 工具,传入 { name: "张三" },返回问候语。
这里有几个关键点:
description:这是 LLM 决定是否调用该工具的核心依据。写清楚工具做什么、什么时候用、输入输出是什么。args:参数定义使用 Zod schema(tool.schema 就是 Zod 的引用),支持 .string()、.number()、.boolean()、.object() 等所有 Zod 类型。execute:异步函数,接收参数并返回字符串结果。返回的内容会直接展示在 LLM 的输出中。简单的 string/number 参数远不够用,实际项目中你可能需要定义复杂的输入结构:
// .opencode/tools/deploy.ts
import { tool } from "@opencode-ai/plugin"
export default tool({
description: "将项目部署到指定环境",
args: {
environment: tool.schema
.enum(["staging", "production"])
.describe("目标部署环境"),
version: tool.schema
.string()
.regex(/^\d+\.\d+\.\d+$/)
.describe("语义化版本号,如 1.2.3"),
config: tool.schema
.object({
replicas: tool.schema.number().min(1).max(10).default(2),
memory: tool.schema.string().default("512Mi"),
})
.optional()
.describe("部署配置(可选)"),
},
async execute(args) {
const { environment, version, config } = args
// 执行实际部署逻辑
const cmd = `deploy --env=${environment} --version=${version} --replicas=${config?.replicas ?? 2}`
return `执行部署命令: ${cmd}\n部署完成!`
},
})
Zod 的类型系统非常强大。你可以用它做参数校验、默认值设置、正则匹配等。如果 LLM 传入的参数不符合 schema 定义,OpenCode 会自动拦截并返回错误信息,LLM 会尝试修正参数后重试。
如果你不想引入 @opencode-ai/plugin,也可以用纯对象的方式定义工具:
// .opencode/tools/ping.ts
import { z } from "zod"
export default {
description: "检测服务健康状态",
args: {
url: z.string().url().describe("要检测的服务 URL"),
},
async execute(args: { url: string }) {
try {
const res = await fetch(args.url)
return `状态码: ${res.status}, 耗时: ${res.headers.get("x-response-time") ?? "未知"}`
} catch {
return "无法连接到该服务"
}
},
}
两种写法完全等价,tool() 辅助函数只是提供了更好的 TypeScript 类型推导。
你可以在单个文件中导出多个工具。每个具名导出会生成一个独立的工具:
// .opencode/tools/math.ts
import { tool } from "@opencode-ai/plugin"
export const add = tool({
description: "对两个数进行加法运算",
args: {
a: tool.schema.number().describe("第一个数"),
b: tool.schema.number().describe("第二个数"),
},
async execute(args) {
return (args.a + args.b).toString()
},
})
export const multiply = tool({
description: "对两个数进行乘法运算",
args: {
a: tool.schema.number().describe("第一个数"),
b: tool.schema.number().describe("第二个数"),
},
async execute(args) {
return (args.a * args.b).toString()
},
})
这会生成两个工具:math_add 和 math_multiply。命名规则是 <文件名>_<导出名>,中间的连接符为下划线。
注意:default 导出是一个特例——它生成的文件名不带后缀。上面的 greet.ts 导出 default,所以工具名就是 greet;而 math.ts 中没有 default 导出,所以每个具名导出都会加上 math_ 前缀。
Custom Tools 的一个高级用法是覆盖内置工具。如果自定义工具的名字与内置工具重名,自定义工具会优先使用。
一个经典场景是:你想限制 LLM 执行 shell 命令的范围,创建一个包装版的 bash:
// .opencode/tools/bash.ts
import { tool } from "@opencode-ai/plugin"
import { $ } from "bun"
const ALLOWED_COMMANDS = [
"npm",
"node",
"git",
"ls",
"mkdir",
"rm",
"cat",
]
export default tool({
description: "受限的 bash 命令执行器",
args: {
command: tool.schema.string().describe("要执行的命令"),
},
async execute(args) {
const baseCmd = args.command.split(" ")[0]
if (!ALLOWED_COMMANDS.includes(baseCmd)) {
return `命令 "${baseCmd}" 不在允许列表中。允许的命令: ${ALLOWED_COMMANDS.join(", ")}`
}
const result = await $`${{ raw: args.command }}`.text()
return result
},
})
不过,更推荐的做法是使用权限系统直接禁止某些工具,而不是通过覆盖来迂回实现。覆盖内置工具主要用于"在原有功能基础上增强"的场景——例如在执行每个 bash 命令前自动记录审计日志。
Tool 的 execute 函数可以接收第二个参数 context,其中包含当前会话的关键信息:
// .opencode/tools/project-info.ts
import { tool } from "@opencode-ai/plugin"
export default tool({
description: "获取当前项目信息,包括工作目录和 Git 根目录",
args: {},
async execute(_args, context) {
const { agent, sessionID, messageID, directory, worktree } = context
return [
`当前 Agent: ${agent}`,
`会话 ID: ${sessionID}`,
`消息 ID: ${messageID}`,
`工作目录: ${directory}`,
`Git 根目录: ${worktree}`,
].join("\n")
},
})
Context 对象的属性说明:
| 属性 | 类型 | 说明 |
|------|------|------|
| agent | string | 当前使用的 agent 名称 |
| sessionID | string | 当前会话的唯一标识 |
| messageID | string | 当前消息的唯一标识 |
| directory | string | 会话的工作目录 |
| worktree | string | Git 仓库的根目录 |
这些信息在你需要定位文件路径、处理项目结构相关的逻辑时非常有用。例如,你可以用 context.worktree 拼接出脚本的绝对路径,避免相对路径在不同工作目录下出错。
TypeScript/JavaScript 只是工具定义的壳,实际执行逻辑完全可以用任何语言编写。下面是一个完整的跨语言工具案例:
首先,写一个 Python 脚本处理 JSON 数据:
# .opencode/tools/analyze_logs.py
import sys
import json
log_file = sys.argv[1]
level_filter = sys.argv[2] if len(sys.argv) > 2 else "ERROR"
with open(log_file, "r") as f:
lines = f.readlines()
results = []
for line in lines:
if level_filter in line:
results.append(line.strip())
output = {
"total_lines": len(lines),
"matched_lines": len(results),
"filter": level_filter,
"matches": results[:10],
}
print(json.dumps(output, ensure_ascii=False, indent=2))
然后创建一个 TypeScript 工具定义来调用它:
// .opencode/tools/log-analyzer.ts
import { tool } from "@opencode-ai/plugin"
import path from "path"
import { $ } from "bun"
export default tool({
description: "分析日志文件,按日志级别过滤并返回统计信息",
args: {
file: tool.schema
.string()
.describe("日志文件的路径(相对于项目根目录)"),
level: tool.schema
.enum(["DEBUG", "INFO", "WARN", "ERROR", "FATAL"])
.default("ERROR")
.describe("过滤的日志级别,默认为 ERROR"),
},
async execute(args, context) {
const script = path.join(
context.worktree,
".opencode/tools/analyze_logs.py"
)
const absFile = path.join(context.worktree, args.file)
const result =
await $`python ${script} ${absFile} ${args.level}`.text()
return result
},
})
这里用到了 Bun.$ 来执行 shell 命令。如果你用的是 Node.js 项目,可以换成 child_process.execSync 或 execa。
现在,当 LLM 需要分析日志时,会自动调用 log-analyzer 工具,传入文件路径和日志级别,得到结构化的 JSON 分析结果。
让我们做一个更实用的案例——让 LLM 能够查询 MySQL 数据库:
// .opencode/tools/db-query.ts
import { tool } from "@opencode-ai/plugin"
import mysql from "mysql2/promise"
const pool = mysql.createPool({
host: process.env.DB_HOST ?? "localhost",
port: Number(process.env.DB_PORT) || 3306,
user: process.env.DB_USER ?? "root",
password: process.env.DB_PASS ?? "",
database: process.env.DB_NAME ?? "myapp",
})
export default tool({
description:
"执行只读 SQL 查询(SELECT)。仅用于数据查询分析,不会修改数据。",
args: {
query: tool.schema
.string()
.startsWith("SELECT", "仅允许 SELECT 查询")
.describe("要执行的 SQL SELECT 语句"),
limit: tool.schema
.number()
.min(1)
.max(100)
.default(20)
.describe("返回结果的最大行数"),
},
async execute(args) {
try {
const [rows] = await pool.query(`${args.query} LIMIT ${args.limit}`)
return JSON.stringify(rows, null, 2)
} catch (err) {
return `查询失败: ${err instanceof Error ? err.message : String(err)}`
}
},
})
注意事项:
.startsWith("SELECT") 限制只允许查询操作,防止 LLM 执行 DELETE/DROP 等危险命令。使用时,你可以对 LLM 说:
用 db-query 工具查询最近 30 天注册的用户数
LLM 会生成类似 SELECT COUNT(*) FROM users WHERE created_at > ... 的查询并返回结果。
Custom Tools 在默认情况下是直接可用的,不需要额外审批。但如果你在安全敏感的环境中工作,可以通过权限系统加以控制。
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"db-query": "ask",
"deploy": "ask",
"log-analyzer": "allow",
"bash": "deny"
}
}
权限级别:
"allow":直接执行,不询问"ask":每次调用前弹窗确认"deny":禁止使用| 位置 | 作用域 | 适用场景 |
|------|--------|----------|
| .opencode/tools/ | 项目级 | 该项目特有的工具(如项目专用部署脚本) |
| ~/.config/opencode/tools/ | 全局 | 跨项目共用的通用工具(如通用日志分析器) |
建议将敏感操作(数据库写入、生产环境部署、费用相关的 API 调用)放在项目级并配置 "ask" 权限,将只读辅助工具放在全局级。
由于 Custom Tools 运行在 OpenCode 的运行时环境中,调试并不像普通应用那么直观。以下是一些实用技巧:
1. 用 console.log 输出调试信息
async execute(args, context) {
console.log("Tool called with args:", JSON.stringify(args))
console.log("Session ID:", context.sessionID)
// ...
}
日志会出现在 OpenCode 的服务端输出中。
2. 返回结构化的错误信息
不要只返回 "error",应该返回足够的上下文让 LLM 理解问题:
async execute(args) {
try {
return await doSomething(args)
} catch (err) {
return `工具执行失败 [${err.code}]: ${err.message}\n建议: 检查参数或重试`
}
}
3. 测试时直接用 Bash 工具模拟调用
在 OpenCode 会话中直接让 LLM 调用你的工具并观察返回结果。如果参数 schema 有问题,LLM 会得到验证错误并自动调整。
Custom Tools 是 OpenCode 扩展体系中最灵活的一环。它不像 MCP 那样需要一套完整的协议服务,也不像 Commands 那样依赖用户手动触发——Custom Tools 让 LLM 自主决策何时调用,而你只需要用几行 TypeScript 定义工具的签名和行为。
回顾本文覆盖的要点:
.opencode/tools/ 目录,文件名即工具名tool() 辅助函数或纯对象定义,参数类型由 Zod 提供<文件名>_<导出名>context 参数可获取会话上下文(工作目录、Git 根目录等)真正强大的 AI 编程助手,不是内置工具最多,而是能无缝接入你的技术栈。Custom Tools 正是这座桥梁。