Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,18 @@ Implementation details live in `src/cli.ts` (see `runUpgrade` around the `src/cl
Install agentmemory: run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server. Then run `/plugin marketplace add rohitg00/agentmemory` and `/plugin install agentmemory` — the plugin registers all 12 hooks, 4 skills, AND auto-wires the `@agentmemory/mcp` stdio server via its `.mcp.json`, so you get 53 MCP tools (memory_smart_search, memory_save, memory_sessions, memory_governance_delete, etc.) without any extra config step. Verify with `curl http://localhost:3111/agentmemory/health`. The real-time viewer is at http://localhost:3113.
```

#### Claude Code without the plugin install (MCP-standalone path)

If you wire agentmemory's MCP server through `~/.claude.json` directly instead of using `/plugin install`, Claude Code never resolves `${CLAUDE_PLUGIN_ROOT}` and you have to point hook scripts at absolute paths in `~/.claude/settings.json`. Those paths typically embed the agentmemory version (e.g. `~/.codex/plugins/cache/agentmemory/agentmemory/0.9.21/scripts/…`), so the next upgrade silently breaks every hook ([#508](https://github.com/rohitg00/agentmemory/issues/508)).

Workaround:

```bash
agentmemory connect claude-code --with-hooks
```

This merges the same hook commands into `~/.claude/settings.json` with absolute paths resolved to the bundled `plugin/` directory of the currently installed `@agentmemory/agentmemory` package. Re-run the command after upgrading agentmemory to refresh the paths. User entries in the same file are preserved; only previous agentmemory entries are replaced. Using the `/plugin install` path remains the recommended approach.

### Codex CLI (Codex plugin platform)

```bash
Expand All @@ -419,7 +431,7 @@ npx @agentmemory/agentmemory

# 2. register the agentmemory marketplace and install the plugin
codex plugin marketplace add rohitg00/agentmemory
codex plugin install agentmemory
codex plugin add agentmemory@agentmemory
```

The Codex plugin ships from the same `plugin/` directory as the Claude Code plugin. It registers:
Expand Down Expand Up @@ -516,7 +528,7 @@ The agentmemory entry is the **same MCP server block** across every host that us
| **Gemini CLI** | `~/.gemini/settings.json` | `gemini mcp add agentmemory npx -y @agentmemory/mcp --scope user` (auto-merges). |
| **OpenClaw** | OpenClaw MCP config | Same `mcpServers` block, or use the deeper [memory plugin](integrations/openclaw/). |
| **Codex CLI (MCP only)** | `.codex/config.toml` | TOML shape: `codex mcp add agentmemory -- npx -y @agentmemory/mcp`, or add `[mcp_servers.agentmemory]` manually. |
| **Codex CLI (full plugin)** | Codex plugin marketplace | `codex plugin marketplace add rohitg00/agentmemory` then `codex plugin install agentmemory`. Registers MCP + 6 lifecycle hooks (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PreCompact, Stop) + 4 skills. On Codex Desktop, also run `agentmemory connect codex --with-hooks` until [openai/codex#16430](https://github.com/openai/codex/issues/16430) lands — plugin hooks are currently silent there. |
| **Codex CLI (full plugin)** | Codex plugin marketplace | `codex plugin marketplace add rohitg00/agentmemory` then `codex plugin add agentmemory@agentmemory`. Registers MCP + 6 lifecycle hooks (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PreCompact, Stop) + 4 skills. On Codex Desktop, also run `agentmemory connect codex --with-hooks` until [openai/codex#16430](https://github.com/openai/codex/issues/16430) lands — plugin hooks are currently silent there. |
| **OpenCode (MCP only)** | `opencode.json` | Different shape — top-level `mcp` key, command as array: `{"mcp": {"agentmemory": {"type": "local", "command": ["npx", "-y", "@agentmemory/mcp"], "enabled": true}}}`. |
| **OpenCode (full plugin)** | `plugin/opencode/` | 22 auto-capture hooks covering session lifecycle, messages, tools, errors. Two slash commands (`/recall`, `/remember`). Copy `plugin/opencode/` into your OpenCode workspace and add the plugin entry to `opencode.json`. See [`plugin/opencode/README.md`](plugin/opencode/README.md) for the full hook table + gap analysis. |
| **pi** | `~/.pi/agent/extensions/agentmemory` | Copy [`integrations/pi`](integrations/pi/) and restart pi. |
Expand Down
86 changes: 86 additions & 0 deletions src/cli/connect/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,15 @@ import {
readJsonSafe,
writeJsonAtomic,
} from "./util.js";
import {
buildMergedHooks,
findPluginRoot,
type HookManifest,
} from "./codex-hooks.js";

const CLAUDE_DIR = join(homedir(), ".claude");
const CLAUDE_JSON = join(homedir(), ".claude.json");
const CLAUDE_SETTINGS = join(CLAUDE_DIR, "settings.json");

type ClaudeMcpEntry = typeof AGENTMEMORY_MCP_BLOCK;
type ClaudeConfig = {
Expand Down Expand Up @@ -51,6 +57,17 @@ export const adapter: ConnectAdapter = {
const alreadyHas = entryMatches(servers["agentmemory"]);
if (alreadyHas && !opts.force) {
logAlreadyWired("Claude Code", CLAUDE_JSON);
// --with-hooks is independent of MCP wiring (issue #508). Run the
// hooks fallback even when MCP is already in place so users with a
// healthy MCP setup can still pick up version-stable hook paths.
if (opts.withHooks) {
const hookResult = installClaudeHooks(opts);
if (hookResult.kind === "skipped") {
p.log.warn(
`Claude Code hooks fallback skipped: ${hookResult.reason}.`,
);
}
}
return { kind: "already-wired", mutatedPath: CLAUDE_JSON };
}

Expand Down Expand Up @@ -86,6 +103,75 @@ export const adapter: ConnectAdapter = {
p.log.info(
"Restart Claude Code (or run `/mcp` inside a session) to pick up the new server.",
);

if (opts.withHooks) {
const hookResult = installClaudeHooks(opts);
if (hookResult.kind === "skipped") {
p.log.warn(
`Claude Code hooks fallback skipped: ${hookResult.reason}. MCP wiring still applied.`,
);
}
}

return { kind: "installed", mutatedPath: CLAUDE_JSON, backupPath };
},
};

/**
* Merge the bundled `plugin/hooks/hooks.json` into
* `~/.claude/settings.json`'s top-level `hooks` field with absolute
* script paths. Use this when agentmemory is NOT installed through
* `/plugin marketplace add` (e.g. MCP standalone wiring), so the
* hook scripts survive version bumps without `${CLAUDE_PLUGIN_ROOT}`
* expansion (issue #508).
*
* Re-install strips entries whose command points under
* `<pluginRoot>/scripts/`; unrelated user hook entries survive.
*/
function installClaudeHooks(opts: ConnectOptions): ConnectResult {
let pluginRoot: string;
try {
pluginRoot = findPluginRoot();
} catch (err) {
return {
kind: "skipped",
reason: err instanceof Error ? err.message : String(err),
};
}

type ClaudeSettings = { hooks?: HookManifest["hooks"]; [key: string]: unknown };
const existing = readJsonSafe<ClaudeSettings>(CLAUDE_SETTINGS) ?? {};
const existingHooks: HookManifest | null = existing.hooks
? { hooks: existing.hooks }
: null;
const merged = buildMergedHooks(existingHooks, pluginRoot, "hooks.json");
Comment on lines +131 to +147
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make hook fallback non-fatal by catching merge/write failures.

installClaudeHooks() only guards findPluginRoot(). If manifest read/parse or settings write fails, it throws and can abort the whole connect flow instead of returning kind: "skipped" for the warning path.

Suggested fix
 function installClaudeHooks(opts: ConnectOptions): ConnectResult {
   let pluginRoot: string;
   try {
     pluginRoot = findPluginRoot();
   } catch (err) {
     return {
       kind: "skipped",
       reason: err instanceof Error ? err.message : String(err),
     };
   }
 
-  type ClaudeSettings = { hooks?: HookManifest["hooks"]; [key: string]: unknown };
-  const existing = readJsonSafe<ClaudeSettings>(CLAUDE_SETTINGS) ?? {};
-  const existingHooks: HookManifest | null = existing.hooks
-    ? { hooks: existing.hooks }
-    : null;
-  const merged = buildMergedHooks(existingHooks, pluginRoot, "hooks.json");
+  try {
+    type ClaudeSettings = { hooks?: HookManifest["hooks"]; [key: string]: unknown };
+    const existing = readJsonSafe<ClaudeSettings>(CLAUDE_SETTINGS) ?? {};
+    const existingHooks: HookManifest | null = existing.hooks
+      ? { hooks: existing.hooks }
+      : null;
+    const merged = buildMergedHooks(existingHooks, pluginRoot, "hooks.json");
 
-  if (opts.dryRun) {
-    p.log.info(
-      `[dry-run] Would merge agentmemory hook entries into ${CLAUDE_SETTINGS} (${Object.keys(merged.hooks).length} event(s))`,
-    );
-    return { kind: "installed", mutatedPath: CLAUDE_SETTINGS };
-  }
+    if (opts.dryRun) {
+      p.log.info(
+        `[dry-run] Would merge agentmemory hook entries into ${CLAUDE_SETTINGS} (${Object.keys(merged.hooks).length} event(s))`,
+      );
+      return { kind: "installed", mutatedPath: CLAUDE_SETTINGS };
+    }
 
-  let backupPath: string | undefined;
-  if (existsSync(CLAUDE_SETTINGS)) {
-    backupPath = backupFile(CLAUDE_SETTINGS, "claude-settings", "json");
-    logBackup(backupPath);
-  } else {
-    mkdirSync(CLAUDE_DIR, { recursive: true });
-  }
+    let backupPath: string | undefined;
+    if (existsSync(CLAUDE_SETTINGS)) {
+      backupPath = backupFile(CLAUDE_SETTINGS, "claude-settings", "json");
+      logBackup(backupPath);
+    } else {
+      mkdirSync(CLAUDE_DIR, { recursive: true });
+    }
 
-  const next: ClaudeSettings = { ...existing, hooks: merged.hooks };
-  writeJsonAtomic(CLAUDE_SETTINGS, next);
+    const next: ClaudeSettings = { ...existing, hooks: merged.hooks };
+    writeJsonAtomic(CLAUDE_SETTINGS, next);
 
-  logInstalled("Claude Code hooks (workaround for `#508`)", CLAUDE_SETTINGS);
-  p.log.info(
-    "User-scope hook entries reference absolute paths under the bundled plugin/ dir. Re-run `agentmemory connect claude-code --with-hooks` after upgrading agentmemory to refresh them.",
-  );
+    logInstalled("Claude Code hooks (workaround for `#508`)", CLAUDE_SETTINGS);
+    p.log.info(
+      "User-scope hook entries reference absolute paths under the bundled plugin/ dir. Re-run `agentmemory connect claude-code --with-hooks` after upgrading agentmemory to refresh them.",
+    );
 
-  return {
-    kind: "installed",
-    mutatedPath: CLAUDE_SETTINGS,
-    ...(backupPath !== undefined && { backupPath }),
-  };
+    return {
+      kind: "installed",
+      mutatedPath: CLAUDE_SETTINGS,
+      ...(backupPath !== undefined && { backupPath }),
+    };
+  } catch (err) {
+    return {
+      kind: "skipped",
+      reason: err instanceof Error ? err.message : String(err),
+    };
+  }
 }

Also applies to: 164-166

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/connect/claude-code.ts` around lines 131 - 147, installClaudeHooks
currently only catches errors from findPluginRoot; any failures while
reading/parsing CLAUDE_SETTINGS, building merged hooks via buildMergedHooks, or
writing settings will throw and abort the connect flow—wrap the risky operations
(readJsonSafe(CLAUDE_SETTINGS), buildMergedHooks(existingHooks, pluginRoot,
"hooks.json"), and the settings write path) in try/catch blocks and on error
return a ConnectResult with kind: "skipped" and reason set to the error message
(preserve existing pattern err instanceof Error ? err.message : String(err));
apply the same defensive handling to the analogous code around the 164-166
region so manifest merge/write failures become non-fatal warnings instead of
exceptions.


if (opts.dryRun) {
p.log.info(
`[dry-run] Would merge agentmemory hook entries into ${CLAUDE_SETTINGS} (${Object.keys(merged.hooks).length} event(s))`,
);
return { kind: "installed", mutatedPath: CLAUDE_SETTINGS };
}

let backupPath: string | undefined;
if (existsSync(CLAUDE_SETTINGS)) {
backupPath = backupFile(CLAUDE_SETTINGS, "claude-settings", "json");
logBackup(backupPath);
} else {
mkdirSync(CLAUDE_DIR, { recursive: true });
}

const next: ClaudeSettings = { ...existing, hooks: merged.hooks };
writeJsonAtomic(CLAUDE_SETTINGS, next);

logInstalled("Claude Code hooks (workaround for #508)", CLAUDE_SETTINGS);
p.log.info(
"User-scope hook entries reference absolute paths under the bundled plugin/ dir. Re-run `agentmemory connect claude-code --with-hooks` after upgrading agentmemory to refresh them.",
);

return {
kind: "installed",
mutatedPath: CLAUDE_SETTINGS,
...(backupPath !== undefined && { backupPath }),
};
}
5 changes: 3 additions & 2 deletions src/cli/connect/codex-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,10 @@ export function findPluginRoot(startUrl: string = import.meta.url): string {
export function buildMergedHooks(
existing: HookManifest | null,
pluginRoot: string,
manifestFile = "hooks.codex.json",
): HookManifest {
const codexManifestPath = join(pluginRoot, "hooks", "hooks.codex.json");
const ours = JSON.parse(readFileSync(codexManifestPath, "utf-8")) as HookManifest;
const bundledManifestPath = join(pluginRoot, "hooks", manifestFile);
const ours = JSON.parse(readFileSync(bundledManifestPath, "utf-8")) as HookManifest;
const scriptsDir = join(pluginRoot, "scripts");

const out: HookManifest = { hooks: {} };
Expand Down
2 changes: 1 addition & 1 deletion src/cli/connect/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export const adapter: ConnectAdapter = {

logInstalled("Codex CLI", CODEX_TOML);
p.log.info(
"Codex picks up MCP servers on next launch. For the deeper plugin install, run: codex plugin marketplace add rohitg00/agentmemory && codex plugin install agentmemory",
"Codex picks up MCP servers on next launch. For the deeper plugin install, run: codex plugin marketplace add rohitg00/agentmemory && codex plugin add agentmemory@agentmemory",
);

if (opts.withHooks) {
Expand Down
74 changes: 74 additions & 0 deletions test/claude-code-with-hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect } from "vitest";
import { resolve } from "node:path";
import {
buildMergedHooks,
findPluginRoot,
type HookManifest,
} from "../src/cli/connect/codex-hooks.js";

const PLUGIN_ROOT = resolve(__dirname, "..", "plugin");

describe("buildMergedHooks against plugin/hooks/hooks.json (Claude Code)", () => {
it("locates the same plugin root used by the codex variant", () => {
expect(findPluginRoot()).toBe(PLUGIN_ROOT);
});

it("rewrites ${CLAUDE_PLUGIN_ROOT} to absolute pluginRoot in every command", () => {
const merged = buildMergedHooks(null, PLUGIN_ROOT, "hooks.json");
for (const entries of Object.values(merged.hooks)) {
for (const entry of entries) {
for (const handler of entry.hooks) {
expect(handler.command).not.toContain("${CLAUDE_PLUGIN_ROOT}");
expect(handler.command).toContain(`${PLUGIN_ROOT}/scripts/`);
}
}
}
});

it("includes Claude-only events that hooks.codex.json omits", () => {
const merged = buildMergedHooks(null, PLUGIN_ROOT, "hooks.json");
const events = Object.keys(merged.hooks);
expect(events).toContain("SessionStart");
expect(events).toContain("Stop");
const claudeOnly = ["SessionEnd", "SubagentStop", "Notification"];
expect(
claudeOnly.some((e) => events.includes(e)),
`hooks.json should include at least one Claude-only event (${claudeOnly.join(", ")})`,
).toBe(true);
});

it("appends to existing user hooks without dropping them", () => {
const existing: HookManifest = {
hooks: {
SessionStart: [
{ hooks: [{ type: "command", command: "echo user-custom-claude" }] },
],
},
};
const merged = buildMergedHooks(existing, PLUGIN_ROOT, "hooks.json");
const sessionStart = merged.hooks["SessionStart"]!;
expect(
sessionStart.some((e) =>
e.hooks.some((h) => h.command === "echo user-custom-claude"),
),
).toBe(true);
expect(
sessionStart.some((e) =>
e.hooks.some((h) =>
h.command.includes(`${PLUGIN_ROOT}/scripts/session-start.mjs`),
),
),
).toBe(true);
});

it("re-install strips previous agentmemory entries (idempotent)", () => {
const first = buildMergedHooks(null, PLUGIN_ROOT, "hooks.json");
const second = buildMergedHooks(first, PLUGIN_ROOT, "hooks.json");
for (const event of Object.keys(first.hooks)) {
expect(
second.hooks[event]!.length,
`${event} should not double after second install`,
).toBe(first.hooks[event]!.length);
}
});
});