在陌生代码库里修改一个功能,难点往往不是写出几行代码,而是找到正确的修改位置:入口在哪里,经过哪些调用,依赖哪些模块,改动又可能影响谁。
OpenCode 已经提供文件读取和文本搜索等工具。它们擅长定位字面内容,但当问题变成“这条业务流程如何串起来”时,代理仍需要在多个文件之间来回查找、阅读和建立联系。CodeGraph 提供的是另一种补充:通过代码解析和关系图,帮助代理探索符号、调用路径、依赖及修改影响。
我开发的 opencode-codegraph-bridge,就是把这项能力接入 OpenCode 的集成层。它不重新实现代码分析引擎,而是把原本分散的准备工作收拢起来:安装入口、MCP 注册、后台首次索引、工具使用指引,以及插件自身的版本更新。
这篇文章以插件本身为主线,先介绍如何使用,再结合源码解释它如何工作,以及为什么采用这样的实现。
本文对应已发布的 Bridge 0.4.0。宿主行为主要在 OpenCode 1.18.29 上核对和验证,CodeGraph 依赖声明为
^1.6.0。后续版本可能调整实现,具体使用说明以仓库 README 为准。
一、它解决什么问题,哪些问题不由它解决
先明确三个角色,后面的设计会更容易理解。
| 组件 | 负责的事情 |
|---|---|
| OpenCode | 运行代理、加载插件、连接 MCP、执行工具和展示原生通知 |
| CodeGraph | 解析代码、建立索引、提供结构查询、监听文件变更 |
| CodeGraph Bridge | 准备 CodeGraph 的运行入口、注册 MCP、协调首次索引、补充使用指引与管理插件版本配置 |
对使用者来说,Bridge 的价值不是多了一个新的查询算法,而是减少接入和维护 CodeGraph 所需的手动步骤。
没有这层集成时,需要分别考虑:CodeGraph 程序装在哪里,MCP 命令怎么写,项目是否已经初始化,代理什么时候应该使用它,以及插件新版发布后如何加载。
Bridge 把这些步骤组织为一条可重复的流程,但保留了清晰边界:
- 不替代 OpenCode 的文件读取和 grep;结构查询与文本检索是互补关系。
- 不重新实现 CodeGraph 的 watcher 或查询工具。
- 不修改
AGENTS.md来永久植入指令。 - 不覆盖用户已经配置的
mcp.codegraph。 - 不承诺索引永远新鲜,也不把静态代码关系当成运行时调用追踪。
例如,“找出包含某个错误码的文件”直接用文本搜索即可;“解释请求从路由到数据写入的调用关系”则适合先借助 CodeGraph 缩小范围,再按需要读取相关文件。
二、安装与使用:从一个命令开始
2.1 用安装器注册插件
从 0.4.0 开始,可以执行:
npx opencode-codegraph-bridge install
安装器默认操作用户级 OpenCode 配置目录:
设置了绝对路径 XDG_CONFIG_HOME
→ $XDG_CONFIG_HOME/opencode
否则
→ ~/.config/opencode
如果还没有配置,它会创建带有 schema 的 opencode.json。以运行 0.4.0 安装器为例,结果类似:
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["[email protected]"]
}
这里写入的是本次执行的安装器所属版本,而不是安装器再去查询一次 latest。因此,执行 npx [email protected] install,注册的就是 0.4.0。已有更高的稳定版本 pin 时,不会反向降级。
如果配置中已经存在其他插件,安装器会保留它们。重复执行不会重复添加相同条目;已有相同精确版本时,不会为了输出一次“成功”而重写文件。
install 完成的是配置注册,不是立即启动 MCP 或扫描代码库。 npx 获取包及其依赖后,安装器写入插件条目;退出并重启 OpenCode,宿主才会按这个配置加载插件。
2.2 也可以保留手动配置方式
如果希望自己管理配置,可以直接在现有 plugin 数组中加入:
{
"plugin": ["opencode-codegraph-bridge"]
}
示例只展示这个插件;实际使用时不要覆盖已有条目。无需额外全局安装 codegraph,也无需再手工添加一份 mcp.codegraph。
确实需要关闭插件时,支持 OpenCode 的 tuple 写法:
{
"plugin": [
["opencode-codegraph-bridge", { "enabled": false }]
]
}
这会关闭插件的接管行为,包括自动更新检查。它不是另一个独立的 autoUpdate 开关。
2.3 在 Git 项目中使用
重启 OpenCode 后,在 Git 项目根目录打开会话。首次索引在后台执行,刚打开会话时不一定已经具备可查询的代码图。
索引就绪后,可以提出这样的任务:
使用当前可用的 CodeGraph 工具,说明鉴权中间件在哪里注册,以及请求如何经过鉴权到达业务处理函数。
或:
准备修改这个服务方法的参数。先定位调用它的相关代码,再解释需要同步检查哪些位置。
这些是使用示例,不是效果基准。实际结果取决于 CodeGraph 的语言支持、索引内容、代码结构及代理的工具权限。插件没有修改权限的职责;工具不可用或结果不足时,代理仍应使用允许的读取和搜索工具继续工作。
三、整体架构:把安装、连接、索引分开
插件看起来只需在配置中出现一次,内部却有三个不同的生命周期。
安装阶段:在 OpenCode 之外执行
npx ... install
│
└─ 更新用户级 plugin 配置
运行阶段:OpenCode 加载插件
插件工厂
│
└─ config hook
├─ 后台检查 Bridge 版本
├─ 注入 mcp.codegraph
│ └─ OpenCode 管理 MCP 连接
└─ 后台检查项目索引
├─ 已健康:复用
└─ 未初始化:启动独立 worker
模型请求阶段
system transform hook
├─ 索引未就绪:不注入成功指引
└─ 索引已就绪:追加简短的 CodeGraph 使用提示
源码按职责拆分,而不是按抽象层级堆叠:
src/
index.js OpenCode 的公开插件入口
internal.js MCP 配置、后台协调、状态与提示词
worker.js 独立索引进程
update.js Bridge 版本检查与配置更新决策
config-file.js JSONC 处理、锁和安全文件写入
cli.js npx install 命令
cli.js 和 update.js 都需要写配置,所以共享文件处理逻辑;但它们不共用业务流程。安装器明确面向用户级配置,自更新器则必须定位本次加载条目的实际来源。把两者混成一个“通用安装函数”,反而容易写错配置文件。
四、MCP 注册:正确的入口,比更多配置更重要
4.1 公开入口只导出插件工厂
OpenCode 的插件入口不是一个普通工具库。对应版本的加载器会检查模块导出,因此不能把常量、辅助函数也随意从公开入口导出。
Bridge 的入口保持很薄:
import { createCodeGraphPlugin } from "./internal.js"
export default async function opencodeCodeGraphBridge(input, options) {
return createCodeGraphPlugin(options)(input)
}
实现函数留在内部模块供测试使用,公开入口只暴露 OpenCode 需要的工厂。这里也接收了第二个 options 参数,确保 tuple 中的 enabled: false 真正进入实现,而不是只在文档里存在。
4.2 从插件自己的依赖树定位程序
OpenCode 的运行环境可能是 Bun,不能直接把 process.execPath 当成适合执行 CodeGraph SDK 的 Node。
当前实现从 @colbymchenry/codegraph 的安装位置解析匹配平台包及 bundled Node:
const packageJson = require.resolve("@colbymchenry/codegraph/package.json")
const packageRoot = dirname(packageJson)
const packageRequire = createRequire(packageJson)
const platformPackage = `@colbymchenry/codegraph-${process.platform}-${process.arch}`
const nodeName = process.platform === "win32" ? "node.exe" : "node"
const nodePath = realpathSync(
packageRequire.resolve(`${platformPackage}/${nodeName}`)
)
const shimPath = realpathSync(join(packageRoot, "npm-shim.js"))
从 CodeGraph 的 package 位置创建 require,而不是只从 Bridge 根目录寻找平台依赖,是为了兼容依赖嵌套安装,并不假设 npm 一定会把平台包提升到顶层。
这项实现与上游包布局存在关联,需要随依赖变化验证。当前 CodeGraph 依赖使用 ^1.6.0,允许同一 major 的 minor、patch 更新,但 semver 范围本身不能替代兼容性测试。
4.3 注入配置,但不接管用户已有服务
MCP 配置的核心逻辑如下:
export function mcpConfig(runtime, root) {
return {
type: "local",
command: [
runtime.nodePath,
runtime.shimPath,
"serve", "--mcp", "--path", root,
],
environment: { CODEGRAPH_NO_DOWNLOAD: "1" },
}
}
export function registerMcp(config, runtime, root) {
config.mcp ??= {}
if (Object.prototype.hasOwnProperty.call(config.mcp, "codegraph")) return false
config.mcp.codegraph = mcpConfig(runtime, root)
return true
}
这里没有重新实现 MCP 客户端,连接交给 OpenCode。CODEGRAPH_NO_DOWNLOAD=1 禁止 CodeGraph shim 在运行时自行补下载平台程序;依赖缺失时,应明确降级,而不是让一次聊天请求突然承担安装任务。
用户已经写入 mcp.codegraph 时,Bridge 不覆盖其命令、远程地址或禁用设置。注册失败也不应阻止其他插件和普通聊天工作。
五、后台索引:为什么必须区分 connected 与 ready
5.1 MCP 连通,不代表项目已经可查询
如果把首次全量索引放在 MCP 握手之前,大仓库会把“需要等待索引”变成“服务连接超时”。
因此,Bridge 先完成配置注册,索引任务通过独立进程在后台执行。所使用的 CodeGraph 版本支持在服务运行期间发现后来创建的索引,这让首次索引可以不阻塞连接建立。
下面是这一设计希望保证的时序,集成测试也围绕它展开:
OpenCode Bridge / worker CodeGraph MCP
│ │ │
├─ 加载配置 ───────>│ │
│<─ MCP 配置 ───────┤ │
├─ initialize ────────────────────────────>│
│<─ capabilities / tools ──────────────────┤
│ ├─ 首次索引 │
│ ├─ 校验 ready │
├─ 查询 ──────────────────────────────────>│
│<─ 项目相关源码和关系 ────────────────────┤
已配置、已连接、索引就绪是三个状态,不能用一个布尔值替代它们。 Bridge 的 ready 用于控制自己的提示和后台任务,不代表所有代理都被授予了 MCP 工具权限。
5.2 为什么使用独立 worker
worker 使用 CodeGraph SDK,关键调用可以归纳为:
// 核心调用顺序示意;完整代码还包含锁、取消与状态复查。
const graph = await CodeGraph.init(root, { index: false })
try {
const result = await graph.indexAll({ signal })
// 检查 result,并再次读取项目状态。
} finally {
graph.destroy()
}
没有直接无条件执行 codegraph init --yes,是因为核对过的 CLI 初始化路径在特定条件下还可能安装 Git hooks。插件只需要建立索引,不应该隐式扩大到仓库 hooks 配置。
独立 worker 还有两个实际作用:让 SDK 在匹配的 Node 环境运行;让长任务有可管理的退出边界。宿主退出或收到终止信号时,worker 发出取消请求,释放图对象和初始化锁,必要时通过有限宽限期结束进程。
5.3 ready 的判定不能只看目录或退出码
源码中的就绪判断是:
export function isReadyStatus(status, expectedRoot, workerSucceeded = true) {
const rootMatches = !expectedRoot || (
typeof status?.projectPath === "string" &&
normalizeRealpath(status.projectPath) === normalizeRealpath(expectedRoot)
)
return workerSucceeded && !!status && rootMatches && status.initialized === true &&
typeof status.projectPath === "string" &&
status.index?.state === "complete" &&
typeof status.lastIndexed === "string" && status.lastIndexed.length > 0 &&
status.index?.pendingRefs === 0 && Number(status.fileCount) > 0
}
这些条件分别回答:是不是目标项目、是否初始化、构建是否完成、是否有完成时间、是否仍有待处理引用,以及是否有实际索引文件。
零文件项目不一定是程序错误,但不应该因此告诉代理“代码图已经可以提供有效上下文”。已有数据库却状态不完整时,也不能直接删库重建,因为另一个会话可能正在使用它。
5.4 多会话协调与后续更新
同一项目可能被多个会话打开。进程内通过按项目根组织的进行中 Promise 去重;跨进程的首次构建使用原子创建的锁目录协调。拿到锁后再次检查状态,防止等待期间其他进程已经完成索引。
暂未就绪时,后续 system hook 可以触发非阻塞复查,但设有 30 秒节流,不会每条消息都执行一次状态命令。健康索引直接复用,后续文件变化交给 CodeGraph MCP 自己的 watcher。
状态命令并不等同于只读一次文件属性,它可能打开数据库。因此实现还会在访问前检查数据路径,并避免高频轮询或擅自接管残留锁。
六、提示词注入:让工具被正确使用,而不是重复工具手册
有工具,并不意味着代理会在合适的任务上使用它。但解决办法也不是往 AGENTS.md 里复制一长段说明。
CodeGraph MCP 本身提供工具描述与服务指引。Bridge 参考官方安装器的使用意图,保留“先定位和理解相关代码”的策略,再做 OpenCode 场景适配:不依赖全局 CLI,不写死宿主添加前缀后的工具名,不绕过权限。
当前注入正文如下,最后附带 JSON 编码的项目根目录:
When CodeGraph tools are available in this session, use their exploration capability first to locate and understand relevant code before broad searches or reading unrelated files. Follow their provided instructions and use returned context for targeted reads; avoid re-fetching context already available. If the tools are unavailable or results are insufficient or stale, fall back to permitted file-reading and search tools. Project root: "/path/to/repository"
注入点是 experimental.chat.system.transform。只有插件完成配置接管且索引 ready 时才追加;未就绪时只安排后台检查,不向模型宣称索引成功。
这段提示有三个目的:
- 在开始广泛搜索或读取无关文件前,优先利用结构探索缩小范围。
- 让后续读取围绕已有结果展开,避免重复获取上下文。
- 明确保留普通工具的回退路径。
它没有强制每轮聊天都调用 CodeGraph,也没有建立运行时在线同步官方提示词的机制。官方工具说明跟随实际运行的 CodeGraph 版本,由 MCP 服务提供;Bridge 只维护少量与自身集成相关的指引。
七、安装器:一个命令如何安全地修改现有配置
7.1 CLI 与插件入口各司其职
npm 的命令入口通过 bin 声明:
{
"main": "src/index.js",
"bin": {
"opencode-codegraph-bridge": "src/cli.js"
}
}
cli.js 使用 Node shebang 和内置 parseArgs,支持 install、--help、--version,不需要额外引入命令行框架。帮助和版本查询在访问配置目录前返回,也不加载 CodeGraph 运行时。
npm 执行命令时会经过 .bin 符号链接,因此入口判断需要比较真实路径,不能仅比较字符串形式的 process.argv[1] 与模块路径。这个细节通过真正打包后的 npx 测试验证,而不只依赖直接调用 JavaScript 函数。
7.2 配置选择不是简单“找第一个文件”
安装器检查用户级目录下的三个候选:config.json、opencode.json、opencode.jsonc。
规则分为两种情况:
- 已经存在唯一的 Bridge npm 条目:修改该条目所在文件,优先级不应把它转移到另一份配置中。
- 没有该条目:选择已有文件中的
opencode.jsonc、opencode.json、config.json,按此顺序追加;都不存在时创建opencode.json。
多个文件或同一数组中出现重复 Bridge 条目时,安装器报错,不自行决定哪份是用户真正想保留的。它同样保留 tuple 的所有参数,特别是 enabled: false,不会把一次安装命令当作取消禁用的授权。
还有一个保守边界:没有明确的 Bridge npm 条目,却存在无法确认身份的本地文件、Git 或 alias 插件时,安装器可能要求人工确认,避免把同一插件以不同形式加载两次。它不会执行其他插件来推断身份。
7.3 保留 JSONC,而不是重新序列化整份文件
配置通常包含注释。读取后直接 JSON.stringify() 整个对象虽然省事,却会抹掉用户说明和原有格式。
安装器与自更新器共用 jsonc-parser:先检查语法、结构与重复键,再做局部编辑。更新 tuple 时只改首项,追加时只插入一个数组元素,而不是重写整个数组。
例如下面的选项与注释应保持原样:
{
// 其他配置保持不变
"plugin": [
["[email protected]", { "enabled": false }]
]
}
执行新版安装器后,目标是仅改变版本字符串,且明确告知用户插件仍处于 disabled 状态。
7.4 新建文件与替换文件需要不同保证
已有文件使用“临时文件写完后 rename”的方式,写入前后比较原内容、文件身份、权限与修改时间,检测到变化则取消。
新文件还需要避免覆盖另一个进程刚创建的目标。对应实现先写完同目录临时文件,再通过 link() 排他发布:
// 新文件发布的核心顺序;异常与临时文件清理见完整实现。
const handle = await open(temporary, "wx", 0o600)
await handle.writeFile(content, "utf8")
await handle.close()
await link(temporary, target)
如果目标已存在,发布失败,而不是覆盖它。发布成功后,临时文件清理的失败也不应反过来把安装状态报告为失败。
目录锁用于协调安装器与自更新器之间的写入;它不能约束所有外部编辑器。最终比较与文件替换之间仍存在很短的竞态窗口,因此文档只承诺检测到冲突时跳过,不宣称提供完整的跨进程事务。
八、默认自更新:更新的是下次启动配置
8.1 区分两种“版本更新”
| 对象 | 规则 |
|---|---|
| CodeGraph 引擎依赖 | @colbymchenry/codegraph: ^1.6.0,由 npm 安装和锁文件决定 |
| Bridge 插件自身 | 后台查询官方 registry 的稳定最新版,条件满足时更新配置中的自身精确版本 |
Bridge 自更新不会单独升级 CodeGraph 依赖,也不是重新执行 npm install。新的 Bridge 包在下次由 OpenCode 加载时,其依赖才按宿主安装流程处理。
当前默认更新器只接受同名包和完整三段式稳定版本,并比较运行版本、配置中的 pin 与远端候选。相同版本不写入,已有更高 pin 不降级;它没有“只允许同一 major”的额外限制。
8.2 只有来源明确,才有资格写回
自更新比安装器更难的一点在于:插件可能来自全局配置,也可能来自项目或显式指定的配置文件。
实现会检查 OpenCode 提供的 plugin_origins 来源信息,并与有效插件条目及磁盘内容重新匹配。这个字段在核对版本中属于内部配置信息,不应当作永远稳定的公开 API。字段缺失、来源冲突或无法匹配时,安全跳过,而不是扫描用户目录寻找一个看起来像配置的文件。
对于内联环境配置、远程来源和本地文件插件,也不能假设存在可写的 npm 条目。
下面是自更新局部替换的核心思路:
const path = Array.isArray(entry)
? ["plugin", index, 0]
: ["plugin", index]
const changed = applyEdits(
content,
modify(content, path, `${PACKAGE_NAME}@${version}`, {})
)
替换后还会重新解析并比较预期对象,保证其他配置值没有变化,再交给共享文件写入层处理。
8.3 网络和通知不能阻塞插件
版本查询有总超时和响应大小限制,拒绝重定向,验证包名和版本格式;失败不会阻塞 MCP 注册或聊天。
配置更新成功后,通知以 CodeGraph Bridge 为标题,正文为:
Update ready. Restart OpenCode to apply.
这里有意区分了“准备完成”和“当前进程已经升级”。OpenCode 已经加载了旧模块,修改磁盘配置不会热替换当前插件;重启后才会使用新的精确版本。
通知调用原生 client.tui.showToast,同一插件实例去重,缺少通知接口或调用失败时保留日志兜底。实际位置与可见性仍由 OpenCode 客户端决定。
还需要注意 registry 的边界:Bridge 查询官方源,但宿主安装器可能使用用户配置的镜像。镜像尚未同步新版时,写入的版本可能暂时无法安装。当前插件不会替用户修改 npm registry;排障时应分别检查“官方是否存在”和“宿主实际使用的源是否可获取”。
九、测试:分别验证函数、宿主和分发产物
这个项目没有用一个“测试通过”覆盖所有链路,而是把验收拆开。
| 层次 | 主要验证内容 |
|---|---|
| 单元测试 | 配置优先、ready 判断、节流、权限边界、JSONC 保留、版本比较、通知失败隔离 |
| 安装器测试 | 新建配置、重复安装零写入、tuple 保留、重复来源拒绝、符号链接入口、排他发布 |
| MCP 集成测试 | 索引前握手、同连接索引后查询、修改文件后 watcher 同步 |
| OpenCode smoke | 隔离配置加载插件,验证动态 mcp.codegraph 确实出现并连接 |
| tarball/npx 验收 | 真正打包后通过 npm 命令入口执行,检查生成配置及重复安装行为 |
项目测试可以运行:
npm ci
npm run test:unit
npm run test:integration
npm run test:opencode
安装测试已纳入 test:unit。完整源码和环境要求见仓库测试目录。
两个断言尤其重要。第一,查询结果不能只检查是否包含查询词,因为未命中提示也可能回显该词;测试使用文件定位和独有源码值判断结果。第二,安装命令不能只检查退出码为零,还要确认配置真的生成,重复执行时内容和修改时间确实不变。
版本测试也必须使用独立的模拟基线。不能让“当前插件版本”偷偷读取会被 Release Please 改写的 package.json,否则发布前的更新场景可能在发布后变成相同版本场景。
这些测试证明了各自覆盖的链路,不等于所有平台和客户端都完成验证。当前主要实测环境是 Linux;不同平台的运行时包、文件系统行为,以及不同 OpenCode 客户端的 Toast 展示,仍需要各自验证。
十、交付:自动生成版本,但保留发布确认
项目使用 Conventional Commits 与 Release Please 管理版本、CHANGELOG.md 和发版 PR。合并发版 PR 后,工作流创建 GitHub Release,再运行发布检查,通过 npm Trusted Publishing 进行 OIDC 发布。
功能或修复提交
→ 生成/更新发版 PR
→ 审核版本、changelog 和测试结果
→ 合并发版 PR
→ GitHub Release
→ 测试、打包检查、npm 发布
这让版本和日志不再靠手工重复维护,但仍保留一次明确的发布确认。GitHub Release 和 npm 发布不是一个事务:测试失败时,可能已经有 Release,却没有对应的 npm 版本。因此发布验收要检查最终 registry 结果,而不能只看 GitHub 标签。
这部分是插件的交付基础设施,不是插件运行时的一部分。使用者不需要了解 Release Please 才能使用 Bridge;维护者则需要让每个版本具备可追溯的源码、测试和发布结果。
结语:集成层的价值,是把职责连接好
CodeGraph Bridge 的技术重点不在于重新发明代码索引,而在于把几个不同生命周期连接起来:安装命令准备配置,OpenCode 加载插件并建立 MCP,worker 负责首次索引,官方 watcher 维护后续变化,模型请求阶段再得到恰当的工具指引。
我希望保留的是这种分工:让宿主继续管理宿主擅长的连接和界面,让 CodeGraph 继续维护解析与查询,让 Bridge 只承担接入中缺失的部分。
对使用者来说,结果是一个安装命令和更少的手工准备;对插件开发者来说,关键实现则集中在启动顺序、进程边界、配置来源、局部写入,以及真实产物的验证上。这些内容,才是一个“小插件”值得认真设计的地方。