Skip to content

Commit 27fd313

Browse files
committed
feat(ide): add Antigravity CLI adapter and session parser
Add full IDE adapter for Google Antigravity CLI (agy), including: - CLI adapter: scan home, scan project, MCP discovery, hook detection - Server adapter: config file generation for agent installs - Session parser: parse brain/<id>/transcript.jsonl into normalized traces - Hook spec: hooks.json format with PreInvocation + Stop events - Session push hook: for when agy hook execution is functional - Reconcile command: observal reconcile --ide antigravity for manual push - WSL path resolution: transparent Windows/Linux/macOS support - IDE registry entries on both server and CLI sides - 19 tests covering adapter protocol, scanning, and hook detection SPDX-FileCopyrightText: 2026 Hari Srinivasan <harisrini21@gmail.com> SPDX-License-Identifier: AGPL-3.0-only
1 parent 55f4535 commit 27fd313

18 files changed

Lines changed: 1243 additions & 1 deletion

observal-server/schemas/ide_registry.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,46 @@
316316
"accepts_model_choice": True,
317317
"auto_sentinel": {"omit_field": True},
318318
},
319+
"antigravity": {
320+
"display_name": "Antigravity",
321+
"features": {"hooks", "mcp_servers", "skills"},
322+
"session_parser": "antigravity",
323+
"scopes": ["project", "user"],
324+
"default_scope": "project",
325+
"scope_labels": ("project (.agents/)", "user (~/.gemini/antigravity-cli/)"),
326+
"rules_file": {
327+
"project": "AGENTS.md",
328+
"user": "~/.gemini/GEMINI.md",
329+
},
330+
"rules_format": "markdown",
331+
"mcp_config_path": {
332+
"project": ".agents/mcp_config.json",
333+
"user": "~/.gemini/antigravity-cli/mcp_config.json",
334+
},
335+
"mcp_servers_key": "mcpServers",
336+
"skill_file": {
337+
"project": ".agents/skills/{name}/SKILL.md",
338+
"user": "~/.gemini/antigravity-cli/skills/{name}/SKILL.md",
339+
},
340+
"skill_format": "markdown",
341+
"home_mcp_config": "~/.gemini/antigravity-cli/mcp_config.json",
342+
"hook_type": "command",
343+
"hook_config_path": {
344+
"project": ".agents/hooks.json",
345+
"user": "~/.gemini/config/hooks.json",
346+
},
347+
"hook_scripts_dir": ".agents/hooks",
348+
"hook_events_map": {
349+
"PreToolUse": "PreToolUse",
350+
"PostToolUse": "PostToolUse",
351+
"Stop": "Stop",
352+
"SessionStart": "SessionStart",
353+
"UserPromptSubmit": "PreInvocation",
354+
},
355+
"config_dir": ".agents",
356+
"accepts_model_choice": True,
357+
"auto_sentinel": {"omit_setting": True},
358+
},
319359
"pi": {
320360
"display_name": "Pi",
321361
"features": {"skills", "hooks", "mcp_servers"},
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# SPDX-FileCopyrightText: 2026 Hari Srinivasan <harisrini21@gmail.com>
2+
# SPDX-License-Identifier: AGPL-3.0-only
3+
4+
"""Antigravity CLI server-side config generator."""
5+
6+
from __future__ import annotations
7+
8+
from loguru import logger
9+
10+
from schemas.ide_registry import IDE_REGISTRY
11+
from services.ide import ConfigContext, register_adapter
12+
13+
14+
class AntigravityAdapter:
15+
"""Antigravity CLI IDE adapter for agent config generation."""
16+
17+
@property
18+
def ide_name(self) -> str:
19+
return "antigravity"
20+
21+
def format_config(self, ctx: ConfigContext) -> dict:
22+
logger.debug("format_config: agent={}", ctx.safe_name)
23+
spec = IDE_REGISTRY["antigravity"]
24+
options = ctx.options
25+
scope = options.get("scope", spec["default_scope"])
26+
27+
# MCP config
28+
mcp_path = spec["mcp_config_path"].get(scope, spec["mcp_config_path"]["project"])
29+
mcp_content = {spec["mcp_servers_key"]: ctx.mcp_configs}
30+
31+
# Rules file
32+
rules_path = spec["rules_file"].get(scope, spec["rules_file"]["project"])
33+
rules_path = rules_path.replace("{name}", ctx.safe_name)
34+
35+
# Skill files
36+
skill_files = []
37+
skill_path_template = spec["skill_file"].get(scope, spec["skill_file"]["project"])
38+
for skill in ctx.skill_configs:
39+
skill_name = skill.get("name", "unnamed")
40+
skill_path = skill_path_template.replace("{name}", skill_name)
41+
skill_files.append({"path": skill_path, "content": skill.get("content", "")})
42+
43+
result: dict = {
44+
"rules_file": {"path": rules_path, "content": ctx.rules_content},
45+
"mcp_config": {"path": mcp_path, "content": mcp_content},
46+
"scope": scope,
47+
}
48+
49+
if skill_files:
50+
result["skill_files"] = skill_files
51+
52+
# Model choice
53+
model = options.get("_resolved_model")
54+
if model:
55+
mcp_content["model"] = model
56+
57+
warnings = list(ctx.compatibility_warnings)
58+
warnings.extend(options.get("_model_warnings") or [])
59+
if warnings:
60+
result["_warnings"] = warnings
61+
62+
return result
63+
64+
65+
register_adapter(AntigravityAdapter())

observal-server/services/ide/load_all.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
Each adapter module auto-registers itself on import.
88
"""
99

10+
from services.ide import antigravity as _antigravity # noqa: F401
1011
from services.ide import claude_code as _claude_code # noqa: F401
1112
from services.ide import codex as _codex # noqa: F401
1213
from services.ide import copilot as _copilot # noqa: F401

observal-server/services/session_parsers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from .cursor import parse_rows as _parse_cursor
2828
from .kiro import parse_rows as _parse_kiro
2929
from .pi import parse_rows as _parse_pi
30+
from .antigravity import parse_rows as _parse_antigravity
3031

3132
# Maps session_parser ID -> parse_rows callable.
3233
# Add new entries here when implementing a new JSONL format.
@@ -36,6 +37,7 @@
3637
"cursor": _parse_cursor,
3738
"kiro": _parse_kiro,
3839
"pi": _parse_pi,
40+
"antigravity": _parse_antigravity,
3941
}
4042

4143

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# SPDX-FileCopyrightText: 2026 Hari Srinivasan <harisrini21@gmail.com>
2+
# SPDX-License-Identifier: AGPL-3.0-only
3+
4+
"""Antigravity CLI JSONL session parser.
5+
6+
Real transcript format (brain/<id>/.system_generated/logs/transcript.jsonl):
7+
{
8+
"step_index": 0,
9+
"source": "USER_EXPLICIT" | "MODEL" | "SYSTEM",
10+
"type": "USER_INPUT" | "PLANNER_RESPONSE" | "LIST_DIRECTORY" | "CONVERSATION_HISTORY" | ...,
11+
"status": "DONE" | "IN_PROGRESS" | "ERROR",
12+
"created_at": "2026-05-31T17:54:04Z",
13+
"content": "...", # present on USER_INPUT and PLANNER_RESPONSE
14+
"tool_calls": [ # present on PLANNER_RESPONSE when tools are invoked
15+
{"name": "list_dir", "args": {...}}
16+
]
17+
}
18+
19+
Tool results come as separate rows with type matching the tool name
20+
(e.g. "LIST_DIRECTORY") and content holding the output.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import json
26+
import re
27+
28+
from .base import basic_event, pick_timestamp
29+
30+
# Types that represent user prompts
31+
_USER_TYPES = {"USER_INPUT"}
32+
33+
# Types that represent model text responses (no tool calls)
34+
_ASSISTANT_TYPES = {"PLANNER_RESPONSE"}
35+
36+
# Types that are tool results (source=MODEL, type != USER_INPUT/PLANNER_RESPONSE/SYSTEM types)
37+
_SYSTEM_TYPES = {"CONVERSATION_HISTORY", "SYSTEM_PROMPT"}
38+
39+
_USER_REQUEST_RE = re.compile(r"<USER_REQUEST>\s*(.*?)\s*</USER_REQUEST>", re.DOTALL)
40+
41+
42+
def _extract_user_text(content: str) -> str:
43+
"""Strip XML wrapper tags agy injects around user prompts."""
44+
m = _USER_REQUEST_RE.search(content)
45+
return m.group(1).strip() if m else content.strip()
46+
47+
48+
def parse_rows(rows: list[dict]) -> list[dict]:
49+
"""Parse raw_line Antigravity transcript rows into normalised frontend events."""
50+
events: list[dict] = []
51+
# Maps step_index of a PLANNER_RESPONSE with tool_calls -> event index
52+
# so we can attach tool results back to the tool call event
53+
tool_step_index: dict[int, int] = {}
54+
55+
for row in rows:
56+
raw_line = row.get("raw_line", "")
57+
ingested_at = row.get("ingested_at", "")
58+
row_ts = row.get("timestamp", "")
59+
ide = row.get("ide", "antigravity")
60+
61+
if not raw_line:
62+
events.append(basic_event(row))
63+
continue
64+
65+
try:
66+
line = json.loads(raw_line)
67+
except (json.JSONDecodeError, ValueError):
68+
events.append(basic_event(row))
69+
continue
70+
71+
line_type = line.get("type", "")
72+
source = line.get("source", "")
73+
status = line.get("status", "")
74+
content = line.get("content", "")
75+
tool_calls = line.get("tool_calls", [])
76+
step_index = line.get("step_index", -1)
77+
jsonl_ts = line.get("created_at")
78+
ts = pick_timestamp(jsonl_ts, row_ts, ingested_at)
79+
80+
# Skip system/history lines
81+
if line_type in _SYSTEM_TYPES or source == "SYSTEM":
82+
continue
83+
84+
# User prompt
85+
if line_type in _USER_TYPES and source in ("USER_EXPLICIT", "USER_IMPLICIT"):
86+
text = _extract_user_text(content) if content else ""
87+
if text:
88+
events.append({
89+
"timestamp": ts,
90+
"event_name": "hook_userpromptsubmit",
91+
"body": text[:120],
92+
"attributes": {"tool_input": text},
93+
"service_name": ide,
94+
})
95+
96+
# Model response with tool calls
97+
elif line_type in _ASSISTANT_TYPES and tool_calls:
98+
for tc in tool_calls:
99+
tool_name = tc.get("name", "")
100+
args = tc.get("args", {})
101+
idx = len(events)
102+
events.append({
103+
"timestamp": ts,
104+
"event_name": "hook_posttooluse",
105+
"body": tool_name,
106+
"attributes": {
107+
"tool_name": tool_name,
108+
"tool_input": json.dumps(args) if isinstance(args, dict) else str(args),
109+
},
110+
"service_name": ide,
111+
})
112+
tool_step_index[step_index] = idx
113+
114+
# Model text response (no tool calls)
115+
elif line_type in _ASSISTANT_TYPES and content and not tool_calls:
116+
events.append({
117+
"timestamp": ts,
118+
"event_name": "hook_assistant_response",
119+
"body": content[:120],
120+
"attributes": {"tool_response": content},
121+
"service_name": ide,
122+
})
123+
124+
# Tool result — type is the tool name (e.g. LIST_DIRECTORY)
125+
# source=MODEL, step_index follows the PLANNER_RESPONSE that called it
126+
elif source == "MODEL" and line_type not in _ASSISTANT_TYPES and content:
127+
# Find the preceding tool call event (step_index - 1 or step_index - 2)
128+
parent_idx = tool_step_index.get(step_index - 1) or tool_step_index.get(step_index - 2)
129+
if parent_idx is not None and parent_idx < len(events):
130+
events[parent_idx]["attributes"]["tool_response"] = content[:500]
131+
if status == "ERROR":
132+
events[parent_idx]["attributes"]["tool_status"] = "error"
133+
else:
134+
# No matching tool call — emit as standalone result
135+
events.append({
136+
"timestamp": ts,
137+
"event_name": "hook_posttooluse",
138+
"body": line_type,
139+
"attributes": {
140+
"tool_name": line_type,
141+
"tool_response": content[:500],
142+
},
143+
"service_name": ide,
144+
})
145+
146+
else:
147+
events.append(basic_event(row))
148+
149+
return events

observal_cli/cmd_doctor.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,9 @@ def doctor_patch(
612612
elif target == "pi":
613613
changed = _patch_pi(dry_run)
614614
any_changes = any_changes or changed
615+
elif target == "antigravity":
616+
changed = _patch_antigravity(dry_run)
617+
any_changes = any_changes or changed
615618

616619
# ── Shims (all IDEs with home MCP config) ──
617620
if do_shims:
@@ -788,6 +791,44 @@ def _patch_cursor(dry_run: bool) -> bool:
788791
return True
789792

790793

794+
def _patch_antigravity(dry_run: bool) -> bool:
795+
"""Install session push hooks into ~/.gemini/config/hooks.json."""
796+
from observal_cli.ide_specs.antigravity_hooks_spec import (
797+
_OBSERVAL_HOOK_NAME,
798+
build_antigravity_hooks,
799+
)
800+
from observal_cli.shared.utils import resolve_antigravity_config_dir
801+
802+
rprint("[cyan]Antigravity - session push hooks[/cyan]")
803+
804+
config_dir = resolve_antigravity_config_dir()
805+
if config_dir is None:
806+
rprint(" [dim]No ~/.gemini/config/ directory - skipping[/dim]")
807+
return False
808+
809+
hooks_path = config_dir / "hooks.json"
810+
desired = build_antigravity_hooks()
811+
812+
existing: dict = {}
813+
if hooks_path.exists():
814+
try:
815+
existing = json.loads(hooks_path.read_text())
816+
except (json.JSONDecodeError, OSError):
817+
existing = {}
818+
819+
if _OBSERVAL_HOOK_NAME in existing:
820+
rprint(" [dim]Already up to date[/dim]")
821+
return False
822+
823+
existing.update(desired)
824+
if not dry_run:
825+
hooks_path.write_text(json.dumps(existing, indent=2) + "\n")
826+
827+
verb = "Would install" if dry_run else "Installed"
828+
rprint(f" {verb} hooks in {hooks_path}")
829+
return True
830+
831+
791832
def _patch_pi(dry_run: bool) -> bool:
792833
"""Install observal-pi into ~/.pi/agent/settings.json packages."""
793834
optic.trace("dry_run={}", dry_run)

0 commit comments

Comments
 (0)