code-puppy 0.0.214__py3-none-any.whl → 0.0.366__py3-none-any.whl

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 (231) hide show
  1. code_puppy/__init__.py +7 -1
  2. code_puppy/agents/__init__.py +2 -0
  3. code_puppy/agents/agent_c_reviewer.py +59 -6
  4. code_puppy/agents/agent_code_puppy.py +7 -1
  5. code_puppy/agents/agent_code_reviewer.py +12 -2
  6. code_puppy/agents/agent_cpp_reviewer.py +73 -6
  7. code_puppy/agents/agent_creator_agent.py +45 -4
  8. code_puppy/agents/agent_golang_reviewer.py +92 -3
  9. code_puppy/agents/agent_javascript_reviewer.py +101 -8
  10. code_puppy/agents/agent_manager.py +81 -4
  11. code_puppy/agents/agent_pack_leader.py +383 -0
  12. code_puppy/agents/agent_planning.py +163 -0
  13. code_puppy/agents/agent_python_programmer.py +165 -0
  14. code_puppy/agents/agent_python_reviewer.py +28 -6
  15. code_puppy/agents/agent_qa_expert.py +98 -6
  16. code_puppy/agents/agent_qa_kitten.py +12 -7
  17. code_puppy/agents/agent_security_auditor.py +113 -3
  18. code_puppy/agents/agent_terminal_qa.py +323 -0
  19. code_puppy/agents/agent_typescript_reviewer.py +106 -7
  20. code_puppy/agents/base_agent.py +802 -176
  21. code_puppy/agents/event_stream_handler.py +350 -0
  22. code_puppy/agents/pack/__init__.py +34 -0
  23. code_puppy/agents/pack/bloodhound.py +304 -0
  24. code_puppy/agents/pack/husky.py +321 -0
  25. code_puppy/agents/pack/retriever.py +393 -0
  26. code_puppy/agents/pack/shepherd.py +348 -0
  27. code_puppy/agents/pack/terrier.py +287 -0
  28. code_puppy/agents/pack/watchdog.py +367 -0
  29. code_puppy/agents/prompt_reviewer.py +145 -0
  30. code_puppy/agents/subagent_stream_handler.py +276 -0
  31. code_puppy/api/__init__.py +13 -0
  32. code_puppy/api/app.py +169 -0
  33. code_puppy/api/main.py +21 -0
  34. code_puppy/api/pty_manager.py +446 -0
  35. code_puppy/api/routers/__init__.py +12 -0
  36. code_puppy/api/routers/agents.py +36 -0
  37. code_puppy/api/routers/commands.py +217 -0
  38. code_puppy/api/routers/config.py +74 -0
  39. code_puppy/api/routers/sessions.py +232 -0
  40. code_puppy/api/templates/terminal.html +361 -0
  41. code_puppy/api/websocket.py +154 -0
  42. code_puppy/callbacks.py +142 -4
  43. code_puppy/chatgpt_codex_client.py +283 -0
  44. code_puppy/claude_cache_client.py +586 -0
  45. code_puppy/cli_runner.py +916 -0
  46. code_puppy/command_line/add_model_menu.py +1079 -0
  47. code_puppy/command_line/agent_menu.py +395 -0
  48. code_puppy/command_line/attachments.py +10 -5
  49. code_puppy/command_line/autosave_menu.py +605 -0
  50. code_puppy/command_line/clipboard.py +527 -0
  51. code_puppy/command_line/colors_menu.py +520 -0
  52. code_puppy/command_line/command_handler.py +176 -738
  53. code_puppy/command_line/command_registry.py +150 -0
  54. code_puppy/command_line/config_commands.py +715 -0
  55. code_puppy/command_line/core_commands.py +792 -0
  56. code_puppy/command_line/diff_menu.py +863 -0
  57. code_puppy/command_line/load_context_completion.py +15 -22
  58. code_puppy/command_line/mcp/base.py +0 -3
  59. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  60. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  61. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  62. code_puppy/command_line/mcp/edit_command.py +148 -0
  63. code_puppy/command_line/mcp/handler.py +9 -4
  64. code_puppy/command_line/mcp/help_command.py +6 -5
  65. code_puppy/command_line/mcp/install_command.py +15 -26
  66. code_puppy/command_line/mcp/install_menu.py +685 -0
  67. code_puppy/command_line/mcp/list_command.py +2 -2
  68. code_puppy/command_line/mcp/logs_command.py +174 -65
  69. code_puppy/command_line/mcp/remove_command.py +2 -2
  70. code_puppy/command_line/mcp/restart_command.py +12 -4
  71. code_puppy/command_line/mcp/search_command.py +16 -10
  72. code_puppy/command_line/mcp/start_all_command.py +18 -6
  73. code_puppy/command_line/mcp/start_command.py +47 -25
  74. code_puppy/command_line/mcp/status_command.py +4 -5
  75. code_puppy/command_line/mcp/stop_all_command.py +7 -1
  76. code_puppy/command_line/mcp/stop_command.py +8 -4
  77. code_puppy/command_line/mcp/test_command.py +2 -2
  78. code_puppy/command_line/mcp/wizard_utils.py +20 -16
  79. code_puppy/command_line/mcp_completion.py +174 -0
  80. code_puppy/command_line/model_picker_completion.py +75 -25
  81. code_puppy/command_line/model_settings_menu.py +884 -0
  82. code_puppy/command_line/motd.py +14 -8
  83. code_puppy/command_line/onboarding_slides.py +179 -0
  84. code_puppy/command_line/onboarding_wizard.py +340 -0
  85. code_puppy/command_line/pin_command_completion.py +329 -0
  86. code_puppy/command_line/prompt_toolkit_completion.py +463 -63
  87. code_puppy/command_line/session_commands.py +296 -0
  88. code_puppy/command_line/utils.py +54 -0
  89. code_puppy/config.py +898 -112
  90. code_puppy/error_logging.py +118 -0
  91. code_puppy/gemini_code_assist.py +385 -0
  92. code_puppy/gemini_model.py +602 -0
  93. code_puppy/http_utils.py +210 -148
  94. code_puppy/keymap.py +128 -0
  95. code_puppy/main.py +5 -698
  96. code_puppy/mcp_/__init__.py +17 -0
  97. code_puppy/mcp_/async_lifecycle.py +35 -4
  98. code_puppy/mcp_/blocking_startup.py +70 -43
  99. code_puppy/mcp_/captured_stdio_server.py +2 -2
  100. code_puppy/mcp_/config_wizard.py +4 -4
  101. code_puppy/mcp_/dashboard.py +15 -6
  102. code_puppy/mcp_/managed_server.py +65 -38
  103. code_puppy/mcp_/manager.py +146 -52
  104. code_puppy/mcp_/mcp_logs.py +224 -0
  105. code_puppy/mcp_/registry.py +6 -6
  106. code_puppy/mcp_/server_registry_catalog.py +24 -5
  107. code_puppy/messaging/__init__.py +199 -2
  108. code_puppy/messaging/bus.py +610 -0
  109. code_puppy/messaging/commands.py +167 -0
  110. code_puppy/messaging/markdown_patches.py +57 -0
  111. code_puppy/messaging/message_queue.py +17 -48
  112. code_puppy/messaging/messages.py +500 -0
  113. code_puppy/messaging/queue_console.py +1 -24
  114. code_puppy/messaging/renderers.py +43 -146
  115. code_puppy/messaging/rich_renderer.py +1027 -0
  116. code_puppy/messaging/spinner/__init__.py +21 -5
  117. code_puppy/messaging/spinner/console_spinner.py +86 -51
  118. code_puppy/messaging/subagent_console.py +461 -0
  119. code_puppy/model_factory.py +634 -83
  120. code_puppy/model_utils.py +167 -0
  121. code_puppy/models.json +66 -68
  122. code_puppy/models_dev_api.json +1 -0
  123. code_puppy/models_dev_parser.py +592 -0
  124. code_puppy/plugins/__init__.py +164 -10
  125. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  126. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  127. code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
  128. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  129. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  130. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  131. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  132. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  133. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  134. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  135. code_puppy/plugins/antigravity_oauth/transport.py +767 -0
  136. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  137. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  138. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  139. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  140. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
  141. code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
  142. code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
  143. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  144. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  145. code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
  146. code_puppy/plugins/claude_code_oauth/config.py +50 -0
  147. code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
  148. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  149. code_puppy/plugins/claude_code_oauth/utils.py +518 -0
  150. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  151. code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
  152. code_puppy/plugins/example_custom_command/README.md +280 -0
  153. code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
  154. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  155. code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
  156. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  157. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  158. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  159. code_puppy/plugins/oauth_puppy_html.py +228 -0
  160. code_puppy/plugins/shell_safety/__init__.py +6 -0
  161. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  162. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  163. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  164. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  165. code_puppy/prompts/codex_system_prompt.md +310 -0
  166. code_puppy/pydantic_patches.py +131 -0
  167. code_puppy/reopenable_async_client.py +8 -8
  168. code_puppy/round_robin_model.py +9 -12
  169. code_puppy/session_storage.py +2 -1
  170. code_puppy/status_display.py +21 -4
  171. code_puppy/summarization_agent.py +41 -13
  172. code_puppy/terminal_utils.py +418 -0
  173. code_puppy/tools/__init__.py +37 -1
  174. code_puppy/tools/agent_tools.py +536 -52
  175. code_puppy/tools/browser/__init__.py +37 -0
  176. code_puppy/tools/browser/browser_control.py +19 -23
  177. code_puppy/tools/browser/browser_interactions.py +41 -48
  178. code_puppy/tools/browser/browser_locators.py +36 -38
  179. code_puppy/tools/browser/browser_manager.py +316 -0
  180. code_puppy/tools/browser/browser_navigation.py +16 -16
  181. code_puppy/tools/browser/browser_screenshot.py +79 -143
  182. code_puppy/tools/browser/browser_scripts.py +32 -42
  183. code_puppy/tools/browser/browser_workflows.py +44 -27
  184. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  185. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  186. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  187. code_puppy/tools/browser/terminal_tools.py +525 -0
  188. code_puppy/tools/command_runner.py +930 -147
  189. code_puppy/tools/common.py +1113 -5
  190. code_puppy/tools/display.py +84 -0
  191. code_puppy/tools/file_modifications.py +288 -89
  192. code_puppy/tools/file_operations.py +226 -154
  193. code_puppy/tools/subagent_context.py +158 -0
  194. code_puppy/uvx_detection.py +242 -0
  195. code_puppy/version_checker.py +30 -11
  196. code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
  197. code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
  198. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/METADATA +149 -75
  199. code_puppy-0.0.366.dist-info/RECORD +217 -0
  200. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
  201. code_puppy/command_line/mcp/add_command.py +0 -183
  202. code_puppy/messaging/spinner/textual_spinner.py +0 -106
  203. code_puppy/tools/browser/camoufox_manager.py +0 -216
  204. code_puppy/tools/browser/vqa_agent.py +0 -70
  205. code_puppy/tui/__init__.py +0 -10
  206. code_puppy/tui/app.py +0 -1105
  207. code_puppy/tui/components/__init__.py +0 -21
  208. code_puppy/tui/components/chat_view.py +0 -551
  209. code_puppy/tui/components/command_history_modal.py +0 -218
  210. code_puppy/tui/components/copy_button.py +0 -139
  211. code_puppy/tui/components/custom_widgets.py +0 -63
  212. code_puppy/tui/components/human_input_modal.py +0 -175
  213. code_puppy/tui/components/input_area.py +0 -167
  214. code_puppy/tui/components/sidebar.py +0 -309
  215. code_puppy/tui/components/status_bar.py +0 -185
  216. code_puppy/tui/messages.py +0 -27
  217. code_puppy/tui/models/__init__.py +0 -8
  218. code_puppy/tui/models/chat_message.py +0 -25
  219. code_puppy/tui/models/command_history.py +0 -89
  220. code_puppy/tui/models/enums.py +0 -24
  221. code_puppy/tui/screens/__init__.py +0 -17
  222. code_puppy/tui/screens/autosave_picker.py +0 -175
  223. code_puppy/tui/screens/help.py +0 -130
  224. code_puppy/tui/screens/mcp_install_wizard.py +0 -803
  225. code_puppy/tui/screens/settings.py +0 -306
  226. code_puppy/tui/screens/tools.py +0 -74
  227. code_puppy/tui_state.py +0 -55
  228. code_puppy-0.0.214.data/data/code_puppy/models.json +0 -112
  229. code_puppy-0.0.214.dist-info/RECORD +0 -131
  230. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +0 -0
  231. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,406 @@
1
+ """Antigravity OAuth Plugin callbacks for Code Puppy CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ import time
8
+ from http.server import BaseHTTPRequestHandler, HTTPServer
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+ from urllib.parse import parse_qs, urlparse
11
+
12
+ from code_puppy.callbacks import register_callback
13
+ from code_puppy.config import set_model_name
14
+ from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning
15
+
16
+ from ..oauth_puppy_html import oauth_failure_html, oauth_success_html
17
+ from .accounts import AccountManager
18
+ from .config import (
19
+ ANTIGRAVITY_OAUTH_CONFIG,
20
+ get_accounts_storage_path,
21
+ get_token_storage_path,
22
+ )
23
+ from .constants import ANTIGRAVITY_MODELS
24
+ from .oauth import (
25
+ TokenExchangeSuccess,
26
+ assign_redirect_uri,
27
+ build_authorization_url,
28
+ exchange_code_for_tokens,
29
+ fetch_antigravity_status,
30
+ prepare_oauth_context,
31
+ )
32
+ from .storage import clear_accounts
33
+ from .utils import (
34
+ add_models_to_config,
35
+ load_antigravity_models,
36
+ load_stored_tokens,
37
+ reload_current_agent,
38
+ remove_antigravity_models,
39
+ save_tokens,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ class _OAuthResult:
46
+ def __init__(self) -> None:
47
+ self.code: Optional[str] = None
48
+ self.state: Optional[str] = None
49
+ self.error: Optional[str] = None
50
+
51
+
52
+ class _CallbackHandler(BaseHTTPRequestHandler):
53
+ result: _OAuthResult
54
+ received_event: threading.Event
55
+ redirect_uri: str
56
+
57
+ def do_GET(self) -> None: # noqa: N802
58
+ logger.info("Callback received: path=%s", self.path)
59
+ parsed = urlparse(self.path)
60
+ params: Dict[str, List[str]] = parse_qs(parsed.query)
61
+
62
+ code = params.get("code", [None])[0]
63
+ state = params.get("state", [None])[0]
64
+
65
+ if code and state:
66
+ self.result.code = code
67
+ self.result.state = state
68
+ success_html = oauth_success_html(
69
+ "Antigravity",
70
+ "You're connected to Antigravity! 🚀 Gemini & Claude models are now available.",
71
+ )
72
+ self._write_response(200, success_html)
73
+ else:
74
+ self.result.error = "Missing code or state"
75
+ failure_html = oauth_failure_html(
76
+ "Antigravity",
77
+ "Missing code or state parameter 🥺",
78
+ )
79
+ self._write_response(400, failure_html)
80
+
81
+ self.received_event.set()
82
+
83
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002
84
+ return
85
+
86
+ def _write_response(self, status: int, body: str) -> None:
87
+ self.send_response(status)
88
+ self.send_header("Content-Type", "text/html; charset=utf-8")
89
+ self.end_headers()
90
+ self.wfile.write(body.encode("utf-8"))
91
+
92
+
93
+ def _start_callback_server(
94
+ context: Any,
95
+ ) -> Optional[Tuple[HTTPServer, _OAuthResult, threading.Event, str]]:
96
+ """Start local HTTP server for OAuth callback."""
97
+ port_range = ANTIGRAVITY_OAUTH_CONFIG["callback_port_range"]
98
+
99
+ for port in range(port_range[0], port_range[1] + 1):
100
+ try:
101
+ server = HTTPServer(("localhost", port), _CallbackHandler)
102
+ redirect_uri = assign_redirect_uri(context, port)
103
+ result = _OAuthResult()
104
+ event = threading.Event()
105
+ _CallbackHandler.result = result
106
+ _CallbackHandler.received_event = event
107
+ _CallbackHandler.redirect_uri = redirect_uri
108
+
109
+ def run_server() -> None:
110
+ with server:
111
+ server.serve_forever()
112
+
113
+ threading.Thread(target=run_server, daemon=True).start()
114
+ return server, result, event, redirect_uri
115
+ except OSError:
116
+ continue
117
+
118
+ emit_error("Could not start OAuth callback server; all candidate ports are in use")
119
+ return None
120
+
121
+
122
+ def _await_callback(context: Any) -> Optional[Tuple[str, str, str]]:
123
+ """Wait for OAuth callback and return (code, state, redirect_uri)."""
124
+ timeout = ANTIGRAVITY_OAUTH_CONFIG["callback_timeout"]
125
+
126
+ started = _start_callback_server(context)
127
+ if not started:
128
+ return None
129
+
130
+ server, result, event, redirect_uri = started
131
+
132
+ auth_url = build_authorization_url(context)
133
+
134
+ try:
135
+ import webbrowser
136
+
137
+ from code_puppy.tools.common import should_suppress_browser
138
+
139
+ if should_suppress_browser():
140
+ emit_info(
141
+ "[HEADLESS MODE] Would normally open browser for Antigravity OAuth…"
142
+ )
143
+ emit_info(f"In normal mode, would visit: {auth_url}")
144
+ else:
145
+ emit_info("🌐 Opening browser for Google OAuth…")
146
+ webbrowser.open(auth_url)
147
+ emit_info(f"If it doesn't open automatically, visit:\n{auth_url}")
148
+ except Exception as exc:
149
+ emit_warning(f"Failed to open browser: {exc}")
150
+ emit_info(f"Please open manually: {auth_url}")
151
+
152
+ emit_info(f"⏳ Waiting for callback on {redirect_uri}")
153
+
154
+ if not event.wait(timeout=timeout):
155
+ emit_error("OAuth callback timed out. Please try again.")
156
+ server.shutdown()
157
+ return None
158
+
159
+ server.shutdown()
160
+
161
+ if result.error:
162
+ emit_error(f"OAuth callback error: {result.error}")
163
+ return None
164
+
165
+ return result.code, result.state, redirect_uri
166
+
167
+
168
+ def _perform_authentication(add_account: bool = False) -> bool:
169
+ """Run the OAuth authentication flow."""
170
+ context = prepare_oauth_context()
171
+ callback_result = _await_callback(context)
172
+
173
+ if not callback_result:
174
+ return False
175
+
176
+ code, state, redirect_uri = callback_result
177
+
178
+ emit_info("🔄 Exchanging authorization code for tokens…")
179
+ result = exchange_code_for_tokens(code, state, redirect_uri)
180
+
181
+ if not isinstance(result, TokenExchangeSuccess):
182
+ emit_error(f"Token exchange failed: {result.error}")
183
+ return False
184
+
185
+ # Save tokens
186
+ tokens = {
187
+ "access_token": result.access_token,
188
+ "refresh_token": result.refresh_token,
189
+ "expires_at": result.expires_at,
190
+ "email": result.email,
191
+ "project_id": result.project_id,
192
+ }
193
+
194
+ if not save_tokens(tokens):
195
+ emit_error("Failed to save tokens locally. Check file permissions.")
196
+ return False
197
+
198
+ # Handle multi-account
199
+ manager = AccountManager.load_from_disk(result.refresh_token)
200
+
201
+ if add_account or manager.account_count == 0:
202
+ manager.add_account(
203
+ refresh_token=result.refresh_token,
204
+ email=result.email,
205
+ project_id=result.project_id,
206
+ )
207
+ manager.save_to_disk()
208
+
209
+ if add_account:
210
+ emit_success(f"✅ Added account: {result.email or 'Unknown'}")
211
+ emit_info(f"📊 Total accounts: {manager.account_count}")
212
+
213
+ if result.email:
214
+ emit_success(f"🎉 Authenticated as {result.email}!")
215
+ else:
216
+ emit_success("🎉 Antigravity OAuth authentication successful!")
217
+
218
+ # Add models
219
+ emit_info("📦 Configuring available models…")
220
+ if add_models_to_config(result.access_token, result.project_id):
221
+ model_count = len(ANTIGRAVITY_MODELS)
222
+ emit_success(f"✅ {model_count} Antigravity models configured!")
223
+ emit_info(
224
+ " Use the `antigravity-` prefix (e.g., antigravity-gemini-3-pro-high)"
225
+ )
226
+ else:
227
+ emit_warning("Failed to configure models. Try running /antigravity-auth again.")
228
+
229
+ # Reload agent
230
+ reload_current_agent()
231
+ return True
232
+
233
+
234
+ def _custom_help() -> List[Tuple[str, str]]:
235
+ """Return help entries for Antigravity commands."""
236
+ return [
237
+ (
238
+ "antigravity-auth",
239
+ "Authenticate with Google/Antigravity for Gemini & Claude models",
240
+ ),
241
+ (
242
+ "antigravity-add",
243
+ "Add another Google account for load balancing",
244
+ ),
245
+ (
246
+ "antigravity-status",
247
+ "Check authentication status and account pool",
248
+ ),
249
+ (
250
+ "antigravity-logout",
251
+ "Remove all Antigravity OAuth tokens and models",
252
+ ),
253
+ ]
254
+
255
+
256
+ def _handle_status() -> None:
257
+ """Handle /antigravity-status command."""
258
+ tokens = load_stored_tokens()
259
+
260
+ if not tokens or not tokens.get("access_token"):
261
+ emit_warning("🔓 Antigravity: Not authenticated")
262
+ emit_info("Run /antigravity-auth to sign in with Google")
263
+ return
264
+
265
+ emit_success("🔐 Antigravity: Authenticated")
266
+
267
+ # Show email if available
268
+ if tokens.get("email"):
269
+ emit_info(f" Primary account: {tokens['email']}")
270
+
271
+ # Show token expiry
272
+ expires_at = tokens.get("expires_at")
273
+ if expires_at:
274
+ remaining = max(0, int(expires_at - time.time()))
275
+ hours, remainder = divmod(remaining, 3600)
276
+ minutes = remainder // 60
277
+ emit_info(f" Token expires in: ~{hours}h {minutes}m")
278
+
279
+ # Fetch tier/quota status from API
280
+ emit_info("\n📊 Fetching tier status...")
281
+ status = fetch_antigravity_status(tokens.get("access_token", ""))
282
+
283
+ if status.error:
284
+ emit_warning(f" Could not fetch status: {status.error}")
285
+ else:
286
+ # Show tier info
287
+ tier_display = {
288
+ "free-tier": "Free Tier (limited)",
289
+ "standard-tier": "Standard Tier (full access)",
290
+ }
291
+ current = tier_display.get(
292
+ status.current_tier, status.current_tier or "Unknown"
293
+ )
294
+ emit_info(f" Current tier: {current}")
295
+
296
+ if status.project_id:
297
+ emit_info(f" Project ID: {status.project_id}")
298
+
299
+ if status.allowed_tiers:
300
+ available = ", ".join(status.allowed_tiers)
301
+ emit_info(f" Available tiers: {available}")
302
+
303
+ # Show account pool
304
+ manager = AccountManager.load_from_disk()
305
+ if manager.account_count > 1:
306
+ emit_info(f"\n📊 Account Pool: {manager.account_count} accounts")
307
+ for acc in manager.get_accounts_snapshot():
308
+ email_str = acc.email or "Unknown"
309
+ limits = []
310
+ if acc.rate_limit_reset_times:
311
+ for key, reset_time in acc.rate_limit_reset_times.items():
312
+ if reset_time > time.time() * 1000:
313
+ wait_sec = int((reset_time - time.time() * 1000) / 1000)
314
+ limits.append(f"{key}: {wait_sec}s")
315
+
316
+ status = f" • {email_str}"
317
+ if limits:
318
+ status += f" (rate-limited: {', '.join(limits)})"
319
+ emit_info(status)
320
+
321
+ # Show configured models
322
+ models = load_antigravity_models()
323
+ antigravity_models = [
324
+ name
325
+ for name, cfg in models.items()
326
+ if cfg.get("oauth_source") == "antigravity-plugin"
327
+ ]
328
+
329
+ if antigravity_models:
330
+ emit_info(f"\n🎯 Configured models: {len(antigravity_models)}")
331
+ # Group by family
332
+ gemini = [m for m in antigravity_models if "gemini" in m]
333
+ claude = [m for m in antigravity_models if "claude" in m]
334
+ other = [m for m in antigravity_models if m not in gemini and m not in claude]
335
+
336
+ if gemini:
337
+ emit_info(f" Gemini: {', '.join(sorted(gemini))}")
338
+ if claude:
339
+ emit_info(f" Claude: {', '.join(sorted(claude))}")
340
+ if other:
341
+ emit_info(f" Other: {', '.join(sorted(other))}")
342
+ else:
343
+ emit_warning("No Antigravity models configured")
344
+
345
+
346
+ def _handle_logout() -> None:
347
+ """Handle /antigravity-logout command."""
348
+ # Remove tokens
349
+ token_path = get_token_storage_path()
350
+ if token_path.exists():
351
+ token_path.unlink()
352
+ emit_info("✓ Removed OAuth tokens")
353
+
354
+ # Remove accounts
355
+ accounts_path = get_accounts_storage_path()
356
+ if accounts_path.exists():
357
+ clear_accounts()
358
+ emit_info("✓ Removed account pool")
359
+
360
+ # Remove models
361
+ removed = remove_antigravity_models()
362
+ if removed:
363
+ emit_info(f"✓ Removed {removed} Antigravity models")
364
+
365
+ emit_success("👋 Antigravity logout complete")
366
+
367
+
368
+ def _handle_custom_command(command: str, name: str) -> Optional[bool]:
369
+ """Handle Antigravity custom commands."""
370
+ if not name:
371
+ return None
372
+
373
+ if name == "antigravity-auth":
374
+ emit_info("🚀 Starting Antigravity OAuth authentication…")
375
+ tokens = load_stored_tokens()
376
+ if tokens and tokens.get("access_token"):
377
+ emit_warning(
378
+ "Existing tokens found. This will refresh your authentication."
379
+ )
380
+
381
+ if _perform_authentication():
382
+ # Set a default model
383
+ set_model_name("antigravity-gemini-3-pro-high")
384
+ return True
385
+
386
+ if name == "antigravity-add":
387
+ emit_info("➕ Adding another Google account…")
388
+ manager = AccountManager.load_from_disk()
389
+ emit_info(f"Current accounts: {manager.account_count}")
390
+ _perform_authentication(add_account=True)
391
+ return True
392
+
393
+ if name == "antigravity-status":
394
+ _handle_status()
395
+ return True
396
+
397
+ if name == "antigravity-logout":
398
+ _handle_logout()
399
+ return True
400
+
401
+ return None
402
+
403
+
404
+ # Register callbacks
405
+ register_callback("custom_command_help", _custom_help)
406
+ register_callback("custom_command", _handle_custom_command)
@@ -0,0 +1,271 @@
1
+ """Account storage for multi-account Antigravity OAuth."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Dict, List, Literal, Optional
10
+
11
+ from .config import get_accounts_storage_path
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ ModelFamily = Literal["claude", "gemini"]
16
+ HeaderStyle = Literal["antigravity", "gemini-cli"]
17
+ QuotaKey = Literal["claude", "gemini-antigravity", "gemini-cli"]
18
+
19
+
20
+ @dataclass
21
+ class RateLimitState:
22
+ """Rate limit reset times per quota key."""
23
+
24
+ claude: Optional[float] = None
25
+ gemini_antigravity: Optional[float] = None
26
+ gemini_cli: Optional[float] = None
27
+
28
+ def to_dict(self) -> Dict[str, float]:
29
+ """Convert to dictionary for JSON serialization."""
30
+ result: Dict[str, float] = {}
31
+ if self.claude is not None:
32
+ result["claude"] = self.claude
33
+ if self.gemini_antigravity is not None:
34
+ result["gemini-antigravity"] = self.gemini_antigravity
35
+ if self.gemini_cli is not None:
36
+ result["gemini-cli"] = self.gemini_cli
37
+ return result
38
+
39
+ @classmethod
40
+ def from_dict(cls, data: Optional[Dict[str, Any]]) -> "RateLimitState":
41
+ """Create from dictionary."""
42
+ if not data:
43
+ return cls()
44
+ return cls(
45
+ claude=data.get("claude"),
46
+ gemini_antigravity=data.get("gemini-antigravity"),
47
+ gemini_cli=data.get("gemini-cli"),
48
+ )
49
+
50
+
51
+ @dataclass
52
+ class AccountMetadata:
53
+ """Stored metadata for a single account."""
54
+
55
+ refresh_token: str
56
+ email: Optional[str] = None
57
+ project_id: Optional[str] = None
58
+ managed_project_id: Optional[str] = None
59
+ added_at: float = 0
60
+ last_used: float = 0
61
+ last_switch_reason: Optional[Literal["rate-limit", "initial", "rotation"]] = None
62
+ rate_limit_reset_times: RateLimitState = field(default_factory=RateLimitState)
63
+
64
+ def to_dict(self) -> Dict[str, Any]:
65
+ """Convert to dictionary for JSON serialization."""
66
+ result: Dict[str, Any] = {
67
+ "refreshToken": self.refresh_token,
68
+ "addedAt": self.added_at,
69
+ "lastUsed": self.last_used,
70
+ }
71
+ if self.email:
72
+ result["email"] = self.email
73
+ if self.project_id:
74
+ result["projectId"] = self.project_id
75
+ if self.managed_project_id:
76
+ result["managedProjectId"] = self.managed_project_id
77
+ if self.last_switch_reason:
78
+ result["lastSwitchReason"] = self.last_switch_reason
79
+
80
+ rate_limits = self.rate_limit_reset_times.to_dict()
81
+ if rate_limits:
82
+ result["rateLimitResetTimes"] = rate_limits
83
+
84
+ return result
85
+
86
+ @classmethod
87
+ def from_dict(cls, data: Dict[str, Any]) -> "AccountMetadata":
88
+ """Create from dictionary."""
89
+ return cls(
90
+ refresh_token=data.get("refreshToken", ""),
91
+ email=data.get("email"),
92
+ project_id=data.get("projectId"),
93
+ managed_project_id=data.get("managedProjectId"),
94
+ added_at=data.get("addedAt", 0),
95
+ last_used=data.get("lastUsed", 0),
96
+ last_switch_reason=data.get("lastSwitchReason"),
97
+ rate_limit_reset_times=RateLimitState.from_dict(
98
+ data.get("rateLimitResetTimes")
99
+ ),
100
+ )
101
+
102
+
103
+ @dataclass
104
+ class AccountStorage:
105
+ """V3 account storage format."""
106
+
107
+ version: int = 3
108
+ accounts: List[AccountMetadata] = field(default_factory=list)
109
+ active_index: int = 0
110
+ active_index_by_family: Dict[str, int] = field(default_factory=dict)
111
+
112
+ def to_dict(self) -> Dict[str, Any]:
113
+ """Convert to dictionary for JSON serialization."""
114
+ return {
115
+ "version": self.version,
116
+ "accounts": [acc.to_dict() for acc in self.accounts],
117
+ "activeIndex": self.active_index,
118
+ "activeIndexByFamily": self.active_index_by_family,
119
+ }
120
+
121
+ @classmethod
122
+ def from_dict(cls, data: Dict[str, Any]) -> "AccountStorage":
123
+ """Create from dictionary."""
124
+ accounts = [AccountMetadata.from_dict(acc) for acc in data.get("accounts", [])]
125
+ return cls(
126
+ version=data.get("version", 3),
127
+ accounts=accounts,
128
+ active_index=data.get("activeIndex", 0),
129
+ active_index_by_family=data.get("activeIndexByFamily", {}),
130
+ )
131
+
132
+
133
+ def _migrate_v1_to_v2(data: Dict[str, Any]) -> Dict[str, Any]:
134
+ """Migrate V1 storage format to V2."""
135
+ now = time.time() * 1000 # V1 used milliseconds
136
+
137
+ accounts = []
138
+ for acc in data.get("accounts", []):
139
+ rate_limits: Dict[str, float] = {}
140
+ if acc.get("isRateLimited") and acc.get("rateLimitResetTime"):
141
+ reset_time = acc["rateLimitResetTime"]
142
+ if reset_time > now:
143
+ rate_limits["claude"] = reset_time
144
+ rate_limits["gemini"] = reset_time
145
+
146
+ accounts.append(
147
+ {
148
+ "email": acc.get("email"),
149
+ "refreshToken": acc.get("refreshToken", ""),
150
+ "projectId": acc.get("projectId"),
151
+ "managedProjectId": acc.get("managedProjectId"),
152
+ "addedAt": acc.get("addedAt", now),
153
+ "lastUsed": acc.get("lastUsed", 0),
154
+ "lastSwitchReason": acc.get("lastSwitchReason"),
155
+ "rateLimitResetTimes": rate_limits if rate_limits else None,
156
+ }
157
+ )
158
+
159
+ return {
160
+ "version": 2,
161
+ "accounts": accounts,
162
+ "activeIndex": data.get("activeIndex", 0),
163
+ }
164
+
165
+
166
+ def _migrate_v2_to_v3(data: Dict[str, Any]) -> Dict[str, Any]:
167
+ """Migrate V2 storage format to V3."""
168
+ now = time.time() * 1000
169
+
170
+ accounts = []
171
+ for acc in data.get("accounts", []):
172
+ rate_limits: Dict[str, float] = {}
173
+ old_limits = acc.get("rateLimitResetTimes", {}) or {}
174
+
175
+ if old_limits.get("claude") and old_limits["claude"] > now:
176
+ rate_limits["claude"] = old_limits["claude"]
177
+ if old_limits.get("gemini") and old_limits["gemini"] > now:
178
+ rate_limits["gemini-antigravity"] = old_limits["gemini"]
179
+
180
+ accounts.append(
181
+ {
182
+ "email": acc.get("email"),
183
+ "refreshToken": acc.get("refreshToken", ""),
184
+ "projectId": acc.get("projectId"),
185
+ "managedProjectId": acc.get("managedProjectId"),
186
+ "addedAt": acc.get("addedAt", 0),
187
+ "lastUsed": acc.get("lastUsed", 0),
188
+ "lastSwitchReason": acc.get("lastSwitchReason"),
189
+ "rateLimitResetTimes": rate_limits if rate_limits else None,
190
+ }
191
+ )
192
+
193
+ return {
194
+ "version": 3,
195
+ "accounts": accounts,
196
+ "activeIndex": data.get("activeIndex", 0),
197
+ "activeIndexByFamily": {},
198
+ }
199
+
200
+
201
+ def load_accounts() -> Optional[AccountStorage]:
202
+ """Load account storage from disk with automatic migration."""
203
+ path = get_accounts_storage_path()
204
+
205
+ try:
206
+ if not path.exists():
207
+ return None
208
+
209
+ content = path.read_text(encoding="utf-8")
210
+ data = json.loads(content)
211
+
212
+ if not isinstance(data.get("accounts"), list):
213
+ logger.warning("Invalid storage format, ignoring")
214
+ return None
215
+
216
+ version = data.get("version", 1)
217
+
218
+ # Migrate if needed
219
+ if version == 1:
220
+ logger.info("Migrating account storage from v1 to v3")
221
+ data = _migrate_v1_to_v2(data)
222
+ data = _migrate_v2_to_v3(data)
223
+ elif version == 2:
224
+ logger.info("Migrating account storage from v2 to v3")
225
+ data = _migrate_v2_to_v3(data)
226
+
227
+ storage = AccountStorage.from_dict(data)
228
+
229
+ # Validate active index
230
+ if storage.accounts:
231
+ storage.active_index = max(
232
+ 0, min(storage.active_index, len(storage.accounts) - 1)
233
+ )
234
+ else:
235
+ storage.active_index = 0
236
+
237
+ # Save migrated data if we migrated
238
+ if version < 3:
239
+ try:
240
+ save_accounts(storage)
241
+ logger.info("Migration to v3 complete")
242
+ except Exception as e:
243
+ logger.warning("Failed to persist migrated storage: %s", e)
244
+
245
+ return storage
246
+
247
+ except FileNotFoundError:
248
+ return None
249
+ except Exception as e:
250
+ logger.error("Failed to load account storage: %s", e)
251
+ return None
252
+
253
+
254
+ def save_accounts(storage: AccountStorage) -> None:
255
+ """Save account storage to disk."""
256
+ path = get_accounts_storage_path()
257
+ path.parent.mkdir(parents=True, exist_ok=True)
258
+
259
+ content = json.dumps(storage.to_dict(), indent=2)
260
+ path.write_text(content, encoding="utf-8")
261
+ path.chmod(0o600)
262
+
263
+
264
+ def clear_accounts() -> None:
265
+ """Clear all stored accounts."""
266
+ path = get_accounts_storage_path()
267
+ try:
268
+ if path.exists():
269
+ path.unlink()
270
+ except Exception as e:
271
+ logger.error("Failed to clear account storage: %s", e)