diff --git a/plugin/scripts/codex.mjs b/plugin/scripts/codex.mjs new file mode 100755 index 00000000..8ec0824f --- /dev/null +++ b/plugin/scripts/codex.mjs @@ -0,0 +1,95 @@ +#!/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 || ""; + const timestamp = (/* @__PURE__ */ new Date()).toISOString(); + 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, + 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, + 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..6e49c555 --- /dev/null +++ b/plugin/scripts/cursor.mjs @@ -0,0 +1,149 @@ +#!/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); + const event = inferEvent(payload); + const timestamp = (/* @__PURE__ */ new Date()).toISOString(); + 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, + data: { prompt: payload.prompt || "" } + }); + return; + case "afterFileEdit": + await postObserve({ + hookType: "post_tool_use", + sessionId: sid, + project: root, + cwd: root, + timestamp, + 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, + 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, + 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..5cd07fce --- /dev/null +++ b/src/hooks/codex.ts @@ -0,0 +1,112 @@ +#!/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 || ""; + const timestamp = new Date().toISOString(); + + 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, + 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, + 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..5049d6bd --- /dev/null +++ b/src/hooks/cursor.ts @@ -0,0 +1,181 @@ +#!/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); + const timestamp = new Date().toISOString(); + + 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, + data: { prompt: payload.prompt || "" }, + }); + return; + case "afterFileEdit": + await postObserve({ + hookType: "post_tool_use", + sessionId: sid, + project: root, + cwd: root, + timestamp, + 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, + 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, + 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..8ecdb09c --- /dev/null +++ b/src/installers.ts @@ -0,0 +1,318 @@ +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" + | "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 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 = i + 1 < input.length ? input[i + 1] : undefined; + 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 (next && ch === "/" && next === "/") { + while (i < input.length && input[i] !== "\n") i += 1; + if (i < input.length) output += "\n"; + continue; + } + if (next && ch === "/" && next === "*") { + i += 2; + 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; + } + return output; +} + +function readJson(path: string): Record { + if (!existsSync(path)) return {}; + try { + return JSON.parse(stripJsonComments(readFileSync(path, "utf8"))) as Record; + } catch { + return {}; + } +} + +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)), ".."); +} + +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 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 ({ $ }) => { + 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(); + 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: getSessionId(), cwd: process.cwd() })).quiet().nothrow(); + } else if (event.type === 'file.edited') { + 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: 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: 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: getSessionId(), 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 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[])] : []; + 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 = chooseExistingPath( + [join(projectRoot, "opencode.jsonc"), join(projectRoot, "opencode.json")], + 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 mcpPath = join(root, "mcp.json"); + mergeCursorMcp(mcpPath); + 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": { + 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 = 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 inside kilo.jsonc."); + notes.push("Kilo has no native lifecycle hooks; use the filesystem watcher for best-available automatic capture."); + 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..e1bbe55c --- /dev/null +++ b/test/installers.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it } from "vitest"; +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"; + +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 MCP config", () => { + const root = tempRoot(); + installTarget("cursor", { projectRoot: root }); + const mcp = JSON.parse(readFileSync(join(root, ".cursor", "mcp.json"), "utf8")); + 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 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, ".kilo", "kilo.jsonc"), "utf8")) as Record; + expect(roo.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"); + 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", + ); + 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(); + writeFileSync( + join(root, "opencode.jsonc"), + '{\n // comment\n "mcp": {"existing": {"type": "local", "command": ["foo"]}}\n}\n', + "utf8", + ); + mkdirSync(join(root, ".kilo"), { recursive: true }); + 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 } }, + }); + }); +}); 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",