From dc01faa4e1030ab2b70228891a0828cc37d112ec Mon Sep 17 00:00:00 2001 From: Valor <210239105+zrh805@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:16:03 +0800 Subject: [PATCH 1/2] feat(opencode): add hook-only installation mode --- examples/opencode-plugin/INSTALL-ZH.md | 14 ++++++++++++ examples/opencode-plugin/INSTALL.md | 15 +++++++++++++ examples/opencode-plugin/README.md | 15 +++++++++++++ examples/opencode-plugin/index.mjs | 11 ++++++++-- examples/opencode-plugin/lib/config.mjs | 9 ++++++++ examples/opencode-plugin/lib/mcp-config.mjs | 3 ++- .../opencode-plugin/tests/config.test.mjs | 22 +++++++++++++++++++ .../opencode-plugin/tests/mcp-config.test.mjs | 10 +++++++++ 8 files changed, 96 insertions(+), 3 deletions(-) diff --git a/examples/opencode-plugin/INSTALL-ZH.md b/examples/opencode-plugin/INSTALL-ZH.md index 3398fb7979..557629e7ab 100644 --- a/examples/opencode-plugin/INSTALL-ZH.md +++ b/examples/opencode-plugin/INSTALL-ZH.md @@ -92,6 +92,7 @@ export { OpenVikingPlugin, default } from "./openviking/index.mjs" ```json { "enabled": true, + "mcp": { "enabled": true }, "timeoutMs": 30000, "repoContext": { "enabled": true, "cacheTtlMs": 60000 }, "autoRecall": { @@ -127,6 +128,19 @@ user/admin API key 的 API_KEY mode 时应留空。 高级场景可以用 `OPENVIKING_PLUGIN_CONFIG` 指向其他配置文件路径。 +### 仅 Hooks 模式 + +如果其他 MCP server 已经提供 OpenViking,可以关闭本插件附带的 MCP 注册,同时保留生命周期 hooks: + +```json +{ + "mcp": { "enabled": false } +} +``` + +repository context、自动 recall、消息 capture 和生命周期 commit 会继续工作,也不会添加或覆盖 +OpenCode 的 `mcp.openviking` 配置。 + ## 验证 修改插件或 OpenViking 配置后,需要重启 OpenCode。 diff --git a/examples/opencode-plugin/INSTALL.md b/examples/opencode-plugin/INSTALL.md index d3d058c1f1..74769d7ca8 100644 --- a/examples/opencode-plugin/INSTALL.md +++ b/examples/opencode-plugin/INSTALL.md @@ -92,6 +92,7 @@ Example configuration: ```json { "enabled": true, + "mcp": { "enabled": true }, "timeoutMs": 30000, "repoContext": { "enabled": true, "cacheTtlMs": 60000 }, "autoRecall": { @@ -122,6 +123,20 @@ API keys are resolved from environment variables or `~/.openviking/ovcli.conf` a For advanced setups, use `OPENVIKING_PLUGIN_CONFIG` to point to another configuration file path. +### Hook-only mode + +If another MCP server already exposes OpenViking, set the bundled MCP registration to `false` while +keeping this plugin's lifecycle hooks active: + +```json +{ + "mcp": { "enabled": false } +} +``` + +Repository context, automatic recall, message capture, and lifecycle commits remain enabled. This +does not add or overwrite OpenCode's `mcp.openviking` entry. + ## Verify Restart OpenCode after changing plugin or OpenViking configuration. diff --git a/examples/opencode-plugin/README.md b/examples/opencode-plugin/README.md index 7dd8df7cb3..6e9659c6d5 100644 --- a/examples/opencode-plugin/README.md +++ b/examples/opencode-plugin/README.md @@ -114,6 +114,7 @@ Create `~/.config/opencode/openviking-config.json`: ```json { "enabled": true, + "mcp": { "enabled": true }, "timeoutMs": 30000, "repoContext": { "enabled": true, "cacheTtlMs": 60000 }, "autoRecall": { @@ -158,6 +159,20 @@ and `OPENVIKING_PEER_ID` take precedence over values in this file. For advanced setups, `OPENVIKING_PLUGIN_CONFIG` can point to another config file path. +### Hook-only mode + +When OpenViking is already exposed through another MCP server, retain the lifecycle hooks while +skipping this plugin's bundled MCP registration: + +```json +{ + "mcp": { "enabled": false } +} +``` + +This leaves repository context, automatic recall, message capture, and lifecycle commits enabled. +It does not add or overwrite OpenCode's `mcp.openviking` entry. + OpenCode's local `read`, `glob`, and `grep` tools cannot read `viking://` URIs. When the agent accidentally tries that, the plugin blocks the filesystem tool call and points it to the OpenViking MCP tools. diff --git a/examples/opencode-plugin/index.mjs b/examples/opencode-plugin/index.mjs index b6c9e3c3a6..039d94e81b 100644 --- a/examples/opencode-plugin/index.mjs +++ b/examples/opencode-plugin/index.mjs @@ -44,8 +44,15 @@ export async function OpenVikingPlugin({ client, directory }) { return { config: async (opencodeConfig) => { - const injected = injectOpenVikingMcpConfig(opencodeConfig, pluginRoot) - log(injected ? "INFO" : "WARN", "mcp", injected ? "Registered OpenViking MCP server" : "OpenViking MCP server was not registered") + const injected = injectOpenVikingMcpConfig(opencodeConfig, pluginRoot, config.mcp.enabled) + const hookOnly = !config.mcp.enabled + log( + injected || hookOnly ? "INFO" : "WARN", + "mcp", + injected ? "Registered OpenViking MCP server" : + hookOnly ? "Skipped bundled MCP registration in hook-only mode" : + "OpenViking MCP server was not registered", + ) }, event: async ({ event }) => { diff --git a/examples/opencode-plugin/lib/config.mjs b/examples/opencode-plugin/lib/config.mjs index 6f5e8ad853..465c1399a0 100644 --- a/examples/opencode-plugin/lib/config.mjs +++ b/examples/opencode-plugin/lib/config.mjs @@ -18,6 +18,9 @@ const DEFAULT_CONFIG = { workspacePeer: true, recallPeerScope: "all", enabled: true, + mcp: { + enabled: true, + }, timeoutMs: 30000, runtime: { dataDir: "", @@ -117,6 +120,12 @@ function applyLegacyConnection(config, fileConfig) { function applyBehaviorConfig(config, fileConfig = {}) { if (fileConfig.enabled !== undefined) config.enabled = fileConfig.enabled !== false + const mcp = fileConfig.mcp && typeof fileConfig.mcp === "object" ? fileConfig.mcp : {} + config.mcp = { + ...DEFAULT_CONFIG.mcp, + ...mcp, + enabled: mcp.enabled !== false, + } if (fileConfig.timeoutMs !== undefined) config.timeoutMs = fileConfig.timeoutMs config.runtime = { ...DEFAULT_CONFIG.runtime, diff --git a/examples/opencode-plugin/lib/mcp-config.mjs b/examples/opencode-plugin/lib/mcp-config.mjs index a5188b1612..4a6d675122 100644 --- a/examples/opencode-plugin/lib/mcp-config.mjs +++ b/examples/opencode-plugin/lib/mcp-config.mjs @@ -11,8 +11,9 @@ export function createOpenVikingMcpConfig(pluginRoot) { } } -export function injectOpenVikingMcpConfig(config, pluginRoot) { +export function injectOpenVikingMcpConfig(config, pluginRoot, enabled = true) { if (!config || typeof config !== "object") return false + if (!enabled) return false config.mcp = config.mcp && typeof config.mcp === "object" ? config.mcp : {} const current = config.mcp[OPENCODE_MCP_NAME] if (current?.enabled === false) return false diff --git a/examples/opencode-plugin/tests/config.test.mjs b/examples/opencode-plugin/tests/config.test.mjs index 2529d53eed..e5cb64aff7 100644 --- a/examples/opencode-plugin/tests/config.test.mjs +++ b/examples/opencode-plugin/tests/config.test.mjs @@ -142,6 +142,28 @@ test("loadConfig can disable workspace peer", async () => { }) }) +test("loadConfig supports hook-only mode without registering the bundled MCP server", async () => { + const snapshot = { ...process.env } + await withTempDir("ov-oc-hook-only-", async (dir) => { + try { + for (const key of Object.keys(process.env)) { + if (key.startsWith("OPENVIKING_")) delete process.env[key] + } + const project = join(dir, "project") + await mkdir(join(project, ".opencode"), { recursive: true }) + await writeFile(join(project, ".opencode", "openviking-config.json"), JSON.stringify({ + mcp: { enabled: false }, + })) + + const cfg = loadConfig(dir, project) + assert.equal(cfg.enabled, true) + assert.equal(cfg.mcp.enabled, false) + } finally { + restoreOpenVikingEnv(snapshot) + } + }) +}) + test("loadConfig preserves an explicit zero commit keep recent count", async () => { const snapshot = { ...process.env } await withTempDir("ov-oc-keep-recent-zero-", async (dir) => { diff --git a/examples/opencode-plugin/tests/mcp-config.test.mjs b/examples/opencode-plugin/tests/mcp-config.test.mjs index a227e940ed..c4e65c0f81 100644 --- a/examples/opencode-plugin/tests/mcp-config.test.mjs +++ b/examples/opencode-plugin/tests/mcp-config.test.mjs @@ -26,10 +26,20 @@ test("injectOpenVikingMcpConfig respects explicit disabled MCP server", () => { assert.deepEqual(config.mcp.openviking, { enabled: false }) }) +test("injectOpenVikingMcpConfig leaves OpenCode MCP config untouched in hook-only mode", () => { + const config = { mcp: { external: { type: "remote", url: "https://example.com/mcp" } } } + + assert.equal(injectOpenVikingMcpConfig(config, "/tmp/openviking-plugin", false), false) + assert.deepEqual(config, { + mcp: { external: { type: "remote", url: "https://example.com/mcp" } }, + }) +}) + test("OpenCode plugin keeps tools on MCP rather than native tool hook", async () => { const source = await readFile(join(testDir, "../index.mjs"), "utf8") assert.match(source, /injectOpenVikingMcpConfig/) + assert.match(source, /injectOpenVikingMcpConfig\(opencodeConfig, pluginRoot, config\.mcp\.enabled\)/) assert.doesNotMatch(source, /createMemoryTools/) assert.doesNotMatch(source, /createCodeTools/) assert.doesNotMatch(source, /\btool:\s*\{/) From 9548bfdc4e5c1162ef3de3d9926067006d5f6ead Mon Sep 17 00:00:00 2001 From: Valor <210239105+zrh805@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:21:28 +0800 Subject: [PATCH 2/2] test(opencode): cover hook-only plugin configuration --- .../opencode-plugin/tests/config.test.mjs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/examples/opencode-plugin/tests/config.test.mjs b/examples/opencode-plugin/tests/config.test.mjs index e5cb64aff7..445a7af6ce 100644 --- a/examples/opencode-plugin/tests/config.test.mjs +++ b/examples/opencode-plugin/tests/config.test.mjs @@ -1,9 +1,11 @@ import test from "node:test" import assert from "node:assert/strict" +import { createServer } from "node:http" import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { loadConfig } from "../lib/config.mjs" +import { OpenVikingPlugin } from "../index.mjs" async function withTempDir(prefix, fn) { const dir = await mkdtemp(join(tmpdir(), prefix)) @@ -14,6 +16,25 @@ async function withTempDir(prefix, fn) { } } +async function withHealthServer(fn) { + const server = createServer((request, response) => { + response.setHeader("Content-Type", "application/json") + if (request.url === "/health") { + response.end(JSON.stringify({ status: "ok" })) + return + } + response.statusCode = 404 + response.end(JSON.stringify({ status: "error" })) + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + try { + const { port } = server.address() + return await fn(`http://127.0.0.1:${port}`) + } finally { + await new Promise((resolve) => server.close(resolve)) + } +} + function restoreOpenVikingEnv(snapshot) { for (const key of Object.keys(process.env)) { if (key.startsWith("OPENVIKING_")) delete process.env[key] @@ -164,6 +185,43 @@ test("loadConfig supports hook-only mode without registering the bundled MCP ser }) }) +test("OpenVikingPlugin keeps lifecycle hooks without mutating MCP config in hook-only mode", async () => { + const snapshot = { ...process.env } + await withTempDir("ov-oc-hook-only-runtime-", async (dir) => { + await withHealthServer(async (endpoint) => { + try { + for (const key of Object.keys(process.env)) { + if (key.startsWith("OPENVIKING_")) delete process.env[key] + } + const configPath = join(dir, "openviking-config.json") + await writeFile(configPath, JSON.stringify({ + mcp: { enabled: false }, + runtime: { dataDir: join(dir, "runtime") }, + repoContext: { enabled: false }, + autoRecall: { enabled: false }, + autoCapture: false, + })) + process.env.OPENVIKING_PLUGIN_CONFIG = configPath + process.env.OPENVIKING_URL = endpoint + + const plugin = await OpenVikingPlugin({ client: {}, directory: dir }) + assert.equal(typeof plugin.event, "function") + assert.equal(typeof plugin["chat.message"], "function") + assert.equal(typeof plugin.dispose, "function") + + const opencodeConfig = { mcp: { external: { type: "remote", url: "https://example.com/mcp" } } } + await plugin.config(opencodeConfig) + assert.deepEqual(opencodeConfig, { + mcp: { external: { type: "remote", url: "https://example.com/mcp" } }, + }) + await plugin.dispose() + } finally { + restoreOpenVikingEnv(snapshot) + } + }) + }) +}) + test("loadConfig preserves an explicit zero commit keep recent count", async () => { const snapshot = { ...process.env } await withTempDir("ov-oc-keep-recent-zero-", async (dir) => {