Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions examples/opencode-plugin/INSTALL-ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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。
Expand Down
15 changes: 15 additions & 0 deletions examples/opencode-plugin/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Example configuration:
```json
{
"enabled": true,
"mcp": { "enabled": true },
"timeoutMs": 30000,
"repoContext": { "enabled": true, "cacheTtlMs": 60000 },
"autoRecall": {
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions examples/opencode-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ Create `~/.config/opencode/openviking-config.json`:
```json
{
"enabled": true,
"mcp": { "enabled": true },
"timeoutMs": 30000,
"repoContext": { "enabled": true, "cacheTtlMs": 60000 },
"autoRecall": {
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions examples/opencode-plugin/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
9 changes: 9 additions & 0 deletions examples/opencode-plugin/lib/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const DEFAULT_CONFIG = {
workspacePeer: true,
recallPeerScope: "all",
enabled: true,
mcp: {
enabled: true,
},
timeoutMs: 30000,
runtime: {
dataDir: "",
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion examples/opencode-plugin/lib/mcp-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions examples/opencode-plugin/tests/config.test.mjs
Original file line number Diff line number Diff line change
@@ -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))
Expand All @@ -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]
Expand Down Expand Up @@ -142,6 +163,65 @@ 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("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) => {
Expand Down
10 changes: 10 additions & 0 deletions examples/opencode-plugin/tests/mcp-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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*\{/)
Expand Down