From faba928cf283cbbd7c379d56ebc8f9b5a0c8d236 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 20:09:01 -0400 Subject: [PATCH 01/11] feat(plugin): add Claude Code package --- .claude-plugin/marketplace.json | 6 ++++ .claude-plugin/plugin.json | 11 +++++++ hooks/hooks.json | 8 +++++ mcp.claude.json | 9 ++++++ tests/plugin-manifest.test.ts | 57 +++++++++++++++++++++++++++++++++ 5 files changed, 91 insertions(+) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 hooks/hooks.json create mode 100644 mcp.claude.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..87a1974 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,6 @@ +{ + "name": "agent-lcm", + "description": "Shared local context memory for agent harnesses.", + "owner": { "name": "Team Volt" }, + "plugins": [{ "name": "agent-lcm", "source": "." }] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..b124825 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "agent-lcm", + "version": "0.0.7", + "description": "Shared local context memory for agent harnesses.", + "author": { "name": "Team Volt" }, + "homepage": "https://github.com/Team-Volt/agent-lcm", + "keywords": ["agent-memory", "context", "recall", "sessions"], + "skills": "./skills/", + "hooks": "./hooks/hooks.json", + "mcpServers": "./mcp.claude.json" +} diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..4b80408 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,8 @@ +{ + "hooks": { + "SessionStart": [{ "hooks": [{ "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/bin/agent-lcm", "capture", "--harness", "claude", "SessionStart"] }] }], + "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/bin/agent-lcm", "capture", "--harness", "claude", "UserPromptSubmit"] }] }], + "PostToolUse": [{ "hooks": [{ "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/bin/agent-lcm", "capture", "--harness", "claude", "PostToolUse"] }] }], + "Stop": [{ "hooks": [{ "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/bin/agent-lcm", "capture", "--harness", "claude", "Stop"] }] }] + } +} diff --git a/mcp.claude.json b/mcp.claude.json new file mode 100644 index 0000000..6e0a46f --- /dev/null +++ b/mcp.claude.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "agent-lcm": { + "type": "stdio", + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/bin/agent-lcm", "mcp"] + } + } +} diff --git a/tests/plugin-manifest.test.ts b/tests/plugin-manifest.test.ts index 72bf494..4cd6908 100644 --- a/tests/plugin-manifest.test.ts +++ b/tests/plugin-manifest.test.ts @@ -78,3 +78,60 @@ test("client hook manifests invoke explicit or detected harness capture", () => assert.deepEqual(Object.keys(portableHooks.hooks).sort(), ["postToolUse", "sessionEnd", "sessionStart", "userPromptSubmitted"]); assert.match(JSON.stringify(portableHooks), /capture --harness auto/u); }); + +test("Claude Code plugin artifacts use isolated native components", () => { + const packageJson = readJson("package.json"); + const portablePlugin = readJson("plugin.json"); + assert.equal(portablePlugin.version, packageJson.version); + delete portablePlugin.$schema; + const plugin = readJson(".claude-plugin/plugin.json"); + assert.deepEqual(plugin, { + ...portablePlugin, + skills: "./skills/", + hooks: "./hooks/hooks.json", + mcpServers: "./mcp.claude.json", + }); + assert.equal(plugin.version, packageJson.version); + + const marketplace = readJson(".claude-plugin/marketplace.json"); + assert.deepEqual(marketplace, { + name: "agent-lcm", + description: "Shared local context memory for agent harnesses.", + owner: { name: "Team Volt" }, + plugins: [{ name: "agent-lcm", source: "." }], + }); + assert.equal(marketplace.plugins.length, 1); + + const expectedEvents = ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"]; + const hooks = readJson("hooks/hooks.json"); + assert.deepEqual(Object.keys(hooks.hooks).sort(), [...expectedEvents].sort()); + for (const event of expectedEvents) { + assert.equal(hooks.hooks[event].length, 1); + assert.deepEqual(Object.keys(hooks.hooks[event][0]).sort(), ["hooks"]); + assert.deepEqual(hooks.hooks[event][0].hooks, [{ + type: "command", + command: "node", + args: ["${CLAUDE_PLUGIN_ROOT}/bin/agent-lcm", "capture", "--harness", "claude", event], + }]); + } + + const mcp = readJson("mcp.claude.json"); + assert.deepEqual(Object.keys(mcp).sort(), ["mcpServers"]); + assert.deepEqual(Object.keys(mcp.mcpServers), ["agent-lcm"]); + assert.deepEqual(mcp.mcpServers["agent-lcm"], { + type: "stdio", + command: "node", + args: ["${CLAUDE_PLUGIN_ROOT}/bin/agent-lcm", "mcp"], + }); + assert.doesNotMatch(JSON.stringify({ plugin, hooks, mcp }), /\$\{PLUGIN_ROOT\}/u); + assert.doesNotMatch(JSON.stringify(hooks), /node ["']/u); + assert.deepEqual(readJson(".mcp.json"), { + mcpServers: { + "agent-lcm": { + type: "stdio", + command: "node", + args: ["${PLUGIN_ROOT}/bin/agent-lcm", "mcp"], + }, + }, + }); +}); From 9aa5ba1c0c6115b375fb7cc64b0835f540dcb732 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 20:41:57 -0400 Subject: [PATCH 02/11] feat: add Claude Code capture and setup --- dist/claude-lifecycle.js | 64 +++++++++++++ dist/cli.js | 18 ++-- dist/doctor.js | 5 + dist/events.js | 2 +- dist/harnesses.js | 3 +- dist/hook.js | 2 +- dist/setup-adapters.js | 36 +++++++ dist/setup-targets.js | 10 +- dist/setup.js | 27 +++++- dist/storage-rows.js | 2 +- src/claude-lifecycle.ts | 91 ++++++++++++++++++ src/cli.ts | 17 ++-- src/doctor.ts | 5 + src/events.ts | 2 +- src/harnesses.ts | 5 +- src/hook.ts | 2 +- src/setup-adapters.ts | 53 ++++++++++- src/setup-hook-status.ts | 8 +- src/setup-hooks.ts | 6 +- src/setup-targets.ts | 9 +- src/setup.ts | 33 ++++++- src/storage-rows.ts | 2 +- tests/doctor-import.test.ts | 5 + tests/events.test.ts | 5 +- tests/fixtures/hooks/claude.json | 6 ++ tests/harnesses.test.ts | 22 ++++- tests/hook-cli.test.ts | 44 +++++++++ tests/setup-adapters.test.ts | 157 +++++++++++++++++++++++++++++++ tests/setup.test.ts | 82 ++++++++++++++++ 29 files changed, 681 insertions(+), 42 deletions(-) create mode 100644 dist/claude-lifecycle.js create mode 100644 src/claude-lifecycle.ts create mode 100644 tests/fixtures/hooks/claude.json diff --git a/dist/claude-lifecycle.js b/dist/claude-lifecycle.js new file mode 100644 index 0000000..a3b60e6 --- /dev/null +++ b/dist/claude-lifecycle.js @@ -0,0 +1,64 @@ +import path from "node:path"; +export class ClaudeLifecycleOutputError extends Error { + name = "ClaudeLifecycleOutputError"; + argv; + constructor(argv) { + super("Claude CLI returned malformed lifecycle JSON."); + this.argv = argv; + } +} +export function runClaudeLifecycle(action, packageRoot, run) { + if (action === "remove") { + const argv = ["plugin", "list", "--json"]; + const plugins = parseRecords(run(argv), argv, isClaudePlugin); + if (hasUserPlugin(plugins)) + run(["plugin", "uninstall", "agent-lcm@agent-lcm", "--scope", "user"]); + return; + } + const marketplaceArgv = ["plugin", "marketplace", "list", "--json"]; + const marketplaces = parseRecords(run(marketplaceArgv), marketplaceArgv, isClaudeMarketplace); + const marketplace = marketplaces.find((entry) => entry.name === "agent-lcm"); + if (marketplace !== undefined && path.resolve(marketplace.path) !== packageRoot) { + throw new ClaudeLifecycleOutputError(marketplaceArgv); + } + if (marketplace === undefined) + run(["plugin", "marketplace", "add", packageRoot, "--scope", "user"]); + const pluginArgv = ["plugin", "list", "--json"]; + const plugins = parseRecords(run(pluginArgv), pluginArgv, isClaudePlugin); + run(["plugin", hasUserPlugin(plugins) ? "update" : "install", "agent-lcm@agent-lcm", "--scope", "user"]); +} +function parseRecords(stdout, argv, isRecordType) { + let value; + try { + value = JSON.parse(stdout); + } + catch { + throw new ClaudeLifecycleOutputError(argv); + } + if (!Array.isArray(value) || !value.every(isRecordType)) + throw new ClaudeLifecycleOutputError(argv); + return value; +} +function hasUserPlugin(plugins) { + return plugins.some((plugin) => plugin.id === "agent-lcm@agent-lcm" && plugin.scope === "user"); +} +function isClaudeMarketplace(value) { + return isRecord(value) + && typeof value.name === "string" + && typeof value.source === "string" + && typeof value.path === "string" + && typeof value.installLocation === "string"; +} +function isClaudePlugin(value) { + return isRecord(value) + && typeof value.id === "string" + && typeof value.version === "string" + && typeof value.scope === "string" + && typeof value.enabled === "boolean" + && typeof value.installPath === "string" + && typeof value.installedAt === "string" + && typeof value.lastUpdated === "string"; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/dist/cli.js b/dist/cli.js index 1c0f496..ea642b3 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -50,7 +50,7 @@ export async function main(argv) { printSetupReports(setupHarness(harness, { home, command: commandPath, - ...(home ? { env: lifecycleEnvironment(home) } : {}), + ...(home ? { env: lifecycleEnvironment(harness, home) } : {}), }), rest.includes("--json")); return; } @@ -59,7 +59,7 @@ export async function main(argv) { const home = optionValue(rest, "--home"); printSetupReports(removeHarness(harness, { home, - ...(home ? { env: lifecycleEnvironment(home) } : {}), + ...(home ? { env: lifecycleEnvironment(harness, home) } : {}), }), rest.includes("--json")); return; } @@ -265,11 +265,11 @@ Commands: agent-lcm daemon run|start|restart|status|stop agent-lcm mcp agent-lcm hook - agent-lcm capture --harness codex|cursor|vscode|copilot|kiro|auto [event] + agent-lcm capture --harness codex|cursor|vscode|copilot|kiro|claude|auto [event] agent-lcm setup all - agent-lcm setup [--home PATH] + agent-lcm setup [--home PATH] agent-lcm setup status - agent-lcm remove [--home PATH] + agent-lcm remove [--home PATH] agent-lcm status [--codex-home PATH] [--json] agent-lcm doctor [--codex-home PATH] [--json] Diagnose install, storage, and capture state agent-lcm health [--json] @@ -286,9 +286,9 @@ Commands: `); } function captureHarness(value, action = "setup") { - if (value === "codex" || value === "cursor" || value === "vscode" || value === "copilot" || value === "kiro") + if (value === "codex" || value === "cursor" || value === "vscode" || value === "copilot" || value === "kiro" || value === "claude") return value; - throw new Error(`Usage: agent-lcm ${action} [--home PATH]`); + throw new Error(`Usage: agent-lcm ${action} [--home PATH]`); } function importHarness(value) { if (value === "codex" || value === "cursor" || value === "vscode" || value === "copilot" || value === "kiro") @@ -342,7 +342,9 @@ function printSetupReports(value, json) { if (reports.some((report) => report.status !== "complete")) process.exitCode = 2; } -function lifecycleEnvironment(home) { +function lifecycleEnvironment(harness, home) { + if (harness === "claude") + return { ...process.env, CLAUDE_CONFIG_DIR: home }; return { ...process.env, HOME: home, diff --git a/dist/doctor.js b/dist/doctor.js index b9121c6..9ae7581 100644 --- a/dist/doctor.js +++ b/dist/doctor.js @@ -39,6 +39,11 @@ function adapterStatus(status) { vscode: nativePluginAdapter("VS Code", setups.vscode.hooksConfigured), copilot: nativePluginAdapter("Copilot", setups.copilot.hooksConfigured), kiro: setupAdapter("kiro", setups.kiro.hooksConfigured), + claude: { + configured: null, + state: "unknown", + detail: "Claude native plugin health is not checked by doctor. Run `claude plugin list --json` or use the client's installed-plugin view.", + }, }; } function nativePluginAdapter(harness, legacyHooksConfigured) { diff --git a/dist/events.js b/dist/events.js index 34ed8e1..7a285b3 100644 --- a/dist/events.js +++ b/dist/events.js @@ -1,6 +1,6 @@ import { DEFAULT_LIMITS } from "./config.js"; import { sanitizeForStorage, sha256 } from "./redact.js"; -export const HARNESS_NAMES = ["codex", "cursor", "vscode", "copilot", "kiro", "mcp", "import"]; +export const HARNESS_NAMES = ["codex", "cursor", "vscode", "copilot", "kiro", "claude", "mcp", "import"]; export function harnessSessionId(harness, nativeId) { const id = nativeId.trim(); if (!id) diff --git a/dist/harnesses.js b/dist/harnesses.js index f5a01b6..c4a1960 100644 --- a/dist/harnesses.js +++ b/dist/harnesses.js @@ -6,6 +6,7 @@ const EVENT_MAP = { vscode: { SessionStart: "SessionStart", UserPromptSubmit: "UserPromptSubmit", PostToolUse: "PostToolUse", Stop: "Stop" }, copilot: { sessionStart: "SessionStart", userPromptSubmitted: "UserPromptSubmit", postToolUse: "PostToolUse", sessionEnd: "Stop" }, kiro: { SessionStart: "SessionStart", UserPromptSubmit: "UserPromptSubmit", PostToolUse: "PostToolUse", Stop: "Stop" }, + claude: { SessionStart: "SessionStart", UserPromptSubmit: "UserPromptSubmit", PostToolUse: "PostToolUse", Stop: "Stop" }, }; const KIRO_ALIASES = { sessionStart: "SessionStart", @@ -109,7 +110,7 @@ function stripHarnessPrefix(sessionId) { return isHarness(prefix[1]) ? prefix[2] : sessionId; } function isHarness(value) { - return value === "codex" || value === "cursor" || value === "vscode" || value === "copilot" || value === "kiro" || value === "mcp" || value === "import"; + return value === "codex" || value === "cursor" || value === "vscode" || value === "copilot" || value === "kiro" || value === "claude" || value === "mcp" || value === "import"; } function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/dist/hook.js b/dist/hook.js index 6768840..a6fe837 100644 --- a/dist/hook.js +++ b/dist/hook.js @@ -109,7 +109,7 @@ function captureArguments(args) { if (index < 0 || !args[index + 1]) throw new Error("Usage: agent-lcm capture --harness [event]"); const requested = args[index + 1]; - if (requested !== "auto" && requested !== "codex" && requested !== "cursor" && requested !== "vscode" && requested !== "copilot" && requested !== "kiro") { + if (requested !== "auto" && requested !== "codex" && requested !== "cursor" && requested !== "vscode" && requested !== "copilot" && requested !== "kiro" && requested !== "claude") { throw new Error(`Unknown capture harness: ${requested}`); } const remaining = args.filter((_, position) => position !== index && position !== index + 1); diff --git a/dist/setup-adapters.js b/dist/setup-adapters.js index be250ba..d54ad60 100644 --- a/dist/setup-adapters.js +++ b/dist/setup-adapters.js @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { withCopilotPluginSource } from "./copilot-plugin.js"; +import { ClaudeLifecycleOutputError, runClaudeLifecycle } from "./claude-lifecycle.js"; export class NativeLifecycleCommandError extends Error { name = "NativeLifecycleCommandError"; executable; @@ -46,6 +47,7 @@ export const HARNESS_LIFECYCLE_ADAPTERS = { probeArgv: ["plugin", "list"], }, kiro: { kind: "manual", executable: "kiro-cli", probeArgv: ["--version"], guide: `${GUIDE_ROOT}/kiro.md` }, + claude: { kind: "claude", executable: "claude", guide: `${GUIDE_ROOT}/claude.md` }, }; export function runHarnessLifecycle(harness, action, options = {}) { const adapter = HARNESS_LIFECYCLE_ADAPTERS[harness]; @@ -58,10 +60,44 @@ export function runHarnessLifecycle(harness, action, options = {}) { return runNative(harness, action, adapter, options.env, options.command); case "codex": return runNative(harness, action, adapter, options.env); + case "claude": + try { + return runClaude("claude", action, adapter, options.env); + } + catch (error) { + if (error instanceof ClaudeCliUnavailableError) { + return outcome(harness, action, "manual-required", null, adapter.guide); + } + throw error; + } default: return assertNever(adapter); } } +function runClaude(harness, action, adapter, env) { + try { + runClaudeLifecycle(action, PACKAGE_ROOT, (argv) => runClaudeCommand(adapter.executable, argv, env)); + } + catch (error) { + if (error instanceof ClaudeLifecycleOutputError) { + throw new NativeLifecycleCommandError(adapter.executable, error.argv, 0, SUPPRESSED_STDERR); + } + throw error; + } + return outcome(harness, action, "native-complete", adapter.executable, adapter.guide); +} +function runClaudeCommand(executable, argv, env) { + const result = spawnLifecycleCommand(executable, argv, env); + if (isEnoent(result.error)) + throw new ClaudeCliUnavailableError(); + if (result.error !== undefined || result.status !== 0) { + throw new NativeLifecycleCommandError(executable, argv, result.status, SUPPRESSED_STDERR); + } + return result.stdout; +} +class ClaudeCliUnavailableError extends Error { + name = "ClaudeCliUnavailableError"; +} function runNative(harness, action, adapter, env, command) { const probe = spawnLifecycleCommand(adapter.executable, adapter.probeArgv, env); if (isEnoent(probe.error)) { diff --git a/dist/setup-targets.js b/dist/setup-targets.js index d62e021..7a43eab 100644 --- a/dist/setup-targets.js +++ b/dist/setup-targets.js @@ -1,8 +1,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -export const SETUP_HARNESSES = ["codex", "cursor", "vscode", "copilot", "kiro"]; +export const SETUP_HARNESSES = ["codex", "cursor", "vscode", "copilot", "kiro", "claude"]; +export function claudeConfigPath(configDir = path.join(os.homedir(), ".claude")) { + return path.join(path.resolve(configDir), "settings.json"); +} export function setupPath(harness, home) { + if (harness === "claude") + return claudeConfigPath(home); const harnessHome = path.resolve(home ?? defaultHarnessHome(harness, os.homedir())); return harness === "codex" || harness === "cursor" ? path.join(harnessHome, "hooks.json") @@ -20,6 +25,8 @@ export function detectedHarnesses(userHome = os.homedir()) { detected.push("vscode"); if (fs.existsSync(defaultHarnessHome("kiro", userHome))) detected.push("kiro"); + if (fs.existsSync(defaultHarnessHome("claude", userHome))) + detected.push("claude"); return detected; } function defaultHarnessHome(harness, userHome) { @@ -29,6 +36,7 @@ function defaultHarnessHome(harness, userHome) { case "vscode": case "copilot": return path.join(userHome, ".copilot"); case "kiro": return path.join(userHome, ".kiro"); + case "claude": return path.join(userHome, ".claude"); } } function vscodeHomes(userHome) { diff --git a/dist/setup.js b/dist/setup.js index 95572d0..e9289f1 100644 --- a/dist/setup.js +++ b/dist/setup.js @@ -3,8 +3,19 @@ import { runHarnessLifecycle } from "./setup-adapters.js"; import { ensureSetupDirectory, mutateSetupConfiguration, readSetupConfiguration, readSetupConfigurationSnapshot, SetupConfigurationChangedError, } from "./setup-files.js"; import { assertSafeSetupCommand, setupHooksConfigured } from "./setup-hook-status.js"; import { mergeSetupHooks, removeSetupHooks, removeSharedSetupHooks, validateSetupHooks, } from "./setup-hooks.js"; -import { SETUP_HARNESSES, setupPath } from "./setup-targets.js"; +import { claudeConfigPath, SETUP_HARNESSES, setupPath } from "./setup-targets.js"; export function setupHarness(harness, options) { + if (harness === "claude") { + const native = runHarnessLifecycle(harness, "setup", options.env ? { env: options.env } : {}); + return { + harness, + action: "setup", + status: native.status === "native-complete" ? "complete" : "manual-required", + nativeCli: native.nativeCli, + hooks: { path: claudeConfigPath(options.home), changed: false }, + guide: native.guide, + }; + } const target = setupPath(harness, options.home); const command = options.command.trim(); assertSafeSetupCommand(command); @@ -24,6 +35,17 @@ export function setupHarness(harness, options) { }; } export function removeHarness(harness, options = {}) { + if (harness === "claude") { + const native = runHarnessLifecycle(harness, "remove", options.env ? { env: options.env } : {}); + return { + harness, + action: "remove", + status: native.status === "native-complete" ? "complete" : "manual-required", + nativeCli: native.nativeCli, + hooks: { path: claudeConfigPath(options.home), changed: false }, + guide: native.guide, + }; + } const target = setupPath(harness, options.home); const snapshot = readSetupConfigurationSnapshot(target); const existing = snapshot.configuration; @@ -41,6 +63,9 @@ export function removeHarness(harness, options = {}) { } export function setupStatus(options = {}) { return Object.fromEntries(SETUP_HARNESSES.map((harness) => { + if (harness === "claude") { + return [harness, { hooksConfigured: false, path: claudeConfigPath(options.home) }]; + } const target = setupPath(harness, options.home); return [harness, { hooksConfigured: setupHooksConfigured(harness, readConfigurationForStatus(target)), path: target }]; })); diff --git a/dist/storage-rows.js b/dist/storage-rows.js index 92f1fc6..6711424 100644 --- a/dist/storage-rows.js +++ b/dist/storage-rows.js @@ -2,7 +2,7 @@ export function rowToSessionSummary(row) { const record = recordValue(row); return { session_id: String(record.session_id), - harness: (record.harness === "cursor" || record.harness === "vscode" || record.harness === "copilot" || record.harness === "kiro" || record.harness === "mcp" || record.harness === "import") ? record.harness : "codex", + harness: (record.harness === "cursor" || record.harness === "vscode" || record.harness === "copilot" || record.harness === "kiro" || record.harness === "claude" || record.harness === "mcp" || record.harness === "import") ? record.harness : "codex", first_seen: String(record.first_seen), last_seen: String(record.last_seen), cwd: String(record.cwd), diff --git a/src/claude-lifecycle.ts b/src/claude-lifecycle.ts new file mode 100644 index 0000000..00db736 --- /dev/null +++ b/src/claude-lifecycle.ts @@ -0,0 +1,91 @@ +import path from "node:path"; + +export class ClaudeLifecycleOutputError extends Error { + readonly name = "ClaudeLifecycleOutputError"; + readonly argv: readonly string[]; + + constructor(argv: readonly string[]) { + super("Claude CLI returned malformed lifecycle JSON."); + this.argv = argv; + } +} + +type ClaudeMarketplace = { + readonly name: string; + readonly source: string; + readonly path: string; + readonly installLocation: string; +}; + +type ClaudePlugin = { + readonly id: string; + readonly version: string; + readonly scope: string; + readonly enabled: boolean; + readonly installPath: string; + readonly installedAt: string; + readonly lastUpdated: string; +}; + +export function runClaudeLifecycle( + action: "setup" | "remove", + packageRoot: string, + run: (argv: readonly string[]) => string, +): void { + if (action === "remove") { + const argv = ["plugin", "list", "--json"] as const; + const plugins = parseRecords(run(argv), argv, isClaudePlugin); + if (hasUserPlugin(plugins)) run(["plugin", "uninstall", "agent-lcm@agent-lcm", "--scope", "user"]); + return; + } + + const marketplaceArgv = ["plugin", "marketplace", "list", "--json"] as const; + const marketplaces = parseRecords(run(marketplaceArgv), marketplaceArgv, isClaudeMarketplace); + const marketplace = marketplaces.find((entry) => entry.name === "agent-lcm"); + if (marketplace !== undefined && path.resolve(marketplace.path) !== packageRoot) { + throw new ClaudeLifecycleOutputError(marketplaceArgv); + } + if (marketplace === undefined) run(["plugin", "marketplace", "add", packageRoot, "--scope", "user"]); + + const pluginArgv = ["plugin", "list", "--json"] as const; + const plugins = parseRecords(run(pluginArgv), pluginArgv, isClaudePlugin); + run(["plugin", hasUserPlugin(plugins) ? "update" : "install", "agent-lcm@agent-lcm", "--scope", "user"]); +} + +function parseRecords(stdout: string, argv: readonly string[], isRecordType: (value: unknown) => value is T): readonly T[] { + let value: unknown; + try { + value = JSON.parse(stdout); + } catch { + throw new ClaudeLifecycleOutputError(argv); + } + if (!Array.isArray(value) || !value.every(isRecordType)) throw new ClaudeLifecycleOutputError(argv); + return value; +} + +function hasUserPlugin(plugins: readonly ClaudePlugin[]): boolean { + return plugins.some((plugin) => plugin.id === "agent-lcm@agent-lcm" && plugin.scope === "user"); +} + +function isClaudeMarketplace(value: unknown): value is ClaudeMarketplace { + return isRecord(value) + && typeof value.name === "string" + && typeof value.source === "string" + && typeof value.path === "string" + && typeof value.installLocation === "string"; +} + +function isClaudePlugin(value: unknown): value is ClaudePlugin { + return isRecord(value) + && typeof value.id === "string" + && typeof value.version === "string" + && typeof value.scope === "string" + && typeof value.enabled === "boolean" + && typeof value.installPath === "string" + && typeof value.installedAt === "string" + && typeof value.lastUpdated === "string"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/cli.ts b/src/cli.ts index 95340ab..61ee911 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -91,7 +91,7 @@ export async function main(argv: string[]): Promise { printSetupReports(setupHarness(harness, { home, command: commandPath, - ...(home ? { env: lifecycleEnvironment(home) } : {}), + ...(home ? { env: lifecycleEnvironment(harness, home) } : {}), }), rest.includes("--json")); return; } @@ -100,7 +100,7 @@ export async function main(argv: string[]): Promise { const home = optionValue(rest, "--home"); printSetupReports(removeHarness(harness, { home, - ...(home ? { env: lifecycleEnvironment(home) } : {}), + ...(home ? { env: lifecycleEnvironment(harness, home) } : {}), }), rest.includes("--json")); return; } @@ -307,11 +307,11 @@ Commands: agent-lcm daemon run|start|restart|status|stop agent-lcm mcp agent-lcm hook - agent-lcm capture --harness codex|cursor|vscode|copilot|kiro|auto [event] + agent-lcm capture --harness codex|cursor|vscode|copilot|kiro|claude|auto [event] agent-lcm setup all - agent-lcm setup [--home PATH] + agent-lcm setup [--home PATH] agent-lcm setup status - agent-lcm remove [--home PATH] + agent-lcm remove [--home PATH] agent-lcm status [--codex-home PATH] [--json] agent-lcm doctor [--codex-home PATH] [--json] Diagnose install, storage, and capture state agent-lcm health [--json] @@ -329,8 +329,8 @@ Commands: } function captureHarness(value: string | undefined, action: "setup" | "remove" = "setup"): CaptureHarness { - if (value === "codex" || value === "cursor" || value === "vscode" || value === "copilot" || value === "kiro") return value; - throw new Error(`Usage: agent-lcm ${action} [--home PATH]`); + if (value === "codex" || value === "cursor" || value === "vscode" || value === "copilot" || value === "kiro" || value === "claude") return value; + throw new Error(`Usage: agent-lcm ${action} [--home PATH]`); } function importHarness(value: string | undefined): ImportHarness { @@ -382,7 +382,8 @@ function printSetupReports(value: SetupReport | RemoveReport | SetupReport[], js if (reports.some((report) => report.status !== "complete")) process.exitCode = 2; } -function lifecycleEnvironment(home: string): NodeJS.ProcessEnv { +function lifecycleEnvironment(harness: CaptureHarness, home: string): NodeJS.ProcessEnv { + if (harness === "claude") return { ...process.env, CLAUDE_CONFIG_DIR: home }; return { ...process.env, HOME: home, diff --git a/src/doctor.ts b/src/doctor.ts index f38043e..231e270 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -121,6 +121,11 @@ function adapterStatus(status: Record): Record> = { codex: { SessionStart: "SessionStart", UserPromptSubmit: "UserPromptSubmit", PostToolUse: "PostToolUse", Stop: "Stop" }, @@ -10,6 +10,7 @@ const EVENT_MAP: Record> = { vscode: { SessionStart: "SessionStart", UserPromptSubmit: "UserPromptSubmit", PostToolUse: "PostToolUse", Stop: "Stop" }, copilot: { sessionStart: "SessionStart", userPromptSubmitted: "UserPromptSubmit", postToolUse: "PostToolUse", sessionEnd: "Stop" }, kiro: { SessionStart: "SessionStart", UserPromptSubmit: "UserPromptSubmit", PostToolUse: "PostToolUse", Stop: "Stop" }, + claude: { SessionStart: "SessionStart", UserPromptSubmit: "UserPromptSubmit", PostToolUse: "PostToolUse", Stop: "Stop" }, }; const KIRO_ALIASES: Record = { @@ -121,7 +122,7 @@ function stripHarnessPrefix(sessionId: string): string { } function isHarness(value: string): value is HarnessName { - return value === "codex" || value === "cursor" || value === "vscode" || value === "copilot" || value === "kiro" || value === "mcp" || value === "import"; + return value === "codex" || value === "cursor" || value === "vscode" || value === "copilot" || value === "kiro" || value === "claude" || value === "mcp" || value === "import"; } function isRecord(value: unknown): value is Record { diff --git a/src/hook.ts b/src/hook.ts index f80befe..cc9e21a 100644 --- a/src/hook.ts +++ b/src/hook.ts @@ -107,7 +107,7 @@ function captureArguments(args: string[]): { harness: CaptureHarness | "auto"; n const index = args.indexOf("--harness"); if (index < 0 || !args[index + 1]) throw new Error("Usage: agent-lcm capture --harness [event]"); const requested = args[index + 1]; - if (requested !== "auto" && requested !== "codex" && requested !== "cursor" && requested !== "vscode" && requested !== "copilot" && requested !== "kiro") { + if (requested !== "auto" && requested !== "codex" && requested !== "cursor" && requested !== "vscode" && requested !== "copilot" && requested !== "kiro" && requested !== "claude") { throw new Error(`Unknown capture harness: ${requested}`); } const remaining = args.filter((_, position) => position !== index && position !== index + 1); diff --git a/src/setup-adapters.ts b/src/setup-adapters.ts index 6e61ea1..7734747 100644 --- a/src/setup-adapters.ts +++ b/src/setup-adapters.ts @@ -5,10 +5,11 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { withCopilotPluginSource } from "./copilot-plugin.ts"; +import { ClaudeLifecycleOutputError, runClaudeLifecycle } from "./claude-lifecycle.ts"; import type { CaptureHarness } from "./harnesses.ts"; export type HarnessLifecycleAction = "setup" | "remove"; -export type HarnessCli = "codex" | "copilot" | "cursor-agent" | "kiro-cli"; +export type HarnessCli = "codex" | "copilot" | "cursor-agent" | "kiro-cli" | "claude"; export type HarnessLifecycleOutcome = { readonly harness: CaptureHarness; @@ -55,6 +56,12 @@ type CopilotLifecycleAdapter = { readonly probeArgv: readonly string[]; }; +type ClaudeLifecycleAdapter = { + readonly kind: "claude"; + readonly executable: "claude"; + readonly guide: string; +}; + type ManualLifecycleAdapter = { readonly kind: "manual"; readonly executable: "cursor-agent" | "kiro-cli"; @@ -62,7 +69,7 @@ type ManualLifecycleAdapter = { readonly guide: string; }; -export type HarnessLifecycleAdapter = CodexLifecycleAdapter | CopilotLifecycleAdapter | ManualLifecycleAdapter; +export type HarnessLifecycleAdapter = CodexLifecycleAdapter | CopilotLifecycleAdapter | ClaudeLifecycleAdapter | ManualLifecycleAdapter; const GUIDE_ROOT = "https://github.com/Team-Volt/agent-lcm/blob/main/docs/install"; const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -94,6 +101,7 @@ export const HARNESS_LIFECYCLE_ADAPTERS = { probeArgv: ["plugin", "list"], }, kiro: { kind: "manual", executable: "kiro-cli", probeArgv: ["--version"], guide: `${GUIDE_ROOT}/kiro.md` }, + claude: { kind: "claude", executable: "claude", guide: `${GUIDE_ROOT}/claude.md` }, } satisfies Record; export function runHarnessLifecycle( @@ -110,11 +118,50 @@ export function runHarnessLifecycle( return runNative(harness, action, adapter, options.env, options.command); case "codex": return runNative(harness, action, adapter, options.env); + case "claude": + try { + return runClaude("claude", action, adapter, options.env); + } catch (error) { + if (error instanceof ClaudeCliUnavailableError) { + return outcome(harness, action, "manual-required", null, adapter.guide); + } + throw error; + } default: return assertNever(adapter); } } +function runClaude(harness: "claude", action: HarnessLifecycleAction, adapter: ClaudeLifecycleAdapter, env: NodeJS.ProcessEnv | undefined): HarnessLifecycleOutcome { + try { + runClaudeLifecycle(action, PACKAGE_ROOT, (argv) => runClaudeCommand(adapter.executable, argv, env)); + } catch (error) { + if (error instanceof ClaudeLifecycleOutputError) { + throw new NativeLifecycleCommandError(adapter.executable, error.argv, 0, SUPPRESSED_STDERR); + } + throw error; + } + return outcome(harness, action, "native-complete", adapter.executable, adapter.guide); +} + +function runClaudeCommand( + executable: "claude", + argv: readonly string[], + env: NodeJS.ProcessEnv | undefined, +): string { + const result = spawnLifecycleCommand(executable, argv, env); + if (isEnoent(result.error)) throw new ClaudeCliUnavailableError(); + if (result.error !== undefined || result.status !== 0) { + throw new NativeLifecycleCommandError(executable, argv, result.status, SUPPRESSED_STDERR); + } + return result.stdout; +} + +class ClaudeCliUnavailableError extends Error { + readonly name = "ClaudeCliUnavailableError"; +} + + function runNative( harness: CaptureHarness, action: HarnessLifecycleAction, @@ -157,7 +204,7 @@ function manualOutcome( return outcome(harness, action, "manual-required", adapter.executable, adapter.guide); } -function runNativeCommand(executable: "codex" | "copilot", argv: readonly string[], env: NodeJS.ProcessEnv | undefined): void { +function runNativeCommand(executable: "codex" | "copilot" | "claude", argv: readonly string[], env: NodeJS.ProcessEnv | undefined): void { const result = spawnLifecycleCommand(executable, argv, env); if (result.status === 0) return; throw new NativeLifecycleCommandError(executable, argv, result.status, SUPPRESSED_STDERR); diff --git a/src/setup-hook-status.ts b/src/setup-hook-status.ts index f53a16c..a0bee24 100644 --- a/src/setup-hook-status.ts +++ b/src/setup-hook-status.ts @@ -2,12 +2,14 @@ import path from "node:path"; import type { CaptureHarness } from "./harnesses.ts"; +type SetupHookHarness = Exclude; + export const CODEX_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PreCompact", "PostCompact", "SubagentStop", "Stop"] as const; export type KiroHook = { name: string; trigger: string; action: { type: "command"; command: string } }; export function setupHooksConfigured( - harness: CaptureHarness, + harness: SetupHookHarness, configuration: Record | undefined, ): boolean { if (!configuration) return false; @@ -34,7 +36,7 @@ export function setupHooksConfigured( }); } -export function eventsFor(harness: CaptureHarness): string[] { +export function eventsFor(harness: SetupHookHarness): string[] { return isSharedHookHarness(harness) ? ["sessionStart", "userPromptSubmitted", "postToolUse", "sessionEnd"] : ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"]; @@ -50,7 +52,7 @@ export function isSharedHookHarness(harness: CaptureHarness): harness is "copilo return harness === "copilot" || harness === "vscode"; } -export function setupCaptureHarness(harness: CaptureHarness): CaptureHarness | "auto" { +export function setupCaptureHarness(harness: SetupHookHarness): SetupHookHarness | "auto" { return isSharedHookHarness(harness) ? "auto" : harness; } diff --git a/src/setup-hooks.ts b/src/setup-hooks.ts index 76e6b02..7aa5e77 100644 --- a/src/setup-hooks.ts +++ b/src/setup-hooks.ts @@ -12,9 +12,11 @@ import { type KiroHook, } from "./setup-hook-status.ts"; +type SetupHookHarness = Exclude; + export function mergeSetupHooks( existing: Record | undefined, - harness: CaptureHarness, + harness: SetupHookHarness, command: string, target: string, ): Record { @@ -162,7 +164,7 @@ function invalidConfiguration(target: string): Error { } export function validateSetupHooks( - harness: CaptureHarness, + harness: SetupHookHarness, configuration: Record | undefined, target: string, ): void { diff --git a/src/setup-targets.ts b/src/setup-targets.ts index 1285212..e785529 100644 --- a/src/setup-targets.ts +++ b/src/setup-targets.ts @@ -4,9 +4,14 @@ import path from "node:path"; import type { CaptureHarness } from "./harnesses.ts"; -export const SETUP_HARNESSES: readonly CaptureHarness[] = ["codex", "cursor", "vscode", "copilot", "kiro"]; +export const SETUP_HARNESSES: readonly CaptureHarness[] = ["codex", "cursor", "vscode", "copilot", "kiro", "claude"]; + +export function claudeConfigPath(configDir = path.join(os.homedir(), ".claude")): string { + return path.join(path.resolve(configDir), "settings.json"); +} export function setupPath(harness: CaptureHarness, home?: string): string { + if (harness === "claude") return claudeConfigPath(home); const harnessHome = path.resolve(home ?? defaultHarnessHome(harness, os.homedir())); return harness === "codex" || harness === "cursor" ? path.join(harnessHome, "hooks.json") @@ -20,6 +25,7 @@ export function detectedHarnesses(userHome = os.homedir()): CaptureHarness[] { if (fs.existsSync(defaultHarnessHome("copilot", userHome))) detected.push("copilot"); else if (vscodeHomes(userHome).some((home) => fs.existsSync(home))) detected.push("vscode"); if (fs.existsSync(defaultHarnessHome("kiro", userHome))) detected.push("kiro"); + if (fs.existsSync(defaultHarnessHome("claude", userHome))) detected.push("claude"); return detected; } @@ -30,6 +36,7 @@ function defaultHarnessHome(harness: CaptureHarness, userHome: string): string { case "vscode": case "copilot": return path.join(userHome, ".copilot"); case "kiro": return path.join(userHome, ".kiro"); + case "claude": return path.join(userHome, ".claude"); } } diff --git a/src/setup.ts b/src/setup.ts index 66b6210..dcc6db4 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -16,7 +16,9 @@ import { removeSharedSetupHooks, validateSetupHooks, } from "./setup-hooks.ts"; -import { SETUP_HARNESSES, setupPath } from "./setup-targets.ts"; +import { claudeConfigPath, SETUP_HARNESSES, setupPath } from "./setup-targets.ts"; + +type HookHarness = Exclude; export type SetupOptions = { readonly home?: string; readonly command: string; readonly env?: NodeJS.ProcessEnv }; export type SetupReport = { @@ -40,6 +42,17 @@ export type SetupStatusOptions = { readonly home?: string }; export type HarnessSetupStatus = { readonly hooksConfigured: boolean; readonly path: string }; export function setupHarness(harness: CaptureHarness, options: SetupOptions): SetupReport { + if (harness === "claude") { + const native = runHarnessLifecycle(harness, "setup", options.env ? { env: options.env } : {}); + return { + harness, + action: "setup", + status: native.status === "native-complete" ? "complete" : "manual-required", + nativeCli: native.nativeCli, + hooks: { path: claudeConfigPath(options.home), changed: false }, + guide: native.guide, + }; + } const target = setupPath(harness, options.home); const command = options.command.trim(); assertSafeSetupCommand(command); @@ -62,6 +75,17 @@ export function setupHarness(harness: CaptureHarness, options: SetupOptions): Se } export function removeHarness(harness: CaptureHarness, options: RemoveOptions = {}): RemoveReport { + if (harness === "claude") { + const native = runHarnessLifecycle(harness, "remove", options.env ? { env: options.env } : {}); + return { + harness, + action: "remove", + status: native.status === "native-complete" ? "complete" : "manual-required", + nativeCli: native.nativeCli, + hooks: { path: claudeConfigPath(options.home), changed: false }, + guide: native.guide, + }; + } const target = setupPath(harness, options.home); const snapshot = readSetupConfigurationSnapshot(target); const existing = snapshot.configuration; @@ -82,13 +106,16 @@ export function removeHarness(harness: CaptureHarness, options: RemoveOptions = export function setupStatus(options: SetupStatusOptions = {}): Record { return Object.fromEntries(SETUP_HARNESSES.map((harness) => { + if (harness === "claude") { + return [harness, { hooksConfigured: false, path: claudeConfigPath(options.home) }]; + } const target = setupPath(harness, options.home); return [harness, { hooksConfigured: setupHooksConfigured(harness, readConfigurationForStatus(target)), path: target }]; })) as Record; } function updateHooks( - harness: CaptureHarness, + harness: HookHarness, nativeStatus: "native-complete" | "manual-required" | "shared-retained", target: string, command: string, @@ -107,7 +134,7 @@ function updateHooks( }, expectedHash); } -function removeHooks(harness: CaptureHarness, target: string, targetExists: boolean, expectedHash: string): boolean { +function removeHooks(harness: HookHarness, target: string, targetExists: boolean, expectedHash: string): boolean { if (harness === "copilot" || harness === "vscode" || (!targetExists && harness !== "codex")) return false; return mutateSetupConfiguration(target, (existing) => existing === undefined ? undefined diff --git a/src/storage-rows.ts b/src/storage-rows.ts index e3f9855..8bfd7ec 100644 --- a/src/storage-rows.ts +++ b/src/storage-rows.ts @@ -7,7 +7,7 @@ export function rowToSessionSummary(row: unknown): SessionSummary { const record = recordValue(row); return { session_id: String(record.session_id), - harness: (record.harness === "cursor" || record.harness === "vscode" || record.harness === "copilot" || record.harness === "kiro" || record.harness === "mcp" || record.harness === "import") ? record.harness : "codex" as HarnessName, + harness: (record.harness === "cursor" || record.harness === "vscode" || record.harness === "copilot" || record.harness === "kiro" || record.harness === "claude" || record.harness === "mcp" || record.harness === "import") ? record.harness : "codex" as HarnessName, first_seen: String(record.first_seen), last_seen: String(record.last_seen), cwd: String(record.cwd), diff --git a/tests/doctor-import.test.ts b/tests/doctor-import.test.ts index 5fca602..a52b437 100644 --- a/tests/doctor-import.test.ts +++ b/tests/doctor-import.test.ts @@ -75,6 +75,11 @@ test("doctor reports actionable recommendations for an unwired empty install", ( state: "unknown", detail: "Copilot native plugin health is not checked by doctor. Run `copilot plugin list` or use the client's installed-plugin view.", }); + assert.deepEqual(report.adapter_status.claude, { + configured: null, + state: "unknown", + detail: "Claude native plugin health is not checked by doctor. Run `claude plugin list --json` or use the client's installed-plugin view.", + }); assert.equal(report.adapter_status.codex.configured, false); assert.equal(report.recommendations.some((text: string) => text.includes("Install the Agent LCM plugin")), true); assert.equal(report.recommendations.some((text: string) => text.includes("import --all")), true); diff --git a/tests/events.test.ts b/tests/events.test.ts index 1e5d81d..6d83e5d 100644 --- a/tests/events.test.ts +++ b/tests/events.test.ts @@ -31,8 +31,9 @@ test("normalizes Codex-style hook payloads without project as primary boundary", }); test("namespaces native session identifiers by harness", () => { - assert.deepEqual(HARNESS_NAMES, ["codex", "cursor", "vscode", "copilot", "kiro", "mcp", "import"]); + assert.deepEqual(HARNESS_NAMES, ["codex", "cursor", "vscode", "copilot", "kiro", "claude", "mcp", "import"]); assert.equal(harnessSessionId("cursor", " abc "), "cursor:abc"); + assert.equal(harnessSessionId("claude", " native-session "), "claude:native-session"); assert.throws(() => harnessSessionId("codex", " "), /native session id must not be empty/u); }); @@ -67,7 +68,7 @@ test("accepts camelCase session and tool keys", () => { assert.deepEqual(event.payload.toolResult, { textResultForLlm: "hello" }); }); -test("uses only Codex session environment fallback keys", () => { +test("Codex normalization ignores CLAUDE_SESSION_ID and uses only its own fallback key", () => { const codexEvent = normalizeHookEvent({ hookEvent: "UserPromptSubmit", rawInput: JSON.stringify({ diff --git a/tests/fixtures/hooks/claude.json b/tests/fixtures/hooks/claude.json new file mode 100644 index 0000000..41e3280 --- /dev/null +++ b/tests/fixtures/hooks/claude.json @@ -0,0 +1,6 @@ +{ + "hook_event_name": "UserPromptSubmit", + "session_id": "claude-fixture", + "cwd": "/tmp/claude-fixture", + "prompt": "claude capture" +} diff --git a/tests/harnesses.test.ts b/tests/harnesses.test.ts index 3bf8989..bbc2d68 100644 --- a/tests/harnesses.test.ts +++ b/tests/harnesses.test.ts @@ -8,7 +8,7 @@ import { mapHarnessEvent } from "../src/harnesses.ts"; const FIXTURES = path.join("tests", "fixtures", "hooks"); test("maps each supported harness into a namespaced sanitized event", () => { - for (const harness of ["codex", "cursor", "vscode", "copilot", "kiro"] as const) { + for (const harness of ["codex", "cursor", "vscode", "copilot", "kiro", "claude"] as const) { const mapped = mapHarnessEvent(harness, undefined, readFixture(harness)); assert.equal(mapped.harness, harness); assert.match(mapped.session_id, new RegExp(`^${harness}:`, "u")); @@ -17,6 +17,26 @@ test("maps each supported harness into a namespaced sanitized event", () => { } }); +test("Claude Code maps its four supported native hook events without aliases", () => { + for (const nativeEvent of ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"]) { + const event = mapHarnessEvent("claude", nativeEvent, { + session_id: "claude-native-session", + cwd: "/tmp/claude-events", + }); + assert.equal(event.harness, "claude"); + assert.equal(event.native_event, nativeEvent); + assert.equal(event.hook_event, nativeEvent); + assert.equal(event.session_id, "claude:claude-native-session"); + } +}); + +test("Claude Code rejects unsupported native hook events", () => { + assert.throws( + () => mapHarnessEvent("claude", "MessageDisplay", { session_id: "claude-message", cwd: "/tmp/claude" }), + /Unsupported claude capture event: MessageDisplay/u, + ); +}); + test("auto capture separates documented VS Code and Copilot payloads", () => { const vscode = mapHarnessEvent("auto", undefined, readFixture("vscode")); const copilot = mapHarnessEvent("auto", undefined, readFixture("copilot")); diff --git a/tests/hook-cli.test.ts b/tests/hook-cli.test.ts index bfa9317..90b9540 100644 --- a/tests/hook-cli.test.ts +++ b/tests/hook-cli.test.ts @@ -58,6 +58,50 @@ test("capture publishes a mapped harness event before starting the shared daemon assert.equal(JSON.parse(status.stdout).running, false); }); +test("Claude Code capture persists a canonical event and rejects unsupported events before publication", () => { + const home = tempHome("agent-lcm-claude-capture-"); + const env = { AGENT_LCM_HOME: home }; + const captured = runCli(["capture", "--harness", "claude", "UserPromptSubmit"], { + input: JSON.stringify({ session_id: "claude-capture", cwd: "/tmp/claude-capture", prompt: "capture through queue" }), + env, + timeout: 15_000, + }); + assertCliOk(captured); + const [event] = readJsonl(path.join(home, "events.jsonl")) as Array<{ + harness: string; + native_event: string; + hook_event: string; + session_id: string; + }>; + assert.equal(event.harness, "claude"); + assert.equal(event.native_event, "UserPromptSubmit"); + assert.equal(event.hook_event, "UserPromptSubmit"); + assert.equal(event.session_id, "claude:claude-capture"); + + const rejectedHome = tempHome("agent-lcm-claude-rejected-"); + const rejected = runCli(["capture", "--harness", "claude", "MessageDisplay"], { + input: JSON.stringify({ session_id: "claude-rejected", cwd: "/tmp/claude-rejected" }), + env: { AGENT_LCM_HOME: rejectedHome }, + timeout: 15_000, + }); + assert.equal(rejected.status, 1); + assert.match(rejected.stderr, /Unsupported claude capture event: MessageDisplay/u); + assert.equal(fs.existsSync(path.join(rejectedHome, "inbox")), false); + assert.equal(fs.existsSync(path.join(rejectedHome, "events.jsonl")), false); +}); + +test("import help and harness support exclude Claude Code", () => { + const home = tempHome("agent-lcm-claude-import-"); + const help = runCli(["--help"], { env: { AGENT_LCM_HOME: home } }); + assertCliOk(help); + assert.match(help.stdout, /import --all\|--harness codex\|cursor\|vscode\|copilot\|kiro/u); + assert.doesNotMatch(help.stdout, /import --all\|--harness [^\n]*claude/u); + + const importResult = runCli(["import", "--harness", "claude"], { env: { AGENT_LCM_HOME: home } }); + assert.equal(importResult.status, 1); + assert.match(importResult.stderr, /codex\|cursor\|vscode\|copilot\|kiro/u); +}); + test("daemon restart replaces the running daemon", (t) => { // Given: an isolated home with a running daemon. const home = tempHome(); diff --git a/tests/setup-adapters.test.ts b/tests/setup-adapters.test.ts index d501fda..74b7d49 100644 --- a/tests/setup-adapters.test.ts +++ b/tests/setup-adapters.test.ts @@ -10,6 +10,130 @@ import { NativeLifecycleCommandError, runHarnessLifecycle } from "../src/setup-a const GUIDE_ROOT = "https://github.com/Team-Volt/agent-lcm/blob/main/docs/install"; const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +test("Claude setup adds its marketplace and installs the user plugin when both are absent", (t) => { + const fake = fakeClaudeCli(t, { marketplaces: [], plugins: [] }); + + const report = runHarnessLifecycle("claude", "setup", { env: fake.env }); + + assert.deepEqual(report, { + harness: "claude", + action: "setup", + status: "native-complete", + nativeCli: "claude", + guide: `${GUIDE_ROOT}/claude.md`, + }); + assert.deepEqual(readCalls(fake.log), [ + ["plugin", "marketplace", "list", "--json"], + ["plugin", "marketplace", "add", PACKAGE_ROOT, "--scope", "user"], + ["plugin", "list", "--json"], + ["plugin", "install", "agent-lcm@agent-lcm", "--scope", "user"], + ]); +}); + +test("Claude setup updates an existing user plugin from the matching marketplace", (t) => { + const fake = fakeClaudeCli(t, { + marketplaces: [claudeMarketplace(PACKAGE_ROOT)], + plugins: [claudePlugin()], + }); + + const report = runHarnessLifecycle("claude", "setup", { env: fake.env }); + + assert.equal(report.status, "native-complete"); + assert.deepEqual(readCalls(fake.log), [ + ["plugin", "marketplace", "list", "--json"], + ["plugin", "list", "--json"], + ["plugin", "update", "agent-lcm@agent-lcm", "--scope", "user"], + ]); +}); + +test("Claude setup stops after a conflicting marketplace source", (t) => { + const fake = fakeClaudeCli(t, { + marketplaces: [claudeMarketplace(path.join(PACKAGE_ROOT, "other"))], + plugins: [], + }); + + assert.throws(() => runHarnessLifecycle("claude", "setup", { env: fake.env }), NativeLifecycleCommandError); + assert.deepEqual(readCalls(fake.log), [["plugin", "marketplace", "list", "--json"]]); +}); + +test("Claude lifecycle rejects malformed JSON and records", (t) => { + const malformedJson = fakeClaudeCli(t, { marketplaces: "{not json", plugins: [] }, { rawMarketplace: true }); + const malformedRecord = fakeClaudeCli(t, { marketplaces: [{ name: "agent-lcm" }], plugins: [] }); + + for (const fake of [malformedJson, malformedRecord]) { + assert.throws(() => runHarnessLifecycle("claude", "setup", { env: fake.env }), (error: unknown) => { + assert.ok(error instanceof NativeLifecycleCommandError); + assert.equal(error.stderr, "suppressed"); + return true; + }); + assert.deepEqual(readCalls(fake.log), [["plugin", "marketplace", "list", "--json"]]); + } +}); + +test("Claude setup rejects malformed plugin records before mutation", (t) => { + const fake = fakeClaudeCli(t, { + marketplaces: [claudeMarketplace(PACKAGE_ROOT)], + plugins: [{ id: "agent-lcm@agent-lcm", scope: "user" }], + }); + + assert.throws(() => runHarnessLifecycle("claude", "setup", { env: fake.env }), NativeLifecycleCommandError); + assert.deepEqual(readCalls(fake.log), [ + ["plugin", "marketplace", "list", "--json"], + ["plugin", "list", "--json"], + ]); +}); + +test("Claude setup reports a missing CLI as manual-required", (t) => { + const bin = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-no-claude-")); + t.after(() => fs.rmSync(bin, { recursive: true, force: true })); + + const report = runHarnessLifecycle("claude", "setup", { env: { PATH: bin } }); + + assert.deepEqual(report, { + harness: "claude", + action: "setup", + status: "manual-required", + nativeCli: null, + guide: `${GUIDE_ROOT}/claude.md`, + }); +}); + +test("Claude removal uninstalls only an exact user plugin and otherwise does nothing", (t) => { + const installed = fakeClaudeCli(t, { marketplaces: [], plugins: [claudePlugin()] }); + const absent = fakeClaudeCli(t, { marketplaces: [], plugins: [{ ...claudePlugin(), scope: "project" }] }); + + assert.equal(runHarnessLifecycle("claude", "remove", { env: installed.env }).status, "native-complete"); + assert.equal(runHarnessLifecycle("claude", "remove", { env: absent.env }).status, "native-complete"); + assert.deepEqual(readCalls(installed.log), [ + ["plugin", "list", "--json"], + ["plugin", "uninstall", "agent-lcm@agent-lcm", "--scope", "user"], + ]); + assert.deepEqual(readCalls(absent.log), [["plugin", "list", "--json"]]); +}); + +test("Claude command failures suppress client stderr", (t) => { + const fake = fakeClaudeCli(t, { marketplaces: [], plugins: [] }, { + fails: ["plugin", "marketplace", "list", "--json"], + }); + + assert.throws(() => runHarnessLifecycle("claude", "setup", { env: fake.env }), (error: unknown) => { + assert.ok(error instanceof NativeLifecycleCommandError); + assert.equal(error.status, 23); + assert.equal(error.stderr, "suppressed"); + assert.doesNotMatch(error.message, /secret-token/u); + return true; + }); +}); + +test("Claude lifecycle resolves a Windows command shim without a shell spawn", { skip: process.platform !== "win32" }, (t) => { + const fake = fakeClaudeCli(t, { marketplaces: [], plugins: [] }); + + const report = runHarnessLifecycle("claude", "setup", { env: fake.env }); + + assert.equal(report.status, "native-complete"); + assert.deepEqual(readCalls(fake.log)[0], ["plugin", "marketplace", "list", "--json"]); +}); + test("Codex setup and remove send the exact argv", (t) => { // Given: a capable fake Codex CLI that records each argv vector. const fake = fakeCli(t, "codex"); @@ -276,6 +400,39 @@ function fakeCli( }; } +function fakeClaudeCli( + t: test.TestContext, + responses: { readonly marketplaces: unknown; readonly plugins: unknown }, + options: { readonly rawMarketplace?: boolean; readonly fails?: readonly string[] } = {}, +): { readonly env: NodeJS.ProcessEnv; readonly log: string } { + const bin = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-fake-claude-")); + const log = path.join(bin, "calls.jsonl"); + const marketplaceOutput = options.rawMarketplace ? String(responses.marketplaces) : JSON.stringify(responses.marketplaces); + const script = `#!/usr/bin/env node\nconst fs = require("node:fs");\nconst argv = process.argv.slice(2);\nfs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify(argv) + "\\n");\nif (${JSON.stringify(options.fails ?? null)} && JSON.stringify(argv) === JSON.stringify(${JSON.stringify(options.fails ?? null)})) { process.stderr.write("secret-token\\n"); process.exit(23); }\nif (JSON.stringify(argv) === JSON.stringify(["plugin", "marketplace", "list", "--json"])) process.stdout.write(${JSON.stringify(marketplaceOutput)});\nif (JSON.stringify(argv) === JSON.stringify(["plugin", "list", "--json"])) process.stdout.write(${JSON.stringify(JSON.stringify(responses.plugins))});\n`; + writeFakeCli(bin, "claude", script); + t.after(() => fs.rmSync(bin, { recursive: true, force: true })); + return { + env: { AGENT_LCM_FAKE_LOG: log, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}` }, + log, + }; +} + +function claudeMarketplace(marketplacePath: string): Record { + return { name: "agent-lcm", source: marketplacePath, path: marketplacePath, installLocation: "user" }; +} + +function claudePlugin(): Record { + return { + id: "agent-lcm@agent-lcm", + version: "0.0.7", + scope: "user", + enabled: true, + installPath: "/tmp/agent-lcm", + installedAt: "2026-08-11T00:00:00.000Z", + lastUpdated: "2026-08-11T00:00:00.000Z", + }; +} + function writeFakeCli(bin: string, name: string, script: string): void { if (process.platform === "win32") { const source = path.join(bin, `${name}.cjs`); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index b80def4..60562f8 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -578,6 +578,72 @@ test("setup all configures only harnesses already installed for the user", () => assert.equal(fs.existsSync(path.join(userHome, ".kiro")), false); }); +test("Claude setup, removal, and status never inspect or mutate settings", (t) => { + const configDir = tempHome("agent-lcm-claude-config-"); + const settings = path.join(configDir, "settings.json"); + const original = Buffer.from("{not json\n"); + fs.writeFileSync(settings, original); + const fake = fakeClaudeLifecycleCli(t); + + const setup = setupHarness("claude", { home: configDir, command: "not-an-absolute-command", env: fake.env }); + const status = setupStatus({ home: configDir }).claude; + const remove = removeHarness("claude", { home: configDir, env: fake.env }); + + assert.equal(setup.status, "complete"); + assert.equal(remove.status, "complete"); + assert.deepEqual(setup.hooks, { path: settings, changed: false }); + assert.deepEqual(remove.hooks, { path: settings, changed: false }); + assert.deepEqual(status, { hooksConfigured: false, path: settings }); + assert.deepEqual(fs.readFileSync(settings), original); + assert.equal(fs.existsSync(path.join(configDir, "hooks")), false); +}); + +test("Claude setup does not follow a symlinked settings path", { skip: process.platform === "win32" }, (t) => { + const configDir = tempHome("agent-lcm-claude-symlink-"); + const victim = path.join(tempHome("agent-lcm-claude-victim-"), "victim.json"); + fs.writeFileSync(victim, "victim bytes\n"); + fs.symlinkSync(victim, path.join(configDir, "settings.json")); + const fake = fakeClaudeLifecycleCli(t); + + setupHarness("claude", { home: configDir, command: "/unused/agent-lcm", env: fake.env }); + + assert.equal(fs.readFileSync(victim, "utf8"), "victim bytes\n"); + assert.equal(fs.readlinkSync(path.join(configDir, "settings.json")), victim); + assert.equal(fs.existsSync(path.join(configDir, "hooks")), false); +}); + +test("Claude CLI --home sets only the Claude config directory lifecycle override", (t) => { + const configDir = tempHome("agent-lcm-claude-cli-home-"); + const fake = fakeClaudeLifecycleCli(t); + + const result = runCli(["setup", "claude", "--home", configDir, "--json"], { env: fake.env }); + + assertCliOk(result); + const calls = readSetupCalls(fake.log) as Array<{ argv: string[]; claudeConfigDir: string | null }>; + assert.equal(calls[0]?.claudeConfigDir, configDir); + assert.deepEqual(calls.map((call) => call.argv), [ + ["plugin", "marketplace", "list", "--json"], + ["plugin", "marketplace", "add", PACKAGE_ROOT, "--scope", "user"], + ["plugin", "list", "--json"], + ["plugin", "install", "agent-lcm@agent-lcm", "--scope", "user"], + ]); +}); + +test("setup all detects a Claude config directory", (t) => { + const userHome = tempHome("agent-lcm-detected-claude-"); + fs.mkdirSync(path.join(userHome, ".claude")); + const fake = fakeClaudeLifecycleCli(t); + + const result = runCli(["setup", "all", "--json"], { + env: { ...fake.env, HOME: userHome, USERPROFILE: userHome }, + }); + + assertCliOk(result); + const reports = JSON.parse(result.stdout); + assert.deepEqual(reports.map((report: { harness: string }) => report.harness), ["claude"]); + assert.equal(reports[0].hooks.path, path.join(userHome, ".claude", "settings.json")); +}); + test("setup prints a clear result for people and keeps JSON output for scripts", () => { const userHome = tempHome("agent-lcm-output-"); const text = runCli(["setup", "codex", "--home", userHome], { env: { PATH: "" } }); @@ -826,6 +892,22 @@ if (${String(failProbe)} && JSON.stringify(process.argv.slice(2)) === JSON.strin }; } +function fakeClaudeLifecycleCli(t: test.TestContext): { readonly env: NodeJS.ProcessEnv; readonly log: string; readonly path: string } { + const bin = fs.mkdtempSync(path.join(tempHome("agent-lcm-claude-lifecycle-parent-"), "bin-")); + const log = path.join(bin, "calls.jsonl"); + const script = `#!/usr/bin/env node +const fs = require("node:fs"); +const argv = process.argv.slice(2); +fs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify({ argv, claudeConfigDir: process.env.CLAUDE_CONFIG_DIR ?? null }) + "\\n"); +if (JSON.stringify(argv) === JSON.stringify(["plugin", "marketplace", "list", "--json"])) process.stdout.write("[]"); +if (JSON.stringify(argv) === JSON.stringify(["plugin", "list", "--json"])) process.stdout.write("[]"); +`; + writeFakeSetupCli(bin, "claude", script); + t.after(() => fs.rmSync(path.dirname(bin), { recursive: true, force: true })); + const executablePath = `${bin}${path.delimiter}${path.dirname(process.execPath)}`; + return { env: { AGENT_LCM_FAKE_LOG: log, PATH: executablePath }, log, path: executablePath }; +} + function writeFakeSetupCli(bin: string, name: string, script: string): void { if (process.platform === "win32") { const source = path.join(bin, `${name}.cjs`); From 05cab6aa5162ef6dfc56c05c9975e078344b7406 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 20:45:05 -0400 Subject: [PATCH 03/11] feat(mcp): add Claude provenance filters --- dist/mcp-catalog.js | 12 ++++++------ src/mcp-catalog.ts | 12 ++++++------ tests/mcp.test.ts | 4 ++-- tests/storage.test.ts | 41 +++++++++++++++++++++++++++++++++++------ 4 files changed, 49 insertions(+), 20 deletions(-) diff --git a/dist/mcp-catalog.js b/dist/mcp-catalog.js index a3f7a2e..ce17c1a 100644 --- a/dist/mcp-catalog.js +++ b/dist/mcp-catalog.js @@ -26,7 +26,7 @@ export const TOOLS = [ cwd: { type: "string" }, repoRoot: { type: "string" }, parentSessionId: { type: "string" }, - harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "mcp", "import"] } }, + harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "claude", "mcp", "import"] } }, rootsOnly: { type: "boolean", default: false }, includeSummaries: { type: "boolean", default: false, description: "Include compact titles, overviews, prompts, outcomes, and topics in this one response." }, limit: { type: "number", default: 50 }, @@ -47,7 +47,7 @@ export const TOOLS = [ cwd: { type: "string" }, repoRoot: { type: "string" }, parentSessionId: { type: "string" }, - harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "mcp", "import"] } }, + harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "claude", "mcp", "import"] } }, rootsOnly: { type: "boolean", default: false }, }, }, @@ -67,7 +67,7 @@ export const TOOLS = [ contentScope: { type: "string", enum: ["memory", "overflow", "both"], default: "memory" }, excludeCurrentSession: { type: "boolean", default: false }, excludeSessionIds: { type: "array", items: { type: "string" } }, - harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "mcp", "import"] } }, + harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "claude", "mcp", "import"] } }, }, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, @@ -116,7 +116,7 @@ export const TOOLS = [ cwd: { type: "string" }, repoRoot: { type: "string" }, sessionIds: { type: "array", items: { type: "string" } }, - harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "mcp", "import"] } }, + harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "claude", "mcp", "import"] } }, budgetTokens: { type: "number", default: 2000 }, limit: { type: "number", default: 4 }, sourceLimit: { type: "number", default: 6, description: "Maximum source events or source summary nodes considered per matched node." }, @@ -176,7 +176,7 @@ export const TOOLS = [ repoRoot: { type: "string" }, excludeCurrentSession: { type: "boolean", default: false }, excludeSessionIds: { type: "array", items: { type: "string" } }, - harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "mcp", "import"] } }, + harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "claude", "mcp", "import"] } }, }, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, @@ -249,7 +249,7 @@ export const TOOLS = [ sessionIds: { type: "array", items: { type: "string" } }, budgetTokens: { type: "number", default: 1200 }, cwd: { type: "string" }, - harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "mcp", "import"] } }, + harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "claude", "mcp", "import"] } }, }, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, diff --git a/src/mcp-catalog.ts b/src/mcp-catalog.ts index fb2bdb3..9a9318a 100644 --- a/src/mcp-catalog.ts +++ b/src/mcp-catalog.ts @@ -26,7 +26,7 @@ export const TOOLS = [ cwd: { type: "string" }, repoRoot: { type: "string" }, parentSessionId: { type: "string" }, - harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "mcp", "import"] } }, + harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "claude", "mcp", "import"] } }, rootsOnly: { type: "boolean", default: false }, includeSummaries: { type: "boolean", default: false, description: "Include compact titles, overviews, prompts, outcomes, and topics in this one response." }, limit: { type: "number", default: 50 }, @@ -47,7 +47,7 @@ export const TOOLS = [ cwd: { type: "string" }, repoRoot: { type: "string" }, parentSessionId: { type: "string" }, - harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "mcp", "import"] } }, + harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "claude", "mcp", "import"] } }, rootsOnly: { type: "boolean", default: false }, }, }, @@ -67,7 +67,7 @@ export const TOOLS = [ contentScope: { type: "string", enum: ["memory", "overflow", "both"], default: "memory" }, excludeCurrentSession: { type: "boolean", default: false }, excludeSessionIds: { type: "array", items: { type: "string" } }, - harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "mcp", "import"] } }, + harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "claude", "mcp", "import"] } }, }, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, @@ -116,7 +116,7 @@ export const TOOLS = [ cwd: { type: "string" }, repoRoot: { type: "string" }, sessionIds: { type: "array", items: { type: "string" } }, - harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "mcp", "import"] } }, + harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "claude", "mcp", "import"] } }, budgetTokens: { type: "number", default: 2000 }, limit: { type: "number", default: 4 }, sourceLimit: { type: "number", default: 6, description: "Maximum source events or source summary nodes considered per matched node." }, @@ -176,7 +176,7 @@ export const TOOLS = [ repoRoot: { type: "string" }, excludeCurrentSession: { type: "boolean", default: false }, excludeSessionIds: { type: "array", items: { type: "string" } }, - harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "mcp", "import"] } }, + harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "claude", "mcp", "import"] } }, }, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, @@ -249,7 +249,7 @@ export const TOOLS = [ sessionIds: { type: "array", items: { type: "string" } }, budgetTokens: { type: "number", default: 1200 }, cwd: { type: "string" }, - harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "mcp", "import"] } }, + harnesses: { type: "array", items: { type: "string", enum: ["codex", "cursor", "vscode", "copilot", "kiro", "claude", "mcp", "import"] } }, }, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 00a36fd..b9a69ea 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -202,7 +202,7 @@ test("MCP server initializes and exposes a stable tool catalog", () => { assert.equal(contextPlanTool.inputSchema.properties.canControlCompaction.const, false); const listTool = responses[1].result.tools.find((tool: { name: string }) => tool.name === "lcm_list_sessions"); assert.equal(listTool.inputSchema.properties.includeSummaries.type, "boolean"); - assert.deepEqual(listTool.inputSchema.properties.harnesses.items.enum, ["codex", "cursor", "vscode", "copilot", "kiro", "mcp", "import"]); + assert.deepEqual(listTool.inputSchema.properties.harnesses.items.enum, ["codex", "cursor", "vscode", "copilot", "kiro", "claude", "mcp", "import"]); for (const tool of responses[1].result.tools) { assert.deepEqual(tool.annotations, { readOnlyHint: true, @@ -323,7 +323,7 @@ test("MCP rejects malformed optional string arrays", () => { assert.deepEqual(responses.map((response) => response.error), [ { code: -32602, message: "value must be an array of non-empty strings." }, { code: -32602, message: "value must be an array of non-empty strings." }, - { code: -32602, message: "harnesses must contain only: codex, cursor, vscode, copilot, kiro, mcp, import." }, + { code: -32602, message: "harnesses must contain only: codex, cursor, vscode, copilot, kiro, claude, mcp, import." }, ]); }); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 4fa14b4..257da16 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -28,7 +28,7 @@ import { clearDerivedSummaries, readJsonl, tempHome } from "./helpers.ts"; const now = () => new Date("2026-06-09T12:00:00.000Z"); -function harnessEvent(eventId: string, harness: "codex" | "cursor", sessionId: string, text: string): NormalizedEvent { +function harnessEvent(eventId: string, harness: "codex" | "cursor" | "claude", sessionId: string, text: string): NormalizedEvent { return { ...normalizeHookEvent({ hookEvent: "UserPromptSubmit", @@ -637,22 +637,35 @@ test("appends JSONL and indexes searchable cross-session events", () => { }); test("storage scopes cross-harness retrieval only when requested", () => { + // Given const home = tempHome(); const storage = createStorage({ home }); const codex = harnessEvent("c1", "codex", "codex:one", "shared needle"); const cursor = harnessEvent("u1", "cursor", "cursor:two", "shared needle"); + const claude = harnessEvent("a1", "claude", "claude:three", "shared needle"); storage.ingest(codex); storage.ingest(cursor); + storage.ingest(claude); + + // When + const defaultSearch = storage.searchSessions({ query: "shared needle" }); + const claudeSearch = storage.searchSessions({ query: "shared needle", harnesses: ["claude"] }); - assert.deepEqual(storage.searchSessions({ query: "shared needle" }).map((session) => session.harness).sort(), ["codex", "cursor"]); + // Then + assert.deepEqual(defaultSearch.map((session) => session.harness).sort(), ["claude", "codex", "cursor"]); + assert.deepEqual(claudeSearch.map((session) => session.session_id), ["claude:three"]); assert.deepEqual(storage.searchSessions({ query: "shared needle", harnesses: ["cursor"] }).map((session) => session.session_id), ["cursor:two"]); - assert.deepEqual(storage.listSessions().sessions.map((session) => session.harness).sort(), ["codex", "cursor"]); + assert.deepEqual(storage.listSessions().sessions.map((session) => session.harness).sort(), ["claude", "codex", "cursor"]); + assert.deepEqual(storage.listSessions({ harnesses: ["claude"] }).sessions.map((session) => session.session_id), ["claude:three"]); assert.deepEqual(storage.listSessions({ harnesses: ["cursor"] }).sessions.map((session) => session.session_id), ["cursor:two"]); - assert.equal(storage.usage().totals.sessions, 2); + assert.equal(storage.usage().totals.sessions, 3); + assert.equal(storage.usage({ harnesses: ["claude"] }).totals.sessions, 1); assert.equal(storage.usage({ harnesses: ["cursor"] }).totals.sessions, 1); - assert.deepEqual([...new Set(storage.expandQuery({ query: "shared needle", budgetTokens: 2_000 }).sources.map((source) => source.harness))].sort(), ["codex", "cursor"]); + assert.deepEqual([...new Set(storage.expandQuery({ query: "shared needle", budgetTokens: 2_000 }).sources.map((source) => source.harness))].sort(), ["claude", "codex", "cursor"]); + assert.deepEqual([...new Set(storage.expandQuery({ query: "shared needle", harnesses: ["claude"], budgetTokens: 2_000 }).sources.map((source) => source.harness))], ["claude"]); assert.deepEqual([...new Set(storage.expandQuery({ query: "shared needle", harnesses: ["cursor"], budgetTokens: 2_000 }).sources.map((source) => source.harness))], ["cursor"]); - assert.deepEqual([...new Set(storage.packContext({ query: "shared needle", budgetTokens: 2_000 }).sources.map((source) => source.harness))].sort(), ["codex", "cursor"]); + assert.deepEqual([...new Set(storage.packContext({ query: "shared needle", budgetTokens: 2_000 }).sources.map((source) => source.harness))].sort(), ["claude", "codex", "cursor"]); + assert.deepEqual([...new Set(storage.packContext({ query: "shared needle", harnesses: ["claude"], budgetTokens: 2_000 }).sources.map((source) => source.harness))], ["claude"]); assert.deepEqual([...new Set(storage.packContext({ query: "shared needle", harnesses: ["cursor"], budgetTokens: 2_000 }).sources.map((source) => source.harness))], ["cursor"]); const db = new DatabaseSync(path.join(home, "index.sqlite")); @@ -667,6 +680,22 @@ test("storage scopes cross-harness retrieval only when requested", () => { ); storage.close(); + + const reopened = createStorage({ home }); + assert.equal(reopened.getSession("claude:three").events[0]?.harness, "claude"); + assert.deepEqual(reopened.searchSessions({ query: "shared needle", harnesses: ["claude"] }).map((session) => session.session_id), ["claude:three"]); + reopened.close(); + + fs.rmSync(path.join(home, "index.sqlite")); + const rawOnly = createStorage({ home, readOnly: true }); + assert.deepEqual(rawOnly.searchSessions({ query: "shared needle" }).map((session) => session.harness).sort(), ["claude", "codex", "cursor"]); + assert.deepEqual(rawOnly.searchSessions({ query: "shared needle", harnesses: ["claude"] }).map((session) => session.session_id), ["claude:three"]); + assert.deepEqual(rawOnly.listSessions().sessions.map((session) => session.harness).sort(), ["claude", "codex", "cursor"]); + assert.deepEqual(rawOnly.listSessions({ harnesses: ["claude"] }).sessions.map((session) => session.session_id), ["claude:three"]); + assert.equal(rawOnly.usage({ harnesses: ["claude"] }).totals.sessions, 1); + assert.deepEqual([...new Set(rawOnly.packContext({ query: "shared needle", budgetTokens: 2_000 }).sources.map((source) => source.harness))].sort(), ["claude", "codex", "cursor"]); + assert.deepEqual([...new Set(rawOnly.packContext({ query: "shared needle", harnesses: ["claude"], budgetTokens: 2_000 }).sources.map((source) => source.harness))], ["claude"]); + rawOnly.close(); }); test("stats reports aggregate summary and graph shape without raw content", () => { From ba90e800dd1463618bb6e7a6a2277e4446063971 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 21:07:42 -0400 Subject: [PATCH 04/11] test(capture): prove Claude durability --- tests/mcp.test.ts | 120 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 2 deletions(-) diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index b9a69ea..6572255 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -1,11 +1,14 @@ import assert from "node:assert/strict"; +import fs from "node:fs"; +import { DatabaseSync } from "node:sqlite"; +import path from "node:path"; import test from "node:test"; import { loadConfig } from "../src/config.ts"; import { daemonStatus, ensureDaemon, stopDaemon } from "../src/daemon-client.ts"; -import { normalizeHookEvent } from "../src/events.ts"; +import { normalizeHookEvent, type NormalizedEvent } from "../src/events.ts"; import { publishInboxEvent } from "../src/inbox.ts"; -import { clearDerivedSummaries, runCli, runMcp, tempHome } from "./helpers.ts"; +import { assertCliOk, clearDerivedSummaries, readJsonl, runCli, runMcp, tempHome } from "./helpers.ts"; type FramedMcpResponse = { readonly id: unknown; @@ -37,6 +40,119 @@ test("MCP bridge drains queued events from every harness through the daemon", as assert.equal((await daemonStatus(config)).running, false); }); +test("Claude capture keeps secrets out of durable and MCP surfaces while draining duplicates and quarantine", async (t) => { + const home = tempHome("agent-lcm-claude-runtime-"); + const config = loadConfig({ home }); + const env = { AGENT_LCM_HOME: home }; + const cwd = "/tmp/claude-runtime-proof"; + const uriPassword = "claude-runtime-uri-password"; + const bearerToken = "claude-runtime-bearer-token"; + const assignmentSecret = "claude-runtime-assignment-secret"; + const privateKey = "claude-runtime-private-key"; + const secrets = [uriPassword, bearerToken, assignmentSecret, privateKey]; + t.after(() => stopDaemon(config)); + + const captures = [ + ["SessionStart", { session_id: "claude-runtime", cwd, source: "startup" }], + [ + "UserPromptSubmit", + { + session_id: "claude-runtime", + cwd, + prompt: `claude runtime proof marker ${"x".repeat(512 * 1024)}`, + credentials: { + uri: `postgres://agent:${uriPassword}@db.example.test/proof`, + authorization: `Bearer ${bearerToken}`, + assignment: `api_key=${assignmentSecret}`, + private_key: `-----BEGIN PRIVATE KEY-----\n${privateKey}\n-----END PRIVATE KEY-----`, + }, + }, + ], + ["PostToolUse", { session_id: "claude-runtime", cwd, tool_name: "Read", tool_input: { path: "proof.txt" } }], + ["Stop", { session_id: "claude-runtime", cwd, reason: "complete" }], + ] as const; + for (const [nativeEvent, payload] of captures) { + const result = runCli(["capture", "--harness", "claude", nativeEvent], { + input: JSON.stringify(payload), + env, + keepDaemon: true, + timeout: 15_000, + }); + assertCliOk(result); + } + + const duplicate: NormalizedEvent = { + ...normalizeHookEvent({ + hookEvent: "UserPromptSubmit", + rawInput: JSON.stringify({ + session_id: "claude-runtime-duplicate", + cwd, + prompt: `duplicate claude runtime proof marker Bearer ${bearerToken}`, + credentials: { uri: `redis://:${uriPassword}@cache.example.test/0`, assignment: `token=${assignmentSecret}` }, + }), + now: () => new Date("2026-08-11T12:00:00.000Z"), + }), + event_id: "claude-runtime-duplicate-event", + harness: "claude", + native_event: "UserPromptSubmit", + session_id: "claude:claude-runtime-duplicate", + }; + fs.mkdirSync(config.inboxDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(path.join(config.inboxDir, "000-malformed.json"), "{not-json", { mode: 0o600 }); + publishInboxEvent(config, duplicate); + const inbox = JSON.stringify(fs.readdirSync(config.inboxDir).map((name) => fs.readFileSync(path.join(config.inboxDir, name), "utf8"))); + for (const secret of secrets) assert.doesNotMatch(inbox, new RegExp(secret, "u")); + + runMcp([ + { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: SUPPORTED_PROTOCOL_VERSION } }, + { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "lcm_grep", arguments: { query: "claude runtime proof marker", harnesses: ["claude"] } } }, + ], env); + publishInboxEvent(config, duplicate); + const responses = runMcp([ + { jsonrpc: "2.0", id: 3, method: "initialize", params: { protocolVersion: SUPPORTED_PROTOCOL_VERSION } }, + { jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "lcm_grep", arguments: { query: "claude runtime proof marker", harnesses: ["claude"] } } }, + { jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "lcm_stats", arguments: {} } }, + ], env); + + const raw = readJsonl(path.join(home, "events.jsonl")) as Array<{ + event_id: string; + harness: string; + native_event: string; + session_id: string; + redactions: readonly unknown[]; + payload: { overflow_ref?: { path: string; sha256: string } }; + }>; + assert.deepEqual(raw.filter((event) => event.session_id === "claude:claude-runtime").map((event) => event.native_event).sort(), ["PostToolUse", "SessionStart", "Stop", "UserPromptSubmit"]); + assert.equal(raw.filter((event) => event.event_id === duplicate.event_id).length, 1); + assert.equal(raw.every((event) => event.harness === "claude"), true); + assert.equal(raw.some((event) => event.redactions.length > 0), true); + const overflow = raw.find((event) => event.native_event === "UserPromptSubmit")?.payload.overflow_ref; + assert.notEqual(overflow, undefined); + assert.equal(overflow?.path, path.join(home, "overflow", `${overflow?.sha256}.json`)); + assert.equal(fs.statSync(overflow?.path ?? "").mode & 0o777, 0o600); + const durable = `${JSON.stringify(raw)}\n${fs.readFileSync(overflow?.path ?? "", "utf8")}`; + for (const secret of secrets) assert.doesNotMatch(durable, new RegExp(secret, "u")); + assert.equal(fs.readdirSync(config.quarantineDir).filter((name) => name === "000-malformed.json").length, 1); + + const db = new DatabaseSync(path.join(home, "index.sqlite")); + try { + const duplicateRow = db.prepare("SELECT COUNT(*) AS count FROM events WHERE event_id = ?1").get(duplicate.event_id); + assert.ok(duplicateRow); + assert.equal(duplicateRow.count, 1); + const indexed = JSON.stringify(db.prepare("SELECT raw_json FROM events ORDER BY rowid").all()); + for (const secret of secrets) assert.doesNotMatch(indexed, new RegExp(secret, "u")); + } finally { + db.close(); + } + const query = JSON.stringify(responses); + for (const secret of secrets) assert.doesNotMatch(query, new RegExp(secret, "u")); + const matches = responses[1].result.structuredContent.matches as Array<{ harness: string }>; + assert.equal(matches.length > 0, true); + assert.equal(matches.every((match) => match.harness === "claude"), true); + assert.equal(responses[2].result.structuredContent.stats.event_count, 5); + assert.equal((await daemonStatus(config)).running, false); +}); + function queuedEvent(eventId: string, harness: "codex" | "cursor", sessionId: string) { const event = normalizeHookEvent({ hookEvent: "UserPromptSubmit", From 0bd75ea315b5e416834ede6b95bb39d655ad11c5 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 21:10:34 -0400 Subject: [PATCH 05/11] docs(claude): add native install guide --- AGENTS.md | 13 +++-- README.md | 39 +++++++++++---- docs/architecture.md | 29 +++++++++--- docs/install/claude.md | 102 ++++++++++++++++++++++++++++++++++++++++ docs/troubleshooting.md | 26 +++++++--- 5 files changed, 183 insertions(+), 26 deletions(-) create mode 100644 docs/install/claude.md diff --git a/AGENTS.md b/AGENTS.md index 75d5db9..1090028 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,10 +33,11 @@ docs/ architecture and troubleshooting ## Harness setup and removal -- `agent-lcm setup ` uses native lifecycle commands only for Codex and - the shared Copilot/VS Code store; Cursor Marketplace and Kiro Powers remain - manual. `agent-lcm remove ` removes only exact Agent LCM-owned hook - entries. +- `agent-lcm setup ` uses native lifecycle commands for Codex, Claude + Code, and the shared Copilot/VS Code store; Cursor Marketplace and Kiro Powers + remain manual. `agent-lcm remove ` removes only exact Agent + LCM-owned hook entries, except Claude Code, whose native removal uninstalls + only the user-scoped plugin and leaves its marketplace configured. - Setup reports `complete` with exit `0`; `manual-required` and `shared-retained` use exit `2`; command errors use exit `1`. - Copilot and VS Code share the native plugin store. Single-harness removal @@ -48,6 +49,10 @@ docs/ architecture and troubleshooting executable. - Successful native Codex setup must not create `~/.codex/hooks.json`; it may remove only exact Agent LCM fallback entries from an existing file. +- Claude Code uses `.claude-plugin/plugin.json`, a local marketplace source `.`, + `hooks/hooks.json`, and `mcp.claude.json` with `${CLAUDE_PLUGIN_ROOT}`. Its + setup status reports `hooksConfigured: false` and does not inspect or mutate + `settings.json`; native plugin health remains unknown to `doctor`. - Setup-file mutation runs through the directory-anchored helper. Do not replace it with path checks followed by later path-based writes. - Validate existing setup JSON before native work. Preserve unrelated and diff --git a/README.md b/README.md index 2e6c50b..d61caac 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Agent LCM Agent LCM gives coding agents one shared, local memory. It captures sessions from -Codex, Cursor, VS Code, GitHub Copilot, and Kiro, then makes that history +Codex, Cursor, VS Code, GitHub Copilot, Kiro, and Claude Code, then makes that history searchable from any of those harnesses through MCP. LCM stands for lossless context memory. The sanitized event archive is the @@ -19,7 +19,10 @@ hosted memory service. combination. Cross-harness search is the default. - Keep one private store per user and machine instead of one database per harness or repository. -- Import sessions that existed before Agent LCM was installed. +- Import sessions that existed before Agent LCM was installed when that harness + has a supported importer. +- Capture Claude Code sessions through its live hooks. Claude Code has no + historical importer in Agent LCM. - Rebuild the SQLite index from the raw archive if the derived data is damaged. - Run without embeddings, external APIs, or cloud storage. @@ -82,14 +85,16 @@ removal: | VS Code | `agent-lcm setup vscode` | [VS Code guide](docs/install/vscode.md) | | GitHub Copilot CLI | `agent-lcm setup copilot` | [Copilot guide](docs/install/copilot.md) | | Kiro IDE | `agent-lcm setup kiro` | [Kiro guide](docs/install/kiro.md) | +| Claude Code | `agent-lcm setup claude` | [Claude Code guide](docs/install/claude.md) | The guides follow the current [Codex plugin](https://github.com/openai/codex/blob/main/codex-rs/skills/src/assets/samples/plugin-creator/references/installing-and-updating.md), [Copilot CLI plugin](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference), [VS Code agent plugin](https://code.visualstudio.com/docs/agent-customization/agent-plugins), -[Cursor Marketplace](https://cursor.com/marketplace), and -[Kiro Powers](https://kiro.dev/docs/powers/) documentation. If setup cannot run a -supported native command, it reports the guide and uses the manual hook path -when that harness needs one. +[Cursor Marketplace](https://cursor.com/marketplace), +[Kiro Powers](https://kiro.dev/docs/powers/), and +[Claude Code plugins](https://code.claude.com/docs/en/plugins) documentation. If +setup cannot run a supported native command, it reports the guide and uses the +manual hook path when that harness needs one. The repository root remains an Agent Plugins package for Kiro and other skills/MCP-only clients: @@ -97,8 +102,9 @@ skills/MCP-only clients: - `skills/lcm-recall/SKILL.md` - the `agent-lcm` stdio server in `mcp.json` -The published npm package omits that root manifest so Codex and Cursor select -their native compatibility manifests, which include hooks. Copilot and VS Code +The published npm package omits that root manifest so Codex, Cursor, and Claude +Code select their native compatibility manifests, which include hooks. Copilot +and VS Code use the native package generated by setup. If a client cannot install the plugin, add this stdio MCP server: @@ -128,6 +134,7 @@ agent-lcm setup cursor agent-lcm setup vscode agent-lcm setup copilot agent-lcm setup kiro +agent-lcm setup claude ``` Run only the commands for the harnesses you use. A legacy VS Code and GitHub @@ -140,15 +147,17 @@ timestamped `-pre-agent-lcm-` backup beside it. Legacy or setup-managed user hook locations are: -| Harness | Hook file | +| Harness | Legacy or setup path | | --- | --- | | Codex | `~/.codex/hooks.json` | | Cursor | `~/.cursor/hooks.json` | | VS Code | `~/.copilot/hooks/agent-lcm.json` | | GitHub Copilot | `~/.copilot/hooks/agent-lcm.json` | | Kiro | `~/.kiro/hooks/agent-lcm.json` | +| Claude Code | No managed hook file; status path is `~/.claude/settings.json` | -Codex, Cursor, Copilot, and VS Code native plugins carry their own hooks. Setup +Codex, Cursor, Copilot, VS Code, and Claude Code native plugins carry their own +hooks. Setup does not add a second user-level copy after native installation. The Codex path above exists only for older fallback entries, which setup removes after native installation succeeds. @@ -187,6 +196,13 @@ Native lifecycle support is limited to the commands that each client documents: command. Cursor must load the native npm package, not the repository-root Agent Plugin, to get hooks. Kiro uses the repository-root Power and the separate Kiro hook file. +- Claude Code probes `claude plugin marketplace list --json` and `claude plugin + list --json`. Setup adds the installed package root with `claude plugin + marketplace add --scope user` when needed, then runs either + `claude plugin install agent-lcm@agent-lcm --scope user` or + `claude plugin update agent-lcm@agent-lcm --scope user`. Removal uninstalls + only that user plugin and retains the marketplace. Pass `--home PATH` to use + a Claude config directory through `CLAUDE_CONFIG_DIR`. Setup validates an existing hook file before invoking a native CLI, preserves unrelated entries, and changes only exact Agent LCM-owned registrations. It @@ -262,6 +278,9 @@ Codex-only command remains available during initial migration work: agent-lcm import-codex-sessions --dry-run --json ``` +Claude Code is live-only in Agent LCM. It is not included in the historical +import scan. + ## Local storage The default store is `~/.agent-lcm`. Set `AGENT_LCM_HOME` to use another one. diff --git a/docs/architecture.md b/docs/architecture.md index b0f5dca..82e2024 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,16 +12,19 @@ skills/lcm-recall/ portable recall skill hooks.json shared lower-camel hook shape for client adapters .codex-plugin/plugin.json Codex native compatibility manifest .cursor-plugin/plugin.json Cursor native compatibility manifest +.claude-plugin/plugin.json Claude Code native compatibility manifest +.claude-plugin/marketplace.json Claude Code local marketplace catalog hooks/ harness-specific hook manifests +mcp.claude.json Claude Code MCP server configuration bin/agent-lcm source and npm CLI entry point dist/ generated npm runtime ``` Agent Plugins 1.0 standardizes skills and MCP servers, not hooks. The npm -artifact therefore omits the root `plugin.json`: Codex and Cursor then select -their native manifests and load bundled hooks. The GitHub repository keeps the -root manifest for Kiro Powers. Copilot and VS Code receive a generated native -package with absolute local commands. +artifact therefore omits the root `plugin.json`: Codex, Cursor, and Claude Code +then select their native manifests and load bundled hooks. The GitHub +repository keeps the root manifest for Kiro Powers. Copilot and VS Code receive +a generated native package with absolute local commands. The npm package and each native plugin copy can start the same per-user daemon. Daemon protocol compatibility, not package release version, decides whether a @@ -44,7 +47,10 @@ Cursor and Kiro run version-only probes for `cursor-agent` and `kiro-cli`. Their Marketplace or Powers steps remain manual, so their native result is `manual-required`. Cursor loads `.cursor-plugin/plugin.json` from the npm package; Kiro loads the repository-root Agent Plugin and uses a separate Kiro -hook file because hooks are not portable. +hook file because hooks are not portable. Claude Code probes its JSON plugin +lists, adds the installed package as the user-scoped local marketplace when +needed, then installs or updates `agent-lcm@agent-lcm`. Its native package uses +`.claude-plugin/plugin.json`, `hooks/hooks.json`, and `mcp.claude.json`. `agent-lcm remove ` removes only exact Agent LCM-owned legacy hooks. Codex runs `codex plugin remove agent-lcm@agent-lcm`. Copilot and VS Code share a @@ -52,6 +58,13 @@ native store, so either single-harness removal returns `shared-retained` and does not invoke an uninstall. Deliberate shared removal remains a documented manual Copilot action after both clients are reviewed. +Claude Code removal lists plugins and uninstalls only the user-scoped +`agent-lcm@agent-lcm` plugin. It leaves the `agent-lcm` marketplace configured. +Claude Code setup does not read or write `settings.json`; its setup status uses +that path only for display and reports `hooksConfigured: false`. `doctor` leaves +Claude native plugin health as `unknown`, so use Claude's plugin list or its +installed-plugin view for the native check. + Lifecycle reports use exit status `0` for `complete`, `2` for `manual-required` or `shared-retained`, and `1` for an error. Existing hook configuration is validated before native work. Unrelated entries and @@ -85,6 +98,10 @@ receive a collision-safe `-pre-agent-lcm-` backup. 7. MCP bridges and storage CLI commands authenticate over local IPC and use the same daemon. +Claude Code's native hook manifest maps exactly `SessionStart`, +`UserPromptSubmit`, `PostToolUse`, and `Stop` to live capture. Agent LCM does not +provide a historical Claude Code importer. + The inbox separates fast harness hooks from database work. Atomic publication also gives the daemon a clear rule: a `.json` file is complete, while temporary files are not ready to consume. @@ -194,7 +211,7 @@ create one inbox file per event; hooks still use the durable inbox. Codex, GitHub Copilot, and Kiro have default local search paths. Cursor accepts chat Markdown exports. VS Code accepts JSON conversation exports or OTLP JSON. Malformed JSONL records are rejected individually so later valid records still -import. +import. Claude Code is live-only and has no historical import path. ## MCP protocol diff --git a/docs/install/claude.md b/docs/install/claude.md new file mode 100644 index 0000000..4581062 --- /dev/null +++ b/docs/install/claude.md @@ -0,0 +1,102 @@ +# Install Agent LCM in Claude Code + +## What setup does + +Run: + +```sh +agent-lcm setup claude +``` + +Setup uses Claude Code's native plugin commands. It adds the installed Agent +LCM package as the user marketplace when needed, then installs or updates the +user-scoped `agent-lcm@agent-lcm` plugin. Run the same command after upgrading +Agent LCM. Setup detects Claude Code when `~/.claude` exists during: + +```sh +agent-lcm setup all +``` + +Use `--home PATH` when Claude uses another configuration directory. For Claude +Code, this option sets `CLAUDE_CONFIG_DIR` to that directory. It does not select +the Agent LCM store and it does not edit `settings.json`. + +If the `claude` executable is missing, setup reports `manual-required` and +prints this guide. A successful setup reports `complete`. + +## Native install and inspection + +The npm package includes the Claude Code package surface: + +- `.claude-plugin/plugin.json` contains the Claude plugin metadata. +- `.claude-plugin/marketplace.json` names the local marketplace and uses `.` as + the plugin source. +- `hooks/hooks.json` contains exactly `SessionStart`, `UserPromptSubmit`, + `PostToolUse`, and `Stop`. +- `mcp.claude.json` starts `bin/agent-lcm mcp` through + `${CLAUDE_PLUGIN_ROOT}`. + +To install the package with Claude Code's documented CLI, resolve the global +npm package directory and add it as a user marketplace: + +```sh +CLAUDE_PACKAGE_ROOT="$(npm root --global)/@team-volt/agent-lcm" +claude plugin marketplace add "$CLAUDE_PACKAGE_ROOT" --scope user +claude plugin install agent-lcm@agent-lcm --scope user +claude plugin list --json +``` + +The official [Claude Code plugin marketplace guide](https://code.claude.com/docs/en/plugin-marketplaces) +documents local marketplace paths and these CLI commands. Review the package +source before allowing its hooks or MCP server. + +After installing or updating, run `/reload-plugins` in Claude Code. If the +plugin still does not appear, start a new Claude Code session or restart the +client, then run `claude plugin list --json` again. + +`agent-lcm setup status` does not inspect Claude's native plugin cache. It +always reports `hooksConfigured: false` with the Claude `settings.json` path; +this is a display path, not a file that setup manages. `agent-lcm doctor --json` +also reports Claude native plugin health as `unknown`. Use +`claude plugin list --json` or Claude Code's installed-plugin view for that +check. + +## Capture scope + +Claude Code support is live capture only. Agent LCM receives these four native +events: + +- `SessionStart` +- `UserPromptSubmit` +- `PostToolUse` +- `Stop` + +Agent LCM has no historical Claude Code importer. Sessions from before the +plugin was installed remain outside the shared store. + +## Remove Agent LCM + +Run: + +```sh +agent-lcm remove claude +``` + +The command lists Claude's plugins and uninstalls only the user-scoped +`agent-lcm@agent-lcm` plugin. It leaves the `agent-lcm` marketplace configured. +If the Claude CLI is unavailable, finish the native removal manually: + +```sh +claude plugin list --json +claude plugin uninstall agent-lcm@agent-lcm --scope user +``` + +Do not remove the marketplace unless you intend to remove that configuration as +well. The setup command creates it at user scope, so use the matching scope: + +```sh +claude plugin marketplace remove agent-lcm --scope user +``` + +After removal, confirm the native state with `claude plugin list --json` and +confirm Agent LCM's local report with `agent-lcm setup status`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index bc1633b..23867c2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -9,9 +9,11 @@ agent-lcm daemon status ``` `doctor` checks Codex plugin wiring, the recall skill, the shared daemon, the -capture queue, quarantine, SQLite, and summary indexing. `setup status` reports -`hooksConfigured` for setup-managed or legacy hook files; it does not claim to -check native plugin health. +capture queue, quarantine, SQLite, and summary indexing. Claude native plugin +health remains `unknown` in that report. `setup status` reports +`hooksConfigured` for setup-managed or legacy hook files; Claude always reports +`hooksConfigured: false` with its `settings.json` display path. It does not claim +to check native plugin health. ## The MCP server is missing @@ -55,9 +57,15 @@ entries from the shared fallback so capture does not run twice. Setup refuses malformed existing JSON instead of overwriting it. Before changing a valid existing file, setup saves a timestamped `-pre-agent-lcm-` backup beside it. -Codex, Cursor, VS Code, and Copilot may ask you to review or trust plugin-owned commands. Capture +Claude Code, Codex, Cursor, VS Code, and Copilot may ask you to review or trust +plugin-owned commands. Claude Code's hooks cover `SessionStart`, +`UserPromptSubmit`, `PostToolUse`, and `Stop`. Capture will not run until the harness allows those hooks. +After installing or updating the Claude Code plugin, run `/reload-plugins` in +Claude Code. Start a new session or restart the client if the plugin still does +not appear. + Check whether events reach the queue and daemon: ```sh @@ -80,7 +88,9 @@ agent-lcm setup copilot agent-lcm setup vscode agent-lcm setup cursor agent-lcm setup kiro +agent-lcm setup claude agent-lcm remove codex +agent-lcm remove claude ``` Replace `codex` with the harness you want to remove. @@ -102,7 +112,10 @@ remove vscode` is intentionally conservative and does not uninstall the shared plugin. A legacy `~/.copilot/hooks/agent-lcm.json` fallback is separate and is left unchanged. Review both clients before using the documented Copilot uninstall command. Cursor Marketplace and Kiro Powers installation/removal -stay manual. +stay manual. Claude Code setup uses its marketplace and plugin JSON lists; a +repeat setup updates the user plugin. Claude removal retains the marketplace and +only uninstalls the user plugin. If `--home PATH` is supplied for Claude, it is +the Claude config directory passed through `CLAUDE_CONFIG_DIR`. Setup validates the existing JSON before starting a native CLI. It changes only exact Agent LCM-owned hook entries and preserves unrelated or near-matching @@ -165,7 +178,8 @@ agent-lcm import --harness vscode /path/to/export.json --dry-run Codex defaults to `~/.codex/sessions`, GitHub Copilot to `~/.copilot/session-state`, and Kiro to `~/.kiro/sessions/cli`. `CODEX_HOME` changes the Codex root. The JSON report separates missing files, rejected -records, duplicates, and harnesses that need an export. +records, duplicates, and harnesses that need an export. Claude Code has no +historical importer in Agent LCM, so older Claude sessions are not scanned. Imports do not edit source files. Repeating a successful import is safe. From 0fdc6387905f058258b964cade3ccbd5edfbded5 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 21:11:51 -0400 Subject: [PATCH 06/11] fix(plugin): avoid duplicate Claude hooks --- .claude-plugin/plugin.json | 1 - tests/plugin-manifest.test.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b124825..9ecd804 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -6,6 +6,5 @@ "homepage": "https://github.com/Team-Volt/agent-lcm", "keywords": ["agent-memory", "context", "recall", "sessions"], "skills": "./skills/", - "hooks": "./hooks/hooks.json", "mcpServers": "./mcp.claude.json" } diff --git a/tests/plugin-manifest.test.ts b/tests/plugin-manifest.test.ts index 4cd6908..6533384 100644 --- a/tests/plugin-manifest.test.ts +++ b/tests/plugin-manifest.test.ts @@ -88,9 +88,9 @@ test("Claude Code plugin artifacts use isolated native components", () => { assert.deepEqual(plugin, { ...portablePlugin, skills: "./skills/", - hooks: "./hooks/hooks.json", mcpServers: "./mcp.claude.json", }); + assert.equal("hooks" in plugin, false); assert.equal(plugin.version, packageJson.version); const marketplace = readJson(".claude-plugin/marketplace.json"); From 8c38145ed4b4d3cd22dfb552a82436f4f207883f Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 21:19:33 -0400 Subject: [PATCH 07/11] test(distribution): verify packaged Claude support --- package.json | 2 ++ scripts/release.ts | 2 +- tests/distribution.test.ts | 59 ++++++++++++++++++++------------------ 3 files changed, 34 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index cc3848b..f5dd586 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "files": [ ".agents/plugins/marketplace.json", + ".claude-plugin/", ".codex-plugin/", ".cursor-plugin/", ".mcp.json", @@ -35,6 +36,7 @@ "hooks/", "skills/", "hooks.json", + "mcp.claude.json", "mcp.cursor.json", "mcp.json" ], diff --git a/scripts/release.ts b/scripts/release.ts index 528ba09..2d43271 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import path from "node:path"; const PACKAGE_NAME = "@team-volt/agent-lcm"; -const MANIFESTS = ["plugin.json", ".codex-plugin/plugin.json", ".cursor-plugin/plugin.json"] as const; +const MANIFESTS = ["plugin.json", ".claude-plugin/plugin.json", ".codex-plugin/plugin.json", ".cursor-plugin/plugin.json"] as const; const VERSION_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u; type JsonRecord = Record; diff --git a/tests/distribution.test.ts b/tests/distribution.test.ts index 84a08d6..f977276 100644 --- a/tests/distribution.test.ts +++ b/tests/distribution.test.ts @@ -22,6 +22,7 @@ test("the npm package contains the complete plugin and no development files", (t for (const required of [ ".agents/plugins/marketplace.json", + ".claude-plugin/marketplace.json", ".claude-plugin/plugin.json", ".codex-plugin/plugin.json", ".cursor-plugin/marketplace.json", ".cursor-plugin/plugin.json", @@ -30,6 +31,7 @@ test("the npm package contains the complete plugin and no development files", (t "README.md", "bin/agent-lcm", "hooks.json", + "hooks/hooks.json", "mcp.claude.json", "mcp.cursor.json", "mcp.json", "package.json", @@ -55,9 +57,9 @@ test("package and native plugin versions stay in sync", () => { const packageJson = readJson("package.json"); const version = packageJson.version; assert.equal(packageJson.name, "@team-volt/agent-lcm"); - assert.equal(readJson("plugin.json").version, version); - assert.equal(readJson(".codex-plugin/plugin.json").version, version); - assert.equal(readJson(".cursor-plugin/plugin.json").version, version); + for (const file of ["plugin.json", ".claude-plugin/plugin.json", ".codex-plugin/plugin.json", ".cursor-plugin/plugin.json"]) { + assert.equal(readJson(file).version, version, file); + } }); test("the publish workflow passes the release tag to the shell as data", () => { @@ -84,9 +86,7 @@ test("the release script updates every versioned package file", (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lcm-version-")); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const files = [ - "package.json", - "package-lock.json", - "plugin.json", + "package.json", "package-lock.json", "plugin.json", ".claude-plugin/plugin.json", ".codex-plugin/plugin.json", ".cursor-plugin/plugin.json", ]; @@ -182,30 +182,21 @@ test("the packed CLI runs outside the checkout and sets up detected harnesses", "agent-lcm", ); assert.equal(fs.existsSync(path.join(packageRoot, "plugin.json")), false); + for (const file of [".claude-plugin/marketplace.json", ".claude-plugin/plugin.json", "hooks/hooks.json", "mcp.claude.json"]) { + assert.equal(fs.existsSync(path.join(packageRoot, file)), true, file); + } assert.equal(JSON.parse(fs.readFileSync(path.join(packageRoot, ".codex-plugin/plugin.json"), "utf8")).hooks, "./hooks/codex.json"); assert.equal(JSON.parse(fs.readFileSync(path.join(packageRoot, ".cursor-plugin/plugin.json"), "utf8")).hooks, "./hooks/cursor.json"); - const mcpConfiguration = JSON.parse(fs.readFileSync(path.join(packageRoot, "mcp.json"), "utf8")) - .mcpServers["agent-lcm"] as { command: string; args: string[] }; - const mcp = spawnSync(mcpConfiguration.command, mcpConfiguration.args.map((arg) => arg.replaceAll("${PLUGIN_ROOT}", packageRoot)), { - cwd: packageRoot, - encoding: "utf8", - env, - input: `${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-11-25" } })}\n`, - timeout: 15_000, - }); - assert.equal(mcp.status, 0, mcp.stderr); - assert.equal(JSON.parse(mcp.stdout).result.serverInfo.version, readJson("package.json").version); - const cursorMcpConfiguration = JSON.parse(fs.readFileSync(path.join(packageRoot, "mcp.cursor.json"), "utf8")) - .mcpServers["agent-lcm"] as { command: string; args: string[] }; - const cursorMcp = spawnSync(cursorMcpConfiguration.command, cursorMcpConfiguration.args.map((arg) => arg.replaceAll("${CURSOR_PLUGIN_ROOT}", packageRoot)), { - cwd: packageRoot, - encoding: "utf8", - env, - input: `${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-11-25" } })}\n`, - timeout: 15_000, - }); - assert.equal(cursorMcp.status, 0, cursorMcp.stderr); - assert.equal(JSON.parse(cursorMcp.stdout).result.serverInfo.version, readJson("package.json").version); + for (const [file, token] of [[".mcp.json", "${PLUGIN_ROOT}"], ["mcp.claude.json", "${CLAUDE_PLUGIN_ROOT}"], ["mcp.cursor.json", "${CURSOR_PLUGIN_ROOT}"]]) { + const configuration = JSON.parse(fs.readFileSync(path.join(packageRoot, file), "utf8")) + .mcpServers["agent-lcm"] as { command: string; args: string[] }; + const mcp = spawnSync(configuration.command, configuration.args.map((arg) => arg.replaceAll(token, packageRoot)), { + cwd: packageRoot, encoding: "utf8", env, timeout: 15_000, + input: `${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-11-25" } })}\n`, + }); + assert.equal(mcp.status, 0, mcp.stderr); + assert.equal(JSON.parse(mcp.stdout).result.serverInfo.version, readJson("package.json").version); + } const importRoot = path.join(root, "empty-codex-sessions"); fs.mkdirSync(importRoot); const imported = runInstalled(["import", "--harness", "codex", importRoot, "--dry-run", "--json"]); @@ -241,6 +232,18 @@ test("the packed CLI runs outside the checkout and sets up detected harnesses", }); assert.equal(postCompact.status, 0, postCompact.stderr); assert.equal(fs.readdirSync(path.join(env.AGENT_LCM_HOME, "post-compact-recovery")).length, 1); + const claudeHooks = JSON.parse(fs.readFileSync(path.join(packageRoot, "hooks/hooks.json"), "utf8")).hooks; + for (const event of ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"]) { + const hook = claudeHooks[event][0].hooks[0] as { command: string; args: string[] }; + const capture = spawnSync(hook.command, hook.args.map((arg) => arg.replaceAll("${CLAUDE_PLUGIN_ROOT}", packageRoot)), { + cwd: root, encoding: "utf8", env, timeout: 15_000, + input: JSON.stringify({ session_id: `distribution-claude-${event}`, cwd: root, prompt: event, tool_name: "Read" }), + }); + assert.equal(capture.status, 0, capture.stderr); + } + const claudeEvents = fs.readFileSync(path.join(env.AGENT_LCM_HOME, "events.jsonl"), "utf8").trim().split("\n") + .map((line) => JSON.parse(line)).filter((event) => event.harness === "claude"); + assert.deepEqual(claudeEvents.map((event) => event.native_event), ["SessionStart", "UserPromptSubmit", "PostToolUse", "Stop"]); const daemon = runInstalled(["daemon", "start", "--json"]); assert.equal(daemon.status, 0, daemon.stderr); assert.equal(JSON.parse(daemon.stdout).running, true); From cf9afcb1f596c873075631a9310d276b2298083e Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 21:36:20 -0400 Subject: [PATCH 08/11] fix(claude): accept native marketplace records --- dist/claude-lifecycle.js | 12 ++++++------ src/claude-lifecycle.ts | 14 ++++++-------- tests/setup-adapters.test.ts | 25 +++++++++++++++++++++++++ tests/setup.test.ts | 2 +- 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/dist/claude-lifecycle.js b/dist/claude-lifecycle.js index a3b60e6..5269672 100644 --- a/dist/claude-lifecycle.js +++ b/dist/claude-lifecycle.js @@ -16,10 +16,12 @@ export function runClaudeLifecycle(action, packageRoot, run) { return; } const marketplaceArgv = ["plugin", "marketplace", "list", "--json"]; - const marketplaces = parseRecords(run(marketplaceArgv), marketplaceArgv, isClaudeMarketplace); + const marketplaces = parseRecords(run(marketplaceArgv), marketplaceArgv, isRecord); const marketplace = marketplaces.find((entry) => entry.name === "agent-lcm"); - if (marketplace !== undefined && path.resolve(marketplace.path) !== packageRoot) { - throw new ClaudeLifecycleOutputError(marketplaceArgv); + if (marketplace !== undefined) { + if (!isClaudeMarketplace(marketplace) || path.resolve(marketplace.path) !== packageRoot) { + throw new ClaudeLifecycleOutputError(marketplaceArgv); + } } if (marketplace === undefined) run(["plugin", "marketplace", "add", packageRoot, "--scope", "user"]); @@ -45,9 +47,7 @@ function hasUserPlugin(plugins) { function isClaudeMarketplace(value) { return isRecord(value) && typeof value.name === "string" - && typeof value.source === "string" - && typeof value.path === "string" - && typeof value.installLocation === "string"; + && typeof value.path === "string"; } function isClaudePlugin(value) { return isRecord(value) diff --git a/src/claude-lifecycle.ts b/src/claude-lifecycle.ts index 00db736..654789e 100644 --- a/src/claude-lifecycle.ts +++ b/src/claude-lifecycle.ts @@ -12,9 +12,7 @@ export class ClaudeLifecycleOutputError extends Error { type ClaudeMarketplace = { readonly name: string; - readonly source: string; readonly path: string; - readonly installLocation: string; }; type ClaudePlugin = { @@ -40,10 +38,12 @@ export function runClaudeLifecycle( } const marketplaceArgv = ["plugin", "marketplace", "list", "--json"] as const; - const marketplaces = parseRecords(run(marketplaceArgv), marketplaceArgv, isClaudeMarketplace); + const marketplaces = parseRecords(run(marketplaceArgv), marketplaceArgv, isRecord); const marketplace = marketplaces.find((entry) => entry.name === "agent-lcm"); - if (marketplace !== undefined && path.resolve(marketplace.path) !== packageRoot) { - throw new ClaudeLifecycleOutputError(marketplaceArgv); + if (marketplace !== undefined) { + if (!isClaudeMarketplace(marketplace) || path.resolve(marketplace.path) !== packageRoot) { + throw new ClaudeLifecycleOutputError(marketplaceArgv); + } } if (marketplace === undefined) run(["plugin", "marketplace", "add", packageRoot, "--scope", "user"]); @@ -70,9 +70,7 @@ function hasUserPlugin(plugins: readonly ClaudePlugin[]): boolean { function isClaudeMarketplace(value: unknown): value is ClaudeMarketplace { return isRecord(value) && typeof value.name === "string" - && typeof value.source === "string" - && typeof value.path === "string" - && typeof value.installLocation === "string"; + && typeof value.path === "string"; } function isClaudePlugin(value: unknown): value is ClaudePlugin { diff --git a/tests/setup-adapters.test.ts b/tests/setup-adapters.test.ts index 74b7d49..9e83c5c 100644 --- a/tests/setup-adapters.test.ts +++ b/tests/setup-adapters.test.ts @@ -30,6 +30,31 @@ test("Claude setup adds its marketplace and installs the user plugin when both a ]); }); +test("Claude setup ignores an unrelated marketplace without a local path", (t) => { + // Given: Claude's normal remote marketplace shape and no Agent LCM plugin. + const fake = fakeClaudeCli(t, { + marketplaces: [{ + name: "claude-plugins-official", + source: "github", + repo: "anthropics/claude-plugins-official", + installLocation: "/tmp/claude-plugins-official", + }], + plugins: [], + }); + + // When: Agent LCM configures the native Claude lifecycle. + const report = runHarnessLifecycle("claude", "setup", { env: fake.env }); + + // Then: it adds only its marketplace and installs its user plugin. + assert.equal(report.status, "native-complete"); + assert.deepEqual(readCalls(fake.log), [ + ["plugin", "marketplace", "list", "--json"], + ["plugin", "marketplace", "add", PACKAGE_ROOT, "--scope", "user"], + ["plugin", "list", "--json"], + ["plugin", "install", "agent-lcm@agent-lcm", "--scope", "user"], + ]); +}); + test("Claude setup updates an existing user plugin from the matching marketplace", (t) => { const fake = fakeClaudeCli(t, { marketplaces: [claudeMarketplace(PACKAGE_ROOT)], diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 60562f8..ab4f73b 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -899,7 +899,7 @@ function fakeClaudeLifecycleCli(t: test.TestContext): { readonly env: NodeJS.Pro const fs = require("node:fs"); const argv = process.argv.slice(2); fs.appendFileSync(process.env.AGENT_LCM_FAKE_LOG, JSON.stringify({ argv, claudeConfigDir: process.env.CLAUDE_CONFIG_DIR ?? null }) + "\\n"); -if (JSON.stringify(argv) === JSON.stringify(["plugin", "marketplace", "list", "--json"])) process.stdout.write("[]"); +if (JSON.stringify(argv) === JSON.stringify(["plugin", "marketplace", "list", "--json"])) process.stdout.write('[{"name":"claude-plugins-official","source":"github","repo":"anthropics/claude-plugins-official","installLocation":"/tmp/claude-plugins-official"}]'); if (JSON.stringify(argv) === JSON.stringify(["plugin", "list", "--json"])) process.stdout.write("[]"); `; writeFakeSetupCli(bin, "claude", script); From 871832b1b7ecff4194fe9ed8a37a28465b936130 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 21:51:53 -0400 Subject: [PATCH 09/11] fix(claude): reject duplicate marketplaces --- dist/claude-lifecycle.js | 5 ++++- src/claude-lifecycle.ts | 4 +++- tests/setup-adapters.test.ts | 39 ++++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/dist/claude-lifecycle.js b/dist/claude-lifecycle.js index 5269672..41ad056 100644 --- a/dist/claude-lifecycle.js +++ b/dist/claude-lifecycle.js @@ -17,7 +17,10 @@ export function runClaudeLifecycle(action, packageRoot, run) { } const marketplaceArgv = ["plugin", "marketplace", "list", "--json"]; const marketplaces = parseRecords(run(marketplaceArgv), marketplaceArgv, isRecord); - const marketplace = marketplaces.find((entry) => entry.name === "agent-lcm"); + const ownedMarketplaces = marketplaces.filter((entry) => entry.name === "agent-lcm"); + if (ownedMarketplaces.length > 1) + throw new ClaudeLifecycleOutputError(marketplaceArgv); + const marketplace = ownedMarketplaces[0]; if (marketplace !== undefined) { if (!isClaudeMarketplace(marketplace) || path.resolve(marketplace.path) !== packageRoot) { throw new ClaudeLifecycleOutputError(marketplaceArgv); diff --git a/src/claude-lifecycle.ts b/src/claude-lifecycle.ts index 654789e..685f6b2 100644 --- a/src/claude-lifecycle.ts +++ b/src/claude-lifecycle.ts @@ -39,7 +39,9 @@ export function runClaudeLifecycle( const marketplaceArgv = ["plugin", "marketplace", "list", "--json"] as const; const marketplaces = parseRecords(run(marketplaceArgv), marketplaceArgv, isRecord); - const marketplace = marketplaces.find((entry) => entry.name === "agent-lcm"); + const ownedMarketplaces = marketplaces.filter((entry) => entry.name === "agent-lcm"); + if (ownedMarketplaces.length > 1) throw new ClaudeLifecycleOutputError(marketplaceArgv); + const marketplace = ownedMarketplaces[0]; if (marketplace !== undefined) { if (!isClaudeMarketplace(marketplace) || path.resolve(marketplace.path) !== packageRoot) { throw new ClaudeLifecycleOutputError(marketplaceArgv); diff --git a/tests/setup-adapters.test.ts b/tests/setup-adapters.test.ts index 9e83c5c..d2b2c9a 100644 --- a/tests/setup-adapters.test.ts +++ b/tests/setup-adapters.test.ts @@ -81,6 +81,45 @@ test("Claude setup stops after a conflicting marketplace source", (t) => { assert.deepEqual(readCalls(fake.log), [["plugin", "marketplace", "list", "--json"]]); }); +test("Claude setup stops when a valid marketplace precedes a conflicting duplicate", (t) => { + // Given: two Agent LCM marketplace records, with the valid record first. + const fake = fakeClaudeCli(t, { + marketplaces: [claudeMarketplace(PACKAGE_ROOT), claudeMarketplace(path.join(PACKAGE_ROOT, "other"))], + plugins: [], + }); + + // When: Claude setup checks the marketplace registry. + assert.throws(() => runHarnessLifecycle("claude", "setup", { env: fake.env }), NativeLifecycleCommandError); + // Then: it stops before any add, install, or update command. + assert.deepEqual(readCalls(fake.log), [["plugin", "marketplace", "list", "--json"]]); +}); + +test("Claude setup stops when a conflicting marketplace precedes a valid duplicate", (t) => { + // Given: two Agent LCM marketplace records, with the conflicting record first. + const fake = fakeClaudeCli(t, { + marketplaces: [claudeMarketplace(path.join(PACKAGE_ROOT, "other")), claudeMarketplace(PACKAGE_ROOT)], + plugins: [], + }); + + // When: Claude setup checks the marketplace registry. + assert.throws(() => runHarnessLifecycle("claude", "setup", { env: fake.env }), NativeLifecycleCommandError); + // Then: it stops before any add, install, or update command. + assert.deepEqual(readCalls(fake.log), [["plugin", "marketplace", "list", "--json"]]); +}); + +test("Claude setup stops when a valid marketplace has a malformed duplicate", (t) => { + // Given: a valid Agent LCM marketplace record and a malformed duplicate. + const fake = fakeClaudeCli(t, { + marketplaces: [claudeMarketplace(PACKAGE_ROOT), { name: "agent-lcm" }], + plugins: [], + }); + + // When: Claude setup checks the marketplace registry. + assert.throws(() => runHarnessLifecycle("claude", "setup", { env: fake.env }), NativeLifecycleCommandError); + // Then: it stops before any add, install, or update command. + assert.deepEqual(readCalls(fake.log), [["plugin", "marketplace", "list", "--json"]]); +}); + test("Claude lifecycle rejects malformed JSON and records", (t) => { const malformedJson = fakeClaudeCli(t, { marketplaces: "{not json", plugins: [] }, { rawMarketplace: true }); const malformedRecord = fakeClaudeCli(t, { marketplaces: [{ name: "agent-lcm" }], plugins: [] }); From 7d888b8eac464943d055633f1aba0f824096de34 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 22:54:58 -0400 Subject: [PATCH 10/11] fix(codex): use native MCP working directory --- .mcp.json | 3 ++- tests/plugin-manifest.test.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.mcp.json b/.mcp.json index de2e47d..8491bfd 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,7 +3,8 @@ "agent-lcm": { "type": "stdio", "command": "node", - "args": ["${PLUGIN_ROOT}/bin/agent-lcm", "mcp"] + "args": ["./bin/agent-lcm", "mcp"], + "cwd": "." } } } diff --git a/tests/plugin-manifest.test.ts b/tests/plugin-manifest.test.ts index 6533384..7c3eae1 100644 --- a/tests/plugin-manifest.test.ts +++ b/tests/plugin-manifest.test.ts @@ -37,7 +37,8 @@ test("client hook manifests invoke explicit or detected harness capture", () => assert.deepEqual(readJson(".mcp.json").mcpServers["agent-lcm"], { type: "stdio", command: "node", - args: ["${PLUGIN_ROOT}/bin/agent-lcm", "mcp"], + args: ["./bin/agent-lcm", "mcp"], + cwd: ".", }); const codexManifest = readJson("hooks/codex.json"); const codexHooks = JSON.stringify(codexManifest); @@ -130,7 +131,8 @@ test("Claude Code plugin artifacts use isolated native components", () => { "agent-lcm": { type: "stdio", command: "node", - args: ["${PLUGIN_ROOT}/bin/agent-lcm", "mcp"], + args: ["./bin/agent-lcm", "mcp"], + cwd: ".", }, }, }); From e3814b3612fd678a17783019bd53b044baadda57 Mon Sep 17 00:00:00 2001 From: davidrobertson Date: Tue, 11 Aug 2026 23:32:02 -0400 Subject: [PATCH 11/11] fix(recall): route cross-harness handoffs through LCM --- dist/mcp-catalog.js | 6 +++--- dist/mcp.js | 1 + skills/lcm-recall/SKILL.md | 3 ++- src/mcp-catalog.ts | 6 +++--- src/mcp.ts | 1 + tests/mcp.test.ts | 4 ++++ 6 files changed, 14 insertions(+), 7 deletions(-) diff --git a/dist/mcp-catalog.js b/dist/mcp-catalog.js index ce17c1a..e1a0270 100644 --- a/dist/mcp-catalog.js +++ b/dist/mcp-catalog.js @@ -17,7 +17,7 @@ export const TOOLS = [ { name: "lcm_list_sessions", title: "LCM List Sessions", - description: "List sessions across projects with time, root/child, pagination, and optional compact summary filters.", + description: "List recent sessions across all harnesses for cross-harness handoffs when query terms are uncertain, with time, root/child, pagination, and optional compact summaries.", inputSchema: { type: "object", properties: { @@ -56,7 +56,7 @@ export const TOOLS = [ { name: "lcm_grep", title: "LCM Grep", - description: "Preferred standard workflow step 1 (grep): search the requested cwd or repo first, then retry globally when scoped memory and overflow results are both empty", + description: "Preferred workflow for cross-harness handoffs, step 1 (grep): search the shared store across all harnesses by default; when cwd or repo is supplied, retry globally if scoped memory and overflow results are both empty.", inputSchema: { type: "object", properties: { @@ -226,7 +226,7 @@ export const TOOLS = [ { name: "lcm_get_recent_context", title: "LCM Get Recent Context", - description: "Retrieve recent events for a session or latest cwd-matching session.", + description: "Retrieve recent events from one session only, selected by session ID or latest cwd match. Do not use for cross-session or cross-harness recall.", inputSchema: { type: "object", properties: { diff --git a/dist/mcp.js b/dist/mcp.js index 8ad9f0e..6e25b83 100644 --- a/dist/mcp.js +++ b/dist/mcp.js @@ -111,6 +111,7 @@ async function handleMessage(message, framing) { serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, instructions: [ "Use Agent LCM for sanitized local evidence from prior agent sessions.", + "The shared store spans all harnesses; the current harness is provenance, not a default search boundary.", "Preferred standard workflow: lcm_grep -> lcm_describe -> lcm_expand.", "Use lcm_expand_query for focused recursive evidence. For recovery after compaction, interruption, or handoff, call lcm_pack_context once and consume structuredContent.markdown from that result.", "For multi-session reviews, call lcm_list_sessions once with includeSummaries; for exact long-session detail, use lcm_describe before bounded graph or paged event reads.", diff --git a/skills/lcm-recall/SKILL.md b/skills/lcm-recall/SKILL.md index ff55319..a0bc23c 100644 --- a/skills/lcm-recall/SKILL.md +++ b/skills/lcm-recall/SKILL.md @@ -11,7 +11,7 @@ description: >- # LCM Recall -Treat Agent LCM as the first lookup for local work memory. Search it before asking the user to repeat durable facts or answering from recollection when earlier work could change the answer. Skip it only when the request is self-contained and prior agent work cannot matter. +Treat Agent LCM as the first lookup for local work memory. Its shared store spans every supported harness, so the current harness is provenance, not a search boundary. Search it before asking the user to repeat durable facts or answering from recollection when earlier work could change the answer. Skip it only when the request is self-contained and prior agent work cannot matter. ## Workflow @@ -32,6 +32,7 @@ For multi-session reviews, call `lcm_list_sessions` once with `includeSummaries: ## Rules - Search all harnesses by default. Pass `harnesses` only when the user asks for a narrower source. +- Use `lcm_get_recent_context` only for one known session. For cross-session or cross-harness handoffs, use `lcm_grep`; if the wording is uncertain, list recent sessions across all harnesses and inspect the few likely candidates. - Use the MCP tools. Do not inspect `~/.agent-lcm`, SQLite, or raw segments directly unless the user asks for storage forensics or MCP is broken. - Keep LCM calls sequential and bounded. Do not fan out one call per session. - `lcm_grep` retries globally when a cwd- or repo-scoped search is empty. Check `search_scope` to distinguish scoped, global, and fallback results; use `lcm_search_sessions` only when scope must remain strict. diff --git a/src/mcp-catalog.ts b/src/mcp-catalog.ts index 9a9318a..7da492d 100644 --- a/src/mcp-catalog.ts +++ b/src/mcp-catalog.ts @@ -17,7 +17,7 @@ export const TOOLS = [ { name: "lcm_list_sessions", title: "LCM List Sessions", - description: "List sessions across projects with time, root/child, pagination, and optional compact summary filters.", + description: "List recent sessions across all harnesses for cross-harness handoffs when query terms are uncertain, with time, root/child, pagination, and optional compact summaries.", inputSchema: { type: "object", properties: { @@ -56,7 +56,7 @@ export const TOOLS = [ { name: "lcm_grep", title: "LCM Grep", - description: "Preferred standard workflow step 1 (grep): search the requested cwd or repo first, then retry globally when scoped memory and overflow results are both empty", + description: "Preferred workflow for cross-harness handoffs, step 1 (grep): search the shared store across all harnesses by default; when cwd or repo is supplied, retry globally if scoped memory and overflow results are both empty.", inputSchema: { type: "object", properties: { @@ -226,7 +226,7 @@ export const TOOLS = [ { name: "lcm_get_recent_context", title: "LCM Get Recent Context", - description: "Retrieve recent events for a session or latest cwd-matching session.", + description: "Retrieve recent events from one session only, selected by session ID or latest cwd match. Do not use for cross-session or cross-harness recall.", inputSchema: { type: "object", properties: { diff --git a/src/mcp.ts b/src/mcp.ts index b3b5149..4b93995 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -126,6 +126,7 @@ async function handleMessage(message: JsonRpcRequest, framing: "line" | "header" serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, instructions: [ "Use Agent LCM for sanitized local evidence from prior agent sessions.", + "The shared store spans all harnesses; the current harness is provenance, not a default search boundary.", "Preferred standard workflow: lcm_grep -> lcm_describe -> lcm_expand.", "Use lcm_expand_query for focused recursive evidence. For recovery after compaction, interruption, or handoff, call lcm_pack_context once and consume structuredContent.markdown from that result.", "For multi-session reviews, call lcm_list_sessions once with includeSummaries; for exact long-session detail, use lcm_describe before bounded graph or paged event reads.", diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 6572255..4decbe5 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -276,6 +276,7 @@ test("MCP server initializes and exposes a stable tool catalog", () => { assert.equal(responses[0].result.protocolVersion, SUPPORTED_PROTOCOL_VERSION); assert.equal(responses[0].result.serverInfo.name, "agent-lcm"); + assert.match(responses[0].result.instructions, /shared store spans all harnesses/u); const toolNames = responses[1].result.tools.map((tool: { name: string }) => tool.name); assert.deepEqual( toolNames, @@ -302,6 +303,7 @@ test("MCP server initializes and exposes a stable tool catalog", () => { assert.equal(toolNames.includes(name), true, `${name} missing from tools/list`); } const grepTool = responses[1].result.tools.find((tool: { name: string }) => tool.name === "lcm_grep"); + const recentTool = responses[1].result.tools.find((tool: { name: string }) => tool.name === "lcm_get_recent_context"); const describeTool = responses[1].result.tools.find((tool: { name: string }) => tool.name === "lcm_describe"); const expandTool = responses[1].result.tools.find((tool: { name: string }) => tool.name === "lcm_expand"); assert.deepEqual(grepTool.inputSchema.properties.contentScope, { @@ -309,6 +311,8 @@ test("MCP server initializes and exposes a stable tool catalog", () => { enum: ["memory", "overflow", "both"], default: "memory", }); + assert.match(grepTool.description, /cross-harness handoffs/u); + assert.match(recentTool.description, /one session only/u); assert.equal(describeTool.inputSchema.properties.includeLineage.type, "boolean"); assert.deepEqual(expandTool.inputSchema.required, ["nodeId"]); const expandQueryTool = responses[1].result.tools.find((tool: { name: string }) => tool.name === "lcm_expand_query");