Skip to content
Merged
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
3 changes: 2 additions & 1 deletion plugin/skills/agentmemory-agents/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- AUTOGEN:agents START - generated by scripts/skills/generate.ts, do not edit by hand -->
`agentmemory connect <agent>` wires the memory server into a host agent. 17 adapters:
`agentmemory connect <agent>` wires the memory server into a host agent. 18 adapters:

| Agent | Name | Protocol |
| --- | --- | --- |
Expand All @@ -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. |
Expand Down
2 changes: 2 additions & 0 deletions src/cli/connect/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -33,6 +34,7 @@ export const ADAPTERS: readonly ConnectAdapter[] = [
continueDev,
zed,
droid,
opencode,
openclaw,
hermes,
pi,
Expand Down
107 changes: 107 additions & 0 deletions src/cli/connect/opencode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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");

// 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,
};

type OpencodeConfig = Record<string, unknown>;
type McpEntry = Record<string, unknown>;

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<ConnectResult> {
const existing = readJsonSafe<OpencodeConfig>(CONFIG_PATH);
const next: OpencodeConfig = existing ? { ...existing } : {};
const existingMcp = next["mcp"];
const mcp: Record<string, McpEntry> =
existingMcp &&
typeof existingMcp === "object" &&
!Array.isArray(existingMcp)
? { ...(existingMcp as Record<string, McpEntry>) }
: {};

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<OpencodeConfig>(CONFIG_PATH);
const verifyMcp = verify?.["mcp"] as Record<string, McpEntry> | 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 }),
};
},
};
66 changes: 32 additions & 34 deletions src/cli/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,38 +27,39 @@ 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));

// 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<string, string> = {
"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" },
Expand All @@ -78,17 +79,14 @@ const PROVIDER_COST_HINTS: Record<string, string> = {
};

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",
}));
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"),
];
}

Expand Down
77 changes: 76 additions & 1 deletion test/cli-connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ describe("agentmemory connect — dispatcher", () => {
"gemini-cli",
"hermes",
"kiro",
"opencode",
"openclaw",
"openhuman",
"pi",
Expand All @@ -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()", () => {
Expand Down Expand Up @@ -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<ConnectAdapter> {
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;
Expand Down