Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ const cliCommands = [
{
key: "hookIngest",
path: ["hook", "ingest"],
usage: "hook ingest --platform codex|claude|copilot|cursor|opencode|openhands|factory-droid",
usage: "hook ingest --platform codex|claude|copilot|opencode|openhands|factory-droid|antigravity",
handler: runHookIngest,
},
{
Expand Down
117 changes: 103 additions & 14 deletions libs/install/platforms/antigravity.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { readJsonObject, writeJson } from "../hook-config.js";
import { copyBundledSkills } from "../skills.js";
import type { PlatformInstallContext, PlatformInstallResult, PlatformInstaller, WorkingMemoryUpdateInput } from "./types.js";

Expand All @@ -13,23 +15,68 @@ function antigravityHome(): string {
return process.env.ANTIGRAVITY_HOME ?? join(homedir(), ".gemini", "antigravity-cli");
}

// Confirmed from a Google Cloud Community deep-dive with tested working
// examples (not just prose docs): Antigravity CLI hooks live in a
// project-local .agents/hooks.json, namespaced per tool at the top level
// (so multiple tools -- cmux, claude-mem, greplica -- can share one file
// without clobbering each other), e.g.:
//
// { "greplica": { "Stop": [{ "hooks": [{ "type": "command", "command": "<abs path>", "timeout": 30 }] }] } }
//
// Two things this differs from the shared mergeHookConfig()/hooks.json shape
// every other installer uses:
// 1. No single top-level "hooks" key -- each tool's entries live under its
// own name at the root instead.
// 2. "command" must be an absolute path to a directly-executable script
// file, not a shell command string with arguments -- so we generate a
// small wrapper script here rather than writing an inline command.
//
// Scope of this first pass: only the Stop event. It's the one event that's
// consistent across every source found (official docs, real production
// integrations, live bug reports) with zero contradictions, and it's
// confirmed to have no veto power (unlike PreToolUse-style gating events,
// where a failing hook can block a tool call or, per one source, an entire
// session). SessionStart is deliberately left out: some real hooks.json
// dumps show it, but Google's own documented event list doesn't include it,
// and that contradiction isn't worth resolving for what Greplica needs --
// Stop alone is enough to trigger the background working-memory update.
const antigravityHookNamespace = "greplica";

export const antigravityInstaller: PlatformInstaller = {
platform: "antigravity",
// Antigravity CLI hooks are configured via a workspace-local
// .agents/hooks.json and, for tool-approval events like PreToolUse, drive
// decisions through a JSON stdin / JSON stdout allow-deny contract --
// materially different from the command + exit-code hooks.json shape the
// other installers share via mergeHookConfig(). Whether a plain
// fire-and-forget SessionStart/Stop hook (what Greplica actually needs)
// uses that same contract or something simpler isn't confirmed from
// documentation available at the time this was written. Rather than
// guess and risk writing a hooks.json that breaks a user's Antigravity
// setup, this installs skills only for now, the same approach already
// taken for OpenCode. Someone running Antigravity CLI day to day is best
// placed to confirm the real hook contract and extend this.
install(_context: PlatformInstallContext): PlatformInstallResult {
install(context: PlatformInstallContext): PlatformInstallResult {
const skills = copyBundledSkills(join(antigravityHome(), "skills"));
if (!context.hooks) return { skills };

// process.argv[1] is the currently-running greplica entrypoint -- the
// same self-reference install.ts already uses to relaunch itself for
// the embedding prewarm. Without it we can't build an absolute command,
// so hooks are skipped rather than writing something broken.
const scriptPath = process.argv[1];
if (scriptPath === undefined) return { skills };

const wrapperPath = writeStopHookScript(context.repoRoot, process.execPath, scriptPath);
const command = `${process.execPath} ${scriptPath} hook ingest --platform antigravity`;

const hookConfigPath = join(context.repoRoot, ".agents", "hooks.json");
const config = readJsonObject(hookConfigPath);
config[antigravityHookNamespace] = {
Stop: [
{
hooks: [{ type: "command", command: wrapperPath, timeout: 30 }],
},
],
};
writeJson(hookConfigPath, config);

return {
skills: copyBundledSkills(join(antigravityHome(), "skills")),
skills,
hooks: {
platform: "antigravity",
configFiles: [hookConfigPath, wrapperPath],
events: ["Stop"],
command,
},
};
},
sessionSourceRef(_sessionId: string): string {
Expand All @@ -45,3 +92,45 @@ export const antigravityInstaller: PlatformInstaller = {
throw new Error("Antigravity background working-memory updates are not supported yet.");
},
};

// Antigravity spawns the "command" path directly rather than interpreting a
// shell string, so the greplica CLI invocation (which needs a node
// executable + script path + args) has to live inside a small wrapper
// script instead of being written inline. The wrapper always exits 0
// regardless of what the inner command does: Stop hooks aren't
// veto-capable in Antigravity as far as every source agrees, but there's no
// reason to risk it -- a real, documented cmux bug shows exactly what
// happens when a hook that was never supposed to gate anything propagates
// a non-zero exit code into Antigravity's hook runner.
function writeStopHookScript(repoRoot: string, execPath: string, scriptPath: string): string {
const hooksDir = join(repoRoot, ".agents", "hooks");
mkdirSync(hooksDir, { recursive: true });

const isWindows = process.platform === "win32";
const wrapperPath = join(hooksDir, isWindows ? "greplica-stop.cmd" : "greplica-stop.sh");

const content = isWindows
? [
"@echo off",
"rem Greplica Stop hook for Antigravity CLI. Always exits 0 -- Stop has no",
"rem veto power in Antigravity, and a failing side-effect must never surface",
"rem as a hook error to the user.",
`"${execPath}" "${scriptPath}" hook ingest --platform antigravity >nul 2>&1`,
"exit /b 0",
"",
].join("\r\n")
: [
"#!/usr/bin/env bash",
"# Greplica Stop hook for Antigravity CLI. Always exits 0 -- Stop has no",
"# veto power in Antigravity, and a failing side-effect must never surface",
"# as a hook error to the user.",
`"${execPath}" "${scriptPath}" hook ingest --platform antigravity >/dev/null 2>&1`,
"exit 0",
"",
].join("\n");

writeFileSync(wrapperPath, content, "utf8");
if (!isWindows) chmodSync(wrapperPath, 0o755);

return wrapperPath;
}
4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@
"smoke:openhands": "npm run build && node scripts/smoke-openhands-install.mjs",
"smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs",
"smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs",
"smoke:cursor": "npm run build && node scripts/smoke-cursor-install.mjs",
"test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js",
"test:repo-installations": "npm run build && node scripts/check-repo-installations.js",
"test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-graph-view.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-antigravity-hooks.js",
"test:transcript-bundle": "npm run build && node scripts/check-transcript-bundle.js",
"test:repo-context": "npm run build && node scripts/check-repo-context.js",
"test:source-memberships": "npm run build && node scripts/check-source-memberships.js",
Expand Down
84 changes: 84 additions & 0 deletions scripts/check-antigravity-hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const root = new URL("..", import.meta.url);
const { installPlatform } = await import(new URL("dist/libs/install/platforms/index.js", root));

const tmp = mkdtempSync(join(tmpdir(), "greplica-antigravity-hooks-test-"));
process.env.ANTIGRAVITY_HOME = join(tmp, "antigravity-home");
const repoRoot = join(tmp, "repo");

const result = installPlatform("antigravity", { repoRoot, hooks: true });

assert.ok(result.hooks, "expected hooks to be installed when hooks: true");
assert.deepEqual(result.hooks.events, ["Stop"]);
assert.equal(result.hooks.configFiles.length, 2);

const hooksJsonPath = join(repoRoot, ".agents", "hooks.json");
const wrapperPath = result.hooks.configFiles[1];
assert.ok(existsSync(hooksJsonPath), "expected .agents/hooks.json to be written");
assert.ok(existsSync(wrapperPath), "expected the wrapper script to be written");

const hooksConfig = JSON.parse(readFileSync(hooksJsonPath, "utf8"));
assert.ok(hooksConfig.greplica, "expected a top-level greplica namespace");
assert.equal(hooksConfig.greplica.Stop.length, 1);
assert.equal(hooksConfig.greplica.Stop[0].hooks[0].command, wrapperPath);
assert.equal(typeof hooksConfig.greplica.Stop[0].hooks[0].timeout, "number");

// Re-running install must not duplicate entries under our own namespace.
installPlatform("antigravity", { repoRoot, hooks: true });
const afterReinstall = JSON.parse(readFileSync(hooksJsonPath, "utf8"));
assert.equal(afterReinstall.greplica.Stop.length, 1, "reinstall must not duplicate Stop entries");
assert.equal(afterReinstall.greplica.Stop[0].hooks.length, 1, "reinstall must not duplicate hook handlers");

// Other tools' namespaces in the same shared hooks.json must survive untouched.
const withOtherTool = JSON.parse(readFileSync(hooksJsonPath, "utf8"));
withOtherTool.cmux = { PreToolUse: [{ hooks: [{ type: "command", command: "/some/other/tool.sh" }] }] };
writeFileSync(hooksJsonPath, JSON.stringify(withOtherTool, null, 2));
installPlatform("antigravity", { repoRoot, hooks: true });
const afterOtherToolPresent = JSON.parse(readFileSync(hooksJsonPath, "utf8"));
assert.deepEqual(afterOtherToolPresent.cmux, withOtherTool.cmux, "another tool's namespace must be preserved untouched");
assert.equal(afterOtherToolPresent.greplica.Stop.length, 1);

// The wrapper script must exit 0 even when the command it wraps fails --
// Stop hooks are documented as non-veto-capable, but the wrapper should
// never depend on that alone (mirrors a real, documented failure mode from
// another tool's Antigravity integration: a hook that propagated a
// non-zero exit code ended up blocking every tool call).
//
// Structural check: the script's last real instruction must unconditionally
// exit 0, regardless of what came before it.
const wrapperLines = readFileSync(wrapperPath, "utf8").trimEnd().split("\n");
const expectedLastLine = process.platform === "win32" ? "exit /b 0" : "exit 0";
assert.equal(wrapperLines[wrapperLines.length - 1], expectedLastLine, "wrapper must unconditionally exit 0 as its last instruction");

// Behavioral check: build a wrapper using the exact same template, but
// pointing at a command that deliberately fails, and confirm it still
// exits 0 end-to-end. Built per-platform since Windows has no bash/chmod.
const isWindows = process.platform === "win32";

const failingInner = join(tmp, isWindows ? "failing-inner-command.cmd" : "failing-inner-command.sh");
writeFileSync(
failingInner,
isWindows ? "@echo off\r\nmore >nul\r\nexit /b 1\r\n" : "#!/usr/bin/env bash\ncat >/dev/null\nexit 1\n",
"utf8",
);
if (!isWindows) spawnSync("chmod", ["+x", failingInner]);

const testWrapperPath = join(tmp, isWindows ? "test-wrapper-with-failing-inner.cmd" : "test-wrapper-with-failing-inner.sh");
writeFileSync(
testWrapperPath,
isWindows
? `@echo off\r\ncall "${failingInner}" >nul 2>&1\r\nexit /b 0\r\n`
: ["#!/usr/bin/env bash", `"${failingInner}" >/dev/null 2>&1`, "exit 0", ""].join("\n"),
"utf8",
);
if (!isWindows) spawnSync("chmod", ["+x", testWrapperPath]);

const wrapperRun = spawnSync(testWrapperPath, [], { input: "{}", encoding: "utf8", shell: isWindows });
assert.equal(wrapperRun.status, 0, "wrapper must exit 0 even when the wrapped command fails");

console.log("Antigravity hook installer checks passed.");
Loading