context-forge-cli 0.4.9__tar.gz → 0.4.11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/PKG-INFO +2 -1
  2. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/PLAN.md +1 -0
  3. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/README.md +1 -0
  4. context_forge_cli-0.4.11/contextforge/adapters/antigravity.py +233 -0
  5. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/adapters/registry.py +2 -0
  6. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/core/token_analyzer.py +6 -0
  7. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/mcp_server.py +13 -3
  8. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/tui/widgets/session_detail.py +2 -0
  9. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/tui/widgets/session_table.py +5 -0
  10. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/tui/widgets/stats_panel.py +3 -1
  11. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/tui/widgets/status_bar.py +2 -1
  12. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/tui/widgets/tokens_panel.py +1 -0
  13. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/tui/widgets/transfer_panel.py +1 -0
  14. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/pyproject.toml +1 -1
  15. context_forge_cli-0.4.11/tests/adapters/test_antigravity.py +195 -0
  16. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/uv.lock +1 -1
  17. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/.contextforge.toml +0 -0
  18. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/.github/workflows/publish.yml +0 -0
  19. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/.gitignore +0 -0
  20. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/.python-version +0 -0
  21. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/AGENTS.md +0 -0
  22. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/__init__.py +0 -0
  23. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/adapters/__init__.py +0 -0
  24. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/adapters/altimate_code.py +0 -0
  25. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/adapters/base.py +0 -0
  26. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/adapters/claude_code.py +0 -0
  27. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/adapters/claude_desktop.py +0 -0
  28. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/adapters/codex.py +0 -0
  29. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/adapters/gemini.py +0 -0
  30. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/cli.py +0 -0
  31. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/core/__init__.py +0 -0
  32. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/core/analytics.py +0 -0
  33. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/core/compactor.py +0 -0
  34. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/core/db.py +0 -0
  35. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/core/injector.py +0 -0
  36. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/core/scanner.py +0 -0
  37. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/core/summarizer.py +0 -0
  38. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/models/__init__.py +0 -0
  39. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/models/config.py +0 -0
  40. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/models/session.py +0 -0
  41. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/tui/__init__.py +0 -0
  42. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/tui/app.py +0 -0
  43. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/tui/clipboard.py +0 -0
  44. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/tui/styles.tcss +0 -0
  45. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/tui/widgets/__init__.py +0 -0
  46. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/utils/__init__.py +0 -0
  47. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/utils/display.py +0 -0
  48. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/contextforge/utils/tokens.py +0 -0
  49. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/tests/__init__.py +0 -0
  50. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/tests/adapters/__init__.py +0 -0
  51. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/tests/adapters/test_claude_code.py +0 -0
  52. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/tests/conftest.py +0 -0
  53. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/tests/core/__init__.py +0 -0
  54. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/tests/core/test_compactor.py +0 -0
  55. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/tests/core/test_db.py +0 -0
  56. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/tests/core/test_injector.py +0 -0
  57. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/tests/core/test_summarizer.py +0 -0
  58. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/tests/core/test_token_analyzer.py +0 -0
  59. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/tests/fixtures/claude_session_sample.jsonl +0 -0
  60. {context_forge_cli-0.4.9 → context_forge_cli-0.4.11}/tests/test_mcp_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: context-forge-cli
3
- Version: 0.4.9
3
+ Version: 0.4.11
4
4
  Summary: Session manager and context bridge for agentic CLI tools
5
5
  Project-URL: Homepage, https://github.com/emmver/contextforge
6
6
  Project-URL: Repository, https://github.com/emmver/contextforge
@@ -169,6 +169,7 @@ The method is chosen automatically based on token count and whether a target ses
169
169
  | Claude Desktop | `~/Library/Application Support/Claude/local-agent-mode-sessions/` | `--system-prompt` |
170
170
  | Codex | `~/.codex/state_5.sqlite` | `resume` / `fork` |
171
171
  | Gemini | `~/.gemini/tmp/<project_hash>/chats/` JSON | New session / `/resume` |
172
+ | Antigravity | `~/.gemini/antigravity-cli/history.jsonl` | `-i` / `--conversation` |
172
173
  | altimate-code | `~/.local/share/altimate-code/opencode.db` | `run -s` / `import` |
173
174
 
174
175
  ## Session Summaries
@@ -87,6 +87,7 @@ Agents and contributors: update this file as you work.
87
87
  - [ ] Install docs: `uv tool install contextforge`
88
88
  - [ ] GitHub Actions CI: `uv run pytest --cov=contextforge`
89
89
  - [ ] `open-claw` adapter (if/when CLI interface is documented)
90
+ - [x] Antigravity adapter (`adapters/antigravity.py`)
90
91
  - [ ] Session search: `cf ls --search <query>`
91
92
  - [ ] Session archiving: `cf archive <id>`
92
93
 
@@ -140,6 +140,7 @@ The method is chosen automatically based on token count and whether a target ses
140
140
  | Claude Desktop | `~/Library/Application Support/Claude/local-agent-mode-sessions/` | `--system-prompt` |
141
141
  | Codex | `~/.codex/state_5.sqlite` | `resume` / `fork` |
142
142
  | Gemini | `~/.gemini/tmp/<project_hash>/chats/` JSON | New session / `/resume` |
143
+ | Antigravity | `~/.gemini/antigravity-cli/history.jsonl` | `-i` / `--conversation` |
143
144
  | altimate-code | `~/.local/share/altimate-code/opencode.db` | `run -s` / `import` |
144
145
 
145
146
  ## Session Summaries
@@ -0,0 +1,233 @@
1
+ """Adapter for Google Antigravity CLI sessions."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import shlex
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ import tiktoken
10
+
11
+ from contextforge.adapters.base import ToolAdapter
12
+ from contextforge.models.session import Message, Session
13
+
14
+ _AGY_HISTORY = Path.home() / ".gemini" / "antigravity-cli" / "history.jsonl"
15
+ _AGY_CONVERSATIONS = Path.home() / ".gemini" / "antigravity-cli" / "conversations"
16
+ _AGY_ALT_HISTORY = Path.home() / ".gemini" / "antigravity" / "history.jsonl"
17
+ _AGY_ALT_CONVERSATIONS = Path.home() / ".gemini" / "antigravity" / "conversations"
18
+
19
+
20
+ def _count_tokens(text: str) -> int:
21
+ """Count tokens in text using tiktoken for Claude models."""
22
+ try:
23
+ enc = tiktoken.encoding_for_model("claude-3-5-sonnet-20241022")
24
+ return len(enc.encode(text))
25
+ except Exception:
26
+ # Fallback: rough estimate (~4 chars per token)
27
+ return len(text) // 4
28
+
29
+
30
+ def _ms_to_dt(ts: int) -> datetime:
31
+ """Convert a millisecond timestamp to UTC datetime."""
32
+ return datetime.fromtimestamp(ts / 1000, tz=timezone.utc)
33
+
34
+
35
+ class AntigravityAdapter(ToolAdapter):
36
+ tool_name = "antigravity"
37
+ default_paths = [_AGY_HISTORY, _AGY_ALT_HISTORY]
38
+
39
+ def _history_path(self) -> Path | None:
40
+ """Return the first existing history.jsonl path."""
41
+ for p in (_AGY_HISTORY, _AGY_ALT_HISTORY):
42
+ if p.exists():
43
+ return p
44
+ return None
45
+
46
+ def _conversations_dir(self) -> Path | None:
47
+ """Return the conversations directory that matches the history path."""
48
+ history = self._history_path()
49
+ if history is not None:
50
+ conversations = history.parent / "conversations"
51
+ if conversations.exists():
52
+ return conversations
53
+ for p in (_AGY_CONVERSATIONS, _AGY_ALT_CONVERSATIONS):
54
+ if p.exists():
55
+ return p
56
+ return None
57
+
58
+ def discover_sessions(self) -> list[Session]:
59
+ """Discover Antigravity sessions from history.jsonl.
60
+
61
+ history.jsonl contains one JSON object per user prompt. Prompts that
62
+ belong to the same saved conversation share a ``conversationId``; we
63
+ group those into a single ``Session``.
64
+ """
65
+ sessions: list[Session] = []
66
+ history_path = self._history_path()
67
+ if history_path is None:
68
+ return sessions
69
+
70
+ # conversationId -> list of history entries
71
+ groups: dict[str, list[dict]] = {}
72
+
73
+ with history_path.open() as f:
74
+ for line in f:
75
+ line = line.strip()
76
+ if not line:
77
+ continue
78
+ try:
79
+ entry = json.loads(line)
80
+ except json.JSONDecodeError:
81
+ continue
82
+
83
+ conversation_id = entry.get("conversationId")
84
+ if not conversation_id:
85
+ # Skip un-persisted one-off prompts.
86
+ continue
87
+ groups.setdefault(conversation_id, []).append(entry)
88
+
89
+ for conversation_id, entries in groups.items():
90
+ if not entries:
91
+ continue
92
+ # Use earliest timestamp as created_at, latest as updated_at.
93
+ timestamps = [e.get("timestamp", 0) for e in entries if e.get("timestamp")]
94
+ created_ts = min(timestamps) if timestamps else 0
95
+ updated_ts = max(timestamps) if timestamps else 0
96
+
97
+ created_at = _ms_to_dt(created_ts) if created_ts else datetime.now(timezone.utc)
98
+ updated_at = _ms_to_dt(updated_ts) if updated_ts else created_at
99
+
100
+ # Title from the first entry's display prompt.
101
+ first_display = entries[0].get("display", "")
102
+ title = first_display[:200] if first_display else None
103
+ cwd = entries[0].get("workspace") or None
104
+
105
+ sessions.append(
106
+ Session(
107
+ id=conversation_id,
108
+ tool=self.tool_name,
109
+ title=title,
110
+ cwd=cwd,
111
+ created_at=created_at,
112
+ updated_at=updated_at,
113
+ raw_path=str(history_path),
114
+ status="unknown",
115
+ )
116
+ )
117
+
118
+ return sessions
119
+
120
+ def load_messages(self, session_id: str) -> list[Message]:
121
+ """Load messages for a given Antigravity session ID.
122
+
123
+ Antigravity stores full conversations as encrypted protobuf (``.pb``)
124
+ files under ``conversations/``. ContextForge cannot currently parse
125
+ those encrypted files, so this method attempts the following, in order:
126
+
127
+ 1. Look for a JSON sidecar ``<session_id>.json`` (e.g. from a test
128
+ fixture or future export).
129
+ 2. Read the history JSONL entries with this ``conversationId`` and
130
+ synthesise minimal ``Message`` objects from the user prompts.
131
+ """
132
+ conv_dir = self._conversations_dir()
133
+ if conv_dir is not None:
134
+ json_path = conv_dir / f"{session_id}.json"
135
+ if json_path.exists():
136
+ return self._parse_json_conversation(json_path)
137
+
138
+ # Fallback: reconstruct user turns from history.jsonl.
139
+ history_path = self._history_path()
140
+ if history_path is None:
141
+ return []
142
+
143
+ messages: list[Message] = []
144
+ with history_path.open() as f:
145
+ for line in f:
146
+ line = line.strip()
147
+ if not line:
148
+ continue
149
+ try:
150
+ entry = json.loads(line)
151
+ except json.JSONDecodeError:
152
+ continue
153
+ if entry.get("conversationId") != session_id:
154
+ continue
155
+
156
+ text = entry.get("display", "")
157
+ if not text:
158
+ continue
159
+
160
+ ts = entry.get("timestamp")
161
+ timestamp = _ms_to_dt(ts) if ts else None
162
+ messages.append(
163
+ Message(
164
+ role="user",
165
+ content=text,
166
+ timestamp=timestamp,
167
+ token_count=_count_tokens(text),
168
+ )
169
+ )
170
+
171
+ return messages
172
+
173
+ def _parse_json_conversation(self, path: Path) -> list[Message]:
174
+ """Parse a JSON-sidecar conversation file into Messages.
175
+
176
+ Expected shape::
177
+
178
+ {
179
+ "messages": [
180
+ {"role": "user", "content": "...", "timestamp": 1234567890000},
181
+ {"role": "assistant", "content": "...", "timestamp": 1234567891000}
182
+ ]
183
+ }
184
+ """
185
+ messages: list[Message] = []
186
+ try:
187
+ with path.open() as f:
188
+ data = json.load(f)
189
+ except (json.JSONDecodeError, OSError):
190
+ return messages
191
+
192
+ for msg in data.get("messages", []):
193
+ role = msg.get("role", "")
194
+ if role not in ("user", "assistant"):
195
+ continue
196
+ content = str(msg.get("content", ""))
197
+ if not content:
198
+ continue
199
+ ts = msg.get("timestamp")
200
+ timestamp = _ms_to_dt(ts) if isinstance(ts, int) else None
201
+ messages.append(
202
+ Message(
203
+ role=role,
204
+ content=content,
205
+ timestamp=timestamp,
206
+ token_count=_count_tokens(content),
207
+ )
208
+ )
209
+
210
+ return messages
211
+
212
+ def build_inject_command(
213
+ self,
214
+ context: str,
215
+ target_session_id: str | None = None,
216
+ cwd: str | None = None,
217
+ method: str = "system_prompt",
218
+ ) -> str:
219
+ """Build a shell command to start/resume an Antigravity CLI session.
220
+
221
+ ``agy -i <prompt>`` starts an interactive session seeded with the given
222
+ prompt. ``agy --conversation <id>`` resumes an existing conversation.
223
+ """
224
+ safe_ctx = shlex.quote(context)
225
+
226
+ if method == "resume" and target_session_id:
227
+ return f"agy --conversation {target_session_id}"
228
+
229
+ # Default: new interactive session with the context as the opening prompt.
230
+ cmd = f"agy -i {safe_ctx}"
231
+ if cwd:
232
+ cmd = f"cd {shlex.quote(cwd)} && {cmd}"
233
+ return cmd
@@ -6,6 +6,7 @@ from contextforge.adapters.base import ToolAdapter
6
6
  from contextforge.adapters.claude_code import ClaudeCodeAdapter
7
7
  from contextforge.adapters.claude_desktop import ClaudeDesktopAdapter
8
8
  from contextforge.adapters.codex import CodexAdapter
9
+ from contextforge.adapters.antigravity import AntigravityAdapter
9
10
  from contextforge.adapters.gemini import GeminiAdapter
10
11
 
11
12
  ADAPTERS: dict[str, type[ToolAdapter]] = {
@@ -14,6 +15,7 @@ ADAPTERS: dict[str, type[ToolAdapter]] = {
14
15
  "codex": CodexAdapter,
15
16
  "altimate_code": AltimateCodeAdapter,
16
17
  "gemini": GeminiAdapter,
18
+ "antigravity": AntigravityAdapter,
17
19
  }
18
20
 
19
21
 
@@ -21,6 +21,9 @@ class TurnStats:
21
21
  text_tokens: int = 0
22
22
  tool_call_tokens: int = 0
23
23
  tool_result_tokens: int = 0
24
+ content: str = "" # full message text content
25
+ tool_calls: list[dict] = field(default_factory=list) # [{name, input}]
26
+ tool_results: list[dict] = field(default_factory=list) # [{output}]
24
27
 
25
28
 
26
29
  @dataclass
@@ -102,6 +105,9 @@ def analyze_tokens(
102
105
  text_tokens=text_tokens,
103
106
  tool_call_tokens=tool_call_tokens,
104
107
  tool_result_tokens=tool_result_tokens,
108
+ content=msg.content,
109
+ tool_calls=msg.tool_calls,
110
+ tool_results=msg.tool_results,
105
111
  ))
106
112
 
107
113
  return SessionTokenReport(
@@ -16,9 +16,10 @@ mcp = FastMCP(
16
16
  "ContextForge provides read-only access to indexed agentic-tool sessions "
17
17
  "and their token usage. Start with list_sessions to discover what's available "
18
18
  "(supports pagination via offset/limit and date filtering via window). "
19
- "Use get_session_tokens for token stats — pass include_turns=True with "
20
- "turns_limit/turns_offset to page through per-turn data without blowing up "
21
- "the context window. Use get_token_analytics for aggregated stats across all tools."
19
+ "Use get_session_tokens for token stats and conversation content — pass "
20
+ "include_turns=True with turns_limit/turns_offset to page through per-turn "
21
+ "data including full message text, tool calls, and tool results. "
22
+ "Use get_token_analytics for aggregated stats across all tools."
22
23
  ),
23
24
  )
24
25
 
@@ -187,6 +188,15 @@ def get_session_tokens(
187
188
  "text_tokens": t.text_tokens,
188
189
  "tool_call_tokens": t.tool_call_tokens,
189
190
  "tool_result_tokens": t.tool_result_tokens,
191
+ "content": t.content[:8000],
192
+ "tool_calls": [
193
+ {"name": tc.get("name", "?"), "input": tc.get("input", "")[:4000]}
194
+ for tc in t.tool_calls
195
+ ],
196
+ "tool_results": [
197
+ {"output": tr.get("output", "")[:4000]}
198
+ for tr in t.tool_results
199
+ ],
190
200
  }
191
201
  for t in turns
192
202
  ]
@@ -16,6 +16,7 @@ TOOL_COLORS = {
16
16
  "altimate_code": "magenta",
17
17
  "claude_desktop": "yellow",
18
18
  "gemini": "blue",
19
+ "antigravity": "white",
19
20
  }
20
21
  TOOL_LABELS = {
21
22
  "claude_code": "◆ Claude Code",
@@ -23,6 +24,7 @@ TOOL_LABELS = {
23
24
  "altimate_code": "⚡ Altimate",
24
25
  "claude_desktop": "◇ Claude Desktop",
25
26
  "gemini": "✦ Gemini",
27
+ "antigravity": "◉ Antigravity",
26
28
  }
27
29
 
28
30
 
@@ -19,6 +19,7 @@ TOOL_ICON = {
19
19
  "altimate_code": "⚡", # Altimate — lightning
20
20
  "claude_desktop": "◇", # Claude Desktop — open diamond
21
21
  "gemini": "✦", # Google Gemini — four-pointed star
22
+ "antigravity": "◉", # Antigravity — circle
22
23
  }
23
24
 
24
25
  TOOL_MARKUP = {
@@ -27,6 +28,7 @@ TOOL_MARKUP = {
27
28
  "altimate_code": "[magenta]⚡ Alt[/magenta]",
28
29
  "claude_desktop": "[yellow]◇ Desktop[/yellow]",
29
30
  "gemini": "[blue]✦ Gemini[/blue]",
31
+ "antigravity": "[white]◉ Agy[/white]",
30
32
  }
31
33
 
32
34
  TOOL_DISPLAY = {
@@ -35,6 +37,7 @@ TOOL_DISPLAY = {
35
37
  "altimate_code": "⚡ Alt",
36
38
  "claude_desktop": "◇ Desktop",
37
39
  "gemini": "✦ Gemini",
40
+ "antigravity": "◉ Agy",
38
41
  }
39
42
 
40
43
  _FTOOL_MAP = {
@@ -43,6 +46,7 @@ _FTOOL_MAP = {
43
46
  "ftool-codex": "codex",
44
47
  "ftool-gem": "gemini",
45
48
  "ftool-alt": "altimate_code",
49
+ "ftool-agy": "antigravity",
46
50
  }
47
51
 
48
52
 
@@ -159,6 +163,7 @@ class SessionTable(Widget):
159
163
  yield Button("⬡ Codex", id="ftool-codex", classes="filter-tool-btn")
160
164
  yield Button("✦ Gem", id="ftool-gem", classes="filter-tool-btn")
161
165
  yield Button("⚡ Alt", id="ftool-alt", classes="filter-tool-btn")
166
+ yield Button("◉ Agy", id="ftool-agy", classes="filter-tool-btn")
162
167
  yield DataTable(cursor_type="row", zebra_stripes=True, id="sessions-datatable")
163
168
 
164
169
  def on_mount(self) -> None:
@@ -21,6 +21,7 @@ TOOL_COLORS = {
21
21
  "altimate_code": "magenta",
22
22
  "claude_desktop": "yellow",
23
23
  "gemini": "blue",
24
+ "antigravity": "white",
24
25
  }
25
26
  TOOL_LABELS = {
26
27
  "claude_code": "◆ Claude Code",
@@ -28,9 +29,10 @@ TOOL_LABELS = {
28
29
  "altimate_code": "⚡ Altimate",
29
30
  "claude_desktop": "◇ Desktop",
30
31
  "gemini": "✦ Gemini",
32
+ "antigravity": "◉ Antigravity",
31
33
  }
32
34
 
33
- _TOOL_ORDER = ["claude_code", "codex", "gemini", "altimate_code", "claude_desktop"]
35
+ _TOOL_ORDER = ["claude_code", "codex", "gemini", "altimate_code", "claude_desktop", "antigravity"]
34
36
 
35
37
  WINDOW_LABELS = {
36
38
  "7d": "7 days",
@@ -69,6 +69,7 @@ class StatusBar(Widget):
69
69
  "altimate_code": "⚡ Alt",
70
70
  "claude_desktop": "◇ Desktop",
71
71
  "gemini": "✦ Gemini",
72
+ "antigravity": "◉ Agy",
72
73
  }
73
74
  for tool, label in labels.items():
74
75
  n = counts.get(tool, 0)
@@ -95,7 +96,7 @@ class StatusBar(Widget):
95
96
  if text:
96
97
  parts.append(f'"{text}"')
97
98
  if tool:
98
- short = {"claude_code": "◆ CC", "codex": "⬡ Codex", "altimate_code": "⚡ Alt", "claude_desktop": "◇ Desktop", "gemini": "✦ Gemini"}.get(tool, tool)
99
+ short = {"claude_code": "◆ CC", "codex": "⬡ Codex", "altimate_code": "⚡ Alt", "claude_desktop": "◇ Desktop", "gemini": "✦ Gemini", "antigravity": "◉ Agy"}.get(tool, tool)
99
100
  parts.append(short)
100
101
 
101
102
  self.query_one("#status-filter", Static).update(
@@ -24,6 +24,7 @@ _TOOL_LABELS = {
24
24
  "altimate_code": "⚡ Altimate",
25
25
  "claude_desktop": "◇ Claude Desktop",
26
26
  "gemini": "✦ Gemini",
27
+ "antigravity": "◉ Antigravity",
27
28
  }
28
29
 
29
30
 
@@ -12,6 +12,7 @@ TOOLS = [
12
12
  ("claude_code", "Claude Code"),
13
13
  ("codex", "Codex"),
14
14
  ("altimate_code", "altimate-code"),
15
+ ("antigravity", "Antigravity"),
15
16
  ]
16
17
 
17
18
  STRATEGIES = [
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "context-forge-cli"
3
- version = "0.4.9"
3
+ version = "0.4.11"
4
4
  description = "Session manager and context bridge for agentic CLI tools"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -0,0 +1,195 @@
1
+ """Tests for the Antigravity adapter using fixture data."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import tempfile
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from contextforge.adapters.antigravity import AntigravityAdapter, _count_tokens, _ms_to_dt
12
+
13
+
14
+ def test_ms_to_dt():
15
+ ts = 1779426988061 # milliseconds
16
+ dt = _ms_to_dt(ts)
17
+ assert dt.year == 2026
18
+ assert dt.tzinfo is not None
19
+
20
+
21
+ def test_count_tokens():
22
+ text = "hello world"
23
+ n = _count_tokens(text)
24
+ assert isinstance(n, int) and n > 0
25
+
26
+
27
+ def test_discover_sessions_from_fixture():
28
+ adapter = AntigravityAdapter()
29
+
30
+ with tempfile.TemporaryDirectory() as tmpdir:
31
+ history = Path(tmpdir) / ".gemini" / "antigravity-cli" / "history.jsonl"
32
+ history.parent.mkdir(parents=True)
33
+ entries = [
34
+ {
35
+ "display": "First prompt",
36
+ "timestamp": 1700000000000,
37
+ "workspace": "/tmp/ws1",
38
+ "conversationId": "conv-a",
39
+ },
40
+ {
41
+ "display": "Second prompt",
42
+ "timestamp": 1700000100000,
43
+ "workspace": "/tmp/ws1",
44
+ "conversationId": "conv-a",
45
+ },
46
+ {
47
+ "display": "Other prompt",
48
+ "timestamp": 1700001000000,
49
+ "workspace": "/tmp/ws2",
50
+ "conversationId": "conv-b",
51
+ },
52
+ ]
53
+ history.write_text("\n".join(json.dumps(e) for e in entries) + "\n")
54
+
55
+ import contextforge.adapters.antigravity as mod
56
+ original_history = mod._AGY_HISTORY
57
+ mod._AGY_HISTORY = history
58
+ try:
59
+ sessions = adapter.discover_sessions()
60
+ finally:
61
+ mod._AGY_HISTORY = original_history
62
+
63
+ assert len(sessions) == 2
64
+ ids = {s.id for s in sessions}
65
+ assert ids == {"conv-a", "conv-b"}
66
+
67
+ conv_a = next(s for s in sessions if s.id == "conv-a")
68
+ assert conv_a.title == "First prompt"
69
+ assert conv_a.cwd == "/tmp/ws1"
70
+ assert conv_a.tool == "antigravity"
71
+ assert conv_a.created_at == _ms_to_dt(1700000000000)
72
+ assert conv_a.updated_at == _ms_to_dt(1700000100000)
73
+
74
+
75
+ def test_discover_skips_entries_without_conversation_id():
76
+ adapter = AntigravityAdapter()
77
+
78
+ with tempfile.TemporaryDirectory() as tmpdir:
79
+ history = Path(tmpdir) / ".gemini" / "antigravity-cli" / "history.jsonl"
80
+ history.parent.mkdir(parents=True)
81
+ entries = [
82
+ {"display": "orphan", "timestamp": 1700000000000, "workspace": "/tmp/ws"},
83
+ # No conversationId
84
+ ]
85
+ history.write_text("\n".join(json.dumps(e) for e in entries) + "\n")
86
+
87
+ import contextforge.adapters.antigravity as mod
88
+ original_history = mod._AGY_HISTORY
89
+ mod._AGY_HISTORY = history
90
+ try:
91
+ sessions = adapter.discover_sessions()
92
+ finally:
93
+ mod._AGY_HISTORY = original_history
94
+
95
+ assert sessions == []
96
+
97
+
98
+ def test_load_messages_json_sidecar():
99
+ adapter = AntigravityAdapter()
100
+
101
+ with tempfile.TemporaryDirectory() as tmpdir:
102
+ history = Path(tmpdir) / ".gemini" / "antigravity-cli" / "history.jsonl"
103
+ history.parent.mkdir(parents=True)
104
+ history.write_text(json.dumps({"display": "hi", "timestamp": 1700000000000, "conversationId": "c1"}) + "\n")
105
+
106
+ conversations = history.parent / "conversations"
107
+ conversations.mkdir()
108
+ json_path = conversations / "c1.json"
109
+ json_path.write_text(
110
+ json.dumps(
111
+ {
112
+ "messages": [
113
+ {"role": "user", "content": "hello", "timestamp": 1700000000000},
114
+ {"role": "assistant", "content": "world", "timestamp": 1700000010000},
115
+ ]
116
+ }
117
+ )
118
+ )
119
+
120
+ import contextforge.adapters.antigravity as mod
121
+ original_history = mod._AGY_HISTORY
122
+ original_conv = mod._AGY_CONVERSATIONS
123
+ mod._AGY_HISTORY = history
124
+ mod._AGY_CONVERSATIONS = conversations
125
+ try:
126
+ messages = adapter.load_messages("c1")
127
+ finally:
128
+ mod._AGY_HISTORY = original_history
129
+ mod._AGY_CONVERSATIONS = original_conv
130
+
131
+ assert len(messages) == 2
132
+ assert messages[0].role == "user"
133
+ assert messages[0].content == "hello"
134
+ assert messages[1].role == "assistant"
135
+ assert messages[1].content == "world"
136
+
137
+
138
+ def test_load_messages_fallback_to_history():
139
+ adapter = AntigravityAdapter()
140
+
141
+ with tempfile.TemporaryDirectory() as tmpdir:
142
+ history = Path(tmpdir) / ".gemini" / "antigravity-cli" / "history.jsonl"
143
+ history.parent.mkdir(parents=True)
144
+ entries = [
145
+ {
146
+ "display": "Prompt one",
147
+ "timestamp": 1700000000000,
148
+ "workspace": "/tmp/ws",
149
+ "conversationId": "c1",
150
+ },
151
+ {
152
+ "display": "Prompt two",
153
+ "timestamp": 1700000010000,
154
+ "workspace": "/tmp/ws",
155
+ "conversationId": "c1",
156
+ },
157
+ ]
158
+ history.write_text("\n".join(json.dumps(e) for e in entries) + "\n")
159
+
160
+ import contextforge.adapters.antigravity as mod
161
+ original_history = mod._AGY_HISTORY
162
+ mod._AGY_HISTORY = history
163
+ try:
164
+ messages = adapter.load_messages("c1")
165
+ finally:
166
+ mod._AGY_HISTORY = original_history
167
+
168
+ assert len(messages) == 2
169
+ assert messages[0].role == "user"
170
+ assert messages[0].content == "Prompt one"
171
+ assert messages[1].role == "user"
172
+ assert messages[1].content == "Prompt two"
173
+
174
+
175
+ def test_build_inject_command_new_session():
176
+ adapter = AntigravityAdapter()
177
+ cmd = adapter.build_inject_command("some context", method="system_prompt")
178
+ assert "agy" in cmd
179
+ assert "-i" in cmd
180
+ assert "some context" in cmd
181
+
182
+
183
+ def test_build_inject_command_resume():
184
+ adapter = AntigravityAdapter()
185
+ cmd = adapter.build_inject_command("ctx", target_session_id="abc-123", method="resume")
186
+ assert "agy" in cmd
187
+ assert "--conversation" in cmd
188
+ assert "abc-123" in cmd
189
+
190
+
191
+ def test_build_inject_command_with_cwd():
192
+ adapter = AntigravityAdapter()
193
+ cmd = adapter.build_inject_command("ctx", cwd="/tmp/proj")
194
+ assert cmd.startswith("cd /tmp/proj")
195
+ assert "agy" in cmd
@@ -206,7 +206,7 @@ wheels = [
206
206
 
207
207
  [[package]]
208
208
  name = "context-forge-cli"
209
- version = "0.2.5"
209
+ version = "0.4.10"
210
210
  source = { editable = "." }
211
211
  dependencies = [
212
212
  { name = "anthropic" },