diff --git a/README.md b/README.md index 332e2010c..eb6bd1fa6 100644 --- a/README.md +++ b/README.md @@ -672,6 +672,7 @@ The agentmemory entry is the **same MCP server block** across every host that us | **Hermes Agent** | `~/.hermes/config.yaml` | Use the deeper [memory provider plugin](integrations/hermes/) with `memory.provider: agentmemory`. | | **Qwen Code** | `~/.qwen/settings.json` | `agentmemory connect qwen` writes the standard `mcpServers` block. Hook payload is field-compatible with Claude Code, so the existing 12-hook scripts work without modification — wire them via the `hooks` section in the same `settings.json`. | | **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. | | **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. | diff --git a/plugin/hooks/hooks.antigravity.json b/plugin/hooks/hooks.antigravity.json new file mode 100644 index 000000000..e3152b7a8 --- /dev/null +++ b/plugin/hooks/hooks.antigravity.json @@ -0,0 +1,43 @@ +{ + "agentmemory": { + "enabled": true, + "PreInvocation": [ + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/antigravity-bridge.mjs PreInvocation", + "timeout": 10 + } + ], + "PreToolUse": [ + { + "matcher": "view_file|view_code_item|read_file|edit_file|replace_file_content|write_to_file|create_file|grep_search|codebase_search|find_by_name|list_dir", + "hooks": [ + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/antigravity-bridge.mjs PreToolUse", + "timeout": 10 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/antigravity-bridge.mjs PostToolUse", + "timeout": 10 + } + ] + } + ], + "Stop": [ + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/antigravity-bridge.mjs Stop", + "timeout": 10 + } + ] + } +} diff --git a/plugin/scripts/antigravity-bridge.mjs b/plugin/scripts/antigravity-bridge.mjs new file mode 100644 index 000000000..12d72ba90 --- /dev/null +++ b/plugin/scripts/antigravity-bridge.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +//#region src/hooks/antigravity-bridge.ts +const SCRIPTS_DIR = dirname(fileURLToPath(import.meta.url)); +const TOOL_NAME_MAP = { + view_file: "read", + view_line_range: "read", + view_code_item: "read", + read_file: "read", + read_url_content: "read", + edit_file: "edit", + replace_file_content: "edit", + propose_code: "edit", + write_to_file: "write", + create_file: "write", + grep_search: "grep", + codebase_search: "grep", + find_by_name: "glob", + list_dir: "glob" +}; +const ARG_KEY_MAP = { + AbsolutePath: "file_path", + TargetFile: "file_path", + DirectoryPath: "path", + SearchDirectory: "path", + Pattern: "pattern", + Query: "pattern", + CommandLine: "command" +}; +function asObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0; +} +function firstString(...values) { + for (const v of values) if (typeof v === "string" && v.length > 0) return v; +} +function normalizeToolArgs(args) { + if (!args) return {}; + const out = { ...args }; + for (const [from, to] of Object.entries(ARG_KEY_MAP)) if (out[to] === void 0 && args[from] !== void 0) out[to] = args[from]; + return out; +} +function normalizePayload(event, raw) { + const toolCall = asObject(raw["toolCall"]); + const workspacePaths = Array.isArray(raw["workspacePaths"]) ? raw["workspacePaths"] : []; + const sessionId = firstString(raw["conversationId"], raw["session_id"], raw["sessionId"]) ?? "unknown"; + const cwd = firstString(raw["cwd"], workspacePaths[0]) ?? process.cwd(); + const out = { + ...raw, + session_id: sessionId, + cwd, + hook_event_name: event + }; + const transcriptPath = firstString(raw["transcript_path"], raw["transcriptPath"]); + if (transcriptPath) out["transcript_path"] = transcriptPath; + if (toolCall) { + const args = normalizeToolArgs(asObject(toolCall["args"]) ?? asObject(toolCall["toolArgs"])); + const rawName = firstString(toolCall["name"], toolCall["toolName"], args["ToolName"], args["toolName"]); + if (rawName) { + out["tool_name"] = TOOL_NAME_MAP[rawName] ?? rawName; + out["native_tool_name"] = rawName; + } + out["tool_input"] = args; + const result = toolCall["result"] ?? raw["toolResult"] ?? raw["result"]; + if (result !== void 0) out["tool_result"] = result; + } + return out; +} +function targetsFor(event, raw) { + switch (event) { + case "PreInvocation": { + const n = raw["invocationNum"]; + return typeof n !== "number" || n <= 1 ? ["session-start.mjs", "prompt-submit.mjs"] : ["prompt-submit.mjs"]; + } + case "PreToolUse": return ["pre-tool-use.mjs"]; + case "PostToolUse": return ["post-tool-use.mjs"]; + case "Stop": return ["stop.mjs", "session-end.mjs"]; + default: return []; + } +} +function responseFor(event) { + return event === "PreToolUse" ? "{\"decision\":\"allow\"}" : "{}"; +} +async function main() { + const event = process.argv[2]; + if (!event) return; + let input = ""; + for await (const chunk of process.stdin) input += chunk; + let raw; + try { + raw = JSON.parse(input); + } catch { + return; + } + if (!raw || typeof raw !== "object") return; + const payload = JSON.stringify(normalizePayload(event, raw)); + for (const script of targetsFor(event, raw)) spawnSync(process.execPath, [join(SCRIPTS_DIR, script)], { + input: payload, + stdio: [ + "pipe", + "ignore", + "ignore" + ] + }); +} +if (process.argv[1] !== void 0 && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(() => {}).finally(() => { + process.stdout.write(responseFor(process.argv[2] ?? "")); + process.exit(0); +}); +//#endregion +export { normalizePayload, responseFor, targetsFor }; + +//# sourceMappingURL=antigravity-bridge.mjs.map \ No newline at end of file diff --git a/plugin/skills/agentmemory-agents/REFERENCE.md b/plugin/skills/agentmemory-agents/REFERENCE.md index a0ec69ca1..8943cfa4b 100644 --- a/plugin/skills/agentmemory-agents/REFERENCE.md +++ b/plugin/skills/agentmemory-agents/REFERENCE.md @@ -3,11 +3,12 @@ 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. 18 adapters: +`agentmemory connect ` wires the memory server into a host agent. 19 adapters: | Agent | Name | Protocol | | --- | --- | --- | | Antigravity | `antigravity` | Using MCP via mcp_config.json. Antigravity replaces Gemini CLI (sunset 2026-06-18). | +| Antigravity CLI (agy) | `antigravity-cli` | Using MCP via ~/.gemini/config/mcp_config.json (the agy CLI, not the Antigravity IDE, that one is `connect antigravity`). The `/mcp` slash command inside agy lists configured servers. Pass --with-hooks to also install the native ~/.gemini/config/hooks.json auto-capture hooks. | | Claude Code | `claude-code` | Using MCP. Hooks are also available, see https://github.com/rohitg00/agentmemory#claude-code-one-block-paste-it. | | Cline | `cline` | Using MCP via ~/.cline/mcp.json (CLI). VS Code users: add the same block via Cline Settings → MCP Servers → Edit JSON. | | Codex CLI | `codex` | Using MCP. Hooks ship via the Codex plugin; on Codex Desktop, also pass --with-hooks to install the global hooks.json workaround for openai/codex#16430. | diff --git a/plugin/skills/agentmemory-rest-api/REFERENCE.md b/plugin/skills/agentmemory-rest-api/REFERENCE.md index d12ff4610..b92e35a9e 100644 --- a/plugin/skills/agentmemory-rest-api/REFERENCE.md +++ b/plugin/skills/agentmemory-rest-api/REFERENCE.md @@ -5,7 +5,7 @@ Generated from `src/triggers/api.ts`. Do not edit the block below by hand; run ` The REST API is the primary surface. All paths are under `http://localhost:3111` (override with `--port`). When `AGENTMEMORY_SECRET` is set, send `Authorization: Bearer $AGENTMEMORY_SECRET`; localhost is otherwise open. -118 registered endpoints: +119 registered endpoints: | Method | Path | | --- | --- | diff --git a/src/cli/connect/antigravity-cli.ts b/src/cli/connect/antigravity-cli.ts new file mode 100644 index 000000000..af64870fd --- /dev/null +++ b/src/cli/connect/antigravity-cli.ts @@ -0,0 +1,97 @@ +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 { + buildMergedAntigravityHooks, + containsSpaces, + type AntigravityHookManifest, +} from "./antigravity-hooks.js"; +import { findPluginRoot } from "./codex-hooks.js"; +import { + backupFile, + logBackup, + logInstalled, + readJsonSafe, + writeJsonAtomic, +} from "./util.js"; + +// The `agy` CLI shares no configuration with the Antigravity IDE that +// `antigravity.ts` wires — it reads MCP from ~/.gemini/config/mcp_config.json +// and hooks from ~/.gemini/config/hooks.json (per-workspace overrides in +// /.agents/hooks.json). Detection keys off ~/.gemini/antigravity-cli/, +// which only the CLI creates; ~/.gemini/ alone would also match Gemini CLI. +// Sources: antigravity.google/docs/hooks, antigravity.google/docs/cli/using +const GEMINI_DIR = join(homedir(), ".gemini"); +const ANTIGRAVITY_CLI_DIR = join(GEMINI_DIR, "antigravity-cli"); +const CUSTOMIZATION_DIR = join(GEMINI_DIR, "config"); +const ANTIGRAVITY_CLI_HOOKS = join(CUSTOMIZATION_DIR, "hooks.json"); + +export const adapter = createJsonMcpAdapter({ + name: "antigravity-cli", + displayName: "Antigravity CLI (agy)", + detectDir: ANTIGRAVITY_CLI_DIR, + configPath: join(CUSTOMIZATION_DIR, "mcp_config.json"), + docs: "https://github.com/rohitg00/agentmemory#other-agents", + protocolNote: + "→ Using MCP via ~/.gemini/config/mcp_config.json (the agy CLI, not the Antigravity IDE — that one is `connect antigravity`). The `/mcp` slash command inside agy lists configured servers. Pass --with-hooks to also install the native ~/.gemini/config/hooks.json auto-capture hooks.", + installHooks: installAntigravityCliHooks, +}); + +/** + * Merge the bundled `plugin/hooks/hooks.antigravity.json` into + * `~/.gemini/config/hooks.json`, replacing only the bundle agentmemory owns. + */ +function installAntigravityCliHooks(opts: ConnectOptions): ConnectResult { + let pluginRoot: string; + try { + pluginRoot = findPluginRoot(); + } catch (err) { + return { + kind: "skipped", + reason: err instanceof Error ? err.message : String(err), + }; + } + + // agy honours no quoting, so a space in the path yields hooks that load + // but never run. Refuse rather than install something that can only fail. + if (containsSpaces(pluginRoot)) { + return { + kind: "skipped", + reason: `Antigravity CLI cannot run hook commands whose path contains spaces, and agentmemory is installed at ${pluginRoot}. Reinstall it under a space-free path to use --with-hooks; MCP works either way.`, + }; + } + + const existing = readJsonSafe(ANTIGRAVITY_CLI_HOOKS); + const merged = buildMergedAntigravityHooks(existing, pluginRoot); + + if (opts.dryRun) { + p.log.info( + `[dry-run] Would ${existing ? "merge" : "create"} ${ANTIGRAVITY_CLI_HOOKS} with ${Object.keys(merged).length} hook bundle(s)`, + ); + return { kind: "installed", mutatedPath: ANTIGRAVITY_CLI_HOOKS }; + } + + let backupPath: string | undefined; + if (existsSync(ANTIGRAVITY_CLI_HOOKS)) { + backupPath = backupFile(ANTIGRAVITY_CLI_HOOKS, "antigravity-cli-hooks", "json"); + logBackup(backupPath); + } else { + mkdirSync(CUSTOMIZATION_DIR, { recursive: true }); + } + + writeJsonAtomic(ANTIGRAVITY_CLI_HOOKS, merged); + + logInstalled("Antigravity CLI hooks", ANTIGRAVITY_CLI_HOOKS); + p.log.info( + "User-scope hooks reference absolute paths under the bundled plugin/ dir. Re-run `agentmemory connect antigravity-cli --with-hooks` after upgrading agentmemory to refresh them.", + ); + + return { + kind: "installed", + mutatedPath: ANTIGRAVITY_CLI_HOOKS, + ...(backupPath !== undefined && { backupPath }), + }; +} diff --git a/src/cli/connect/antigravity-hooks.ts b/src/cli/connect/antigravity-hooks.ts new file mode 100644 index 000000000..b3b11297d --- /dev/null +++ b/src/cli/connect/antigravity-hooks.ts @@ -0,0 +1,163 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Merge engine for Antigravity CLI's `hooks.json`. + * + * Antigravity does not use the `{ hooks: { : [...] } }` envelope + * that `codex-hooks.ts` handles for Claude Code, Codex and Droid. Its file + * is a map of *named* hook bundles at the root: + * + * { + * "": { + * "enabled": true, + * "PreToolUse": [ { "matcher": "…", "hooks": [ { type, command, timeout } ] } ], + * "Stop": [ { type, command, timeout } ] + * } + * } + * + * The two shapes are not a typo: tool events take the `{ matcher, hooks }` + * wrapper, lifecycle events a flat handler list. Wrapping a lifecycle event + * makes agy read the wrapper as a handler and reject the *whole file* + * (`command hook must specify 'command'`), disabling every other bundle in + * it too. + * + * Named bundles make the merge simpler than the Codex one: agentmemory owns + * exactly the top-level keys whose commands point under + * `/scripts/`, so a re-install drops those wholesale and re-adds + * a fresh bundle, preserving user keys and their order. + * + * `${CLAUDE_PLUGIN_ROOT}` is resolved at install time — agy expands no env + * vars and requires absolute paths. The resolved path must also be bare and + * space-free: agy runs no shell and strips no quotes, so `node "/…"` + * looks for a module whose name starts with a quote, and a space truncates + * the argument with no escaping form that works. + * + * Behaviour above verified against agy 1.0.15. + * Source: antigravity.google/docs/hooks + */ + +type HookHandler = { type: string; command: string; timeout?: number }; +type HookEntry = { matcher?: string; hooks: HookHandler[] }; +export type NamedHook = { enabled?: boolean } & Record< + string, + boolean | HookEntry[] | HookHandler[] | undefined +>; +export type AntigravityHookManifest = Record; + +/** Tool events: `[ { matcher, hooks: [...] } ]`. */ +const TOOL_EVENT_KEYS = new Set(["PreToolUse", "PostToolUse"]); + +/** Lifecycle events: a flat `[ { type, command } ]` handler list. */ +const LIFECYCLE_EVENT_KEYS = new Set([ + "PreInvocation", + "PostInvocation", + "Stop", +]); + +/** Events Antigravity dispatches. Anything else in a bundle is metadata. */ +const EVENT_KEYS = new Set([...TOOL_EVENT_KEYS, ...LIFECYCLE_EVENT_KEYS]); + +export function buildMergedAntigravityHooks( + existing: AntigravityHookManifest | null, + pluginRoot: string, + manifestFile = "hooks.antigravity.json", +): AntigravityHookManifest { + const ours = JSON.parse( + readFileSync(join(pluginRoot, "hooks", manifestFile), "utf-8"), + ) as AntigravityHookManifest; + const scriptsDir = join(pluginRoot, "scripts"); + + const out: AntigravityHookManifest = {}; + + for (const [name, bundle] of Object.entries(existing ?? {})) { + if (isAgentmemoryBundle(bundle, scriptsDir)) continue; + out[name] = bundle; + } + + for (const [name, bundle] of Object.entries(ours)) { + out[name] = resolveBundle(bundle, pluginRoot); + } + + return out; +} + +/** True when `pluginRoot` cannot be expressed in an Antigravity `command`. */ +export function containsSpaces(pluginRoot: string): boolean { + return /\s/.test(pluginRoot); +} + +/** + * Every handler in a bundle, across both event shapes: an entry that carries + * no `hooks` array is itself the handler. + */ +function allHandlers(bundle: NamedHook): HookHandler[] { + const out: HookHandler[] = []; + for (const [key, value] of Object.entries(bundle)) { + if (!EVENT_KEYS.has(key) || !Array.isArray(value)) continue; + for (const entry of value as (HookEntry | HookHandler)[]) { + if (!entry || typeof entry !== "object") continue; + const nested = (entry as HookEntry).hooks; + if (Array.isArray(nested)) out.push(...nested); + else out.push(entry as HookHandler); + } + } + return out; +} + +function isAgentmemoryBundle(bundle: unknown, scriptsDir: string): boolean { + if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) { + return false; + } + const normalizedScriptsDir = normalizePathForCommandMatch(scriptsDir); + return allHandlers(bundle as NamedHook).some((handler) => + normalizePathForCommandMatch(handler?.command ?? "").includes( + normalizedScriptsDir, + ), + ); +} + +function resolveBundle(bundle: NamedHook, pluginRoot: string): NamedHook { + const out: NamedHook = {}; + for (const [key, value] of Object.entries(bundle)) { + if (!EVENT_KEYS.has(key) || !Array.isArray(value)) { + out[key] = value as boolean; + continue; + } + if (LIFECYCLE_EVENT_KEYS.has(key)) { + out[key] = (value as HookHandler[]).map((handler) => + resolveHandler(handler, pluginRoot), + ); + continue; + } + out[key] = (value as HookEntry[]).map((entry) => { + const next: HookEntry = { + hooks: entry.hooks.map((handler) => resolveHandler(handler, pluginRoot)), + }; + if (entry.matcher !== undefined) next.matcher = entry.matcher; + return next; + }); + } + return out; +} + +function resolveHandler( + handler: HookHandler, + pluginRoot: string, +): HookHandler { + return { + type: handler.type, + // Replacer function, not a string: a plugin path containing `$$`, `$&`, + // "$`" or `$'` would otherwise be read as a replacement pattern and + // silently mangle the installed command. + command: handler.command.replace( + /\$\{CLAUDE_PLUGIN_ROOT\}/g, + () => pluginRoot, + ), + ...(handler.timeout !== undefined && { timeout: handler.timeout }), + }; +} + +function normalizePathForCommandMatch(value: string): string { + return value.replace(/\\/g, "/"); +} diff --git a/src/cli/connect/guidelines.ts b/src/cli/connect/guidelines.ts index d1b95593a..26770b6a7 100644 --- a/src/cli/connect/guidelines.ts +++ b/src/cli/connect/guidelines.ts @@ -120,6 +120,14 @@ export function guidelineTargets( scope: "global", source: "https://antigravity.google/docs/rules-workflows", }, + // The agy CLI reads the same ~/.gemini/GEMINI.md as the IDE. + "antigravity-cli": { + globalPath: join(home, ".gemini", "GEMINI.md"), + projectPath: join(".agents", "rules", "agentmemory.md"), + format: "block", + scope: "global", + source: "https://antigravity.google/docs/rules-workflows", + }, "copilot-cli": { globalPath: join(home, ".copilot", "copilot-instructions.md"), projectPath: join(".github", "copilot-instructions.md"), diff --git a/src/cli/connect/index.ts b/src/cli/connect/index.ts index 0014e7d4d..a0256c7ad 100644 --- a/src/cli/connect/index.ts +++ b/src/cli/connect/index.ts @@ -4,6 +4,7 @@ import pc from "picocolors"; import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; import { writeGuideline } from "./guidelines.js"; import { adapter as antigravity } from "./antigravity.js"; +import { adapter as antigravityCli } from "./antigravity-cli.js"; import { adapter as claudeCode } from "./claude-code.js"; import { adapter as cline } from "./cline.js"; import { adapter as copilotCli } from "./copilot-cli.js"; @@ -30,6 +31,7 @@ export const ADAPTERS: readonly ConnectAdapter[] = [ geminiCli, qwen, antigravity, + antigravityCli, kiro, warp, cline, diff --git a/src/hooks/antigravity-bridge.ts b/src/hooks/antigravity-bridge.ts new file mode 100644 index 000000000..60e65bac7 --- /dev/null +++ b/src/hooks/antigravity-bridge.ts @@ -0,0 +1,210 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Antigravity CLI (`agy`) bridge — sits in front of the canonical hooks +// because three parts of agy's contract make them unusable as direct +// `command` targets: only five events exist (no SessionStart/SessionEnd/ +// UserPromptSubmit, so the lifecycle is synthesized from PreInvocation and +// Stop); the payload is camelCase and nests tool calls under `toolCall`; and +// stdout must be a JSON object, which `pre-tool-use.mjs` breaks when +// AGENTMEMORY_INJECT_CONTEXT=true makes it write raw context text. +// +// Invoked as: node antigravity-bridge.mjs +// Sources: antigravity.google/docs/hooks, antigravity.google/docs/cli/using + +const SCRIPTS_DIR = dirname(fileURLToPath(import.meta.url)); + +// Antigravity inherits Cascade-style tool names. Map them onto the tool +// vocabulary pre-tool-use.ts / post-tool-use.ts already understand so the +// existing file-activity heuristics keep working unchanged. +const TOOL_NAME_MAP: Record = { + view_file: "read", + view_line_range: "read", + view_code_item: "read", + read_file: "read", + read_url_content: "read", + edit_file: "edit", + replace_file_content: "edit", + propose_code: "edit", + write_to_file: "write", + create_file: "write", + grep_search: "grep", + codebase_search: "grep", + find_by_name: "glob", + list_dir: "glob", +}; + +// Antigravity tool args are PascalCase; the canonical hooks look for +// snake_case keys. Only the keys those hooks actually read are mapped. +const ARG_KEY_MAP: Record = { + AbsolutePath: "file_path", + TargetFile: "file_path", + DirectoryPath: "path", + SearchDirectory: "path", + Pattern: "pattern", + Query: "pattern", + CommandLine: "command", +}; + +type Json = Record; + +function asObject(value: unknown): Json | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Json) + : undefined; +} + +function firstString(...values: unknown[]): string | undefined { + for (const v of values) { + if (typeof v === "string" && v.length > 0) return v; + } + return undefined; +} + +function normalizeToolArgs(args: Json | undefined): Json { + if (!args) return {}; + const out: Json = { ...args }; + for (const [from, to] of Object.entries(ARG_KEY_MAP)) { + if (out[to] === undefined && args[from] !== undefined) out[to] = args[from]; + } + return out; +} + +// Translate one Antigravity hook payload into the flat, snake_case shape the +// bundled hooks consume. Unknown fields pass through so future Antigravity +// additions stay visible to the capture pipeline. +export function normalizePayload(event: string, raw: Json): Json { + const toolCall = asObject(raw["toolCall"]); + const workspacePaths = Array.isArray(raw["workspacePaths"]) + ? (raw["workspacePaths"] as unknown[]) + : []; + + const sessionId = + firstString(raw["conversationId"], raw["session_id"], raw["sessionId"]) ?? + "unknown"; + const cwd = + firstString(raw["cwd"], workspacePaths[0]) ?? process.cwd(); + + const out: Json = { + ...raw, + session_id: sessionId, + cwd, + hook_event_name: event, + }; + + const transcriptPath = firstString( + raw["transcript_path"], + raw["transcriptPath"], + ); + if (transcriptPath) out["transcript_path"] = transcriptPath; + + if (toolCall) { + const args = normalizeToolArgs( + asObject(toolCall["args"]) ?? asObject(toolCall["toolArgs"]), + ); + const rawName = firstString( + toolCall["name"], + toolCall["toolName"], + args["ToolName"], + args["toolName"], + ); + if (rawName) { + out["tool_name"] = TOOL_NAME_MAP[rawName] ?? rawName; + // Keep the host-native name so captured observations stay traceable + // back to the tool Antigravity actually ran. + out["native_tool_name"] = rawName; + } + out["tool_input"] = args; + const result = toolCall["result"] ?? raw["toolResult"] ?? raw["result"]; + if (result !== undefined) out["tool_result"] = result; + } + + return out; +} + +// Map an Antigravity event to the bundled scripts it should drive. +// PreInvocation stands in for both SessionStart and UserPromptSubmit: the +// first invocation of a conversation opens the session, every later one is a +// fresh user turn. PostInvocation is deliberately unmapped — PostToolUse +// already captures the work, and firing again would double-record it. +export function targetsFor(event: string, raw: Json): string[] { + switch (event) { + case "PreInvocation": { + const n = raw["invocationNum"]; + const isFirst = typeof n !== "number" || n <= 1; + return isFirst + ? ["session-start.mjs", "prompt-submit.mjs"] + : ["prompt-submit.mjs"]; + } + case "PreToolUse": + return ["pre-tool-use.mjs"]; + case "PostToolUse": + return ["post-tool-use.mjs"]; + case "Stop": + return ["stop.mjs", "session-end.mjs"]; + default: + return []; + } +} + +// The stdout contract, per event. agy treats a PreToolUse response without +// `decision` as a denial, so a bare `{}` there makes it refuse every matched +// tool call (verified on 1.0.15). No other event carries a permission +// decision, so they stay on `{}` — sending one would override user settings. +export function responseFor(event: string): string { + return event === "PreToolUse" ? '{"decision":"allow"}' : "{}"; +} + +async function main() { + const event = process.argv[2]; + if (!event) return; + + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + let raw: Json; + try { + raw = JSON.parse(input) as Json; + } catch { + return; + } + if (!raw || typeof raw !== "object") return; + + const payload = JSON.stringify(normalizePayload(event, raw)); + + for (const script of targetsFor(event, raw)) { + // Synchronous so the hook process does not exit before the capture + // POSTs are issued. Each bundled hook already caps its own fetch + // timeout, so the worst case here is bounded by those. + spawnSync(process.execPath, [join(SCRIPTS_DIR, script)], { + input: payload, + // Child stdout is discarded on purpose: Antigravity parses this + // process's stdout as the hook response, and the bundled scripts + // emit prose when context injection is enabled. + stdio: ["pipe", "ignore", "ignore"], + }); + } +} + +// Guarded so the pure helpers above stay importable from tests without the +// module blocking on stdin. Every other bundled hook is a leaf script and +// needs no such guard. +const invokedDirectly = + process.argv[1] !== undefined && + resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (invokedDirectly) { + main() + .catch(() => {}) + .finally(() => { + // Always a well-formed, non-blocking response, emitted even when the + // capture above threw or the payload was unparseable — a hook that + // writes nothing is as fatal to PreToolUse as one that writes `{}`. + process.stdout.write(responseFor(process.argv[2] ?? "")); + process.exit(0); + }); +} diff --git a/test/antigravity-connect-hooks.test.ts b/test/antigravity-connect-hooks.test.ts new file mode 100644 index 000000000..7b33f1e6d --- /dev/null +++ b/test/antigravity-connect-hooks.test.ts @@ -0,0 +1,375 @@ +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + buildMergedAntigravityHooks, + containsSpaces, + type AntigravityHookManifest, +} from "../src/cli/connect/antigravity-hooks.js"; +import { findPluginRoot } from "../src/cli/connect/codex-hooks.js"; +import { + normalizePayload, + responseFor, + targetsFor, +} from "../src/hooks/antigravity-bridge.js"; + +const PLUGIN_ROOT = resolve(__dirname, "..", "plugin"); + +function build(existing: AntigravityHookManifest | null = null) { + return buildMergedAntigravityHooks(existing, findPluginRoot()); +} + +type Handler = { type: string; command: string; timeout?: number }; + +function eventEntries(bundle: unknown, event: string) { + return (bundle as Record)[event] as { + matcher?: string; + hooks: Handler[]; + }[]; +} + +function handlers(bundle: unknown, event: string) { + return (bundle as Record)[event] as Handler[]; +} + +/** Every handler in a bundle, across both of agy's event shapes. */ +function allCommands(bundle: unknown): string[] { + const out: string[] = []; + for (const value of Object.values(bundle as Record)) { + if (!Array.isArray(value)) continue; + for (const entry of value) { + const nested = (entry as { hooks?: Handler[] }).hooks; + for (const h of nested ?? [entry as Handler]) out.push(h.command); + } + } + return out; +} + +describe("buildMergedAntigravityHooks", () => { + it("rewrites ${CLAUDE_PLUGIN_ROOT} to absolute pluginRoot in every command", () => { + for (const bundle of Object.values(build())) { + for (const command of allCommands(bundle)) { + expect(command).not.toContain("${CLAUDE_PLUGIN_ROOT}"); + expect(command).toContain(`${PLUGIN_ROOT}/scripts/`); + } + } + }); + + it("leaves the resolved script path unquoted, as agy's parser requires", () => { + // agy does not run `command` through a shell and does not strip quotes + // before splitting, so `node "/x.mjs"` makes node look for a module + // whose name literally begins with a double quote. Verified on 1.0.15. + for (const bundle of Object.values(build())) { + for (const command of allCommands(bundle)) { + expect(command).not.toContain('"'); + } + } + }); + + it("shapes tool events and lifecycle events the way agy parses them", () => { + // Only tool events take the { matcher, hooks } wrapper. Wrapping a + // lifecycle event makes agy reject the entire file, which silently + // disables every other bundle in it too. Verified on agy 1.0.15. + const bundle = build()["agentmemory"]!; + for (const event of ["PreToolUse", "PostToolUse"]) { + for (const entry of eventEntries(bundle, event)) { + expect(Array.isArray(entry.hooks), event).toBe(true); + expect(entry, event).not.toHaveProperty("command"); + } + } + for (const event of ["PreInvocation", "Stop"]) { + for (const handler of handlers(bundle, event)) { + expect(handler.type, event).toBe("command"); + expect(handler.command, event).toContain("antigravity-bridge.mjs"); + expect(handler, event).not.toHaveProperty("hooks"); + expect(handler, event).not.toHaveProperty("matcher"); + } + } + }); + + it("flags a plugin path that agy could never execute", () => { + // Quoted or not, a space truncates the argument — there is no escaping + // form that works, so the installer has to refuse instead of writing a + // bundle that loads but never fires. + expect(containsSpaces("C:/Program Files/agentmemory/plugin")).toBe(true); + expect(containsSpaces("/opt/agentmemory/plugin")).toBe(false); + }); + + it("registers under a single named bundle, as Antigravity's schema requires", () => { + expect(Object.keys(build())).toEqual(["agentmemory"]); + expect(build()["agentmemory"]!["enabled"]).toBe(true); + }); + + it("only wires events Antigravity actually dispatches", () => { + const bundle = build()["agentmemory"]!; + const events = Object.keys(bundle).filter((k) => k !== "enabled"); + // PostInvocation is intentionally unwired: PostToolUse already captures + // the work, so firing both would double-record every turn. + expect(events.sort()).toEqual( + ["PreInvocation", "PreToolUse", "PostToolUse", "Stop"].sort(), + ); + }); + + it("scopes PreToolUse to the file tools agy actually exposes", () => { + const matcher = eventEntries(build()["agentmemory"], "PreToolUse")[0]! + .matcher!; + for (const tool of ["view_file", "edit_file", "write_to_file", "grep_search"]) { + expect(matcher.split("|")).toContain(tool); + } + // run_command is deliberately excluded — shell invocations are captured + // on PostToolUse, and matching them here would fire on every command. + expect(matcher.split("|")).not.toContain("run_command"); + }); + + it("keeps user-authored hook bundles untouched", () => { + const existing: AntigravityHookManifest = { + "block-run-command": { + enabled: true, + PreToolUse: [ + { + matcher: "run_command", + hooks: [{ type: "command", command: "/usr/local/bin/deny.sh" }], + }, + ], + }, + }; + const merged = build(existing); + expect(merged["block-run-command"]).toEqual(existing["block-run-command"]); + expect(merged["agentmemory"]).toBeDefined(); + }); + + it("replaces a stale agentmemory bundle instead of duplicating it", () => { + const stale: AntigravityHookManifest = { + "agentmemory-legacy": { + enabled: true, + Stop: [ + { + hooks: [ + { + type: "command", + command: `node "${PLUGIN_ROOT}/scripts/removed-hook.mjs" Stop`, + }, + ], + }, + ], + }, + }; + const merged = build(stale); + expect(merged["agentmemory-legacy"]).toBeUndefined(); + expect(Object.keys(merged)).toEqual(["agentmemory"]); + }); + + it("recognises a stale bundle written in the flat lifecycle shape too", () => { + // Ownership detection has to see through both shapes, or a re-install + // leaves the old bundle behind and agy runs two copies of every hook. + const stale: AntigravityHookManifest = { + "agentmemory-legacy": { + enabled: true, + Stop: [ + { + type: "command", + command: `node ${PLUGIN_ROOT}/scripts/removed-hook.mjs Stop`, + }, + ], + }, + }; + expect(build(stale)["agentmemory-legacy"]).toBeUndefined(); + }); + + it("re-install is idempotent", () => { + const first = build(); + expect(build(first)).toEqual(first); + }); + + it("keeps a pluginRoot containing $-replacement patterns literal", () => { + // `String.prototype.replace` with a string argument reads `$$`, `$&`, + // "$`" and `$'` in the replacement as patterns. An install path holding + // any of them would otherwise be rewritten into a broken command, and + // the only symptom would be hooks that silently never fire. + const tmp = mkdtempSync(join(tmpdir(), "am-antigravity-")); + try { + const oddRoot = join(tmp, "plug$&$$in"); + mkdirSync(join(oddRoot, "hooks"), { recursive: true }); + copyFileSync( + join(PLUGIN_ROOT, "hooks", "hooks.antigravity.json"), + join(oddRoot, "hooks", "hooks.antigravity.json"), + ); + + const command = handlers( + buildMergedAntigravityHooks(null, oddRoot)["agentmemory"], + "Stop", + )[0]!.command; + + expect(command).toContain(`${oddRoot}/scripts/`); + expect(command).not.toContain("${CLAUDE_PLUGIN_ROOT}"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + +describe("antigravity bridge payload normalization", () => { + it("maps conversationId and workspacePaths onto the canonical fields", () => { + const out = normalizePayload("PostToolUse", { + conversationId: "conv_123", + workspacePaths: ["/repo/app"], + transcriptPath: "/tmp/t.jsonl", + }); + expect(out["session_id"]).toBe("conv_123"); + expect(out["cwd"]).toBe("/repo/app"); + expect(out["transcript_path"]).toBe("/tmp/t.jsonl"); + expect(out["hook_event_name"]).toBe("PostToolUse"); + }); + + it("flattens toolCall into tool_name/tool_input with Cascade names mapped", () => { + const out = normalizePayload("PreToolUse", { + conversationId: "c1", + toolCall: { + name: "view_file", + args: { AbsolutePath: "/repo/src/index.ts", StartLine: 1 }, + }, + }); + expect(out["tool_name"]).toBe("read"); + expect(out["native_tool_name"]).toBe("view_file"); + expect((out["tool_input"] as Record)["file_path"]).toBe( + "/repo/src/index.ts", + ); + // Original PascalCase args survive for anything downstream that wants them. + expect((out["tool_input"] as Record)["StartLine"]).toBe(1); + }); + + it("normalizes a payload captured verbatim from agy 1.0.15", () => { + // Recorded by pointing a probe hook at a live `agy --print` run. Note + // there is no `cwd` key at all, and `workspacePaths` came back empty in + // headless mode — the session id has to come from `conversationId`. + const out = normalizePayload("PreToolUse", { + artifactDirectoryPath: + "C:/Users/u/.gemini/antigravity-cli/brain/53642203-62f2-45e9-bda3-b1304c61bc99", + conversationId: "53642203-62f2-45e9-bda3-b1304c61bc99", + modelName: "gemini-3.6-flash-high", + stepIdx: 3, + toolCall: { + args: { DirectoryPath: "C:\\Users\\u\\.gemini\\antigravity-cli" }, + name: "list_dir", + }, + transcriptPath: + "C:/Users/u/.gemini/antigravity-cli/brain/53642203/.system_generated/logs/transcript_full.jsonl", + workspacePaths: [], + }); + + expect(out["session_id"]).toBe("53642203-62f2-45e9-bda3-b1304c61bc99"); + expect(out["tool_name"]).toBe("glob"); + expect(out["native_tool_name"]).toBe("list_dir"); + expect((out["tool_input"] as Record)["path"]).toBe( + "C:\\Users\\u\\.gemini\\antigravity-cli", + ); + expect(out["transcript_path"]).toContain("transcript_full.jsonl"); + // Fields agy sends that no bundled hook reads still survive the trip. + expect(out["modelName"]).toBe("gemini-3.6-flash-high"); + expect(out["stepIdx"]).toBe(3); + }); + + it("maps every PascalCase arg the canonical hooks read", () => { + const cases: [string, string, string][] = [ + ["AbsolutePath", "file_path", "/repo/a.ts"], + ["TargetFile", "file_path", "/repo/b.ts"], + ["DirectoryPath", "path", "/repo/src"], + ["SearchDirectory", "path", "/repo/test"], + ["Pattern", "pattern", "*.ts"], + ["Query", "pattern", "normalizePayload"], + ["CommandLine", "command", "npm test"], + ]; + for (const [from, to, value] of cases) { + const input = normalizePayload("PreToolUse", { + toolCall: { name: "view_file", args: { [from]: value } }, + })["tool_input"] as Record; + expect(input[to], `${from} -> ${to}`).toBe(value); + // The original key survives alongside the canonical one. + expect(input[from], from).toBe(value); + } + }); + + it("does not let a mapped alias clobber an explicit canonical key", () => { + const input = normalizePayload("PreToolUse", { + toolCall: { + name: "edit_file", + args: { TargetFile: "/repo/alias.ts", file_path: "/repo/explicit.ts" }, + }, + })["tool_input"] as Record; + expect(input["file_path"]).toBe("/repo/explicit.ts"); + }); + + it("passes unmapped tool names through unchanged", () => { + const out = normalizePayload("PostToolUse", { + toolCall: { name: "run_command", args: { CommandLine: "npm test" } }, + }); + expect(out["tool_name"]).toBe("run_command"); + expect((out["tool_input"] as Record)["command"]).toBe( + "npm test", + ); + }); + + it("falls back to a placeholder session id rather than dropping the event", () => { + expect(normalizePayload("Stop", {})["session_id"]).toBe("unknown"); + }); +}); + +describe("antigravity bridge stdout contract", () => { + // agy documents `decision` as required on PreToolUse output and treats a + // response without it as a denial — a bare `{}` there makes the agent + // refuse every matched tool call instead of passively capturing it. + it("answers PreToolUse with an explicit allow, everything else with {}", () => { + expect(JSON.parse(responseFor("PreToolUse"))).toEqual({ + decision: "allow", + }); + for (const event of ["PreInvocation", "PostToolUse", "Stop", ""]) { + expect(JSON.parse(responseFor(event)), event).toEqual({}); + } + }); + + it("writes that contract to stdout when the bundled script actually runs", () => { + const script = join(PLUGIN_ROOT, "scripts", "antigravity-bridge.mjs"); + const run = (event: string) => + execFileSync(process.execPath, [script, event], { + input: JSON.stringify({ + conversationId: "c1", + toolCall: { name: "view_file", args: { AbsolutePath: "/repo/a.ts" } }, + }), + encoding: "utf-8", + // No server is listening on port 1, so every capture fetch fails + // fast: this asserts the response survives a failed capture, which + // is exactly the case where a swallowed error could emit nothing. + env: { ...process.env, AGENTMEMORY_URL: "http://127.0.0.1:1" }, + stdio: ["pipe", "pipe", "ignore"], + }); + + expect(JSON.parse(run("PreToolUse"))).toEqual({ decision: "allow" }); + expect(JSON.parse(run("PostToolUse"))).toEqual({}); + }); +}); + +describe("antigravity bridge event routing", () => { + it("opens the session on the first invocation only", () => { + expect(targetsFor("PreInvocation", { invocationNum: 1 })).toEqual([ + "session-start.mjs", + "prompt-submit.mjs", + ]); + expect(targetsFor("PreInvocation", { invocationNum: 4 })).toEqual([ + "prompt-submit.mjs", + ]); + }); + + it("treats a missing invocationNum as the first invocation", () => { + expect(targetsFor("PreInvocation", {})).toContain("session-start.mjs"); + }); + + it("closes the session on Stop", () => { + expect(targetsFor("Stop", {})).toEqual(["stop.mjs", "session-end.mjs"]); + }); + + it("ignores PostInvocation to avoid double-capturing a turn", () => { + expect(targetsFor("PostInvocation", {})).toEqual([]); + }); +}); diff --git a/test/cli-connect.test.ts b/test/cli-connect.test.ts index 46a1f240b..ce43ac83d 100644 --- a/test/cli-connect.test.ts +++ b/test/cli-connect.test.ts @@ -44,6 +44,7 @@ describe("agentmemory connect — dispatcher", () => { expect(knownAgents().sort()).toEqual( [ "antigravity", + "antigravity-cli", "claude-code", "cline", "copilot-cli", @@ -63,7 +64,7 @@ describe("agentmemory connect — dispatcher", () => { "zed", ].sort(), ); - expect(ADAPTERS.length).toBe(18); + expect(ADAPTERS.length).toBe(19); }); it("every adapter exposes detect() and install()", () => { diff --git a/test/connect-guidelines.test.ts b/test/connect-guidelines.test.ts index 917272360..633aedc5e 100644 --- a/test/connect-guidelines.test.ts +++ b/test/connect-guidelines.test.ts @@ -133,6 +133,7 @@ describe("guidelineTargets coverage", () => { expect(names).toEqual( [ "antigravity", + "antigravity-cli", "cline", "continue", "copilot-cli", diff --git a/tsdown.config.ts b/tsdown.config.ts index 390a4df6c..0c6426c1b 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -14,6 +14,7 @@ const hookEntries = [ "src/hooks/stop.ts", "src/hooks/session-end.ts", "src/hooks/post-commit.ts", + "src/hooks/antigravity-bridge.ts", ]; const shared = {