From 1b43e89a7f9c476d873e05c4f2fba68a2bc7063a Mon Sep 17 00:00:00 2001 From: Bertho Joris Date: Mon, 3 Aug 2026 11:39:33 +0700 Subject: [PATCH 1/5] feat(cli): native hooks adapter for Antigravity CLI (agy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antigravity ships two products with unrelated configuration: the IDE, already wired by `connect antigravity`, and the `agy` CLI, which reads its customizations out of ~/.gemini/ and until now was not wired at all. This adds `connect antigravity-cli` for the latter — MCP via ~/.gemini/config/mcp_config.json, plus optional native auto-capture hooks behind --with-hooks. Unlike Droid (#1130), the Codex merge engine could not be reused. The Antigravity hooks contract differs in three ways: * hooks.json is a map of *named* hook bundles at the root, not the `{ hooks: { : [...] } }` envelope, so antigravity-hooks.ts implements a merge that owns top-level keys instead of per-event entries. User-authored bundles are preserved; a re-install replaces only the bundle whose commands point under the bundled plugin dir. * only five events exist (PreToolUse, PostToolUse, PreInvocation, PostInvocation, Stop) — no SessionStart/SessionEnd/UserPromptSubmit, so the session lifecycle is synthesized from the first PreInvocation and from Stop. PostInvocation is left unwired to avoid double-capture. * the stdin payload is camelCase and nested (`toolCall.args` with PascalCase keys, `conversationId`, `workspacePaths`), and stdout must be a JSON object — `pre-tool-use.mjs` writes raw prose when context injection is on. plugin/scripts/antigravity-bridge.mjs bridges all three: it normalizes the payload onto the shape the bundled hooks already accept, maps Cascade tool names (view_file, replace_file_content, …) onto the read/edit/write/grep vocabulary the capture heuristics use, pipes to the right script, discards child stdout and always answers `{}` so Antigravity's own permission decisions are never overridden. Event names, tool names and arg keys were verified against the shipped agy binary rather than docs alone (docs disagree on the global hooks path); the customization dir is ~/.gemini/config/, matching where agy already keeps mcp_config.json and plugins/. Signed-off-by: Bertho Joris --- README.md | 1 + plugin/hooks/hooks.antigravity.json | 51 ++++ plugin/scripts/antigravity-bridge.mjs | 124 ++++++++++ plugin/skills/agentmemory-agents/REFERENCE.md | 3 +- .../skills/agentmemory-rest-api/REFERENCE.md | 2 +- src/cli/connect/antigravity-cli.ts | 100 ++++++++ src/cli/connect/antigravity-hooks.ts | 123 ++++++++++ src/cli/connect/guidelines.ts | 8 + src/cli/connect/index.ts | 2 + src/hooks/antigravity-bridge.ts | 222 ++++++++++++++++++ test/antigravity-connect-hooks.test.ts | 179 ++++++++++++++ test/cli-connect.test.ts | 3 +- test/connect-guidelines.test.ts | 1 + tsdown.config.ts | 1 + 14 files changed, 817 insertions(+), 3 deletions(-) create mode 100644 plugin/hooks/hooks.antigravity.json create mode 100644 plugin/scripts/antigravity-bridge.mjs create mode 100644 src/cli/connect/antigravity-cli.ts create mode 100644 src/cli/connect/antigravity-hooks.ts create mode 100644 src/hooks/antigravity-bridge.ts create mode 100644 test/antigravity-connect-hooks.test.ts 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..8f37fad86 --- /dev/null +++ b/plugin/hooks/hooks.antigravity.json @@ -0,0 +1,51 @@ +{ + "agentmemory": { + "enabled": true, + "PreInvocation": [ + { + "hooks": [ + { + "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": [ + { + "hooks": [ + { + "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..47eee862d --- /dev/null +++ b/plugin/scripts/antigravity-bridge.mjs @@ -0,0 +1,124 @@ +#!/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; +} +/** +* Translate one Antigravity hook payload into the flat, snake_case shape +* the bundled hooks consume. Unknown fields are passed through so future +* Antigravity additions stay visible to the capture pipeline. +*/ +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; +} +/** +* 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. +*/ +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 []; + } +} +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("{}"); + process.exit(0); +}); +//#endregion +export { normalizePayload, 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..4a3a70083 --- /dev/null +++ b/src/cli/connect/antigravity-cli.ts @@ -0,0 +1,100 @@ +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, + type AntigravityHookManifest, +} from "./antigravity-hooks.js"; +import { findPluginRoot } from "./codex-hooks.js"; +import { + backupFile, + logBackup, + logInstalled, + readJsonSafe, + writeJsonAtomic, +} from "./util.js"; + +// Antigravity ships two products that do NOT share configuration: +// +// • the IDE, wired by `antigravity.ts` via the app-support directory +// (~/Library/Application Support/Antigravity/User/mcp_config.json); +// • the `agy` CLI, wired here, which reads its customizations out of +// ~/.gemini/ — MCP servers from ~/.gemini/config/mcp_config.json and +// hooks from ~/.gemini/config/hooks.json, with per-workspace overrides +// in /.agents/hooks.json. +// +// Detection keys off ~/.gemini/antigravity-cli/, which only the CLI +// creates — ~/.gemini/ alone would also match a Gemini CLI install. +// +// Unlike Claude Code, Codex and Droid, Antigravity's hooks.json is a map of +// *named* hook bundles and exposes only five events, so `--with-hooks` +// uses the dedicated merge engine in antigravity-hooks.ts and routes every +// event through the bridge in plugin/scripts/antigravity-bridge.mjs. +// 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`. Idempotent in the same way as the Codex + * and Droid installers: re-running replaces only the hook bundle + * agentmemory owns and leaves user-authored bundles alone. + */ +function installAntigravityCliHooks(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(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..5e68fb481 --- /dev/null +++ b/src/cli/connect/antigravity-hooks.ts @@ -0,0 +1,123 @@ +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 } ] } ] + * } + * } + * + * The naming is what makes the merge simpler than the Codex one: instead + * of filtering entries event by event, agentmemory owns exactly the + * top-level keys whose commands point under `/scripts/`, so a + * re-install drops those keys wholesale and re-adds a fresh bundle. Keys + * the user authored are copied through untouched, and key order is + * preserved so re-running `connect` produces a minimal diff. + * + * As with the Codex manifest, `${CLAUDE_PLUGIN_ROOT}` is an internal + * placeholder for the bundled `plugin/` dir — Antigravity does not expand + * env vars in `command`, and its docs require absolute paths, so the token + * is resolved at install time. + * + * 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[] | undefined +>; +export type AntigravityHookManifest = Record; + +/** Events Antigravity dispatches. Anything else in a bundle is metadata. */ +const EVENT_KEYS = new Set([ + "PreToolUse", + "PostToolUse", + "PreInvocation", + "PostInvocation", + "Stop", +]); + +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; +} + +function eventEntries(bundle: NamedHook): HookEntry[][] { + return Object.entries(bundle) + .filter(([key, value]) => EVENT_KEYS.has(key) && Array.isArray(value)) + .map(([, value]) => value as HookEntry[]); +} + +function isAgentmemoryBundle(bundle: unknown, scriptsDir: string): boolean { + if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) { + return false; + } + const normalizedScriptsDir = normalizePathForCommandMatch(scriptsDir); + return eventEntries(bundle as NamedHook).some((entries) => + entries.some((entry) => + (entry?.hooks ?? []).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; + } + out[key] = (value as HookEntry[]).map((entry) => { + const next: HookEntry = { + hooks: entry.hooks.map((handler) => ({ + type: handler.type, + command: handler.command.replace( + /\$\{CLAUDE_PLUGIN_ROOT\}/g, + pluginRoot, + ), + ...(handler.timeout !== undefined && { timeout: handler.timeout }), + })), + }; + if (entry.matcher !== undefined) next.matcher = entry.matcher; + return next; + }); + } + return out; +} + +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..6cd4e8af6 --- /dev/null +++ b/src/hooks/antigravity-bridge.ts @@ -0,0 +1,222 @@ +#!/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. +// +// Antigravity ships a first-party hooks system, but its contract differs +// from the Claude Code / Codex / Droid family in three ways that make the +// bundled hook scripts unusable as direct `command` targets: +// +// 1. Only five events exist — PreToolUse, PostToolUse, PreInvocation, +// PostInvocation, Stop. There is no SessionStart, SessionEnd or +// UserPromptSubmit, so the session lifecycle has to be synthesized +// from PreInvocation (first invocation) and Stop. +// 2. The stdin payload is camelCase and nested: tool calls arrive as +// `toolCall.name` + `toolCall.args` (args themselves PascalCase, e.g. +// `AbsolutePath`, `TargetFile`, `Query`), and the session key is +// `conversationId`, not `session_id`. +// 3. stdout must be a JSON object. `pre-tool-use.mjs` writes raw context +// text when AGENTMEMORY_INJECT_CONTEXT=true, which Antigravity would +// fail to parse as a PreToolUse decision. +// +// So this bridge sits in front of the canonical hooks: it normalizes the +// payload into the shape they already accept, pipes it to the right +// script(s), discards their stdout, and always emits `{}` — a no-op +// response that leaves Antigravity's own permission decisions untouched. +// Auto-capture is the goal; the bridge never blocks or rewrites a tool call. +// +// 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 are passed 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 []; + } +} + +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 — never `decision` or + // `terminationBehavior`, which would override the user's own settings. + process.stdout.write("{}"); + 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..7f556bb91 --- /dev/null +++ b/test/antigravity-connect-hooks.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from "vitest"; +import { resolve } from "node:path"; +import { + buildMergedAntigravityHooks, + type AntigravityHookManifest, +} from "../src/cli/connect/antigravity-hooks.js"; +import { findPluginRoot } from "../src/cli/connect/codex-hooks.js"; +import { + normalizePayload, + targetsFor, +} from "../src/hooks/antigravity-bridge.js"; + +const PLUGIN_ROOT = resolve(__dirname, "..", "plugin"); + +function build(existing: AntigravityHookManifest | null = null) { + return buildMergedAntigravityHooks(existing, findPluginRoot()); +} + +function eventEntries(bundle: unknown, event: string) { + return (bundle as Record)[event] as { + matcher?: string; + hooks: { type: string; command: string; timeout?: number }[]; + }[]; +} + +describe("buildMergedAntigravityHooks", () => { + it("rewrites ${CLAUDE_PLUGIN_ROOT} to absolute pluginRoot in every command", () => { + const merged = build(); + for (const bundle of Object.values(merged)) { + for (const [key, value] of Object.entries(bundle)) { + if (!Array.isArray(value)) continue; + for (const entry of value) { + for (const handler of entry.hooks) { + expect(handler.command, key).not.toContain("${CLAUDE_PLUGIN_ROOT}"); + expect(handler.command, key).toContain(`${PLUGIN_ROOT}/scripts/`); + } + } + } + } + }); + + 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("re-install is idempotent", () => { + const first = build(); + expect(build(first)).toEqual(first); + }); +}); + +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("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 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 = { From bb7481099c119cfbb729bde617a188bc92fb18ff Mon Sep 17 00:00:00 2001 From: Bertho Joris Date: Mon, 3 Aug 2026 14:40:21 +0700 Subject: [PATCH 2/5] fix(cli): keep $-bearing plugin paths literal when resolving hook commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveBundle() expanded ${CLAUDE_PLUGIN_ROOT} via String.prototype.replace with a string argument, so a plugin root containing `$$`, `$&`, "$`" or `$'` was read as a replacement pattern and rewritten: C:/plug$&in -> C:/plug${CLAUDE_PLUGIN_ROOT}in/scripts/... C:/plug$$in -> C:/plug$in/scripts/... `$1` and `$` are unaffected — the regex has no capture groups. Switching to a replacer function keeps the path verbatim. The failure mode this closes is silent: the hook installs with a broken command and auto-capture simply never fires. Regression test builds the manifest against a temp plugin root named `plug$&$$in` and asserts the resolved command contains it literally. Reported by CodeRabbit on #1146. Signed-off-by: Bertho Joris --- src/cli/connect/antigravity-hooks.ts | 5 ++++- test/antigravity-connect-hooks.test.ts | 30 +++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/cli/connect/antigravity-hooks.ts b/src/cli/connect/antigravity-hooks.ts index 5e68fb481..87f072f60 100644 --- a/src/cli/connect/antigravity-hooks.ts +++ b/src/cli/connect/antigravity-hooks.ts @@ -104,9 +104,12 @@ function resolveBundle(bundle: NamedHook, pluginRoot: string): NamedHook { const next: HookEntry = { hooks: entry.hooks.map((handler) => ({ 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, + () => pluginRoot, ), ...(handler.timeout !== undefined && { timeout: handler.timeout }), })), diff --git a/test/antigravity-connect-hooks.test.ts b/test/antigravity-connect-hooks.test.ts index 7f556bb91..9c92b8f47 100644 --- a/test/antigravity-connect-hooks.test.ts +++ b/test/antigravity-connect-hooks.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from "vitest"; -import { resolve } from "node:path"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; import { buildMergedAntigravityHooks, type AntigravityHookManifest, @@ -107,6 +109,32 @@ describe("buildMergedAntigravityHooks", () => { 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 = eventEntries( + buildMergedAntigravityHooks(null, oddRoot)["agentmemory"], + "Stop", + )[0]!.hooks[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", () => { From 278fb0541c806ac9014c318fda838dc76d7106d0 Mon Sep 17 00:00:00 2001 From: Bertho Joris Date: Mon, 3 Aug 2026 16:45:52 +0700 Subject: [PATCH 3/5] fix(antigravity): emit an explicit allow decision from the PreToolUse hook Antigravity documents `decision` as a required field of PreToolUse hook output, and agy treats a response that omits it as a denial: the bare `{}` the bridge used to write made the agent refuse every matched tool call (reported against agy 1.0.5 in cmux#5358) instead of passively capturing it. `responseFor` now answers PreToolUse with `{"decision":"allow"}` and leaves every other event on `{}`, so no event that carries no permission decision starts overriding the user's own settings. The response is written from the `finally` block, so a failed capture or an unparseable payload still produces the contract rather than empty stdout, which PreToolUse would read the same way as `{}`. Tests cover both the pure contract and the built bundled script running end to end with no server listening. Also extends the ARG_KEY_MAP test to every mapped key and pins that an explicit canonical key wins over a PascalCase alias. --- plugin/scripts/antigravity-bridge.mjs | 18 ++++++- src/hooks/antigravity-bridge.ts | 29 ++++++++--- test/antigravity-connect-hooks.test.ts | 66 ++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/plugin/scripts/antigravity-bridge.mjs b/plugin/scripts/antigravity-bridge.mjs index 47eee862d..8b75a00ca 100644 --- a/plugin/scripts/antigravity-bridge.mjs +++ b/plugin/scripts/antigravity-bridge.mjs @@ -92,6 +92,20 @@ function targetsFor(event, raw) { default: return []; } } +/** +* The stdout contract, per event. +* +* Antigravity documents `decision` as a *required* field of PreToolUse hook +* output, and agy treats a response that omits it as a denial: a bare `{}` +* on PreToolUse makes the agent refuse every matched tool call (reported +* against agy 1.0.5 in cmux#5358). A passive capture hook must therefore say +* `allow` explicitly. No other event carries a permission decision, so they +* stay on `{}` — emitting `decision` or `terminationBehavior` there would +* override the user's own settings. +*/ +function responseFor(event) { + return event === "PreToolUse" ? "{\"decision\":\"allow\"}" : "{}"; +} async function main() { const event = process.argv[2]; if (!event) return; @@ -115,10 +129,10 @@ async function main() { }); } if (process.argv[1] !== void 0 && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(() => {}).finally(() => { - process.stdout.write("{}"); + process.stdout.write(responseFor(process.argv[2] ?? "")); process.exit(0); }); //#endregion -export { normalizePayload, targetsFor }; +export { normalizePayload, responseFor, targetsFor }; //# sourceMappingURL=antigravity-bridge.mjs.map \ No newline at end of file diff --git a/src/hooks/antigravity-bridge.ts b/src/hooks/antigravity-bridge.ts index 6cd4e8af6..7ba1e903a 100644 --- a/src/hooks/antigravity-bridge.ts +++ b/src/hooks/antigravity-bridge.ts @@ -23,9 +23,10 @@ import { fileURLToPath } from "node:url"; // // So this bridge sits in front of the canonical hooks: it normalizes the // payload into the shape they already accept, pipes it to the right -// script(s), discards their stdout, and always emits `{}` — a no-op -// response that leaves Antigravity's own permission decisions untouched. -// Auto-capture is the goal; the bridge never blocks or rewrites a tool call. +// script(s), discards their stdout, and emits the minimal well-formed +// response for the event. Auto-capture is the goal; the bridge never blocks +// or rewrites a tool call — see `responseFor` for why PreToolUse is the one +// event that cannot answer with a bare `{}`. // // Invoked as: node antigravity-bridge.mjs // Sources: antigravity.google/docs/hooks, antigravity.google/docs/cli/using @@ -170,6 +171,21 @@ export function targetsFor(event: string, raw: Json): string[] { } } +/** + * The stdout contract, per event. + * + * Antigravity documents `decision` as a *required* field of PreToolUse hook + * output, and agy treats a response that omits it as a denial: a bare `{}` + * on PreToolUse makes the agent refuse every matched tool call (reported + * against agy 1.0.5 in cmux#5358). A passive capture hook must therefore say + * `allow` explicitly. No other event carries a permission decision, so they + * stay on `{}` — emitting `decision` or `terminationBehavior` there would + * override the user's own settings. + */ +export function responseFor(event: string): string { + return event === "PreToolUse" ? '{"decision":"allow"}' : "{}"; +} + async function main() { const event = process.argv[2]; if (!event) return; @@ -214,9 +230,10 @@ if (invokedDirectly) { main() .catch(() => {}) .finally(() => { - // Always a well-formed, non-blocking response — never `decision` or - // `terminationBehavior`, which would override the user's own settings. - process.stdout.write("{}"); + // 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 index 9c92b8f47..359329c0c 100644 --- a/test/antigravity-connect-hooks.test.ts +++ b/test/antigravity-connect-hooks.test.ts @@ -1,4 +1,5 @@ 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"; @@ -9,6 +10,7 @@ import { import { findPluginRoot } from "../src/cli/connect/codex-hooks.js"; import { normalizePayload, + responseFor, targetsFor, } from "../src/hooks/antigravity-bridge.js"; @@ -167,6 +169,36 @@ describe("antigravity bridge payload normalization", () => { expect((out["tool_input"] as Record)["StartLine"]).toBe(1); }); + 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" } }, @@ -182,6 +214,40 @@ describe("antigravity bridge payload normalization", () => { }); }); +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([ From 6e481cbcf822f1d02e97c2d4c50d71f24ee4a008 Mon Sep 17 00:00:00 2001 From: Bertho Joris Date: Mon, 3 Aug 2026 17:32:39 +0700 Subject: [PATCH 4/5] fix(antigravity): match agy's real hooks.json schema, verified against 1.0.15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects found by probing a live agy 1.0.15 with an instrumented hook, each of which stopped the adapter from capturing anything at all. Lifecycle events take a flat handler list, not the tool-event wrapper. agy parses `PreToolUse`/`PostToolUse` as `[{matcher, hooks: [...]}]` but `PreInvocation`/`PostInvocation`/`Stop` as a bare `[{type, command}]`, since there is no tool name to match on. Wrapping a lifecycle event makes agy read the wrapper itself as a handler and reject the *whole file* with `invalid hook "agentmemory": command hook must specify 'command'` — so the mis-shaped Stop entry disabled every hook in the bundle, and would have disabled hooks other tools had written to the same file. `command` is not run through a shell and quotes are not stripped, so the quoted path resolved to a module name that literally began with a double quote: `Cannot find module 'C:\Users\…\.gemini\config\"C:\…\bridge.mjs"'`. Commands are now bare. That also means a path containing spaces cannot be expressed at all — quoted and unquoted both fail — so the installer refuses with an explanation instead of writing hooks that can only fail at tool time. The merge engine reads both shapes when deciding which bundles agentmemory owns, so a re-install over the old wrapped layout still replaces it rather than leaving a second copy behind. Tests pin both event shapes, the absence of quotes, the space check, and normalization of a payload captured verbatim from the live run — which also confirms `conversationId`, PascalCase `toolCall.args`, and that agy sends no `cwd` key at all. --- plugin/hooks/hooks.antigravity.json | 24 ++--- src/cli/connect/antigravity-cli.ts | 11 +++ src/cli/connect/antigravity-hooks.ts | 112 ++++++++++++++++------ test/antigravity-connect-hooks.test.ts | 128 ++++++++++++++++++++++--- 4 files changed, 218 insertions(+), 57 deletions(-) diff --git a/plugin/hooks/hooks.antigravity.json b/plugin/hooks/hooks.antigravity.json index 8f37fad86..e3152b7a8 100644 --- a/plugin/hooks/hooks.antigravity.json +++ b/plugin/hooks/hooks.antigravity.json @@ -3,13 +3,9 @@ "enabled": true, "PreInvocation": [ { - "hooks": [ - { - "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/antigravity-bridge.mjs\" PreInvocation", - "timeout": 10 - } - ] + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/antigravity-bridge.mjs PreInvocation", + "timeout": 10 } ], "PreToolUse": [ @@ -18,7 +14,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/antigravity-bridge.mjs\" PreToolUse", + "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/antigravity-bridge.mjs PreToolUse", "timeout": 10 } ] @@ -30,7 +26,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/antigravity-bridge.mjs\" PostToolUse", + "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/antigravity-bridge.mjs PostToolUse", "timeout": 10 } ] @@ -38,13 +34,9 @@ ], "Stop": [ { - "hooks": [ - { - "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/antigravity-bridge.mjs\" Stop", - "timeout": 10 - } - ] + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/antigravity-bridge.mjs Stop", + "timeout": 10 } ] } diff --git a/src/cli/connect/antigravity-cli.ts b/src/cli/connect/antigravity-cli.ts index 4a3a70083..1a36b12b9 100644 --- a/src/cli/connect/antigravity-cli.ts +++ b/src/cli/connect/antigravity-cli.ts @@ -6,6 +6,7 @@ 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"; @@ -67,6 +68,16 @@ function installAntigravityCliHooks(opts: ConnectOptions): ConnectResult { }; } + // agy parses `command` itself and honours no quoting, so a plugin path + // with a space produces a bundle that loads but never runs. Refuse rather + // than install hooks that can only fail at tool time. + 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); diff --git a/src/cli/connect/antigravity-hooks.ts b/src/cli/connect/antigravity-hooks.ts index 87f072f60..58a1f8fd3 100644 --- a/src/cli/connect/antigravity-hooks.ts +++ b/src/cli/connect/antigravity-hooks.ts @@ -11,10 +11,20 @@ import { join } from "node:path"; * { * "": { * "enabled": true, - * "PreToolUse": [ { "matcher": "…", "hooks": [ { type, command, timeout } ] } ] + * "PreToolUse": [ { "matcher": "…", "hooks": [ { type, command, timeout } ] } ], + * "Stop": [ { type, command, timeout } ] * } * } * + * The two events above are not a typo. Only the tool events (`PreToolUse`, + * `PostToolUse`) take the `{ matcher, hooks }` wrapper; the lifecycle events + * (`PreInvocation`, `PostInvocation`, `Stop`) take a flat handler list, since + * there is no tool name to match on. Wrapping a lifecycle event makes agy + * read the wrapper itself as a handler and reject the *whole file* with + * `invalid hook "": command hook must specify 'command'` — so one + * mis-shaped event silently disables every other hook in the bundle, + * including hooks other tools installed. Verified on agy 1.0.15. + * * The naming is what makes the merge simpler than the Codex one: instead * of filtering entries event by event, agentmemory owns exactly the * top-level keys whose commands point under `/scripts/`, so a @@ -27,6 +37,17 @@ import { join } from "node:path"; * env vars in `command`, and its docs require absolute paths, so the token * is resolved at install time. * + * `command` is *not* run through a shell, and agy does not strip quotes + * before splitting it, so the resolved path must be bare: `node "/…"` + * makes node look for a module whose name literally starts with a double + * quote. Verified against agy 1.0.15, which fails such a hook with + * `Cannot find module 'C:\Users\…\.gemini\config\"C:\…\bridge.mjs"'` — note + * both the quotes and that the relative resolution base is the hooks.json + * directory, not the workspace. The flip side is that a plugin path + * containing spaces cannot be expressed at all: quoted and unquoted both + * fail, so `containsSpaces` lets the installer say so instead of writing a + * bundle that silently never fires. + * * Source: antigravity.google/docs/hooks */ @@ -34,19 +55,23 @@ type HookHandler = { type: string; command: string; timeout?: number }; type HookEntry = { matcher?: string; hooks: HookHandler[] }; export type NamedHook = { enabled?: boolean } & Record< string, - boolean | HookEntry[] | undefined + boolean | HookEntry[] | HookHandler[] | undefined >; export type AntigravityHookManifest = Record; -/** Events Antigravity dispatches. Anything else in a bundle is metadata. */ -const EVENT_KEYS = new Set([ - "PreToolUse", - "PostToolUse", +/** 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, @@ -71,10 +96,32 @@ export function buildMergedAntigravityHooks( return out; } -function eventEntries(bundle: NamedHook): HookEntry[][] { - return Object.entries(bundle) - .filter(([key, value]) => EVENT_KEYS.has(key) && Array.isArray(value)) - .map(([, value]) => value as HookEntry[]); +/** + * True when `pluginRoot` cannot be expressed in an Antigravity `command`. + * agy splits the string itself without honouring quotes, so a space in the + * path always truncates the argument — there is no escaping form that works. + */ +export function containsSpaces(pluginRoot: string): boolean { + return /\s/.test(pluginRoot); +} + +/** + * Every handler in a bundle, flattened across both event shapes. A tool + * event nests its handlers under `hooks`; a lifecycle event *is* the handler + * list, so 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 { @@ -82,13 +129,9 @@ function isAgentmemoryBundle(bundle: unknown, scriptsDir: string): boolean { return false; } const normalizedScriptsDir = normalizePathForCommandMatch(scriptsDir); - return eventEntries(bundle as NamedHook).some((entries) => - entries.some((entry) => - (entry?.hooks ?? []).some((handler) => - normalizePathForCommandMatch(handler?.command ?? "").includes( - normalizedScriptsDir, - ), - ), + return allHandlers(bundle as NamedHook).some((handler) => + normalizePathForCommandMatch(handler?.command ?? "").includes( + normalizedScriptsDir, ), ); } @@ -100,19 +143,15 @@ function resolveBundle(bundle: NamedHook, pluginRoot: string): NamedHook { 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) => ({ - 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 }), - })), + hooks: entry.hooks.map((handler) => resolveHandler(handler, pluginRoot)), }; if (entry.matcher !== undefined) next.matcher = entry.matcher; return next; @@ -121,6 +160,23 @@ function resolveBundle(bundle: NamedHook, pluginRoot: string): NamedHook { 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/test/antigravity-connect-hooks.test.ts b/test/antigravity-connect-hooks.test.ts index 359329c0c..7b33f1e6d 100644 --- a/test/antigravity-connect-hooks.test.ts +++ b/test/antigravity-connect-hooks.test.ts @@ -5,6 +5,7 @@ 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"; @@ -20,29 +21,82 @@ 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: { type: string; command: string; timeout?: number }[]; + 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", () => { - const merged = build(); - for (const bundle of Object.values(merged)) { - for (const [key, value] of Object.entries(bundle)) { - if (!Array.isArray(value)) continue; - for (const entry of value) { - for (const handler of entry.hooks) { - expect(handler.command, key).not.toContain("${CLAUDE_PLUGIN_ROOT}"); - expect(handler.command, key).toContain(`${PLUGIN_ROOT}/scripts/`); - } - } + 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); @@ -107,6 +161,23 @@ describe("buildMergedAntigravityHooks", () => { 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); @@ -126,10 +197,10 @@ describe("buildMergedAntigravityHooks", () => { join(oddRoot, "hooks", "hooks.antigravity.json"), ); - const command = eventEntries( + const command = handlers( buildMergedAntigravityHooks(null, oddRoot)["agentmemory"], "Stop", - )[0]!.hooks[0]!.command; + )[0]!.command; expect(command).toContain(`${oddRoot}/scripts/`); expect(command).not.toContain("${CLAUDE_PLUGIN_ROOT}"); @@ -169,6 +240,37 @@ describe("antigravity bridge payload normalization", () => { 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"], From 3a095bad6d1056d00c718d74af95e7911b1bb7f7 Mon Sep 17 00:00:00 2001 From: Bertho Joris Date: Mon, 3 Aug 2026 17:47:39 +0700 Subject: [PATCH 5/5] refactor(antigravity): cut comment volume to match the sibling adapters The bundled script carried 24 comment lines where every other script in plugin/scripts has three. The bundler strips `//` comments but preserves JSDoc blocks, so the fix is to document the bridge's exported helpers with line comments: the explanations stay in source and the generated artifact comes out as clean as its siblings. The connect adapter and merge engine restated the same facts in a file header and again in a per-function block. Kept one statement of each, dropped the repetition, and left the verified agy behaviour in place since that is the part not derivable from the code. --- plugin/scripts/antigravity-bridge.mjs | 24 ---------- src/cli/connect/antigravity-cli.ts | 30 ++++-------- src/cli/connect/antigravity-hooks.ts | 55 +++++++--------------- src/hooks/antigravity-bridge.ts | 67 ++++++++------------------- 4 files changed, 45 insertions(+), 131 deletions(-) diff --git a/plugin/scripts/antigravity-bridge.mjs b/plugin/scripts/antigravity-bridge.mjs index 8b75a00ca..12d72ba90 100644 --- a/plugin/scripts/antigravity-bridge.mjs +++ b/plugin/scripts/antigravity-bridge.mjs @@ -41,11 +41,6 @@ function normalizeToolArgs(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; } -/** -* Translate one Antigravity hook payload into the flat, snake_case shape -* the bundled hooks consume. Unknown fields are passed through so future -* Antigravity additions stay visible to the capture pipeline. -*/ function normalizePayload(event, raw) { const toolCall = asObject(raw["toolCall"]); const workspacePaths = Array.isArray(raw["workspacePaths"]) ? raw["workspacePaths"] : []; @@ -72,14 +67,6 @@ function normalizePayload(event, raw) { } 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. -*/ function targetsFor(event, raw) { switch (event) { case "PreInvocation": { @@ -92,17 +79,6 @@ function targetsFor(event, raw) { default: return []; } } -/** -* The stdout contract, per event. -* -* Antigravity documents `decision` as a *required* field of PreToolUse hook -* output, and agy treats a response that omits it as a denial: a bare `{}` -* on PreToolUse makes the agent refuse every matched tool call (reported -* against agy 1.0.5 in cmux#5358). A passive capture hook must therefore say -* `allow` explicitly. No other event carries a permission decision, so they -* stay on `{}` — emitting `decision` or `terminationBehavior` there would -* override the user's own settings. -*/ function responseFor(event) { return event === "PreToolUse" ? "{\"decision\":\"allow\"}" : "{}"; } diff --git a/src/cli/connect/antigravity-cli.ts b/src/cli/connect/antigravity-cli.ts index 1a36b12b9..af64870fd 100644 --- a/src/cli/connect/antigravity-cli.ts +++ b/src/cli/connect/antigravity-cli.ts @@ -18,22 +18,11 @@ import { writeJsonAtomic, } from "./util.js"; -// Antigravity ships two products that do NOT share configuration: -// -// • the IDE, wired by `antigravity.ts` via the app-support directory -// (~/Library/Application Support/Antigravity/User/mcp_config.json); -// • the `agy` CLI, wired here, which reads its customizations out of -// ~/.gemini/ — MCP servers from ~/.gemini/config/mcp_config.json and -// hooks from ~/.gemini/config/hooks.json, with per-workspace overrides -// in /.agents/hooks.json. -// -// Detection keys off ~/.gemini/antigravity-cli/, which only the CLI -// creates — ~/.gemini/ alone would also match a Gemini CLI install. -// -// Unlike Claude Code, Codex and Droid, Antigravity's hooks.json is a map of -// *named* hook bundles and exposes only five events, so `--with-hooks` -// uses the dedicated merge engine in antigravity-hooks.ts and routes every -// event through the bridge in plugin/scripts/antigravity-bridge.mjs. +// 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"); @@ -53,9 +42,7 @@ export const adapter = createJsonMcpAdapter({ /** * Merge the bundled `plugin/hooks/hooks.antigravity.json` into - * `~/.gemini/config/hooks.json`. Idempotent in the same way as the Codex - * and Droid installers: re-running replaces only the hook bundle - * agentmemory owns and leaves user-authored bundles alone. + * `~/.gemini/config/hooks.json`, replacing only the bundle agentmemory owns. */ function installAntigravityCliHooks(opts: ConnectOptions): ConnectResult { let pluginRoot: string; @@ -68,9 +55,8 @@ function installAntigravityCliHooks(opts: ConnectOptions): ConnectResult { }; } - // agy parses `command` itself and honours no quoting, so a plugin path - // with a space produces a bundle that loads but never runs. Refuse rather - // than install hooks that can only fail at tool time. + // 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", diff --git a/src/cli/connect/antigravity-hooks.ts b/src/cli/connect/antigravity-hooks.ts index 58a1f8fd3..b3b11297d 100644 --- a/src/cli/connect/antigravity-hooks.ts +++ b/src/cli/connect/antigravity-hooks.ts @@ -16,38 +16,24 @@ import { join } from "node:path"; * } * } * - * The two events above are not a typo. Only the tool events (`PreToolUse`, - * `PostToolUse`) take the `{ matcher, hooks }` wrapper; the lifecycle events - * (`PreInvocation`, `PostInvocation`, `Stop`) take a flat handler list, since - * there is no tool name to match on. Wrapping a lifecycle event makes agy - * read the wrapper itself as a handler and reject the *whole file* with - * `invalid hook "": command hook must specify 'command'` — so one - * mis-shaped event silently disables every other hook in the bundle, - * including hooks other tools installed. Verified on agy 1.0.15. + * 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. * - * The naming is what makes the merge simpler than the Codex one: instead - * of filtering entries event by event, agentmemory owns exactly the - * top-level keys whose commands point under `/scripts/`, so a - * re-install drops those keys wholesale and re-adds a fresh bundle. Keys - * the user authored are copied through untouched, and key order is - * preserved so re-running `connect` produces a minimal diff. + * 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. * - * As with the Codex manifest, `${CLAUDE_PLUGIN_ROOT}` is an internal - * placeholder for the bundled `plugin/` dir — Antigravity does not expand - * env vars in `command`, and its docs require absolute paths, so the token - * is resolved at install time. - * - * `command` is *not* run through a shell, and agy does not strip quotes - * before splitting it, so the resolved path must be bare: `node "/…"` - * makes node look for a module whose name literally starts with a double - * quote. Verified against agy 1.0.15, which fails such a hook with - * `Cannot find module 'C:\Users\…\.gemini\config\"C:\…\bridge.mjs"'` — note - * both the quotes and that the relative resolution base is the hooks.json - * directory, not the workspace. The flip side is that a plugin path - * containing spaces cannot be expressed at all: quoted and unquoted both - * fail, so `containsSpaces` lets the installer say so instead of writing a - * bundle that silently never fires. + * `${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 */ @@ -96,19 +82,14 @@ export function buildMergedAntigravityHooks( return out; } -/** - * True when `pluginRoot` cannot be expressed in an Antigravity `command`. - * agy splits the string itself without honouring quotes, so a space in the - * path always truncates the argument — there is no escaping form that works. - */ +/** 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, flattened across both event shapes. A tool - * event nests its handlers under `hooks`; a lifecycle event *is* the handler - * list, so an entry that carries no `hooks` array is itself the handler. + * 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[] = []; diff --git a/src/hooks/antigravity-bridge.ts b/src/hooks/antigravity-bridge.ts index 7ba1e903a..60e65bac7 100644 --- a/src/hooks/antigravity-bridge.ts +++ b/src/hooks/antigravity-bridge.ts @@ -3,30 +3,13 @@ import { spawnSync } from "node:child_process"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -// Antigravity CLI (`agy`) bridge. -// -// Antigravity ships a first-party hooks system, but its contract differs -// from the Claude Code / Codex / Droid family in three ways that make the -// bundled hook scripts unusable as direct `command` targets: -// -// 1. Only five events exist — PreToolUse, PostToolUse, PreInvocation, -// PostInvocation, Stop. There is no SessionStart, SessionEnd or -// UserPromptSubmit, so the session lifecycle has to be synthesized -// from PreInvocation (first invocation) and Stop. -// 2. The stdin payload is camelCase and nested: tool calls arrive as -// `toolCall.name` + `toolCall.args` (args themselves PascalCase, e.g. -// `AbsolutePath`, `TargetFile`, `Query`), and the session key is -// `conversationId`, not `session_id`. -// 3. stdout must be a JSON object. `pre-tool-use.mjs` writes raw context -// text when AGENTMEMORY_INJECT_CONTEXT=true, which Antigravity would -// fail to parse as a PreToolUse decision. -// -// So this bridge sits in front of the canonical hooks: it normalizes the -// payload into the shape they already accept, pipes it to the right -// script(s), discards their stdout, and emits the minimal well-formed -// response for the event. Auto-capture is the goal; the bridge never blocks -// or rewrites a tool call — see `responseFor` for why PreToolUse is the one -// event that cannot answer with a bare `{}`. +// 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 @@ -89,11 +72,9 @@ function normalizeToolArgs(args: Json | undefined): Json { return out; } -/** - * Translate one Antigravity hook payload into the flat, snake_case shape - * the bundled hooks consume. Unknown fields are passed through so future - * Antigravity additions stay visible to the capture pipeline. - */ +// 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"]) @@ -143,14 +124,11 @@ export function normalizePayload(event: string, raw: Json): Json { 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. - */ +// 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": { @@ -171,17 +149,10 @@ export function targetsFor(event: string, raw: Json): string[] { } } -/** - * The stdout contract, per event. - * - * Antigravity documents `decision` as a *required* field of PreToolUse hook - * output, and agy treats a response that omits it as a denial: a bare `{}` - * on PreToolUse makes the agent refuse every matched tool call (reported - * against agy 1.0.5 in cmux#5358). A passive capture hook must therefore say - * `allow` explicitly. No other event carries a permission decision, so they - * stay on `{}` — emitting `decision` or `terminationBehavior` there would - * override the user's own settings. - */ +// 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"}' : "{}"; }