From cb52b65d39a02ce264e1c1733d0cdba7a3cac0f0 Mon Sep 17 00:00:00 2001 From: Bertho Joris Date: Mon, 3 Aug 2026 11:21:47 +0700 Subject: [PATCH 1/2] fix(cli): refresh Codex hooks when MCP is already wired `connect codex --with-hooks` no-oped for users with an existing mcp_servers.agentmemory block. Mirror Claude Code/Droid so re-runs still merge ~/.codex/hooks.json, and cover the path with adapter tests. Co-authored-by: Cursor --- plugin/skills/agentmemory-agents/REFERENCE.md | 2 +- src/cli/connect/codex.ts | 17 +- test/codex-connect-hooks.test.ts | 149 +++++++++++++++++- 3 files changed, 163 insertions(+), 5 deletions(-) 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/src/cli/connect/codex.ts b/src/cli/connect/codex.ts index 3dbc1882f..dbd182a28 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,14 @@ export const adapter: ConnectAdapter = { if (wired && !opts.force) { logAlreadyWired("Codex CLI", CODEX_TOML); + // --with-hooks is independent of MCP wiring (issue #508 / openai/codex#16430). + // Re-run refreshes absolute script paths even when MCP is already in place. + 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 +94,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..0cdaadef9 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,120 @@ 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(() => { + 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); + }); +}); From a388455d2b020b4bc451979d5d2ce3c2f418c6b9 Mon Sep 17 00:00:00 2001 From: Bertho Joris Date: Mon, 3 Aug 2026 18:39:48 +0700 Subject: [PATCH 2/2] fix(cli): address review comments for Codex hooks adapter --- plugin/skills/agentmemory-rest-api/REFERENCE.md | 2 +- src/cli/connect/codex.ts | 2 -- test/codex-connect-hooks.test.ts | 3 ++- 3 files changed, 3 insertions(+), 4 deletions(-) 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 dbd182a28..7af60c814 100644 --- a/src/cli/connect/codex.ts +++ b/src/cli/connect/codex.ts @@ -79,8 +79,6 @@ export const adapter: ConnectAdapter = { if (wired && !opts.force) { logAlreadyWired("Codex CLI", CODEX_TOML); - // --with-hooks is independent of MCP wiring (issue #508 / openai/codex#16430). - // Re-run refreshes absolute script paths even when MCP is already in place. if (opts.withHooks) { const hookResult = installCodexHooks(opts); if (hookResult.kind === "skipped") { diff --git a/test/codex-connect-hooks.test.ts b/test/codex-connect-hooks.test.ts index 0cdaadef9..6500c9f97 100644 --- a/test/codex-connect-hooks.test.ts +++ b/test/codex-connect-hooks.test.ts @@ -180,7 +180,8 @@ describe("connect: Codex --with-hooks", () => { }); afterEach(() => { - process.env["HOME"] = ORIG; + 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 });