-
Notifications
You must be signed in to change notification settings - Fork 2.3k
feat(cli): native hooks adapter for Antigravity CLI (agy) #1146
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
1b43e89
bb74810
278fb05
6e481cb
3a095ba
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
| ] | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| #!/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 []; | ||
| } | ||
| } | ||
| /** | ||
| * 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; | ||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| 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"; | ||
|
|
||
| // 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 <repo>/.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, | ||
| }); | ||
|
|
||
| /** | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. lot of similar comments
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Trimmed in What I deliberately kept is the agy behaviour I verified against a live 1.0.15 — the two event shapes, the no-quoting rule, the PreToolUse decision contract. That part isn't derivable from the code, and each of those was a real defect here, so a future reader changing the manifest needs the reason. Happy to cut further if you'd rather that lived only in the PR description. |
||
| * 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), | ||
| }; | ||
| } | ||
|
|
||
| // 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<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 }), | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove these comments
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in
3a095ba— the artifact is down to the three boilerplate lines every other bundled script has.The cause was that the bundler strips
//comments but preserves JSDoc blocks, so the/** */docs on the bridge's exported helpers were the only ones surviving intoplugin/scripts/. Switched those to line comments: the explanation stays insrc/hooks/antigravity-bridge.tsand the generated file now matchespre-tool-use.mjsand friends (3 lines, all of them#region/sourcemap markers).