From fcd5472d9665f4f05f9fae179a583045b8eaddc3 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Wed, 10 Jun 2026 10:52:43 +0100 Subject: [PATCH 1/3] fix(connect): add opencode adapter and derive onboarding picker from ADAPTERS The onboarding agent picker and the connect interactive menu maintained two separate hardcoded lists that had drifted: onboarding offered opencode/goose/kilo/aider/claude-desktop/windsurf/roo (which connect had no adapter for, so picking them hit 'no adapter available, skipped'), while connect had antigravity/kiro/warp/continue/zed/droid/qwen that onboarding never offered. Add a real opencode adapter writing OpenCode's documented mcp schema (top-level mcp key, command as array, type/enabled), and make onboarding's buildAgentOptions derive from connect's ADAPTERS so the two can never diverge again. Connect-only adapters now appear in onboarding; opencode is now wireable; phantom agents with no adapter no longer appear. Fixes #872. --- src/cli/connect/index.ts | 2 + src/cli/connect/opencode.ts | 102 ++++++++++++++++++++++++++++++++++++ src/cli/onboarding.ts | 67 ++++++++++++----------- test/cli-connect.test.ts | 77 ++++++++++++++++++++++++++- 4 files changed, 213 insertions(+), 35 deletions(-) create mode 100644 src/cli/connect/opencode.ts diff --git a/src/cli/connect/index.ts b/src/cli/connect/index.ts index c32adb07a..ccf0553e8 100644 --- a/src/cli/connect/index.ts +++ b/src/cli/connect/index.ts @@ -13,6 +13,7 @@ import { adapter as geminiCli } from "./gemini-cli.js"; import { adapter as hermes } from "./hermes.js"; import { adapter as kiro } from "./kiro.js"; import { adapter as openclaw } from "./openclaw.js"; +import { adapter as opencode } from "./opencode.js"; import { adapter as openhuman } from "./openhuman.js"; import { adapter as pi } from "./pi.js"; import { adapter as qwen } from "./qwen.js"; @@ -33,6 +34,7 @@ export const ADAPTERS: readonly ConnectAdapter[] = [ continueDev, zed, droid, + opencode, openclaw, hermes, pi, diff --git a/src/cli/connect/opencode.ts b/src/cli/connect/opencode.ts new file mode 100644 index 000000000..04d76e62b --- /dev/null +++ b/src/cli/connect/opencode.ts @@ -0,0 +1,102 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import * as p from "@clack/prompts"; +import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; +import { + backupFile, + logAlreadyWired, + logBackup, + logInstalled, + readJsonSafe, + writeJsonAtomic, +} from "./util.js"; + +// OpenCode does not use the standard `mcpServers` block. Its config is a +// top-level `mcp` key whose entries carry `type`, `command` as an array, +// and `enabled` (docs: README "OpenCode (MCP only)"). So it needs its own +// adapter rather than createJsonMcpAdapter. + +const CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode.json"); +const DETECT_DIR = join(homedir(), ".config", "opencode"); + +const OPENCODE_ENTRY = { + type: "local", + command: ["npx", "-y", "@agentmemory/mcp"], + enabled: true, + environment: { + AGENTMEMORY_URL: "${AGENTMEMORY_URL:-http://localhost:3111}", + AGENTMEMORY_SECRET: "${AGENTMEMORY_SECRET:-}", + AGENTMEMORY_TOOLS: "${AGENTMEMORY_TOOLS:-all}", + }, +}; + +type OpencodeConfig = Record; +type McpEntry = Record; + +function entryMatches(entry: unknown): boolean { + if (!entry || typeof entry !== "object") return false; + const command = (entry as McpEntry)["command"]; + return Array.isArray(command) && command.includes("@agentmemory/mcp"); +} + +export const adapter: ConnectAdapter = { + name: "opencode", + displayName: "OpenCode", + docs: "https://github.com/rohitg00/agentmemory#other-agents", + protocolNote: + "Using MCP via ~/.config/opencode/opencode.json (top-level `mcp` key). For full auto-capture, also install the bundled plugin in plugin/opencode/.", + + detect(): boolean { + return existsSync(DETECT_DIR); + }, + + async install(opts: ConnectOptions): Promise { + const existing = readJsonSafe(CONFIG_PATH); + const next: OpencodeConfig = existing ? { ...existing } : {}; + const mcp: Record = { + ...((next["mcp"] as Record) ?? {}), + }; + + const alreadyHas = entryMatches(mcp["agentmemory"]); + if (alreadyHas && !opts.force) { + logAlreadyWired(this.displayName, CONFIG_PATH); + return { kind: "already-wired", mutatedPath: CONFIG_PATH }; + } + + if (opts.dryRun) { + p.log.info( + `[dry-run] Would ${alreadyHas ? "overwrite" : "add"} mcp.agentmemory in ${CONFIG_PATH}`, + ); + return { kind: "installed", mutatedPath: CONFIG_PATH }; + } + + let backupPath: string | undefined; + if (existsSync(CONFIG_PATH)) { + backupPath = backupFile(CONFIG_PATH, this.name); + logBackup(backupPath); + } else { + mkdirSync(dirname(CONFIG_PATH), { recursive: true }); + } + + mcp["agentmemory"] = { ...OPENCODE_ENTRY }; + next["mcp"] = mcp; + writeJsonAtomic(CONFIG_PATH, next); + + const verify = readJsonSafe(CONFIG_PATH); + const verifyMcp = verify?.["mcp"] as Record | undefined; + if (!entryMatches(verifyMcp?.["agentmemory"])) { + p.log.error( + `Verification failed: ${CONFIG_PATH} did not contain mcp.agentmemory after write.`, + ); + return { kind: "skipped", reason: "verification-failed" }; + } + + logInstalled(this.displayName, CONFIG_PATH); + return { + kind: "installed", + mutatedPath: CONFIG_PATH, + ...(backupPath !== undefined && { backupPath }), + }; + }, +}; diff --git a/src/cli/onboarding.ts b/src/cli/onboarding.ts index cd0f7c824..5a1f716b8 100644 --- a/src/cli/onboarding.ts +++ b/src/cli/onboarding.ts @@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url"; import * as p from "@clack/prompts"; import { appendFileSync, readFileSync } from "node:fs"; import { readPrefs, writePrefs } from "./preferences.js"; -import { resolveAdapter, runAdapter } from "./connect/index.js"; +import { ADAPTERS, resolveAdapter, runAdapter } from "./connect/index.js"; import type { ConnectResult } from "./connect/types.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -35,30 +35,31 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); // Native plugin row — these agents ship an agentmemory plugin or // first-party integration. Glyphs match SkillKit's published set // where they overlap; the rest fall back to the generic `◇`. -const NATIVE_AGENTS: { value: string; label: string; glyph: string }[] = [ - { value: "claude-code", label: "Claude Code", glyph: "⟁" }, - { value: "copilot-cli", label: "GitHub Copilot CLI", glyph: "◈" }, - { value: "codex", label: "Codex", glyph: "◎" }, - { value: "openhuman", label: "OpenHuman", glyph: "◇" }, - { value: "openclaw", label: "OpenClaw", glyph: "◇" }, - { value: "hermes", label: "Hermes", glyph: "◇" }, - { value: "pi", label: "Pi", glyph: "◇" }, - { value: "cursor", label: "Cursor", glyph: "◫" }, - { value: "gemini-cli", label: "Gemini CLI", glyph: "✦" }, -]; +// Display glyph per agent; the agent set itself comes from connect's +// ADAPTERS (single source of truth) so the picker can never drift from +// what `agentmemory connect` can actually wire (#872). Unknown adapters +// fall back to a neutral glyph. +const AGENT_GLYPH: Record = { + "claude-code": "⟁", + "copilot-cli": "◈", + codex: "◎", + cursor: "◫", + "gemini-cli": "✦", + opencode: "⬡", +}; -// MCP-only row — these agents use the MCP server we ship rather than -// a native plugin. -const MCP_AGENTS: { value: string; label: string; glyph: string }[] = [ - { value: "opencode", label: "OpenCode", glyph: "⬡" }, - { value: "cline", label: "Cline", glyph: "◇" }, - { value: "goose", label: "Goose", glyph: "◇" }, - { value: "kilo", label: "Kilo", glyph: "◇" }, - { value: "aider", label: "Aider", glyph: "◇" }, - { value: "claude-desktop", label: "Claude Desktop", glyph: "⟁" }, - { value: "windsurf", label: "Windsurf", glyph: "◇" }, - { value: "roo", label: "Roo", glyph: "◇" }, -]; +// Agents wired through a native plugin / lifecycle hooks rather than the +// MCP server. Cosmetic grouping only; everything still goes through an +// adapter. +const NATIVE_AGENTS = new Set([ + "claude-code", + "copilot-cli", + "codex", + "openhuman", + "openclaw", + "hermes", + "pi", +]); const PROVIDERS: { value: string; label: string; envKey: string | null }[] = [ { value: "anthropic", label: "Anthropic — claude", envKey: "ANTHROPIC_API_KEY" }, @@ -78,17 +79,15 @@ const PROVIDER_COST_HINTS: Record = { }; export function buildAgentOptions(): { value: string; label: string; hint?: string }[] { + const options = ADAPTERS.map((a) => ({ + value: a.name, + label: `${AGENT_GLYPH[a.name] ?? "◇"} ${a.displayName}`, + hint: NATIVE_AGENTS.has(a.name) ? "native plugin" : "MCP server", + })); + // Native plugins first, then MCP-only, each keeping ADAPTERS order. return [ - ...NATIVE_AGENTS.map((a) => ({ - value: a.value, - label: `${a.glyph} ${a.label}`, - hint: "native plugin", - })), - ...MCP_AGENTS.map((a) => ({ - value: a.value, - label: `${a.glyph} ${a.label}`, - hint: "MCP server", - })), + ...options.filter((o) => o.hint === "native plugin"), + ...options.filter((o) => o.hint === "MCP server"), ]; } diff --git a/test/cli-connect.test.ts b/test/cli-connect.test.ts index 49d719193..39822ec09 100644 --- a/test/cli-connect.test.ts +++ b/test/cli-connect.test.ts @@ -54,6 +54,7 @@ describe("agentmemory connect — dispatcher", () => { "gemini-cli", "hermes", "kiro", + "opencode", "openclaw", "openhuman", "pi", @@ -62,7 +63,7 @@ describe("agentmemory connect — dispatcher", () => { "zed", ].sort(), ); - expect(ADAPTERS.length).toBe(17); + expect(ADAPTERS.length).toBe(18); }); it("every adapter exposes detect() and install()", () => { @@ -207,6 +208,80 @@ describe("agentmemory connect — claude-code adapter (mock filesystem)", () => }); }); +describe("agentmemory connect — opencode adapter (#872)", () => { + let tmpHome: string; + let originalHome: string | undefined; + let originalUserprofile: string | undefined; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "am-opencode-")); + originalHome = process.env["HOME"]; + originalUserprofile = process.env["USERPROFILE"]; + process.env["HOME"] = tmpHome; + process.env["USERPROFILE"] = tmpHome; + vi.resetModules(); + }); + + afterEach(() => { + if (originalHome !== undefined) process.env["HOME"] = originalHome; + else delete process.env["HOME"]; + if (originalUserprofile !== undefined) + process.env["USERPROFILE"] = originalUserprofile; + else delete process.env["USERPROFILE"]; + rmSync(tmpHome, { recursive: true, force: true }); + vi.resetModules(); + }); + + const cfgPath = () => + join(tmpHome, ".config", "opencode", "opencode.json"); + + async function loadOpencode(): Promise { + const mod = await import("../src/cli/connect/opencode.js?t=" + Date.now()); + return (mod as { adapter: ConnectAdapter }).adapter; + } + + it("writes the opencode `mcp` schema (command as array) and preserves other servers", async () => { + require("node:fs").mkdirSync(join(tmpHome, ".config", "opencode"), { + recursive: true, + }); + writeFileSync( + cfgPath(), + JSON.stringify({ mcp: { other: { type: "local", command: ["x"] } } }), + ); + + const a = await loadOpencode(); + expect(a.name).toBe("opencode"); + expect(a.detect()).toBe(true); + + const first = await a.install({ dryRun: false, force: false }); + expect(first.kind).toBe("installed"); + + const config = JSON.parse(readFileSync(cfgPath(), "utf-8")); + const entry = config.mcp.agentmemory; + expect(entry.type).toBe("local"); + expect(Array.isArray(entry.command)).toBe(true); + expect(entry.command).toContain("@agentmemory/mcp"); + expect(entry.enabled).toBe(true); + expect(config.mcp.other.command).toEqual(["x"]); + + const second = await a.install({ dryRun: false, force: false }); + expect(second.kind).toBe("already-wired"); + }); + + it("dry-run does not mutate the file", async () => { + require("node:fs").mkdirSync(join(tmpHome, ".config", "opencode"), { + recursive: true, + }); + const before = JSON.stringify({ mcp: {} }); + writeFileSync(cfgPath(), before); + + const a = await loadOpencode(); + const result = await a.install({ dryRun: true, force: false }); + expect(result.kind).toBe("installed"); + expect(readFileSync(cfgPath(), "utf-8")).toBe(before); + }); +}); + describe("agentmemory connect — copilot-cli adapter (mock filesystem)", () => { let tmpHome: string; let originalHome: string | undefined; From 154ab674808b2123be52871c33640d117566f241 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Wed, 10 Jun 2026 13:54:23 +0100 Subject: [PATCH 2/3] fix(connect): harden opencode adapter mcp merge and drop unexpanded env block Guard the mcp spread against a non-object value in an existing opencode.json, and remove the environment block whose shell-style ${VAR:-default} values OpenCode does not expand (writing them literally would clobber a user's real shell AGENTMEMORY_URL). The stdio child inherits the shell env and the shim defaults unset vars. Also drop a redundant inline comment in onboarding. --- src/cli/connect/opencode.ts | 21 +++++++++++++-------- src/cli/onboarding.ts | 1 - 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/cli/connect/opencode.ts b/src/cli/connect/opencode.ts index 04d76e62b..630657202 100644 --- a/src/cli/connect/opencode.ts +++ b/src/cli/connect/opencode.ts @@ -20,15 +20,16 @@ import { const CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode.json"); const DETECT_DIR = join(homedir(), ".config", "opencode"); +// No `environment` block: OpenCode does not expand shell-style +// `${VAR:-default}` values, and writing them literally would override the +// user's real shell AGENTMEMORY_URL with an unexpanded string. The stdio +// child inherits the shell environment (an exported AGENTMEMORY_URL / +// AGENTMEMORY_SECRET still reaches the server), and the @agentmemory/mcp +// shim defaults unset vars (URL -> localhost:3111, no secret, all tools). const OPENCODE_ENTRY = { type: "local", command: ["npx", "-y", "@agentmemory/mcp"], enabled: true, - environment: { - AGENTMEMORY_URL: "${AGENTMEMORY_URL:-http://localhost:3111}", - AGENTMEMORY_SECRET: "${AGENTMEMORY_SECRET:-}", - AGENTMEMORY_TOOLS: "${AGENTMEMORY_TOOLS:-all}", - }, }; type OpencodeConfig = Record; @@ -54,9 +55,13 @@ export const adapter: ConnectAdapter = { async install(opts: ConnectOptions): Promise { const existing = readJsonSafe(CONFIG_PATH); const next: OpencodeConfig = existing ? { ...existing } : {}; - const mcp: Record = { - ...((next["mcp"] as Record) ?? {}), - }; + const existingMcp = next["mcp"]; + const mcp: Record = + existingMcp && + typeof existingMcp === "object" && + !Array.isArray(existingMcp) + ? { ...(existingMcp as Record) } + : {}; const alreadyHas = entryMatches(mcp["agentmemory"]); if (alreadyHas && !opts.force) { diff --git a/src/cli/onboarding.ts b/src/cli/onboarding.ts index 5a1f716b8..5d9ed7adf 100644 --- a/src/cli/onboarding.ts +++ b/src/cli/onboarding.ts @@ -84,7 +84,6 @@ export function buildAgentOptions(): { value: string; label: string; hint?: stri label: `${AGENT_GLYPH[a.name] ?? "◇"} ${a.displayName}`, hint: NATIVE_AGENTS.has(a.name) ? "native plugin" : "MCP server", })); - // Native plugins first, then MCP-only, each keeping ADAPTERS order. return [ ...options.filter((o) => o.hint === "native plugin"), ...options.filter((o) => o.hint === "MCP server"), From b090cfda0ae35ee0c6e8ac9d76a59c9a3bd5264c Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Wed, 10 Jun 2026 14:10:09 +0100 Subject: [PATCH 3/3] docs(skills): regenerate agents reference for opencode adapter Adding the opencode adapter changed ADAPTERS (17 -> 18); regenerate the auto-derived agents table so npm run skills:check passes in CI. --- plugin/skills/agentmemory-agents/REFERENCE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugin/skills/agentmemory-agents/REFERENCE.md b/plugin/skills/agentmemory-agents/REFERENCE.md index 7be767134..1dd0b7a20 100644 --- a/plugin/skills/agentmemory-agents/REFERENCE.md +++ b/plugin/skills/agentmemory-agents/REFERENCE.md @@ -3,7 +3,7 @@ Generated from `src/cli/connect/index.ts`. Do not edit the block below by hand; run `npm run skills:gen` after adding or removing an adapter. -`agentmemory connect ` wires the memory server into a host agent. 17 adapters: +`agentmemory connect ` wires the memory server into a host agent. 18 adapters: | Agent | Name | Protocol | | --- | --- | --- | @@ -19,6 +19,7 @@ Generated from `src/cli/connect/index.ts`. Do not edit the block below by hand; | Hermes Agent | `hermes` | Using MCP. Hooks are also available, see docs/hermes.md. | | Kiro | `kiro` | Using MCP via ~/.kiro/settings/mcp.json (user-level). Workspace overrides live in .kiro/settings/mcp.json. | | OpenClaw | `openclaw` | Using MCP. Hooks are also available, see docs/openclaw.md. | +| OpenCode | `opencode` | Using MCP via ~/.config/opencode/opencode.json (top-level `mcp` key). For full auto-capture, also install the bundled plugin in plugin/opencode/. | | OpenHuman | `openhuman` | Using native hooks (REST API at :3111). MCP not required. | | pi | `pi` | Using native hooks (REST API at :3111). MCP not required. | | Qwen Code | `qwen` | Using MCP via ~/.qwen/settings.json. Qwen Code's hook system can also be wired separately, see docs. |