feat(cli): native hooks adapter for Antigravity CLI (agy) (#1146)

* feat(cli): native hooks adapter for Antigravity CLI (agy)

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: { <Event>: [...] } }` 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 <bertho_joris@yahoo.co.id>

* fix(cli): keep $-bearing plugin paths literal when resolving hook commands

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 `$<name>` 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 <bertho_joris@yahoo.co.id>

* 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.

* fix(antigravity): match agy's real hooks.json schema, verified against 1.0.15

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.

* 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.

---------

Signed-off-by: Bertho Joris <bertho_joris@yahoo.co.id>
This commit is contained in:
Bertho Joris
2026-08-04 00:35:41 +07:00
committed by GitHub
parent 5023cf3ccb
commit d60652a705
14 changed files with 1020 additions and 3 deletions
+1
View File
@@ -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. |
+43
View File
@@ -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
}
]
}
}
+114
View File
@@ -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
@@ -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.
<!-- AUTOGEN:agents START - generated by scripts/skills/generate.ts, do not edit by hand -->
`agentmemory connect <agent>` wires the memory server into a host agent. 18 adapters:
`agentmemory connect <agent>` 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. |
@@ -5,7 +5,7 @@ Generated from `src/triggers/api.ts`. Do not edit the block below by hand; run `
<!-- AUTOGEN:rest START - generated by scripts/skills/generate.ts, do not edit by hand -->
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 |
| --- | --- |
+97
View File
@@ -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
// <repo>/.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<AntigravityHookManifest>(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 }),
};
}
+163
View File
@@ -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: { <Event>: [...] } }` envelope
* that `codex-hooks.ts` handles for Claude Code, Codex and Droid. Its file
* is a map of *named* hook bundles at the root:
*
* {
* "<hook-name>": {
* "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
* `<pluginRoot>/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 "<root>/…"`
* 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<string, NamedHook>;
/** 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, "/");
}
+8
View File
@@ -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"),
+2
View File
@@ -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,
+210
View File
@@ -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 <PreInvocation|PreToolUse|PostToolUse|Stop>
// 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<string, string> = {
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<string, string> = {
AbsolutePath: "file_path",
TargetFile: "file_path",
DirectoryPath: "path",
SearchDirectory: "path",
Pattern: "pattern",
Query: "pattern",
CommandLine: "command",
};
type Json = Record<string, unknown>;
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);
});
}
+375
View File
@@ -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<string, unknown>)[event] as {
matcher?: string;
hooks: Handler[];
}[];
}
function handlers(bundle: unknown, event: string) {
return (bundle as Record<string, unknown>)[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<string, unknown>)) {
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 "<root>/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<string, unknown>)["file_path"]).toBe(
"/repo/src/index.ts",
);
// Original PascalCase args survive for anything downstream that wants them.
expect((out["tool_input"] as Record<string, unknown>)["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<string, unknown>)["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<string, unknown>;
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<string, unknown>;
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<string, unknown>)["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([]);
});
});
+2 -1
View File
@@ -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()", () => {
+1
View File
@@ -133,6 +133,7 @@ describe("guidelineTargets coverage", () => {
expect(names).toEqual(
[
"antigravity",
"antigravity-cli",
"cline",
"continue",
"copilot-cli",
+1
View File
@@ -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 = {