diff --git a/plugin/skills/agentmemory-agents/REFERENCE.md b/plugin/skills/agentmemory-agents/REFERENCE.md index a0ec69ca1..a46028697 100644 --- a/plugin/skills/agentmemory-agents/REFERENCE.md +++ b/plugin/skills/agentmemory-agents/REFERENCE.md @@ -10,7 +10,7 @@ Generated from `src/cli/connect/index.ts`. Do not edit the block below by hand; | Antigravity | `antigravity` | Using MCP via mcp_config.json. Antigravity replaces Gemini CLI (sunset 2026-06-18). | | Claude Code | `claude-code` | Using MCP. Hooks are also available, see https://github.com/rohitg00/agentmemory#claude-code-one-block-paste-it. | | Cline | `cline` | Using MCP via ~/.cline/mcp.json (CLI). VS Code users: add the same block via Cline Settings → MCP Servers → Edit JSON. | -| Codex CLI | `codex` | Using MCP. Hooks ship via the Codex plugin; on Codex Desktop, also pass --with-hooks to install the global hooks.json workaround for openai/codex#16430. | +| Codex CLI | `codex` | Using MCP. Hooks ship via the Codex plugin; on Codex Desktop, also pass --with-hooks to install (or refresh) the global ~/.codex/hooks.json workaround for openai/codex#16430, works even when MCP is already wired. | | Continue | `continue` | Using MCP via ~/.continue/config.yaml (preferred) or config.json (legacy, only when no yaml). | | GitHub Copilot CLI | `copilot-cli` | Using MCP. Install the plugin too for full hooks/skills coverage. | | Cursor | `cursor` | Using MCP (the only protocol Cursor speaks). Memory bridge runs at :3111 underneath. | diff --git a/plugin/skills/agentmemory-rest-api/REFERENCE.md b/plugin/skills/agentmemory-rest-api/REFERENCE.md index d12ff4610..b92e35a9e 100644 --- a/plugin/skills/agentmemory-rest-api/REFERENCE.md +++ b/plugin/skills/agentmemory-rest-api/REFERENCE.md @@ -5,7 +5,7 @@ Generated from `src/triggers/api.ts`. Do not edit the block below by hand; run ` The REST API is the primary surface. All paths are under `http://localhost:3111` (override with `--port`). When `AGENTMEMORY_SECRET` is set, send `Authorization: Bearer $AGENTMEMORY_SECRET`; localhost is otherwise open. -118 registered endpoints: +119 registered endpoints: | Method | Path | | --- | --- | diff --git a/src/cli/connect/codex.ts b/src/cli/connect/codex.ts index 3dbc1882f..7af60c814 100644 --- a/src/cli/connect/codex.ts +++ b/src/cli/connect/codex.ts @@ -66,7 +66,7 @@ export const adapter: ConnectAdapter = { category: "native", docs: "https://github.com/rohitg00/agentmemory#codex-cli-codex-plugin-platform", protocolNote: - "→ Using MCP. Hooks ship via the Codex plugin; on Codex Desktop, also pass --with-hooks to install the global hooks.json workaround for openai/codex#16430.", + "→ Using MCP. Hooks ship via the Codex plugin; on Codex Desktop, also pass --with-hooks to install (or refresh) the global ~/.codex/hooks.json workaround for openai/codex#16430 — works even when MCP is already wired.", detect(): boolean { return existsSync(CODEX_DIR); @@ -79,6 +79,12 @@ export const adapter: ConnectAdapter = { if (wired && !opts.force) { logAlreadyWired("Codex CLI", CODEX_TOML); + if (opts.withHooks) { + const hookResult = installCodexHooks(opts); + if (hookResult.kind === "skipped") { + p.log.warn(`Codex hooks fallback skipped: ${hookResult.reason}.`); + } + } return { kind: "already-wired", mutatedPath: CODEX_TOML }; } @@ -86,7 +92,12 @@ export const adapter: ConnectAdapter = { p.log.info( `[dry-run] Would ${wired ? "rewrite" : "append"} [mcp_servers.agentmemory] in ${CODEX_TOML}`, ); - if (opts.withHooks) installCodexHooks(opts); + if (opts.withHooks) { + const hookResult = installCodexHooks(opts); + if (hookResult.kind === "skipped") { + p.log.warn(`Codex hooks fallback skipped: ${hookResult.reason}.`); + } + } return { kind: "installed", mutatedPath: CODEX_TOML }; } diff --git a/test/codex-connect-hooks.test.ts b/test/codex-connect-hooks.test.ts index 75accbee7..6500c9f97 100644 --- a/test/codex-connect-hooks.test.ts +++ b/test/codex-connect-hooks.test.ts @@ -1,5 +1,12 @@ -import { describe, it, expect } from "vitest"; -import { writeFileSync, readFileSync, mkdirSync, rmSync } from "node:fs"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + writeFileSync, + readFileSync, + mkdirSync, + rmSync, + mkdtempSync, + existsSync, +} from "node:fs"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { @@ -10,6 +17,27 @@ import { const PLUGIN_ROOT = resolve(__dirname, "..", "plugin"); +const CODEX_TOML_BLOCK = `[mcp_servers.agentmemory] +command = "npx" +args = ["-y", "@agentmemory/mcp"] + +[mcp_servers.agentmemory.env] +AGENTMEMORY_URL = "http://localhost:3111" +`; + +const CODEX_EVENTS = [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PreCompact", + "Stop", +] as const; + +function freshHome(): string { + return mkdtempSync(join(tmpdir(), "am-codex-connect-")); +} + describe("findPluginRoot", () => { it("locates the bundled plugin/ directory from src/cli/connect/", () => { const root = findPluginRoot(); @@ -135,3 +163,121 @@ describe("buildMergedHooks file round-trip", () => { } }); }); + +describe("connect: Codex --with-hooks", () => { + let home: string; + const ORIG = process.env["HOME"]; + const ORIG_USERPROFILE = process.env["USERPROFILE"]; + + beforeEach(() => { + home = freshHome(); + vi.resetModules(); + process.env["HOME"] = home; + // os.homedir() on win32 reads USERPROFILE, not HOME — without this, + // adapter.detect()/install() resolve the real user's home directory + // instead of the isolated temp one. + process.env["USERPROFILE"] = home; + }); + + afterEach(() => { + if (ORIG === undefined) delete process.env["HOME"]; + else process.env["HOME"] = ORIG; + if (ORIG_USERPROFILE === undefined) delete process.env["USERPROFILE"]; + else process.env["USERPROFILE"] = ORIG_USERPROFILE; + rmSync(home, { recursive: true, force: true }); + }); + + it("--with-hooks writes ~/.codex/hooks.json with Codex's six lifecycle events", async () => { + mkdirSync(join(home, ".codex"), { recursive: true }); + const { adapter } = await import("../src/cli/connect/codex.js"); + const result = await adapter.install({ + dryRun: false, + force: false, + withHooks: true, + }); + expect(result.kind).toBe("installed"); + const hooksPath = join(home, ".codex", "hooks.json"); + expect(existsSync(hooksPath)).toBe(true); + const hooks = JSON.parse(readFileSync(hooksPath, "utf-8")) as HookManifest; + expect(Object.keys(hooks.hooks).sort()).toEqual([...CODEX_EVENTS].sort()); + }); + + it("without --with-hooks, does not write ~/.codex/hooks.json", async () => { + mkdirSync(join(home, ".codex"), { recursive: true }); + const { adapter } = await import("../src/cli/connect/codex.js"); + await adapter.install({ dryRun: false, force: false }); + expect(existsSync(join(home, ".codex", "hooks.json"))).toBe(false); + }); + + it("re-running install with --with-hooks on an already-wired MCP config still refreshes hooks.json", async () => { + const codexDir = join(home, ".codex"); + mkdirSync(codexDir, { recursive: true }); + writeFileSync(join(codexDir, "config.toml"), CODEX_TOML_BLOCK, "utf-8"); + + const { adapter } = await import("../src/cli/connect/codex.js"); + const result = await adapter.install({ + dryRun: false, + force: false, + withHooks: true, + }); + expect(result.kind).toBe("already-wired"); + + const hooksPath = join(codexDir, "hooks.json"); + expect(existsSync(hooksPath)).toBe(true); + const hooks = JSON.parse(readFileSync(hooksPath, "utf-8")) as HookManifest; + expect(Object.keys(hooks.hooks).sort()).toEqual([...CODEX_EVENTS].sort()); + }); + + it("already-wired without --with-hooks does not write hooks.json", async () => { + const codexDir = join(home, ".codex"); + mkdirSync(codexDir, { recursive: true }); + writeFileSync(join(codexDir, "config.toml"), CODEX_TOML_BLOCK, "utf-8"); + + const { adapter } = await import("../src/cli/connect/codex.js"); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("already-wired"); + expect(existsSync(join(codexDir, "hooks.json"))).toBe(false); + }); + + it("re-install with --with-hooks is idempotent and preserves user hook entries", async () => { + const codexDir = join(home, ".codex"); + mkdirSync(codexDir, { recursive: true }); + writeFileSync(join(codexDir, "config.toml"), CODEX_TOML_BLOCK, "utf-8"); + writeFileSync( + join(codexDir, "hooks.json"), + `${JSON.stringify( + { + hooks: { + SessionStart: [ + { hooks: [{ type: "command", command: "echo user-custom" }] }, + ], + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + + const { adapter } = await import("../src/cli/connect/codex.js"); + await adapter.install({ dryRun: false, force: false, withHooks: true }); + await adapter.install({ dryRun: false, force: false, withHooks: true }); + + const hooks = JSON.parse( + readFileSync(join(codexDir, "hooks.json"), "utf-8"), + ) as HookManifest; + const sessionStart = hooks.hooks["SessionStart"]!; + expect( + sessionStart.some((e) => + e.hooks.some((h) => h.command === "echo user-custom"), + ), + ).toBe(true); + expect( + sessionStart.filter((e) => + e.hooks.some((h) => + h.command.replace(/\\/g, "/").includes("/scripts/session-start.mjs"), + ), + ), + ).toHaveLength(1); + }); +});