code-puppy 0.0.325__py3-none-any.whl → 0.0.341__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 (52) hide show
  1. code_puppy/agents/base_agent.py +110 -124
  2. code_puppy/claude_cache_client.py +208 -2
  3. code_puppy/cli_runner.py +152 -32
  4. code_puppy/command_line/add_model_menu.py +4 -0
  5. code_puppy/command_line/autosave_menu.py +23 -24
  6. code_puppy/command_line/clipboard.py +527 -0
  7. code_puppy/command_line/colors_menu.py +5 -0
  8. code_puppy/command_line/config_commands.py +24 -1
  9. code_puppy/command_line/core_commands.py +85 -0
  10. code_puppy/command_line/diff_menu.py +5 -0
  11. code_puppy/command_line/mcp/custom_server_form.py +4 -0
  12. code_puppy/command_line/mcp/install_menu.py +5 -1
  13. code_puppy/command_line/model_settings_menu.py +5 -0
  14. code_puppy/command_line/motd.py +13 -7
  15. code_puppy/command_line/onboarding_slides.py +180 -0
  16. code_puppy/command_line/onboarding_wizard.py +340 -0
  17. code_puppy/command_line/prompt_toolkit_completion.py +118 -0
  18. code_puppy/config.py +3 -2
  19. code_puppy/http_utils.py +201 -279
  20. code_puppy/keymap.py +10 -8
  21. code_puppy/mcp_/managed_server.py +7 -11
  22. code_puppy/messaging/messages.py +3 -0
  23. code_puppy/messaging/rich_renderer.py +114 -22
  24. code_puppy/model_factory.py +102 -15
  25. code_puppy/models.json +2 -2
  26. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  27. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  28. code_puppy/plugins/antigravity_oauth/antigravity_model.py +668 -0
  29. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  30. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  31. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  32. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  33. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  34. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  35. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  36. code_puppy/plugins/antigravity_oauth/transport.py +664 -0
  37. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  38. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -0
  39. code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -0
  40. code_puppy/plugins/claude_code_oauth/utils.py +126 -7
  41. code_puppy/reopenable_async_client.py +8 -8
  42. code_puppy/terminal_utils.py +295 -3
  43. code_puppy/tools/command_runner.py +43 -54
  44. code_puppy/tools/common.py +3 -9
  45. code_puppy/uvx_detection.py +242 -0
  46. {code_puppy-0.0.325.data → code_puppy-0.0.341.data}/data/code_puppy/models.json +2 -2
  47. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/METADATA +26 -49
  48. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/RECORD +52 -36
  49. {code_puppy-0.0.325.data → code_puppy-0.0.341.data}/data/code_puppy/models_dev_api.json +0 -0
  50. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/WHEEL +0 -0
  51. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/entry_points.txt +0 -0
  52. {code_puppy-0.0.325.dist-info → code_puppy-0.0.341.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,169 @@
1
+ """Utility helpers for the Antigravity OAuth plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from .config import (
10
+ ANTIGRAVITY_OAUTH_CONFIG,
11
+ get_antigravity_models_path,
12
+ get_token_storage_path,
13
+ )
14
+ from .constants import ANTIGRAVITY_ENDPOINT, ANTIGRAVITY_HEADERS, ANTIGRAVITY_MODELS
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def load_stored_tokens() -> Optional[Dict[str, Any]]:
20
+ """Load stored OAuth tokens from disk."""
21
+ try:
22
+ token_path = get_token_storage_path()
23
+ if token_path.exists():
24
+ with open(token_path, "r", encoding="utf-8") as f:
25
+ return json.load(f)
26
+ except Exception as e:
27
+ logger.error("Failed to load tokens: %s", e)
28
+ return None
29
+
30
+
31
+ def save_tokens(tokens: Dict[str, Any]) -> bool:
32
+ """Save OAuth tokens to disk."""
33
+ try:
34
+ token_path = get_token_storage_path()
35
+ with open(token_path, "w", encoding="utf-8") as f:
36
+ json.dump(tokens, f, indent=2)
37
+ token_path.chmod(0o600)
38
+ return True
39
+ except Exception as e:
40
+ logger.error("Failed to save tokens: %s", e)
41
+ return False
42
+
43
+
44
+ def load_antigravity_models() -> Dict[str, Any]:
45
+ """Load configured Antigravity models from disk."""
46
+ try:
47
+ models_path = get_antigravity_models_path()
48
+ if models_path.exists():
49
+ with open(models_path, "r", encoding="utf-8") as f:
50
+ return json.load(f)
51
+ except Exception as e:
52
+ logger.error("Failed to load Antigravity models: %s", e)
53
+ return {}
54
+
55
+
56
+ def save_antigravity_models(models: Dict[str, Any]) -> bool:
57
+ """Save Antigravity models configuration to disk."""
58
+ try:
59
+ models_path = get_antigravity_models_path()
60
+ with open(models_path, "w", encoding="utf-8") as f:
61
+ json.dump(models, f, indent=2)
62
+ return True
63
+ except Exception as e:
64
+ logger.error("Failed to save Antigravity models: %s", e)
65
+ return False
66
+
67
+
68
+ def add_models_to_config(access_token: str, project_id: str = "") -> bool:
69
+ """Add all available Antigravity models to the configuration."""
70
+ try:
71
+ models_config: Dict[str, Any] = {}
72
+ prefix = ANTIGRAVITY_OAUTH_CONFIG["prefix"]
73
+
74
+ for model_id, model_info in ANTIGRAVITY_MODELS.items():
75
+ prefixed_name = f"{prefix}{model_id}"
76
+
77
+ # Build custom headers
78
+ headers = dict(ANTIGRAVITY_HEADERS)
79
+
80
+ # Use custom_gemini type with Antigravity transport
81
+ models_config[prefixed_name] = {
82
+ "type": "custom_gemini",
83
+ "name": model_id,
84
+ "custom_endpoint": {
85
+ "url": ANTIGRAVITY_ENDPOINT,
86
+ "api_key": access_token,
87
+ "headers": headers,
88
+ },
89
+ "project_id": project_id,
90
+ "context_length": model_info.get("context_length", 200000),
91
+ "family": model_info.get("family", "other"),
92
+ "oauth_source": "antigravity-plugin",
93
+ "antigravity": True, # Flag to use Antigravity transport
94
+ }
95
+
96
+ # Add thinking budget if present
97
+ if model_info.get("thinking_budget"):
98
+ models_config[prefixed_name]["thinking_budget"] = model_info[
99
+ "thinking_budget"
100
+ ]
101
+
102
+ if save_antigravity_models(models_config):
103
+ logger.info("Added %d Antigravity models", len(models_config))
104
+ return True
105
+
106
+ except Exception as e:
107
+ logger.error("Error adding models to config: %s", e)
108
+ return False
109
+
110
+
111
+ def remove_antigravity_models() -> int:
112
+ """Remove all Antigravity models from configuration."""
113
+ try:
114
+ models = load_antigravity_models()
115
+ to_remove = [
116
+ name
117
+ for name, config in models.items()
118
+ if config.get("oauth_source") == "antigravity-plugin"
119
+ ]
120
+
121
+ if not to_remove:
122
+ return 0
123
+
124
+ for model_name in to_remove:
125
+ models.pop(model_name, None)
126
+
127
+ if save_antigravity_models(models):
128
+ return len(to_remove)
129
+ except Exception as e:
130
+ logger.error("Error removing Antigravity models: %s", e)
131
+ return 0
132
+
133
+
134
+ def get_model_families_summary() -> Dict[str, List[str]]:
135
+ """Get a summary of available models by family."""
136
+ families: Dict[str, List[str]] = {
137
+ "gemini": [],
138
+ "claude": [],
139
+ "other": [],
140
+ }
141
+
142
+ for model_id, info in ANTIGRAVITY_MODELS.items():
143
+ family = info.get("family", "other")
144
+ if family in families:
145
+ families[family].append(model_id)
146
+
147
+ return families
148
+
149
+
150
+ def reload_current_agent() -> None:
151
+ """Reload the current agent so new auth tokens are picked up immediately."""
152
+ try:
153
+ from code_puppy.agents import get_current_agent
154
+
155
+ current_agent = get_current_agent()
156
+ if current_agent is None:
157
+ logger.debug("No current agent to reload")
158
+ return
159
+
160
+ if hasattr(current_agent, "refresh_config"):
161
+ try:
162
+ current_agent.refresh_config()
163
+ except Exception:
164
+ pass
165
+
166
+ current_agent.reload_code_generation_agent()
167
+ logger.info("Active agent reloaded with new authentication")
168
+ except Exception as e:
169
+ logger.warning("Agent reload failed: %s", e)
@@ -6,6 +6,7 @@ import os
6
6
  from typing import List, Optional, Tuple
7
7
 
8
8
  from code_puppy.callbacks import register_callback
9
+ from code_puppy.config import set_model_name
9
10
  from code_puppy.messaging import emit_info, emit_success, emit_warning
10
11
 
11
12
  from .config import CHATGPT_OAUTH_CONFIG, get_token_storage_path
@@ -75,6 +76,7 @@ def _handle_custom_command(command: str, name: str) -> Optional[bool]:
75
76
 
76
77
  if name == "chatgpt-auth":
77
78
  run_oauth_flow()
79
+ set_model_name("chatgpt-gpt-5.2-codex")
78
80
  return True
79
81
 
80
82
  if name == "chatgpt-status":
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional, Tuple
12
12
  from urllib.parse import parse_qs, urlparse
13
13
 
14
14
  from code_puppy.callbacks import register_callback
15
+ from code_puppy.config import set_model_name
15
16
  from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning
16
17
 
17
18
  from ..oauth_puppy_html import oauth_failure_html, oauth_success_html
@@ -260,6 +261,7 @@ def _handle_custom_command(command: str, name: str) -> Optional[bool]:
260
261
  "Existing Claude Code tokens found. Continuing will overwrite them."
261
262
  )
262
263
  _perform_authentication()
264
+ set_model_name("claude-code-claude-opus-4-5-20251101")
263
265
  return True
264
266
 
265
267
  if name == "claude-code-status":
@@ -21,6 +21,8 @@ from .config import (
21
21
  get_token_storage_path,
22
22
  )
23
23
 
24
+ TOKEN_REFRESH_BUFFER_SECONDS = 60
25
+
24
26
  logger = logging.getLogger(__name__)
25
27
 
26
28
 
@@ -132,6 +134,124 @@ def load_stored_tokens() -> Optional[Dict[str, Any]]:
132
134
  return None
133
135
 
134
136
 
137
+ def _calculate_expires_at(expires_in: Optional[float]) -> Optional[float]:
138
+ if expires_in is None:
139
+ return None
140
+ try:
141
+ return time.time() + float(expires_in)
142
+ except (TypeError, ValueError):
143
+ return None
144
+
145
+
146
+ def is_token_expired(tokens: Dict[str, Any]) -> bool:
147
+ expires_at = tokens.get("expires_at")
148
+ if expires_at is None:
149
+ return False
150
+ try:
151
+ expires_at_value = float(expires_at)
152
+ except (TypeError, ValueError):
153
+ return False
154
+ return time.time() >= expires_at_value - TOKEN_REFRESH_BUFFER_SECONDS
155
+
156
+
157
+ def update_claude_code_model_tokens(access_token: str) -> bool:
158
+ try:
159
+ claude_models = load_claude_models()
160
+ if not claude_models:
161
+ return False
162
+
163
+ updated = False
164
+ for config in claude_models.values():
165
+ if config.get("oauth_source") != "claude-code-plugin":
166
+ continue
167
+ custom_endpoint = config.get("custom_endpoint")
168
+ if not isinstance(custom_endpoint, dict):
169
+ continue
170
+ custom_endpoint["api_key"] = access_token
171
+ updated = True
172
+
173
+ if updated:
174
+ return save_claude_models(claude_models)
175
+ except Exception as exc: # pragma: no cover - defensive logging
176
+ logger.error("Failed to update Claude model tokens: %s", exc)
177
+ return False
178
+
179
+
180
+ def refresh_access_token(force: bool = False) -> Optional[str]:
181
+ tokens = load_stored_tokens()
182
+ if not tokens:
183
+ return None
184
+
185
+ if not force and not is_token_expired(tokens):
186
+ return tokens.get("access_token")
187
+
188
+ refresh_token = tokens.get("refresh_token")
189
+ if not refresh_token:
190
+ logger.debug("No refresh_token available")
191
+ return None
192
+
193
+ payload = {
194
+ "grant_type": "refresh_token",
195
+ "client_id": CLAUDE_CODE_OAUTH_CONFIG["client_id"],
196
+ "refresh_token": refresh_token,
197
+ }
198
+
199
+ headers = {
200
+ "Content-Type": "application/json",
201
+ "Accept": "application/json",
202
+ "anthropic-beta": "oauth-2025-04-20",
203
+ }
204
+
205
+ try:
206
+ response = requests.post(
207
+ CLAUDE_CODE_OAUTH_CONFIG["token_url"],
208
+ json=payload,
209
+ headers=headers,
210
+ timeout=30,
211
+ )
212
+ if response.status_code == 200:
213
+ new_tokens = response.json()
214
+ tokens["access_token"] = new_tokens.get("access_token")
215
+ tokens["refresh_token"] = new_tokens.get("refresh_token", refresh_token)
216
+ if "expires_in" in new_tokens:
217
+ tokens["expires_in"] = new_tokens["expires_in"]
218
+ tokens["expires_at"] = _calculate_expires_at(
219
+ new_tokens.get("expires_in")
220
+ )
221
+ if save_tokens(tokens):
222
+ update_claude_code_model_tokens(tokens["access_token"])
223
+ return tokens["access_token"]
224
+ else:
225
+ logger.error(
226
+ "Token refresh failed: %s - %s", response.status_code, response.text
227
+ )
228
+ except Exception as exc: # pragma: no cover - defensive logging
229
+ logger.error("Token refresh error: %s", exc)
230
+ return None
231
+
232
+
233
+ def get_valid_access_token() -> Optional[str]:
234
+ tokens = load_stored_tokens()
235
+ if not tokens:
236
+ logger.debug("No stored Claude Code OAuth tokens found")
237
+ return None
238
+
239
+ access_token = tokens.get("access_token")
240
+ if not access_token:
241
+ logger.debug("No access_token in stored tokens")
242
+ return None
243
+
244
+ if is_token_expired(tokens):
245
+ logger.info("Claude Code OAuth token expired, attempting refresh")
246
+ refreshed = refresh_access_token()
247
+ if refreshed:
248
+ return refreshed
249
+ logger.warning("Claude Code token refresh failed")
250
+ return None
251
+
252
+ return access_token
253
+
254
+
135
255
  def save_tokens(tokens: Dict[str, Any]) -> bool:
136
256
  try:
137
257
  token_path = get_token_storage_path()
@@ -243,7 +363,11 @@ def exchange_code_for_tokens(
243
363
  logger.info("Token exchange response: %s", response.status_code)
244
364
  logger.debug("Response body: %s", response.text)
245
365
  if response.status_code == 200:
246
- return response.json()
366
+ token_data = response.json()
367
+ token_data["expires_at"] = _calculate_expires_at(
368
+ token_data.get("expires_in")
369
+ )
370
+ return token_data
247
371
  logger.error(
248
372
  "Token exchange failed: %s - %s",
249
373
  response.status_code,
@@ -341,12 +465,7 @@ def add_models_to_extra_config(models: List[str]) -> bool:
341
465
  # Start fresh - overwrite the file on every auth instead of loading existing
342
466
  claude_models = {}
343
467
  added = 0
344
- tokens = load_stored_tokens()
345
-
346
- # Handle case where tokens are None or empty
347
- access_token = ""
348
- if tokens and "access_token" in tokens:
349
- access_token = tokens["access_token"]
468
+ access_token = get_valid_access_token() or ""
350
469
 
351
470
  for model_name in filtered_models:
352
471
  prefixed = f"{CLAUDE_CODE_OAUTH_CONFIG['prefix']}{model_name}"
@@ -54,13 +54,15 @@ class ReopenableAsyncClient:
54
54
  if self._stream_context:
55
55
  return await self._stream_context.__aexit__(exc_type, exc_val, exc_tb)
56
56
 
57
- def __init__(self, **kwargs):
57
+ def __init__(self, client_class=None, **kwargs):
58
58
  """
59
59
  Initialize the ReopenableAsyncClient.
60
60
 
61
61
  Args:
62
- **kwargs: All arguments that would be passed to httpx.AsyncClient()
62
+ client_class: Class to use for creating the internal client (defaults to httpx.AsyncClient)
63
+ **kwargs: All arguments that would be passed to the client constructor
63
64
  """
65
+ self._client_class = client_class or httpx.AsyncClient
64
66
  self._client_kwargs = kwargs.copy()
65
67
  self._client: Optional[httpx.AsyncClient] = None
66
68
  self._is_closed = True
@@ -70,7 +72,7 @@ class ReopenableAsyncClient:
70
72
  Ensure the underlying client is open and ready to use.
71
73
 
72
74
  Returns:
73
- The active httpx.AsyncClient instance
75
+ The active client instance
74
76
 
75
77
  Raises:
76
78
  RuntimeError: If client cannot be opened
@@ -80,12 +82,12 @@ class ReopenableAsyncClient:
80
82
  return self._client
81
83
 
82
84
  async def _create_client(self) -> None:
83
- """Create a new httpx.AsyncClient with the stored configuration."""
85
+ """Create a new client with the stored configuration."""
84
86
  if self._client is not None and not self._is_closed:
85
87
  # Close existing client first
86
88
  await self._client.aclose()
87
89
 
88
- self._client = httpx.AsyncClient(**self._client_kwargs)
90
+ self._client = self._client_class(**self._client_kwargs)
89
91
  self._is_closed = False
90
92
 
91
93
  async def reopen(self) -> None:
@@ -171,14 +173,12 @@ class ReopenableAsyncClient:
171
173
  """
172
174
  if self._client is None or self._is_closed:
173
175
  # Create a temporary client just for building the request
174
- temp_client = httpx.AsyncClient(**self._client_kwargs)
176
+ temp_client = self._client_class(**self._client_kwargs)
175
177
  try:
176
178
  request = temp_client.build_request(method, url, **kwargs)
177
179
  return request
178
180
  finally:
179
181
  # Clean up the temporary client synchronously if possible
180
- # Note: This might leave a connection open, but it's better than
181
- # making this method async just for building requests
182
182
  pass
183
183
  return self._client.build_request(method, url, **kwargs)
184
184