diff --git a/README.md b/README.md index 6a6462d56..3868fce6b 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,11 @@ agentmemory works with any agent that supports hooks, MCP, or REST API. All agen Warp
connect + MCP + skills + +Klaat Code
+Klaat Code
+6 hooks + MCP + @@ -709,6 +714,7 @@ The agentmemory entry is the **same MCP server block** across every host that us | **Antigravity** (replaces Gemini CLI) | `mcp_config.json` (in Antigravity's User dir) | `agentmemory connect antigravity` writes the standard `mcpServers` block. macOS: `~/Library/Application Support/Antigravity/User/`. Linux: `~/.config/Antigravity/User/`. Use after the 2026-06-18 Gemini CLI sunset. | | **Antigravity CLI** (`agy`) | `~/.gemini/config/mcp_config.json` | `agentmemory connect antigravity-cli`. The `agy` CLI keeps its own config under `~/.gemini/`, separate from the Antigravity IDE above. Pass `--with-hooks` for native auto-capture via `~/.gemini/config/hooks.json`. | | **Kiro** | `~/.kiro/settings/mcp.json` | `agentmemory connect kiro` writes the user-level config. Workspace overrides go in `.kiro/settings/mcp.json` next to your code. | +| **Klaat Code (MCP + hooks)** | `~/.klaatai/mcp.json` | `agentmemory connect klaatcode` merges the MCP entry under Klaat Code's `servers` key (not `mcpServers`); `--with-hooks` adds six native auto-capture hooks (`session_start`, `before_message`, `before_tool`, `after_tool`, `after_message`, `session_end`) into `~/.klaatai/hooks.json` with Klaat Code's snake_case tool matchers. Verify with `/mcp` and `/hooks` inside klaatcode. Hooks fire in the interactive TUI only — `klaatai run` (headless) and ACP sessions are not captured. Klaat Code also auto-loads `.claude/skills/`, so the 17 agentmemory skills work with no extra step. | | **Warp** | `~/.warp/.mcp.json` | `agentmemory connect warp` writes the standard `mcpServers` block. Warp also auto-discovers skills from `.claude/skills/`; once the Claude Code plugin is installed the 8 agentmemory skills (`remember`, `recall`, `recap`, `handoff`, `forget`, `commit-context`, `commit-history`, `session-history`) appear natively in Warp's slash-command palette. | | **Cline (CLI)** | `~/.cline/mcp.json` | `agentmemory connect cline` writes the standard `mcpServers` block. VS Code extension users: paste the same block via Cline Settings → MCP Servers → Edit JSON. | | **Continue.dev** | `~/.continue/config.yaml` (preferred) or `config.json` (legacy) | `agentmemory connect continue` creates `config.yaml` from scratch when neither exists, or modifies existing `config.json`. **If you already have `config.yaml`** the adapter prints the exact block to paste under `mcpServers:`; it won't silently rewrite your yaml because preserving comments and anchors safely needs a YAML parser the package doesn't ship. Continue uses array form (not object) for `mcpServers`. | diff --git a/plugin/hooks/hooks.klaatcode.json b/plugin/hooks/hooks.klaatcode.json new file mode 100644 index 000000000..9a4bac8b0 --- /dev/null +++ b/plugin/hooks/hooks.klaatcode.json @@ -0,0 +1,39 @@ +{ + "session_start": [ + { + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-start.mjs\"", + "timeout": 5 + } + ], + "before_message": [ + { + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/prompt-submit.mjs\"", + "timeout": 5 + } + ], + "before_tool": [ + { + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-tool-use.mjs\"", + "matcher": "^(run_command|edit_file|multi_edit|write_file|read_file|apply_patch|grep|glob)$", + "timeout": 5 + } + ], + "after_tool": [ + { + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-use.mjs\"", + "timeout": 5 + } + ], + "after_message": [ + { + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/stop.mjs\"", + "timeout": 5 + } + ], + "session_end": [ + { + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-end.mjs\"", + "timeout": 5 + } + ] +} diff --git a/plugin/scripts/notification.mjs b/plugin/scripts/notification.mjs index dcf2e3931..6e35018f4 100755 --- a/plugin/scripts/notification.mjs +++ b/plugin/scripts/notification.mjs @@ -27,8 +27,15 @@ function hookCwd(data) { if (Array.isArray(roots)) { for (const root of roots) if (typeof root === "string" && root.trim()) return root; } - const projectDir = process.env["DEVIN_PROJECT_DIR"] || process.env["CLAUDE_PROJECT_DIR"]; - if (projectDir && projectDir.trim()) return projectDir; + if (typeof data.project_root === "string" && data.project_root.trim()) return data.project_root; + for (const name of [ + "DEVIN_PROJECT_DIR", + "CLAUDE_PROJECT_DIR", + "KLAATAI_PROJECT_ROOT" + ]) { + const value = process.env[name]; + if (value && value.trim()) return value; + } } //#endregion //#region src/hooks/notification.ts diff --git a/plugin/scripts/post-commit.mjs b/plugin/scripts/post-commit.mjs index 56a17ccea..995f11415 100755 --- a/plugin/scripts/post-commit.mjs +++ b/plugin/scripts/post-commit.mjs @@ -9,8 +9,15 @@ function hookCwd(data) { if (Array.isArray(roots)) { for (const root of roots) if (typeof root === "string" && root.trim()) return root; } - const projectDir = process.env["DEVIN_PROJECT_DIR"] || process.env["CLAUDE_PROJECT_DIR"]; - if (projectDir && projectDir.trim()) return projectDir; + if (typeof data.project_root === "string" && data.project_root.trim()) return data.project_root; + for (const name of [ + "DEVIN_PROJECT_DIR", + "CLAUDE_PROJECT_DIR", + "KLAATAI_PROJECT_ROOT" + ]) { + const value = process.env[name]; + if (value && value.trim()) return value; + } } //#endregion //#region src/hooks/post-commit.ts diff --git a/plugin/scripts/post-tool-failure.mjs b/plugin/scripts/post-tool-failure.mjs index 0af94e7cd..0f4532f5f 100755 --- a/plugin/scripts/post-tool-failure.mjs +++ b/plugin/scripts/post-tool-failure.mjs @@ -27,8 +27,15 @@ function hookCwd(data) { if (Array.isArray(roots)) { for (const root of roots) if (typeof root === "string" && root.trim()) return root; } - const projectDir = process.env["DEVIN_PROJECT_DIR"] || process.env["CLAUDE_PROJECT_DIR"]; - if (projectDir && projectDir.trim()) return projectDir; + if (typeof data.project_root === "string" && data.project_root.trim()) return data.project_root; + for (const name of [ + "DEVIN_PROJECT_DIR", + "CLAUDE_PROJECT_DIR", + "KLAATAI_PROJECT_ROOT" + ]) { + const value = process.env[name]; + if (value && value.trim()) return value; + } } //#endregion //#region src/hooks/post-tool-failure.ts diff --git a/plugin/scripts/post-tool-use.mjs b/plugin/scripts/post-tool-use.mjs index 1189e35fe..a164d8eda 100755 --- a/plugin/scripts/post-tool-use.mjs +++ b/plugin/scripts/post-tool-use.mjs @@ -27,8 +27,15 @@ function hookCwd(data) { if (Array.isArray(roots)) { for (const root of roots) if (typeof root === "string" && root.trim()) return root; } - const projectDir = process.env["DEVIN_PROJECT_DIR"] || process.env["CLAUDE_PROJECT_DIR"]; - if (projectDir && projectDir.trim()) return projectDir; + if (typeof data.project_root === "string" && data.project_root.trim()) return data.project_root; + for (const name of [ + "DEVIN_PROJECT_DIR", + "CLAUDE_PROJECT_DIR", + "KLAATAI_PROJECT_ROOT" + ]) { + const value = process.env[name]; + if (value && value.trim()) return value; + } } //#endregion //#region src/hooks/post-tool-use.ts diff --git a/plugin/scripts/pre-compact.mjs b/plugin/scripts/pre-compact.mjs index 753094359..50352a5cb 100755 --- a/plugin/scripts/pre-compact.mjs +++ b/plugin/scripts/pre-compact.mjs @@ -27,8 +27,15 @@ function hookCwd(data) { if (Array.isArray(roots)) { for (const root of roots) if (typeof root === "string" && root.trim()) return root; } - const projectDir = process.env["DEVIN_PROJECT_DIR"] || process.env["CLAUDE_PROJECT_DIR"]; - if (projectDir && projectDir.trim()) return projectDir; + if (typeof data.project_root === "string" && data.project_root.trim()) return data.project_root; + for (const name of [ + "DEVIN_PROJECT_DIR", + "CLAUDE_PROJECT_DIR", + "KLAATAI_PROJECT_ROOT" + ]) { + const value = process.env[name]; + if (value && value.trim()) return value; + } } //#endregion //#region src/hooks/pre-compact.ts diff --git a/plugin/scripts/prompt-submit.mjs b/plugin/scripts/prompt-submit.mjs index 53daba26c..c1c600e40 100755 --- a/plugin/scripts/prompt-submit.mjs +++ b/plugin/scripts/prompt-submit.mjs @@ -27,8 +27,15 @@ function hookCwd(data) { if (Array.isArray(roots)) { for (const root of roots) if (typeof root === "string" && root.trim()) return root; } - const projectDir = process.env["DEVIN_PROJECT_DIR"] || process.env["CLAUDE_PROJECT_DIR"]; - if (projectDir && projectDir.trim()) return projectDir; + if (typeof data.project_root === "string" && data.project_root.trim()) return data.project_root; + for (const name of [ + "DEVIN_PROJECT_DIR", + "CLAUDE_PROJECT_DIR", + "KLAATAI_PROJECT_ROOT" + ]) { + const value = process.env[name]; + if (value && value.trim()) return value; + } } //#endregion //#region src/hooks/prompt-submit.ts diff --git a/plugin/scripts/session-end.mjs b/plugin/scripts/session-end.mjs index f2d8f79b1..20d845e8a 100755 --- a/plugin/scripts/session-end.mjs +++ b/plugin/scripts/session-end.mjs @@ -28,8 +28,15 @@ function hookCwd(data) { if (Array.isArray(roots)) { for (const root of roots) if (typeof root === "string" && root.trim()) return root; } - const projectDir = process.env["DEVIN_PROJECT_DIR"] || process.env["CLAUDE_PROJECT_DIR"]; - if (projectDir && projectDir.trim()) return projectDir; + if (typeof data.project_root === "string" && data.project_root.trim()) return data.project_root; + for (const name of [ + "DEVIN_PROJECT_DIR", + "CLAUDE_PROJECT_DIR", + "KLAATAI_PROJECT_ROOT" + ]) { + const value = process.env[name]; + if (value && value.trim()) return value; + } } //#endregion //#region src/hooks/session-end.ts diff --git a/plugin/scripts/session-start.mjs b/plugin/scripts/session-start.mjs index 7c112250c..40ba01ffc 100755 --- a/plugin/scripts/session-start.mjs +++ b/plugin/scripts/session-start.mjs @@ -27,8 +27,15 @@ function hookCwd(data) { if (Array.isArray(roots)) { for (const root of roots) if (typeof root === "string" && root.trim()) return root; } - const projectDir = process.env["DEVIN_PROJECT_DIR"] || process.env["CLAUDE_PROJECT_DIR"]; - if (projectDir && projectDir.trim()) return projectDir; + if (typeof data.project_root === "string" && data.project_root.trim()) return data.project_root; + for (const name of [ + "DEVIN_PROJECT_DIR", + "CLAUDE_PROJECT_DIR", + "KLAATAI_PROJECT_ROOT" + ]) { + const value = process.env[name]; + if (value && value.trim()) return value; + } } //#endregion //#region src/hooks/session-start.ts diff --git a/plugin/scripts/subagent-start.mjs b/plugin/scripts/subagent-start.mjs index 722f0c7f0..82eb74d7f 100755 --- a/plugin/scripts/subagent-start.mjs +++ b/plugin/scripts/subagent-start.mjs @@ -27,8 +27,15 @@ function hookCwd(data) { if (Array.isArray(roots)) { for (const root of roots) if (typeof root === "string" && root.trim()) return root; } - const projectDir = process.env["DEVIN_PROJECT_DIR"] || process.env["CLAUDE_PROJECT_DIR"]; - if (projectDir && projectDir.trim()) return projectDir; + if (typeof data.project_root === "string" && data.project_root.trim()) return data.project_root; + for (const name of [ + "DEVIN_PROJECT_DIR", + "CLAUDE_PROJECT_DIR", + "KLAATAI_PROJECT_ROOT" + ]) { + const value = process.env[name]; + if (value && value.trim()) return value; + } } //#endregion //#region src/hooks/subagent-start.ts diff --git a/plugin/scripts/subagent-stop.mjs b/plugin/scripts/subagent-stop.mjs index 8927c1af6..2e84ecfcd 100755 --- a/plugin/scripts/subagent-stop.mjs +++ b/plugin/scripts/subagent-stop.mjs @@ -27,8 +27,15 @@ function hookCwd(data) { if (Array.isArray(roots)) { for (const root of roots) if (typeof root === "string" && root.trim()) return root; } - const projectDir = process.env["DEVIN_PROJECT_DIR"] || process.env["CLAUDE_PROJECT_DIR"]; - if (projectDir && projectDir.trim()) return projectDir; + if (typeof data.project_root === "string" && data.project_root.trim()) return data.project_root; + for (const name of [ + "DEVIN_PROJECT_DIR", + "CLAUDE_PROJECT_DIR", + "KLAATAI_PROJECT_ROOT" + ]) { + const value = process.env[name]; + if (value && value.trim()) return value; + } } //#endregion //#region src/hooks/subagent-stop.ts diff --git a/plugin/scripts/task-completed.mjs b/plugin/scripts/task-completed.mjs index 613a9cd9b..b0a9a03bd 100755 --- a/plugin/scripts/task-completed.mjs +++ b/plugin/scripts/task-completed.mjs @@ -27,8 +27,15 @@ function hookCwd(data) { if (Array.isArray(roots)) { for (const root of roots) if (typeof root === "string" && root.trim()) return root; } - const projectDir = process.env["DEVIN_PROJECT_DIR"] || process.env["CLAUDE_PROJECT_DIR"]; - if (projectDir && projectDir.trim()) return projectDir; + if (typeof data.project_root === "string" && data.project_root.trim()) return data.project_root; + for (const name of [ + "DEVIN_PROJECT_DIR", + "CLAUDE_PROJECT_DIR", + "KLAATAI_PROJECT_ROOT" + ]) { + const value = process.env[name]; + if (value && value.trim()) return value; + } } //#endregion //#region src/hooks/task-completed.ts diff --git a/plugin/skills/agentmemory-agents/REFERENCE.md b/plugin/skills/agentmemory-agents/REFERENCE.md index 84d637d8e..32d165d66 100644 --- a/plugin/skills/agentmemory-agents/REFERENCE.md +++ b/plugin/skills/agentmemory-agents/REFERENCE.md @@ -3,7 +3,7 @@ Generated from `src/cli/connect/index.ts`. Do not edit the block below by hand; run `npm run skills:gen` after adding or removing an adapter. -`agentmemory connect ` wires the memory server into a host agent. 21 adapters: +`agentmemory connect ` wires the memory server into a host agent. 22 adapters: | Agent | Name | Protocol | | --- | --- | --- | @@ -21,6 +21,7 @@ Generated from `src/cli/connect/index.ts`. Do not edit the block below by hand; | Gemini CLI | `gemini-cli` | Using MCP (the only protocol Gemini CLI speaks). Memory bridge runs at :3111 underneath. | | Hermes Agent | `hermes` | Using MCP. Hooks are also available, see https://github.com/rohitg00/agentmemory/tree/main/integrations/hermes. | | Kiro | `kiro` | Using MCP via ~/.kiro/settings/mcp.json (user-level). Workspace overrides live in .kiro/settings/mcp.json. | +| Klaat Code | `klaatcode` | Using MCP via ~/.klaatai/mcp.json (key: servers). Pass --with-hooks for native auto-capture. | | OpenClaw | `openclaw` | Using MCP. Hooks are also available, see https://github.com/rohitg00/agentmemory/tree/main/integrations/openclaw. | | OpenCode | `opencode` | Using MCP via ~/.config/opencode/opencode.json (top-level `mcp` key). For full auto-capture, also install the bundled plugin in plugin/opencode/. | | OpenHuman | `openhuman` | Using native hooks (REST API at :3111). MCP not required. | diff --git a/src/cli/connect/guidelines.ts b/src/cli/connect/guidelines.ts index 26770b6a7..a9614f457 100644 --- a/src/cli/connect/guidelines.ts +++ b/src/cli/connect/guidelines.ts @@ -84,6 +84,16 @@ export function guidelineTargets( scope: "global", source: "https://kiro.dev/docs/steering", }, + // Klaat Code has capture hooks but discards their stdout, so unlike Claude + // Code and Codex it has no path for injecting recalled context. Without a + // guideline nothing ever prompts the model to call memory_recall. + klaatcode: { + globalPath: join(home, ".klaatai", "rules.md"), + projectPath: join(".klaatai", "rules.md"), + format: "block", + scope: "global", + source: "https://github.com/KlaatAI/klaatcode#project-rules", + }, "gemini-cli": { globalPath: join(home, ".gemini", "GEMINI.md"), projectPath: "GEMINI.md", diff --git a/src/cli/connect/index.ts b/src/cli/connect/index.ts index 981b7d65f..cb0be153c 100644 --- a/src/cli/connect/index.ts +++ b/src/cli/connect/index.ts @@ -17,6 +17,7 @@ import { adapter as dsh } from "./dsh.js"; import { adapter as geminiCli } from "./gemini-cli.js"; import { adapter as hermes } from "./hermes.js"; import { adapter as kiro } from "./kiro.js"; +import { adapter as klaatcode } from "./klaatcode.js"; import { adapter as openclaw } from "./openclaw.js"; import { adapter as opencode } from "./opencode.js"; import { adapter as openhuman } from "./openhuman.js"; @@ -36,6 +37,7 @@ export const ADAPTERS: readonly ConnectAdapter[] = [ antigravity, antigravityCli, kiro, + klaatcode, warp, cline, continueDev, diff --git a/src/cli/connect/klaatcode-hooks.ts b/src/cli/connect/klaatcode-hooks.ts new file mode 100644 index 000000000..3252c7b1a --- /dev/null +++ b/src/cli/connect/klaatcode-hooks.ts @@ -0,0 +1,98 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Separate from `codex-hooks.ts` because Klaat Code's hooks file is flat and + * snake_case rather than the nested Claude-Code shape `buildMergedHooks` + * handles. Loader: KlaatAI/klaatcode `src/screens/repl.ts`. + * + * Klaat Code never injects `${CLAUDE_PLUGIN_ROOT}` — the token is the shared + * placeholder in every bundled manifest, resolved here so the written file + * needs no env expansion. + */ + +export type KlaatcodeHookEvent = + | "before_tool" + | "after_tool" + | "before_message" + | "after_message" + | "session_start" + | "session_end"; + +export type KlaatcodeHookEntry = + | string + | { command: string; matcher?: string; timeout?: number }; + +export type KlaatcodeHooksConfig = Partial< + Record +>; + +function entryCommand(entry: KlaatcodeHookEntry): string { + return typeof entry === "string" ? entry : entry.command; +} + +function normalizePathForCommandMatch(value: string): string { + return value.replace(/\\/g, "/"); +} + +function isAgentmemoryEntry( + entry: KlaatcodeHookEntry, + scriptsDir: string, +): boolean { + return normalizePathForCommandMatch(entryCommand(entry)).includes( + normalizePathForCommandMatch(scriptsDir), + ); +} + +function resolveEntry( + entry: KlaatcodeHookEntry, + pluginRoot: string, +): KlaatcodeHookEntry { + const command = entryCommand(entry).replace( + /\$\{CLAUDE_PLUGIN_ROOT\}/g, + pluginRoot, + ); + if (typeof entry === "string") return command; + const next: { command: string; matcher?: string; timeout?: number } = { + command, + }; + if (entry.matcher !== undefined) next.matcher = entry.matcher; + if (entry.timeout !== undefined) next.timeout = entry.timeout; + return next; +} + +export function buildMergedKlaatcodeHooks( + existing: KlaatcodeHooksConfig | null, + pluginRoot: string, + manifestFile = "hooks.klaatcode.json", +): KlaatcodeHooksConfig { + const ours = JSON.parse( + readFileSync(join(pluginRoot, "hooks", manifestFile), "utf-8"), + ) as KlaatcodeHooksConfig; + const scriptsDir = join(pluginRoot, "scripts"); + + const out: KlaatcodeHooksConfig = {}; + + if (existing) { + for (const [event, entries] of Object.entries(existing) as [ + KlaatcodeHookEvent, + KlaatcodeHookEntry[], + ][]) { + if (!Array.isArray(entries)) continue; + const kept = entries.filter( + (entry) => !isAgentmemoryEntry(entry, scriptsDir), + ); + if (kept.length > 0) out[event] = kept; + } + } + + for (const [event, entries] of Object.entries(ours) as [ + KlaatcodeHookEvent, + KlaatcodeHookEntry[], + ][]) { + const resolved = entries.map((entry) => resolveEntry(entry, pluginRoot)); + out[event] = [...(out[event] ?? []), ...resolved]; + } + + return out; +} diff --git a/src/cli/connect/klaatcode.ts b/src/cli/connect/klaatcode.ts new file mode 100644 index 000000000..62f184e4d --- /dev/null +++ b/src/cli/connect/klaatcode.ts @@ -0,0 +1,88 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import * as p from "@clack/prompts"; +import { createJsonMcpAdapter } from "./json-mcp-adapter.js"; +import type { ConnectOptions, ConnectResult } from "./types.js"; +import { findPluginRoot } from "./codex-hooks.js"; +import { + buildMergedKlaatcodeHooks, + type KlaatcodeHooksConfig, +} from "./klaatcode-hooks.js"; +import { + backupFile, + logBackup, + logInstalled, + readJsonSafe, + writeJsonAtomic, +} from "./util.js"; + +function klaatDir(): string { + const override = process.env["KLAATAI_DIR"]; + if (override && override.trim()) return override; + return join(homedir(), ".klaatai"); +} + +const KLAAT_DIR = klaatDir(); +const KLAAT_MCP = join(KLAAT_DIR, "mcp.json"); +const KLAAT_HOOKS = join(KLAAT_DIR, "hooks.json"); + +export const adapter = createJsonMcpAdapter({ + name: "klaatcode", + displayName: "Klaat Code", + detectDir: KLAAT_DIR, + configPath: KLAAT_MCP, + wrapperKey: "servers", + category: "native", + docs: "https://github.com/rohitg00/agentmemory#other-agents", + protocolNote: + "→ Using MCP via ~/.klaatai/mcp.json (key: servers). Pass --with-hooks for native auto-capture.", + installHooks: installKlaatcodeHooks, +}); + +function installKlaatcodeHooks(opts: ConnectOptions): ConnectResult { + let pluginRoot: string; + try { + pluginRoot = findPluginRoot(); + } catch (err) { + return { + kind: "skipped", + reason: err instanceof Error ? err.message : String(err), + }; + } + + const existing = readJsonSafe(KLAAT_HOOKS); + const merged = buildMergedKlaatcodeHooks( + existing, + pluginRoot, + "hooks.klaatcode.json", + ); + + if (opts.dryRun) { + p.log.info( + `[dry-run] Would write ${Object.keys(merged).length} hook event(s) into ${KLAAT_HOOKS}`, + ); + return { kind: "installed", mutatedPath: KLAAT_HOOKS }; + } + + let backupPath: string | undefined; + if (existsSync(KLAAT_HOOKS)) { + backupPath = backupFile(KLAAT_HOOKS, "klaatcode-hooks", "json"); + logBackup(backupPath); + } else { + mkdirSync(KLAAT_DIR, { recursive: true }); + } + + writeJsonAtomic(KLAAT_HOOKS, merged); + + logInstalled("Klaat Code hooks", KLAAT_HOOKS); + p.log.info( + "Verify with `/hooks` inside klaatcode. Hooks fire in the interactive TUI only — headless (`klaatai run`) and ACP sessions are not captured. Re-run `agentmemory connect klaatcode --with-hooks` after upgrading agentmemory so the plugin paths stay current.", + ); + + return { + kind: "installed", + mutatedPath: KLAAT_HOOKS, + ...(backupPath !== undefined && { backupPath }), + }; +} diff --git a/src/cli/onboarding.ts b/src/cli/onboarding.ts index 926cdbea9..6d8b8a39f 100644 --- a/src/cli/onboarding.ts +++ b/src/cli/onboarding.ts @@ -45,6 +45,7 @@ const AGENT_GLYPH: Record = { codex: "◎", cursor: "◫", "gemini-cli": "✦", + klaatcode: "◭", opencode: "⬡", }; diff --git a/src/hooks/_project.ts b/src/hooks/_project.ts index 9f0320c5c..3a030454e 100644 --- a/src/hooks/_project.ts +++ b/src/hooks/_project.ts @@ -28,8 +28,16 @@ export function hookCwd(data: Record | null | undefined): strin if (typeof root === "string" && root.trim()) return root; } } - const projectDir = - process.env["DEVIN_PROJECT_DIR"] || process.env["CLAUDE_PROJECT_DIR"]; - if (projectDir && projectDir.trim()) return projectDir; + if (typeof data.project_root === "string" && data.project_root.trim()) { + return data.project_root; + } + for (const name of [ + "DEVIN_PROJECT_DIR", + "CLAUDE_PROJECT_DIR", + "KLAATAI_PROJECT_ROOT", + ]) { + const value = process.env[name]; + if (value && value.trim()) return value; + } return undefined; } diff --git a/test/cli-connect.test.ts b/test/cli-connect.test.ts index ce1cc3440..4c741634b 100644 --- a/test/cli-connect.test.ts +++ b/test/cli-connect.test.ts @@ -57,6 +57,7 @@ describe("agentmemory connect — dispatcher", () => { "gemini-cli", "hermes", "kiro", + "klaatcode", "opencode", "openclaw", "openhuman", @@ -66,7 +67,7 @@ describe("agentmemory connect — dispatcher", () => { "zed", ].sort(), ); - expect(ADAPTERS.length).toBe(21); + expect(ADAPTERS.length).toBe(22); }); it("every adapter exposes detect() and install()", () => { diff --git a/test/connect-guidelines.test.ts b/test/connect-guidelines.test.ts index 633aedc5e..8023d1586 100644 --- a/test/connect-guidelines.test.ts +++ b/test/connect-guidelines.test.ts @@ -29,6 +29,17 @@ describe("writeGuideline", () => { expect(body).toContain("memory_save"); }); + it("writes a marked block into Klaat Code's global rules.md", () => { + const r = writeGuideline("klaatcode", { cwd, home }); + expect(r.kind).toBe("written"); + if (r.kind === "written") expect(r.scope).toBe("global"); + const path = join(home, ".klaatai", "rules.md"); + expect(existsSync(path)).toBe(true); + const body = readFileSync(path, "utf8"); + expect(body).toContain("memory_recall"); + expect(body).toContain("memory_save"); + }); + it("writes a Kiro steering file with inclusion: always (global)", () => { const r = writeGuideline("kiro", { cwd, home }); expect(r.kind).toBe("written"); @@ -141,6 +152,7 @@ describe("guidelineTargets coverage", () => { "droid", "gemini-cli", "kiro", + "klaatcode", "opencode", "qwen", "warp", diff --git a/test/klaatcode-connect-hooks.test.ts b/test/klaatcode-connect-hooks.test.ts new file mode 100644 index 000000000..195046a1f --- /dev/null +++ b/test/klaatcode-connect-hooks.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect } from "vitest"; +import { resolve } from "node:path"; +import { spawn } from "node:child_process"; +import { findPluginRoot } from "../src/cli/connect/codex-hooks.js"; +import { + buildMergedKlaatcodeHooks, + type KlaatcodeHooksConfig, +} from "../src/cli/connect/klaatcode-hooks.js"; + +const PLUGIN_ROOT = resolve(__dirname, "..", "plugin"); + +const KLAATCODE_EVENTS = [ + "session_start", + "before_message", + "before_tool", + "after_tool", + "after_message", + "session_end", +]; + +const KLAATCODE_TOOL_NAMES = [ + "run_command", + "edit_file", + "multi_edit", + "write_file", + "read_file", + "apply_patch", + "grep", + "glob", +]; + +function entryCommand(entry: string | { command: string }): string { + return typeof entry === "string" ? entry : entry.command; +} + +function runHook( + script: string, + payload: Record, + env: Record = {}, +): Promise<{ out: string; code: number | null }> { + return new Promise((resolve_) => { + const child = spawn("node", [`plugin/scripts/${script}.mjs`], { + env: { ...process.env, AGENTMEMORY_URL: "http://127.0.0.1:1", ...env }, + }); + let out = ""; + child.stdout.on("data", (c) => (out += c)); + child.on("exit", (code) => resolve_({ out, code })); + child.stdin.write(JSON.stringify(payload)); + child.stdin.end(); + }); +} + +describe("buildMergedKlaatcodeHooks (Klaat Code manifest)", () => { + it("rewrites ${CLAUDE_PLUGIN_ROOT} to absolute pluginRoot in every command", () => { + const merged = buildMergedKlaatcodeHooks(null, findPluginRoot()); + for (const entries of Object.values(merged)) { + for (const entry of entries!) { + const cmd = entryCommand(entry); + expect(cmd).not.toContain("${CLAUDE_PLUGIN_ROOT}"); + expect(cmd).toContain(`${PLUGIN_ROOT}/scripts/`); + } + } + }); + + it("wires exactly the six events Klaat Code dispatches", () => { + const merged = buildMergedKlaatcodeHooks(null, findPluginRoot()); + expect(Object.keys(merged).sort()).toEqual([...KLAATCODE_EVENTS].sort()); + }); + + it("matches Klaat Code's snake_case tool names, not Claude Code's", () => { + const merged = buildMergedKlaatcodeHooks(null, findPluginRoot()); + const entry = merged["before_tool"]?.[0]; + expect(typeof entry).toBe("object"); + const matcher = (entry as { matcher?: string }).matcher; + expect(matcher).toBeDefined(); + const re = new RegExp(matcher!); + for (const tool of KLAATCODE_TOOL_NAMES) expect(re.test(tool)).toBe(true); + for (const claudeTool of ["Edit", "Write", "Bash"]) { + expect(re.test(claudeTool)).toBe(false); + } + }); + + it("preserves user-authored hooks in both v1 and v2 form", () => { + const existing: KlaatcodeHooksConfig = { + after_message: ["afplay /System/Library/Sounds/Glass.aiff"], + before_tool: [{ command: "./scripts/guard-shell.sh", matcher: "run_command" }], + }; + const merged = buildMergedKlaatcodeHooks(existing, findPluginRoot()); + expect(merged["after_message"]?.[0]).toBe( + "afplay /System/Library/Sounds/Glass.aiff", + ); + expect(entryCommand(merged["before_tool"]![0]!)).toBe( + "./scripts/guard-shell.sh", + ); + }); + + it("is idempotent across re-installs", () => { + const root = findPluginRoot(); + const once = buildMergedKlaatcodeHooks(null, root); + const twice = buildMergedKlaatcodeHooks(once, root); + expect(twice).toEqual(once); + }); +}); + +describe("hook payload compatibility with Klaat Code", () => { + it("resolves the project from the payload's project_root field", async () => { + const { resolveProject, hookCwd } = await import("../src/hooks/_project.js"); + const payload = { session_id: "s1", project_root: "/tmp" }; + expect(hookCwd(payload)).toBe("/tmp"); + expect(resolveProject(hookCwd(payload))).toBe("tmp"); + }); + + it("falls back to KLAATAI_PROJECT_ROOT when the payload carries no path", async () => { + const { hookCwd } = await import("../src/hooks/_project.js"); + const before = process.env["KLAATAI_PROJECT_ROOT"]; + process.env["KLAATAI_PROJECT_ROOT"] = "/tmp"; + try { + expect(hookCwd({ session_id: "s1" })).toBe("/tmp"); + } finally { + if (before === undefined) delete process.env["KLAATAI_PROJECT_ROOT"]; + else process.env["KLAATAI_PROJECT_ROOT"] = before; + } + }); + + it("skips a blank earlier env var instead of letting it shadow the fallback", async () => { + const { hookCwd } = await import("../src/hooks/_project.js"); + const before = { + devin: process.env["DEVIN_PROJECT_DIR"], + klaat: process.env["KLAATAI_PROJECT_ROOT"], + }; + process.env["DEVIN_PROJECT_DIR"] = " "; + process.env["KLAATAI_PROJECT_ROOT"] = "/tmp"; + try { + expect(hookCwd({ session_id: "s1" })).toBe("/tmp"); + } finally { + for (const [key, value] of [ + ["DEVIN_PROJECT_DIR", before.devin], + ["KLAATAI_PROJECT_ROOT", before.klaat], + ] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); + + it("fails open when the memory server is unreachable", async () => { + const { code } = await runHook("session-start", { + event: "session_start", + session_id: "s1", + project_root: "/tmp", + }); + expect(code).toBe(0); + }); +}); diff --git a/website/components/Agents.tsx b/website/components/Agents.tsx index d8f4fa3ca..2cf05e6be 100644 --- a/website/components/Agents.tsx +++ b/website/components/Agents.tsx @@ -233,6 +233,14 @@ const MARQUEE: Agent[] = [ accent: "#317CFF", href: "https://devin.ai", }, + { + id: "klaatcode", + name: "Klaat Code", + from: "KlaatAI", + logo: "/klaatcode.png", + accent: "#8B5CF6", + href: "https://github.com/KlaatAI/klaatcode", + }, ]; function FeaturedCard({ a }: { a: Agent }) { diff --git a/website/public/klaatcode.png b/website/public/klaatcode.png new file mode 100644 index 000000000..5412271c2 Binary files /dev/null and b/website/public/klaatcode.png differ