From 42bc0964f6e46d72154c08ae1f343b9083c3824b Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Mon, 11 May 2026 23:35:56 +0200 Subject: [PATCH 1/4] Add multi-IDE installer support for hook and MCP targets --- plugin/scripts/codex.mjs | 94 ++++++++++++++ plugin/scripts/cursor.mjs | 147 ++++++++++++++++++++++ src/cli.ts | 29 +++++ src/hooks/codex.ts | 111 +++++++++++++++++ src/hooks/cursor.ts | 180 +++++++++++++++++++++++++++ src/installers.ts | 253 ++++++++++++++++++++++++++++++++++++++ test/installers.test.ts | 59 +++++++++ tsdown.config.ts | 2 + 8 files changed, 875 insertions(+) create mode 100755 plugin/scripts/codex.mjs create mode 100755 plugin/scripts/cursor.mjs create mode 100644 src/hooks/codex.ts create mode 100644 src/hooks/cursor.ts create mode 100644 src/installers.ts create mode 100644 test/installers.test.ts diff --git a/plugin/scripts/codex.mjs b/plugin/scripts/codex.mjs new file mode 100755 index 00000000..a99ffeda --- /dev/null +++ b/plugin/scripts/codex.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +//#region src/hooks/codex.ts +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; +function authHeaders() { + const h = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} +async function readJsonFromStdin() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + try { + return JSON.parse(input); + } catch { + return null; + } +} +async function main() { + const payload = await readJsonFromStdin(); + if (!payload) return; + const sid = payload.session_id || `codex-${Date.now().toString(36)}`; + const root = payload.cwd || process.cwd(); + const event = payload.hook_event_name || ""; + try { + if (event === "SessionStart") { + const res = await fetch(`${REST_URL}/agentmemory/session/start`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + sessionId: sid, + project: root, + cwd: root + }), + signal: AbortSignal.timeout(1500) + }); + if (res.ok) { + const data = await res.json(); + if (typeof data.context === "string" && data.context) process.stdout.write(data.context); + } + return; + } + if (event === "UserPromptSubmit") { + await fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "prompt_submit", + sessionId: sid, + project: root, + cwd: root, + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + data: { prompt: payload.prompt || "" } + }), + signal: AbortSignal.timeout(3e3) + }); + return; + } + if (event === "PostToolUse" || event === "PreToolUse") { + await fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "post_tool_use", + sessionId: sid, + project: root, + cwd: root, + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + data: { + tool_name: payload.tool_name || "tool", + tool_input: payload.tool_input || {}, + tool_output: payload.tool_output ?? "tool execution" + } + }), + signal: AbortSignal.timeout(3e3) + }); + return; + } + if (event === "Stop") { + await fetch(`${REST_URL}/agentmemory/session/end`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sessionId: sid }), + signal: AbortSignal.timeout(1500) + }); + return; + } + } catch {} +} +main(); + +//#endregion +export { }; +//# sourceMappingURL=codex.mjs.map \ No newline at end of file diff --git a/plugin/scripts/cursor.mjs b/plugin/scripts/cursor.mjs new file mode 100755 index 00000000..c9351a2f --- /dev/null +++ b/plugin/scripts/cursor.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +//#region src/hooks/cursor.ts +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; +function authHeaders() { + const h = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} +function inferEvent(payload) { + if (typeof payload.hook_event_name === "string" && payload.hook_event_name) return payload.hook_event_name; + if (typeof payload.prompt === "string" && payload.prompt) return "beforeSubmitPrompt"; + if (typeof payload.file_path === "string" && payload.file_path) return "afterFileEdit"; + if (typeof payload.command === "string" && payload.command) return "afterShellExecution"; + if (payload.mcp_server_name || payload.mcp_tool_name) return "afterMCPExecution"; + if (payload.reason) return "stop"; + return "sessionStart"; +} +async function readJsonFromStdin() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + try { + return JSON.parse(input); + } catch { + return null; + } +} +function sessionId(payload) { + return payload.session_id || payload.conversation_id || `cursor-${Date.now().toString(36)}`; +} +function projectRoot(payload) { + return payload.cwd || payload.workspace_roots?.[0] || process.cwd(); +} +async function postObserve(body) { + try { + await fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(3e3) + }); + } catch {} +} +async function postSessionStart(body) { + try { + const res = await fetch(`${REST_URL}/agentmemory/session/start`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(1500) + }); + if (res.ok) { + const data = await res.json(); + if (typeof data.context === "string" && data.context) process.stdout.write(data.context); + } + } catch {} +} +async function main() { + const payload = await readJsonFromStdin(); + if (!payload) return; + const sid = sessionId(payload); + const root = projectRoot(payload); + switch (inferEvent(payload)) { + case "sessionStart": + await postSessionStart({ + sessionId: sid, + project: root, + cwd: root + }); + return; + case "beforeSubmitPrompt": + await postObserve({ + hookType: "prompt_submit", + sessionId: sid, + project: root, + cwd: root, + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + data: { prompt: payload.prompt || "" } + }); + return; + case "afterFileEdit": + await postObserve({ + hookType: "post_tool_use", + sessionId: sid, + project: root, + cwd: root, + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + data: { + tool_name: "CursorEdit", + tool_input: { + file_path: payload.file_path || "", + old_content: payload.old_content, + new_content: payload.new_content + }, + tool_output: `Edited ${payload.file_path || "file"}` + } + }); + return; + case "afterShellExecution": + case "beforeShellExecution": + await postObserve({ + hookType: "post_tool_use", + sessionId: sid, + project: root, + cwd: root, + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + data: { + tool_name: "Bash", + tool_input: { command: payload.command || "" }, + tool_output: payload.reason || "shell execution" + } + }); + return; + case "afterMCPExecution": + case "beforeMCPExecution": + await postObserve({ + hookType: "post_tool_use", + sessionId: sid, + project: root, + cwd: root, + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + data: { + tool_name: payload.mcp_tool_name || payload.mcp_server_name || "MCP", + tool_input: payload.mcp_tool_input || {}, + tool_output: payload.mcp_tool_output ?? "MCP execution" + } + }); + return; + case "stop": + case "sessionEnd": + try { + await fetch(`${REST_URL}/agentmemory/session/end`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sessionId: sid }), + signal: AbortSignal.timeout(1500) + }); + } catch {} + return; + default: return; + } +} +main(); + +//#endregion +export { }; +//# sourceMappingURL=cursor.mjs.map \ No newline at end of file diff --git a/src/cli.ts b/src/cli.ts index ced7a149..c6cb84af 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1120,6 +1120,34 @@ async function runMcp(): Promise { await import("./mcp/standalone.js"); } +async function runInstall(): Promise { + const target = args[1]; + const global = args.includes("--global"); + const rootIdx = args.indexOf("--project-root"); + const projectRoot = rootIdx !== -1 && args[rootIdx + 1] ? args[rootIdx + 1] : undefined; + + if (!target) { + p.log.error( + "Usage: agentmemory install [--global] [--project-root ]", + ); + process.exit(1); + } + + const { installTarget } = await import("./installers.js"); + const result = installTarget(target as never, { global, projectRoot }); + p.note( + [ + `Target: ${result.target}`, + `Files:`, + ...result.filesWritten.map((f) => ` - ${f}`), + "", + `Notes:`, + ...result.notes.map((n) => ` - ${n}`), + ].join("\n"), + "agentmemory install", + ); +} + async function runImportJsonl(): Promise { // Long-form flags that take a value. Their value tokens must be // consumed alongside the flag so they don't leak into positional @@ -1301,6 +1329,7 @@ const commands: Record Promise> = { doctor: runDoctor, demo: runDemo, upgrade: runUpgrade, + install: runInstall, mcp: runMcp, "import-jsonl": runImportJsonl, }; diff --git a/src/hooks/codex.ts b/src/hooks/codex.ts new file mode 100644 index 00000000..f438f0fa --- /dev/null +++ b/src/hooks/codex.ts @@ -0,0 +1,111 @@ +#!/usr/bin/env node + +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; + +type CodexPayload = { + session_id?: string; + transcript_path?: string | null; + cwd?: string; + hook_event_name?: string; + prompt?: string; + tool_name?: string; + tool_input?: Record; + tool_output?: unknown; + reason?: string; +}; + +function authHeaders(): Record { + const h: Record = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} + +async function readJsonFromStdin(): Promise { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + try { + return JSON.parse(input) as T; + } catch { + return null; + } +} + +async function main(): Promise { + const payload = await readJsonFromStdin(); + if (!payload) return; + + const sid = payload.session_id || `codex-${Date.now().toString(36)}`; + const root = payload.cwd || process.cwd(); + const event = payload.hook_event_name || ""; + + try { + if (event === "SessionStart") { + const res = await fetch(`${REST_URL}/agentmemory/session/start`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sessionId: sid, project: root, cwd: root }), + signal: AbortSignal.timeout(1500), + }); + if (res.ok) { + const data = (await res.json()) as { context?: string }; + if (typeof data.context === "string" && data.context) { + process.stdout.write(data.context); + } + } + return; + } + + if (event === "UserPromptSubmit") { + await fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "prompt_submit", + sessionId: sid, + project: root, + cwd: root, + timestamp: new Date().toISOString(), + data: { prompt: payload.prompt || "" }, + }), + signal: AbortSignal.timeout(3000), + }); + return; + } + + if (event === "PostToolUse" || event === "PreToolUse") { + await fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "post_tool_use", + sessionId: sid, + project: root, + cwd: root, + timestamp: new Date().toISOString(), + data: { + tool_name: payload.tool_name || "tool", + tool_input: payload.tool_input || {}, + tool_output: payload.tool_output ?? "tool execution", + }, + }), + signal: AbortSignal.timeout(3000), + }); + return; + } + + if (event === "Stop") { + await fetch(`${REST_URL}/agentmemory/session/end`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sessionId: sid }), + signal: AbortSignal.timeout(1500), + }); + return; + } + } catch { + // hooks must never block host + } +} + +main(); diff --git a/src/hooks/cursor.ts b/src/hooks/cursor.ts new file mode 100644 index 00000000..7cc5e20b --- /dev/null +++ b/src/hooks/cursor.ts @@ -0,0 +1,180 @@ +#!/usr/bin/env node + +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; + +type CursorPayload = { + session_id?: string; + conversation_id?: string; + cwd?: string; + workspace_roots?: string[]; + hook_event_name?: string; + prompt?: string; + command?: string; + file_path?: string; + mcp_server_name?: string; + mcp_tool_name?: string; + mcp_tool_input?: Record; + mcp_tool_output?: unknown; + old_content?: string; + new_content?: string; + reason?: string; +}; + +function authHeaders(): Record { + const h: Record = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} + +function inferEvent(payload: CursorPayload): string { + if (typeof payload.hook_event_name === "string" && payload.hook_event_name) { + return payload.hook_event_name; + } + if (typeof payload.prompt === "string" && payload.prompt) return "beforeSubmitPrompt"; + if (typeof payload.file_path === "string" && payload.file_path) return "afterFileEdit"; + if (typeof payload.command === "string" && payload.command) return "afterShellExecution"; + if (payload.mcp_server_name || payload.mcp_tool_name) return "afterMCPExecution"; + if (payload.reason) return "stop"; + return "sessionStart"; +} + +async function readJsonFromStdin(): Promise { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + try { + return JSON.parse(input) as T; + } catch { + return null; + } +} + +function sessionId(payload: CursorPayload): string { + return payload.session_id || payload.conversation_id || `cursor-${Date.now().toString(36)}`; +} + +function projectRoot(payload: CursorPayload): string { + return payload.cwd || payload.workspace_roots?.[0] || process.cwd(); +} + +async function postObserve(body: Record): Promise { + try { + await fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(3000), + }); + } catch { + // hooks must never block the host + } +} + +async function postSessionStart(body: Record): Promise { + try { + const res = await fetch(`${REST_URL}/agentmemory/session/start`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(1500), + }); + if (res.ok) { + const data = (await res.json()) as { context?: string }; + if (typeof data.context === "string" && data.context) { + process.stdout.write(data.context); + } + } + } catch { + // hooks must never block the host + } +} + +async function main(): Promise { + const payload = await readJsonFromStdin(); + if (!payload) return; + + const sid = sessionId(payload); + const root = projectRoot(payload); + const event = inferEvent(payload); + + switch (event) { + case "sessionStart": + await postSessionStart({ sessionId: sid, project: root, cwd: root }); + return; + case "beforeSubmitPrompt": + await postObserve({ + hookType: "prompt_submit", + sessionId: sid, + project: root, + cwd: root, + timestamp: new Date().toISOString(), + data: { prompt: payload.prompt || "" }, + }); + return; + case "afterFileEdit": + await postObserve({ + hookType: "post_tool_use", + sessionId: sid, + project: root, + cwd: root, + timestamp: new Date().toISOString(), + data: { + tool_name: "CursorEdit", + tool_input: { + file_path: payload.file_path || "", + old_content: payload.old_content, + new_content: payload.new_content, + }, + tool_output: `Edited ${payload.file_path || "file"}`, + }, + }); + return; + case "afterShellExecution": + case "beforeShellExecution": + await postObserve({ + hookType: "post_tool_use", + sessionId: sid, + project: root, + cwd: root, + timestamp: new Date().toISOString(), + data: { + tool_name: "Bash", + tool_input: { command: payload.command || "" }, + tool_output: payload.reason || "shell execution", + }, + }); + return; + case "afterMCPExecution": + case "beforeMCPExecution": + await postObserve({ + hookType: "post_tool_use", + sessionId: sid, + project: root, + cwd: root, + timestamp: new Date().toISOString(), + data: { + tool_name: payload.mcp_tool_name || payload.mcp_server_name || "MCP", + tool_input: payload.mcp_tool_input || {}, + tool_output: payload.mcp_tool_output ?? "MCP execution", + }, + }); + return; + case "stop": + case "sessionEnd": + try { + await fetch(`${REST_URL}/agentmemory/session/end`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sessionId: sid }), + signal: AbortSignal.timeout(1500), + }); + } catch { + // ignore + } + return; + default: + return; + } +} + +main(); diff --git a/src/installers.ts b/src/installers.ts new file mode 100644 index 00000000..ae1d72c1 --- /dev/null +++ b/src/installers.ts @@ -0,0 +1,253 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +export type InstallTarget = + | "opencode" + | "cursor" + | "codex" + | "roo" + | "kilo" + | "pi" + | "openclaw" + | "hermes"; + +export type InstallResult = { + target: InstallTarget; + filesWritten: string[]; + notes: string[]; +}; + +function ensureDir(path: string): void { + mkdirSync(path, { recursive: true }); +} + +function writeText(path: string, content: string): void { + ensureDir(dirname(path)); + writeFileSync(path, content, "utf8"); +} + +function readJson(path: string): Record { + if (!existsSync(path)) return {}; + try { + return JSON.parse(readFileSync(path, "utf8")) as Record; + } catch { + return {}; + } +} + +function writeJson(path: string, value: unknown): void { + writeText(path, JSON.stringify(value, null, 2)); +} + +function packageRootFromCliDist(): string { + return resolve(dirname(new URL(import.meta.url).pathname), ".."); +} + +function commandScript(packageRoot: string, rel: string): string { + return `node ${join(packageRoot, "dist", "hooks", rel).replace(/\\/g, "/")}`; +} + +function mergeCursorMcp(configPath: string): void { + const current = readJson(configPath); + const mcpServers = (current.mcpServers as Record) || {}; + mcpServers.agentmemory = { + command: "npx", + args: ["-y", "@agentmemory/mcp"], + env: { AGENTMEMORY_URL: "http://localhost:3111" }, + }; + current.mcpServers = mcpServers; + writeJson(configPath, current); +} + +function mergeCursorHooks(configPath: string, packageRoot: string): void { + const current = readJson(configPath); + const hooks = (current.hooks as Record) || {}; + const script = commandScript(packageRoot, "cursor.mjs"); + const entry = [{ command: script }]; + hooks.sessionStart = entry; + hooks.beforeSubmitPrompt = entry; + hooks.afterFileEdit = entry; + hooks.afterShellExecution = entry; + hooks.afterMCPExecution = entry; + hooks.preCompact = entry; + hooks.stop = entry; + current.version = 1; + current.hooks = hooks; + writeJson(configPath, current); +} + +function mergeOpenCodeConfig(configPath: string): void { + const current = readJson(configPath); + const mcp = (current.mcp as Record) || {}; + mcp.agentmemory = { + type: "local", + command: ["npx", "-y", "@agentmemory/mcp"], + environment: { AGENTMEMORY_URL: "http://localhost:3111" }, + enabled: true, + }; + current.$schema = "https://opencode.ai/config.json"; + current.mcp = mcp; + writeJson(configPath, current); +} + +function generateOpenCodePlugin(packageRoot: string): string { + const sessionStartScript = commandScript(packageRoot, "session-start.mjs"); + const promptScript = commandScript(packageRoot, "prompt-submit.mjs"); + const postToolScript = commandScript(packageRoot, "post-tool-use.mjs"); + const stopScript = commandScript(packageRoot, "session-end.mjs"); + return `export const AgentmemoryPlugin = async ({ $ }) => ({ + event: async ({ event }) => { + if (event.type === 'session.created') { + await $\`${sessionStartScript}\`.stdin(JSON.stringify({ session_id: 'opencode-' + Date.now().toString(36), cwd: process.cwd() })).quiet().nothrow(); + } else if (event.type === 'session.idle') { + await $\`${stopScript}\`.stdin(JSON.stringify({ session_id: 'opencode-' + Date.now().toString(36), cwd: process.cwd() })).quiet().nothrow(); + } else if (event.type === 'file.edited') { + await $\`${postToolScript}\`.stdin(JSON.stringify({ session_id: 'opencode-' + Date.now().toString(36), cwd: process.cwd(), tool_name: 'OpenCodeEdit', tool_input: { file_path: event.properties?.path ?? '' }, tool_output: 'file edited' })).quiet().nothrow(); + } else if (event.type === 'command.executed') { + await $\`${postToolScript}\`.stdin(JSON.stringify({ session_id: 'opencode-' + Date.now().toString(36), cwd: process.cwd(), tool_name: 'Bash', tool_input: { command: event.properties?.command ?? '' }, tool_output: 'command executed' })).quiet().nothrow(); + } + }, + 'tool.execute.after': async (input, output) => { + await $\`${postToolScript}\`.stdin(JSON.stringify({ session_id: 'opencode-' + Date.now().toString(36), cwd: process.cwd(), tool_name: input.tool, tool_input: input.args, tool_output: output })).quiet().nothrow(); + }, + 'chat.message': async (_input, output) => { + if (output?.message) { + await $\`${promptScript}\`.stdin(JSON.stringify({ session_id: 'opencode-' + Date.now().toString(36), cwd: process.cwd(), prompt: output.message })).quiet().nothrow(); + } + } +}); +`; +} + +function mergeCodexConfig(configPath: string): void { + const current = existsSync(configPath) ? readFileSync(configPath, "utf8") : ""; + let next = current; + if (!next.includes("[features]")) next += (next.endsWith("\n") ? "" : "\n") + "[features]\n"; + if (!/codex_hooks\s*=\s*true/.test(next)) { + next = next.replace(/\[features\][^[]*/m, (block) => block.includes("codex_hooks") ? block : `${block}codex_hooks = true\n`); + } + if (!next.includes("[mcp_servers.agentmemory]")) { + next += `${next.endsWith("\n") ? "" : "\n"}\n[mcp_servers.agentmemory]\ncommand = \"npx\"\nargs = [\"-y\", \"@agentmemory/mcp\"]\n\n[mcp_servers.agentmemory.env]\nAGENTMEMORY_URL = \"http://localhost:3111\"\n`; + } + writeText(configPath, next); +} + +function generateCodexHooks(packageRoot: string): Record { + return { + hooks: { + SessionStart: [{ hooks: [{ type: "command", command: commandScript(packageRoot, "codex.mjs") }] }], + UserPromptSubmit: [{ hooks: [{ type: "command", command: commandScript(packageRoot, "codex.mjs") }] }], + PostToolUse: [{ matcher: ".*", hooks: [{ type: "command", command: commandScript(packageRoot, "codex.mjs") }] }], + Stop: [{ hooks: [{ type: "command", command: commandScript(packageRoot, "codex.mjs") }] }], + }, + }; +} + +function mergeGenericMcpJson(configPath: string): void { + const current = readJson(configPath); + const servers = (current.mcpServers as Record) || {}; + servers.agentmemory = { + command: "npx", + args: ["-y", "@agentmemory/mcp"], + env: { AGENTMEMORY_URL: "http://localhost:3111" }, + }; + current.mcpServers = servers; + writeJson(configPath, current); +} + +function ensureArraySetting(configPath: string, key: string, value: string): void { + const current = readJson(configPath); + const arr = Array.isArray(current[key]) ? [...(current[key] as string[])] : []; + if (!arr.includes(value)) arr.push(value); + current[key] = arr; + writeJson(configPath, current); +} + +function copyDir(src: string, dest: string): void { + ensureDir(dirname(dest)); + cpSync(src, dest, { recursive: true }); +} + +export function installTarget(target: InstallTarget, opts?: { projectRoot?: string; global?: boolean }): InstallResult { + const packageRoot = packageRootFromCliDist(); + const projectRoot = opts?.projectRoot || process.cwd(); + const filesWritten: string[] = []; + const notes: string[] = []; + + switch (target) { + case "opencode": { + const pluginPath = join(projectRoot, ".opencode", "plugins", "agentmemory.js"); + const configPath = join(projectRoot, "opencode.json"); + writeText(pluginPath, generateOpenCodePlugin(packageRoot)); + mergeOpenCodeConfig(configPath); + filesWritten.push(pluginPath, configPath); + notes.push("Installed OpenCode plugin + MCP entry."); + break; + } + case "cursor": { + const root = opts?.global ? join(homedir(), ".cursor") : join(projectRoot, ".cursor"); + const hooksPath = join(root, "hooks.json"); + const mcpPath = join(root, "mcp.json"); + mergeCursorHooks(hooksPath, packageRoot); + mergeCursorMcp(mcpPath); + filesWritten.push(hooksPath, mcpPath); + notes.push("Installed Cursor hooks + MCP config."); + notes.push("Roo/Kilo inside Cursor still use their own MCP configs; install those separately."); + break; + } + case "codex": { + const root = opts?.global ? join(homedir(), ".codex") : join(projectRoot, ".codex"); + const hooksPath = join(root, "hooks.json"); + const configPath = join(root, "config.toml"); + writeJson(hooksPath, generateCodexHooks(packageRoot)); + mergeCodexConfig(configPath); + filesWritten.push(hooksPath, configPath); + notes.push("Installed Codex hooks + enabled codex_hooks feature + MCP config."); + break; + } + case "roo": { + const path = join(projectRoot, ".roo", "mcp.json"); + mergeGenericMcpJson(path); + filesWritten.push(path); + notes.push("Installed Roo MCP config. Roo has no native lifecycle hooks; use Cursor hooks separately if desired."); + break; + } + case "kilo": { + const path = join(projectRoot, ".kilocode", "mcp.json"); + mergeGenericMcpJson(path); + filesWritten.push(path); + notes.push("Installed Kilo Code MCP config. Kilo has no native lifecycle hooks; use Cursor hooks separately if desired."); + break; + } + case "pi": { + const home = homedir(); + const extDir = join(home, ".pi", "agent", "extensions", "agentmemory"); + copyDir(join(packageRoot, "integrations", "pi"), extDir); + ensureArraySetting(join(home, ".pi", "agent", "settings.json"), "extensions", extDir); + filesWritten.push(extDir, join(home, ".pi", "agent", "settings.json")); + notes.push("Installed PI extension with lifecycle hooks."); + break; + } + case "openclaw": { + const home = homedir(); + const extDir = join(home, ".openclaw", "extensions", "agentmemory"); + copyDir(join(packageRoot, "integrations", "openclaw"), extDir); + filesWritten.push(extDir); + notes.push("Copied OpenClaw integration folder. Enable plugin in ~/.openclaw/openclaw.json manually if needed."); + break; + } + case "hermes": { + const home = homedir(); + const extDir = join(home, ".hermes", "plugins", "agentmemory"); + copyDir(join(packageRoot, "integrations", "hermes"), extDir); + filesWritten.push(extDir); + notes.push("Copied Hermes integration folder. Set memory.provider=agentmemory in ~/.hermes/config.yaml manually if needed."); + break; + } + default: + throw new Error(`Unsupported target: ${target satisfies never}`); + } + + return { target, filesWritten, notes }; +} diff --git a/test/installers.test.ts b/test/installers.test.ts new file mode 100644 index 00000000..e0a14671 --- /dev/null +++ b/test/installers.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { installTarget } from "../src/installers.js"; + +const tmpRoots: string[] = []; + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "agentmemory-installers-")); + tmpRoots.push(root); + return root; +} + +afterEach(() => { + while (tmpRoots.length) { + const root = tmpRoots.pop(); + if (root) rmSync(root, { recursive: true, force: true }); + } +}); + +describe("installTarget", () => { + it("writes OpenCode plugin and config", () => { + const root = tempRoot(); + const result = installTarget("opencode", { projectRoot: root }); + expect(result.filesWritten.some((p) => p.endsWith("agentmemory.js"))).toBe(true); + const config = JSON.parse(readFileSync(join(root, "opencode.json"), "utf8")); + expect(config.mcp.agentmemory.command).toEqual(["npx", "-y", "@agentmemory/mcp"]); + }); + + it("writes Cursor hooks and MCP config", () => { + const root = tempRoot(); + installTarget("cursor", { projectRoot: root }); + const hooks = JSON.parse(readFileSync(join(root, ".cursor", "hooks.json"), "utf8")); + const mcp = JSON.parse(readFileSync(join(root, ".cursor", "mcp.json"), "utf8")); + expect(hooks.hooks.sessionStart[0].command).toContain("cursor.mjs"); + expect(mcp.mcpServers.agentmemory.command).toBe("npx"); + }); + + it("writes Codex hooks and config", () => { + const root = tempRoot(); + installTarget("codex", { projectRoot: root }); + const hooks = JSON.parse(readFileSync(join(root, ".codex", "hooks.json"), "utf8")); + const config = readFileSync(join(root, ".codex", "config.toml"), "utf8"); + expect(hooks.hooks.SessionStart[0].hooks[0].command).toContain("codex.mjs"); + expect(config).toContain("codex_hooks = true"); + expect(config).toContain("[mcp_servers.agentmemory]"); + }); + + it("writes Roo and Kilo MCP configs", () => { + const root = tempRoot(); + installTarget("roo", { projectRoot: root }); + installTarget("kilo", { projectRoot: root }); + const roo = JSON.parse(readFileSync(join(root, ".roo", "mcp.json"), "utf8")); + const kilo = JSON.parse(readFileSync(join(root, ".kilocode", "mcp.json"), "utf8")); + expect(roo.mcpServers.agentmemory.command).toBe("npx"); + expect(kilo.mcpServers.agentmemory.command).toBe("npx"); + }); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index 2c30a48b..3bccfc10 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -3,6 +3,8 @@ import { defineConfig } from "tsdown"; const hookEntries = [ "src/hooks/session-start.ts", "src/hooks/prompt-submit.ts", + "src/hooks/cursor.ts", + "src/hooks/codex.ts", "src/hooks/pre-tool-use.ts", "src/hooks/post-tool-use.ts", "src/hooks/post-tool-failure.ts", From 2e7e169fb6305846eee9f0b240093b385aefbeb8 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Tue, 12 May 2026 07:46:14 +0200 Subject: [PATCH 2/4] Tighten installer hook payload consistency and path handling --- plugin/scripts/codex.mjs | 5 +++-- plugin/scripts/cursor.mjs | 12 +++++++----- src/hooks/codex.ts | 5 +++-- src/hooks/cursor.ts | 9 +++++---- src/installers.ts | 21 ++++++++++++++------- 5 files changed, 32 insertions(+), 20 deletions(-) diff --git a/plugin/scripts/codex.mjs b/plugin/scripts/codex.mjs index a99ffeda..8ec0824f 100755 --- a/plugin/scripts/codex.mjs +++ b/plugin/scripts/codex.mjs @@ -22,6 +22,7 @@ async function main() { const sid = payload.session_id || `codex-${Date.now().toString(36)}`; const root = payload.cwd || process.cwd(); const event = payload.hook_event_name || ""; + const timestamp = (/* @__PURE__ */ new Date()).toISOString(); try { if (event === "SessionStart") { const res = await fetch(`${REST_URL}/agentmemory/session/start`, { @@ -49,7 +50,7 @@ async function main() { sessionId: sid, project: root, cwd: root, - timestamp: (/* @__PURE__ */ new Date()).toISOString(), + timestamp, data: { prompt: payload.prompt || "" } }), signal: AbortSignal.timeout(3e3) @@ -65,7 +66,7 @@ async function main() { sessionId: sid, project: root, cwd: root, - timestamp: (/* @__PURE__ */ new Date()).toISOString(), + timestamp, data: { tool_name: payload.tool_name || "tool", tool_input: payload.tool_input || {}, diff --git a/plugin/scripts/cursor.mjs b/plugin/scripts/cursor.mjs index c9351a2f..6e49c555 100755 --- a/plugin/scripts/cursor.mjs +++ b/plugin/scripts/cursor.mjs @@ -60,7 +60,9 @@ async function main() { if (!payload) return; const sid = sessionId(payload); const root = projectRoot(payload); - switch (inferEvent(payload)) { + const event = inferEvent(payload); + const timestamp = (/* @__PURE__ */ new Date()).toISOString(); + switch (event) { case "sessionStart": await postSessionStart({ sessionId: sid, @@ -74,7 +76,7 @@ async function main() { sessionId: sid, project: root, cwd: root, - timestamp: (/* @__PURE__ */ new Date()).toISOString(), + timestamp, data: { prompt: payload.prompt || "" } }); return; @@ -84,7 +86,7 @@ async function main() { sessionId: sid, project: root, cwd: root, - timestamp: (/* @__PURE__ */ new Date()).toISOString(), + timestamp, data: { tool_name: "CursorEdit", tool_input: { @@ -103,7 +105,7 @@ async function main() { sessionId: sid, project: root, cwd: root, - timestamp: (/* @__PURE__ */ new Date()).toISOString(), + timestamp, data: { tool_name: "Bash", tool_input: { command: payload.command || "" }, @@ -118,7 +120,7 @@ async function main() { sessionId: sid, project: root, cwd: root, - timestamp: (/* @__PURE__ */ new Date()).toISOString(), + timestamp, data: { tool_name: payload.mcp_tool_name || payload.mcp_server_name || "MCP", tool_input: payload.mcp_tool_input || {}, diff --git a/src/hooks/codex.ts b/src/hooks/codex.ts index f438f0fa..5cd07fce 100644 --- a/src/hooks/codex.ts +++ b/src/hooks/codex.ts @@ -38,6 +38,7 @@ async function main(): Promise { const sid = payload.session_id || `codex-${Date.now().toString(36)}`; const root = payload.cwd || process.cwd(); const event = payload.hook_event_name || ""; + const timestamp = new Date().toISOString(); try { if (event === "SessionStart") { @@ -65,7 +66,7 @@ async function main(): Promise { sessionId: sid, project: root, cwd: root, - timestamp: new Date().toISOString(), + timestamp, data: { prompt: payload.prompt || "" }, }), signal: AbortSignal.timeout(3000), @@ -82,7 +83,7 @@ async function main(): Promise { sessionId: sid, project: root, cwd: root, - timestamp: new Date().toISOString(), + timestamp, data: { tool_name: payload.tool_name || "tool", tool_input: payload.tool_input || {}, diff --git a/src/hooks/cursor.ts b/src/hooks/cursor.ts index 7cc5e20b..5049d6bd 100644 --- a/src/hooks/cursor.ts +++ b/src/hooks/cursor.ts @@ -96,6 +96,7 @@ async function main(): Promise { const sid = sessionId(payload); const root = projectRoot(payload); const event = inferEvent(payload); + const timestamp = new Date().toISOString(); switch (event) { case "sessionStart": @@ -107,7 +108,7 @@ async function main(): Promise { sessionId: sid, project: root, cwd: root, - timestamp: new Date().toISOString(), + timestamp, data: { prompt: payload.prompt || "" }, }); return; @@ -117,7 +118,7 @@ async function main(): Promise { sessionId: sid, project: root, cwd: root, - timestamp: new Date().toISOString(), + timestamp, data: { tool_name: "CursorEdit", tool_input: { @@ -136,7 +137,7 @@ async function main(): Promise { sessionId: sid, project: root, cwd: root, - timestamp: new Date().toISOString(), + timestamp, data: { tool_name: "Bash", tool_input: { command: payload.command || "" }, @@ -151,7 +152,7 @@ async function main(): Promise { sessionId: sid, project: root, cwd: root, - timestamp: new Date().toISOString(), + timestamp, data: { tool_name: payload.mcp_tool_name || payload.mcp_server_name || "MCP", tool_input: payload.mcp_tool_input || {}, diff --git a/src/installers.ts b/src/installers.ts index ae1d72c1..65936704 100644 --- a/src/installers.ts +++ b/src/installers.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; export type InstallTarget = | "opencode" @@ -41,7 +42,7 @@ function writeJson(path: string, value: unknown): void { } function packageRootFromCliDist(): string { - return resolve(dirname(new URL(import.meta.url).pathname), ".."); + return resolve(dirname(fileURLToPath(import.meta.url)), ".."); } function commandScript(packageRoot: string, rel: string): string { @@ -97,23 +98,29 @@ function generateOpenCodePlugin(packageRoot: string): string { const postToolScript = commandScript(packageRoot, "post-tool-use.mjs"); const stopScript = commandScript(packageRoot, "session-end.mjs"); return `export const AgentmemoryPlugin = async ({ $ }) => ({ + let sessionId = ''; + const getSessionId = () => { + if (!sessionId) sessionId = 'opencode-' + Date.now().toString(36); + return sessionId; + }; event: async ({ event }) => { if (event.type === 'session.created') { - await $\`${sessionStartScript}\`.stdin(JSON.stringify({ session_id: 'opencode-' + Date.now().toString(36), cwd: process.cwd() })).quiet().nothrow(); + sessionId = getSessionId(); + await $\`${sessionStartScript}\`.stdin(JSON.stringify({ session_id: sessionId, cwd: process.cwd() })).quiet().nothrow(); } else if (event.type === 'session.idle') { - await $\`${stopScript}\`.stdin(JSON.stringify({ session_id: 'opencode-' + Date.now().toString(36), cwd: process.cwd() })).quiet().nothrow(); + await $\`${stopScript}\`.stdin(JSON.stringify({ session_id: getSessionId(), cwd: process.cwd() })).quiet().nothrow(); } else if (event.type === 'file.edited') { - await $\`${postToolScript}\`.stdin(JSON.stringify({ session_id: 'opencode-' + Date.now().toString(36), cwd: process.cwd(), tool_name: 'OpenCodeEdit', tool_input: { file_path: event.properties?.path ?? '' }, tool_output: 'file edited' })).quiet().nothrow(); + await $\`${postToolScript}\`.stdin(JSON.stringify({ session_id: getSessionId(), cwd: process.cwd(), tool_name: 'OpenCodeEdit', tool_input: { file_path: event.properties?.path ?? '' }, tool_output: 'file edited' })).quiet().nothrow(); } else if (event.type === 'command.executed') { - await $\`${postToolScript}\`.stdin(JSON.stringify({ session_id: 'opencode-' + Date.now().toString(36), cwd: process.cwd(), tool_name: 'Bash', tool_input: { command: event.properties?.command ?? '' }, tool_output: 'command executed' })).quiet().nothrow(); + await $\`${postToolScript}\`.stdin(JSON.stringify({ session_id: getSessionId(), cwd: process.cwd(), tool_name: 'Bash', tool_input: { command: event.properties?.command ?? '' }, tool_output: 'command executed' })).quiet().nothrow(); } }, 'tool.execute.after': async (input, output) => { - await $\`${postToolScript}\`.stdin(JSON.stringify({ session_id: 'opencode-' + Date.now().toString(36), cwd: process.cwd(), tool_name: input.tool, tool_input: input.args, tool_output: output })).quiet().nothrow(); + await $\`${postToolScript}\`.stdin(JSON.stringify({ session_id: getSessionId(), cwd: process.cwd(), tool_name: input.tool, tool_input: input.args, tool_output: output })).quiet().nothrow(); }, 'chat.message': async (_input, output) => { if (output?.message) { - await $\`${promptScript}\`.stdin(JSON.stringify({ session_id: 'opencode-' + Date.now().toString(36), cwd: process.cwd(), prompt: output.message })).quiet().nothrow(); + await $\`${promptScript}\`.stdin(JSON.stringify({ session_id: getSessionId(), cwd: process.cwd(), prompt: output.message })).quiet().nothrow(); } } }); From 3e1efba34fce5263b4db955dfa0d83f85b61058b Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Tue, 12 May 2026 09:19:10 +0200 Subject: [PATCH 3/4] Align installers with native host config formats --- src/installers.ts | 103 +++++++++++++++++++++++++++++----------- test/installers.test.ts | 65 ++++++++++++++++++++++--- 2 files changed, 135 insertions(+), 33 deletions(-) diff --git a/src/installers.ts b/src/installers.ts index 65936704..078ad7c0 100644 --- a/src/installers.ts +++ b/src/installers.ts @@ -28,10 +28,49 @@ function writeText(path: string, content: string): void { writeFileSync(path, content, "utf8"); } +function stripJsonComments(input: string): string { + let output = ""; + let inString = false; + let escaped = false; + for (let i = 0; i < input.length; i += 1) { + const ch = input[i]!; + const next = input[i + 1]!; + if (inString) { + output += ch; + if (escaped) { + escaped = false; + } else if (ch === "\\") { + escaped = true; + } else if (ch === '"') { + inString = false; + } + continue; + } + if (ch === '"') { + inString = true; + output += ch; + continue; + } + if (ch === "/" && next === "/") { + while (i < input.length && input[i] !== "\n") i += 1; + if (i < input.length) output += "\n"; + continue; + } + if (ch === "/" && next === "*") { + i += 2; + while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i += 1; + i += 1; + continue; + } + output += ch; + } + return output; +} + function readJson(path: string): Record { if (!existsSync(path)) return {}; try { - return JSON.parse(readFileSync(path, "utf8")) as Record; + return JSON.parse(stripJsonComments(readFileSync(path, "utf8"))) as Record; } catch { return {}; } @@ -41,6 +80,13 @@ function writeJson(path: string, value: unknown): void { writeText(path, JSON.stringify(value, null, 2)); } +function chooseExistingPath(candidates: string[], fallback: string): string { + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + return fallback; +} + function packageRootFromCliDist(): string { return resolve(dirname(fileURLToPath(import.meta.url)), ".."); } @@ -61,23 +107,6 @@ function mergeCursorMcp(configPath: string): void { writeJson(configPath, current); } -function mergeCursorHooks(configPath: string, packageRoot: string): void { - const current = readJson(configPath); - const hooks = (current.hooks as Record) || {}; - const script = commandScript(packageRoot, "cursor.mjs"); - const entry = [{ command: script }]; - hooks.sessionStart = entry; - hooks.beforeSubmitPrompt = entry; - hooks.afterFileEdit = entry; - hooks.afterShellExecution = entry; - hooks.afterMCPExecution = entry; - hooks.preCompact = entry; - hooks.stop = entry; - current.version = 1; - current.hooks = hooks; - writeJson(configPath, current); -} - function mergeOpenCodeConfig(configPath: string): void { const current = readJson(configPath); const mcp = (current.mcp as Record) || {}; @@ -163,6 +192,20 @@ function mergeGenericMcpJson(configPath: string): void { writeJson(configPath, current); } +function mergeKiloConfig(configPath: string): void { + const current = readJson(configPath); + const mcp = (current.mcp as Record) || {}; + mcp.agentmemory = { + type: "local", + command: ["npx", "-y", "@agentmemory/mcp"], + environment: { AGENTMEMORY_URL: "http://localhost:3111" }, + enabled: true, + }; + current.mcp = mcp; + current.$schema = current.$schema || "https://kilocode.ai/config.schema.json"; + writeJson(configPath, current); +} + function ensureArraySetting(configPath: string, key: string, value: string): void { const current = readJson(configPath); const arr = Array.isArray(current[key]) ? [...(current[key] as string[])] : []; @@ -185,7 +228,10 @@ export function installTarget(target: InstallTarget, opts?: { projectRoot?: stri switch (target) { case "opencode": { const pluginPath = join(projectRoot, ".opencode", "plugins", "agentmemory.js"); - const configPath = join(projectRoot, "opencode.json"); + const configPath = chooseExistingPath( + [join(projectRoot, "opencode.jsonc"), join(projectRoot, "opencode.json")], + join(projectRoot, "opencode.json"), + ); writeText(pluginPath, generateOpenCodePlugin(packageRoot)); mergeOpenCodeConfig(configPath); filesWritten.push(pluginPath, configPath); @@ -194,13 +240,12 @@ export function installTarget(target: InstallTarget, opts?: { projectRoot?: stri } case "cursor": { const root = opts?.global ? join(homedir(), ".cursor") : join(projectRoot, ".cursor"); - const hooksPath = join(root, "hooks.json"); const mcpPath = join(root, "mcp.json"); - mergeCursorHooks(hooksPath, packageRoot); mergeCursorMcp(mcpPath); - filesWritten.push(hooksPath, mcpPath); - notes.push("Installed Cursor hooks + MCP config."); - notes.push("Roo/Kilo inside Cursor still use their own MCP configs; install those separately."); + filesWritten.push(mcpPath); + notes.push("Installed Cursor MCP config."); + notes.push("Cursor does not expose a first-class lifecycle hook system like Claude Code; for automatic capture use the existing filesystem watcher or a host-specific extension/plugin layer."); + notes.push("Roo/Kilo inside Cursor still use their own configs; install those separately."); break; } case "codex": { @@ -221,10 +266,14 @@ export function installTarget(target: InstallTarget, opts?: { projectRoot?: stri break; } case "kilo": { - const path = join(projectRoot, ".kilocode", "mcp.json"); - mergeGenericMcpJson(path); + const path = chooseExistingPath( + [join(projectRoot, "kilo.jsonc"), join(projectRoot, ".kilo", "kilo.jsonc")], + join(projectRoot, ".kilo", "kilo.jsonc"), + ); + mergeKiloConfig(path); filesWritten.push(path); - notes.push("Installed Kilo Code MCP config. Kilo has no native lifecycle hooks; use Cursor hooks separately if desired."); + notes.push("Installed Kilo Code MCP config inside kilo.jsonc."); + notes.push("Kilo has no native lifecycle hooks; use the filesystem watcher for best-available automatic capture."); break; } case "pi": { diff --git a/test/installers.test.ts b/test/installers.test.ts index e0a14671..acb0c4df 100644 --- a/test/installers.test.ts +++ b/test/installers.test.ts @@ -28,12 +28,10 @@ describe("installTarget", () => { expect(config.mcp.agentmemory.command).toEqual(["npx", "-y", "@agentmemory/mcp"]); }); - it("writes Cursor hooks and MCP config", () => { + it("writes Cursor MCP config", () => { const root = tempRoot(); installTarget("cursor", { projectRoot: root }); - const hooks = JSON.parse(readFileSync(join(root, ".cursor", "hooks.json"), "utf8")); const mcp = JSON.parse(readFileSync(join(root, ".cursor", "mcp.json"), "utf8")); - expect(hooks.hooks.sessionStart[0].command).toContain("cursor.mjs"); expect(mcp.mcpServers.agentmemory.command).toBe("npx"); }); @@ -47,13 +45,68 @@ describe("installTarget", () => { expect(config).toContain("[mcp_servers.agentmemory]"); }); - it("writes Roo and Kilo MCP configs", () => { + it("writes Roo MCP config and Kilo JSONC config", () => { const root = tempRoot(); installTarget("roo", { projectRoot: root }); installTarget("kilo", { projectRoot: root }); const roo = JSON.parse(readFileSync(join(root, ".roo", "mcp.json"), "utf8")); - const kilo = JSON.parse(readFileSync(join(root, ".kilocode", "mcp.json"), "utf8")); + const kilo = JSON.parse(readFileSync(join(root, ".kilo", "kilo.jsonc"), "utf8")) as Record; expect(roo.mcpServers.agentmemory.command).toBe("npx"); - expect(kilo.mcpServers.agentmemory.command).toBe("npx"); + expect(kilo.mcp.agentmemory.command).toEqual(["npx", "-y", "@agentmemory/mcp"]); + }); + + it("preserves JSONC-based existing server entries when merging", () => { + const root = tempRoot(); + const cursorDir = join(root, ".cursor"); + const kiloDir = join(root, ".kilo"); + require("node:fs").mkdirSync(cursorDir, { recursive: true }); + require("node:fs").mkdirSync(kiloDir, { recursive: true }); + require("node:fs").writeFileSync( + join(cursorDir, "mcp.json"), + '{\n // comment\n "mcpServers": {\n "existing": {"command": "foo"}\n }\n}\n', + "utf8", + ); + require("node:fs").writeFileSync( + join(kiloDir, "kilo.jsonc"), + '{\n // comment\n "mcp": {\n "existing": {"type": "local", "command": ["foo"]}\n }\n}\n', + "utf8", + ); + + installTarget("cursor", { projectRoot: root }); + installTarget("kilo", { projectRoot: root }); + + const cursor = JSON.parse(readFileSync(join(cursorDir, "mcp.json"), "utf8")) as Record; + const kilo = JSON.parse(readFileSync(join(kiloDir, "kilo.jsonc"), "utf8")) as Record; + expect(cursor.mcpServers.existing.command).toBe("foo"); + expect(cursor.mcpServers.agentmemory.command).toBe("npx"); + expect(kilo.mcp.existing.command).toEqual(["foo"]); + expect(kilo.mcp.agentmemory.command).toEqual(["npx", "-y", "@agentmemory/mcp"]); + }); + + it("reuses native .jsonc paths when they already exist", () => { + const root = tempRoot(); + require("node:fs").writeFileSync( + join(root, "opencode.jsonc"), + '{\n // comment\n "mcp": {"existing": {"type": "local", "command": ["foo"]}}\n}\n', + "utf8", + ); + require("node:fs").mkdirSync(join(root, ".kilo"), { recursive: true }); + require("node:fs").writeFileSync( + join(root, "kilo.jsonc"), + '{\n // comment\n "mcp": {"existing": {"type": "local", "command": ["foo"]}}\n}\n', + "utf8", + ); + + const opencode = installTarget("opencode", { projectRoot: root }); + const kilo = installTarget("kilo", { projectRoot: root }); + + expect(opencode.filesWritten.some((p) => p.endsWith("opencode.jsonc"))).toBe(true); + expect(kilo.filesWritten.some((p) => p.endsWith("kilo.jsonc"))).toBe(true); + expect(JSON.parse(readFileSync(join(root, "opencode.jsonc"), "utf8"))).toMatchObject({ + mcp: { existing: { command: ["foo"] }, agentmemory: { enabled: true } }, + }); + expect(JSON.parse(readFileSync(join(root, "kilo.jsonc"), "utf8"))).toMatchObject({ + mcp: { existing: { command: ["foo"] }, agentmemory: { enabled: true } }, + }); }); }); From e5967211f76daf130dc8f0f0d250f10e8a1f35bc Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Thu, 14 May 2026 12:07:22 +0200 Subject: [PATCH 4/4] Harden installer format parsing and OpenCode plugin generation --- src/installers.ts | 23 ++++++++++++++++------- test/installers.test.ts | 16 ++++++++-------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/installers.ts b/src/installers.ts index 078ad7c0..8ecdb09c 100644 --- a/src/installers.ts +++ b/src/installers.ts @@ -34,7 +34,7 @@ function stripJsonComments(input: string): string { let escaped = false; for (let i = 0; i < input.length; i += 1) { const ch = input[i]!; - const next = input[i + 1]!; + const next = i + 1 < input.length ? input[i + 1] : undefined; if (inString) { output += ch; if (escaped) { @@ -51,15 +51,22 @@ function stripJsonComments(input: string): string { output += ch; continue; } - if (ch === "/" && next === "/") { + if (next && ch === "/" && next === "/") { while (i < input.length && input[i] !== "\n") i += 1; if (i < input.length) output += "\n"; continue; } - if (ch === "/" && next === "*") { + if (next && ch === "/" && next === "*") { i += 2; - while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i += 1; - i += 1; + while (i < input.length) { + const curr = input[i]; + const following = i + 1 < input.length ? input[i + 1] : undefined; + if (curr === "*" && following === "/") { + i += 1; + break; + } + i += 1; + } continue; } output += ch; @@ -126,12 +133,13 @@ function generateOpenCodePlugin(packageRoot: string): string { const promptScript = commandScript(packageRoot, "prompt-submit.mjs"); const postToolScript = commandScript(packageRoot, "post-tool-use.mjs"); const stopScript = commandScript(packageRoot, "session-end.mjs"); - return `export const AgentmemoryPlugin = async ({ $ }) => ({ + return `export const AgentmemoryPlugin = async ({ $ }) => { let sessionId = ''; const getSessionId = () => { if (!sessionId) sessionId = 'opencode-' + Date.now().toString(36); return sessionId; }; + return { event: async ({ event }) => { if (event.type === 'session.created') { sessionId = getSessionId(); @@ -152,7 +160,8 @@ function generateOpenCodePlugin(packageRoot: string): string { await $\`${promptScript}\`.stdin(JSON.stringify({ session_id: getSessionId(), cwd: process.cwd(), prompt: output.message })).quiet().nothrow(); } } -}); + }; +}; `; } diff --git a/test/installers.test.ts b/test/installers.test.ts index acb0c4df..e1bbe55c 100644 --- a/test/installers.test.ts +++ b/test/installers.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { installTarget } from "../src/installers.js"; @@ -59,14 +59,14 @@ describe("installTarget", () => { const root = tempRoot(); const cursorDir = join(root, ".cursor"); const kiloDir = join(root, ".kilo"); - require("node:fs").mkdirSync(cursorDir, { recursive: true }); - require("node:fs").mkdirSync(kiloDir, { recursive: true }); - require("node:fs").writeFileSync( + mkdirSync(cursorDir, { recursive: true }); + mkdirSync(kiloDir, { recursive: true }); + writeFileSync( join(cursorDir, "mcp.json"), '{\n // comment\n "mcpServers": {\n "existing": {"command": "foo"}\n }\n}\n', "utf8", ); - require("node:fs").writeFileSync( + writeFileSync( join(kiloDir, "kilo.jsonc"), '{\n // comment\n "mcp": {\n "existing": {"type": "local", "command": ["foo"]}\n }\n}\n', "utf8", @@ -85,13 +85,13 @@ describe("installTarget", () => { it("reuses native .jsonc paths when they already exist", () => { const root = tempRoot(); - require("node:fs").writeFileSync( + writeFileSync( join(root, "opencode.jsonc"), '{\n // comment\n "mcp": {"existing": {"type": "local", "command": ["foo"]}}\n}\n', "utf8", ); - require("node:fs").mkdirSync(join(root, ".kilo"), { recursive: true }); - require("node:fs").writeFileSync( + mkdirSync(join(root, ".kilo"), { recursive: true }); + writeFileSync( join(root, "kilo.jsonc"), '{\n // comment\n "mcp": {"existing": {"type": "local", "command": ["foo"]}}\n}\n', "utf8",